diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 588c8dbaf..cf3eb1328 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -609,6 +609,35 @@ 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: [
+ SparkExitManager.shared.opensProgress,
+ SparkExitManager.shared.showsResult,
+ SparkExitManager.shared.hasLoadedRecord,
+ isLockedForDeepLinks,
+ wallet.walletExists == true,
+ ]) {
+ // A tapped exit notification opens the exit once the wallet is unlocked and its record is loaded.
+ let exit = SparkExitManager.shared
+ guard exit.opensProgress, wallet.walletExists == true, !isLockedForDeepLinks else { return }
+ switch SparkExitManager.notificationOpen(
+ opensProgress: exit.opensProgress,
+ showsResult: exit.showsResult,
+ hasLoadedRecord: exit.hasLoadedRecord
+ ) {
+ case .wait:
+ return
+ case .drop:
+ exit.opensProgress = false
+ case .open:
+ exit.opensProgress = false
+ navigation.reset()
+ navigation.navigate(.sparkExitProgress)
+ }
+ }
.task(id: foregroundContactSession) {
shopContactImport?.task.cancel()
shopContactImport = nil
diff --git a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json
index f90a70255..0838f5531 100644
--- a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json
+++ b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/Contents.json
@@ -1,7 +1,7 @@
{
"images" : [
{
- "filename" : "hourglass-simple.pdf",
+ "filename" : "hourglass-simple.svg",
"idiom" : "universal"
}
],
@@ -10,6 +10,7 @@
"version" : 1
},
"properties" : {
+ "preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
diff --git a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.pdf b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.pdf
deleted file mode 100644
index cc1ee0ef0..000000000
Binary files a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.pdf and /dev/null differ
diff --git a/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg
new file mode 100644
index 000000000..510ad0b7e
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/hourglass-simple.imageset/hourglass-simple.svg
@@ -0,0 +1,4 @@
+
diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 03273d8aa..89ce0bdba 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
@@ -163,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)
}
diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift
index e52608561..07bc428ef 100644
--- a/Bitkit/Constants/Env.swift
+++ b/Bitkit/Constants/Env.swift
@@ -296,6 +296,27 @@ 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")
+ }
+
+ /// Earlier Spark exits' funding addresses that hold, or may still receive, coins, kept past the exit record.
+ static func sparkExitEarlierAddresses(walletIndex: Int) -> URL {
+ appStorageUrl
+ .appendingPathComponent(networkName)
+ .appendingPathComponent("wallet\(walletIndex)/spark-exit-earlier-addresses.json")
+ }
+
+ /// The leaves an older state of the wallet claimed in an earlier Spark exit, kept out of every later one.
+ static func sparkExitLostLeaves(walletIndex: Int) -> URL {
+ appStorageUrl
+ .appendingPathComponent(networkName)
+ .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/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
+ UIDesignRequiresCompatibilityUSDT_BRIDGE_NETWORKS
diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift
index 03f2ef74b..d1c507c19 100644
--- a/Bitkit/MainNavView.swift
+++ b/Bitkit/MainNavView.swift
@@ -710,6 +710,12 @@ 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 .sparkExitFeeBump: SparkExitFeeBumpView()
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..f53e0e3f9 100644
--- a/Bitkit/Managers/BalanceManager.swift
+++ b/Bitkit/Managers/BalanceManager.swift
@@ -26,7 +26,9 @@ class BalanceManager {
/// Derives the complete balance state including pending transfers
/// - Returns: BalanceState with all balance calculations
/// - Throws: Error if balance retrieval fails
- func deriveBalanceState() async throws -> BalanceState {
+ /// - Parameter exitInTransfer: the running Spark exit's transfer and what it still brings to savings, counted in
+ /// place of that transfer's fixed amount.
+ func deriveBalanceState(exitInTransfer: (transferId: String, sats: UInt64)? = nil) async throws -> BalanceState {
let balanceDetails = await MainActor.run { lightningService.balances }
guard let balanceDetails else {
throw AppError(message: "Balance details unavailable", debugMessage: "LightningService.balances is nil")
@@ -43,7 +45,8 @@ class BalanceManager {
let (lightningToSubtract, pendingCloseAmount) = await getCloseTransferAmounts(
transfers: activeTransfers,
- balanceDetails: balanceDetails
+ balanceDetails: balanceDetails,
+ exitInTransfer: exitInTransfer
)
// Detect orphan closed channels: channels with lightning balance that are no longer
@@ -138,12 +141,18 @@ class BalanceManager {
/// - Returns: lightningToSubtract (always subtract from display), pendingAmount (add to total only if on-chain hasn't arrived)
private func getCloseTransferAmounts(
transfers: [Transfer],
- balanceDetails: BalanceDetails
+ balanceDetails: BalanceDetails,
+ exitInTransfer: (transferId: String, sats: UInt64)? = nil
) async -> (lightningToSubtract: UInt64, pendingAmount: UInt64) {
var lightningToSubtract: UInt64 = 0
var pendingAmount: UInt64 = 0
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 += Self.sparkExitInTransferSats(transfer, exitInTransfer: exitInTransfer)
+ continue
+ }
guard let channelId = transfer.channelId else { continue }
let balanceFromLdk = balanceDetails.lightningBalances
@@ -165,6 +174,12 @@ class BalanceManager {
return (lightningToSubtract, pendingAmount)
}
+ /// The running exit's own transfer counts what it still brings to savings; any other exit transfer its amount.
+ static func sparkExitInTransferSats(_ transfer: Transfer, exitInTransfer: (transferId: String, sats: UInt64)?) -> UInt64 {
+ guard let exitInTransfer, exitInTransfer.transferId == transfer.id else { return transfer.amountSats }
+ return exitInTransfer.sats
+ }
+
/// Detects "orphan" closed channels - channels that have lightning balance in LDK
/// but are no longer in the channels list and don't have a Transfer record yet.
private func getOrphanClosedChannelBalance(
diff --git a/Bitkit/Managers/SparkExitManager.swift b/Bitkit/Managers/SparkExitManager.swift
new file mode 100644
index 000000000..a9bf5c3c0
--- /dev/null
+++ b/Bitkit/Managers/SparkExitManager.swift
@@ -0,0 +1,1670 @@
+import BackgroundTasks
+import BitkitCore
+import BreezSdkSpark
+import Foundation
+import LDKNode
+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"
+ /// The `bitkit_action` of the exit's notifications, so tapping one opens the exit.
+ nonisolated static let notificationAction = "spark_exit"
+ private static let foregroundTickSeconds: UInt64 = 60
+ /// How long a foreground tick waits for Spark to connect again before it goes on without the SDK.
+ private static let reconnectTimeoutSeconds: TimeInterval = 20
+
+ private(set) var record: SparkExitRecord? {
+ didSet { noteBalanceInputs() }
+ }
+
+ private(set) var quote: SparkExitQuote?
+ /// Funding addresses earlier exits left coins at, or may still receive them at: kept change and cancelled exits'.
+ private(set) var earlierAddresses: [SparkExitEarlierAddress] = []
+ private(set) var quoteError: String?
+ private(set) var isQuoting = false
+ private(set) var tipHeight: UInt32?
+ /// Confirmed or not, what reached the funding address and is not a funding input yet.
+ private(set) var fundingSeenSats: UInt64 = 0 {
+ didSet { noteBalanceInputs() }
+ }
+
+ /// How many payments make up `fundingSeenSats` on the pre-start funding screen.
+ private(set) var fundingSeenCount = 0
+ /// The network's rates now, read on every tick: the SDK's recommendation, or the chain host's without the SDK.
+ 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?
+ /// Every higher rate would leave leaves on Spark, so none is offered.
+ private(set) var isFeeBumpLeavingLeaves = false
+ /// What the running exit shows about its fee funding while it lacks some.
+ var fundingState: SparkExitFundingState? {
+ Self.isFundingOfExit(fundingExitCreatedAt, record: record) ? storedFundingState : nil
+ }
+
+ /// The full funding the Progress top-up asks for, every extra payment's input fee included.
+ var fundingAskTotal: UInt64? {
+ Self.isFundingOfExit(fundingExitCreatedAt, record: record) ? storedFundingAskTotal : nil
+ }
+
+ private var storedFundingState: SparkExitFundingState?
+ private var storedFundingAskTotal: UInt64?
+ /// The exit `fundingState` and `fundingAskTotal` were read for.
+ private var fundingExitCreatedAt: Date?
+ private(set) var fundingAddress: String?
+ private(set) var lastError: String?
+ /// Set when the user tapped an exit notification, until the app shows the exit or the loaded record shows none.
+ var opensProgress = false
+ /// The record of this wallet was read (there may be none), so a tapped notification no longer waits for it.
+ private(set) var hasLoadedRecord = false
+
+ /// The funding choice made on the funding screen, used when the exit starts.
+ @ObservationIgnored var fundFromSavings = false
+ @ObservationIgnored var savingsFeeRate: UInt32 = 0
+ @ObservationIgnored var savingsFeeSats: UInt64 = 0
+ /// The savings fee a higher-fee payment was refused for, by rate.
+ @ObservationIgnored private var feeBumpRefusedFees: [UInt64: UInt64] = [:]
+ /// The savings fee the start's funding payment was refused for: the next funding prices and approves at least that.
+ @ObservationIgnored private(set) var savingsFundingRefusedFeeSats: UInt64?
+ @ObservationIgnored private var walletIndex = 0
+ @ObservationIgnored private var isTicking = false
+ /// The exit a start is still settling: no tick or background run drives it until its optimization wait, requote
+ /// and reconnect end, since optimization may still swap the leaves it exits.
+ @ObservationIgnored private var startingCreatedAt: Date?
+ @ObservationIgnored private(set) var pendingDestination: String?
+ @ObservationIgnored private var pendingFeeRate: UInt64?
+ @ObservationIgnored private(set) var pendingFundingIndex: UInt32?
+
+ /// Recomputes the wallet's totals, through Spark's state hook, when what they read from the exit changed.
+ @ObservationIgnored private let balanceWatch = SparkExitBalanceWatch { SparkManager.shared.onStateChanged?() }
+
+ /// The chain a test drives the exit on, in place of the network's hosts.
+ @ObservationIgnored private let testChain: SparkExitChain?
+
+ /// The app uses `shared`; tests make their own, on a wallet index no wallet uses, optionally on their own chain.
+ init(walletIndex: Int = 0, chain: SparkExitChain? = nil) {
+ self.walletIndex = walletIndex
+ testChain = chain
+ }
+
+ private func noteBalanceInputs() {
+ balanceWatch.update(record: record, fundingSeenSats: fundingSeenSats, sparkBalanceSats: SparkManager.shared.balanceSats)
+ }
+
+ /// The fee rate of the quote on screen.
+ var quoteFeeRate: UInt64? {
+ pendingFeeRate
+ }
+
+ /// What paying the fee funding from savings costs: that payment's own fee, and the total that leaves savings. Nil
+ /// when another wallet pays, or there is nothing to fund.
+ var savingsPayment: (feeSats: UInt64, totalSats: UInt64)? {
+ guard fundFromSavings else { return nil }
+ let sats = savingsSats
+ guard sats > 0 else { return nil }
+ return (savingsFeeSats, sats + savingsFeeSats)
+ }
+
+ /// What savings pays for the quote on screen: what the funding address still lacks, with the input fee of the
+ /// savings payment when it completes a payment from another wallet.
+ var savingsSats: UInt64 {
+ guard let quote else { return 0 }
+ return Self.savingsFundingSats(
+ confirmed: quote,
+ fresh: quote,
+ seenSats: fundingSeenSats,
+ seenCount: fundingSeenCount,
+ feeRate: pendingFeeRate ?? 0
+ )
+ }
+
+ var isExiting: Bool {
+ record?.isActive == true
+ }
+
+ /// 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
+ }
+
+ /// The earlier exits' addresses to show: one the current exit uses again is that exit's while Emergency exit opens
+ /// its progress or result, which shows it there.
+ var shownEarlierAddresses: [SparkExitEarlierAddress] {
+ Self.shownEarlierAddresses(earlierAddresses, record: record)
+ }
+
+ nonisolated static func shownEarlierAddresses(_ entries: [SparkExitEarlierAddress], record: SparkExitRecord?) -> [SparkExitEarlierAddress] {
+ guard let record, record.showsResult, let current = record.funding.first?.address else { return entries }
+ return entries.filter { $0.address != current }
+ }
+
+ private var earlierAddressStore: SparkExitEarlierAddressStore {
+ SparkExitEarlierAddressStore(walletIndex: walletIndex)
+ }
+
+ /// Reads the watched earlier exit addresses, kept change and cancelled exits' for a late payment, and shows what
+ /// each holds.
+ func refreshEarlierAddresses() async {
+ do {
+ try await Self.recoverFunding(fundingRecovery, now: Date())
+ } catch {
+ Logger.warn("Failed to look for earlier exits' funding after a restore: \(error)", context: "SparkExitManager")
+ }
+ let store = earlierAddressStore
+ earlierAddresses = (try? store.load()) ?? earlierAddresses
+ guard let chain else { return }
+ let refreshed = await Self.refreshEarlierAddresses(store: store, fallback: earlierAddresses, now: Date()) {
+ try await chain.utxos(address: $0)
+ }
+ if let refreshed {
+ earlierAddresses = refreshed
+ }
+ }
+
+ /// The store's entries with what each watched address holds, saved; nil when nothing changed. The balances read
+ /// go into the store as it is once the reads are done, so an address added meanwhile stays.
+ nonisolated static func refreshEarlierAddresses(
+ store: SparkExitEarlierAddressStore,
+ fallback: [SparkExitEarlierAddress],
+ now: Date,
+ utxos: (String) async throws -> [EsploraUtxo]
+ ) async -> [SparkExitEarlierAddress]? {
+ let stored = (try? store.load()) ?? fallback
+ guard stored.contains(where: { $0.isWatched(now: now) }) else { return nil }
+ let balances = await SparkExitEarlierAddress.balances(stored, now: now, utxos: utxos)
+ let current = (try? store.load()) ?? stored
+ let refreshed = SparkExitEarlierAddress.updating(current, balances: balances)
+ guard refreshed != current else { return nil }
+ do {
+ try store.save(refreshed)
+ } catch {
+ Logger.warn("Failed to save the earlier Spark exit addresses: \(error)", context: "SparkExitManager")
+ }
+ return refreshed
+ }
+
+ private var store: SparkExitStore {
+ 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. The lost-leaf
+ /// list is best effort: it never fails the save the driver relies on.
+ private var saveRecord: (SparkExitRecord) throws -> Void {
+ Self.gatedSave(store: store, lostLeafStore: lostLeafStore, earlierAddressStore: earlierAddressStore, gate: writeGate)
+ }
+
+ /// Writes from ticks and background runs, closed for good once the wallet is wiped: a tick still running then can
+ /// never write the deleted exit back.
+ @ObservationIgnored private var writeGate = SparkExitWriteGate()
+
+ nonisolated static func gatedSave(
+ store: SparkExitStore,
+ lostLeafStore: SparkExitLostLeafStore,
+ earlierAddressStore: SparkExitEarlierAddressStore? = nil,
+ gate: SparkExitWriteGate
+ ) -> (SparkExitRecord) throws -> Void {
+ {
+ guard gate.isOpen else { throw SparkExitWriteGate.Closed() }
+ try store.save($0)
+ do {
+ try lostLeafStore.add(lostIn: $0)
+ } catch {
+ Logger.warn("Failed to keep the Spark exit's lost leaves: \(error)", context: "SparkExitManager")
+ }
+ // Kept change stays reachable after a later exit replaced this record. Best effort, as the lost leaves.
+ if let earlierAddressStore, let kept = SparkExitEarlierAddress.keptChange($0, network: Env.bitkitCoreNetwork, at: Date()) {
+ do {
+ try earlierAddressStore.add(kept)
+ } catch {
+ Logger.warn("Failed to keep the Spark exit's kept change address: \(error)", context: "SparkExitManager")
+ }
+ }
+ }
+ }
+
+ private var chain: SparkExitChain? {
+ if let testChain {
+ return testChain
+ }
+ guard let network = SparkManager.exitNetwork else { return nil }
+ return EsploraSparkExitChain.network(network)
+ }
+
+ // MARK: Lifecycle
+
+ func load(walletIndex: Int) {
+ self.walletIndex = walletIndex
+ do {
+ record = try Self.loadRecord(store: store, lostLeafStore: lostLeafStore)
+ earlierAddresses = (try? earlierAddressStore.load()) ?? []
+ hasLoadedRecord = true
+ } catch is SparkExitStore.Unavailable {
+ // Before the first unlock the file is there but cannot be read yet: it is read again on the next load.
+ Logger.warn("The Spark exit record cannot be read yet", context: "SparkExitManager")
+ record = nil
+ } catch {
+ // 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
+ hasLoadedRecord = true
+ }
+ if isExiting {
+ SparkManager.shared.detachForExit()
+ }
+ let record = record
+ Task {
+ if let record {
+ await settleIfFinished(record)
+ }
+ await settleOrphanedTransfers(record)
+ }
+ }
+
+ /// 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? {
+ 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")
+ // The original is copied aside first and the salvaged record then replaces it in one write.
+ try store.keepCopyAside()
+ try store.save(salvaged)
+ record = salvaged
+ }
+ try? lostLeafStore.add(lostIn: record)
+ return record
+ }
+
+ /// Forgets the exit of a wallet being wiped: closes the writes of any tick still running, waits for it to stop, then
+ /// deletes the record, so the exit can't come back.
+ func wipe() async {
+ writeGate.close()
+ while isTicking {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ writeGate = SparkExitWriteGate()
+ do {
+ try store.deleteWithCopies()
+ } catch {
+ Logger.error("Failed to delete the wiped wallet's Spark exit record: \(error)", context: "SparkExitManager")
+ }
+ try? lostLeafStore.delete()
+ try? earlierAddressStore.delete()
+ earlierAddresses = []
+ record = nil
+ hasLoadedRecord = false
+ quote = nil
+ // The quote's destination and funding belong to the wiped wallet: the next wallet's exit never pays to them.
+ pendingDestination = nil
+ pendingFeeRate = nil
+ pendingFundingIndex = nil
+ fundingAddress = nil
+ fundFromSavings = false
+ savingsFeeRate = 0
+ savingsFeeSats = 0
+ savingsFundingRefusedFeeSats = nil
+ fundingSeenSats = 0
+ fundingSeenCount = 0
+ 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 else { return false }
+ return Self.canCancel(record, fundingSeenSats: fundingSeenSats, unresolvedSendAddress: unresolvedSendAddress)
+ }
+
+ /// The address of an unresolved savings send, read on every tick and before a cancel.
+ private(set) var unresolvedSendAddress: String?
+ /// The savings payment of the start that a node refused, read on every tick: progress says so and offers to pay
+ /// again.
+ private(set) var refusedSavingsPayment: OnchainSendAttempt?
+
+ var isSavingsPaymentRefused: Bool {
+ record.map { Self.isSavingsPaymentRefused($0, attempt: refusedSavingsPayment) } ?? false
+ }
+
+ /// A node refused the savings payment of `record`'s fee funding, and none was adopted since: only before the exit
+ /// is funded, for the payment the start made.
+ nonisolated static func isSavingsPaymentRefused(_ record: SparkExitRecord, attempt: OnchainSendAttempt?) -> Bool {
+ guard record.isActive, case .awaitingFunding = record.phase, record.fundingPaymentTxid == nil,
+ let address = record.funding.first?.address, let attempt
+ else { return false }
+ return attempt.address == address && attempt.status == .rejected
+ }
+
+ /// Pays a refused savings payment again: the same attempt, re-signed at the rate the user approved over the inputs
+ /// it already spends. Every version spends those inputs, so at most one ever confirms, and nothing waits for the
+ /// refused one to be known gone from the mempool first. The txid to wait for, or nil when it was refused again.
+ nonisolated static func payRefusedAgain(
+ _ refused: OnchainSendAttempt,
+ record: SparkExitRecord,
+ retrySamePayment: (OnchainSendPendingContext) async throws -> OnchainSendResult
+ ) async throws -> String? {
+ guard isSavingsPaymentRefused(record, attempt: refused), refused.canRetrySamePayment else {
+ throw OnchainSendAttemptError.unresolved
+ }
+ let result = try await retrySamePayment(OnchainSendPendingContext(attemptId: refused.id, walletId: refused.walletId, txid: refused.txid))
+ return fundingTxid(result)
+ }
+
+ func retrySavingsPayment() async throws {
+ // Under the ticks' lock from the check to the saved txid, as the start's payment: a cancel either ends the exit
+ // before savings pays again or finds the payment and refuses.
+ while isTicking {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ isTicking = true
+ defer { isTicking = false }
+ if let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore) {
+ record = stored
+ }
+ guard let current = record, let refused = refusedSavingsPayment else { return }
+ let attempts = OnchainSendAttemptService.shared
+ let txid = try await Self.payRefusedAgain(refused, record: current) { context in
+ try await attempts.retrySamePayment(using: LightningService.shared, context: context) { attempt, _ in
+ guard attempt.id == refused.id, attempt.address == refused.address else { throw OnchainSendAttemptError.unresolved }
+ }
+ }
+ await refreshRefusedSavingsPayment()
+ guard let txid else {
+ throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark exit savings payment refused again")
+ }
+ _ = try? await attempts.resumeAcceptedOrdinarySend(walletId: refused.walletId, observedTxid: txid)
+ guard var paid = record, paid.createdAt == current.createdAt, paid.isActive else { return }
+ paid.fundingPaymentTxid = txid
+ try saveRecord(paid)
+ record = paid
+ setFunding(SparkExitMath.fundingState(paid, utxos: nil), for: paid)
+ refusedSavingsPayment = nil
+ Logger.info("Paid the Spark exit funding from savings again in \(txid)", context: "SparkExitManager")
+ Task { await tick() }
+ }
+
+ private func refreshRefusedSavingsPayment() async {
+ let attempt = try? await OnchainSendAttemptService.shared.blockingAttempt()
+ refusedSavingsPayment = attempt?.status == .rejected ? attempt : nil
+ }
+
+ /// What the exit still brings to savings, for its own transfer's in-transfer amount; nil without one.
+ var inTransfer: (transferId: String, sats: UInt64)? {
+ guard let record, let transferId = record.transferId else { return nil }
+ return (transferId, SparkExitMath.inTransferSats(record, fundingSeenSats: fundingSeenSats))
+ }
+
+ /// Nothing built, nothing paid from savings and nothing seen at the funding address, and no unresolved savings send
+ /// to that address: such a send can still be retried from Send, always to the same address, which a forgotten exit
+ /// would no longer watch.
+ nonisolated static func canCancel(_ record: SparkExitRecord, fundingSeenSats: UInt64, unresolvedSendAddress: String? = nil) -> Bool {
+ guard record.recovery == nil, record.fundedSats == 0, fundingSeenSats == 0, record.fundingPaymentTxid == nil else { return false }
+ if let unresolvedSendAddress, record.funding.contains(where: { $0.address == unresolvedSendAddress }) {
+ return false
+ }
+ if case .awaitingFunding = record.phase {
+ return true
+ }
+ return false
+ }
+
+ enum CancelOutcome: Equatable {
+ /// Nothing reached the funding address: the exit is forgotten and Spark comes back.
+ case forget
+ /// Funding reached it after all: the exit ends and sends that funding back to savings.
+ case sweepFunding([SparkExitRecord.Leftover])
+ /// The address could not be read: nothing changes.
+ case keep
+ /// The exit can no longer be cancelled, such as once a tick adopted its funding: nothing changes.
+ case notCancellable
+ }
+
+ /// What cancelling does, given what the funding address holds now (nil when it could not be read).
+ /// What a tapped exit notification does now.
+ enum NotificationOpen: Equatable {
+ case wait, open, drop
+ }
+
+ /// It waits for the record to load, opens the exit while it shows a result, and is let go once the loaded record
+ /// shows none, so a later result never jumps to Progress.
+ nonisolated static func notificationOpen(opensProgress: Bool, showsResult: Bool, hasLoadedRecord: Bool) -> NotificationOpen {
+ guard opensProgress else { return .drop }
+ if showsResult {
+ return .open
+ }
+ return hasLoadedRecord ? .drop : .wait
+ }
+
+ /// The funding index counter once an exit using `index` is forgotten.
+ /// Given back when it is the last one used, so the next exit watches that address again.
+ nonisolated static func fundingCounterAfterForget(_ counter: Int, index: UInt32) -> Int {
+ counter == Int(index) + 1 ? Int(index) : counter
+ }
+
+ nonisolated static func cancelOutcome(utxos: [EsploraUtxo]?) -> CancelOutcome {
+ guard let utxos else { return .keep }
+ guard !utxos.isEmpty else { return .forget }
+ return .sweepFunding(utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) })
+ }
+
+ /// Notes that the user opened the result of an exit that failed or lost leaves, so Emergency exit starts a new
+ /// one next time.
+ func markResultSeen() {
+ 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")
+ }
+ }
+
+ /// Reads the funding address first: a payment that arrived since the last tick is never orphaned. With funding there
+ /// the record stays, ends as cancelled and its leftover sweep returns the funding to savings.
+ /// Holds the same lock as the driver's ticks and the background run for the whole cancel, and decides on the record
+ /// as stored now: one a tick funded, built or published meanwhile is never cancelled.
+ @discardableResult
+ func cancel() async -> CancelOutcome {
+ while isTicking {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ isTicking = true
+ defer { isTicking = false }
+ if let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore) {
+ record = stored
+ }
+ unresolvedSendAddress = await OnchainSendAttemptService.shared.unresolvedSendAddress()
+ guard canCancel, var record, let address = record.funding.first?.address else { return .notCancellable }
+ let utxos = try? await chain?.utxos(address: address)
+ let outcome = Self.cancelOutcome(utxos: utxos)
+ switch outcome {
+ case .keep:
+ Logger.warn("Spark exit not cancelled: its funding address could not be read", context: "SparkExitManager")
+ return outcome
+ case .notCancellable:
+ return outcome
+ case let .sweepFunding(leftover):
+ record.phase = .failed(reason: SparkExitRecord.cancelledReason)
+ record.isCancelled = true
+ record.isResultSeen = true
+ record.leftover = leftover
+ do {
+ try saveRecord(record)
+ } catch {
+ Logger.error("Failed to save the cancelled Spark exit: \(error)", context: "SparkExitManager")
+ return .keep
+ }
+ self.record = record
+ if let transferId = record.transferId {
+ try? await TransferService(lightningService: .shared).markSettled(id: transferId)
+ }
+ await reattachSpark(isCancel: true)
+ Logger.info("Cancelled the Spark exit after funding arrived; sweeping it back to savings", context: "SparkExitManager")
+ // After the lock is released by this function's return.
+ Task { await tick() }
+ return outcome
+ case .forget:
+ break
+ }
+ // Its address stays listed, and is read for a late payment while the app is in the foreground for four weeks.
+ if let cancelled = SparkExitEarlierAddress.cancelledFunding(record, network: Env.bitkitCoreNetwork, at: Date()) {
+ do {
+ earlierAddresses = try earlierAddressStore.add(cancelled)
+ } catch {
+ Logger.warn("Failed to keep the cancelled Spark exit's address: \(error)", context: "SparkExitManager")
+ }
+ }
+ try? store.delete()
+ self.record = nil
+ Self.clearReminders()
+ // A payment that lands at the forgotten address late is then seen by the next exit, which uses it again.
+ let counter = UserDefaults.standard.integer(forKey: fundingIndexKey)
+ UserDefaults.standard.set(Self.fundingCounterAfterForget(counter, index: record.fundingIndex), forKey: fundingIndexKey)
+ // The counter rides the metadata backup, which its dynamic key does not mark by itself.
+ SettingsViewModel.shared.notifyAppStateChanged()
+ if let transferId = record.transferId {
+ try? await TransferService(lightningService: .shared).markSettled(id: transferId)
+ }
+ await reattachSpark(isCancel: true)
+ Logger.info("Cancelled the Spark exit before it was funded", context: "SparkExitManager")
+ return .forget
+ }
+
+ /// Ticks every minute while the app is active and an exit runs. Each step of an exit needs Bitkit to publish it.
+ func runWhileActive() async {
+ await refreshEarlierAddresses()
+ 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, Self.canDrive(current, startingCreatedAt: startingCreatedAt), let chain else { return }
+ isTicking = true
+ defer { isTicking = false }
+ let store = store
+ let walletIndex = walletIndex
+ // A reconnect for the exit that failed leaves Spark ready but disconnected: the tick connects it again.
+ if SparkManager.shared.isReady, !SparkManager.shared.isConnected {
+ _ = await SparkManager.shared.connectForExit(walletIndex: walletIndex, timeout: Self.reconnectTimeoutSeconds)
+ }
+ let driver = SparkExitDriver(
+ chain: chain,
+ recovering: SparkManager.shared.exitRecovering,
+ makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) },
+ save: saveRecord,
+ trustedFeeRate: Self.trustedFeeRate
+ )
+ do {
+ tipHeight = await (try? chain.tipHeight()) ?? tipHeight
+ unresolvedSendAddress = await OnchainSendAttemptService.shared.unresolvedSendAddress()
+ await refreshRefusedSavingsPayment()
+ await refreshNetworkFees(chain: chain)
+ await refreshFundingSeen(chain: chain, record: current)
+ var updated = try await driver.tick(current)
+ // 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; a reset the driver made, such as for change kept
+ // after the result was opened, stays.
+ if record?.isResultSeen == true, current.isResultSeen != true, updated.isResultSeen != true {
+ updated.isResultSeen = true
+ try? store.save(updated)
+ }
+ record = updated
+ if Self.isFundingAskWithdrawn(updated, previous: current) {
+ Self.dismissDeliveredFundingNotifications(record: updated)
+ }
+ earlierAddresses = (try? earlierAddressStore.load()) ?? earlierAddresses
+ lastError = nil
+ await afterTick(updated, wasActive: current.isActive)
+ } catch {
+ lastError = error.localizedDescription
+ Logger.error("Spark exit tick failed: \(error)", context: "SparkExitManager")
+ // The driver saved along the way: the next tick starts from what is on disk, never from the copy it began with.
+ let stored = (try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore)) ?? current
+ record = stored
+ await scheduleReminders(stored)
+ }
+ }
+
+ 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. A sweep of a
+ /// generation the failed-leaf retry replaced is looked for too.
+ private func settleIfFinished(_ record: SparkExitRecord) async {
+ guard let transferId = record.transferId, let transfer = try? TransferStorage.shared.getById(transferId) else { return }
+ let activity = CoreService.shared.activity
+ var isSweepSeen = false
+ // The sweep's activity is marked as a transfer even after the transfer settled: it may reach the activity
+ // list only later.
+ for txid in SparkExitMath.sweepTxids(record) {
+ guard await activity.hasOnchainActivityForTxid(txid: txid) else { continue }
+ await activity.markOnchainActivityAsTransfer(txId: txid)
+ isSweepSeen = true
+ }
+ guard Self.isTransferFinished(record, isSweepSeen: isSweepSeen), !transfer.isSettled else { return }
+ try? await TransferService(lightningService: .shared).markSettled(id: transferId)
+ }
+
+ /// The exit's transfer ends once the exit ended, or once its sweep reached savings and no leaf is outstanding (a
+ /// failed cooperative one before or during its retry, or one a build skipped): the value those leaves hold stays in
+ /// transfer, less what already reached savings (`inTransferSats`).
+ nonisolated static func isTransferFinished(_ record: SparkExitRecord, isSweepSeen: Bool) -> Bool {
+ !record.isActive || (isSweepSeen && !record.hasLeavesOutstanding)
+ }
+
+ /// 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(isCancel: Bool = false) async {
+ let spark = SparkManager.shared
+ await spark.reconnectForExit(isCancel: isCancel)
+ // A Spark the reconnect left disconnected stays off the rail, so Lightning keeps paying through LDK.
+ if LightningService.shared.channelFreeRail == nil, spark.isReady, spark.isConnected {
+ LightningService.shared.channelFreeRail = spark
+ }
+ spark.onStateChanged?()
+ }
+
+ /// 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 {
+ // Adopted, or nothing to fund: what was seen is part of the exit now, never funding still to count.
+ fundingSeenSats = 0
+ fundingSeenCount = 0
+ setFunding(nil, for: record)
+ return
+ }
+ let utxos = try? await chain.utxos(address: address)
+ if let utxos {
+ fundingSeenSats = SparkExitMath.newFunding(utxos, record: record).reduce(0) { $0 + $1.value }
+ }
+ setFunding(SparkExitMath.fundingState(record, utxos: utxos), askTotal: SparkExitMath.fundingAskTotal(record, utxos: utxos), for: record)
+ }
+
+ private func setFunding(_ state: SparkExitFundingState?, askTotal: UInt64? = nil, for record: SparkExitRecord) {
+ storedFundingState = state
+ storedFundingAskTotal = askTotal
+ fundingExitCreatedAt = record.createdAt
+ }
+
+ /// Whether funding read for the exit created at `createdAt` belongs to `record`: a cancelled exit's savings
+ /// payment or funding ask never shows for the next one.
+ nonisolated static func isFundingOfExit(_ createdAt: Date?, record: SparkExitRecord?) -> Bool {
+ createdAt != nil && createdAt == record?.createdAt
+ }
+
+ private func refreshNetworkFees(chain: SparkExitChain) async {
+ 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 else { return false }
+ return Self.canPayHigherFee(record, tip: tipHeight, isNetworkAboveExitRate: isNetworkAboveExitRate)
+ }
+
+ /// A recovery of the exit's own still has a step to confirm: a failed-leaf retry that built one counts too, its
+ /// timing kept for the leaves still outstanding. One waiting only on the operators has nothing to speed up.
+ nonisolated static func canPayHigherFee(_ record: SparkExitRecord, tip: UInt32?, isNetworkAboveExitRate: Bool) -> Bool {
+ guard record.phase == .running, !record.isRebuilding, let recovery = record.recovery?.response else { return false }
+ let hasStepToConfirm = recovery.transactions.contains {
+ if case .confirmed = $0.status {
+ return false
+ }
+ return true
+ }
+ guard !record.isRetryingFailedLeaves || hasStepToConfirm else { return false }
+ return record.underpricedStep(tip: tip) != nil || isNetworkAboveExitRate
+ }
+
+ /// The network's next-block rate is above the exit's.
+ 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
+ isFeeBumpLeavingLeaves = false
+ 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)
+ var leavesBehind = false
+ for i in options.indices {
+ do {
+ options[i].extraSats = try await driver.feeBumpShortfall(record, rate: options[i].rate)
+ } catch let SparkExitFeeBumpError.leavesBehind(sats) {
+ leavesBehind = true
+ Logger.info(
+ "Spark exit fee bump to \(options[i].rate) sat/vB would leave \(sats) sats on Spark; not offered",
+ context: "SparkExitManager"
+ )
+ } catch {
+ Logger.warn("Failed to price a Spark exit fee bump to \(options[i].rate) sat/vB: \(error)", context: "SparkExitManager")
+ }
+ }
+ feeBumpOptions = options.filter { $0.extraSats != nil }
+ if feeBumpOptions.isEmpty {
+ if leavesBehind {
+ isFeeBumpLeavingLeaves = true
+ } else {
+ feeBumpError = t("lightning__spark_exit__fee_bump_error")
+ }
+ }
+ }
+
+ /// An ended exit keeps a savings payment that went out after it ended as pending, so the exit is driven until the
+ /// payment reaches the funding address and joins the leftover sweep (`followEndedPayment`). Nil while the exit is
+ /// active (its funding adopts the payment), without a txid, or while an earlier payment is still followed.
+ nonisolated static func followingLatePayment(_ record: SparkExitRecord, txid: String?) -> SparkExitRecord? {
+ guard !record.isActive, let txid, !record.isEndedPaymentPending else { return nil }
+ var record = record
+ record.fundingPaymentTxid = txid
+ record.fundingPaymentSeenAtHeight = nil
+ record.fundingPaymentMissingSince = nil
+ return record
+ }
+
+ /// Notes what savings pays in fees to send the extra funding of `rate`.
+ func setFeeBumpPaymentFee(rate: UInt64, sats: UInt64) {
+ guard let i = feeBumpOptions.firstIndex(where: { $0.rate == rate }) else { return }
+ feeBumpOptions[i].paymentFeeSats = Self.feeBumpPaymentFee(priced: sats, refusedFeeSats: feeBumpRefusedFees[rate])
+ }
+
+ /// Notes the savings fee the start's funding payment was refused for, so the next funding prices and approves it.
+ func noteSavingsFundingRefused(preparedFeeSats: UInt64?) {
+ savingsFundingRefusedFeeSats = preparedFeeSats
+ }
+
+ /// Notes the savings fee a higher-fee payment at `rate` was refused for, so the next confirm approves it.
+ func noteFeeBumpRefused(rate: UInt64, preparedFeeSats: UInt64?) {
+ feeBumpRefusedFees[rate] = preparedFeeSats
+ }
+
+ /// The fee a higher-fee payment is confirmed at: coin selection can need more than the estimate, so after a refusal
+ /// at least the fee it was refused for.
+ nonisolated static func feeBumpPaymentFee(priced: UInt64, refusedFeeSats: UInt64?) -> UInt64 {
+ max(priced, refusedFeeSats ?? 0)
+ }
+
+ /// Moves the exit to `option`'s rate: pays its extra funding from savings at `savingsFeeRate` first, then rebuilds
+ /// 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 {
+ // The payment goes out on Bitkit's chain: while Spark reads another, the exit would never see it.
+ guard SparkManager.shared.canFundFromChain else {
+ throw AppError(message: t("lightning__spark_exit__fee_bump_error"), debugMessage: "Spark does not read Bitkit's chain")
+ }
+ guard let paymentFeeSats = option.paymentFeeSats else {
+ throw AppError(message: t("lightning__spark_exit__fee_bump_error"), debugMessage: "Fee bump payment not priced")
+ }
+ txid = try await payFunding(address: address, sats: extraSats, satsPerVbyte: savingsFeeRate, feeSats: paymentFeeSats)
+ }
+ // Written between ticks, so a tick in flight never saves over it.
+ while isTicking {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ guard var record else { return }
+ // The exit may have ended while savings paid: the extra stays at the funding address for the leftover sweep.
+ guard record.phase == .running, !record.isRebuilding else {
+ Logger.warn("Spark exit ended while its higher fee was paid; the payment goes back with the leftover", context: "SparkExitManager")
+ if let followed = Self.followingLatePayment(record, txid: txid) {
+ try saveRecord(followed)
+ self.record = followed
+ Task { await tick() }
+ }
+ return
+ }
+ record.applyFeeBump(rate: option.rate, extraSats: extraSats, paymentTxid: txid)
+ try saveRecord(record)
+ self.record = record
+ setFunding(SparkExitMath.fundingState(record, utxos: nil), askTotal: storedFundingAskTotal, for: record)
+ Logger.info("Spark exit moves to \(option.rate) sat/vB with \(extraSats) sats more funding", context: "SparkExitManager")
+ Task { await tick() }
+ }
+
+ // 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 newQuote = try await SparkExitQuote.prepare(
+ recovering,
+ feeRateSatPerVbyte: feeRate,
+ destination: destination,
+ lostLeafIds: (try? lostLeafStore.load()) ?? []
+ )
+ let fundingIndex = if let pendingFundingIndex {
+ pendingFundingIndex
+ } else {
+ try await nextFundingIndex()
+ }
+ let fundingAddress = try Self.fundingKey(walletIndex: walletIndex, index: fundingIndex).address
+ useQuote(newQuote, destination: destination, feeRate: feeRate, fundingIndex: fundingIndex, fundingAddress: fundingAddress)
+ } catch {
+ Logger.error("Failed to quote the Spark exit: \(error)", context: "SparkExitManager")
+ quote = nil
+ quoteError = t("lightning__spark_exit__quote_error")
+ }
+ }
+
+ /// The quote the funding and confirm screens show, and `start()` starts from.
+ func useQuote(_ quote: SparkExitQuote, destination: String, feeRate: UInt64, fundingIndex: UInt32, fundingAddress: String) {
+ // What reached another address, an earlier exit's, is not this exit's funding.
+ if self.fundingAddress != fundingAddress {
+ fundingSeenSats = 0
+ fundingSeenCount = 0
+ }
+ pendingDestination = destination
+ pendingFeeRate = feeRate
+ pendingFundingIndex = fundingIndex
+ self.quote = quote
+ self.fundingAddress = fundingAddress
+ }
+
+ /// Watches the funding address while the user funds it from another wallet.
+ func watchFundingAddress() async {
+ guard let chain, let fundingAddress else { return }
+ while !Task.isCancelled {
+ if let utxos = try? await chain.utxos(address: fundingAddress) {
+ noteFundingSeen(utxos)
+ }
+ try? await Task.sleep(nanoseconds: 15 * 1_000_000_000)
+ }
+ }
+
+ /// What reached the funding address before the exit starts.
+ func noteFundingSeen(_ utxos: [EsploraUtxo]) {
+ fundingSeenSats = utxos.reduce(0) { $0 + $1.value }
+ fundingSeenCount = utxos.count
+ }
+
+ // MARK: Start
+
+ /// Starts the exit for the current quote: writes its record, moves the Spark balance into transfer to savings
+ /// 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)
+ // A send that passed its exit check before the hold may still spend leaves. One still sending after the wait
+ // refuses the start before anything is saved, so the quote stays and the user starts again once it ended.
+ // The record holds Spark from its save on.
+ defer { SparkManager.shared.releaseSpendsHold() }
+ try await SparkManager.shared.holdSpendsForExit()
+ var record = SparkExitRecord(
+ network: "\(network)",
+ createdAt: Date(),
+ 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
+ )
+ record.fundingStrategy = SparkExitRecord.perNodeStrategy
+ record.outsideSats = SparkExitMath.outsideSats(sparkBalanceSats: SparkManager.shared.balanceSats, quote: quote)
+ record.leafSats = quote.leafSats
+ let transfers = TransferService(lightningService: .shared)
+ let store = store
+ record = try await Self.writeStart(
+ record,
+ incomingSats: quote.incomingSats,
+ createTransfer: { try await transfers.createTransfer(type: .sparkExit, amountSats: $0) },
+ save: { try store.save($0) },
+ markSettled: { try? await transfers.markSettled(id: $0) }
+ )
+ UserDefaults.standard.set(walletIndex, forKey: Self.walletIndexKey)
+ UserDefaults.standard.set(Int(fundingIndex) + 1, forKey: fundingIndexKey)
+ SettingsViewModel.shared.notifyAppStateChanged()
+ pendingFundingIndex = nil
+ startingCreatedAt = record.createdAt
+ defer { startingCreatedAt = nil }
+ self.record = record
+ // In the same step the exit shows: a refresh before the reconnect below would read Spark as exiting.
+ SparkManager.shared.holdKeptBalanceForExit()
+ SparkManager.shared.detachForExit()
+ // Reconnecting cuts an optimization short, which would leave its counter-swap unclaimed for a while.
+ await SparkManager.shared.waitForOptimization()
+ // The rest runs under the ticks' lock, on this exit as stored: one cancelled during the wait above is never
+ // reconnected for or paid to.
+ while isTicking {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ isTicking = true
+ defer { isTicking = false }
+ let stored = try? Self.loadRecord(store: store, lostLeafStore: lostLeafStore)
+ guard var started = Self.startedExit(stored: stored, createdAt: record.createdAt) else {
+ Logger.info("Spark exit was cancelled before it started; nothing paid", context: "SparkExitManager")
+ throw SparkExitStartCancelled()
+ }
+ // An optimization since the quote swapped leaves: the exit is quoted again over the leaves Spark holds now.
+ var exitQuote = quote
+ if let recovering = SparkManager.shared.exitRecovering,
+ let fresh = await Self.requote(
+ recovering,
+ confirmed: quote,
+ feeRate: feeRate,
+ destination: destination,
+ lostLeafIds: (try? lostLeafStore.load()) ?? []
+ ),
+ let requoted = Self.requoted(started, fresh: fresh, sparkBalanceSats: SparkManager.shared.balanceSats)
+ {
+ let transfers = TransferService(lightningService: .shared)
+ let store = store
+ let written = await Self.writeRequote(
+ requoted,
+ replacing: started,
+ incomingSats: fresh.incomingSats,
+ createTransfer: { try await transfers.createTransfer(type: .sparkExit, amountSats: $0) },
+ save: { try store.save($0) },
+ markSettled: { try? await transfers.markSettled(id: $0) }
+ )
+ if let written {
+ started = written
+ self.record = written
+ exitQuote = fresh
+ }
+ }
+ await SparkManager.shared.reconnectForExit()
+ Logger.info("Started Spark exit of \(exitQuote.exitSats) sats over \(exitQuote.leafIds.count) leaves", context: "SparkExitManager")
+
+ let savingsSats = Self.savingsFundingSats(
+ confirmed: quote,
+ fresh: exitQuote,
+ seenSats: fundingSeenSats,
+ seenCount: fundingSeenCount,
+ feeRate: feeRate
+ )
+ if fundFromSavings, savingsSats > 0 {
+ let txid: String
+ do {
+ txid = try await payFunding(address: key.address, sats: savingsSats, satsPerVbyte: savingsFeeRate, feeSats: savingsFeeSats)
+ } catch {
+ // A refused payment shows on progress at once, with the offer to pay again.
+ await refreshRefusedSavingsPayment()
+ throw error
+ }
+ // Saved before the lock is released, so a cancel after the payment finds it and refuses.
+ started.fundingPaymentTxid = txid
+ do {
+ try store.save(started)
+ } catch {
+ Logger.error("Failed to save the Spark exit funding payment: \(error)", context: "SparkExitManager")
+ }
+ self.record = started
+ setFunding(SparkExitMath.fundingState(started, utxos: nil), for: started)
+ savingsFundingRefusedFeeSats = nil
+ }
+ Task { await tick() }
+ }
+
+ /// The exit quoted again at its start, when the leaves Spark holds changed since the confirmed quote; nil when they
+ /// did not, when none is left, or when the quote fails (the exit then goes on with the confirmed one).
+ nonisolated static func requote(
+ _ recovering: SparkExitRecovering,
+ confirmed: SparkExitQuote,
+ feeRate: UInt64,
+ destination: String,
+ lostLeafIds: Set
+ ) async -> SparkExitQuote? {
+ do {
+ let fresh = try await SparkExitQuote.prepare(recovering, feeRateSatPerVbyte: feeRate, destination: destination, lostLeafIds: lostLeafIds)
+ guard !fresh.leafIds.isEmpty, Set(fresh.leafIds) != Set(confirmed.leafIds) else { return nil }
+ Logger.warn("Quoting the Spark exit again: its leaves changed since the quote", context: "SparkExitManager")
+ return fresh
+ } catch {
+ Logger.warn("Failed to quote the Spark exit again at its start: \(error)", context: "SparkExitManager")
+ return nil
+ }
+ }
+
+ /// The started exit over the fresh quote's leaves, amount and funding, and what `sparkBalanceSats` holds beyond them.
+ nonisolated static func requoted(_ record: SparkExitRecord, fresh: SparkExitQuote, sparkBalanceSats: UInt64) -> SparkExitRecord? {
+ guard case .awaitingFunding = record.phase else { return nil }
+ var record = record
+ record.phase = .awaitingFunding(requiredSats: fresh.fundingSats)
+ record.leafIds = fresh.leafIds
+ record.leafTotalSats = fresh.leafTotalSats
+ record.expectedSats = fresh.incomingSats
+ record.skippedLeafCount = fresh.prepared.skipped.count
+ record.leafSats = fresh.leafSats
+ record.outsideSats = SparkExitMath.outsideSats(sparkBalanceSats: sparkBalanceSats, quote: fresh)
+ return record
+ }
+
+ /// The new exit stored with its transfer in one write, so no running exit is ever stored without the transfer
+ /// counting it. A failed save settles the transfer again; one a killed start left behind is settled as an orphan
+ /// on the next load.
+ nonisolated static func writeStart(
+ _ record: SparkExitRecord,
+ incomingSats: UInt64,
+ createTransfer: (UInt64) async throws -> String,
+ save: (SparkExitRecord) throws -> Void,
+ markSettled: (String) async -> Void
+ ) async throws -> SparkExitRecord {
+ var record = record
+ let transferId = try await createTransfer(incomingSats)
+ record.transferId = transferId
+ do {
+ try save(record)
+ } catch {
+ await markSettled(transferId)
+ throw error
+ }
+ return record
+ }
+
+ /// The started exit moved onto the fresh quote: a transfer for its amount, saved, and the confirmed quote's transfer
+ /// settled. Nil when that write fails, with a transfer made for it settled again: the start goes on with the
+ /// confirmed quote, as after a failed requote, so it still reconnects, pays and ticks.
+ nonisolated static func writeRequote(
+ _ requoted: SparkExitRecord,
+ replacing started: SparkExitRecord,
+ incomingSats: UInt64,
+ createTransfer: (UInt64) async throws -> String,
+ save: (SparkExitRecord) throws -> Void,
+ markSettled: (String) async -> Void
+ ) async -> SparkExitRecord? {
+ var requoted = requoted
+ do {
+ requoted.transferId = try await createTransfer(incomingSats)
+ } catch {
+ Logger.warn("Failed to create the requoted Spark exit transfer, going on with the confirmed quote: \(error)", context: "SparkExitManager")
+ return nil
+ }
+ do {
+ try save(requoted)
+ } catch {
+ Logger.warn("Failed to save the requoted Spark exit, going on with the confirmed quote: \(error)", context: "SparkExitManager")
+ if let created = requoted.transferId {
+ await markSettled(created)
+ }
+ return nil
+ }
+ if let replaced = started.transferId {
+ await markSettled(replaced)
+ }
+ return requoted
+ }
+
+ /// Whether a tick may drive the exit now: not while its start still settles it (`startingCreatedAt`).
+ nonisolated static func canDrive(_ record: SparkExitRecord, startingCreatedAt: Date?) -> Bool {
+ record.needsDriving && record.createdAt != startingCreatedAt
+ }
+
+ /// Savings pays at most the funding the user confirmed: a higher fresh amount goes through the shortfall flow on
+ /// Progress, which asks for the rest.
+ /// Funding another wallet already sent to the address (`seenSats`) is not paid again. What is still missing is
+ /// paid at least at the dust limit, as the Progress top-up asks for it: the onchain wallet refuses less.
+ nonisolated static func savingsFundingSats(
+ confirmed: SparkExitQuote,
+ fresh: SparkExitQuote,
+ seenSats: UInt64,
+ seenCount: Int,
+ feeRate: UInt64
+ ) -> UInt64 {
+ let total = SparkExitMath.prestartFunding(
+ fundingSats: min(confirmed.fundingSats, fresh.fundingSats),
+ payments: seenCount,
+ receivedSats: seenSats,
+ feeRate: feeRate
+ ).toSend
+ let missing = total.saturatingSub(seenSats)
+ return missing > 0 ? max(missing, SparkExitMath.fundingDustSats) : 0
+ }
+
+ /// The exit a start began, still stored and active (not cancelled meanwhile); nil otherwise. The store keeps
+ /// dates to the millisecond.
+ nonisolated static func startedExit(stored: SparkExitRecord?, createdAt: Date) -> SparkExitRecord? {
+ guard let stored, stored.isActive else { return nil }
+ let millis = { (date: Date) in (date.timeIntervalSince1970 * 1000).rounded() }
+ return millis(stored.createdAt) == millis(createdAt) ? stored : nil
+ }
+
+ /// Keeps the savings payment in the record, so the exit waits for it instead of asking for the funding again.
+ /// Written between ticks, so a tick in flight never saves over it.
+ /// The funding key index of the next exit: one no exit of this wallet used, so leftover of an earlier exit never
+ /// mixes into a new one. A scan's result rides the next metadata backup.
+ private func nextFundingIndex() async throws -> UInt32 {
+ let wasKept = UserDefaults.standard.object(forKey: fundingIndexKey) != nil
+ let index = try await Self.nextFundingIndex(fundingRecovery, now: Date())
+ if !wasKept {
+ SettingsViewModel.shared.notifyAppStateChanged()
+ }
+ return index
+ }
+
+ /// One wallet's exit funding as a restore leaves it: the counter at `key`, the stored record, the earlier exit
+ /// addresses, and each funding index's address and path.
+ struct FundingRecovery {
+ let defaults: UserDefaults
+ let key: String
+ let pendingKey: String
+ let record: SparkExitRecord?
+ let chain: SparkExitChain?
+ let earlierAddressStore: SparkExitEarlierAddressStore
+ let funding: (UInt32) throws -> (address: String, path: String)
+ }
+
+ private var fundingRecovery: FundingRecovery {
+ let walletIndex = walletIndex
+ return FundingRecovery(
+ defaults: .standard,
+ key: fundingIndexKey,
+ pendingKey: SparkExitFundingIndexes.recoveryPendingKey(network: "\(Env.network)", walletIndex: walletIndex),
+ record: record,
+ chain: chain,
+ earlierAddressStore: earlierAddressStore,
+ funding: { index in
+ let key = try Self.fundingKey(walletIndex: walletIndex, index: index)
+ return (key.address, SparkExitFundingKey.path(index: index, network: Env.bitkitCoreNetwork))
+ }
+ )
+ }
+
+ /// From the counter the last start left and the stored record. With neither, as after a restore from the phrase or
+ /// from a backup older than the counter, from a scan of the funding addresses first. A scan the chain fails keeps
+ /// nothing.
+ static func nextFundingIndex(_ recovery: FundingRecovery, now: Date) async throws -> UInt32 {
+ let defaults = recovery.defaults
+ if defaults.object(forKey: recovery.key) != nil || recovery.record != nil {
+ return SparkExitMath.nextFundingIndex(counter: defaults.integer(forKey: recovery.key), record: recovery.record)
+ }
+ return try await recoverFunding(recovery, now: now) ?? 0
+ }
+
+ /// Finds earlier exits' funding after a restore. With no counter kept and no record, scans for the next index and
+ /// keeps it; with a counter a restore brought back, reads each funding address below it, once. Each funding address
+ /// that still holds coins and nothing watches is listed as kept change, so it is watched for good. Throws when a
+ /// read fails, keeping no counter and the restore pending, so it runs again on the next start.
+ ///
+ /// Returns the index a scan found, nil when there was nothing to scan.
+ @discardableResult
+ static func recoverFunding(_ recovery: FundingRecovery, now: Date) async throws -> UInt32? {
+ let defaults = recovery.defaults
+ let kept = defaults.object(forKey: recovery.key) as? Int
+ let isPending = kept == nil ? recovery.record == nil : defaults.bool(forKey: recovery.pendingKey)
+ guard isPending, let chain = recovery.chain else { return nil }
+ var derived: [UInt32: (address: String, path: String)] = [:]
+ func funding(_ index: UInt32) throws -> (address: String, path: String) {
+ if let known = derived[index] {
+ return known
+ }
+ let found = try recovery.funding(index)
+ derived[index] = found
+ return found
+ }
+ var used: [UInt32] = []
+ var scanned: UInt32?
+ if kept == nil {
+ scanned = try await SparkExitMath.scanNextFundingIndex { index in
+ let isUsed = try await chain.isAddressUsed(address: funding(index).address)
+ if isUsed {
+ used.append(index)
+ }
+ return isUsed
+ }
+ }
+ let candidates = kept.map { Array(0 ..< UInt32(clamping: max(0, $0))) } ?? used
+ let watched = Set(((try? recovery.earlierAddressStore.load()) ?? []).map(\.address) + (recovery.record?.funding.map(\.address) ?? []))
+ for index in candidates {
+ let (address, path) = try funding(index)
+ guard !watched.contains(address) else { continue }
+ let sats = try await chain.utxos(address: address).reduce(0) { $0 + $1.value }
+ guard sats > 0 else { continue }
+ try recovery.earlierAddressStore.add(SparkExitEarlierAddress(address: address, path: path, kind: .keptChange, sats: sats, date: now))
+ }
+ if let scanned {
+ defaults.set(max(defaults.integer(forKey: recovery.key), Int(scanned)), forKey: recovery.key)
+ }
+ defaults.removeObject(forKey: recovery.pendingKey)
+ return scanned
+ }
+
+ private var fundingIndexKey: String {
+ SparkExitFundingIndexes.defaultsKey(network: "\(Env.network)", walletIndex: walletIndex)
+ }
+
+ /// The fast rate of Bitkit's own fee source, the ceiling of a leftover sweep with the exit's own rate.
+ nonisolated static func trustedFeeRate() async -> UInt64? {
+ await (try? CoreService.shared.blocktank.fees())?.map { UInt64($0.fast) }
+ }
+
+ /// Pays the fee funding from savings. It shows as an ordinary payment, not as a transfer to spending.
+ @discardableResult
+ func payFunding(address: String, sats: UInt64, satsPerVbyte: UInt32, feeSats: UInt64) async throws -> String {
+ let lightning = LightningService.shared
+ let attempts = OnchainSendAttemptService.shared
+ let result = try await Self.sendFunding(
+ using: lightning, attempts: attempts, address: address, sats: sats, satsPerVbyte: satsPerVbyte, feeSats: feeSats
+ )
+ // An unknown outcome may still have gone out: it is kept as paid (pending), so the exit, and its address, stay
+ // until the payment lands or the own-payment expiry forgets it.
+ guard let txid = Self.fundingTxid(result) else {
+ throw AppError(message: t("wallet__send_error_tx_failed"), debugMessage: "Spark exit funding outcome: \(result)")
+ }
+ if case .accepted = result {
+ _ = try? await attempts.resumeAcceptedOrdinarySend(
+ walletId: OnchainSendAttemptService.walletId(index: lightning.currentWalletIndex),
+ observedTxid: txid
+ )
+ Logger.info("Funded the Spark exit with \(sats) sats in \(txid)", context: "SparkExitManager")
+ } else {
+ Logger.warn("Spark exit funding \(txid) has an unknown outcome; kept as pending", context: "SparkExitManager")
+ }
+ return txid
+ }
+
+ /// Sends the fee funding from savings for the mining fee `feeSats` the user confirmed, which it never exceeds: a higher
+ /// one is refused before broadcast (`SparkFundingChanged`) and asks again.
+ nonisolated static func sendFunding(
+ using sender: any OnchainSending,
+ attempts: OnchainSendAttemptService,
+ address: String,
+ sats: UInt64,
+ satsPerVbyte: UInt32,
+ feeSats: UInt64
+ ) async throws -> OnchainSendResult {
+ try await attempts.send(
+ using: sender,
+ address: address,
+ amountSats: sats,
+ satsPerVbyte: satsPerVbyte,
+ utxosToSpend: nil,
+ isMaxAmount: false,
+ followupContext: OnchainSendFollowupContext(
+ feeSats: feeSats,
+ feeRate: satsPerVbyte,
+ tags: [],
+ contact: nil,
+ createdAt: UInt64(Date().timeIntervalSince1970)
+ ),
+ approvedMiningFeeSats: feeSats
+ )
+ }
+
+ /// The funding payment's txid to keep as paid: accepted, or unknown (it may still have gone out). A refused one is none.
+ nonisolated static func fundingTxid(_ result: OnchainSendResult) -> String? {
+ switch result {
+ case let .accepted(txid), let .unknown(txid): txid
+ case .rejected: nil
+ }
+ }
+
+ // MARK: Keys
+
+ private nonisolated static func fundingKey(walletIndex: Int, index: UInt32) throws -> SparkExitFundingKey.Derived {
+ 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))
+ // 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)
+ }
+ guard !isTicking, startingCreatedAt == nil, let chain else { return .init(record: record) }
+ let wasActive = record?.isActive == true
+ isTicking = true
+ defer { isTicking = false }
+ let store = store
+ let lostLeafStore = lostLeafStore
+ let saveRecord = saveRecord
+ let walletIndex = walletIndex
+ let run = SparkExitBackgroundRun(
+ load: { try Self.loadRecord(store: store, lostLeafStore: lostLeafStore) },
+ makeDriver: { chain, recovering in
+ SparkExitDriver(
+ chain: chain,
+ recovering: recovering,
+ makeSigner: { try Self.signer(walletIndex: walletIndex, index: $0) },
+ save: saveRecord,
+ trustedFeeRate: Self.trustedFeeRate
+ )
+ },
+ chain: chain,
+ connected: SparkManager.shared.exitRecovering,
+ connect: { timeout in await SparkManager.shared.connectForExit(walletIndex: walletIndex, timeout: timeout) },
+ deadline: deadline
+ )
+ tipHeight = await (try? chain.tipHeight()) ?? tipHeight
+ let outcome = await run.run()
+ let seconds = SparkExitBackgroundRun.Step.allCases.compactMap { step in
+ outcome.seconds[step].map { "\(step.rawValue)=\(String(format: "%.2f", $0))s" }
+ }
+ 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.notificationId(reminder, record: record)
+ let date = Self.reminderDate(reminder, rememberedAt: remembered[id].map(Date.init(timeIntervalSince1970:)), now: now)
+ kept[id] = date.remembered?.timeIntervalSince1970
+ guard let fireAt = date.fireAt else { continue }
+ let content = UNMutableNotificationContent()
+ content.userInfo = ["bitkit_action": Self.notificationAction]
+ content.title = t("lightning__spark_exit__notification_title")
+ 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")
+ }
+ 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(Self.keepingFired(kept, remembered: remembered, now: now), forKey: Self.remindersKey)
+ }
+
+ /// When `reminder` posts, nil once it fired, and the date remembered for it. A reminder that fired is not posted
+ /// again. One still ahead keeps its earliest date, so runs that do not publish its step never push it out.
+ /// A date guessed without the tip keeps the remembered one and is never remembered itself, so a later run with the
+ /// tip still plans the reminder at maturity.
+ nonisolated static func reminderDate(_ reminder: SparkExitReminder, rememberedAt: Date?, now: Date) -> (fireAt: Date?, remembered: Date?) {
+ if let rememberedAt, rememberedAt <= now {
+ return (nil, rememberedAt)
+ }
+ if reminder.isEstimated {
+ return (max(now.addingTimeInterval(1), rememberedAt ?? reminder.fireAt), rememberedAt)
+ }
+ let fireAt = max(now.addingTimeInterval(1), min(reminder.fireAt, rememberedAt ?? reminder.fireAt))
+ return (fireAt, fireAt)
+ }
+
+ /// How long a fired reminder stays remembered after it left the plan, so a plan that drops an id for one run (a
+ /// tip that could not be read, a phase that flickered) never posts it again.
+ nonisolated static let firedReminderMemory: TimeInterval = 30 * 24 * 60 * 60
+
+ /// `kept` plus every reminder that already fired within `firedReminderMemory`, whether or not it is planned now.
+ nonisolated static func keepingFired(_ kept: [String: TimeInterval], remembered: [String: TimeInterval], now: Date) -> [String: TimeInterval] {
+ var result = kept
+ for (id, firedAt) in remembered where result[id] == nil {
+ let age = now.timeIntervalSince1970 - firedAt
+ if age >= 0, age < firedReminderMemory {
+ result[id] = firedAt
+ }
+ }
+ return result
+ }
+
+ /// A reminder's notification id, scoped to its exit (its creation time), so a later exit's `failed`, `lost-1` or
+ /// `1-rebuild` is never taken for one an earlier exit already fired.
+ nonisolated static func notificationId(_ reminder: SparkExitReminder, record: SparkExitRecord) -> String {
+ "\(notificationPrefix)-\(Int64(record.createdAt.timeIntervalSince1970 * 1000))-\(reminder.id)"
+ }
+
+ private nonisolated static func rememberedReminders() -> [String: TimeInterval] {
+ UserDefaults.standard.dictionary(forKey: remindersKey) as? [String: TimeInterval] ?? [:]
+ }
+
+ /// An unverified rebuild's fee ask the tick from `previous` withdrew: the valid generation needs nothing more.
+ nonisolated static func isFundingAskWithdrawn(_ record: SparkExitRecord, previous: SparkExitRecord) -> Bool {
+ guard previous.isUnverifiedAsk == true, case .needsFunding = previous.phase else { return false }
+ return record.phase == .running
+ }
+
+ /// Takes back the exit's delivered fee asks, which no longer hold.
+ private nonisolated static func dismissDeliveredFundingNotifications(record: SparkExitRecord) {
+ let prefix = notificationId(SparkExitReminder(id: "", fireAt: Date()), record: record)
+ UNUserNotificationCenter.current().getDeliveredNotifications { delivered in
+ let ids = delivered.map(\.request.identifier).filter { $0.hasPrefix(prefix) && $0.contains("funding-") }
+ UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
+ }
+ }
+
+ private nonisolated static func clearReminders() {
+ UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
+ UNUserNotificationCenter.current()
+ .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)
+ }
+}
+
+/// A start whose exit was cancelled while it waited: nothing was paid and Spark was not taken off Lightning.
+struct SparkExitStartCancelled: Error {}
+
+/// Lets the exit's writes through until a wipe closes it.
+final class SparkExitWriteGate: @unchecked Sendable {
+ struct Closed: Error {}
+
+ private let lock = NSLock()
+ private var closed = false
+
+ var isOpen: Bool {
+ lock.withLock { !closed }
+ }
+
+ func close() {
+ lock.withLock { closed = true }
+ }
+}
+
+/// What the wallet's totals read from the exit: its transfer, what is still in transfer to savings and what stays on
+/// Spark. A funding payment seen at the address or a record write changes them without any Spark state change, so
+/// the watch asks the wallet to recompute when they changed, and only then.
+struct SparkExitBalanceInputs: Equatable {
+ let transferId: String?
+ let inTransferSats: UInt64?
+ let outsideExitSats: UInt64?
+
+ init(record: SparkExitRecord?, fundingSeenSats: UInt64, sparkBalanceSats: UInt64) {
+ transferId = record?.transferId
+ inTransferSats = record.map { SparkExitMath.inTransferSats($0, fundingSeenSats: fundingSeenSats) }
+ outsideExitSats = record.flatMap { SparkExitMath.outsideExitSats(sparkBalanceSats: sparkBalanceSats, record: $0) }
+ }
+}
+
+@MainActor
+final class SparkExitBalanceWatch {
+ private var last: SparkExitBalanceInputs?
+ private let onChange: () -> Void
+
+ init(onChange: @escaping () -> Void) {
+ self.onChange = onChange
+ }
+
+ func update(record: SparkExitRecord?, fundingSeenSats: UInt64, sparkBalanceSats: UInt64) {
+ let inputs = SparkExitBalanceInputs(record: record, fundingSeenSats: fundingSeenSats, sparkBalanceSats: sparkBalanceSats)
+ guard inputs != last else { return }
+ last = inputs
+ onChange()
+ }
+}
diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift
index 29386d23b..35215b91f 100644
--- a/Bitkit/Managers/SparkManager.swift
+++ b/Bitkit/Managers/SparkManager.swift
@@ -49,17 +49,32 @@ final class SparkManager: ChannelFreeLightningRail {
private nonisolated static let depositClaimFeePpm: UInt32 = 2000
private(set) var isReady = false
+ private(set) var walletIndex = 0
/// What Spark read, which sends and withdrawals use.
private(set) var balanceSats: UInt64 = 0
/// The last balance shown, kept while Spark reads 0 after a failed withdrawal or a failed sync; display only.
private(set) var keptBalanceSats: UInt64?
+ /// The kept balance when an exit started, shown again should the exit be cancelled.
+ @ObservationIgnored private var keptBeforeExit: UInt64?
/// The balance Home and the Spark connection show.
var shownBalanceSats: UInt64 {
keptBalanceSats ?? balanceSats
}
+ /// The balance Spending and its connections show: 0 during an emergency exit, whose amount shows in transfer to
+ /// savings instead.
+ var shownSpendingSats: UInt64 {
+ Self.shownSpendingSats(isExiting: isExiting, shownBalanceSats: shownBalanceSats)
+ }
+
+ nonisolated static func shownSpendingSats(isExiting: Bool, shownBalanceSats: UInt64) -> UInt64 {
+ isExiting ? 0 : shownBalanceSats
+ }
+
/// Deposits seen on-chain that Spark claims into `balanceSats` after 3 confirmations.
private(set) var pendingDepositSats: UInt64 = 0
+ /// Funds an emergency exit can still recover, as Spark reports them.
+ private(set) var recoverableFundsSats: UInt64 = 0
/// On-chain deposits into Spark, pending ones first, for the Lightning Connections screens.
private(set) var deposits: [SparkDeposit] = []
private(set) var withdrawals: [SparkWithdrawal] = []
@@ -87,8 +102,24 @@ final class SparkManager: ChannelFreeLightningRail {
@ObservationIgnored private var withdrewAllAt: UInt64?
@ObservationIgnored private var listenerId: String?
@ObservationIgnored private var connectTask: Task?
+ /// A leaf optimization is running, as its events tell.
+ @ObservationIgnored private var isOptimizing = false
+ /// Sends and withdrawals past their exit check, which an exit starting waits for.
+ let spends = SparkSpends { SparkExitManager.shared.isExiting }
+ /// Waiting before an exit quote for value an interrupted leaf optimization still owes the wallet.
+ private(set) var isSettlingForExit = false
+ /// When a leaf optimization last moved without completing, so a counter-swap it left may still be unclaimed.
+ 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
/// Bumped by `wipe`, so a connect that started before it disconnects instead of keeping the wiped wallet.
@ObservationIgnored private var connectGeneration = 0
+ /// Set for the whole wipe: no connect, start or exit reconnect is admitted, so nothing brings the wiped wallet's
+ /// Spark identity back while its mnemonic still exists.
+ @ObservationIgnored private var isWiping = false
/// The refresh in flight; callers during it wait for it, and it reads once more for them.
@ObservationIgnored private var refreshTask: Task?
@ObservationIgnored private var refreshAgain = false
@@ -118,6 +149,204 @@ 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
+ }
+ }
+
+ /// Saves the kept balance as an exit starts, before a refresh reads Spark as exiting and drops it: a cancelled
+ /// exit shows it again.
+ func holdKeptBalanceForExit() {
+ keptBeforeExit = keptBalanceSats ?? keptBeforeExit
+ keptBalanceSats = nil
+ }
+
+ /// Connects Spark again so its leaf optimization follows whether an exit runs: off when one starts, back on
+ /// once it ends. An exit cancelled before it paid anything (`isCancel`) shows the kept balance again.
+ func reconnectForExit(isCancel: Bool = false) async {
+ guard let network = Self.sparkNetwork, !isWiping else { return }
+ if isExiting {
+ holdKeptBalanceForExit()
+ } else {
+ keptBalanceSats = Self.keptBalanceSats(afterExitKept: keptBeforeExit, read: balanceSats, isCancel: isCancel)
+ ?? keptBalanceSats
+ keptBeforeExit = nil
+ }
+ // A connect still running, such as the launch's, builds an SDK on the same storage: it ends first and is
+ // disconnected below, so no second SDK keeps running.
+ if let connecting = connectTask {
+ _ = try? await connecting.value
+ }
+ noteOptimizationCutShort()
+ if let sdk {
+ if let listenerId {
+ _ = await sdk.removeEventListener(id: listenerId)
+ }
+ try? await sdk.disconnect()
+ }
+ sdk = nil
+ listenerId = nil
+ do {
+ let generation = connectGeneration
+ let walletIndex = walletIndex
+ let connecting = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) }
+ connectTask = connecting
+ defer {
+ if connectTask == connecting {
+ connectTask = nil
+ }
+ }
+ try await connecting.value
+ try await refresh()
+ } catch {
+ Logger.error("Failed to reconnect Spark for the exit: \(error)", context: "SparkManager")
+ }
+ }
+
+ /// Connects the Spark SDK alone for a background exit run, never the node: no refresh, no activity sync and no
+ /// Lightning rail. Returns nil when it does not connect within `timeout`; the connect keeps going, and a later
+ /// `start` picks it up.
+ /// `operation`'s result, or nil once `timeout` passes first. Returns at the timeout without waiting for `operation`,
+ /// which a task group would do; the operation carries on, unstructured, and its late result is dropped.
+ nonisolated static func firstOf(timeout: TimeInterval, _ operation: @escaping @Sendable () async -> T) async -> T? {
+ await withCheckedContinuation { continuation in
+ let resumed = SparkFirstResume()
+ Task {
+ let value = await operation()
+ if resumed.claim() {
+ continuation.resume(returning: value)
+ }
+ }
+ Task {
+ try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000))
+ if resumed.claim() {
+ continuation.resume(returning: nil)
+ }
+ }
+ }
+ }
+
+ func connectForExit(walletIndex: Int, timeout: TimeInterval) async -> SparkExitRecovering? {
+ guard let network = Self.sparkNetwork else { return nil }
+ if sdk == nil {
+ self.walletIndex = walletIndex
+ if connectTask == nil {
+ let generation = connectGeneration
+ connectTask = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) }
+ }
+ let connecting = connectTask
+ // nil while still connecting at the timeout; the connect itself keeps running for `start`.
+ let didConnect: Bool? = await Self.firstOf(timeout: timeout) { await (try? connecting?.value) != nil }
+ // A finished connect is forgotten, so a failed one is tried again; one still running is kept for `start`.
+ if didConnect != nil {
+ connectTask = nil
+ }
+ 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)
+ }
+ }
+
+ /// Waits for the sends and withdrawals that passed their exit check before the exit held Spark, for up to two
+ /// minutes, so the exit quotes the leaves they leave.
+ func holdSpendsForExit() async throws {
+ try await spends.holdForExit(timeout: Self.optimizationWaitSeconds)
+ }
+
+ func releaseSpendsHold() {
+ spends.release()
+ }
+
+ 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)
+ }
+
+ /// 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
+ case .regtest: .regtest
+ default: nil
+ }
+ }
+
/// The exit data's generation while connected, nil while disconnected, so a backup can tell a change from observing
/// that restarted while Spark stayed connected.
var exitStateVersion: Int? {
@@ -145,19 +374,30 @@ final class SparkManager: ChannelFreeLightningRail {
}
func start(walletIndex: Int) async {
- guard let network = Self.sparkNetwork else { return }
+ guard let network = Self.sparkNetwork, !isWiping else { return }
+ self.walletIndex = walletIndex
+ SparkExitManager.shared.load(walletIndex: walletIndex)
do {
if sdk == nil {
if connectTask == nil {
let generation = connectGeneration
connectTask = Task { try await connect(walletIndex: walletIndex, network: network, generation: generation) }
}
- try await connectTask?.value
- connectTask = nil
+ let task = connectTask
+ // A reconnect may have replaced the task while this one was awaited: only the task it awaited is cleared.
+ defer {
+ if connectTask == task {
+ connectTask = nil
+ }
+ }
+ try await task?.value
}
// Registered before the first refresh marks Spark ready, so Lightning calls never see it ready while they
- // still go to LDK, even when that refresh or the activity sync fails.
- LightningService.shared.channelFreeRail = self
+ // still go to LDK, even when that refresh or the activity sync fails. An emergency exit keeps it out.
+ LightningService.shared.channelFreePaymentHistory = self
+ if !isExiting {
+ LightningService.shared.channelFreeRail = self
+ }
activitiesSynced = try await Self.startConnected(
activitiesSynced: activitiesSynced,
refresh: { try await refresh() },
@@ -169,7 +409,6 @@ final class SparkManager: ChannelFreeLightningRail {
)
await settleContactWithdrawal(allowClear: false)
} catch {
- connectTask = nil
Logger.error("Failed to start Spark wallet: \(error)", context: "SparkManager")
}
}
@@ -197,11 +436,12 @@ final class SparkManager: ChannelFreeLightningRail {
}
private func connect(walletIndex: Int, network: BreezSdkSpark.Network, generation: Int) async throws {
+ guard !isWiping else { throw CancellationError() }
guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else {
throw CustomServiceError.mnemonicNotFound
}
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)
// What `BreezSdkSpark.connect` builds, plus our own chain service where Env names one.
@@ -212,7 +452,7 @@ final class SparkManager: ChannelFreeLightningRail {
}
syncedThisRun = false
let connected = try await builder.build()
- guard generation == connectGeneration else {
+ guard generation == connectGeneration, !isWiping else {
try? await connected.disconnect()
throw CancellationError()
}
@@ -226,7 +466,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, no Lightning address server and no third-party input lookups: Bitkit uses none.
@@ -239,21 +479,59 @@ 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
}
/// Disconnects and deletes this wallet's Spark data. A connect still running is waited for first, so it can't
/// keep the wiped wallet; throws when the data could not be deleted, so the wipe is retried.
func wipe(walletIndex: Int) async throws {
- connectGeneration += 1
- syncedThisRun = false
+ defer { isWiping = false }
+ var disconnectError: Error?
+ try await Self.wipeInOrder(
+ closeAdmission: {
+ isWiping = true
+ connectGeneration += 1
+ syncedThisRun = false
+ },
+ drainExitWork: { await SparkExitManager.shared.wipe() },
+ disconnect: { disconnectError = await disconnectForWipe() },
+ deleteData: { try deleteWipedData(walletIndex: walletIndex) }
+ )
+ if let disconnectError {
+ throw disconnectError
+ }
+ }
+
+ /// The wipe's steps, in the order that keeps the wiped wallet's identity from coming back.
+ static func wipeInOrder(
+ closeAdmission: () -> Void,
+ drainExitWork: () async -> Void,
+ disconnect: () async -> Void,
+ deleteData: () throws -> Void
+ ) async throws {
+ closeAdmission()
+ // An exit tick finishing now may reconnect; with admission closed it can't, and it is done before the SDK goes.
+ await drainExitWork()
+ await disconnect()
+ try deleteData()
+ }
+
+ private func disconnectForWipe() async -> Error? {
if let connectTask {
_ = try? await connectTask.value
}
connectTask = nil
+ UserDefaults.standard.removeObject(forKey: Self.optimizationActivityKey)
+ isOptimizing = false
if LightningService.shared.channelFreeRail === self {
LightningService.shared.channelFreeRail = nil
}
+ if LightningService.shared.channelFreePaymentHistory === self {
+ LightningService.shared.channelFreePaymentHistory = nil
+ }
LightningService.shared.forgetRailAttempts()
var disconnectError: Error?
if let sdk {
@@ -272,11 +550,17 @@ final class SparkManager: ChannelFreeLightningRail {
refreshAgain = false
activitiesSynced = false
isReady = false
+ return disconnectError
+ }
+
+ private func deleteWipedData(walletIndex: Int) throws {
balanceSats = 0
keptBalanceSats = nil
+ keptBeforeExit = nil
withdrawalFailedAt = nil
withdrewAllAt = nil
pendingDepositSats = 0
+ recoverableFundsSats = 0
deposits = []
withdrawals = []
broadcastDeposits = [:]
@@ -289,9 +573,11 @@ final class SparkManager: ChannelFreeLightningRail {
onStateChanged?()
try Self.removeIfPresent(Env.sparkStorage(walletIndex: walletIndex))
try Self.removeIfPresent(Env.sparkExitRestore(walletIndex: walletIndex))
- if let disconnectError {
- throw disconnectError
- }
+ // The exit's own wipe only logs a failed delete: here it throws, so the wipe is retried, and covers the wiped
+ // wallet's index even when the exit manager holds another one.
+ try SparkExitStore(walletIndex: walletIndex).deleteWithCopies()
+ try Self.removeIfPresent(Env.sparkExitLostLeaves(walletIndex: walletIndex))
+ try Self.removeIfPresent(Env.sparkExitEarlierAddresses(walletIndex: walletIndex))
}
nonisolated static func removeIfPresent(_ url: URL) throws {
@@ -347,16 +633,18 @@ final class SparkManager: ChannelFreeLightningRail {
}
// The fee limit is checked on the quote that is sent, not only on the earlier estimate.
try Self.checkSendFee(feeSats, maxFeeSats: maxFeeSats)
- // A deadline that passed while the quote was prepared stops the payment here, before anything leaves.
- try beforeSubmission()
- let response: SendPaymentResponse
- do {
- response = try await sdk.sendPayment(request: SendPaymentRequest(
- prepareResponse: prepared,
- options: Self.bolt11SendOptions
- ))
- } catch {
- throw Self.sendPaymentFailure(error)
+ // An exit that started while the quote was prepared refuses it here, before its hash is recorded.
+ let response: SendPaymentResponse = try await spends.run {
+ // A deadline that passed while the quote was prepared stops the payment here, before anything leaves.
+ try beforeSubmission()
+ do {
+ return try await sdk.sendPayment(request: SendPaymentRequest(
+ prepareResponse: prepared,
+ options: Self.bolt11SendOptions
+ ))
+ } catch {
+ throw Self.sendPaymentFailure(error)
+ }
}
Logger.info("Sent Spark payment '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager")
return invoiceDetails.paymentHash
@@ -520,10 +808,14 @@ final class SparkManager: ChannelFreeLightningRail {
do {
let prepared = try await prepareWithdrawAll(to: address)
try Self.checkWithdrawalFee(Self.withdrawalFeeSats(prepared), shownFeeSats: maxFeeSats)
- let response = try await requireSdk().sendPayment(request: SendPaymentRequest(
- prepareResponse: prepared,
- options: .bitcoinAddress(confirmationSpeed: .medium)
- ))
+ let sdk = try await requireSdk()
+ // Preparing suspends, so an exit can start meanwhile.
+ let response = try await spends.run {
+ try await sdk.sendPayment(request: SendPaymentRequest(
+ prepareResponse: prepared,
+ options: .bitcoinAddress(confirmationSpeed: .medium)
+ ))
+ }
preparedWithdrawal = nil
if case let .withdraw(txId, _, _) = response.payment.details {
sentWithdrawalTxIds.insert(txId)
@@ -688,6 +980,10 @@ final class SparkManager: ChannelFreeLightningRail {
let prepared: PrepareSendPaymentResponse
let earlierPayments: [Payment]
do {
+ // An exit spends every leaf, so no contact withdrawal starts while one runs.
+ guard !isExiting else {
+ throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description"))
+ }
try claimContactWithdrawal()
} catch {
throw SparkWithdrawalNotSent(underlying: error)
@@ -731,35 +1027,34 @@ final class SparkManager: ChannelFreeLightningRail {
},
paymentDeadline: paymentDeadline
)
- let response: SendPaymentResponse
- do {
- response = try await sdk.sendPayment(request: SendPaymentRequest(
- prepareResponse: prepared,
- options: .bitcoinAddress(confirmationSpeed: .medium),
- idempotencyKey: pending.idempotencyKey
- ))
- } catch {
- throw await Self.withdrawalSendFailure(
- error,
- closeUnsent: {
- await Self.closeUnsentWithdrawal(journaled, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance)
- },
- settleLater: {
- let failedAt = UInt64(Date().timeIntervalSince1970)
- Task {
- await Self.settleAfterFailedWithdrawal(
- failedAt: failedAt,
- syncWallet: {
- _ = try await self.requireSdk().syncWallet(request: SyncWalletRequest())
- self.syncedThisRun = true
- },
- hasSynced: { await self.hasSynced(after: $0) },
- settle: { await self.settleContactWithdrawal(allowClear: $0) }
- )
- }
+ let idempotencyKey = pending.idempotencyKey
+ let response = try await Self.sendJournaledWithdrawal(
+ spends: spends,
+ send: {
+ try await sdk.sendPayment(request: SendPaymentRequest(
+ prepareResponse: prepared,
+ options: .bitcoinAddress(confirmationSpeed: .medium),
+ idempotencyKey: idempotencyKey
+ ))
+ },
+ closeUnsent: {
+ await Self.closeUnsentWithdrawal(journaled, releaseProof: Self.releaseUnsentProof, failAllowance: Self.failAllowance)
+ },
+ settleLater: {
+ let failedAt = UInt64(Date().timeIntervalSince1970)
+ Task {
+ await Self.settleAfterFailedWithdrawal(
+ failedAt: failedAt,
+ syncWallet: {
+ _ = try await self.requireSdk().syncWallet(request: SyncWalletRequest())
+ self.syncedThisRun = true
+ },
+ hasSynced: { await self.hasSynced(after: $0) },
+ settle: { await self.settleContactWithdrawal(allowClear: $0) }
+ )
}
- )
- }
+ }
+ )
Logger.info("Sent Spark withdrawal '\(response.payment.id)' with status '\(response.payment.status)'", context: "SparkManager")
pending.paymentId = response.payment.id
Self.pendingContactWithdrawal = pending
@@ -801,6 +1096,22 @@ final class SparkManager: ChannelFreeLightningRail {
return txId
}
+ /// Sends a contact withdrawal whose journal is open, counted in `spends` so an exit starting meanwhile refuses it or
+ /// waits for it. A send refused before it left closes the journal and ends as `SparkWithdrawalNotSent`; any other
+ /// failure settles later from Spark's payment list.
+ static func sendJournaledWithdrawal(
+ spends: SparkSpends,
+ send: () async throws -> T,
+ closeUnsent: () async -> Void,
+ settleLater: () -> Void
+ ) async throws -> T {
+ do {
+ return try await spends.run(send)
+ } catch {
+ throw await withdrawalSendFailure(error, closeUnsent: closeUnsent, settleLater: settleLater)
+ }
+ }
+
/// What a failed `sendPayment` of a journaled withdrawal means. A refusal Spark makes before anything reaches the
/// operators, as on the Lightning rail, closes the journal at once; an outcome that may have sent it stays journaled
/// and settles from the sync after it.
@@ -1227,7 +1538,8 @@ final class SparkManager: ChannelFreeLightningRail {
read: info.balanceSats,
lastShown: shownBalanceSats,
isStale: withdrawalFailedAt != nil || info.lastSyncError != nil,
- withdrewAll: withdrewAllAt != nil
+ withdrewAll: withdrewAllAt != nil,
+ isExiting: isExiting
)
let changed = !isReady || Self.balancesChanged(
from: SparkBalances(readSats: balanceSats, keptSats: keptBalanceSats, pendingSats: pendingDepositSats),
@@ -1237,6 +1549,7 @@ final class SparkManager: ChannelFreeLightningRail {
balanceSats = info.balanceSats
keptBalanceSats = kept
pendingDepositSats = pending
+ recoverableFundsSats = info.recoverableFundsSats
deposits = listedDeposits
withdrawals = Self.withdrawals(payments)
for deposit in deposits {
@@ -1324,9 +1637,14 @@ final class SparkManager: ChannelFreeLightningRail {
/// After a failed withdrawal, or while Spark's last sync failed (`isStale`), Spark can read 0 with the funds still
/// there, so the last shown balance stays shown until it reads one again. After a withdrawal of the whole balance
- /// (`withdrewAll`) the 0 is real.
- nonisolated static func keptBalanceSats(read: UInt64, lastShown: UInt64, isStale: Bool, withdrewAll: Bool) -> UInt64? {
- read == 0 && isStale && !withdrewAll && lastShown > 0 ? lastShown : nil
+ /// (`withdrewAll`) the 0 is real. An emergency exit moves the balance to savings, so a 0 read during one keeps nothing.
+ nonisolated static func keptBalanceSats(read: UInt64, lastShown: UInt64, isStale: Bool, withdrewAll: Bool, isExiting: Bool) -> UInt64? {
+ read == 0 && isStale && !withdrewAll && lastShown > 0 && !isExiting ? lastShown : nil
+ }
+
+ /// The balance kept again once an exit ends: a cancelled exit moved nothing, so what was kept before it still is.
+ nonisolated static func keptBalanceSats(afterExitKept kept: UInt64?, read: UInt64, isCancel: Bool) -> UInt64? {
+ isCancel && read == 0 ? kept : nil
}
/// A failed withdrawal stops keeping the balance once Spark reads one, or reads 0 after a sync that ended after it.
@@ -1506,6 +1824,8 @@ final class SparkManager: ChannelFreeLightningRail {
await recordActivity(payment)
case .unilateralExitStateChanged:
Self.notifyExitStateChanged()
+ case let .autoOptimization(optimizationEvent):
+ noteOptimization(optimizationEvent)
default:
break
}
@@ -1818,6 +2138,35 @@ 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))
+ }
+
+ 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 {
private let handler: @Sendable (SdkEvent) async -> Void
@@ -1830,6 +2179,65 @@ private final class SparkEventListener: BreezSdkSpark.EventListener, @unchecked
}
}
+/// The Spark sends and withdrawals past their exit check. An exit holds Spark before it waits for them, so every later
+/// one refuses, and it quotes the leaves they leave once they end.
+@MainActor
+final class SparkSpends {
+ private let isExiting: () -> Bool
+ private(set) var inFlight = 0
+ /// An exit start holds Spark while it waits for the sends, before its record exists.
+ private(set) var isHeld = false
+
+ init(isExiting: @escaping () -> Bool) {
+ self.isExiting = isExiting
+ }
+
+ /// Runs `send` counted, unless an exit holds Spark. Called after the send's last await but the one inside `send`,
+ /// so an exit starting meanwhile either refuses it here or waits for it.
+ func run(_ send: () async throws -> T) async throws -> T {
+ guard !isExiting(), !isHeld else {
+ Logger.info("Refused a Spark send: an emergency exit holds Spark", context: "SparkManager")
+ throw ChannelFreeRailSendRefused(reason: t("wallet__payment_failed_description"))
+ }
+ inFlight += 1
+ defer { inFlight -= 1 }
+ return try await send()
+ }
+
+ /// Holds Spark for an exit start and waits for the counted sends to end, for up to `timeout`. Throws, and lets go,
+ /// when one is still sending then: the exit must not start while a send can spend its leaves, and the user starts
+ /// it again once the send ended. The start lets go (`release`) once its record holds Spark.
+ func holdForExit(timeout: TimeInterval) async throws {
+ isHeld = true
+ let deadline = Date().addingTimeInterval(timeout)
+ while inFlight > 0, !Task.isCancelled, Date() < deadline {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ if inFlight > 0 {
+ Logger.warn("Gave up waiting for Spark sends in flight to end", context: "SparkManager")
+ isHeld = false
+ throw AppError(message: t("other__try_again"), debugMessage: "A Spark payment is still sending")
+ }
+ }
+
+ func release() {
+ isHeld = false
+ }
+}
+
+/// Lets the first of two racing tasks resume a continuation, once.
+private final class SparkFirstResume: @unchecked Sendable {
+ private let lock = NSLock()
+ private var isClaimed = false
+
+ func claim() -> Bool {
+ lock.withLock {
+ defer { isClaimed = true }
+ return !isClaimed
+ }
+ }
+}
+
/// The balances a refresh reads: what Spark read (`readSats`), the balance kept on screen, and pending deposits.
struct SparkBalances: Equatable {
let readSats: UInt64
diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift
index a083b111c..628345dcd 100644
--- a/Bitkit/Models/BackupPayloads.swift
+++ b/Bitkit/Models/BackupPayloads.swift
@@ -27,6 +27,9 @@ struct MetadataBackupV1: Codable {
/// before this field, and in envelopes from an app that knows no names. Shared with
/// bitkit-android, which spells the key the same way — see its `MetadataBackupV1`.
let hwWalletNames: [String: String]?
+ /// The earlier Spark exit addresses each wallet watches, keyed `/wallet` as bitkit-android keys them.
+ /// Nil in envelopes written before this field.
+ var sparkExitEarlierAddresses: [String: [SparkExitEarlierAddressBackupV1]]?
init(
version: Int,
@@ -35,7 +38,8 @@ struct MetadataBackupV1: Codable {
cache: AppCacheData,
pubkySession: PubkySessionBackupV1?,
pubkyContactProfileOverrides: [String: PubkyProfileData]?,
- hwWalletNames: [String: String]? = nil
+ hwWalletNames: [String: String]? = nil,
+ sparkExitEarlierAddresses: [String: [SparkExitEarlierAddressBackupV1]]? = nil
) {
self.version = version
self.createdAt = createdAt
@@ -44,9 +48,37 @@ struct MetadataBackupV1: Codable {
self.pubkySession = pubkySession
self.pubkyContactProfileOverrides = pubkyContactProfileOverrides
self.hwWalletNames = hwWalletNames
+ self.sparkExitEarlierAddresses = sparkExitEarlierAddresses
}
}
+extension MetadataBackupV1 {
+ /// An earlier exit address list this app can't read is left out, so it never fails the rest of the envelope.
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ version = try c.decode(Int.self, forKey: .version)
+ createdAt = try c.decode(UInt64.self, forKey: .createdAt)
+ tagMetadata = try c.decode([PreActivityMetadata].self, forKey: .tagMetadata)
+ cache = try c.decode(AppCacheData.self, forKey: .cache)
+ pubkySession = try c.decodeIfPresent(PubkySessionBackupV1.self, forKey: .pubkySession)
+ pubkyContactProfileOverrides = try c.decodeIfPresent([String: PubkyProfileData].self, forKey: .pubkyContactProfileOverrides)
+ hwWalletNames = try c.decodeIfPresent([String: String].self, forKey: .hwWalletNames)
+ sparkExitEarlierAddresses = try? c.decodeIfPresent(
+ [String: [SparkExitEarlierAddressBackupV1]].self,
+ forKey: .sparkExitEarlierAddresses
+ )
+ }
+}
+
+/// An earlier Spark exit address as both apps back it up. `kind` is `keptChange` or `cancelledFunding`; a reader skips
+/// one it does not know. What the address holds is read again from the chain.
+struct SparkExitEarlierAddressBackupV1: Codable, Equatable {
+ let address: String
+ let path: String
+ let kind: String
+ let atMillis: Int64
+}
+
struct PubkySessionBackupV1: Codable, Equatable {
enum Kind: String, Codable {
case localSeed
@@ -84,6 +116,9 @@ struct AppCacheData: Codable {
let detachedActivityContacts: Set
/// Imported cache addresses may have been public; local snapshots do not export the live receive address.
let onchainAddress: String?
+ /// The funding key index each wallet's next Spark exit takes, keyed `/wallet` as bitkit-android keys
+ /// its own. Nil in envelopes written before this field.
+ let sparkExitNextFundingIndexes: [String: Int]?
init(
hasSeenContactsIntro: Bool,
@@ -103,7 +138,8 @@ struct AppCacheData: Codable {
dismissedSuggestions: [String],
lastUsedTags: [String],
quickPayLedger: QuickPayLedger? = nil,
- detachedActivityContacts: Set = []
+ detachedActivityContacts: Set = [],
+ sparkExitNextFundingIndexes: [String: Int]? = nil
) {
self.hasSeenContactsIntro = hasSeenContactsIntro
self.hasSeenProfileIntro = hasSeenProfileIntro
@@ -123,6 +159,7 @@ struct AppCacheData: Codable {
self.lastUsedTags = lastUsedTags
self.quickPayLedger = quickPayLedger
self.detachedActivityContacts = detachedActivityContacts
+ self.sparkExitNextFundingIndexes = sparkExitNextFundingIndexes
onchainAddress = nil
}
@@ -147,6 +184,7 @@ struct AppCacheData: Codable {
quickPayLedger = try c.decodeIfPresent(QuickPayLedger.self, forKey: .quickPayLedger)
detachedActivityContacts = try c.decodeIfPresent(Set.self, forKey: .detachedActivityContacts) ?? []
onchainAddress = try c.decodeIfPresent(String.self, forKey: .onchainAddress)
+ sparkExitNextFundingIndexes = try c.decodeIfPresent([String: Int].self, forKey: .sparkExitNextFundingIndexes)
}
private enum CodingKeys: String, CodingKey {
@@ -158,6 +196,7 @@ struct AppCacheData: Codable {
case quickPayLedger
case detachedActivityContacts
case onchainAddress
+ case sparkExitNextFundingIndexes
}
}
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 fca7583af..423599fed 100644
--- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings
@@ -319,6 +319,90 @@
"lightning__spark_provider_value" = "Spark عبر Breez";
"lightning__spark_receiving_unlimited" = "بلا حد";
"lightning__spark_trust" = "يُحفظ هذا الرصيد على Spark الذي تديره Lightspark وBreez وFlashnet. يبقى آمنًا ما دام أحدهم على الأقل يتصرف بأمانة. يمكنهم إيقاف الوصول مؤقتًا، والخروج من دونهم يتطلب خروجًا على السلسلة.";
+"lightning__spark_exit__button" = "خروج طارئ";
+"lightning__spark_exit__nav_title" = "الخروج الطارئ";
+"lightning__spark_exit__intro_title" = "غادر Spark\nعلى السلسلة";
+"lightning__spark_exit__intro_text" = "استخدم هذا فقط إذا توقف Spark عن العمل لديك. ينقل رصيد الإنفاق إلى المدخرات من دون مشغّلي Spark.\n\nيستغرق حتى 4 أسابيع، ويكلّف رسومًا على السلسلة تُدفع من المدخرات، وقد تبقى مبالغ صغيرة جدًا. يعمل في الخلفية، ويُعلمك Bitkit إذا احتاج إليك.";
+"lightning__spark_exit__quote_title" = "عرض\nالخروج";
+"lightning__spark_exit__amount" = "المبلغ المراد إخراجه";
+"lightning__spark_exit__fees" = "رسوم الشبكة";
+"lightning__spark_exit__receive" = "ستستلم نحو";
+"lightning__spark_exit__savings_fee" = "رسوم الدفع من المدخرات";
+"lightning__spark_exit__savings_refused" = "لم يكتمل دفعك من المدخرات. ادفع مرة أخرى، أو موّل من محفظة أخرى.";
+"lightning__spark_exit__savings_total" = "الإجمالي من المدخرات";
+"lightning__spark_exit__funding" = "تمويل الرسوم المطلوب";
+"lightning__spark_exit__left_behind" = "يبقى على Spark";
+"lightning__spark_exit__outside_exit" = "{amount} على Spark ليس جزءًا من هذا الخروج ويبقى على Spark.";
+"lightning__spark_exit__quote_loading" = "جارٍ الحصول على عرض…";
+"lightning__spark_exit__quote_settling" = "بانتظار أن ينتهي Spark من نقل رصيدك. قد يستغرق ذلك حتى 5 دقائق.";
+"lightning__spark_exit__quote_error" = "تعذّر الحصول على عرض للخروج. حاول مرة أخرى.";
+"lightning__spark_exit__nothing" = "لا يوجد ما يمكن إخراجه الآن.";
+"lightning__spark_exit__uneconomic" = "الرسوم أعلى مما ستستلمه.";
+"lightning__spark_exit__funding_title" = "موّل\nالرسوم";
+"lightning__spark_exit__funding_text" = "يدفع الخروج رسومه من دفعة منفصلة على السلسلة بقيمة {amount}. ما لا يُنفق منها يعود إلى المدخرات في النهاية.";
+"lightning__spark_exit__fund_savings" = "ادفع من المدخرات";
+"lightning__spark_exit__fund_external" = "موّل من محفظة أخرى";
+"lightning__spark_exit__fund_external_text" = "أرسل {amount} على الأقل إلى هذا العنوان. يبدأ الخروج بمجرد تأكيدها.";
+"lightning__spark_exit__fund_insufficient" = "لا تكفي مدخراتك لتغطية تمويل الرسوم.";
+"lightning__spark_exit__fund_seen" = "تم رصد الدفعة.";
+"lightning__spark_exit__fund_partial" = "تم استلام {received} من {required}، أرسل {missing} إضافية.";
+"lightning__spark_exit__confirm_title" = "بدء\nالخروج؟";
+"lightning__spark_exit__confirm_text" = "لن تتمكن من استخدام هذه الأموال لمدة تصل إلى 4 أسابيع.";
+"lightning__spark_exit__swipe" = "اسحب للخروج";
+"lightning__spark_exit__progress_title" = "الخروج\nقيد التنفيذ";
+"lightning__spark_exit__progress_text" = "يعمل هذا في الخلفية. يُعلمك Bitkit إذا احتاج إليك.";
+"lightning__spark_exit__step_funding" = "تأكيد تمويل الرسوم";
+"lightning__spark_exit__step_publish" = "نشر معاملات الخروج";
+"lightning__spark_exit__step_timelock" = "انتظار القفل الزمني";
+"lightning__spark_exit__step_sweep" = "الإرسال إلى المدخرات";
+"lightning__spark_exit__ready_in" = "جاهز خلال {duration}";
+"lightning__spark_exit__done_title" = "اكتمل\nالخروج";
+"lightning__spark_exit__done_text" = "أموالك في المدخرات.";
+"lightning__spark_exit__partial_text" = "بقيت {count} مبالغ صغيرة على Spark.";
+"lightning__spark_exit__failed_leaves" = "نقل مشغّلو Spark مبلغ {amount} إلى السلسلة وهو ليس على Spark. لا يزال بإمكان المشغّلين تحريره لك.";
+"lightning__spark_exit__leftover" = "لا يزال {amount} من تمويل الرسوم في {address}.";
+"lightning__spark_exit__leftover_swept" = "أُرسل {amount} من تمويل الرسوم المتبقي إلى المدخرات.";
+"lightning__spark_exit__leftover_sending" = "جارٍ إرسال {amount} من تمويل الرسوم المتبقي إلى المدخرات.";
+"lightning__spark_exit__leftover_dust" = "{amount} من تمويل الرسوم المتبقي صغير جدًا بحيث لا يمكن إرساله ويبقى في {address}، مسار الاشتقاق {path}.";
+"lightning__spark_exit__fund_paid" = "تم الدفع من المدخرات، بانتظار التأكيد.";
+"lightning__spark_exit__fund_pending" = "تم رصد الدفعة، بانتظار التأكيد.";
+"lightning__spark_exit__lost_leaves" = "طالب مالك سابق لهذه الأموال بمبلغ {amount} على السلسلة ولا يمكن استرداده.";
+"lightning__spark_exit__notification_lost" = "لا يمكن استرداد {amount} من خروجك. افتح Bitkit لمعرفة السبب.";
+"lightning__spark_exit__notification_failed" = "توقف خروجك الطارئ. افتح Bitkit لمعرفة السبب.";
+"lightning__spark_exit__quote_outside" = "لا يمكن إخراج {amount} من رصيد Spark الآن: مبالغ أصغر من أن تدفع رسومها، أو لا تزال تتحرك داخل Spark. يبقى على Spark.";
+"lightning__spark_exit__left_behind_leaves" = "لا يمكن إخراج {amount} بمعدل الرسوم هذا ويبقى على Spark.";
+"lightning__spark_exit__needs_funding_title" = "مطلوب\nرسوم إضافية";
+"lightning__spark_exit__needs_funding_text" = "يحتاج الخروج إلى {amount} إضافية لدفع رسومه. موّله للمتابعة.";
+"lightning__spark_exit__failed_title" = "توقف\nالخروج";
+"lightning__spark_exit__cancel" = "إلغاء الخروج";
+"lightning__spark_exit__failed_text" = "تعذّر استرداد أي شيء.";
+"lightning__spark_exit__failed_stays" = "يبقى {amount} على Spark.";
+"lightning__spark_exit__notification_title" = "خروج طارئ";
+"lightning__spark_exit__notification_body" = "افتح Bitkit لمتابعة خروجك.";
+"lightning__spark_exit__notification_funding" = "يحتاج خروجك إلى رسوم إضافية للمتابعة. افتح Bitkit لتمويله.";
+"lightning__spark_exit__bg_refresh_off" = "تحديث التطبيقات في الخلفية متوقف لـ Bitkit، لذا لا يتقدم الخروج إلا عندما يكون Bitkit مفتوحًا. فعّله في الإعدادات ليعمل في الخلفية.";
+"lightning__spark_exit__low_power" = "يوقف وضع الطاقة المنخفضة العمل في الخلفية مؤقتًا. يتابع الخروج بمجرد إيقافه، أو عند فتح Bitkit.";
+"lightning__spark_exit__notifications_off" = "الإشعارات متوقفة، لذا لا يستطيع Bitkit إعلامك عندما يحتاج إليك الخروج. فعّلها في الإعدادات لتلقي التذكيرات.";
+"lightning__spark_exit__not_moving" = "الخروج لا يتقدم: {error}";
+"lightning__spark_exit__fee_low" = "الشبكة أكثر ازدحامًا من معدل رسوم هذا الخروج البالغ {rate} sat/vB. ادفع رسومًا أعلى، أو انتظر انخفاض الرسوم.";
+"lightning__spark_exit__waiting_leaves" = "تتعذّر قراءة جزء من هذا الخروج على السلسلة الآن. يحاول Bitkit مجددًا لمدة تصل إلى أسبوع.";
+"lightning__spark_exit__rebuilt_from_chain" = "تعذّر على Bitkit قراءة التقدم المحفوظ لهذا الخروج، لذا يعيد بناءه من السلسلة.";
+"lightning__spark_exit__done_received" = "وصل {amount} إلى مدخراتك.";
+"lightning__spark_exit__leftover_rejected" = "تعذّر إرسال {amount} من تمويل الرسوم المتبقي ويبقى في {address}، مسار الاشتقاق {path}.";
+"lightning__spark_exit__notification_not_moving" = "خروجك الطارئ لا يتقدم. افتح Bitkit لمعرفة السبب.";
+"lightning__spark_exit__funding_split" = "يقسّمه Bitkit إلى {count} أجزاء، جزء لكل خطوة، حتى لا تعطّل خطوة عالقة الخطوات الأخرى.";
+"lightning__spark_exit__waiting_operators" = "{amount} الذي نقله مشغّلو Spark إلى السلسلة ينتظر أن يحرروه. يطلب Bitkit ذلك مجددًا لمدة تصل إلى أسبوع.";
+"lightning__spark_exit__fee_rose" = "ارتفعت رسوم الشبكة فوق رسوم هذا الخروج. قد يتباطأ.";
+"lightning__spark_exit__fee_bump_button" = "ادفع رسومًا أعلى";
+"lightning__spark_exit__fee_bump_title" = "ادفع\nرسومًا أعلى";
+"lightning__spark_exit__fee_bump_text" = "يدفع هذا الخروج {rate} sat/vB، وهو أقل مما تطلبه الشبكة الآن. اختر معدلًا أعلى وسيعيد Bitkit إرسال الخطوات المنتظرة. يدفع الفرق من المدخرات: المبلغ الظاهر بجانب كل سرعة.";
+"lightning__spark_exit__fee_bump_error" = "تعذّر تسعير رسوم أعلى. حاول مرة أخرى.";
+"lightning__spark_exit__fee_bump_confirm" = "استخدم هذه الرسوم";
+"lightning__spark_exit__leftover_waiting" = "سيصل {amount} من فكة الرسوم إلى مدخراتك عند انخفاض رسوم الشبكة.";
+"lightning__spark_exit__cancelled_title" = "أُلغي\nالخروج";
+"lightning__spark_exit__cancelled_text" = "لقد ألغيت الخروج. يعود تمويل الرسوم الذي وصل إلى المدخرات.";
+"lightning__spark_exit__cancel_failed" = "تعذّر التحقق من عنوان التمويل. حاول مرة أخرى.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "قد تترك رسوم أعلى جزءًا من هذا الخروج على Spark، لذا لا يُعرض أي منها. يتأكد الخروج بمجرد انخفاض رسوم الشبكة.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
index 43a9d07be..2d197ea97 100644
--- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings
@@ -356,6 +356,90 @@
"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" = "Sortida d'emergència";
+"lightning__spark_exit__nav_title" = "Sortida d'emergència";
+"lightning__spark_exit__intro_title" = "Surt de Spark\non-chain";
+"lightning__spark_exit__intro_text" = "Fes-ho servir només si Spark deixa de funcionar-te. Mou el teu saldo de despesa a estalvis sense els operadors de Spark.\n\nTriga fins a 4 setmanes, té comissions on-chain pagades des d'estalvis, i és possible que quedin enrere quantitats molt petites. S'executa en segon pla i Bitkit t'avisa si et necessita.";
+"lightning__spark_exit__quote_title" = "Pressupost\nde sortida";
+"lightning__spark_exit__amount" = "Import a retirar";
+"lightning__spark_exit__fees" = "Comissions de xarxa";
+"lightning__spark_exit__receive" = "Rebràs aproximadament";
+"lightning__spark_exit__savings_fee" = "Comissió del pagament des d'estalvis";
+"lightning__spark_exit__savings_refused" = "El teu pagament dels estalvis no s'ha completat. Torna a pagar o finança des d'un altre moneder.";
+"lightning__spark_exit__savings_total" = "Total des d'estalvis";
+"lightning__spark_exit__funding" = "Finançament de comissions necessari";
+"lightning__spark_exit__left_behind" = "Es queda a Spark";
+"lightning__spark_exit__outside_exit" = "{amount} a Spark no forma part d'aquesta sortida i es queda a Spark.";
+"lightning__spark_exit__quote_loading" = "Obtenint un pressupost…";
+"lightning__spark_exit__quote_settling" = "Esperant que Spark acabi de moure el teu saldo. Pot trigar fins a 5 minuts.";
+"lightning__spark_exit__quote_error" = "No s'ha pogut obtenir un pressupost de sortida. Torna-ho a provar.";
+"lightning__spark_exit__nothing" = "Ara mateix no es pot retirar res.";
+"lightning__spark_exit__uneconomic" = "Les comissions són més altes que el que rebries.";
+"lightning__spark_exit__funding_title" = "Finança les\ncomissions";
+"lightning__spark_exit__funding_text" = "La sortida paga les comissions amb un pagament on-chain separat de {amount}. El que no gasti torna a estalvis al final.";
+"lightning__spark_exit__fund_savings" = "Paga des d'estalvis";
+"lightning__spark_exit__fund_external" = "Finança des d'una altra cartera";
+"lightning__spark_exit__fund_external_text" = "Envia almenys {amount} a aquesta adreça. La sortida comença quan es confirmi.";
+"lightning__spark_exit__fund_insufficient" = "Els teus estalvis no poden cobrir el finançament de comissions.";
+"lightning__spark_exit__fund_seen" = "Pagament detectat.";
+"lightning__spark_exit__fund_partial" = "S'han rebut {received} de {required}, envia'n {missing} més.";
+"lightning__spark_exit__confirm_title" = "Iniciar la\nsortida?";
+"lightning__spark_exit__confirm_text" = "No podràs fer servir aquests fons durant fins a 4 setmanes.";
+"lightning__spark_exit__swipe" = "Llisca per sortir";
+"lightning__spark_exit__progress_title" = "Sortida\nen curs";
+"lightning__spark_exit__progress_text" = "S'executa en segon pla. Bitkit t'avisa si et necessita.";
+"lightning__spark_exit__step_funding" = "Confirmació del finançament de comissions";
+"lightning__spark_exit__step_publish" = "Publicant les transaccions de sortida";
+"lightning__spark_exit__step_timelock" = "Esperant el bloqueig temporal";
+"lightning__spark_exit__step_sweep" = "Enviant a estalvis";
+"lightning__spark_exit__ready_in" = "A punt d'aquí a {duration}";
+"lightning__spark_exit__done_title" = "Sortida\ncompletada";
+"lightning__spark_exit__done_text" = "Els teus fons són a estalvis.";
+"lightning__spark_exit__partial_text" = "{count} quantitats petites s'han quedat a Spark.";
+"lightning__spark_exit__failed_leaves" = "Els operadors de Spark han mogut {amount} on-chain i no és a Spark. Els operadors encara te'ls poden alliberar.";
+"lightning__spark_exit__leftover" = "{amount} del finançament de comissions encara és a {address}.";
+"lightning__spark_exit__leftover_swept" = "S'han enviat a estalvis {amount} del finançament de comissions sobrant.";
+"lightning__spark_exit__leftover_sending" = "Enviant a estalvis {amount} del finançament de comissions sobrant.";
+"lightning__spark_exit__leftover_dust" = "{amount} del finançament de comissions sobrant és massa poc per enviar i es queda a {address}, camí de derivació {path}.";
+"lightning__spark_exit__fund_paid" = "Pagat des d'estalvis, esperant la confirmació.";
+"lightning__spark_exit__fund_pending" = "Pagament detectat, esperant la confirmació.";
+"lightning__spark_exit__lost_leaves" = "Un propietari anterior d'aquests fons ha reclamat {amount} on-chain i no es pot recuperar.";
+"lightning__spark_exit__notification_lost" = "{amount} de la teva sortida no es pot recuperar. Obre Bitkit per saber per què.";
+"lightning__spark_exit__notification_failed" = "La teva sortida d'emergència s'ha aturat. Obre Bitkit per saber per què.";
+"lightning__spark_exit__quote_outside" = "{amount} del teu saldo Spark no es pot retirar ara mateix: quantitats massa petites per pagar les seves pròpies comissions, o que encara s'estan movent dins de Spark. Es queda a Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} no es pot retirar amb aquesta taxa de comissió i es queda a Spark.";
+"lightning__spark_exit__needs_funding_title" = "Calen més\ncomissions";
+"lightning__spark_exit__needs_funding_text" = "La sortida necessita {amount} més per pagar les comissions. Finança-la per continuar.";
+"lightning__spark_exit__failed_title" = "Sortida\naturada";
+"lightning__spark_exit__cancel" = "Cancel·la la sortida";
+"lightning__spark_exit__failed_text" = "No s'ha pogut recuperar res.";
+"lightning__spark_exit__failed_stays" = "{amount} es queda a Spark.";
+"lightning__spark_exit__notification_title" = "Sortida d'emergència";
+"lightning__spark_exit__notification_body" = "Obre Bitkit per continuar la teva sortida.";
+"lightning__spark_exit__notification_funding" = "La teva sortida necessita més comissions per continuar. Obre Bitkit per finançar-la.";
+"lightning__spark_exit__bg_refresh_off" = "L'actualització en segon pla està desactivada per a Bitkit, així que la sortida només avança mentre Bitkit està obert. Activa-la a Configuració perquè s'executi en segon pla.";
+"lightning__spark_exit__low_power" = "El mode de baix consum pausa la feina en segon pla. La sortida continua quan es desactiva, o quan obres Bitkit.";
+"lightning__spark_exit__notifications_off" = "Les notificacions estan desactivades, així que Bitkit no et pot avisar quan la sortida et necessiti. Activa-les a Configuració per rebre recordatoris.";
+"lightning__spark_exit__not_moving" = "La sortida no avança: {error}";
+"lightning__spark_exit__fee_low" = "La xarxa està més saturada que la taxa de comissió d'aquesta sortida, de {rate} sat/vB. Paga una comissió més alta o espera que baixin les comissions.";
+"lightning__spark_exit__waiting_leaves" = "Ara mateix no es pot llegir on-chain una part d'aquesta sortida. Bitkit ho torna a provar durant una setmana com a màxim.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit no ha pogut llegir el progrés desat d'aquesta sortida, així que el reconstrueix a partir de la cadena.";
+"lightning__spark_exit__done_received" = "{amount} ha arribat als teus estalvis.";
+"lightning__spark_exit__leftover_rejected" = "{amount} del finançament de comissions sobrant no s'ha pogut enviar i es queda a {address}, camí de derivació {path}.";
+"lightning__spark_exit__notification_not_moving" = "La teva sortida d'emergència no avança. Obre Bitkit per saber per què.";
+"lightning__spark_exit__funding_split" = "Bitkit el divideix en {count} parts, una per a cada pas, perquè un pas encallat no pugui retenir els altres.";
+"lightning__spark_exit__waiting_operators" = "{amount} que els operadors de Spark han mogut on-chain espera que l'alliberin. Bitkit ho torna a demanar durant una setmana com a màxim.";
+"lightning__spark_exit__fee_rose" = "Les comissions de xarxa han pujat per sobre de la comissió d'aquesta sortida. Pot alentir-se.";
+"lightning__spark_exit__fee_bump_button" = "Paga una comissió més alta";
+"lightning__spark_exit__fee_bump_title" = "Paga una\ncomissió més alta";
+"lightning__spark_exit__fee_bump_text" = "Aquesta sortida paga {rate} sat/vB, menys del que la xarxa demana ara. Tria una taxa més alta i Bitkit tornarà a enviar els passos pendents. Paga la diferència des d'estalvis: l'import al costat de cada velocitat.";
+"lightning__spark_exit__fee_bump_error" = "No s'ha pogut calcular el preu d'una comissió més alta. Torna-ho a provar.";
+"lightning__spark_exit__fee_bump_confirm" = "Fes servir aquesta comissió";
+"lightning__spark_exit__leftover_waiting" = "{amount} de canvi de comissions arribarà als teus estalvis quan baixin les comissions de xarxa.";
+"lightning__spark_exit__cancelled_title" = "Sortida\ncancel·lada";
+"lightning__spark_exit__cancelled_text" = "Has cancel·lat la sortida. El finançament de comissions que ha arribat torna a estalvis.";
+"lightning__spark_exit__cancel_failed" = "No s'ha pogut comprovar l'adreça de finançament. Torna-ho a provar.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Una comissió més alta deixaria part d'aquesta sortida a Spark, així que no se n'ofereix cap. La sortida es confirma quan baixin les comissions de xarxa.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
index cef8cfd4c..eca56f13d 100644
--- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings
@@ -372,6 +372,90 @@
"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" = "Nouzový výstup";
+"lightning__spark_exit__nav_title" = "Nouzový výstup";
+"lightning__spark_exit__intro_title" = "Opusťte Spark\non-chain";
+"lightning__spark_exit__intro_text" = "Použijte to jen tehdy, když vám Spark přestane fungovat. Přesune váš zůstatek k utrácení do úspor bez operátorů Spark.\n\nTrvá to až 4 týdny, stojí to on-chain poplatky placené z úspor a velmi malé částky mohou zůstat. Běží to na pozadí a Bitkit vás upozorní, pokud vás bude potřebovat.";
+"lightning__spark_exit__quote_title" = "Nabídka\nvýstupu";
+"lightning__spark_exit__amount" = "Částka k výstupu";
+"lightning__spark_exit__fees" = "Síťové poplatky";
+"lightning__spark_exit__receive" = "Obdržíte přibližně";
+"lightning__spark_exit__savings_fee" = "Poplatek za platbu z úspor";
+"lightning__spark_exit__savings_refused" = "Vaše platba z úspor neprošla. Zaplaťte znovu, nebo financujte z jiné peněženky.";
+"lightning__spark_exit__savings_total" = "Celkem z úspor";
+"lightning__spark_exit__funding" = "Potřebné krytí poplatků";
+"lightning__spark_exit__left_behind" = "Zůstává na Spark";
+"lightning__spark_exit__outside_exit" = "{amount} na Spark není součástí tohoto výstupu a zůstává na Spark.";
+"lightning__spark_exit__quote_loading" = "Získáváme nabídku…";
+"lightning__spark_exit__quote_settling" = "Čekáme, až Spark dokončí přesun vašeho zůstatku. Může to trvat až 5 minut.";
+"lightning__spark_exit__quote_error" = "Nabídku výstupu se nepodařilo získat. Zkuste to znovu.";
+"lightning__spark_exit__nothing" = "Momentálně nelze nic vyvést.";
+"lightning__spark_exit__uneconomic" = "Poplatky jsou vyšší než částka, kterou byste obdrželi.";
+"lightning__spark_exit__funding_title" = "Uhraďte\npoplatky";
+"lightning__spark_exit__funding_text" = "Výstup platí své poplatky ze samostatné on-chain platby ve výši {amount}. Co neutratí, se na konci vrátí do úspor.";
+"lightning__spark_exit__fund_savings" = "Zaplatit z úspor";
+"lightning__spark_exit__fund_external" = "Uhradit z jiné peněženky";
+"lightning__spark_exit__fund_external_text" = "Pošlete na tuto adresu alespoň {amount}. Výstup začne, jakmile se platba potvrdí.";
+"lightning__spark_exit__fund_insufficient" = "Vaše úspory nepokryjí krytí poplatků.";
+"lightning__spark_exit__fund_seen" = "Platba zaznamenána.";
+"lightning__spark_exit__fund_partial" = "Přijato {received} z {required}, pošlete ještě {missing}.";
+"lightning__spark_exit__confirm_title" = "Zahájit\nvýstup?";
+"lightning__spark_exit__confirm_text" = "Tyto prostředky nebudete moci používat až 4 týdny.";
+"lightning__spark_exit__swipe" = "Přejeďte pro výstup";
+"lightning__spark_exit__progress_title" = "Výstup\nprobíhá";
+"lightning__spark_exit__progress_text" = "Běží to na pozadí. Bitkit vás upozorní, pokud vás bude potřebovat.";
+"lightning__spark_exit__step_funding" = "Potvrzení krytí poplatků";
+"lightning__spark_exit__step_publish" = "Zveřejňování výstupních transakcí";
+"lightning__spark_exit__step_timelock" = "Čekání na časový zámek";
+"lightning__spark_exit__step_sweep" = "Odesílání do úspor";
+"lightning__spark_exit__ready_in" = "Připraveno za {duration}";
+"lightning__spark_exit__done_title" = "Výstup\ndokončen";
+"lightning__spark_exit__done_text" = "Vaše prostředky jsou v úsporách.";
+"lightning__spark_exit__partial_text" = "Malé částky ({count}) zůstaly na Spark.";
+"lightning__spark_exit__failed_leaves" = "{amount} přesunuli operátoři Spark na blockchain a není na Spark. Operátoři vám ji stále mohou uvolnit.";
+"lightning__spark_exit__leftover" = "{amount} z krytí poplatků je stále na adrese {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} zbývajícího krytí poplatků bylo odesláno do úspor.";
+"lightning__spark_exit__leftover_sending" = "Odesíláme {amount} zbývajícího krytí poplatků do úspor.";
+"lightning__spark_exit__leftover_dust" = "{amount} zbývajícího krytí poplatků je příliš málo k odeslání a zůstává na adrese {address}, derivační cesta {path}.";
+"lightning__spark_exit__fund_paid" = "Zaplaceno z úspor, čeká se na potvrzení.";
+"lightning__spark_exit__fund_pending" = "Platba zaznamenána, čeká se na potvrzení.";
+"lightning__spark_exit__lost_leaves" = "{amount} si on-chain nárokoval dřívější vlastník těchto prostředků a nelze to získat zpět.";
+"lightning__spark_exit__notification_lost" = "{amount} z vašeho výstupu nelze získat zpět. Otevřete Bitkit a zjistěte proč.";
+"lightning__spark_exit__notification_failed" = "Váš nouzový výstup se zastavil. Otevřete Bitkit a zjistěte proč.";
+"lightning__spark_exit__quote_outside" = "{amount} z vašeho zůstatku Spark nyní nelze vyvést: částky příliš malé na zaplacení vlastních poplatků nebo se stále přesouvají uvnitř Spark. Zůstává to na Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} nelze při této sazbě poplatku vyvést a zůstává na Spark.";
+"lightning__spark_exit__needs_funding_title" = "Potřeba více\npoplatků";
+"lightning__spark_exit__needs_funding_text" = "Výstup potřebuje na poplatky ještě {amount}. Pro pokračování je uhraďte.";
+"lightning__spark_exit__failed_title" = "Výstup\nzastaven";
+"lightning__spark_exit__cancel" = "Zrušit výstup";
+"lightning__spark_exit__failed_text" = "Nic se nepodařilo získat zpět.";
+"lightning__spark_exit__failed_stays" = "{amount} zůstává na Spark.";
+"lightning__spark_exit__notification_title" = "Nouzový výstup";
+"lightning__spark_exit__notification_body" = "Otevřete Bitkit a pokračujte ve výstupu.";
+"lightning__spark_exit__notification_funding" = "Váš výstup potřebuje k pokračování více poplatků. Otevřete Bitkit a uhraďte je.";
+"lightning__spark_exit__bg_refresh_off" = "Aktualizace aplikací na pozadí je pro Bitkit vypnutá, takže výstup postupuje jen při otevřeném Bitkitu. Zapněte ji v Nastavení, aby mohl běžet na pozadí.";
+"lightning__spark_exit__low_power" = "Režim nízké spotřeby pozastavuje práci na pozadí. Výstup bude pokračovat po jeho vypnutí nebo po otevření Bitkitu.";
+"lightning__spark_exit__notifications_off" = "Oznámení jsou vypnutá, takže vám Bitkit nemůže dát vědět, když vás výstup potřebuje. Zapněte je v Nastavení, abyste dostávali připomenutí.";
+"lightning__spark_exit__not_moving" = "Výstup nepostupuje: {error}";
+"lightning__spark_exit__fee_low" = "Síť je vytíženější, než odpovídá sazbě poplatku tohoto výstupu {rate} sat/vB. Zaplaťte vyšší poplatek, nebo počkejte, až poplatky klesnou.";
+"lightning__spark_exit__waiting_leaves" = "Část tohoto výstupu teď nelze na blockchainu načíst. Bitkit to bude zkoušet znovu až týden.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit nedokázal načíst uložený postup tohoto výstupu, proto ho obnovuje z blockchainu.";
+"lightning__spark_exit__done_received" = "{amount} dorazilo do vašich úspor.";
+"lightning__spark_exit__leftover_rejected" = "{amount} zbývajícího krytí poplatků se nepodařilo odeslat a zůstává na adrese {address}, derivační cesta {path}.";
+"lightning__spark_exit__notification_not_moving" = "Váš nouzový výstup nepostupuje. Otevřete Bitkit a zjistěte proč.";
+"lightning__spark_exit__funding_split" = "Bitkit ji rozdělí na {count} částí, jednu pro každý krok, aby jeden zaseknutý krok nezdržel ostatní.";
+"lightning__spark_exit__waiting_operators" = "{amount}, které operátoři Spark přesunuli na blockchain, čeká, až je uvolní. Bitkit se bude ptát znovu až týden.";
+"lightning__spark_exit__fee_rose" = "Síťové poplatky vzrostly nad poplatek tohoto výstupu. Může se zpomalit.";
+"lightning__spark_exit__fee_bump_button" = "Zaplatit vyšší poplatek";
+"lightning__spark_exit__fee_bump_title" = "Zaplatit\nvyšší poplatek";
+"lightning__spark_exit__fee_bump_text" = "Tento výstup platí {rate} sat/vB, méně, než síť nyní požaduje. Zvolte vyšší sazbu a Bitkit čekající kroky odešle znovu. Rozdíl zaplatí z úspor: částku uvedenou u každé rychlosti.";
+"lightning__spark_exit__fee_bump_error" = "Cenu vyššího poplatku se nepodařilo zjistit. Zkuste to znovu.";
+"lightning__spark_exit__fee_bump_confirm" = "Použít tento poplatek";
+"lightning__spark_exit__leftover_waiting" = "{amount} vrácených poplatků dorazí do vašich úspor, až síťové poplatky klesnou.";
+"lightning__spark_exit__cancelled_title" = "Výstup\nzrušen";
+"lightning__spark_exit__cancelled_text" = "Výstup jste zrušili. Krytí poplatků, které dorazilo, se vrací do úspor.";
+"lightning__spark_exit__cancel_failed" = "Adresu pro krytí poplatků se nepodařilo zkontrolovat. Zkuste to znovu.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Vyšší poplatek by část tohoto výstupu ponechal na Spark, proto žádný není nabízen. Výstup se potvrdí, až síťové poplatky klesnou.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings
index ffaf43a73..9235bbcd5 100644
--- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings
@@ -370,6 +370,90 @@
"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" = "Notausstieg";
+"lightning__spark_exit__nav_title" = "Notausstieg";
+"lightning__spark_exit__intro_title" = "Spark verlassen\non-chain";
+"lightning__spark_exit__intro_text" = "Nutze dies nur, wenn Spark für dich nicht mehr funktioniert. Es verschiebt dein Ausgabenguthaben ins Sparkonto, ohne die Spark-Betreiber.\n\nEs dauert bis zu 4 Wochen, kostet On-Chain-Gebühren aus dem Sparkonto, und sehr kleine Beträge können zurückbleiben. Es läuft im Hintergrund, und Bitkit benachrichtigt dich, wenn es dich braucht.";
+"lightning__spark_exit__quote_title" = "Ausstiegs-\nangebot";
+"lightning__spark_exit__amount" = "Auszahlungsbetrag";
+"lightning__spark_exit__fees" = "Netzwerkgebühren";
+"lightning__spark_exit__receive" = "Du erhältst etwa";
+"lightning__spark_exit__savings_fee" = "Gebühr für Zahlung aus dem Sparkonto";
+"lightning__spark_exit__savings_refused" = "Deine Zahlung vom Sparkonto ist nicht durchgegangen. Zahle erneut oder finanziere aus einer anderen Wallet.";
+"lightning__spark_exit__savings_total" = "Gesamt aus dem Sparkonto";
+"lightning__spark_exit__funding" = "Benötigte Gebührendeckung";
+"lightning__spark_exit__left_behind" = "Bleibt auf Spark";
+"lightning__spark_exit__outside_exit" = "{amount} auf Spark sind nicht Teil dieses Ausstiegs und bleiben auf Spark.";
+"lightning__spark_exit__quote_loading" = "Angebot wird abgerufen…";
+"lightning__spark_exit__quote_settling" = "Warte, bis Spark dein Guthaben fertig verschoben hat. Das kann bis zu 5 Minuten dauern.";
+"lightning__spark_exit__quote_error" = "Ausstiegsangebot konnte nicht abgerufen werden. Versuche es erneut.";
+"lightning__spark_exit__nothing" = "Derzeit kann nichts ausgezahlt werden.";
+"lightning__spark_exit__uneconomic" = "Die Gebühren sind höher als der Betrag, den du erhalten würdest.";
+"lightning__spark_exit__funding_title" = "Gebühren\ndecken";
+"lightning__spark_exit__funding_text" = "Der Ausstieg zahlt seine Gebühren aus einer separaten On-Chain-Zahlung von {amount}. Was nicht verbraucht wird, kommt am Ende zurück ins Sparkonto.";
+"lightning__spark_exit__fund_savings" = "Aus dem Sparkonto zahlen";
+"lightning__spark_exit__fund_external" = "Aus einer anderen Wallet zahlen";
+"lightning__spark_exit__fund_external_text" = "Sende mindestens {amount} an diese Adresse. Der Ausstieg startet, sobald die Zahlung bestätigt ist.";
+"lightning__spark_exit__fund_insufficient" = "Dein Sparkonto reicht nicht für die Gebührendeckung.";
+"lightning__spark_exit__fund_seen" = "Zahlung erkannt.";
+"lightning__spark_exit__fund_partial" = "{received} von {required} erhalten, sende noch {missing}.";
+"lightning__spark_exit__confirm_title" = "Ausstieg\nstarten?";
+"lightning__spark_exit__confirm_text" = "Du kannst diese Mittel bis zu 4 Wochen lang nicht nutzen.";
+"lightning__spark_exit__swipe" = "Swipe zum Aussteigen";
+"lightning__spark_exit__progress_title" = "Ausstieg\nläuft";
+"lightning__spark_exit__progress_text" = "Dies läuft im Hintergrund. Bitkit benachrichtigt dich, wenn es dich braucht.";
+"lightning__spark_exit__step_funding" = "Gebührendeckung wird bestätigt";
+"lightning__spark_exit__step_publish" = "Ausstiegstransaktionen werden veröffentlicht";
+"lightning__spark_exit__step_timelock" = "Warten auf den Timelock";
+"lightning__spark_exit__step_sweep" = "Senden ans Sparkonto";
+"lightning__spark_exit__ready_in" = "Bereit in {duration}";
+"lightning__spark_exit__done_title" = "Ausstieg\nabgeschlossen";
+"lightning__spark_exit__done_text" = "Deine Mittel sind im Sparkonto.";
+"lightning__spark_exit__partial_text" = "{count} kleine Beträge sind auf Spark geblieben.";
+"lightning__spark_exit__failed_leaves" = "{amount} wurden von den Spark-Betreibern on-chain verschoben und sind nicht auf Spark. Die Betreiber können sie dir weiterhin freigeben.";
+"lightning__spark_exit__leftover" = "{amount} der Gebührendeckung liegen noch auf {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} übrige Gebührendeckung wurden ans Sparkonto gesendet.";
+"lightning__spark_exit__leftover_sending" = "{amount} übrige Gebührendeckung werden ans Sparkonto gesendet.";
+"lightning__spark_exit__leftover_dust" = "{amount} übrige Gebührendeckung sind zu klein zum Senden und bleiben auf {address}, Ableitungspfad {path}.";
+"lightning__spark_exit__fund_paid" = "Aus dem Sparkonto bezahlt, warte auf Bestätigung.";
+"lightning__spark_exit__fund_pending" = "Zahlung erkannt, warte auf Bestätigung.";
+"lightning__spark_exit__lost_leaves" = "{amount} wurden von einem früheren Besitzer dieser Mittel on-chain beansprucht und können nicht wiederhergestellt werden.";
+"lightning__spark_exit__notification_lost" = "{amount} deines Ausstiegs können nicht wiederhergestellt werden. Öffne Bitkit, um den Grund zu sehen.";
+"lightning__spark_exit__notification_failed" = "Dein Notausstieg wurde gestoppt. Öffne Bitkit, um den Grund zu sehen.";
+"lightning__spark_exit__quote_outside" = "{amount} deines Spark-Guthabens können derzeit nicht ausgezahlt werden: Beträge, die zu klein sind, um ihre eigenen Gebühren zu zahlen, oder die sich noch innerhalb von Spark bewegen. Sie bleiben auf Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} können bei diesem Gebührensatz nicht ausgezahlt werden und bleiben auf Spark.";
+"lightning__spark_exit__needs_funding_title" = "Mehr Gebühren\nnötig";
+"lightning__spark_exit__needs_funding_text" = "Der Ausstieg braucht {amount} mehr, um seine Gebühren zu zahlen. Decke sie, um fortzufahren.";
+"lightning__spark_exit__failed_title" = "Ausstieg\ngestoppt";
+"lightning__spark_exit__cancel" = "Ausstieg abbrechen";
+"lightning__spark_exit__failed_text" = "Es konnte nichts wiederhergestellt werden.";
+"lightning__spark_exit__failed_stays" = "{amount} bleiben auf Spark.";
+"lightning__spark_exit__notification_title" = "Notausstieg";
+"lightning__spark_exit__notification_body" = "Öffne Bitkit, um deinen Ausstieg fortzusetzen.";
+"lightning__spark_exit__notification_funding" = "Dein Ausstieg braucht mehr Gebühren, um fortzufahren. Öffne Bitkit, um sie zu decken.";
+"lightning__spark_exit__bg_refresh_off" = "Hintergrundaktualisierung ist für Bitkit deaktiviert, daher läuft der Ausstieg nur, solange Bitkit geöffnet ist. Aktiviere sie in den Einstellungen, damit er im Hintergrund laufen kann.";
+"lightning__spark_exit__low_power" = "Der Stromsparmodus pausiert Hintergrundaktivität. Der Ausstieg läuft weiter, sobald er aus ist oder wenn du Bitkit öffnest.";
+"lightning__spark_exit__notifications_off" = "Benachrichtigungen sind deaktiviert, daher kann Bitkit dir nicht sagen, wenn der Ausstieg dich braucht. Aktiviere sie in den Einstellungen, um erinnert zu werden.";
+"lightning__spark_exit__not_moving" = "Der Ausstieg kommt nicht voran: {error}";
+"lightning__spark_exit__fee_low" = "Das Netzwerk ist stärker ausgelastet als der Gebührensatz dieses Ausstiegs von {rate} sat/vB. Zahle eine höhere Gebühr oder warte, bis die Gebühren sinken.";
+"lightning__spark_exit__waiting_leaves" = "Ein Teil dieses Ausstiegs kann derzeit nicht on-chain gelesen werden. Bitkit versucht es bis zu einer Woche lang erneut.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit konnte den gespeicherten Fortschritt dieses Ausstiegs nicht lesen und stellt ihn daher aus der Blockchain wieder her.";
+"lightning__spark_exit__done_received" = "{amount} sind in deinem Sparkonto angekommen.";
+"lightning__spark_exit__leftover_rejected" = "{amount} übrige Gebührendeckung konnten nicht gesendet werden und bleiben auf {address}, Ableitungspfad {path}.";
+"lightning__spark_exit__notification_not_moving" = "Dein Notausstieg kommt nicht voran. Öffne Bitkit, um den Grund zu sehen.";
+"lightning__spark_exit__funding_split" = "Bitkit teilt sie in {count} Teile auf, einen pro Schritt, damit ein hängender Schritt die anderen nicht aufhält.";
+"lightning__spark_exit__waiting_operators" = "{amount}, die die Spark-Betreiber on-chain verschoben haben, warten darauf, dass sie sie freigeben. Bitkit fragt bis zu einer Woche lang erneut nach.";
+"lightning__spark_exit__fee_rose" = "Die Netzwerkgebühren sind über die Gebühr dieses Ausstiegs gestiegen. Er kann sich verlangsamen.";
+"lightning__spark_exit__fee_bump_button" = "Höhere Gebühr zahlen";
+"lightning__spark_exit__fee_bump_title" = "Höhere Gebühr\nzahlen";
+"lightning__spark_exit__fee_bump_text" = "Dieser Ausstieg zahlt {rate} sat/vB, weniger als das Netzwerk derzeit verlangt. Wähle einen höheren Satz, und Bitkit sendet die wartenden Schritte erneut. Die Differenz wird aus dem Sparkonto bezahlt: der Betrag neben jeder Geschwindigkeit.";
+"lightning__spark_exit__fee_bump_error" = "Höhere Gebühr konnte nicht berechnet werden. Versuche es erneut.";
+"lightning__spark_exit__fee_bump_confirm" = "Diese Gebühr nutzen";
+"lightning__spark_exit__leftover_waiting" = "{amount} Gebührenwechselgeld erreichen dein Sparkonto, wenn die Netzwerkgebühren sinken.";
+"lightning__spark_exit__cancelled_title" = "Ausstieg\nabgebrochen";
+"lightning__spark_exit__cancelled_text" = "Du hast den Ausstieg abgebrochen. Die eingegangene Gebührendeckung geht zurück ins Sparkonto.";
+"lightning__spark_exit__cancel_failed" = "Die Adresse für die Gebührendeckung konnte nicht geprüft werden. Versuche es erneut.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Eine höhere Gebühr würde einen Teil dieses Ausstiegs auf Spark lassen, daher wird keine angeboten. Der Ausstieg wird bestätigt, sobald die Netzwerkgebühren sinken.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings
index b2ba14fd9..7bc5feb2f 100644
--- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings
@@ -343,6 +343,90 @@
"lightning__spark_provider_value" = "Spark μέσω Breez";
"lightning__spark_receiving_unlimited" = "Χωρίς όριο";
"lightning__spark_trust" = "Αυτό το υπόλοιπο φυλάσσεται στο Spark, που λειτουργούν οι Lightspark, Breez και Flashnet. Είναι ασφαλές όσο τουλάχιστον ένας από αυτούς ενεργεί έντιμα. Μπορούν να αναστείλουν την πρόσβαση, και η έξοδος χωρίς αυτούς απαιτεί έξοδο on-chain.";
+"lightning__spark_exit__button" = "Έξοδος έκτακτης ανάγκης";
+"lightning__spark_exit__nav_title" = "Έξοδος Έκτακτης Ανάγκης";
+"lightning__spark_exit__intro_title" = "Αποχώρηση από το Spark\non-chain";
+"lightning__spark_exit__intro_text" = "Χρησιμοποιήστε το μόνο αν το Spark σταματήσει να λειτουργεί για εσάς. Μεταφέρει το υπόλοιπο δαπανών σας στις αποταμιεύσεις χωρίς τους διαχειριστές του Spark.\n\nΔιαρκεί έως 4 εβδομάδες, κοστίζει τέλη on-chain που πληρώνονται από τις αποταμιεύσεις, και πολύ μικρά ποσά μπορεί να μείνουν πίσω. Εκτελείται στο παρασκήνιο και το Bitkit σας ειδοποιεί αν σας χρειαστεί.";
+"lightning__spark_exit__quote_title" = "Προσφορά\nεξόδου";
+"lightning__spark_exit__amount" = "Ποσό εξόδου";
+"lightning__spark_exit__fees" = "Τέλη δικτύου";
+"lightning__spark_exit__receive" = "Λαμβάνετε περίπου";
+"lightning__spark_exit__savings_fee" = "Τέλος πληρωμής από αποταμιεύσεις";
+"lightning__spark_exit__savings_refused" = "Η πληρωμή σας από αποταμιεύσεις δεν ολοκληρώθηκε. Πληρώστε ξανά ή χρηματοδοτήστε από άλλο πορτοφόλι.";
+"lightning__spark_exit__savings_total" = "Σύνολο από αποταμιεύσεις";
+"lightning__spark_exit__funding" = "Απαιτούμενη χρηματοδότηση τελών";
+"lightning__spark_exit__left_behind" = "Παραμένει στο Spark";
+"lightning__spark_exit__outside_exit" = "{amount} στο Spark δεν περιλαμβάνεται σε αυτή την έξοδο και παραμένει στο Spark.";
+"lightning__spark_exit__quote_loading" = "Λήψη προσφοράς…";
+"lightning__spark_exit__quote_settling" = "Αναμονή για να ολοκληρώσει το Spark τη μεταφορά του υπολοίπου σας. Αυτό μπορεί να διαρκέσει έως 5 λεπτά.";
+"lightning__spark_exit__quote_error" = "Δεν ήταν δυνατή η λήψη προσφοράς εξόδου. Δοκιμάστε ξανά.";
+"lightning__spark_exit__nothing" = "Δεν υπάρχει τίποτα για έξοδο αυτή τη στιγμή.";
+"lightning__spark_exit__uneconomic" = "Τα τέλη είναι υψηλότερα από όσα θα λαμβάνατε.";
+"lightning__spark_exit__funding_title" = "Χρηματοδοτήστε\nτα τέλη";
+"lightning__spark_exit__funding_text" = "Η έξοδος πληρώνει τα τέλη της από ξεχωριστή πληρωμή on-chain ύψους {amount}. Ό,τι δεν ξοδευτεί επιστρέφει στις αποταμιεύσεις στο τέλος.";
+"lightning__spark_exit__fund_savings" = "Πληρωμή από αποταμιεύσεις";
+"lightning__spark_exit__fund_external" = "Χρηματοδότηση από άλλο πορτοφόλι";
+"lightning__spark_exit__fund_external_text" = "Στείλτε τουλάχιστον {amount} σε αυτή τη διεύθυνση. Η έξοδος ξεκινά μόλις επιβεβαιωθεί.";
+"lightning__spark_exit__fund_insufficient" = "Οι αποταμιεύσεις σας δεν επαρκούν για τη χρηματοδότηση των τελών.";
+"lightning__spark_exit__fund_seen" = "Η πληρωμή εντοπίστηκε.";
+"lightning__spark_exit__fund_partial" = "Ελήφθησαν {received} από {required}, στείλτε ακόμη {missing}.";
+"lightning__spark_exit__confirm_title" = "Έναρξη\nεξόδου;";
+"lightning__spark_exit__confirm_text" = "Δεν θα μπορείτε να χρησιμοποιήσετε αυτά τα κεφάλαια για έως 4 εβδομάδες.";
+"lightning__spark_exit__swipe" = "Σύρετε για Έξοδο";
+"lightning__spark_exit__progress_title" = "Έξοδος\nσε εξέλιξη";
+"lightning__spark_exit__progress_text" = "Εκτελείται στο παρασκήνιο. Το Bitkit σας ειδοποιεί αν σας χρειαστεί.";
+"lightning__spark_exit__step_funding" = "Επιβεβαίωση χρηματοδότησης τελών";
+"lightning__spark_exit__step_publish" = "Δημοσίευση συναλλαγών εξόδου";
+"lightning__spark_exit__step_timelock" = "Αναμονή για το χρονοκλείδωμα";
+"lightning__spark_exit__step_sweep" = "Αποστολή στις αποταμιεύσεις";
+"lightning__spark_exit__ready_in" = "Έτοιμο σε {duration}";
+"lightning__spark_exit__done_title" = "Η έξοδος\nολοκληρώθηκε";
+"lightning__spark_exit__done_text" = "Τα κεφάλαιά σας βρίσκονται στις αποταμιεύσεις.";
+"lightning__spark_exit__partial_text" = "{count} μικρά ποσά παρέμειναν στο Spark.";
+"lightning__spark_exit__failed_leaves" = "{amount} μεταφέρθηκε on-chain από τους διαχειριστές του Spark και δεν βρίσκεται στο Spark. Οι διαχειριστές μπορούν ακόμη να το αποδεσμεύσουν σε εσάς.";
+"lightning__spark_exit__leftover" = "{amount} της χρηματοδότησης τελών βρίσκεται ακόμη στη διεύθυνση {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} από την υπόλοιπη χρηματοδότηση τελών στάλθηκε στις αποταμιεύσεις.";
+"lightning__spark_exit__leftover_sending" = "Αποστολή {amount} από την υπόλοιπη χρηματοδότηση τελών στις αποταμιεύσεις.";
+"lightning__spark_exit__leftover_dust" = "{amount} από την υπόλοιπη χρηματοδότηση τελών είναι πολύ μικρό για αποστολή και παραμένει στη διεύθυνση {address}, διαδρομή παραγωγής {path}.";
+"lightning__spark_exit__fund_paid" = "Πληρώθηκε από τις αποταμιεύσεις, αναμονή επιβεβαίωσης.";
+"lightning__spark_exit__fund_pending" = "Η πληρωμή εντοπίστηκε, αναμονή επιβεβαίωσης.";
+"lightning__spark_exit__lost_leaves" = "{amount} διεκδικήθηκε on-chain από προηγούμενο κάτοχο αυτών των κεφαλαίων και δεν μπορεί να ανακτηθεί.";
+"lightning__spark_exit__notification_lost" = "{amount} της εξόδου σας δεν μπορεί να ανακτηθεί. Ανοίξτε το Bitkit για να δείτε γιατί.";
+"lightning__spark_exit__notification_failed" = "Η έξοδος έκτακτης ανάγκης σταμάτησε. Ανοίξτε το Bitkit για να δείτε γιατί.";
+"lightning__spark_exit__quote_outside" = "{amount} του υπολοίπου Spark σας δεν μπορεί να εξέλθει αυτή τη στιγμή: ποσά πολύ μικρά για να πληρώσουν τα δικά τους τέλη, ή που ακόμη μετακινούνται μέσα στο Spark. Παραμένει στο Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} δεν μπορεί να εξέλθει με αυτόν τον ρυθμό τελών και παραμένει στο Spark.";
+"lightning__spark_exit__needs_funding_title" = "Απαιτούνται\nεπιπλέον τέλη";
+"lightning__spark_exit__needs_funding_text" = "Η έξοδος χρειάζεται ακόμη {amount} για να πληρώσει τα τέλη της. Χρηματοδοτήστε τη για να συνεχίσει.";
+"lightning__spark_exit__failed_title" = "Η έξοδος\nσταμάτησε";
+"lightning__spark_exit__cancel" = "Ακύρωση εξόδου";
+"lightning__spark_exit__failed_text" = "Δεν ήταν δυνατή η ανάκτηση κανενός ποσού.";
+"lightning__spark_exit__failed_stays" = "{amount} παραμένει στο Spark.";
+"lightning__spark_exit__notification_title" = "Έξοδος έκτακτης ανάγκης";
+"lightning__spark_exit__notification_body" = "Ανοίξτε το Bitkit για να συνεχίσετε την έξοδό σας.";
+"lightning__spark_exit__notification_funding" = "Η έξοδός σας χρειάζεται επιπλέον τέλη για να συνεχίσει. Ανοίξτε το Bitkit για να τη χρηματοδοτήσετε.";
+"lightning__spark_exit__bg_refresh_off" = "Η Ανανέωση εφαρμογών στο παρασκήνιο είναι απενεργοποιημένη για το Bitkit, οπότε η έξοδος προχωρά μόνο όσο το Bitkit είναι ανοιχτό. Ενεργοποιήστε τη στις Ρυθμίσεις για να εκτελείται στο παρασκήνιο.";
+"lightning__spark_exit__low_power" = "Η Λειτουργία χαμηλής κατανάλωσης διακόπτει την εργασία στο παρασκήνιο. Η έξοδος συνεχίζεται μόλις απενεργοποιηθεί, ή όταν ανοίξετε το Bitkit.";
+"lightning__spark_exit__notifications_off" = "Οι ειδοποιήσεις είναι απενεργοποιημένες, οπότε το Bitkit δεν μπορεί να σας ενημερώσει όταν η έξοδος σας χρειάζεται. Ενεργοποιήστε τις στις Ρυθμίσεις για να λαμβάνετε υπενθυμίσεις.";
+"lightning__spark_exit__not_moving" = "Η έξοδος δεν προχωρά: {error}";
+"lightning__spark_exit__fee_low" = "Το δίκτυο είναι πιο φορτωμένο από τον ρυθμό τελών αυτής της εξόδου, {rate} sat/vB. Πληρώστε υψηλότερο τέλος ή περιμένετε να μειωθούν τα τέλη.";
+"lightning__spark_exit__waiting_leaves" = "Μέρος αυτής της εξόδου δεν μπορεί να διαβαστεί on-chain αυτή τη στιγμή. Το Bitkit προσπαθεί ξανά για έως μία εβδομάδα.";
+"lightning__spark_exit__rebuilt_from_chain" = "Το Bitkit δεν μπόρεσε να διαβάσει την αποθηκευμένη πρόοδο αυτής της εξόδου, οπότε την ανακατασκευάζει από την αλυσίδα.";
+"lightning__spark_exit__done_received" = "{amount} έφτασε στις αποταμιεύσεις σας.";
+"lightning__spark_exit__leftover_rejected" = "{amount} από την υπόλοιπη χρηματοδότηση τελών δεν ήταν δυνατό να σταλεί και παραμένει στη διεύθυνση {address}, διαδρομή παραγωγής {path}.";
+"lightning__spark_exit__notification_not_moving" = "Η έξοδος έκτακτης ανάγκης δεν προχωρά. Ανοίξτε το Bitkit για να δείτε γιατί.";
+"lightning__spark_exit__funding_split" = "Το Bitkit τη χωρίζει σε {count} μέρη, ένα για κάθε βήμα, ώστε ένα κολλημένο βήμα να μην καθυστερεί τα υπόλοιπα.";
+"lightning__spark_exit__waiting_operators" = "{amount} που μετέφεραν on-chain οι διαχειριστές του Spark περιμένει να το αποδεσμεύσουν. Το Bitkit ζητά ξανά για έως μία εβδομάδα.";
+"lightning__spark_exit__fee_rose" = "Τα τέλη δικτύου αυξήθηκαν πάνω από το τέλος αυτής της εξόδου. Μπορεί να καθυστερήσει.";
+"lightning__spark_exit__fee_bump_button" = "Πληρωμή υψηλότερου τέλους";
+"lightning__spark_exit__fee_bump_title" = "Πληρώστε\nυψηλότερο τέλος";
+"lightning__spark_exit__fee_bump_text" = "Αυτή η έξοδος πληρώνει {rate} sat/vB, λιγότερο από όσο ζητά τώρα το δίκτυο. Επιλέξτε υψηλότερο ρυθμό και το Bitkit στέλνει ξανά τα βήματα σε αναμονή. Πληρώνει τη διαφορά από τις αποταμιεύσεις: το ποσό δίπλα σε κάθε ταχύτητα.";
+"lightning__spark_exit__fee_bump_error" = "Δεν ήταν δυνατός ο υπολογισμός υψηλότερου τέλους. Δοκιμάστε ξανά.";
+"lightning__spark_exit__fee_bump_confirm" = "Χρήση αυτού του τέλους";
+"lightning__spark_exit__leftover_waiting" = "{amount} ρέστα τελών θα φτάσουν στις αποταμιεύσεις σας όταν μειωθούν τα τέλη δικτύου.";
+"lightning__spark_exit__cancelled_title" = "Η έξοδος\nακυρώθηκε";
+"lightning__spark_exit__cancelled_text" = "Ακυρώσατε την έξοδο. Η χρηματοδότηση τελών που έφτασε επιστρέφει στις αποταμιεύσεις.";
+"lightning__spark_exit__cancel_failed" = "Δεν ήταν δυνατός ο έλεγχος της διεύθυνσης χρηματοδότησης. Δοκιμάστε ξανά.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Ένα υψηλότερο τέλος θα άφηνε μέρος αυτής της εξόδου στο Spark, οπότε δεν προσφέρεται κανένα. Η έξοδος επιβεβαιώνεται μόλις μειωθούν τα τέλη δικτύου.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 0705f67fc..463913ad8 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -367,6 +367,96 @@
"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__savings_fee" = "Savings payment fee";
+"lightning__spark_exit__savings_refused" = "Your savings payment didn't go through. Pay again, or fund from another wallet.";
+"lightning__spark_exit__savings_total" = "Total from savings";
+"lightning__spark_exit__funding" = "Fee funding needed";
+"lightning__spark_exit__left_behind" = "Stays on Spark";
+"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" = "The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.";
+"lightning__spark_exit__step_funding" = "Fee funding confirms";
+"lightning__spark_exit__step_publish" = "Publishing exit transactions";
+"lightning__spark_exit__step_timelock" = "Waiting for the timelock";
+"lightning__spark_exit__step_sweep" = "Sending to savings";
+"lightning__spark_exit__ready_in" = "Ready in {duration}";
+"lightning__spark_exit__done_title" = "Exit\ncomplete";
+"lightning__spark_exit__done_text" = "Your funds are in savings.";
+"lightning__spark_exit__partial_text" = "{count} small amounts stayed on Spark.";
+"lightning__spark_exit__failed_leaves" = "{amount} was moved on-chain by the Spark operators and is not on Spark. The operators can still release it to you.";
+"lightning__spark_exit__leftover" = "{amount} of fee funding is still at {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} of leftover fee funding was sent to savings.";
+"lightning__spark_exit__leftover_sending" = "Sending {amount} of leftover fee funding to savings.";
+"lightning__spark_exit__earlier_addresses" = "Earlier exit addresses";
+"lightning__spark_exit__earlier_addresses_text" = "Earlier emergency exits used these funding addresses. Coins left at one stay yours: spend them with your recovery phrase and its derivation path.";
+"lightning__spark_exit__earlier_path" = "Derivation path";
+"lightning__spark_exit__leftover_dust" = "{amount} of leftover fee funding is too small to send and stays at {address}, derivation path {path}.";
+"lightning__spark_exit__fund_paid" = "Paid from savings, waiting for confirmation.";
+"lightning__spark_exit__fund_pending" = "Payment seen, waiting for confirmation.";
+"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 if the exit needs you. Without them, open Bitkit every few hours until the exit finishes.";
+"lightning__spark_exit__note_notifications_button" = "Turn on notifications";
+"lightning__spark_exit__not_moving" = "The exit is not moving: {error}";
+"lightning__spark_exit__fee_low" = "The network is busier than this exit's fee rate of {rate} sat/vB. Pay a higher fee, or wait for fees to drop.";
+"lightning__spark_exit__waiting_leaves" = "Part of this exit can't be read on-chain right now. Bitkit tries again for up to a week.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit couldn't read this exit's saved progress, so it rebuilds it from the chain.";
+"lightning__spark_exit__done_received" = "{amount} reached your savings.";
+"lightning__spark_exit__leftover_rejected" = "{amount} of leftover fee funding could not be sent and stays at {address}, derivation path {path}.";
+"lightning__spark_exit__notification_not_moving" = "Your emergency exit is not moving. Open Bitkit to see why.";
+"lightning__spark_exit__funding_split" = "Bitkit splits it into {count} parts, one for each step, so one stuck step can't hold up the others.";
+"lightning__spark_exit__waiting_operators" = "{amount} that the Spark operators moved on-chain waits for them to release it. Bitkit asks again for up to a week.";
+"lightning__spark_exit__fee_rose" = "Network fees rose above this exit's fee. It may slow down.";
+"lightning__spark_exit__fee_bump_button" = "Pay a higher fee";
+"lightning__spark_exit__fee_bump_title" = "Pay a\nhigher fee";
+"lightning__spark_exit__fee_bump_text" = "This exit pays {rate} sat/vB, less than the network asks now. Pick a higher rate and Bitkit sends the waiting steps again. It pays the difference from savings: the amount next to each speed.";
+"lightning__spark_exit__fee_bump_error" = "Could not price a higher fee. Try again.";
+"lightning__spark_exit__fee_bump_confirm" = "Use this fee";
+"lightning__spark_exit__leftover_waiting" = "{amount} of fee change will reach your savings when network fees drop.";
+"lightning__spark_exit__cancelled_title" = "Exit\ncancelled";
+"lightning__spark_exit__cancelled_text" = "You cancelled the exit. The fee funding that arrived goes back to savings.";
+"lightning__spark_exit__cancel_failed" = "Couldn't check the funding address. Try again.";
+"lightning__spark_exit__cancel_text" = "Spark goes back to how it was. If a payment reached the exit's fee funding address, Bitkit sends it back to savings.";
+"lightning__spark_exit__cancel_title" = "Cancel exit?";
+"lightning__spark_exit__fee_bump_leaves_behind" = "A higher fee would leave part of this exit on Spark, so none is offered. The exit confirms once network fees drop.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"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 614b579a7..4366b1a2a 100644
--- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings
@@ -373,6 +373,90 @@
"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" = "Salida de emergencia";
+"lightning__spark_exit__nav_title" = "Salida de emergencia";
+"lightning__spark_exit__intro_title" = "Sal de Spark\non-chain";
+"lightning__spark_exit__intro_text" = "Usa esto solo si Spark deja de funcionarte. Mueve tu saldo de gastos a ahorros sin los operadores de Spark.\n\nTarda hasta 4 semanas, cuesta comisiones on-chain que se pagan desde ahorros, y las cantidades muy pequeñas pueden quedarse atrás. Se ejecuta en segundo plano y Bitkit te avisa si te necesita.";
+"lightning__spark_exit__quote_title" = "Cotización\nde salida";
+"lightning__spark_exit__amount" = "Cantidad a retirar";
+"lightning__spark_exit__fees" = "Comisiones de red";
+"lightning__spark_exit__receive" = "Recibes aproximadamente";
+"lightning__spark_exit__savings_fee" = "Comisión del pago desde ahorros";
+"lightning__spark_exit__savings_refused" = "Tu pago desde ahorros no se completó. Paga de nuevo o financia desde otra billetera.";
+"lightning__spark_exit__savings_total" = "Total desde ahorros";
+"lightning__spark_exit__funding" = "Fondos para comisiones necesarios";
+"lightning__spark_exit__left_behind" = "Se queda en Spark";
+"lightning__spark_exit__outside_exit" = "{amount} en Spark no forma parte de esta salida y se queda en Spark.";
+"lightning__spark_exit__quote_loading" = "Obteniendo una cotización…";
+"lightning__spark_exit__quote_settling" = "Esperando a que Spark termine de mover tu saldo. Esto puede tardar hasta 5 minutos.";
+"lightning__spark_exit__quote_error" = "No se pudo obtener una cotización de salida. Vuelve a intentarlo.";
+"lightning__spark_exit__nothing" = "En este momento no se puede retirar nada.";
+"lightning__spark_exit__uneconomic" = "Las comisiones son más altas que lo que recibirías.";
+"lightning__spark_exit__funding_title" = "Financia las\ncomisiones";
+"lightning__spark_exit__funding_text" = "La salida paga sus comisiones con un pago on-chain aparte de {amount}. Lo que no gaste vuelve a ahorros al final.";
+"lightning__spark_exit__fund_savings" = "Pagar desde ahorros";
+"lightning__spark_exit__fund_external" = "Financiar desde otro billetera";
+"lightning__spark_exit__fund_external_text" = "Envía al menos {amount} a esta dirección. La salida empieza en cuanto se confirme.";
+"lightning__spark_exit__fund_insufficient" = "Tus ahorros no alcanzan para financiar las comisiones.";
+"lightning__spark_exit__fund_seen" = "Pago detectado.";
+"lightning__spark_exit__fund_partial" = "Recibidos {received} de {required}, envía {missing} más.";
+"lightning__spark_exit__confirm_title" = "¿Iniciar\nla salida?";
+"lightning__spark_exit__confirm_text" = "No podrás usar estos fondos durante hasta 4 semanas.";
+"lightning__spark_exit__swipe" = "Deslizar para salir";
+"lightning__spark_exit__progress_title" = "Salida\nen curso";
+"lightning__spark_exit__progress_text" = "Esto se ejecuta en segundo plano. Bitkit te avisa si te necesita.";
+"lightning__spark_exit__step_funding" = "Confirmación de los fondos para comisiones";
+"lightning__spark_exit__step_publish" = "Publicando las transacciones de salida";
+"lightning__spark_exit__step_timelock" = "Esperando el bloqueo temporal";
+"lightning__spark_exit__step_sweep" = "Enviando a ahorros";
+"lightning__spark_exit__ready_in" = "Listo en {duration}";
+"lightning__spark_exit__done_title" = "Salida\ncompletada";
+"lightning__spark_exit__done_text" = "Tus fondos están en ahorros.";
+"lightning__spark_exit__partial_text" = "{count} cantidades pequeñas se quedaron en Spark.";
+"lightning__spark_exit__failed_leaves" = "Los operadores de Spark movieron {amount} on-chain y ya no está en Spark. Los operadores todavía pueden liberártelo.";
+"lightning__spark_exit__leftover" = "{amount} de los fondos para comisiones sigue en {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} sobrante de los fondos para comisiones se envió a ahorros.";
+"lightning__spark_exit__leftover_sending" = "Enviando a ahorros {amount} sobrante de los fondos para comisiones.";
+"lightning__spark_exit__leftover_dust" = "{amount} sobrante de los fondos para comisiones es demasiado pequeño para enviarlo y se queda en {address}, ruta de derivación {path}.";
+"lightning__spark_exit__fund_paid" = "Pagado desde ahorros, esperando confirmación.";
+"lightning__spark_exit__fund_pending" = "Pago detectado, esperando confirmación.";
+"lightning__spark_exit__lost_leaves" = "Un propietario anterior de estos fondos reclamó {amount} on-chain y no se puede recuperar.";
+"lightning__spark_exit__notification_lost" = "{amount} de tu salida no se puede recuperar. Abre Bitkit para ver por qué.";
+"lightning__spark_exit__notification_failed" = "Tu salida de emergencia se detuvo. Abre Bitkit para ver por qué.";
+"lightning__spark_exit__quote_outside" = "{amount} de tu saldo de Spark no se puede retirar en este momento: cantidades demasiado pequeñas para pagar sus propias comisiones o que aún se están moviendo dentro de Spark. Se queda en Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} no se puede retirar con esta tasa de comisión y se queda en Spark.";
+"lightning__spark_exit__needs_funding_title" = "Se necesitan\nmás comisiones";
+"lightning__spark_exit__needs_funding_text" = "La salida necesita {amount} más para pagar sus comisiones. Agrega fondos para continuar.";
+"lightning__spark_exit__failed_title" = "Salida\ndetenida";
+"lightning__spark_exit__cancel" = "Cancelar salida";
+"lightning__spark_exit__failed_text" = "No se pudo recuperar nada.";
+"lightning__spark_exit__failed_stays" = "{amount} se queda en Spark.";
+"lightning__spark_exit__notification_title" = "Salida de emergencia";
+"lightning__spark_exit__notification_body" = "Abre Bitkit para continuar tu salida.";
+"lightning__spark_exit__notification_funding" = "Tu salida necesita más comisiones para continuar. Abre Bitkit para agregar fondos.";
+"lightning__spark_exit__bg_refresh_off" = "La Actualización en segundo plano está desactivada para Bitkit, así que la salida solo avanza mientras Bitkit está abierto. Actívala en Ajustes para que funcione en segundo plano.";
+"lightning__spark_exit__low_power" = "El Modo de bajo consumo pausa el trabajo en segundo plano. La salida continúa cuando se desactive o cuando abras Bitkit.";
+"lightning__spark_exit__notifications_off" = "Las notificaciones están desactivadas, así que Bitkit no puede avisarte cuando la salida te necesite. Actívalas en Ajustes para recibir recordatorios.";
+"lightning__spark_exit__not_moving" = "La salida no avanza: {error}";
+"lightning__spark_exit__fee_low" = "La red está más saturada que la tasa de comisión de esta salida, de {rate} sat/vB. Paga una comisión más alta o espera a que bajen las comisiones.";
+"lightning__spark_exit__waiting_leaves" = "En este momento no se puede leer on-chain una parte de esta salida. Bitkit lo vuelve a intentar durante hasta una semana.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit no pudo leer el progreso guardado de esta salida, así que lo reconstruye a partir de la cadena.";
+"lightning__spark_exit__done_received" = "{amount} llegó a tus ahorros.";
+"lightning__spark_exit__leftover_rejected" = "{amount} sobrante de los fondos para comisiones no se pudo enviar y se queda en {address}, ruta de derivación {path}.";
+"lightning__spark_exit__notification_not_moving" = "Tu salida de emergencia no avanza. Abre Bitkit para ver por qué.";
+"lightning__spark_exit__funding_split" = "Bitkit lo divide en {count} partes, una para cada paso, para que un paso atascado no retrase a los demás.";
+"lightning__spark_exit__waiting_operators" = "{amount} que los operadores de Spark movieron on-chain espera a que lo liberen. Bitkit vuelve a pedirlo durante hasta una semana.";
+"lightning__spark_exit__fee_rose" = "Las comisiones de red superan la comisión de esta salida. Puede ir más lenta.";
+"lightning__spark_exit__fee_bump_button" = "Pagar una comisión más alta";
+"lightning__spark_exit__fee_bump_title" = "Paga una\ncomisión más alta";
+"lightning__spark_exit__fee_bump_text" = "Esta salida paga {rate} sat/vB, menos de lo que pide la red ahora. Elige una tasa más alta y Bitkit vuelve a enviar los pasos pendientes. Paga la diferencia desde ahorros: la cantidad junto a cada velocidad.";
+"lightning__spark_exit__fee_bump_error" = "No se pudo calcular una comisión más alta. Vuelve a intentarlo.";
+"lightning__spark_exit__fee_bump_confirm" = "Usar esta comisión";
+"lightning__spark_exit__leftover_waiting" = "{amount} de cambio de comisiones llegará a tus ahorros cuando bajen las comisiones de red.";
+"lightning__spark_exit__cancelled_title" = "Salida\ncancelada";
+"lightning__spark_exit__cancelled_text" = "Cancelaste la salida. Los fondos para comisiones que llegaron vuelven a ahorros.";
+"lightning__spark_exit__cancel_failed" = "No se pudo verificar la dirección de financiación. Vuelve a intentarlo.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Una comisión más alta dejaría parte de esta salida en Spark, así que no se ofrece ninguna. La salida se confirma cuando bajen las comisiones de red.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings
index 0b7b7819b..b28c883d1 100644
--- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings
@@ -357,6 +357,90 @@
"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" = "Salida de emergencia";
+"lightning__spark_exit__nav_title" = "Salida de emergencia";
+"lightning__spark_exit__intro_title" = "Sal de Spark\non-chain";
+"lightning__spark_exit__intro_text" = "Usa esto solo si Spark deja de funcionarte. Mueve tu saldo de gastos a ahorros sin los operadores de Spark.\n\nTarda hasta 4 semanas, cuesta comisiones on-chain que se pagan desde ahorros, y las cantidades muy pequeñas pueden quedarse atrás. Se ejecuta en segundo plano y Bitkit te avisa si te necesita.";
+"lightning__spark_exit__quote_title" = "Presupuesto\nde salida";
+"lightning__spark_exit__amount" = "Cantidad a retirar";
+"lightning__spark_exit__fees" = "Comisiones de red";
+"lightning__spark_exit__receive" = "Recibes aproximadamente";
+"lightning__spark_exit__savings_fee" = "Comisión del pago desde ahorros";
+"lightning__spark_exit__savings_refused" = "Tu pago desde ahorros no se completó. Paga de nuevo o financia desde otro monedero.";
+"lightning__spark_exit__savings_total" = "Total desde ahorros";
+"lightning__spark_exit__funding" = "Fondos para comisiones necesarios";
+"lightning__spark_exit__left_behind" = "Se queda en Spark";
+"lightning__spark_exit__outside_exit" = "{amount} en Spark no forma parte de esta salida y se queda en Spark.";
+"lightning__spark_exit__quote_loading" = "Obteniendo un presupuesto…";
+"lightning__spark_exit__quote_settling" = "Esperando a que Spark termine de mover tu saldo. Esto puede tardar hasta 5 minutos.";
+"lightning__spark_exit__quote_error" = "No se pudo obtener un presupuesto de salida. Inténtalo de nuevo.";
+"lightning__spark_exit__nothing" = "Ahora mismo no se puede retirar nada.";
+"lightning__spark_exit__uneconomic" = "Las comisiones son más altas que lo que recibirías.";
+"lightning__spark_exit__funding_title" = "Financia las\ncomisiones";
+"lightning__spark_exit__funding_text" = "La salida paga sus comisiones con un pago on-chain aparte de {amount}. Lo que no gaste vuelve a ahorros al final.";
+"lightning__spark_exit__fund_savings" = "Pagar desde ahorros";
+"lightning__spark_exit__fund_external" = "Financiar desde otro monedero";
+"lightning__spark_exit__fund_external_text" = "Envía al menos {amount} a esta dirección. La salida empieza en cuanto se confirme.";
+"lightning__spark_exit__fund_insufficient" = "Tus ahorros no alcanzan para financiar las comisiones.";
+"lightning__spark_exit__fund_seen" = "Pago detectado.";
+"lightning__spark_exit__fund_partial" = "Recibidos {received} de {required}, envía {missing} más.";
+"lightning__spark_exit__confirm_title" = "¿Iniciar\nla salida?";
+"lightning__spark_exit__confirm_text" = "No podrás usar estos fondos durante hasta 4 semanas.";
+"lightning__spark_exit__swipe" = "Deslizar para salir";
+"lightning__spark_exit__progress_title" = "Salida\nen curso";
+"lightning__spark_exit__progress_text" = "Esto se ejecuta en segundo plano. Bitkit te avisa si te necesita.";
+"lightning__spark_exit__step_funding" = "Confirmación de los fondos para comisiones";
+"lightning__spark_exit__step_publish" = "Publicando las transacciones de salida";
+"lightning__spark_exit__step_timelock" = "Esperando el bloqueo temporal";
+"lightning__spark_exit__step_sweep" = "Enviando a ahorros";
+"lightning__spark_exit__ready_in" = "Listo en {duration}";
+"lightning__spark_exit__done_title" = "Salida\ncompletada";
+"lightning__spark_exit__done_text" = "Tus fondos están en ahorros.";
+"lightning__spark_exit__partial_text" = "{count} cantidades pequeñas se quedaron en Spark.";
+"lightning__spark_exit__failed_leaves" = "Los operadores de Spark movieron {amount} on-chain y ya no está en Spark. Los operadores todavía pueden liberártelo.";
+"lightning__spark_exit__leftover" = "{amount} de los fondos para comisiones sigue en {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} sobrante de los fondos para comisiones se envió a ahorros.";
+"lightning__spark_exit__leftover_sending" = "Enviando a ahorros {amount} sobrante de los fondos para comisiones.";
+"lightning__spark_exit__leftover_dust" = "{amount} sobrante de los fondos para comisiones es demasiado pequeño para enviarlo y se queda en {address}, ruta de derivación {path}.";
+"lightning__spark_exit__fund_paid" = "Pagado desde ahorros, esperando confirmación.";
+"lightning__spark_exit__fund_pending" = "Pago detectado, esperando confirmación.";
+"lightning__spark_exit__lost_leaves" = "Un propietario anterior de estos fondos reclamó {amount} on-chain y no se puede recuperar.";
+"lightning__spark_exit__notification_lost" = "{amount} de tu salida no se puede recuperar. Abre Bitkit para ver por qué.";
+"lightning__spark_exit__notification_failed" = "Tu salida de emergencia se detuvo. Abre Bitkit para ver por qué.";
+"lightning__spark_exit__quote_outside" = "{amount} de tu saldo de Spark no se puede retirar ahora mismo: cantidades demasiado pequeñas para pagar sus propias comisiones o que aún se están moviendo dentro de Spark. Se queda en Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} no se puede retirar con esta tasa de comisión y se queda en Spark.";
+"lightning__spark_exit__needs_funding_title" = "Se necesitan\nmás comisiones";
+"lightning__spark_exit__needs_funding_text" = "La salida necesita {amount} más para pagar sus comisiones. Añade fondos para continuar.";
+"lightning__spark_exit__failed_title" = "Salida\ndetenida";
+"lightning__spark_exit__cancel" = "Cancelar salida";
+"lightning__spark_exit__failed_text" = "No se pudo recuperar nada.";
+"lightning__spark_exit__failed_stays" = "{amount} se queda en Spark.";
+"lightning__spark_exit__notification_title" = "Salida de emergencia";
+"lightning__spark_exit__notification_body" = "Abre Bitkit para continuar tu salida.";
+"lightning__spark_exit__notification_funding" = "Tu salida necesita más comisiones para continuar. Abre Bitkit para añadir fondos.";
+"lightning__spark_exit__bg_refresh_off" = "La Actualización en segundo plano está desactivada para Bitkit, así que la salida solo avanza mientras Bitkit está abierto. Actívala en Ajustes para que funcione en segundo plano.";
+"lightning__spark_exit__low_power" = "El Modo de bajo consumo pausa el trabajo en segundo plano. La salida continúa cuando se desactive o cuando abras Bitkit.";
+"lightning__spark_exit__notifications_off" = "Las notificaciones están desactivadas, así que Bitkit no puede avisarte cuando la salida te necesite. Actívalas en Ajustes para recibir recordatorios.";
+"lightning__spark_exit__not_moving" = "La salida no avanza: {error}";
+"lightning__spark_exit__fee_low" = "La red está más congestionada que la tasa de comisión de esta salida, de {rate} sat/vB. Paga una comisión más alta o espera a que bajen las comisiones.";
+"lightning__spark_exit__waiting_leaves" = "Ahora mismo no se puede leer on-chain una parte de esta salida. Bitkit lo vuelve a intentar durante hasta una semana.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit no pudo leer el progreso guardado de esta salida, así que lo reconstruye a partir de la cadena.";
+"lightning__spark_exit__done_received" = "{amount} llegó a tus ahorros.";
+"lightning__spark_exit__leftover_rejected" = "{amount} sobrante de los fondos para comisiones no se pudo enviar y se queda en {address}, ruta de derivación {path}.";
+"lightning__spark_exit__notification_not_moving" = "Tu salida de emergencia no avanza. Abre Bitkit para ver por qué.";
+"lightning__spark_exit__funding_split" = "Bitkit lo divide en {count} partes, una para cada paso, para que un paso atascado no retrase a los demás.";
+"lightning__spark_exit__waiting_operators" = "{amount} que los operadores de Spark movieron on-chain espera a que lo liberen. Bitkit vuelve a pedirlo durante hasta una semana.";
+"lightning__spark_exit__fee_rose" = "Las comisiones de red superan la comisión de esta salida. Puede ir más lenta.";
+"lightning__spark_exit__fee_bump_button" = "Pagar una comisión más alta";
+"lightning__spark_exit__fee_bump_title" = "Paga una\ncomisión más alta";
+"lightning__spark_exit__fee_bump_text" = "Esta salida paga {rate} sat/vB, menos de lo que pide la red ahora. Elige una tasa más alta y Bitkit vuelve a enviar los pasos pendientes. Paga la diferencia desde ahorros: la cantidad junto a cada velocidad.";
+"lightning__spark_exit__fee_bump_error" = "No se pudo calcular una comisión más alta. Inténtalo de nuevo.";
+"lightning__spark_exit__fee_bump_confirm" = "Usar esta comisión";
+"lightning__spark_exit__leftover_waiting" = "{amount} de cambio de comisiones llegará a tus ahorros cuando bajen las comisiones de red.";
+"lightning__spark_exit__cancelled_title" = "Salida\ncancelada";
+"lightning__spark_exit__cancelled_text" = "Cancelaste la salida. Los fondos para comisiones que llegaron vuelven a ahorros.";
+"lightning__spark_exit__cancel_failed" = "No se pudo comprobar la dirección de financiación. Inténtalo de nuevo.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Una comisión más alta dejaría parte de esta salida en Spark, así que no se ofrece ninguna. La salida se confirma cuando bajen las comisiones de red.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
index 259152279..4353a5eb2 100644
--- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings
@@ -373,6 +373,90 @@
"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" = "Sortie d'urgence";
+"lightning__spark_exit__nav_title" = "Sortie d'urgence";
+"lightning__spark_exit__intro_title" = "Quitter Spark\non-chain";
+"lightning__spark_exit__intro_text" = "N'utilisez ceci que si Spark ne fonctionne plus pour vous. Cela transfère votre solde de dépenses vers l'épargne sans les opérateurs Spark.\n\nCela prend jusqu'à 4 semaines, coûte des frais on-chain payés depuis l'épargne, et de très petits montants peuvent rester sur Spark. Cela s'exécute en arrière-plan, et Bitkit vous avertit s'il a besoin de vous.";
+"lightning__spark_exit__quote_title" = "Devis de\nsortie";
+"lightning__spark_exit__amount" = "Montant à sortir";
+"lightning__spark_exit__fees" = "Frais de réseau";
+"lightning__spark_exit__receive" = "Vous recevez environ";
+"lightning__spark_exit__savings_fee" = "Frais de paiement depuis l'épargne";
+"lightning__spark_exit__savings_refused" = "Votre paiement depuis l'épargne n'a pas abouti. Payez à nouveau ou financez depuis un autre portefeuille.";
+"lightning__spark_exit__savings_total" = "Total depuis l'épargne";
+"lightning__spark_exit__funding" = "Financement des frais requis";
+"lightning__spark_exit__left_behind" = "Reste sur Spark";
+"lightning__spark_exit__outside_exit" = "{amount} sur Spark ne fait pas partie de cette sortie et reste sur Spark.";
+"lightning__spark_exit__quote_loading" = "Obtention d'un devis…";
+"lightning__spark_exit__quote_settling" = "En attente que Spark termine le transfert de votre solde. Cela peut prendre jusqu'à 5 minutes.";
+"lightning__spark_exit__quote_error" = "Impossible d'obtenir un devis de sortie. Réessayez.";
+"lightning__spark_exit__nothing" = "Rien ne peut être sorti pour le moment.";
+"lightning__spark_exit__uneconomic" = "Les frais sont supérieurs à ce que vous recevriez.";
+"lightning__spark_exit__funding_title" = "Financer les\nfrais";
+"lightning__spark_exit__funding_text" = "La sortie paie ses frais à partir d'un paiement on-chain séparé de {amount}. Ce qui n'est pas dépensé revient dans l'épargne à la fin.";
+"lightning__spark_exit__fund_savings" = "Payer depuis l'épargne";
+"lightning__spark_exit__fund_external" = "Financer depuis un autre portefeuille";
+"lightning__spark_exit__fund_external_text" = "Envoyez au moins {amount} à cette adresse. La sortie démarre une fois le paiement confirmé.";
+"lightning__spark_exit__fund_insufficient" = "Votre épargne ne peut pas couvrir le financement des frais.";
+"lightning__spark_exit__fund_seen" = "Paiement détecté.";
+"lightning__spark_exit__fund_partial" = "{received} sur {required} reçus, envoyez encore {missing}.";
+"lightning__spark_exit__confirm_title" = "Lancer la\nsortie ?";
+"lightning__spark_exit__confirm_text" = "Vous ne pourrez pas utiliser ces fonds pendant jusqu'à 4 semaines.";
+"lightning__spark_exit__swipe" = "Glissez pour sortir";
+"lightning__spark_exit__progress_title" = "Sortie\nen cours";
+"lightning__spark_exit__progress_text" = "Cela s'exécute en arrière-plan. Bitkit vous avertit s'il a besoin de vous.";
+"lightning__spark_exit__step_funding" = "Confirmation du financement des frais";
+"lightning__spark_exit__step_publish" = "Publication des transactions de sortie";
+"lightning__spark_exit__step_timelock" = "En attente du timelock";
+"lightning__spark_exit__step_sweep" = "Envoi vers l'épargne";
+"lightning__spark_exit__ready_in" = "Prêt dans {duration}";
+"lightning__spark_exit__done_title" = "Sortie\nterminée";
+"lightning__spark_exit__done_text" = "Vos fonds sont dans l'épargne.";
+"lightning__spark_exit__partial_text" = "{count} petits montants sont restés sur Spark.";
+"lightning__spark_exit__failed_leaves" = "{amount} a été déplacé on-chain par les opérateurs Spark et n'est plus sur Spark. Les opérateurs peuvent encore vous le libérer.";
+"lightning__spark_exit__leftover" = "{amount} de financement des frais se trouve encore à {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} de financement des frais restant a été envoyé vers l'épargne.";
+"lightning__spark_exit__leftover_sending" = "Envoi de {amount} de financement des frais restant vers l'épargne.";
+"lightning__spark_exit__leftover_dust" = "{amount} de financement des frais restant est trop faible pour être envoyé et reste à {address}, chemin de dérivation {path}.";
+"lightning__spark_exit__fund_paid" = "Payé depuis l'épargne, en attente de confirmation.";
+"lightning__spark_exit__fund_pending" = "Paiement détecté, en attente de confirmation.";
+"lightning__spark_exit__lost_leaves" = "{amount} a été réclamé on-chain par un ancien propriétaire de ces fonds et ne peut pas être récupéré.";
+"lightning__spark_exit__notification_lost" = "{amount} de votre sortie ne peut pas être récupéré. Ouvrez Bitkit pour savoir pourquoi.";
+"lightning__spark_exit__notification_failed" = "Votre sortie d'urgence s'est arrêtée. Ouvrez Bitkit pour savoir pourquoi.";
+"lightning__spark_exit__quote_outside" = "{amount} de votre solde Spark ne peut pas être sorti pour le moment : des montants trop faibles pour payer leurs propres frais, ou encore en mouvement au sein de Spark. Ils restent sur Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} ne peut pas être sorti à ce taux de frais et reste sur Spark.";
+"lightning__spark_exit__needs_funding_title" = "Frais supplémentaires\nrequis";
+"lightning__spark_exit__needs_funding_text" = "La sortie a besoin de {amount} de plus pour payer ses frais. Financez-la pour continuer.";
+"lightning__spark_exit__failed_title" = "Sortie\narrêtée";
+"lightning__spark_exit__cancel" = "Annuler la sortie";
+"lightning__spark_exit__failed_text" = "Rien n'a pu être récupéré.";
+"lightning__spark_exit__failed_stays" = "{amount} reste sur Spark.";
+"lightning__spark_exit__notification_title" = "Sortie d'urgence";
+"lightning__spark_exit__notification_body" = "Ouvrez Bitkit pour poursuivre votre sortie.";
+"lightning__spark_exit__notification_funding" = "Votre sortie a besoin de plus de frais pour continuer. Ouvrez Bitkit pour la financer.";
+"lightning__spark_exit__bg_refresh_off" = "L'actualisation en arrière-plan est désactivée pour Bitkit, la sortie n'avance donc que lorsque Bitkit est ouvert. Activez-la dans Réglages pour qu'elle s'exécute en arrière-plan.";
+"lightning__spark_exit__low_power" = "Le mode Économie d'énergie met en pause le travail en arrière-plan. La sortie reprend une fois qu'il est désactivé, ou lorsque vous ouvrez Bitkit.";
+"lightning__spark_exit__notifications_off" = "Les notifications sont désactivées, Bitkit ne peut donc pas vous prévenir quand la sortie a besoin de vous. Activez-les dans Réglages pour recevoir des rappels.";
+"lightning__spark_exit__not_moving" = "La sortie n'avance pas : {error}";
+"lightning__spark_exit__fee_low" = "Le réseau est plus chargé que le taux de frais de cette sortie de {rate} sat/vB. Payez des frais plus élevés, ou attendez que les frais baissent.";
+"lightning__spark_exit__waiting_leaves" = "Une partie de cette sortie ne peut pas être lue on-chain pour le moment. Bitkit réessaie pendant une semaine au maximum.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit n'a pas pu lire la progression enregistrée de cette sortie, il la reconstruit donc à partir de la blockchain.";
+"lightning__spark_exit__done_received" = "{amount} est arrivé dans votre épargne.";
+"lightning__spark_exit__leftover_rejected" = "{amount} de financement des frais restant n'a pas pu être envoyé et reste à {address}, chemin de dérivation {path}.";
+"lightning__spark_exit__notification_not_moving" = "Votre sortie d'urgence n'avance pas. Ouvrez Bitkit pour savoir pourquoi.";
+"lightning__spark_exit__funding_split" = "Bitkit le divise en {count} parties, une pour chaque étape, afin qu'une étape bloquée ne retarde pas les autres.";
+"lightning__spark_exit__waiting_operators" = "{amount} que les opérateurs Spark ont déplacé on-chain attend qu'ils le libèrent. Bitkit redemande pendant une semaine au maximum.";
+"lightning__spark_exit__fee_rose" = "Les frais de réseau ont dépassé les frais de cette sortie. Elle pourrait ralentir.";
+"lightning__spark_exit__fee_bump_button" = "Payer des frais plus élevés";
+"lightning__spark_exit__fee_bump_title" = "Payer des\nfrais plus élevés";
+"lightning__spark_exit__fee_bump_text" = "Cette sortie paie {rate} sat/vB, moins que ce que le réseau demande actuellement. Choisissez un taux plus élevé et Bitkit renvoie les étapes en attente. La différence est payée depuis l'épargne : le montant affiché à côté de chaque vitesse.";
+"lightning__spark_exit__fee_bump_error" = "Impossible d'estimer des frais plus élevés. Réessayez.";
+"lightning__spark_exit__fee_bump_confirm" = "Utiliser ces frais";
+"lightning__spark_exit__leftover_waiting" = "{amount} de monnaie de frais arrivera dans votre épargne lorsque les frais de réseau baisseront.";
+"lightning__spark_exit__cancelled_title" = "Sortie\nannulée";
+"lightning__spark_exit__cancelled_text" = "Vous avez annulé la sortie. Le financement des frais déjà reçu retourne dans l'épargne.";
+"lightning__spark_exit__cancel_failed" = "Impossible de vérifier l'adresse de financement. Réessayez.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Des frais plus élevés laisseraient une partie de cette sortie sur Spark, donc aucun n'est proposé. La sortie se confirme une fois que les frais de réseau baissent.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings
index d04621d14..6ad15e3c8 100644
--- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings
@@ -366,6 +366,90 @@
"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" = "Uscita di emergenza";
+"lightning__spark_exit__nav_title" = "Uscita di emergenza";
+"lightning__spark_exit__intro_title" = "Lascia Spark\non-chain";
+"lightning__spark_exit__intro_text" = "Usala solo se Spark smette di funzionare per te. Sposta il tuo saldo di spesa nei risparmi senza gli operatori Spark.\n\nRichiede fino a 4 settimane, costa commissioni on-chain pagate dai risparmi e importi molto piccoli possono restare su Spark. Funziona in background e Bitkit ti avvisa se ha bisogno di te.";
+"lightning__spark_exit__quote_title" = "Preventivo\ndi uscita";
+"lightning__spark_exit__amount" = "Importo da far uscire";
+"lightning__spark_exit__fees" = "Commissioni di rete";
+"lightning__spark_exit__receive" = "Ricevi circa";
+"lightning__spark_exit__savings_fee" = "Commissione del pagamento dai risparmi";
+"lightning__spark_exit__savings_refused" = "Il tuo pagamento dai risparmi non è andato a buon fine. Paga di nuovo o finanzia da un altro portafoglio.";
+"lightning__spark_exit__savings_total" = "Totale dai risparmi";
+"lightning__spark_exit__funding" = "Finanziamento commissioni necessario";
+"lightning__spark_exit__left_behind" = "Resta su Spark";
+"lightning__spark_exit__outside_exit" = "{amount} su Spark non fa parte di questa uscita e resta su Spark.";
+"lightning__spark_exit__quote_loading" = "Recupero del preventivo…";
+"lightning__spark_exit__quote_settling" = "In attesa che Spark finisca di spostare il tuo saldo. Può richiedere fino a 5 minuti.";
+"lightning__spark_exit__quote_error" = "Impossibile ottenere un preventivo di uscita. Riprova.";
+"lightning__spark_exit__nothing" = "Al momento non è possibile far uscire nulla.";
+"lightning__spark_exit__uneconomic" = "Le commissioni superano quanto riceveresti.";
+"lightning__spark_exit__funding_title" = "Finanzia le\ncommissioni";
+"lightning__spark_exit__funding_text" = "L'uscita paga le sue commissioni con un pagamento on-chain separato di {amount}. Ciò che non spende torna nei risparmi alla fine.";
+"lightning__spark_exit__fund_savings" = "Paga dai risparmi";
+"lightning__spark_exit__fund_external" = "Finanzia da un altro wallet";
+"lightning__spark_exit__fund_external_text" = "Invia almeno {amount} a questo indirizzo. L'uscita inizia appena il pagamento è confermato.";
+"lightning__spark_exit__fund_insufficient" = "I tuoi risparmi non bastano a coprire il finanziamento delle commissioni.";
+"lightning__spark_exit__fund_seen" = "Pagamento rilevato.";
+"lightning__spark_exit__fund_partial" = "Ricevuti {received} su {required}, invia altri {missing}.";
+"lightning__spark_exit__confirm_title" = "Avviare\nl'uscita?";
+"lightning__spark_exit__confirm_text" = "Non potrai usare questi fondi per fino a 4 settimane.";
+"lightning__spark_exit__swipe" = "Scorri Per Uscire";
+"lightning__spark_exit__progress_title" = "Uscita\nin corso";
+"lightning__spark_exit__progress_text" = "Funziona in background. Bitkit ti avvisa se ha bisogno di te.";
+"lightning__spark_exit__step_funding" = "Conferma del finanziamento commissioni";
+"lightning__spark_exit__step_publish" = "Pubblicazione delle transazioni di uscita";
+"lightning__spark_exit__step_timelock" = "In attesa del timelock";
+"lightning__spark_exit__step_sweep" = "Invio ai risparmi";
+"lightning__spark_exit__ready_in" = "Pronto tra {duration}";
+"lightning__spark_exit__done_title" = "Uscita\ncompletata";
+"lightning__spark_exit__done_text" = "I tuoi fondi sono nei risparmi.";
+"lightning__spark_exit__partial_text" = "{count} piccoli importi sono rimasti su Spark.";
+"lightning__spark_exit__failed_leaves" = "{amount} è stato spostato on-chain dagli operatori Spark e non è su Spark. Gli operatori possono ancora rilasciarlo a te.";
+"lightning__spark_exit__leftover" = "{amount} del finanziamento commissioni è ancora su {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} di finanziamento commissioni avanzato è stato inviato ai risparmi.";
+"lightning__spark_exit__leftover_sending" = "Invio di {amount} di finanziamento commissioni avanzato ai risparmi.";
+"lightning__spark_exit__leftover_dust" = "{amount} di finanziamento commissioni avanzato è troppo poco per essere inviato e resta su {address}, percorso di derivazione {path}.";
+"lightning__spark_exit__fund_paid" = "Pagato dai risparmi, in attesa di conferma.";
+"lightning__spark_exit__fund_pending" = "Pagamento rilevato, in attesa di conferma.";
+"lightning__spark_exit__lost_leaves" = "{amount} è stato riscattato on-chain da un precedente proprietario di questi fondi e non può essere recuperato.";
+"lightning__spark_exit__notification_lost" = "{amount} della tua uscita non può essere recuperato. Apri Bitkit per scoprire perché.";
+"lightning__spark_exit__notification_failed" = "La tua uscita di emergenza si è fermata. Apri Bitkit per scoprire perché.";
+"lightning__spark_exit__quote_outside" = "{amount} del tuo saldo Spark non può uscire al momento: importi troppo piccoli per pagare le proprie commissioni, o ancora in movimento dentro Spark. Resta su Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} non può uscire con questo tasso di commissione e resta su Spark.";
+"lightning__spark_exit__needs_funding_title" = "Servono altre\ncommissioni";
+"lightning__spark_exit__needs_funding_text" = "L'uscita ha bisogno di altri {amount} per pagare le commissioni. Finanziala per continuare.";
+"lightning__spark_exit__failed_title" = "Uscita\ninterrotta";
+"lightning__spark_exit__cancel" = "Annulla uscita";
+"lightning__spark_exit__failed_text" = "Non è stato possibile recuperare nulla.";
+"lightning__spark_exit__failed_stays" = "{amount} resta su Spark.";
+"lightning__spark_exit__notification_title" = "Uscita di emergenza";
+"lightning__spark_exit__notification_body" = "Apri Bitkit per continuare la tua uscita.";
+"lightning__spark_exit__notification_funding" = "La tua uscita ha bisogno di altre commissioni per continuare. Apri Bitkit per finanziarla.";
+"lightning__spark_exit__bg_refresh_off" = "L'aggiornamento app in background è disattivato per Bitkit, quindi l'uscita avanza solo mentre Bitkit è aperto. Attivalo nelle Impostazioni per farla procedere in background.";
+"lightning__spark_exit__low_power" = "La modalità Risparmio energetico sospende le attività in background. L'uscita riprende quando la disattivi o quando apri Bitkit.";
+"lightning__spark_exit__notifications_off" = "Le notifiche sono disattivate, quindi Bitkit non può avvisarti quando l'uscita ha bisogno di te. Attivale nelle Impostazioni per ricevere promemoria.";
+"lightning__spark_exit__not_moving" = "L'uscita non avanza: {error}";
+"lightning__spark_exit__fee_low" = "La rete è più congestionata del tasso di commissione di questa uscita di {rate} sat/vB. Paga una commissione più alta o attendi che le commissioni scendano.";
+"lightning__spark_exit__waiting_leaves" = "Parte di questa uscita non può essere letta on-chain al momento. Bitkit riprova per un massimo di una settimana.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit non è riuscito a leggere i progressi salvati di questa uscita, quindi li ricostruisce dalla blockchain.";
+"lightning__spark_exit__done_received" = "{amount} è arrivato nei tuoi risparmi.";
+"lightning__spark_exit__leftover_rejected" = "{amount} di finanziamento commissioni avanzato non è stato inviato e resta su {address}, percorso di derivazione {path}.";
+"lightning__spark_exit__notification_not_moving" = "La tua uscita di emergenza non avanza. Apri Bitkit per scoprire perché.";
+"lightning__spark_exit__funding_split" = "Bitkit lo divide in {count} parti, una per ogni passaggio, così un passaggio bloccato non può trattenere gli altri.";
+"lightning__spark_exit__waiting_operators" = "{amount} che gli operatori Spark hanno spostato on-chain attende che lo rilascino. Bitkit lo richiede per un massimo di una settimana.";
+"lightning__spark_exit__fee_rose" = "Le commissioni di rete hanno superato la commissione di questa uscita. Potrebbe rallentare.";
+"lightning__spark_exit__fee_bump_button" = "Paga una commissione più alta";
+"lightning__spark_exit__fee_bump_title" = "Paga una\ncommissione più alta";
+"lightning__spark_exit__fee_bump_text" = "Questa uscita paga {rate} sat/vB, meno di quanto chiede ora la rete. Scegli un tasso più alto e Bitkit invia di nuovo i passaggi in attesa. Paga la differenza dai risparmi: l'importo accanto a ogni velocità.";
+"lightning__spark_exit__fee_bump_error" = "Impossibile calcolare una commissione più alta. Riprova.";
+"lightning__spark_exit__fee_bump_confirm" = "Usa questa commissione";
+"lightning__spark_exit__leftover_waiting" = "{amount} di resto delle commissioni arriverà nei tuoi risparmi quando le commissioni di rete scenderanno.";
+"lightning__spark_exit__cancelled_title" = "Uscita\nannullata";
+"lightning__spark_exit__cancelled_text" = "Hai annullato l'uscita. Il finanziamento commissioni arrivato torna nei risparmi.";
+"lightning__spark_exit__cancel_failed" = "Impossibile controllare l'indirizzo di finanziamento. Riprova.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Una commissione più alta lascerebbe parte di questa uscita su Spark, quindi non ne viene offerta nessuna. L'uscita si conferma quando le commissioni di rete scendono.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
index d2a7a7a4f..f25f5f5b6 100644
--- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings
@@ -370,6 +370,90 @@
"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" = "Nooduitgang";
+"lightning__spark_exit__nav_title" = "Nooduitgang";
+"lightning__spark_exit__intro_title" = "Spark verlaten\non-chain";
+"lightning__spark_exit__intro_text" = "Gebruik dit alleen als Spark niet meer voor je werkt. Het zet je bestedingssaldo over naar spaargeld zonder de Spark-operators.\n\nHet duurt tot 4 weken, kost on-chain vergoedingen die uit je spaargeld worden betaald, en zeer kleine bedragen kunnen achterblijven. Het loopt op de achtergrond, en Bitkit stuurt je een melding als het je nodig heeft.";
+"lightning__spark_exit__quote_title" = "Exit-\nofferte";
+"lightning__spark_exit__amount" = "Bedrag om op te nemen";
+"lightning__spark_exit__fees" = "Netwerkkosten";
+"lightning__spark_exit__receive" = "Je ontvangt ongeveer";
+"lightning__spark_exit__savings_fee" = "Vergoeding betaling uit spaargeld";
+"lightning__spark_exit__savings_refused" = "Je betaling uit spaargeld is niet gelukt. Betaal opnieuw of financier vanuit een andere wallet.";
+"lightning__spark_exit__savings_total" = "Totaal uit spaargeld";
+"lightning__spark_exit__funding" = "Benodigde vergoedingsdekking";
+"lightning__spark_exit__left_behind" = "Blijft op Spark";
+"lightning__spark_exit__outside_exit" = "{amount} op Spark valt niet onder deze exit en blijft op Spark.";
+"lightning__spark_exit__quote_loading" = "Offerte ophalen…";
+"lightning__spark_exit__quote_settling" = "Wachten tot Spark klaar is met het verplaatsen van je saldo. Dit kan tot 5 minuten duren.";
+"lightning__spark_exit__quote_error" = "Kon geen exit-offerte ophalen. Probeer het opnieuw.";
+"lightning__spark_exit__nothing" = "Er kan nu niets worden opgenomen.";
+"lightning__spark_exit__uneconomic" = "De vergoedingen zijn hoger dan wat je zou ontvangen.";
+"lightning__spark_exit__funding_title" = "Dek de\nvergoedingen";
+"lightning__spark_exit__funding_text" = "De exit betaalt zijn vergoedingen uit een aparte on-chain betaling van {amount}. Wat niet wordt gebruikt, komt aan het einde terug naar je spaargeld.";
+"lightning__spark_exit__fund_savings" = "Betalen uit spaargeld";
+"lightning__spark_exit__fund_external" = "Betalen vanuit een andere wallet";
+"lightning__spark_exit__fund_external_text" = "Stuur minimaal {amount} naar dit adres. De exit start zodra de betaling is bevestigd.";
+"lightning__spark_exit__fund_insufficient" = "Je spaargeld is niet genoeg om de vergoedingen te dekken.";
+"lightning__spark_exit__fund_seen" = "Betaling gezien.";
+"lightning__spark_exit__fund_partial" = "{received} van {required} ontvangen, stuur nog {missing}.";
+"lightning__spark_exit__confirm_title" = "Exit\nstarten?";
+"lightning__spark_exit__confirm_text" = "Je kunt dit geld tot 4 weken niet gebruiken.";
+"lightning__spark_exit__swipe" = "Veeg Om Te Verlaten";
+"lightning__spark_exit__progress_title" = "Exit\nbezig";
+"lightning__spark_exit__progress_text" = "Dit loopt op de achtergrond. Bitkit stuurt je een melding als het je nodig heeft.";
+"lightning__spark_exit__step_funding" = "Vergoedingsdekking wordt bevestigd";
+"lightning__spark_exit__step_publish" = "Exit-transacties publiceren";
+"lightning__spark_exit__step_timelock" = "Wachten op de timelock";
+"lightning__spark_exit__step_sweep" = "Versturen naar spaargeld";
+"lightning__spark_exit__ready_in" = "Klaar over {duration}";
+"lightning__spark_exit__done_title" = "Exit\nvoltooid";
+"lightning__spark_exit__done_text" = "Je geld staat in je spaargeld.";
+"lightning__spark_exit__partial_text" = "{count} kleine bedragen zijn op Spark gebleven.";
+"lightning__spark_exit__failed_leaves" = "{amount} is door de Spark-operators on-chain gezet en staat niet op Spark. De operators kunnen het nog steeds aan je vrijgeven.";
+"lightning__spark_exit__leftover" = "{amount} aan vergoedingsdekking staat nog op {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} aan overgebleven vergoedingsdekking is naar je spaargeld gestuurd.";
+"lightning__spark_exit__leftover_sending" = "{amount} aan overgebleven vergoedingsdekking wordt naar je spaargeld gestuurd.";
+"lightning__spark_exit__leftover_dust" = "{amount} aan overgebleven vergoedingsdekking is te klein om te versturen en blijft op {address}, afleidingspad {path}.";
+"lightning__spark_exit__fund_paid" = "Betaald uit spaargeld, wachten op bevestiging.";
+"lightning__spark_exit__fund_pending" = "Betaling gezien, wachten op bevestiging.";
+"lightning__spark_exit__lost_leaves" = "{amount} is on-chain geclaimd door een eerdere eigenaar van dit geld en kan niet worden teruggehaald.";
+"lightning__spark_exit__notification_lost" = "{amount} van je exit kan niet worden teruggehaald. Open Bitkit om te zien waarom.";
+"lightning__spark_exit__notification_failed" = "Je nooduitgang is gestopt. Open Bitkit om te zien waarom.";
+"lightning__spark_exit__quote_outside" = "{amount} van je Spark-saldo kan nu niet worden opgenomen: bedragen die te klein zijn om hun eigen vergoedingen te betalen, of die nog binnen Spark worden verplaatst. Het blijft op Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} kan bij dit vergoedingstarief niet worden opgenomen en blijft op Spark.";
+"lightning__spark_exit__needs_funding_title" = "Meer vergoeding\nnodig";
+"lightning__spark_exit__needs_funding_text" = "De exit heeft nog {amount} nodig om zijn vergoedingen te betalen. Dek dit om door te gaan.";
+"lightning__spark_exit__failed_title" = "Exit\ngestopt";
+"lightning__spark_exit__cancel" = "Exit annuleren";
+"lightning__spark_exit__failed_text" = "Er kon niets worden teruggehaald.";
+"lightning__spark_exit__failed_stays" = "{amount} blijft op Spark.";
+"lightning__spark_exit__notification_title" = "Nooduitgang";
+"lightning__spark_exit__notification_body" = "Open Bitkit om je exit voort te zetten.";
+"lightning__spark_exit__notification_funding" = "Je exit heeft meer vergoeding nodig om door te gaan. Open Bitkit om dit te dekken.";
+"lightning__spark_exit__bg_refresh_off" = "Ververs op achtergrond staat uit voor Bitkit, dus de exit gaat alleen verder als Bitkit open is. Zet het aan in Instellingen zodat het op de achtergrond kan lopen.";
+"lightning__spark_exit__low_power" = "De energiebesparingsmodus pauzeert achtergrondtaken. De exit gaat verder zodra die uit staat, of wanneer je Bitkit opent.";
+"lightning__spark_exit__notifications_off" = "Meldingen staan uit, dus Bitkit kan je niet laten weten wanneer de exit je nodig heeft. Zet ze aan in Instellingen om herinnerd te worden.";
+"lightning__spark_exit__not_moving" = "De exit komt niet verder: {error}";
+"lightning__spark_exit__fee_low" = "Het netwerk is drukker dan het vergoedingstarief van deze exit van {rate} sat/vB. Betaal een hogere vergoeding, of wacht tot de vergoedingen dalen.";
+"lightning__spark_exit__waiting_leaves" = "Een deel van deze exit kan nu niet on-chain worden gelezen. Bitkit probeert het tot een week lang opnieuw.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit kon de opgeslagen voortgang van deze exit niet lezen, dus bouwt het die opnieuw op vanuit de blockchain.";
+"lightning__spark_exit__done_received" = "{amount} is aangekomen in je spaargeld.";
+"lightning__spark_exit__leftover_rejected" = "{amount} aan overgebleven vergoedingsdekking kon niet worden verstuurd en blijft op {address}, afleidingspad {path}.";
+"lightning__spark_exit__notification_not_moving" = "Je nooduitgang komt niet verder. Open Bitkit om te zien waarom.";
+"lightning__spark_exit__funding_split" = "Bitkit splitst het in {count} delen, één per stap, zodat één vastgelopen stap de andere niet ophoudt.";
+"lightning__spark_exit__waiting_operators" = "{amount} dat de Spark-operators on-chain hebben gezet, wacht tot zij het vrijgeven. Bitkit vraagt het tot een week lang opnieuw.";
+"lightning__spark_exit__fee_rose" = "De netwerkkosten zijn boven de vergoeding van deze exit gestegen. Het kan trager gaan.";
+"lightning__spark_exit__fee_bump_button" = "Hogere vergoeding betalen";
+"lightning__spark_exit__fee_bump_title" = "Betaal een\nhogere vergoeding";
+"lightning__spark_exit__fee_bump_text" = "Deze exit betaalt {rate} sat/vB, minder dan het netwerk nu vraagt. Kies een hoger tarief en Bitkit verstuurt de wachtende stappen opnieuw. Het verschil wordt uit je spaargeld betaald: het bedrag naast elke snelheid.";
+"lightning__spark_exit__fee_bump_error" = "Kon de prijs van een hogere vergoeding niet berekenen. Probeer het opnieuw.";
+"lightning__spark_exit__fee_bump_confirm" = "Deze vergoeding gebruiken";
+"lightning__spark_exit__leftover_waiting" = "{amount} aan wisselgeld van de vergoeding komt in je spaargeld wanneer de netwerkkosten dalen.";
+"lightning__spark_exit__cancelled_title" = "Exit\ngeannuleerd";
+"lightning__spark_exit__cancelled_text" = "Je hebt de exit geannuleerd. De vergoedingsdekking die is binnengekomen, gaat terug naar je spaargeld.";
+"lightning__spark_exit__cancel_failed" = "Kon het dekkingsadres niet controleren. Probeer het opnieuw.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Een hogere vergoeding zou een deel van deze exit op Spark laten, dus wordt er geen aangeboden. De exit wordt bevestigd zodra de netwerkkosten dalen.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
index ffa9e799b..5e490d8c0 100644
--- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings
@@ -372,6 +372,90 @@
"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" = "Wyjście awaryjne";
+"lightning__spark_exit__nav_title" = "Wyjście awaryjne";
+"lightning__spark_exit__intro_title" = "Opuść Spark\non-chain";
+"lightning__spark_exit__intro_text" = "Używaj tego tylko wtedy, gdy Spark przestanie u Ciebie działać. Przenosi to Twoje saldo wydatków do oszczędności bez operatorów Spark.\n\nTrwa to do 4 tygodni, kosztuje opłaty on-chain płacone z oszczędności, a bardzo małe kwoty mogą pozostać. Działa w tle, a Bitkit powiadomi Cię, jeśli będziesz potrzebny.";
+"lightning__spark_exit__quote_title" = "Wycena\nwyjścia";
+"lightning__spark_exit__amount" = "Kwota do wypłaty";
+"lightning__spark_exit__fees" = "Opłaty sieciowe";
+"lightning__spark_exit__receive" = "Otrzymasz około";
+"lightning__spark_exit__savings_fee" = "Opłata za płatność z oszczędności";
+"lightning__spark_exit__savings_refused" = "Twoja płatność z Oszczędności nie przeszła. Zapłać ponownie lub sfinansuj z innego portfela.";
+"lightning__spark_exit__savings_total" = "Łącznie z oszczędności";
+"lightning__spark_exit__funding" = "Potrzebne środki na opłaty";
+"lightning__spark_exit__left_behind" = "Zostaje na Spark";
+"lightning__spark_exit__outside_exit" = "{amount} na Spark nie jest częścią tego wyjścia i zostaje na Spark.";
+"lightning__spark_exit__quote_loading" = "Pobieranie wyceny…";
+"lightning__spark_exit__quote_settling" = "Czekamy, aż Spark zakończy przenoszenie Twojego salda. Może to potrwać do 5 minut.";
+"lightning__spark_exit__quote_error" = "Nie udało się pobrać wyceny wyjścia. Spróbuj ponownie.";
+"lightning__spark_exit__nothing" = "W tej chwili nie ma nic do wypłacenia.";
+"lightning__spark_exit__uneconomic" = "Opłaty są wyższe niż kwota, którą otrzymasz.";
+"lightning__spark_exit__funding_title" = "Opłać\nopłaty";
+"lightning__spark_exit__funding_text" = "Wyjście pokrywa opłaty z osobnej płatności on-chain w wysokości {amount}. To, czego nie wyda, wraca na koniec do oszczędności.";
+"lightning__spark_exit__fund_savings" = "Zapłać z oszczędności";
+"lightning__spark_exit__fund_external" = "Zasil z innego portfela";
+"lightning__spark_exit__fund_external_text" = "Wyślij co najmniej {amount} na ten adres. Wyjście rozpocznie się po potwierdzeniu.";
+"lightning__spark_exit__fund_insufficient" = "Twoje oszczędności nie wystarczą na pokrycie opłat.";
+"lightning__spark_exit__fund_seen" = "Płatność wykryta.";
+"lightning__spark_exit__fund_partial" = "Otrzymano {received} z {required}, wyślij jeszcze {missing}.";
+"lightning__spark_exit__confirm_title" = "Rozpocząć\nwyjście?";
+"lightning__spark_exit__confirm_text" = "Nie będziesz mógł korzystać z tych środków przez do 4 tygodni.";
+"lightning__spark_exit__swipe" = "Przesuń, aby wyjść";
+"lightning__spark_exit__progress_title" = "Wyjście\nw toku";
+"lightning__spark_exit__progress_text" = "To działa w tle. Bitkit powiadomi Cię, jeśli będziesz potrzebny.";
+"lightning__spark_exit__step_funding" = "Potwierdzenie środków na opłaty";
+"lightning__spark_exit__step_publish" = "Publikowanie transakcji wyjścia";
+"lightning__spark_exit__step_timelock" = "Oczekiwanie na timelock";
+"lightning__spark_exit__step_sweep" = "Wysyłanie do oszczędności";
+"lightning__spark_exit__ready_in" = "Gotowe za {duration}";
+"lightning__spark_exit__done_title" = "Wyjście\nzakończone";
+"lightning__spark_exit__done_text" = "Twoje środki są w oszczędnościach.";
+"lightning__spark_exit__partial_text" = "Małe kwoty pozostałe na Spark: {count}.";
+"lightning__spark_exit__failed_leaves" = "{amount} zostało przeniesione on-chain przez operatorów Spark i nie jest na Spark. Operatorzy wciąż mogą Ci je wydać.";
+"lightning__spark_exit__leftover" = "{amount} środków na opłaty wciąż jest pod adresem {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} pozostałych środków na opłaty wysłano do oszczędności.";
+"lightning__spark_exit__leftover_sending" = "Wysyłanie {amount} pozostałych środków na opłaty do oszczędności.";
+"lightning__spark_exit__leftover_dust" = "{amount} pozostałych środków na opłaty to za mało, by je wysłać, i zostaje pod adresem {address}, ścieżka derywacji {path}.";
+"lightning__spark_exit__fund_paid" = "Zapłacono z oszczędności, czekamy na potwierdzenie.";
+"lightning__spark_exit__fund_pending" = "Płatność wykryta, czekamy na potwierdzenie.";
+"lightning__spark_exit__lost_leaves" = "{amount} zostało przejęte on-chain przez poprzedniego właściciela tych środków i nie można go odzyskać.";
+"lightning__spark_exit__notification_lost" = "Nie można odzyskać {amount} z Twojego wyjścia. Otwórz Bitkit, aby sprawdzić dlaczego.";
+"lightning__spark_exit__notification_failed" = "Twoje wyjście awaryjne zostało zatrzymane. Otwórz Bitkit, aby sprawdzić dlaczego.";
+"lightning__spark_exit__quote_outside" = "{amount} Twojego salda Spark nie można teraz wypłacić: kwoty są zbyt małe, by pokryć własne opłaty, albo wciąż przemieszczają się w Spark. Zostają na Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} nie można wypłacić przy tej stawce opłaty i zostaje na Spark.";
+"lightning__spark_exit__needs_funding_title" = "Potrzeba więcej\nopłat";
+"lightning__spark_exit__needs_funding_text" = "Wyjście potrzebuje jeszcze {amount} na opłaty. Zasil je, aby kontynuować.";
+"lightning__spark_exit__failed_title" = "Wyjście\nzatrzymane";
+"lightning__spark_exit__cancel" = "Anuluj wyjście";
+"lightning__spark_exit__failed_text" = "Nic nie udało się odzyskać.";
+"lightning__spark_exit__failed_stays" = "{amount} zostaje na Spark.";
+"lightning__spark_exit__notification_title" = "Wyjście awaryjne";
+"lightning__spark_exit__notification_body" = "Otwórz Bitkit, aby kontynuować wyjście.";
+"lightning__spark_exit__notification_funding" = "Twoje wyjście potrzebuje więcej opłat, aby kontynuować. Otwórz Bitkit, aby je zasilić.";
+"lightning__spark_exit__bg_refresh_off" = "Odświeżanie aplikacji w tle jest wyłączone dla Bitkit, więc wyjście postępuje tylko przy otwartym Bitkit. Włącz je w Ustawieniach, aby działało w tle.";
+"lightning__spark_exit__low_power" = "Tryb niskiego zużycia energii wstrzymuje pracę w tle. Wyjście będzie kontynuowane po jego wyłączeniu lub po otwarciu Bitkit.";
+"lightning__spark_exit__notifications_off" = "Powiadomienia są wyłączone, więc Bitkit nie może Cię powiadomić, gdy wyjście będzie Cię potrzebować. Włącz je w Ustawieniach, aby otrzymywać przypomnienia.";
+"lightning__spark_exit__not_moving" = "Wyjście nie postępuje: {error}";
+"lightning__spark_exit__fee_low" = "Sieć jest bardziej obciążona, niż pozwala stawka opłaty tego wyjścia wynosząca {rate} sat/vB. Zapłać wyższą opłatę lub poczekaj, aż opłaty spadną.";
+"lightning__spark_exit__waiting_leaves" = "Części tego wyjścia nie można teraz odczytać on-chain. Bitkit będzie próbować ponownie przez maksymalnie tydzień.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit nie mógł odczytać zapisanego postępu tego wyjścia, więc odtwarza go z łańcucha.";
+"lightning__spark_exit__done_received" = "{amount} trafiło do Twoich oszczędności.";
+"lightning__spark_exit__leftover_rejected" = "Nie udało się wysłać {amount} pozostałych środków na opłaty, które zostają pod adresem {address}, ścieżka derywacji {path}.";
+"lightning__spark_exit__notification_not_moving" = "Twoje wyjście awaryjne nie postępuje. Otwórz Bitkit, aby sprawdzić dlaczego.";
+"lightning__spark_exit__funding_split" = "Bitkit dzieli je na części ({count}), po jednej na każdy krok, aby jeden zablokowany krok nie wstrzymywał pozostałych.";
+"lightning__spark_exit__waiting_operators" = "{amount} przeniesione on-chain przez operatorów Spark czeka, aż je wydadzą. Bitkit będzie pytać ponownie przez maksymalnie tydzień.";
+"lightning__spark_exit__fee_rose" = "Opłaty sieciowe wzrosły powyżej opłaty tego wyjścia. Może ono zwolnić.";
+"lightning__spark_exit__fee_bump_button" = "Zapłać wyższą opłatę";
+"lightning__spark_exit__fee_bump_title" = "Zapłać\nwyższą opłatę";
+"lightning__spark_exit__fee_bump_text" = "To wyjście płaci {rate} sat/vB, mniej niż sieć wymaga teraz. Wybierz wyższą stawkę, a Bitkit ponownie wyśle oczekujące kroki. Różnicę zapłaci z oszczędności: kwota obok każdej prędkości.";
+"lightning__spark_exit__fee_bump_error" = "Nie udało się wycenić wyższej opłaty. Spróbuj ponownie.";
+"lightning__spark_exit__fee_bump_confirm" = "Użyj tej opłaty";
+"lightning__spark_exit__leftover_waiting" = "{amount} reszty z opłat trafi do Twoich oszczędności, gdy opłaty sieciowe spadną.";
+"lightning__spark_exit__cancelled_title" = "Wyjście\nanulowane";
+"lightning__spark_exit__cancelled_text" = "Anulowałeś wyjście. Środki na opłaty, które dotarły, wracają do oszczędności.";
+"lightning__spark_exit__cancel_failed" = "Nie udało się sprawdzić adresu środków na opłaty. Spróbuj ponownie.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Wyższa opłata pozostawiłaby część tego wyjścia na Spark, więc nie jest oferowana. Wyjście potwierdzi się, gdy opłaty sieciowe spadną.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
index 230392076..a13057e29 100644
--- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
@@ -372,6 +372,90 @@
"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" = "Saída de emergência";
+"lightning__spark_exit__nav_title" = "Saída de Emergência";
+"lightning__spark_exit__intro_title" = "Sair da Spark\non-chain";
+"lightning__spark_exit__intro_text" = "Use isto somente se a Spark parar de funcionar para você. Isso move seu saldo de gastos para a poupança sem os operadores da Spark.\n\nLeva até 4 semanas, tem taxas on-chain pagas da poupança, e valores muito pequenos podem ficar para trás. Roda em segundo plano, e a Bitkit avisa você se precisar.";
+"lightning__spark_exit__quote_title" = "Cotação\nda saída";
+"lightning__spark_exit__amount" = "Valor a sair";
+"lightning__spark_exit__fees" = "Taxas de rede";
+"lightning__spark_exit__receive" = "Você recebe cerca de";
+"lightning__spark_exit__savings_fee" = "Taxa do pagamento da poupança";
+"lightning__spark_exit__savings_refused" = "Seu pagamento da poupança não foi concluído. Pague novamente ou financie de outra carteira.";
+"lightning__spark_exit__savings_total" = "Total da poupança";
+"lightning__spark_exit__funding" = "Fundos para taxas necessários";
+"lightning__spark_exit__left_behind" = "Fica na Spark";
+"lightning__spark_exit__outside_exit" = "{amount} na Spark não faz parte desta saída e fica na Spark.";
+"lightning__spark_exit__quote_loading" = "Obtendo uma cotação…";
+"lightning__spark_exit__quote_settling" = "Aguardando a Spark terminar de mover seu saldo. Isso pode levar até 5 minutos.";
+"lightning__spark_exit__quote_error" = "Não foi possível obter uma cotação da saída. Tente novamente.";
+"lightning__spark_exit__nothing" = "Nada pode sair no momento.";
+"lightning__spark_exit__uneconomic" = "As taxas são maiores do que o que você receberia.";
+"lightning__spark_exit__funding_title" = "Financiar as\ntaxas";
+"lightning__spark_exit__funding_text" = "A saída paga suas taxas com um pagamento on-chain separado de {amount}. O que ela não gastar volta para a poupança no final.";
+"lightning__spark_exit__fund_savings" = "Pagar da poupança";
+"lightning__spark_exit__fund_external" = "Financiar de outra carteira";
+"lightning__spark_exit__fund_external_text" = "Envie pelo menos {amount} para este endereço. A saída começa assim que ele for confirmado.";
+"lightning__spark_exit__fund_insufficient" = "Sua poupança não cobre os fundos para taxas.";
+"lightning__spark_exit__fund_seen" = "Pagamento detectado.";
+"lightning__spark_exit__fund_partial" = "{received} de {required} recebidos, envie mais {missing}.";
+"lightning__spark_exit__confirm_title" = "Iniciar\nsaída?";
+"lightning__spark_exit__confirm_text" = "Você não poderá usar esses fundos por até 4 semanas.";
+"lightning__spark_exit__swipe" = "Deslizar Para Sair";
+"lightning__spark_exit__progress_title" = "Saída em\nandamento";
+"lightning__spark_exit__progress_text" = "Isso roda em segundo plano. A Bitkit avisa você se precisar.";
+"lightning__spark_exit__step_funding" = "Confirmação dos fundos para taxas";
+"lightning__spark_exit__step_publish" = "Publicando as transações de saída";
+"lightning__spark_exit__step_timelock" = "Aguardando o bloqueio de tempo";
+"lightning__spark_exit__step_sweep" = "Enviando para a poupança";
+"lightning__spark_exit__ready_in" = "Pronto em {duration}";
+"lightning__spark_exit__done_title" = "Saída\nconcluída";
+"lightning__spark_exit__done_text" = "Seus fundos estão na poupança.";
+"lightning__spark_exit__partial_text" = "{count} valores pequenos ficaram na Spark.";
+"lightning__spark_exit__failed_leaves" = "{amount} foi movido on-chain pelos operadores da Spark e não está na Spark. Os operadores ainda podem liberá-lo para você.";
+"lightning__spark_exit__leftover" = "{amount} dos fundos para taxas ainda está em {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} de fundos para taxas restantes foi enviado para a poupança.";
+"lightning__spark_exit__leftover_sending" = "Enviando {amount} de fundos para taxas restantes para a poupança.";
+"lightning__spark_exit__leftover_dust" = "{amount} de fundos para taxas restantes é pequeno demais para enviar e fica em {address}, caminho de derivação {path}.";
+"lightning__spark_exit__fund_paid" = "Pago da poupança, aguardando confirmação.";
+"lightning__spark_exit__fund_pending" = "Pagamento detectado, aguardando confirmação.";
+"lightning__spark_exit__lost_leaves" = "{amount} foi resgatado on-chain por um antigo dono desses fundos e não pode ser recuperado.";
+"lightning__spark_exit__notification_lost" = "{amount} da sua saída não pode ser recuperado. Abra a Bitkit para ver o motivo.";
+"lightning__spark_exit__notification_failed" = "Sua saída de emergência parou. Abra a Bitkit para ver o motivo.";
+"lightning__spark_exit__quote_outside" = "{amount} do seu saldo Spark não pode sair no momento: valores pequenos demais para pagar as próprias taxas, ou ainda em movimento dentro da Spark. Ele fica na Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} não pode sair com esta taxa e fica na Spark.";
+"lightning__spark_exit__needs_funding_title" = "Mais taxas\nnecessárias";
+"lightning__spark_exit__needs_funding_text" = "A saída precisa de mais {amount} para pagar suas taxas. Financie para continuar.";
+"lightning__spark_exit__failed_title" = "Saída\ninterrompida";
+"lightning__spark_exit__cancel" = "Cancelar saída";
+"lightning__spark_exit__failed_text" = "Nada pôde ser recuperado.";
+"lightning__spark_exit__failed_stays" = "{amount} fica na Spark.";
+"lightning__spark_exit__notification_title" = "Saída de emergência";
+"lightning__spark_exit__notification_body" = "Abra a Bitkit para continuar sua saída.";
+"lightning__spark_exit__notification_funding" = "Sua saída precisa de mais taxas para continuar. Abra a Bitkit para financiá-la.";
+"lightning__spark_exit__bg_refresh_off" = "A Atualização em 2º Plano está desativada para a Bitkit, então a saída só avança enquanto a Bitkit estiver aberta. Ative-a nos Ajustes para deixá-la rodar em segundo plano.";
+"lightning__spark_exit__low_power" = "O Modo Pouca Energia pausa o trabalho em segundo plano. A saída continua quando ele for desativado, ou quando você abrir a Bitkit.";
+"lightning__spark_exit__notifications_off" = "As notificações estão desativadas, então a Bitkit não pode avisar quando a saída precisar de você. Ative-as nos Ajustes para receber lembretes.";
+"lightning__spark_exit__not_moving" = "A saída não está avançando: {error}";
+"lightning__spark_exit__fee_low" = "A rede está mais congestionada do que a taxa desta saída de {rate} sat/vB. Pague uma taxa maior, ou espere as taxas caírem.";
+"lightning__spark_exit__waiting_leaves" = "Parte desta saída não pode ser lida on-chain no momento. A Bitkit tenta novamente por até uma semana.";
+"lightning__spark_exit__rebuilt_from_chain" = "A Bitkit não conseguiu ler o progresso salvo desta saída, então o reconstrói a partir da blockchain.";
+"lightning__spark_exit__done_received" = "{amount} chegou à sua poupança.";
+"lightning__spark_exit__leftover_rejected" = "{amount} de fundos para taxas restantes não pôde ser enviado e fica em {address}, caminho de derivação {path}.";
+"lightning__spark_exit__notification_not_moving" = "Sua saída de emergência não está avançando. Abra a Bitkit para ver o motivo.";
+"lightning__spark_exit__funding_split" = "A Bitkit divide em {count} partes, uma para cada etapa, para que uma etapa travada não segure as outras.";
+"lightning__spark_exit__waiting_operators" = "{amount} que os operadores da Spark moveram on-chain aguarda que eles o liberem. A Bitkit pede novamente por até uma semana.";
+"lightning__spark_exit__fee_rose" = "As taxas de rede subiram acima da taxa desta saída. Ela pode ficar mais lenta.";
+"lightning__spark_exit__fee_bump_button" = "Pagar uma taxa maior";
+"lightning__spark_exit__fee_bump_title" = "Pagar uma\ntaxa maior";
+"lightning__spark_exit__fee_bump_text" = "Esta saída paga {rate} sat/vB, menos do que a rede pede agora. Escolha uma taxa maior e a Bitkit envia novamente as etapas pendentes. Ela paga a diferença da poupança: o valor ao lado de cada velocidade.";
+"lightning__spark_exit__fee_bump_error" = "Não foi possível calcular o preço de uma taxa maior. Tente novamente.";
+"lightning__spark_exit__fee_bump_confirm" = "Usar esta taxa";
+"lightning__spark_exit__leftover_waiting" = "{amount} de troco das taxas chegará à sua poupança quando as taxas de rede caírem.";
+"lightning__spark_exit__cancelled_title" = "Saída\ncancelada";
+"lightning__spark_exit__cancelled_text" = "Você cancelou a saída. Os fundos para taxas que chegaram voltam para a poupança.";
+"lightning__spark_exit__cancel_failed" = "Não foi possível verificar o endereço de financiamento. Tente novamente.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Uma taxa maior deixaria parte desta saída na Spark, então nenhuma é oferecida. A saída é confirmada quando as taxas de rede caírem.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
index 1457e5319..c4bed456c 100644
--- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings
@@ -370,6 +370,90 @@
"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" = "Saída de emergência";
+"lightning__spark_exit__nav_title" = "Saída de Emergência";
+"lightning__spark_exit__intro_title" = "Sair da Spark\non-chain";
+"lightning__spark_exit__intro_text" = "Use isto apenas se a Spark deixar de funcionar para si. Move o seu saldo de gastos para a poupança sem os operadores da Spark.\n\nDemora até 4 semanas, tem taxas on-chain pagas da poupança, e montantes muito pequenos podem ficar para trás. Corre em segundo plano, e a Bitkit notifica-o se precisar de si.";
+"lightning__spark_exit__quote_title" = "Cotação\nda saída";
+"lightning__spark_exit__amount" = "Montante a retirar";
+"lightning__spark_exit__fees" = "Taxas de rede";
+"lightning__spark_exit__receive" = "Recebe cerca de";
+"lightning__spark_exit__savings_fee" = "Taxa do pagamento da poupança";
+"lightning__spark_exit__savings_refused" = "O seu pagamento da poupança não foi concluído. Pague novamente ou financie a partir de outra carteira.";
+"lightning__spark_exit__savings_total" = "Total da poupança";
+"lightning__spark_exit__funding" = "Fundos para taxas necessários";
+"lightning__spark_exit__left_behind" = "Fica na Spark";
+"lightning__spark_exit__outside_exit" = "{amount} na Spark não faz parte desta saída e fica na Spark.";
+"lightning__spark_exit__quote_loading" = "A obter uma cotação…";
+"lightning__spark_exit__quote_settling" = "A aguardar que a Spark termine de mover o seu saldo. Isto pode demorar até 5 minutos.";
+"lightning__spark_exit__quote_error" = "Não foi possível obter uma cotação da saída. Tente novamente.";
+"lightning__spark_exit__nothing" = "Nada pode ser retirado neste momento.";
+"lightning__spark_exit__uneconomic" = "As taxas são superiores ao que receberia.";
+"lightning__spark_exit__funding_title" = "Financiar as\ntaxas";
+"lightning__spark_exit__funding_text" = "A saída paga as suas taxas a partir de um pagamento on-chain separado de {amount}. O que não gastar volta para a poupança no final.";
+"lightning__spark_exit__fund_savings" = "Pagar da poupança";
+"lightning__spark_exit__fund_external" = "Financiar a partir de outra carteira";
+"lightning__spark_exit__fund_external_text" = "Envie pelo menos {amount} para este endereço. A saída começa assim que for confirmado.";
+"lightning__spark_exit__fund_insufficient" = "A sua poupança não cobre os fundos para taxas.";
+"lightning__spark_exit__fund_seen" = "Pagamento detetado.";
+"lightning__spark_exit__fund_partial" = "{received} de {required} recebidos, envie mais {missing}.";
+"lightning__spark_exit__confirm_title" = "Iniciar\nsaída?";
+"lightning__spark_exit__confirm_text" = "Não poderá usar estes fundos durante até 4 semanas.";
+"lightning__spark_exit__swipe" = "Deslizar Para Sair";
+"lightning__spark_exit__progress_title" = "Saída em\ncurso";
+"lightning__spark_exit__progress_text" = "Isto corre em segundo plano. A Bitkit notifica-o se precisar de si.";
+"lightning__spark_exit__step_funding" = "Confirmação dos fundos para taxas";
+"lightning__spark_exit__step_publish" = "A publicar as transações de saída";
+"lightning__spark_exit__step_timelock" = "A aguardar o bloqueio temporal";
+"lightning__spark_exit__step_sweep" = "A enviar para a poupança";
+"lightning__spark_exit__ready_in" = "Pronto em {duration}";
+"lightning__spark_exit__done_title" = "Saída\nconcluída";
+"lightning__spark_exit__done_text" = "Os seus fundos estão na poupança.";
+"lightning__spark_exit__partial_text" = "{count} montantes pequenos ficaram na Spark.";
+"lightning__spark_exit__failed_leaves" = "{amount} foi movido on-chain pelos operadores da Spark e não está na Spark. Os operadores ainda o podem libertar para si.";
+"lightning__spark_exit__leftover" = "{amount} dos fundos para taxas ainda está em {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} de fundos para taxas restantes foi enviado para a poupança.";
+"lightning__spark_exit__leftover_sending" = "A enviar {amount} de fundos para taxas restantes para a poupança.";
+"lightning__spark_exit__leftover_dust" = "{amount} de fundos para taxas restantes é demasiado pequeno para enviar e fica em {address}, caminho de derivação {path}.";
+"lightning__spark_exit__fund_paid" = "Pago da poupança, a aguardar confirmação.";
+"lightning__spark_exit__fund_pending" = "Pagamento detetado, a aguardar confirmação.";
+"lightning__spark_exit__lost_leaves" = "{amount} foi reclamado on-chain por um antigo dono destes fundos e não pode ser recuperado.";
+"lightning__spark_exit__notification_lost" = "{amount} da sua saída não pode ser recuperado. Abra a Bitkit para ver porquê.";
+"lightning__spark_exit__notification_failed" = "A sua saída de emergência parou. Abra a Bitkit para ver porquê.";
+"lightning__spark_exit__quote_outside" = "{amount} do seu saldo Spark não pode ser retirado neste momento: montantes demasiado pequenos para pagar as próprias taxas, ou ainda em movimento dentro da Spark. Fica na Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} não pode ser retirado com esta taxa e fica na Spark.";
+"lightning__spark_exit__needs_funding_title" = "Mais taxas\nnecessárias";
+"lightning__spark_exit__needs_funding_text" = "A saída precisa de mais {amount} para pagar as suas taxas. Financie-a para continuar.";
+"lightning__spark_exit__failed_title" = "Saída\nparada";
+"lightning__spark_exit__cancel" = "Cancelar saída";
+"lightning__spark_exit__failed_text" = "Não foi possível recuperar nada.";
+"lightning__spark_exit__failed_stays" = "{amount} fica na Spark.";
+"lightning__spark_exit__notification_title" = "Saída de emergência";
+"lightning__spark_exit__notification_body" = "Abra a Bitkit para continuar a sua saída.";
+"lightning__spark_exit__notification_funding" = "A sua saída precisa de mais taxas para continuar. Abra a Bitkit para a financiar.";
+"lightning__spark_exit__bg_refresh_off" = "A Atualização em segundo plano está desativada para a Bitkit, por isso a saída só avança enquanto a Bitkit estiver aberta. Ative-a nas Definições para a deixar correr em segundo plano.";
+"lightning__spark_exit__low_power" = "O Modo de baixo consumo pausa o trabalho em segundo plano. A saída continua quando for desativado, ou quando abrir a Bitkit.";
+"lightning__spark_exit__notifications_off" = "As notificações estão desativadas, por isso a Bitkit não o pode avisar quando a saída precisar de si. Ative-as nas Definições para receber lembretes.";
+"lightning__spark_exit__not_moving" = "A saída não está a avançar: {error}";
+"lightning__spark_exit__fee_low" = "A rede está mais congestionada do que a taxa desta saída de {rate} sat/vB. Pague uma taxa mais alta, ou aguarde que as taxas desçam.";
+"lightning__spark_exit__waiting_leaves" = "Parte desta saída não pode ser lida on-chain neste momento. A Bitkit tenta novamente durante até uma semana.";
+"lightning__spark_exit__rebuilt_from_chain" = "A Bitkit não conseguiu ler o progresso guardado desta saída, por isso reconstrói-o a partir da blockchain.";
+"lightning__spark_exit__done_received" = "{amount} chegou à sua poupança.";
+"lightning__spark_exit__leftover_rejected" = "{amount} de fundos para taxas restantes não pôde ser enviado e fica em {address}, caminho de derivação {path}.";
+"lightning__spark_exit__notification_not_moving" = "A sua saída de emergência não está a avançar. Abra a Bitkit para ver porquê.";
+"lightning__spark_exit__funding_split" = "A Bitkit divide-o em {count} partes, uma para cada passo, para que um passo bloqueado não atrase os outros.";
+"lightning__spark_exit__waiting_operators" = "{amount} que os operadores da Spark moveram on-chain aguarda que o libertem. A Bitkit volta a pedir durante até uma semana.";
+"lightning__spark_exit__fee_rose" = "As taxas de rede subiram acima da taxa desta saída. Pode ficar mais lenta.";
+"lightning__spark_exit__fee_bump_button" = "Pagar uma taxa mais alta";
+"lightning__spark_exit__fee_bump_title" = "Pagar uma\ntaxa mais alta";
+"lightning__spark_exit__fee_bump_text" = "Esta saída paga {rate} sat/vB, menos do que a rede pede agora. Escolha uma taxa mais alta e a Bitkit volta a enviar os passos em espera. Paga a diferença da poupança: o montante ao lado de cada velocidade.";
+"lightning__spark_exit__fee_bump_error" = "Não foi possível calcular o preço de uma taxa mais alta. Tente novamente.";
+"lightning__spark_exit__fee_bump_confirm" = "Usar esta taxa";
+"lightning__spark_exit__leftover_waiting" = "{amount} de troco das taxas chegará à sua poupança quando as taxas de rede descerem.";
+"lightning__spark_exit__cancelled_title" = "Saída\ncancelada";
+"lightning__spark_exit__cancelled_text" = "Cancelou a saída. Os fundos para taxas que chegaram voltam para a poupança.";
+"lightning__spark_exit__cancel_failed" = "Não foi possível verificar o endereço de financiamento. Tente novamente.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Uma taxa mais alta deixaria parte desta saída na Spark, por isso não é oferecida nenhuma. A saída confirma quando as taxas de rede descerem.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
index 3dcab3259..659e45965 100644
--- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings
@@ -367,6 +367,90 @@
"lightning__spark_provider_value" = "Spark через Breez";
"lightning__spark_receiving_unlimited" = "Без ограничений";
"lightning__spark_trust" = "Этот баланс хранится в Spark, которым управляют Lightspark, Breez и Flashnet. Он в безопасности, пока хотя бы один из них действует честно. Они могут приостановить доступ, а выход без них требует ончейн-вывода.";
+"lightning__spark_exit__button" = "Экстренный выход";
+"lightning__spark_exit__nav_title" = "Экстренный Выход";
+"lightning__spark_exit__intro_title" = "Выход из Spark\nончейн";
+"lightning__spark_exit__intro_text" = "Используйте это, только если Spark перестал у вас работать. Выход переводит ваш баланс расходов в сбережения без операторов Spark.\n\nОн занимает до 4 недель, требует ончейн-комиссий, оплачиваемых из сбережений, а очень маленькие суммы могут остаться. Выход идёт в фоне, и Bitkit уведомит вас, если вы понадобитесь.";
+"lightning__spark_exit__quote_title" = "Расчёт\nвыхода";
+"lightning__spark_exit__amount" = "Сумма к выводу";
+"lightning__spark_exit__fees" = "Сетевые комиссии";
+"lightning__spark_exit__receive" = "Вы получите примерно";
+"lightning__spark_exit__savings_fee" = "Комиссия платежа из сбережений";
+"lightning__spark_exit__savings_refused" = "Платёж из сбережений не прошёл. Оплатите снова или пополните с другого кошелька.";
+"lightning__spark_exit__savings_total" = "Всего из сбережений";
+"lightning__spark_exit__funding" = "Нужно средств на комиссии";
+"lightning__spark_exit__left_behind" = "Остаётся в Spark";
+"lightning__spark_exit__outside_exit" = "{amount} в Spark не входит в этот выход и остаётся в Spark.";
+"lightning__spark_exit__quote_loading" = "Получаем расчёт…";
+"lightning__spark_exit__quote_settling" = "Ждём, пока Spark завершит перемещение вашего баланса. Это может занять до 5 минут.";
+"lightning__spark_exit__quote_error" = "Не удалось получить расчёт выхода. Попробуйте снова.";
+"lightning__spark_exit__nothing" = "Сейчас нечего выводить.";
+"lightning__spark_exit__uneconomic" = "Комиссии выше суммы, которую вы получите.";
+"lightning__spark_exit__funding_title" = "Оплатите\nкомиссии";
+"lightning__spark_exit__funding_text" = "Выход оплачивает комиссии отдельным ончейн-платежом на {amount}. Неизрасходованное вернётся в сбережения в конце.";
+"lightning__spark_exit__fund_savings" = "Оплатить из сбережений";
+"lightning__spark_exit__fund_external" = "Пополнить с другого кошелька";
+"lightning__spark_exit__fund_external_text" = "Отправьте не менее {amount} на этот адрес. Выход начнётся после подтверждения.";
+"lightning__spark_exit__fund_insufficient" = "В ваших сбережениях недостаточно средств на комиссии.";
+"lightning__spark_exit__fund_seen" = "Платёж обнаружен.";
+"lightning__spark_exit__fund_partial" = "Получено {received} из {required}, отправьте ещё {missing}.";
+"lightning__spark_exit__confirm_title" = "Начать\nвыход?";
+"lightning__spark_exit__confirm_text" = "Вы не сможете пользоваться этими средствами до 4 недель.";
+"lightning__spark_exit__swipe" = "Проведите для Выхода";
+"lightning__spark_exit__progress_title" = "Выход\nв процессе";
+"lightning__spark_exit__progress_text" = "Выход идёт в фоне. Bitkit уведомит вас, если вы понадобитесь.";
+"lightning__spark_exit__step_funding" = "Подтверждение оплаты комиссий";
+"lightning__spark_exit__step_publish" = "Публикация транзакций выхода";
+"lightning__spark_exit__step_timelock" = "Ожидание таймлока";
+"lightning__spark_exit__step_sweep" = "Отправка в сбережения";
+"lightning__spark_exit__ready_in" = "Готово через {duration}";
+"lightning__spark_exit__done_title" = "Выход\nзавершён";
+"lightning__spark_exit__done_text" = "Ваши средства в сбережениях.";
+"lightning__spark_exit__partial_text" = "Небольших сумм, оставшихся в Spark: {count}.";
+"lightning__spark_exit__failed_leaves" = "{amount} перевели ончейн операторы Spark, и в Spark этой суммы нет. Операторы всё ещё могут выдать её вам.";
+"lightning__spark_exit__leftover" = "{amount} средств на комиссии всё ещё на адресе {address}.";
+"lightning__spark_exit__leftover_swept" = "{amount} оставшихся средств на комиссии отправлено в сбережения.";
+"lightning__spark_exit__leftover_sending" = "Отправляем {amount} оставшихся средств на комиссии в сбережения.";
+"lightning__spark_exit__leftover_dust" = "{amount} оставшихся средств на комиссии слишком мало для отправки, они остаются на адресе {address}, путь деривации {path}.";
+"lightning__spark_exit__fund_paid" = "Оплачено из сбережений, ждём подтверждения.";
+"lightning__spark_exit__fund_pending" = "Платёж обнаружен, ждём подтверждения.";
+"lightning__spark_exit__lost_leaves" = "{amount} забрал ончейн один из прежних владельцев этих средств, их нельзя вернуть.";
+"lightning__spark_exit__notification_lost" = "{amount} из вашего выхода нельзя вернуть. Откройте Bitkit, чтобы узнать причину.";
+"lightning__spark_exit__notification_failed" = "Ваш экстренный выход остановлен. Откройте Bitkit, чтобы узнать причину.";
+"lightning__spark_exit__quote_outside" = "{amount} вашего баланса Spark сейчас нельзя вывести: суммы слишком малы, чтобы покрыть свои комиссии, или ещё перемещаются внутри Spark. Они остаются в Spark.";
+"lightning__spark_exit__left_behind_leaves" = "{amount} нельзя вывести при этой ставке комиссии, сумма остаётся в Spark.";
+"lightning__spark_exit__needs_funding_title" = "Нужно больше\nкомиссий";
+"lightning__spark_exit__needs_funding_text" = "Выходу нужно ещё {amount} на комиссии. Пополните, чтобы продолжить.";
+"lightning__spark_exit__failed_title" = "Выход\nостановлен";
+"lightning__spark_exit__cancel" = "Отменить выход";
+"lightning__spark_exit__failed_text" = "Ничего не удалось вернуть.";
+"lightning__spark_exit__failed_stays" = "{amount} остаётся в Spark.";
+"lightning__spark_exit__notification_title" = "Экстренный выход";
+"lightning__spark_exit__notification_body" = "Откройте Bitkit, чтобы продолжить выход.";
+"lightning__spark_exit__notification_funding" = "Для продолжения выходу нужно больше комиссий. Откройте Bitkit, чтобы пополнить.";
+"lightning__spark_exit__bg_refresh_off" = "Фоновое обновление приложений для Bitkit выключено, поэтому выход продвигается только при открытом Bitkit. Включите его в Настройках, чтобы выход шёл в фоне.";
+"lightning__spark_exit__low_power" = "Режим энергосбережения приостанавливает фоновую работу. Выход продолжится, когда режим будет выключен или когда вы откроете Bitkit.";
+"lightning__spark_exit__notifications_off" = "Уведомления выключены, поэтому Bitkit не сможет сообщить, когда выходу понадобится ваше участие. Включите их в Настройках, чтобы получать напоминания.";
+"lightning__spark_exit__not_moving" = "Выход не продвигается: {error}";
+"lightning__spark_exit__fee_low" = "Сеть загружена сильнее, чем позволяет ставка комиссии этого выхода в {rate} sat/vB. Заплатите более высокую комиссию или дождитесь снижения комиссий.";
+"lightning__spark_exit__waiting_leaves" = "Часть этого выхода сейчас не удаётся прочитать в блокчейне. Bitkit будет пробовать снова в течение недели.";
+"lightning__spark_exit__rebuilt_from_chain" = "Bitkit не смог прочитать сохранённый прогресс этого выхода и восстанавливает его по блокчейну.";
+"lightning__spark_exit__done_received" = "{amount} поступило в ваши сбережения.";
+"lightning__spark_exit__leftover_rejected" = "{amount} оставшихся средств на комиссии не удалось отправить, они остаются на адресе {address}, путь деривации {path}.";
+"lightning__spark_exit__notification_not_moving" = "Ваш экстренный выход не продвигается. Откройте Bitkit, чтобы узнать причину.";
+"lightning__spark_exit__funding_split" = "Bitkit делит их на части ({count}), по одной на каждый шаг, чтобы один застрявший шаг не задерживал остальные.";
+"lightning__spark_exit__waiting_operators" = "{amount}, которые операторы Spark перевели ончейн, ждут, пока операторы их выдадут. Bitkit будет запрашивать снова в течение недели.";
+"lightning__spark_exit__fee_rose" = "Сетевые комиссии превысили комиссию этого выхода. Он может замедлиться.";
+"lightning__spark_exit__fee_bump_button" = "Заплатить больше";
+"lightning__spark_exit__fee_bump_title" = "Повысить\nкомиссию";
+"lightning__spark_exit__fee_bump_text" = "Этот выход платит {rate} sat/vB, меньше, чем сейчас требует сеть. Выберите более высокую ставку, и Bitkit заново отправит ожидающие шаги. Разницу он оплатит из сбережений: сумма указана рядом с каждой скоростью.";
+"lightning__spark_exit__fee_bump_error" = "Не удалось рассчитать более высокую комиссию. Попробуйте снова.";
+"lightning__spark_exit__fee_bump_confirm" = "Использовать эту комиссию";
+"lightning__spark_exit__leftover_waiting" = "{amount} сдачи с комиссий поступит в ваши сбережения, когда сетевые комиссии снизятся.";
+"lightning__spark_exit__cancelled_title" = "Выход\nотменён";
+"lightning__spark_exit__cancelled_text" = "Вы отменили выход. Поступившие средства на комиссии вернутся в сбережения.";
+"lightning__spark_exit__cancel_failed" = "Не удалось проверить адрес для оплаты комиссий. Попробуйте снова.";
+"lightning__spark_exit__fee_bump_leaves_behind" = "Более высокая комиссия оставила бы часть этого выхода в Spark, поэтому она не предлагается. Выход подтвердится, когда сетевые комиссии снизятся.";
"lightning__spark_learn__title" = "Transfer\nspeed";
"lightning__spark_learn__text" = "Instant pays the provider to add your transfer to your spending balance before it confirms, so it is ready in a few minutes.\n\nLater waits for 3 confirmations, about 30 minutes, and costs less.";
"lightning__spark_learn__label" = "Spending balance after transfer";
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index 999e7e96e..536de26ed 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -135,6 +135,7 @@ class BackupService {
/// export never replaces the remote backup it couldn't read yet.
private static let sparkExitRestorePendingKey = "sparkExitRestorePending"
private let backupData: ((BackupCategory) async throws -> Data)?
+ private let isSparkExitActive: () async -> Bool
private let uploadBackup: (String, Data) async throws -> Void
private let waitForBackupDelay: () async throws -> Void
private let waitForTemporaryFailureDelay: (UInt64) async throws -> Void
@@ -165,6 +166,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)
@@ -183,6 +185,7 @@ class BackupService {
}
) {
self.defaults = defaults
+ self.isSparkExitActive = isSparkExitActive
self.fetchBackup = fetchBackup
self.backupData = backupData
self.usdtRecoveryBackup = usdtRecoveryBackup
@@ -789,6 +792,9 @@ class BackupService {
// App-owned, so it takes no part in the core field migration above and never sets
// needsRewrite. Restored names wait as pending ones until each wallet is paired again.
HwKnownDeviceStorage.restoreNames(payload.hwWalletNames ?? [:])
+ SparkExitEarlierAddressBackup.restore(payload.sparkExitEarlierAddresses, network: "\(Env.network)") {
+ SparkExitEarlierAddressStore(walletIndex: $0)
+ }
// Force address rotation by clearing onchain address
UserDefaults.standard.set("", forKey: "onchainAddress")
@@ -953,6 +959,15 @@ class BackupService {
}
.store(in: cancellables)
+ // METADATA (earlier Spark exit addresses)
+ SparkExitEarlierAddressStore.savedPublisher
+ .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
+ .sink { @Sendable [weak self] _ in
+ guard let self, !self.shouldSkipBackup() else { return }
+ markBackupRequired(category: .metadata)
+ }
+ .store(in: cancellables)
+
// APP STATE (UserDefaults changes, etc.)
SettingsViewModel.shared.appStatePublisher
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
@@ -1380,7 +1395,11 @@ class BackupService {
cache: cache,
pubkySession: pubkySession,
pubkyContactProfileOverrides: pubkyContactProfileOverrides,
- hwWalletNames: hwWalletNames.isEmpty ? nil : hwWalletNames
+ hwWalletNames: hwWalletNames.isEmpty ? nil : hwWalletNames,
+ sparkExitEarlierAddresses: SparkExitEarlierAddressBackup.backup(
+ SparkExitEarlierAddressStore(walletIndex: LightningService.shared.currentWalletIndex),
+ walletKey: SparkExitFundingIndexes.walletKey(network: "\(Env.network)", walletIndex: LightningService.shared.currentWalletIndex)
+ )
)
return try JSONEncoder().encode(payload)
@@ -1451,7 +1470,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/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift
index 119137588..e8ca82e3c 100644
--- a/Bitkit/Services/LightningService.swift
+++ b/Bitkit/Services/LightningService.swift
@@ -147,6 +147,9 @@ class LightningService: NodeLifecycleService {
/// Node sends under way, so a listing that shows only an earlier failed attempt doesn't drop their hash.
private var ldkSendsInFlight: Set = []
weak var channelFreeRail: ChannelFreeLightningRail?
+ /// The rail whose payments the duplicate-payment check reads, kept while an emergency exit takes Spark off
+ /// Lightning: an invoice Spark paid or is paying is never paid again over a channel.
+ weak var channelFreePaymentHistory: ChannelFreeLightningRail?
var onchainTransactionConfirmed: (@Sendable (String) async -> Void)?
var onchainTransactionReceived: (@Sendable (String) async -> Void)?
typealias OnchainActivityCompletion = @MainActor () async -> Void
@@ -2240,7 +2243,8 @@ extension LightningService {
/// here but isn't connected, or no sync has reached it yet, so a payment it may hold is never read as one it never
/// made.
func channelFreeRailPayments() async throws -> [ChannelFreeRailPayment] {
- if let rail = channelFreeRail {
+ // During an emergency exit Spark is off the rail but still lists the payments it made.
+ if let rail = channelFreeRail ?? channelFreePaymentHistory {
// A restored wallet fills its list on its first sync, so before a sync reaches Spark a missing send proves
// nothing.
guard await rail.hasSynced(after: nil) else { throw ChannelFreeRailUnavailable() }
diff --git a/Bitkit/Services/OnchainSendAttemptService.swift b/Bitkit/Services/OnchainSendAttemptService.swift
index 4790a7bf4..e802537af 100644
--- a/Bitkit/Services/OnchainSendAttemptService.swift
+++ b/Bitkit/Services/OnchainSendAttemptService.swift
@@ -607,6 +607,7 @@ actor OnchainSendAttemptService {
followupContext: OnchainSendFollowupContext? = nil,
transferContext: OnchainSendTransferContext? = nil,
isSparkFunding: Bool = false,
+ approvedMiningFeeSats: UInt64? = nil,
paymentDeadline: PaykitPreciseInstant? = nil,
beforeBroadcastAttempt: () async throws -> Void = {},
onPreDispatchFailure: (Error) async throws -> Void = { _ in throw OnchainSendAttemptError.preDispatchCleanupFailed }
@@ -656,9 +657,9 @@ actor OnchainSendAttemptService {
else { throw OnchainSendAttemptError.unresolved }
}
// Savings never pay more than the Total the user confirmed, so a higher fee asks for another swipe.
- if isSparkFunding {
+ if isSparkFunding || approvedMiningFeeSats != nil {
guard prepared.recipientAmountSats == amountSats, let miningFee = prepared.miningFeeSats,
- let approvedFee = followupContext?.feeSats, miningFee <= approvedFee
+ let approvedFee = approvedMiningFeeSats ?? followupContext?.feeSats, miningFee <= approvedFee
else { throw SparkFundingChanged(preparedFeeSats: prepared.miningFeeSats) }
}
attempt.amountSats = prepared.recipientAmountSats
@@ -965,6 +966,13 @@ actor OnchainSendAttemptService {
catch { throw OnchainSendAttemptError.unresolved }
}
+ /// The address of the send still unresolved (pending, unknown or refused, so retryable or blocking a new send), if any.
+ /// A retry always pays this same address with the same inputs.
+ func unresolvedSendAddress() -> String? {
+ guard let attempt = try? currentAttempt(), attempt.status.blocksNewSend else { return nil }
+ return attempt.address
+ }
+
private func currentAttempt() throws -> OnchainSendAttempt? {
let attempts = try store.load()
guard attempts.count <= 1 else { throw OnchainSendAttemptError.unresolved }
diff --git a/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift
new file mode 100644
index 000000000..9e12a6ae9
--- /dev/null
+++ b/Bitkit/Services/SparkExit/SparkExitBackgroundRun.swift
@@ -0,0 +1,185 @@
+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()
+ do {
+ record = try await makeDriver(chain, nil).tick(record)
+ } catch {
+ Logger.warn("Spark exit keyless pass failed: \(error)", context: "SparkExitBackgroundRun")
+ // The pass saved along the way: go on from the record on disk, never from the copy it started with.
+ if let stored = try? load() {
+ record = stored
+ }
+ }
+ timings.add(.keyless, now().timeIntervalSince(keylessStarted))
+
+ 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")
+ if let stored = try? load() {
+ record = stored
+ }
+ }
+ }
+ 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) }
+ }
+
+ 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) }
+ }
+}
+
+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 isAddressUsed(address: String) async throws -> Bool {
+ try await base.isAddressUsed(address: address)
+ }
+
+ func txStatus(txid: String) async throws -> EsploraTxStatus {
+ try await base.txStatus(txid: txid)
+ }
+
+ 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) }
+ }
+
+ func fastestFeeRate() async throws -> UInt64 {
+ try await base.fastestFeeRate()
+ }
+
+ /// Forwarded, or the protocol's default (every tier at the fastest rate) would price background sweeps.
+ func networkFees() async throws -> SparkExitNetworkFees {
+ try await base.networkFees()
+ }
+}
diff --git a/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift
new file mode 100644
index 000000000..bc5ae86b6
--- /dev/null
+++ b/Bitkit/Services/SparkExit/SparkExitBroadcaster.swift
@@ -0,0 +1,452 @@
+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)"
+ }
+ }
+}
+
+/// Reads esplora's `/fee-estimates` (target in blocks → sat/vB). A host may answer `{}` or skip targets, as the local
+/// regtest mempool does: a missing target falls back to the next one estimated, the nearest longer one first, and
+/// with none at all to the 1 sat/vB relay floor, so no step fails for want of an estimate.
+enum SparkExitFeeEstimates {
+ /// A host's answer with rates but none usable: the estimate is unavailable, as when the host can't be read.
+ struct Unusable: Error {}
+
+ /// The estimates a host answered, less any rate that is not finite, not above 0 or above `maxRate`; throws when
+ /// it answered rates and none is usable.
+ static func validated(_ estimates: [String: Double]) throws -> [String: Double] {
+ let usable = estimates.filter { isUsable($0.value) }
+ guard usable.isEmpty, !estimates.isEmpty else { return usable }
+ Logger.warn("Spark exit fee estimates had no usable rate: \(estimates)", context: "SparkExitFeeEstimates")
+ throw Unusable()
+ }
+
+ /// The most a rate may be, in sat/vB: anything above is a faulty host, never converted.
+ static let maxRate: Double = 10000
+
+ private static func isUsable(_ rate: Double) -> Bool {
+ rate.isFinite && rate > 0 && rate <= maxRate
+ }
+
+ static func rate(_ estimates: [String: Double], target: Int) -> UInt64 {
+ let targets = parsed(estimates)
+ guard let value = (targets.first { $0.target >= target } ?? targets.last)?.rate else { return SparkExitMath.minRelayFeeRate }
+ return rounded(value)
+ }
+
+ /// The slowest tier: the longest target estimated (mempool's `economyFee` is its day-long rate).
+ static func economy(_ estimates: [String: Double]) -> UInt64 {
+ parsed(estimates).last.map { rounded($0.rate) } ?? SparkExitMath.minRelayFeeRate
+ }
+
+ private static func parsed(_ estimates: [String: Double]) -> [(target: Int, rate: Double)] {
+ estimates.filter { isUsable($0.value) }
+ .compactMap { key, value in Int(key).map { (target: $0, rate: value) } }
+ .sorted { $0.target < $1.target }
+ }
+
+ private static func rounded(_ rate: Double) -> UInt64 {
+ max(SparkExitMath.minRelayFeeRate, UInt64(max(0, rate).rounded(.up)))
+ }
+}
+
+/// The chain the Spark SDK reads, for what an exit does outside the SDK: broadcasting and the funding address.
+protocol SparkExitChain: Sendable {
+ func tipHeight() async throws -> UInt32
+ func utxos(address: String) async throws -> [EsploraUtxo]
+ /// Whether the address has any transaction, in a block or the mempool, from `/address/{address}`.
+ func isAddressUsed(address: String) async throws -> Bool
+ func txStatus(txid: String) async throws -> EsploraTxStatus
+ /// Whether the transaction is in a block or in the mempool; false once every host answers it has no such
+ /// transaction.
+ 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
+ /// 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, economy: fastest)
+ }
+}
+
+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: 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 {
+ 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()
+
+ 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 chain service Spark connects with, `regtestChainServiceUrl` (`SPARK_CHAIN_SERVICE_URL`), does both,
+ /// else the SDK's default host (breez spark-sdk `default_rest_chain_services`).
+ static func network(
+ _ network: BreezSdkSpark.Network,
+ regtestChainServiceUrl: String? = Env.sparkChainServiceUrl(for: .regtest)
+ ) -> EsploraSparkExitChain {
+ switch network {
+ case .mainnet:
+ let electrs = URL(string: Env.sparkChainServiceUrl(for: .bitcoin)!)!
+ 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:
+ let configured = regtestChainServiceUrl.flatMap(URL.init(string:))
+ return EsploraSparkExitChain(hosts: [configured ?? URL(string: "https://regtest-mempool.us-west-2.sparkinfra.net/api")!])
+ default:
+ return EsploraSparkExitChain(hosts: [])
+ }
+ }
+
+ 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 isAddressUsed(address: String) async throws -> Bool {
+ struct Stats: Decodable {
+ let txCount: Int
+
+ enum CodingKeys: String, CodingKey {
+ case txCount = "tx_count"
+ }
+ }
+ struct Address: Decodable {
+ let chainStats: Stats
+ let mempoolStats: Stats
+
+ enum CodingKeys: String, CodingKey {
+ case chainStats = "chain_stats"
+ case mempoolStats = "mempool_stats"
+ }
+ }
+ let stats = try await JSONDecoder().decode(Address.self, from: get("/address/\(address)"))
+ return stats.chainStats.txCount + stats.mempoolStats.txCount > 0
+ }
+
+ func txStatus(txid: String) async throws -> EsploraTxStatus {
+ try await JSONDecoder().decode(EsploraTxStatus.self, from: get("/tx/\(txid)/status"))
+ }
+
+ /// esplora's `/fee-estimates`, whose target of one block is mempool's `fastestFee`.
+ func fastestFeeRate() async throws -> UInt64 {
+ try await SparkExitFeeEstimates.rate(estimates(), target: 1)
+ }
+
+ private func estimates() async throws -> [String: Double] {
+ try await SparkExitFeeEstimates.validated(JSONDecoder().decode([String: Double].self, from: get("/fee-estimates")))
+ }
+
+ /// esplora's targets of one and three blocks, mempool's `fastestFee` and `halfHourFee`.
+ func networkFees() async throws -> SparkExitNetworkFees {
+ let estimates = try await estimates()
+ return SparkExitNetworkFees(
+ fastest: SparkExitFeeEstimates.rate(estimates, target: 1),
+ halfHour: SparkExitFeeEstimates.rate(estimates, target: 3),
+ economy: SparkExitFeeEstimates.economy(estimates)
+ )
+ }
+
+ /// `/tx/{txid}/hex` looks in the mempool and the chain alike; `/status` of an unknown transaction may answer
+ /// 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] = []
+ for host in broadcastHosts {
+ 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 {
+ 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: 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)
+ 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 {
+ 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 readHosts {
+ 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
+ }
+ }
+ if readHosts.isEmpty {
+ throw URLError(.cannotFindHost)
+ }
+ if let lastError {
+ throw lastError
+ }
+ return nil
+ }
+}
+
+/// 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",
+ // Core's newer wording, which esplora hosts pass on as plain text.
+ "already in utxo set",
+ ]
+ private static let notMatureMessages = ["non-BIP68-final", "non-final"]
+ private static let missingInputMessages = ["bad-txns-inputs-missingorspent", "missing-inputs", "missingorspent"]
+
+ 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
+ }
+
+ /// 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 {
+ guard case let .rejected(text) = outcome else { return false }
+ 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
+ }
+ 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, or as esplora's
+ /// plain text (`sendrawtransaction RPC error -27: …`).
+ private static func hasRpcCode(_ code: Int, in text: String) -> Bool {
+ text.range(of: #"(code\\?"\s*:\s*|RPC error )\#(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..3f07dfdea
--- /dev/null
+++ b/Bitkit/Services/SparkExit/SparkExitDriver.swift
@@ -0,0 +1,1717 @@
+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
+ func exportExitState() async throws -> String
+ func importExitState(_ exitState: String) async throws
+}
+
+enum SparkExitFeeBumpError: Error, Equatable {
+ case notConnected
+ /// At this rate a leaf could no longer pay its own exit and would stay on Spark: the rate is not offered.
+ case leavesBehind(sats: UInt64)
+}
+
+/// Moves an exit one step forward each tick: builds it once its fee funding confirms, broadcasts what is ready,
+/// 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
+ /// The fast rate of Bitkit's own fee source, nil when it cannot be read: with the exit's own rate, the most a
+ /// leftover sweep pays, whatever the chain host asks.
+ var trustedFeeRate: () async -> UInt64? = { nil }
+
+ /// A ready transaction accepted in this generation is sent again after this long, in case a node dropped it.
+ static let rebroadcastInterval: TimeInterval = 600
+ /// 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
+ /// 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
+ /// 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
+ /// A savings payment Bitkit sent that no host knows for this many blocks, or that the exit did not adopt within
+ /// `ownPaymentTimeoutBlocks`, is forgotten: the exit asks for the funding, and reminds, again. Long enough that a
+ /// host still indexing it, or one false miss, never asks for the funding twice.
+ static let ownPaymentMissingBlocks: UInt32 = 3
+ static let ownPaymentTimeoutBlocks: UInt32 = 144
+
+ @discardableResult
+ func tick(_ record: SparkExitRecord) async throws -> SparkExitRecord {
+ var record = record
+ switch record.phase {
+ case .done, .failed:
+ guard record.needsDriving, !Task.isCancelled else { return record }
+ let before = record
+ await followEndedPayment(&record)
+ if record.isLeftoverSweepPending {
+ try await sweepLeftover(&record)
+ try save(record)
+ return record
+ }
+ return try saveIfChanged(record, from: before)
+ case .awaitingFunding, .needsFunding:
+ guard !Task.isCancelled else { return record }
+ let before = record
+ await noteOwnPayment(&record)
+ // A higher fee's top-up leaves the current generation valid: it keeps being checked and sent meanwhile.
+ if record.isDrivingWhileFunding {
+ if let recovering {
+ try await driveWhileFunding(&record, recovering: recovering)
+ } else {
+ try await broadcastWithoutSdk(&record)
+ }
+ }
+ // Still waiting for funding (a generation that finished meanwhile is back to running).
+ if record.phase != .running {
+ // A skipped leaf's fee funding that never came is let go after the same week as its retries. It needs
+ // only the chain, and lands whole: while the address can't be read nothing changes, and a later tick
+ // lets go.
+ if record.isFundingWaitingLeaves, let since = record.retrySince, let tip = try? await chain.tipHeight(),
+ tip.saturatingSub(since) >= Self.releaseBlocks
+ {
+ var released = record
+ letGoOfWaitingLeaves(&released)
+ await finish(&released, phase: .done)
+ guard !released.isActive else { return try saveIfChanged(record, from: before) }
+ Logger.warn("Spark exit let go of skipped leaves whose fee funding never came", context: "SparkExitDriver")
+ record = released
+ try save(record)
+ return record
+ }
+ guard let recovering else {
+ await updateClaimableHeight(&record)
+ return try saveIfChanged(record, from: before)
+ }
+ // Nothing to fund when every leaf is recovered cooperatively.
+ var isRedoFromHeldFunding = false
+ if let shortfall = record.fundingShortfallSats {
+ let isAdopted = try await adoptFunding(&record, requiredSats: shortfall)
+ // A Redo while a top-up is awaited rebuilds from the funding the exit has: a top-up that is dropped
+ // never leaves it waiting, and one the rebuild needs is asked for again. Paced like any rebuild.
+ isRedoFromHeldFunding = !isAdopted && record.isRebuilding && record.isRedoRebuild == true
+ && !record.isFundingWaitingLeaves && isRebuildDue(record)
+ if isRedoFromHeldFunding {
+ record.lastBuildAt = now()
+ }
+ guard isAdopted || isRedoFromHeldFunding else {
+ await countShortOwnPayment(&record)
+ // The steps already out keep their countdown while the top-up is awaited.
+ await updateClaimableHeight(&record)
+ return try saveIfChanged(record, from: before)
+ }
+ }
+ if record.isFundingWaitingLeaves {
+ // Funding for leaves a partial build skipped: only they are built, now that it arrived.
+ record.phase = .running
+ let tip = try? await chain.tipHeight()
+ if try await !retryWaitingLeaves(&record, recovering: recovering, tip: tip, isFunded: true) {
+ await finish(&record, phase: .done)
+ }
+ } else if record.isUnverifiedAsk == true, !record.isRebuilding {
+ // Funding an unverified rebuild asked for resumes that rebuild, with its guard: the valid
+ // generation stays over a failed-only build, running again with the funding adopted. Once a
+ // block, like the check's rebuild: the valid generation's retry clock does not pace it.
+ let tip = try? await chain.tipHeight()
+ if tip == nil || record.unverifiedRebuiltAtHeight != tip {
+ record.unverifiedRebuiltAtHeight = tip
+ var valid = record
+ valid.phase = .running
+ valid.isUnverifiedAsk = nil
+ try await build(&record, recovering: recovering)
+ keepRetryClock(&record, from: valid)
+ }
+ } else {
+ try await build(&record, recovering: recovering)
+ // Held funding that pays for less: the awaited top-up is still followed, as while waiting for it.
+ if isRedoFromHeldFunding, case .needsFunding = record.phase {
+ await countShortOwnPayment(&record)
+ }
+ }
+ }
+ 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)
+ // Every step in a block, as the chain host shows: the exit ends without waiting for the SDK. Leaves the
+ // operators could not co-sign need the SDK's retry, so that exit waits for it.
+ if let recovery = record.recovery?.response, !recovery.transactions.isEmpty, recovery.failed.isEmpty,
+ record.waitingLeafIds == nil, recovery.transactions.allSatisfy(\.isConfirmed)
+ {
+ await finish(&record, phase: .done)
+ }
+ }
+ }
+ await updateClaimableHeight(&record)
+ try save(record)
+ return record
+ }
+
+ /// Without the tip the countdown keeps its last target rather than jump to another measure.
+ private func updateClaimableHeight(_ record: inout SparkExitRecord) async {
+ if let tip = try? await chain.tipHeight() {
+ record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response, tip: tip) }
+ } else if record.claimableAtHeight == nil {
+ record.claimableAtHeight = record.recovery.flatMap { SparkExitMath.claimableAtHeight($0.response) }
+ }
+ }
+
+ private func saveIfChanged(_ record: SparkExitRecord, from before: SparkExitRecord) throws -> SparkExitRecord {
+ if record != before {
+ try save(record)
+ }
+ return record
+ }
+
+ // MARK: Funding
+
+ /// Forgets a savings payment the exit waits for once no host knows it for `ownPaymentMissingBlocks` (dropped or
+ /// replaced), or once it is older than `ownPaymentTimeoutBlocks` without being adopted, so the ask and the reminder
+ /// come back. Nothing changes without a tip.
+ private func noteOwnPayment(_ record: inout SparkExitRecord) async {
+ guard record.isAwaitingOwnPayment || record.isEndedPaymentPending, let paid = record.fundingPaymentTxid,
+ let tip = try? await chain.tipHeight()
+ else { return }
+ let seenAt = record.fundingPaymentSeenAtHeight ?? tip
+ record.fundingPaymentSeenAtHeight = seenAt
+ switch try? await chain.isKnown(txid: paid) {
+ case true?: record.fundingPaymentMissingSince = nil
+ case false?: record.fundingPaymentMissingSince = record.fundingPaymentMissingSince ?? tip
+ case nil: break
+ }
+ let isMissing = record.fundingPaymentMissingSince.map { tip.saturatingSub($0) >= Self.ownPaymentMissingBlocks } ?? false
+ let isTooOld = tip.saturatingSub(seenAt) >= Self.ownPaymentTimeoutBlocks
+ guard isMissing || isTooOld else { return }
+ Logger.warn("Spark exit forgets its funding payment \(paid) (missing: \(isMissing), too old: \(isTooOld))", context: "SparkExitDriver")
+ record.fundingPaymentTxid = nil
+ record.fundingPaymentSeenAtHeight = nil
+ record.fundingPaymentMissingSince = nil
+ }
+
+ /// A savings payment that confirmed at the funding address without covering the funding is no longer waited for: it
+ /// counts as funding seen there, and the rest is asked for and reminded about at once.
+ private func countShortOwnPayment(_ record: inout SparkExitRecord) async {
+ guard record.isAwaitingOwnPayment, let paid = record.fundingPaymentTxid, let address = record.funding.first?.address,
+ let utxos = try? await chain.utxos(address: address), utxos.contains(where: { $0.txid == paid && $0.status.confirmed })
+ else { return }
+ Logger.info("Spark exit savings payment \(paid) confirmed short, asking for the rest", context: "SparkExitDriver")
+ record.fundingPaymentTxid = nil
+ record.fundingPaymentSeenAtHeight = nil
+ record.fundingPaymentMissingSince = nil
+ record.isFundingPaymentShort = true
+ }
+
+ /// An ended exit's savings payment that had not arrived: once at the funding address it joins the leftover and is
+ /// swept with it, after any sweep in flight settles (a confirmed one is kept in `sweptLeftover`). One no host
+ /// sees is forgotten by the own-payment expiry.
+ private func followEndedPayment(_ record: inout SparkExitRecord) async {
+ guard !record.isActive, let paid = record.fundingPaymentTxid else { return }
+ guard record.isEndedPaymentPending else {
+ // Already in the leftover (or adopted as funding): nothing more to follow.
+ record.fundingPaymentTxid = nil
+ record.fundingPaymentSeenAtHeight = nil
+ record.fundingPaymentMissingSince = nil
+ return
+ }
+ guard let address = record.funding.first?.address, let tip = try? await chain.tipHeight(),
+ let listed = try? await chain.utxos(address: address)
+ else { return }
+ guard listed.contains(where: { $0.txid == paid }) else {
+ await noteOwnPayment(&record)
+ return
+ }
+ let sweep = record.leftoverSweep
+ var isSweepConfirmed = sweep?.isConfirmed == true
+ if let txid = sweep?.txid, !isSweepConfirmed, sweep?.rejection == nil {
+ // In flight: its coins are settled first, so the payment is never mixed into a sweep that may replace it.
+ guard let status = try? await chain.txStatus(txid: txid), status.confirmed else { return }
+ isSweepConfirmed = true
+ } else if sweep?.txid == nil, await isAnyKnown(sweep?.replacedTxids ?? []) {
+ return
+ }
+ if isSweepConfirmed, let sweep, let txid = sweep.txid {
+ if !(record.sweptLeftover ?? []).contains(where: { $0.txid == txid }) {
+ record.sweptLeftover = (record.sweptLeftover ?? []) + [.init(txid: txid, valueSats: sweep.valueSats)]
+ }
+ // A host behind the block can still list what the sweep spent.
+ let spent = Set((sweep.txHex.flatMap(SparkExitTxid.inputOutpoints(hex:)) ?? []).map { "\($0.txid):\($0.vout)" })
+ record.leftover = listed.filter { !spent.contains("\($0.txid):\($0.vout)") }
+ .map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) }
+ } else {
+ let known = Set(record.leftover.map { "\($0.txid):\($0.vout)" })
+ record.leftover += listed.filter { $0.txid == paid && !known.contains("\($0.txid):\($0.vout)") }
+ .map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) }
+ }
+ Logger.info("Spark exit funding payment \(paid) arrived after the exit ended; sweeping it back", context: "SparkExitDriver")
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(waitingSince: tip, checkedAtHeight: tip, retryAtHeight: tip)
+ record.fundingPaymentTxid = nil
+ record.fundingPaymentSeenAtHeight = nil
+ record.fundingPaymentMissingSince = nil
+ }
+
+ /// While a higher fee's top-up is awaited: the current generation is checked and its ready steps sent. A
+ /// generation that finished in the meantime runs on to Done; one that diverged waits for the top-up's rebuild.
+ private func driveWhileFunding(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws {
+ guard let stored = record.recovery?.response else { return }
+ let checked = try await recovering.check(withConfirmations(stored))
+ record.recovery = StoredRecovery(checked.recovery)
+ record.noteProgressAfterRebuildFromChain(checked.recovery)
+ switch checked.verdict {
+ case .valid:
+ // An unverified rebuild's ask ends once every step reads again: the valid generation needs nothing more.
+ // Funding that reached the address meanwhile stays there, for a later rebuild or the leftover sweep.
+ if record.isUnverifiedAsk == true, record.fundingPaymentTxid == nil,
+ !checked.recovery.transactions.contains(where: { $0.status == .unverified })
+ {
+ Logger.info("Spark exit steps read again: the unverified rebuild's fee ask is withdrawn", context: "SparkExitDriver")
+ record.phase = .running
+ record.isUnverifiedAsk = nil
+ }
+ let tip = try? await chain.tipHeight()
+ try await broadcastReady(&record, tip: tip)
+ case .done:
+ // Nothing is left to pay more for: back to running, whose next check finishes it (failed leaves included).
+ // A payment still on its way is followed after the end and swept back with the leftover.
+ record.phase = .running
+ record.isUnverifiedAsk = nil
+ case .redo:
+ // The diverged generation is never sent again: its rebuild, a Redo one, waits for the top-up.
+ Logger.warn("Spark exit diverged on-chain while a top-up is awaited; rebuilding once it confirms", context: "SparkExitDriver")
+ record.isRebuilding = true
+ record.isRedoRebuild = true
+ record.isUnverifiedAsk = nil
+ }
+ }
+
+ /// 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,
+ inputFeeSats: SparkExitMath.inputFeeSats(record)
+ ) 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
+
+ /// Over a generation that did not diverge, however the build was reached (an unverified rebuild, a fee bump, a
+ /// rebuild after a leaf was left behind), that generation stays over a rebuild that leaves waiting a leaf it
+ /// recovers, before that rebuild is saved or any of it broadcast.
+ private func build(_ record: inout SparkExitRecord, recovering: SparkExitRecovering) async throws {
+ let tip = try? await chain.tipHeight()
+ if record.isFeeBumpPending == true {
+ record.feeBumpBuiltAtHeight = tip
+ }
+ let valid = record.validGeneration
+ // Waiting for a leaf that may still move: tried again at growing gaps of blocks, not every tick.
+ if let since = record.retrySince {
+ guard let tip, Self.isRetryDue(since: since, retriedAt: record.retriedAt ?? since, tip: tip) else { return }
+ }
+ 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, 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)
+ let isRecovered = record.recovery != nil || record.isRebuiltFromChain == true
+ await finish(&record, phase: isRecovered ? .done : .failed(reason: "Nothing left to recover"))
+ return
+ }
+ // A leaf that may still move is kept apart and tried again before the exit ends.
+ let isWaitReleased = record.retrySince.flatMap { since in tip.map { $0.saturatingSub(since) >= Self.releaseBlocks } } == true
+ let waitingLeafIds: [String] = isWaitReleased ? [] : prepared.skipped
+ .filter { isProvisional($0, prepared: prepared, record: record, tip: tip) }
+ .map(\.leafId)
+ record.retrySince = nil
+ record.retriedAt = nil
+ let built: RecoverFundsResponse
+ 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)
+ }
+ // Over a valid generation that recovers a leaf this build left waiting, that generation stays, rebuilt over
+ // again under its guard once a block.
+ if var valid, valid.recovery?.response.leaves.contains(where: { waitingLeafIds.contains($0.leafId) }) == true {
+ valid.leftBehindLeaves = record.leftBehindLeaves
+ record = valid
+ return
+ }
+ record.isRebuilding = record.recovery != nil
+ record.lastBuildAt = nil
+ return
+ } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) {
+ await askForFunding(&record, requiredSats: requiredSats)
+ return
+ }
+ guard !built.transactions.isEmpty else {
+ // Only cooperative leaves the operators could not co-sign: kept as the exit's recovery, so the next check
+ // puts them through the bounded failed-leaf retry (about a week), with the operators-waiting line.
+ if !built.failed.isEmpty {
+ record.adopt(built, at: now())
+ record.waitingLeafIds = waitingLeafIds.isEmpty ? nil : waitingLeafIds
+ if let valid {
+ keepValidGeneration(&record, over: valid)
+ }
+ return
+ }
+ await finish(&record, phase: .failed(reason: "Nothing to recover"))
+ return
+ }
+ // 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")
+ 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.waitingLeafIds = waitingLeafIds.isEmpty ? nil : waitingLeafIds
+ if let valid, keepValidGeneration(&record, over: valid) {
+ return
+ }
+ record.spentStepStrikes = strikes.isEmpty ? nil : strikes
+ // Saved before any of it is broadcast, so a crash never leaves broadcast transactions without their record.
+ try save(record)
+ 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(record.prepareRequest(
+ isRecoverableOnly ? .recoverableOnly : .specific(leafIds: record.exitLeafIds)
+ ))
+ 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(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)
+ } 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 }
+ // The first build has no recovery yet: the quote's values price its leaves.
+ let values = Dictionary((record.recovery?.response.leaves ?? []).map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { first, _ in first })
+ .merging(record.leafSats ?? [:]) { recovered, _ in recovered }
+ Logger.warn("Spark exit leaves left behind: \(leafIds): \(reason)", context: "SparkExitDriver")
+ record.leftBehindLeaves = (record.leftBehindLeaves ?? []) + leafIds.map {
+ SparkExitRecord.LeftBehindLeaf(leafId: $0, valueSats: values[$0] ?? 0, reason: reason)
+ }
+ }
+
+ /// 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 {
+ // Also a salvaged exit's, before its first rebuild: the SDK may no longer hold a leaf being exited.
+ do {
+ try await recovering.importExitState(snapshot)
+ } catch {
+ 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, 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, .operatorsUnavailable: 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] {
+ 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
+ }
+ // Nothing to check on-chain: only cooperative leaves the operators have not co-signed yet.
+ if stored.transactions.isEmpty, !stored.failed.isEmpty {
+ let tip = try? await chain.tipHeight()
+ if try await retryFailedLeaves(&record, failed: stored.failed, recovering: recovering, tip: tip) {
+ return
+ }
+ if try await retryWaitingLeaves(&record, recovering: recovering, tip: tip) {
+ return
+ }
+ await finish(&record, phase: .done)
+ return
+ }
+ let checked = try await recovering.check(withConfirmations(stored))
+ record.recovery = StoredRecovery(checked.recovery)
+ record.noteProgressAfterRebuildFromChain(checked.recovery)
+ 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 fee bump whose build kept this generation over a waiting leaf is built at its rate again once a block,
+ // whatever the leaf waits for, until a build at it is adopted. That build also settles any unread step.
+ // A generation it keeps is still sent.
+ if record.isFeeBumpPending == true, let tip, record.feeBumpBuiltAtHeight != tip {
+ Logger.info("Spark exit builds again at its higher fee rate \(record.feeRateSatPerVbyte) sat/vB", context: "SparkExitDriver")
+ record.unverifiedRebuiltAtHeight = tip
+ let valid = record
+ try await build(&record, recovering: recovering)
+ keepRetryClock(&record, from: valid)
+ if record.generation == valid.generation, record.phase == .running, !record.isRebuilding {
+ try await broadcastReady(&record, tip: tip)
+ }
+ return
+ }
+ // A step the SDK could not read is settled by a fresh build, once a block, unless a step is still to send:
+ // a build now would conflict with it at the same fee.
+ if let tip, isUnverifiedRebuildDue(checked.recovery, record: record, tip: tip) {
+ Logger.warn("Spark exit has a step the SDK could not read, rebuilding over it", context: "SparkExitDriver")
+ record.unverifiedRebuiltAtHeight = tip
+ try save(record)
+ let valid = record
+ try await build(&record, recovering: recovering)
+ keepRetryClock(&record, from: valid)
+ if case .needsFunding = record.phase {
+ // Like a fee bump's, the valid generation keeps going while the funding it asks for comes.
+ record.isUnverifiedAsk = true
+ }
+ return
+ }
+ // The SDK sees no divergence when another wallet's CPFP child confirmed a node our leaves share.
+ 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 rebuildStalled(&record, recovering: recovering)
+ return
+ }
+ 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:
+ if try await retryFailedLeaves(&record, failed: checked.recovery.failed, recovering: recovering, tip: tip) {
+ return
+ }
+ if try await retryWaitingLeaves(&record, recovering: recovering, tip: tip) {
+ return
+ }
+ await finish(&record, phase: .done)
+ case .redo:
+ Logger.warn("Spark exit diverged on-chain, rebuilding generation \(record.generation + 1)", context: "SparkExitDriver")
+ record.isRedoRebuild = true
+ try await rebuild(&record, recovering: recovering)
+ }
+ }
+
+ /// 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 }
+ await useExitStateSnapshot(&record, recovering: recovering)
+ 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 }
+ // A failed leaf skipped while it may still move is kept and tried again on its own.
+ noteStoppedLeaves(prepared, tip: tip, in: &record)
+ let skipped = prepared.skipped.filter { isProvisional($0, prepared: prepared, record: record, tip: tip) }.map(\.leafId)
+ let built = try await recovering.recover(
+ RecoverFundsRequest(prepared: prepared, fundingInputs: prepared.funding == nil ? [] : record.fundingInputs),
+ signer: prepared.funding == nil ? nil : makeSigner(record.fundingIndex)
+ )
+ guard !built.transactions.isEmpty else { return true }
+ Logger.info("Spark exit recovers \(built.leaves.count) leaves the operators moved on-chain", context: "SparkExitDriver")
+ record.adopt(built, at: now())
+ let waiting = Array(Set((record.waitingLeafIds ?? []) + skipped)).sorted()
+ record.waitingLeafIds = waiting.isEmpty ? nil : waiting
+ try save(record)
+ try await broadcastReady(&record, tip: tip)
+ } catch {
+ Logger.warn("Spark exit could not recover the leaves the operators moved on-chain yet: \(error)", context: "SparkExitDriver")
+ }
+ return true
+ }
+
+ /// The SDK names the total all inputs must hold, not what is missing.
+ private func askForFunding(_ record: inout SparkExitRecord, requiredSats: UInt64) async {
+ var utxos: [EsploraUtxo]?
+ if let address = record.funding.first?.address {
+ utxos = try? await chain.utxos(address: address)
+ }
+ record.phase = .needsFunding(requiredSats: SparkExitMath.neededFunding(requiredSats: requiredSats, record: record, utxos: utxos))
+ }
+
+ /// Leaves a build skipped while they may still move (`waitingLeafIds`), prepared again on their own once the rest of
+ /// the exit is done: at once, then at Glow's doubling gaps (`isRetryDue`), until they build or about a week passed
+ /// (`releaseBlocks`). One that needs more fee funding asks for it and is built as soon as it arrives (`isFunded`).
+ /// True while the exit keeps waiting for them, false once it can end.
+ private func retryWaitingLeaves(
+ _ record: inout SparkExitRecord,
+ recovering: SparkExitRecovering,
+ tip: UInt32?,
+ isFunded: Bool = false
+ ) async throws -> Bool {
+ let ids = (record.waitingLeafIds ?? []).filter(record.exitLeafIds.contains)
+ guard !ids.isEmpty else {
+ record.waitingLeafIds = nil
+ return false
+ }
+ guard let tip else { return true }
+ let since = record.retrySince ?? tip
+ if tip.saturatingSub(since) >= Self.releaseBlocks {
+ Logger.warn("Spark exit lets go of the skipped leaves it waited about a week for: \(ids)", context: "SparkExitDriver")
+ letGoOfWaitingLeaves(&record)
+ return false
+ }
+ if !isFunded, record.retrySince != nil, !Self.isRetryDue(since: since, retriedAt: record.retriedAt ?? since, tip: tip) {
+ return true
+ }
+ record.retrySince = since
+ record.retriedAt = tip
+ try save(record)
+ guard !Task.isCancelled else { return true }
+ await useExitStateSnapshot(&record, recovering: recovering)
+ do {
+ let prepared = try await recovering.prepare(record.prepareRequest(.specific(leafIds: ids)))
+ noteStoppedLeaves(prepared, tip: tip, in: &record)
+ let still = prepared.skipped.filter { isProvisional($0, prepared: prepared, record: record, tip: tip) }.map(\.leafId)
+ guard !prepared.leaves.isEmpty else {
+ guard !still.isEmpty else {
+ letGoOfWaitingLeaves(&record)
+ return false
+ }
+ record.waitingLeafIds = still
+ return true
+ }
+ let built = try await recovering.recover(
+ RecoverFundsRequest(prepared: prepared, fundingInputs: prepared.funding == nil ? [] : record.fundingInputs),
+ signer: prepared.funding == nil ? nil : makeSigner(record.fundingIndex)
+ )
+ guard !built.transactions.isEmpty else { return true }
+ Logger.info("Spark exit recovers \(built.leaves.count) leaves it skipped while they could still move", context: "SparkExitDriver")
+ record.adopt(built, at: now())
+ record.waitingLeafIds = still.isEmpty ? nil : still
+ record.retrySince = nil
+ record.retriedAt = nil
+ try save(record)
+ try await broadcastReady(&record, tip: tip)
+ } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) {
+ await askForFunding(&record, requiredSats: requiredSats)
+ } catch {
+ Logger.warn("Spark exit could not recover the leaves it skipped yet: \(error)", context: "SparkExitDriver")
+ }
+ return true
+ }
+
+ private func letGoOfWaitingLeaves(_ record: inout SparkExitRecord) {
+ // The quote's skips never were exit leaves: the leaves let go here add to them.
+ record.skippedLeafCount += record.waitingLeafIds?.count ?? 0
+ record.waitingLeafIds = nil
+ record.retrySince = nil
+ record.retriedAt = nil
+ }
+
+ /// 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 }
+ // A copy at the new rate: a leaf the SDK refuses at that rate is left behind on the copy only, as a rebuild would.
+ var record = record
+ record.feeRateSatPerVbyte = rate
+ await useExitStateSnapshot(&record, recovering: recovering)
+ let leftBehindBefore = record.leftBehindSats
+ let leavesBefore = Set(record.exitLeafIds)
+ guard let prepared = try await prepare(&record, recovering: recovering) else {
+ throw SparkExitFeeBumpError.leavesBehind(sats: record.leftBehindSats.saturatingSub(leftBehindBefore))
+ }
+ // A rate at which a leaf can't pay its own exit would leave it on Spark: never offered.
+ if Set(record.exitLeafIds) != leavesBefore {
+ let values = Dictionary((record.recovery?.response.leaves ?? []).map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { a, _ in a })
+ let dropped = leavesBefore.subtracting(record.exitLeafIds).reduce(UInt64(0)) { $0 + (values[$1] ?? 0) }
+ throw SparkExitFeeBumpError.leavesBehind(sats: dropped)
+ }
+ // A prepare that succeeds can still skip an exiting leaf whose exit costs more than it holds at this rate.
+ let unpaid = prepared.skipped.filter {
+ guard case .feeExceedsValue = $0.reason else { return false }
+ return record.exitLeafIds.contains($0.leafId)
+ }
+ if !unpaid.isEmpty {
+ throw SparkExitFeeBumpError.leavesBehind(sats: unpaid.reduce(0) { $0 + $1.valueSats })
+ }
+ guard prepared.funding != nil, !prepared.leaves.isEmpty else { return 0 }
+ do {
+ _ = try await recovering.recover(
+ RecoverFundsRequest(prepared: prepared, fundingInputs: record.fundingInputs),
+ signer: makeSigner(record.fundingIndex)
+ )
+ return 0
+ } catch let SdkError.SparkError(message) where SparkExitMath.isUnexitableLeafError(message) {
+ // The build refused a leaf at this rate: it would stay on Spark, so the rate is not offered.
+ let leafId = SparkExitMath.unexitableLeafId(message)
+ let value = (record.recovery?.response.leaves ?? []).first { $0.leafId == leafId }?.valueSats ?? 0
+ throw SparkExitFeeBumpError.leavesBehind(sats: value)
+ } catch let SdkError.InsufficientCpfpFunds(requiredSats, _) {
+ var atRate = record
+ atRate.feeRateSatPerVbyte = rate
+ 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.
+ 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
+ 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
+ /// 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
+ }
+ }
+
+ /// A rebuild that leaves waiting a leaf the valid generation recovers drops that leaf's signed steps, which a
+ /// matured step needs sent within the operators' window, whether it built only failed cooperative leaves or other
+ /// leaves: the valid generation stays, as `valid` holds it, and is what the tick saves. While the leaf stays
+ /// unreadable the rebuild repeats once a block. True when it stayed.
+ @discardableResult
+ private func keepValidGeneration(_ record: inout SparkExitRecord, over valid: SparkExitRecord) -> Bool {
+ guard record.generation != valid.generation, let waiting = record.waitingLeafIds,
+ valid.recovery?.response.leaves.contains(where: { waiting.contains($0.leafId) }) == true
+ else { return false }
+ Logger.warn("Spark exit keeps its generation: the rebuild left waiting a leaf it recovers", context: "SparkExitDriver")
+ record = valid
+ return true
+ }
+
+ /// An unverified rebuild that adopted nothing keeps the valid generation's retry clock: one it set while waiting
+ /// for the unread leaf would let that leaf go after about a week, ending the exit before its signed steps are sent.
+ private func keepRetryClock(_ record: inout SparkExitRecord, from valid: SparkExitRecord) {
+ guard record.generation == valid.generation, record.isActive else { return }
+ record.retrySince = valid.retrySince
+ record.retriedAt = valid.retriedAt
+ }
+
+ private func isRebuildDue(_ record: SparkExitRecord) -> Bool {
+ guard let last = record.lastBuildAt else { return true }
+ return now().timeIntervalSince(last) >= Self.rebuildInterval
+ }
+
+ /// 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. 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 {
+ if record.leftoverSweep?.isRemainingUnread == true {
+ await followRemainingOutputs(&record)
+ return
+ }
+ // A sweep it replaced may confirm instead: that ends the sweep as well.
+ for txid in record.leftoverSweep?.replacedTxids ?? [] where record.leftoverSweep?.isConfirmed != true {
+ if let status = try? await chain.txStatus(txid: txid), status.confirmed {
+ // Its own amount and inputs are what reached savings, not the replacement's.
+ if let landed = record.acceptedSweeps?.first(where: { $0.txid == txid }) {
+ record.leftoverSweep?.txid = landed.txid
+ record.leftoverSweep?.txHex = landed.txHex
+ record.leftoverSweep?.valueSats = landed.valueSats
+ }
+ record.leftoverSweep?.isConfirmed = true
+ await followRemainingOutputs(&record)
+ return
+ }
+ }
+ // A sweep refused for its fee, before this rule or on the last tick, waits for the next block and a rate the
+ // node takes.
+ if let sweep = record.leftoverSweep, let rejection = sweep.rejection, sweep.isFeeRejection, !sweep.isAccepted {
+ await waitAfterFeeRefusal(&record, refusal: rejection)
+ try save(record)
+ }
+ if let sweep = record.leftoverSweep, let oldTxid = sweep.txid, sweep.isConfirmed != true, sweep.rejection == nil,
+ !Task.isCancelled, let address = record.funding.first?.address
+ {
+ let oldRate = sweep.feeRateSatPerVbyte ?? record.feeRateSatPerVbyte
+ var isReplaceDue = false
+ if sweep.isAccepted, let sentAt = sweep.sentAtHeight, let tip = try? await chain.tipHeight(),
+ tip.saturatingSub(sentAt) >= Self.sweepReplaceBlocks
+ {
+ // A node that dropped it answers not found: that is unconfirmed too.
+ isReplaceDue = await (try? chain.txStatus(txid: oldTxid))?.confirmed != true
+ }
+ // At the ceiling already: sent again as it is until it confirms.
+ let ceiling = isReplaceDue ? await sweepFeeCeiling(record) : 0
+ if isReplaceDue, oldRate < ceiling {
+ // A host with no fee estimates still gets a replacement one sat/vB over the old rate.
+ let fastest = try? await chain.fastestFeeRate()
+ let rate = min(max(fastest ?? 0, oldRate + 1), ceiling)
+ let utxos = SparkExitMath.economicalUtxos(record.leftover.map {
+ EsploraUtxo(txid: $0.txid, vout: $0.vout, value: $0.valueSats, status: EsploraTxStatus(confirmed: true, blockHeight: nil))
+ }, rate: rate)
+ 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, let tip = try? await chain.tipHeight() {
+ // Too small to send at the new rate, and nothing of it is in the mempool: it waits for fees to drop.
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(
+ feeRateSatPerVbyte: rate,
+ replacedTxids: sweep.replacedTxids,
+ waitingSince: sweep.waitingSince ?? tip,
+ checkedAtHeight: tip
+ )
+ 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
+ else { return }
+ if sweep.isAccepted, let status = try? await chain.txStatus(txid: txid), status.confirmed {
+ record.leftoverSweep?.isConfirmed = true
+ await followRemainingOutputs(&record)
+ return
+ }
+ // Built but never accepted for `sweepRecheckBlocks`: its coins may have changed, so it is built again from what
+ // the address holds now.
+ if !sweep.isAccepted, let builtAt = sweep.builtAtHeight, let tip = try? await chain.tipHeight(),
+ tip.saturatingSub(builtAt) >= Self.sweepRecheckBlocks
+ {
+ Logger.warn("Spark exit leftover sweep \(txid) never accepted, building it again", context: "SparkExitDriver")
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(
+ feeRateSatPerVbyte: sweep.feeRateSatPerVbyte,
+ replacedTxids: sweep.replacedTxids,
+ waitingSince: sweep.waitingSince ?? tip,
+ checkedAtHeight: tip,
+ refusedFeeRate: sweep.refusedFeeRate,
+ retryAtHeight: tip + 1
+ )
+ return
+ }
+ let outcome = await chain.broadcast(RecoveryTransaction(
+ kind: .sweep,
+ nodeId: nil,
+ 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
+ if !(record.acceptedSweeps ?? []).contains(where: { $0.txid == txid }) {
+ record.acceptedSweeps = (record.acceptedSweeps ?? []) + [.init(txid: txid, txHex: hex, valueSats: sweep.valueSats)]
+ }
+ if record.leftoverSweep?.sentAtHeight == nil {
+ record.leftoverSweep?.sentAtHeight = try? await chain.tipHeight()
+ }
+ case let .rejected(reason) where !sweep.isAccepted:
+ if SparkExitRecord.LeftoverSweep(rejection: reason).isFeeRejection {
+ await waitAfterFeeRefusal(&record, refusal: reason)
+ } else if await isAnyKnown(sweep.replacedTxids ?? []) {
+ // A replacement refused for another reason while the sweep it replaces may still confirm: that one is
+ // followed (the replaced txids end the sweep once in a block), and a new build is tried later.
+ if let tip = try? await chain.tipHeight() {
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(
+ feeRateSatPerVbyte: sweep.feeRateSatPerVbyte,
+ replacedTxids: sweep.replacedTxids,
+ waitingSince: sweep.waitingSince ?? tip,
+ checkedAtHeight: tip
+ )
+ }
+ } else {
+ record.leftoverSweep?.rejection = reason
+ // Final: shown once more with its address, even after the result was opened, as kept change is.
+ record.isResultSeen = nil
+ }
+ // Once accepted, a later refusal is mostly a node that already has it or a conflict with it confirming. A fee
+ // refusal of one no host holds any more means it was evicted when the mempool's floor rose: it waits and is
+ // built again over the same coins at the rate the node asks for, replacing it (RBF).
+ case let .rejected(reason) where SparkExitRecord.LeftoverSweep(rejection: reason).isFeeRejection:
+ let isKnown = try? await chain.isKnown(txid: txid)
+ if isKnown == false {
+ Logger.warn("Spark exit leftover sweep \(txid) left the mempool unconfirmed", context: "SparkExitDriver")
+ await waitAfterFeeRefusal(&record, refusal: reason)
+ }
+ case .inputsMissing where !sweep.isAccepted:
+ // The coins it spends are gone or changed: built again from what the address holds, from the next block.
+ if let tip = try? await chain.tipHeight() {
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(
+ feeRateSatPerVbyte: sweep.feeRateSatPerVbyte,
+ replacedTxids: sweep.replacedTxids,
+ waitingSince: sweep.waitingSince ?? tip,
+ checkedAtHeight: tip,
+ refusedFeeRate: sweep.refusedFeeRate,
+ retryAtHeight: tip + 1
+ )
+ }
+ default: break
+ }
+ }
+
+ /// After a sweep confirms, reads the funding address again: outputs the sweep left out (too small for their fee,
+ /// or unconfirmed when it was built) start a new wait of their own, so they are swept once worth it or kept with
+ /// their path after `sweepWaitBlocks`. What the confirmed sweeps sent is kept for the result. While the tip or the
+ /// address cannot be read the sweep stays pending, and a later tick reads them again.
+ private func followRemainingOutputs(_ record: inout SparkExitRecord) async {
+ guard let sweep = record.leftoverSweep, let txid = sweep.txid, let address = record.funding.first?.address else { return }
+ guard let tip = try? await chain.tipHeight(), let listed = try? await chain.utxos(address: address) else {
+ record.leftoverSweep?.isRemainingUnread = true
+ return
+ }
+ record.leftoverSweep?.isRemainingUnread = nil
+ // A host behind the block can still list what the sweep spent.
+ let spent = Set((sweep.txHex.flatMap(SparkExitTxid.inputOutpoints(hex:)) ?? []).map { "\($0.txid):\($0.vout)" })
+ let utxos = listed.filter { !spent.contains("\($0.txid):\($0.vout)") }
+ guard !utxos.isEmpty else { return }
+ Logger.info("Spark exit sweep \(txid) left \(utxos.count) outputs at the funding address; following them", context: "SparkExitDriver")
+ record.sweptLeftover = (record.sweptLeftover ?? []) + [.init(txid: txid, valueSats: sweep.valueSats)]
+ record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) }
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(waitingSince: tip, checkedAtHeight: tip)
+ }
+
+ private func isAnyKnown(_ txids: [String]) async -> Bool {
+ for txid in txids {
+ let isKnown = try? await chain.isKnown(txid: txid)
+ if isKnown == true {
+ return true
+ }
+ }
+ return false
+ }
+
+ /// A sweep refused for its fee is not final: the leftover waits again, keeping when its wait began, and is built
+ /// again from the next block at the rate the refusal asks for (`refusedSweepRate`, capped by `refusedSweepFloor`), or
+ /// the economy rate if higher.
+ private func waitAfterFeeRefusal(_ record: inout SparkExitRecord, refusal: String) async {
+ // Without a tip no height is written: the refusal is kept, and the next tick turns it into the wait.
+ guard let sweep = record.leftoverSweep, let tip = try? await chain.tipHeight() else {
+ if record.leftoverSweep?.isAccepted == false {
+ record.leftoverSweep?.rejection = refusal
+ }
+ return
+ }
+ let rate = SparkExitMath.refusedSweepRate(refusal, paidRate: sweep.feeRateSatPerVbyte ?? record.feeRateSatPerVbyte)
+ // One a node accepted may still confirm: any of the replaced sweeps confirming ends the sweep.
+ let replaced = sweep.isAccepted ? (sweep.replacedTxids ?? []) + [sweep.txid].compactMap { $0 } : sweep.replacedTxids
+ Logger.info("Spark exit leftover sweep refused for its fee (\(refusal)), tries again at \(rate) sat/vB", context: "SparkExitDriver")
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(
+ feeRateSatPerVbyte: sweep.feeRateSatPerVbyte,
+ replacedTxids: replaced,
+ waitingSince: sweep.waitingSince ?? tip,
+ checkedAtHeight: tip,
+ refusedFeeRate: rate,
+ retryAtHeight: tip + 1
+ )
+ }
+
+ /// Builds the leftover sweep at the network's economy rate (`sweepFeeRate`) from the UTXOs worth more than their
+ /// own input fee. While nothing is worth sending it waits, checking the rate again every `sweepRecheckBlocks`,
+ /// and after `sweepWaitBlocks` keeps the change at the funding address, shown with the path that spends it.
+ private func sweepWhenEconomical(_ record: inout SparkExitRecord) async throws {
+ guard let address = record.funding.first?.address else { return }
+ let waiting = record.leftoverSweep
+ // Every state below records a height: without the tip nothing changes this tick.
+ guard let tip = try? await chain.tipHeight() else { return }
+ if let waiting, let since = waiting.waitingSince {
+ if tip.saturatingSub(since) >= Self.sweepWaitBlocks {
+ Logger.info("Spark exit keeps its fee change at the funding address after waiting for fees", context: "SparkExitDriver")
+ record.leftoverSweep?.isDustKept = true
+ // Shown once more with its path, even after the result was opened; the earlier exit addresses keep
+ // listing it after that.
+ record.isResultSeen = nil
+ try save(record)
+ return
+ }
+ let due = waiting.retryAtHeight ?? (waiting.checkedAtHeight ?? since).addingReportingOverflow(Self.sweepRecheckBlocks).partialValue
+ guard tip >= due else { return }
+ }
+ let utxos = try await chain.utxos(address: address)
+ record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: $0.vout, valueSats: $0.value) }
+ let confirmed = utxos.filter(\.status.confirmed)
+ guard !confirmed.isEmpty else { return }
+ // An unconfirmed output (a stuck payment, say) holds the sweep back for one recheck at most; then the confirmed
+ // ones go and the rest waits for the next sweep.
+ if confirmed.count < utxos.count {
+ let since = waiting?.waitingSince ?? tip
+ if tip.saturatingSub(since) < Self.sweepRecheckBlocks {
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(
+ replacedTxids: waiting?.replacedTxids,
+ waitingSince: since,
+ checkedAtHeight: tip,
+ refusedFeeRate: waiting?.refusedFeeRate,
+ retryAtHeight: tip + 1
+ )
+ try save(record)
+ return
+ }
+ }
+ let fees = try? await chain.networkFees()
+ // A rate a node refused stays refused: the least it asked for lifts the economy rate, up to the next block's or
+ // the exit's rate. A node asking more refuses again, and the sweep waits a block for fees to drop.
+ let ceiling = await sweepFeeCeiling(record)
+ let refusedFloor = SparkExitMath.refusedSweepFloor(
+ waiting?.refusedFeeRate ?? 0,
+ nextBlock: fees.map { min($0.fastest, ceiling) },
+ exitRate: record.feeRateSatPerVbyte
+ )
+ let rate = max(SparkExitMath.sweepFeeRate(economy: fees?.economy, exitRate: record.feeRateSatPerVbyte), refusedFloor)
+ let built = try await SparkExitLeftoverSweep.build(
+ utxos: SparkExitMath.economicalUtxos(confirmed, rate: rate),
+ fundingAddress: address,
+ destination: record.destination,
+ feeRateSatPerVbyte: rate,
+ signer: makeSigner(record.fundingIndex)
+ )
+ if let built {
+ // The wait's start carries over, so a sweep refused again still ends at `sweepWaitBlocks`.
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(
+ txid: built.txid,
+ txHex: built.txHex,
+ valueSats: built.valueSats,
+ feeRateSatPerVbyte: rate,
+ replacedTxids: waiting?.replacedTxids,
+ waitingSince: waiting?.waitingSince,
+ builtAtHeight: tip
+ )
+ } else {
+ let since = waiting?.waitingSince ?? tip
+ Logger.info("Spark exit waits for lower fees to send its fee change (\(rate) sat/vB)", context: "SparkExitDriver")
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(
+ feeRateSatPerVbyte: rate,
+ replacedTxids: waiting?.replacedTxids,
+ waitingSince: since,
+ checkedAtHeight: tip,
+ refusedFeeRate: waiting?.refusedFeeRate
+ )
+ }
+ try save(record)
+ }
+
+ /// The most a leftover sweep pays per vbyte: the exit's own rate, which the user confirmed, or Bitkit's own fast rate
+ /// when higher. The chain host's estimates and refusals are not trusted past it.
+ private func sweepFeeCeiling(_ record: SparkExitRecord) async -> UInt64 {
+ await max(record.feeRateSatPerVbyte, trustedFeeRate() ?? 0)
+ }
+
+ // MARK: Broadcast
+
+ /// A unilateral step the SDK could not read is never sent: a rebuild settles it. A cooperative one is, since nodes
+ /// 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))
+ 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, tip: tip)
+ 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))
+ let saved = Set(recovery.transactions.filter(\.isConfirmed).map(\.txid))
+ var confirmed = saved
+ var heights: [String: UInt32] = [:]
+ for tx in recovery.transactions {
+ if case let .confirmed(height?) = tx.status {
+ heights[tx.txid] = height
+ }
+ }
+ // A saved confirmation is asked again, since a reorg can undo it; it stands only while no host answers.
+ let unknown = recovery.transactions.map(\.txid) + Set(recovery.transactions.flatMap(\.dependsOn)).subtracting(listed)
+ let statuses = await statuses(unknown)
+ let reorged = saved.filter { statuses[$0]?.confirmed == false }
+ for txid in reorged {
+ confirmed.remove(txid)
+ heights[txid] = nil
+ }
+ if !reorged.isEmpty {
+ Self.undoReorg(reorged, in: recovery, record: &record)
+ }
+ for (txid, status) in statuses where status.confirmed {
+ confirmed.insert(txid)
+ heights[txid] = status.blockHeight ?? heights[txid]
+ }
+ for (i, tx) in recovery.transactions.enumerated() {
+ guard !Task.isCancelled else { break }
+ if let status = statuses[tx.txid], status.confirmed {
+ recovery.transactions[i].status = .confirmed(blockHeight: status.blockHeight ?? heights[tx.txid])
+ continue
+ }
+ if reorged.contains(tx.txid) {
+ recovery.transactions[i].status = .waitingForDependencies
+ } else if tx.isConfirmed {
+ continue
+ }
+ guard tx.dependsOn.allSatisfy(confirmed.contains), isDue(tx.txid, in: record, withoutSdk: true) else { continue }
+ 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, tip: tip)
+ record.recovery = StoredRecovery(recovery)
+ try save(record)
+ }
+ record.recovery = StoredRecovery(recovery)
+ }
+
+ /// What was concluded from confirmations a reorg undid, a spent input or a failed fee chain of a step or of a step
+ /// after it, no longer holds.
+ private static func undoReorg(_ reorged: Set, in recovery: RecoverFundsResponse, record: inout SparkExitRecord) {
+ Logger.warn("Spark exit steps \(reorged.sorted()) lost their confirmation; asking again", context: "SparkExitDriver")
+ var undone = reorged
+ while true {
+ let added = recovery.transactions
+ .filter { !undone.contains($0.txid) && $0.dependsOn.contains(where: undone.contains) }
+ .map(\.txid)
+ if added.isEmpty {
+ break
+ }
+ undone.formUnion(added)
+ }
+ for i in record.broadcastLog.indices where record.broadcastLog[i].generation == record.generation
+ && undone.contains(record.broadcastLog[i].txid)
+ {
+ record.broadcastLog[i].isInputSpent = nil
+ record.broadcastLog[i].feeChainFailures = nil
+ }
+ }
+
+ /// Without the SDK, a step whose input someone else spent is never due: only a rebuild moves on from it.
+ private func isDue(_ txid: String, in record: SparkExitRecord, withoutSdk: Bool = false) -> Bool {
+ guard let last = record.broadcastLog.first(where: { $0.generation == record.generation && $0.txid == txid }) else { return true }
+ 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. A failed fee
+ /// 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
+ let isInputsMissing = if case .inputsMissing = outcome {
+ true
+ } else {
+ false
+ }
+ 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 {
+ !isUnpaid && !isFeeRateRefused
+ } 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()
+ 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
+ record.broadcastLog[i].isFeeRateRefused = isFeeRateRefused ? true : nil
+ } 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,
+ feeChainFailures: isFeeChainFailure ? 1 : nil,
+ acceptedAtHeight: isAccepted ? tip : nil,
+ isFeeRateRefused: isFeeRateRefused ? true : nil
+ ))
+ }
+ }
+}
+
+private extension RecoveryTransaction {
+ var isConfirmed: Bool {
+ if case .confirmed = status {
+ return true
+ }
+ return false
+ }
+}
+
+extension SparkExitRecord {
+ /// The exit running its current generation, when that generation did not diverge: what a rebuild that would drop a
+ /// waiting leaf's signed steps keeps. Funding adopted for the rebuild and a bumped rate stay the exit's.
+ var validGeneration: SparkExitRecord? {
+ guard recovery != nil, isRedoRebuild != true else { return nil }
+ var valid = self
+ valid.phase = .running
+ valid.isRebuilding = false
+ valid.isUnverifiedAsk = nil
+ return valid
+ }
+
+ /// What keeps the exit from moving, once rebuilding over its stuck steps stopped helping.
+ var surfacedRejection: String? {
+ isStalled ? stuckStep?.lastOutcome : nil
+ }
+
+ /// 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
+/// 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:)) }
+ }
+
+ /// 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 outpoints a serialized transaction spends; nil when the hex does not decode.
+ static func inputOutpoints(hex: String) -> [(txid: String, vout: UInt32)]? {
+ let bytes = hex.hexaBytes
+ guard hex.count == bytes.count * 2 else { return nil }
+ var reader = Reader(bytes: bytes)
+ return reader.inputs()?.inputs.map { ($0.txid, $0.vout) }
+ }
+
+ /// The txids a serialized transaction spends; nil when the hex does not decode.
+ static func inputTxids(hex: String) -> [String]? {
+ let bytes = hex.hexaBytes
+ 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]? {
+ 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)
+ }
+ }
+
+ /// 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
+ 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, count <= bytes.count - position 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(UInt64(0)) { $0 | UInt64($1.element) << (8 * $1.offset) }
+ // A count no transaction can hold reads as malformed rather than trapping.
+ guard value <= UInt64(bytes.count) else { return nil }
+ return (Int(value), [first] + rest)
+ }
+
+ mutating func inputs() -> (version: Int32, inputs: [(txid: String, vout: UInt32, sequence: UInt32)])? {
+ guard let versionBytes = take(4) else { return nil }
+ let version = Int32(bitPattern: littleEndian(versionBytes))
+ if bytes.count > 5, bytes[4] == 0, bytes[5] == 1 {
+ position += 2
+ }
+ guard let count = varInt() else { return nil }
+ var inputs: [(txid: String, vout: UInt32, sequence: UInt32)] = []
+ for _ in 0 ..< count.value {
+ guard let txid = take(32), let vout = take(4), let script = varInt(), take(script.value) != nil, let sequence = take(4) else {
+ return nil
+ }
+ inputs.append((Data(txid.reversed()).hex, littleEndian(vout), littleEndian(sequence)))
+ }
+ 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) }
+ }
+
+ 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/SparkExitEarlierAddresses.swift b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift
new file mode 100644
index 000000000..e28f6b374
--- /dev/null
+++ b/Bitkit/Services/SparkExit/SparkExitEarlierAddresses.swift
@@ -0,0 +1,198 @@
+import BitkitCore
+import Combine
+import Foundation
+
+/// A funding address an earlier exit left coins at, or may still receive them at, with the path that spends it. Kept
+/// apart from the record, so a later exit can start and replace the record while the address stays reachable.
+struct SparkExitEarlierAddress: Codable, Equatable, Identifiable {
+ enum Kind: String, Codable {
+ /// Fee funding left at the funding address for good: change too small to send, or a refused sweep's.
+ case keptChange
+ /// The funding address of a cancelled exit that was forgotten: a late payment can still land there.
+ case cancelledFunding
+ }
+
+ let address: String
+ let path: String
+ let kind: Kind
+ /// What the address holds, when known.
+ var sats: UInt64?
+ let date: Date
+
+ var id: String {
+ address
+ }
+
+ /// About four weeks: how long a cancelled exit's address is still read for a late payment.
+ static let watchSeconds: TimeInterval = 28 * 24 * 3600
+
+ /// Read in the foreground for what it holds: kept change always, so change spent by hand shows as spent, and a
+ /// cancelled exit's address younger than `watchSeconds`, for a late payment.
+ func isWatched(now: Date) -> Bool {
+ kind == .keptChange || now.timeIntervalSince(date) < Self.watchSeconds
+ }
+
+ /// The leftover an ended exit kept at its funding address.
+ static func keptChange(_ record: SparkExitRecord, network: BitkitCore.Network, at date: Date) -> SparkExitEarlierAddress? {
+ guard record.isLeftoverKept, let address = record.funding.first?.address else { return nil }
+ return SparkExitEarlierAddress(
+ address: address,
+ path: SparkExitFundingKey.path(index: record.fundingIndex, network: network),
+ kind: .keptChange,
+ sats: record.leftover.reduce(0) { $0 + $1.valueSats },
+ date: date
+ )
+ }
+
+ /// The funding address of a cancelled exit being forgotten.
+ static func cancelledFunding(_ record: SparkExitRecord, network: BitkitCore.Network, at date: Date) -> SparkExitEarlierAddress? {
+ guard let address = record.funding.first?.address else { return nil }
+ return SparkExitEarlierAddress(
+ address: address,
+ path: SparkExitFundingKey.path(index: record.fundingIndex, network: network),
+ kind: .cancelledFunding,
+ sats: nil,
+ date: date
+ )
+ }
+
+ /// The entries with what each watched address holds now; one that can't be read keeps what it showed.
+ static func refreshed(
+ _ entries: [SparkExitEarlierAddress],
+ now: Date,
+ utxos: (String) async throws -> [EsploraUtxo]
+ ) async -> [SparkExitEarlierAddress] {
+ await updating(entries, balances: balances(entries, now: now, utxos: utxos))
+ }
+
+ /// What each watched address that could be read holds now, by address.
+ static func balances(
+ _ entries: [SparkExitEarlierAddress],
+ now: Date,
+ utxos: (String) async throws -> [EsploraUtxo]
+ ) async -> [String: UInt64] {
+ var balances: [String: UInt64] = [:]
+ for entry in entries where entry.isWatched(now: now) {
+ guard let held = try? await utxos(entry.address) else { continue }
+ balances[entry.address] = held.reduce(0) { $0 + $1.value }
+ }
+ return balances
+ }
+
+ /// The entries with the `balances` read; the others keep what they showed.
+ static func updating(_ entries: [SparkExitEarlierAddress], balances: [String: UInt64]) -> [SparkExitEarlierAddress] {
+ entries.map { entry in
+ guard let sats = balances[entry.address] else { return entry }
+ var updated = entry
+ updated.sats = sats > 0 ? sats : nil
+ return updated
+ }
+ }
+}
+
+/// The earlier exits' addresses of a wallet, across exits. A wipe deletes it with the record.
+struct SparkExitEarlierAddressStore {
+ /// Sends on every write of any wallet's list, so the metadata backup that carries them runs again.
+ static let savedPublisher = savedSubject.eraseToAnyPublisher()
+ private static let savedSubject = PassthroughSubject()
+
+ let url: URL
+
+ init(url: URL) {
+ self.url = url
+ }
+
+ init(walletIndex: Int) {
+ url = Env.sparkExitEarlierAddresses(walletIndex: walletIndex)
+ }
+
+ func load() throws -> [SparkExitEarlierAddress] {
+ guard FileManager.default.fileExists(atPath: url.path) else { return [] }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .millisecondsSince1970
+ return try decoder.decode([SparkExitEarlierAddress].self, from: Data(contentsOf: url))
+ }
+
+ /// Adds `entry` unless its address is listed already. Kept change at a cancelled exit's address, which a later exit
+ /// reused, replaces that entry, so it is read for good.
+ @discardableResult
+ func add(_ entry: SparkExitEarlierAddress) throws -> [SparkExitEarlierAddress] {
+ let stored = (try? load()) ?? []
+ if let index = stored.firstIndex(where: { $0.address == entry.address }) {
+ guard entry.kind == .keptChange, stored[index].kind != .keptChange else { return stored }
+ var entries = stored
+ entries[index] = entry
+ try save(entries)
+ return entries
+ }
+ let entries = stored + [entry]
+ try save(entries)
+ return entries
+ }
+
+ func save(_ entries: [SparkExitEarlierAddress]) throws {
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .millisecondsSince1970
+ try encoder.encode(entries).write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
+ Self.savedSubject.send()
+ }
+
+ func delete() throws {
+ guard FileManager.default.fileExists(atPath: url.path) else { return }
+ try FileManager.default.removeItem(at: url)
+ }
+}
+
+/// The earlier exit addresses in the metadata backup.
+enum SparkExitEarlierAddressBackup {
+ /// The entries of `store`, keyed `walletKey`; nil with none, or when the list can't be read, which then never
+ /// blocks the rest of the metadata.
+ static func backup(_ store: SparkExitEarlierAddressStore, walletKey: String) -> [String: [SparkExitEarlierAddressBackupV1]]? {
+ guard let entries = try? store.load(), !entries.isEmpty else { return nil }
+ return [walletKey: entries.map(\.backup)]
+ }
+
+ /// Lists each backed-up address of `network` in its wallet's store, next to the entries already there.
+ static func restore(
+ _ backedUp: [String: [SparkExitEarlierAddressBackupV1]]?,
+ network: String,
+ store: (Int) -> SparkExitEarlierAddressStore
+ ) {
+ for (key, entries) in backedUp ?? [:] {
+ let prefix = "\(network)/wallet"
+ guard key.hasPrefix(prefix), let walletIndex = Int(key.dropFirst(prefix.count)) else { continue }
+ let walletStore = store(walletIndex)
+ for entry in entries.compactMap(SparkExitEarlierAddress.init(backup:)) {
+ do {
+ try walletStore.add(entry)
+ } catch {
+ Logger.warn("Failed to restore the earlier Spark exit address: \(error)", context: "SparkExitEarlierAddressBackup")
+ }
+ }
+ }
+ }
+}
+
+extension SparkExitEarlierAddress {
+ var backup: SparkExitEarlierAddressBackupV1 {
+ SparkExitEarlierAddressBackupV1(
+ address: address,
+ path: path,
+ kind: kind.rawValue,
+ atMillis: Int64((date.timeIntervalSince1970 * 1000).rounded())
+ )
+ }
+
+ /// The backed-up entry, with what it holds read again later; nil for a kind this app does not know.
+ init?(backup: SparkExitEarlierAddressBackupV1) {
+ guard let kind = Kind(rawValue: backup.kind) else { return nil }
+ self.init(
+ address: backup.address,
+ path: backup.path,
+ kind: kind,
+ sats: nil,
+ date: Date(timeIntervalSince1970: Double(backup.atMillis) / 1000)
+ )
+ }
+}
diff --git a/Bitkit/Services/SparkExit/SparkExitFunding.swift b/Bitkit/Services/SparkExit/SparkExitFunding.swift
new file mode 100644
index 000000000..a287b0c44
--- /dev/null
+++ b/Bitkit/Services/SparkExit/SparkExitFunding.swift
@@ -0,0 +1,752 @@
+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 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
+ /// the quote has no leaf, as when no leaf can be exited.
+ static func prepare(
+ _ recovering: SparkExitRecovering,
+ feeRateSatPerVbyte: UInt64,
+ destination: String,
+ lostLeafIds: Set,
+ fundingStrategy: CpfpFundingStrategy = .perNode
+ ) async throws -> SparkExitQuote {
+ func request(_ selection: ExitLeafSelection) -> PrepareRecoverFundsRequest {
+ 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)
+ + 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 {
+ 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 SparkExitQuote(prepared: prepared, lostLeafSats: lostLeafSats)
+ }
+
+ /// 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. 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 {
+ 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 }
+ }
+
+ /// Each leaf's value, kept with the exit for a leaf its first build leaves behind.
+ var leafSats: [String: UInt64] {
+ Dictionary(prepared.leaves.map { ($0.leafId, $0.valueSats) }, uniquingKeysWith: { first, _ in first })
+ }
+
+ /// Breez's rule: only exit when the value is more than its fees.
+ var isWorthExiting: Bool {
+ !prepared.leaves.isEmpty && exitSats > networkFeeSats
+ }
+}
+
+/// 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
+ /// 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`,
+/// 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
+ let fireAt: Date
+ var isFunding = false
+ var isLostLeaves = false
+ var isFailed = false
+ var isNotMoving = false
+ /// Planned without the chain tip, so its date is a guess that never takes the place of one planned with it.
+ var isEstimated = false
+}
+
+/// What the exit shows about its fee funding while it lacks some.
+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 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
+ }
+ return nil
+ }.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
+ /// 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. 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)]
+ // An exit stopped because every leaf was lost is told by the lost reminder alone.
+ if case .failed = record.phase, !record.isEveryLeafLost, record.isCancelled != true {
+ lostReminder.append(SparkExitReminder(id: "failed", fireAt: now, isFailed: true))
+ }
+ // A payment Bitkit already sent from savings, such as a higher fee's, needs no reminder to fund until the exit
+ // forgets it (gone from every host, or not adopted in time).
+ if case let .awaitingFunding(requiredSats) = record.phase {
+ guard record.fundingShortfallSats != nil, !record.isAwaitingOwnPayment else { return lostReminder }
+ // Started but never funded: Spark stays detached until the user funds or cancels. A savings payment that
+ // confirmed short is reminded about at once.
+ let remindAt = record.isFundingPaymentShort == true ? now : record.createdAt.addingTimeInterval(reminderSeconds(network: network))
+ let fireAt = max(now, remindAt)
+ return lostReminder + [SparkExitReminder(id: "await-funding-\(requiredSats)", fireAt: fireAt, isFunding: true)]
+ }
+ var reminders = lostReminder
+ if case let .needsFunding(requiredSats) = record.phase {
+ if !record.isAwaitingOwnPayment {
+ reminders.append(SparkExitReminder(id: "funding-\(requiredSats)", fireAt: now, isFunding: true))
+ }
+ // The live generation keeps going while the top-up comes, and so do its step reminders.
+ guard record.isDrivingWhileFunding else { return reminders }
+ } else {
+ guard case .running = record.phase else { return lostReminder }
+ }
+ guard let recovery = record.recovery?.response else { return reminders }
+ // Waiting for a leaf that may still move is the background runs' job, for up to a week.
+ if record.isWaitingForLeaves || record.isWaitingForOperators {
+ return reminders
+ }
+ 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 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()
+ 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:
+ 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), isEstimated: tip == nil))
+ }
+ 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
+ }
+
+ /// 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 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, transactions.first { $0.kind == .sweep }?.txid ?? record.landedSweepTxid)
+ }
+
+ /// The exit's sweeps to savings: the recovery's, else the one that landed before a retry replaced it, and those of
+ /// generations a Redo replaced.
+ static func sweepTxids(_ record: SparkExitRecord) -> [String] {
+ let current = (record.recovery?.response.transactions ?? []).filter { $0.kind == .sweep }.map(\.txid)
+ var txids = current.isEmpty ? [record.landedSweepTxid].compactMap { $0 } : current
+ for txid in (record.earlierReceivedSats ?? [:]).keys.sorted() where !txids.contains(txid) {
+ txids.append(txid)
+ }
+ return txids
+ }
+
+ /// What each sweep or cooperative recovery among `transactions` pays to `destination`, by txid.
+ static func paidToDestination(_ transactions: [RecoveryTransaction], destination: String) -> [String: UInt64] {
+ guard let script = try? SparkExitAddress.scriptPubKey(destination) else { return [:] }
+ 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.
+ 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 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.chainedTxids($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. `requiredSats` assumes one payment; each
+ /// further one adds its own input fee, `inputFeeSats`.
+ static func fundingTopUp(_ newFunding: [EsploraUtxo], requiredSats: UInt64, inputFeeSats: UInt64 = 0) -> [EsploraUtxo]? {
+ // Fewest coins, largest first; one worth no more than its own input fee stays for the leftover sweep.
+ let coins = newFunding.filter { $0.status.confirmed && $0.value > inputFeeSats }.sorted { $0.value > $1.value }
+ var taken: [EsploraUtxo] = []
+ var total: UInt64 = 0
+ for coin in coins {
+ taken.append(coin)
+ total += coin.value
+ if total >= requiredSats + inputFeeSats * UInt64(taken.count - 1) {
+ return taken
+ }
+ }
+ return nil
+ }
+
+ /// A signed P2WPKH input: each payment to the funding address is one more input for the build to pay for.
+ 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) + 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) + inputFeeSats(record)
+ }
+
+ /// 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 }
+ let total = fundingTotal(shortfall: shortfall, payments: fresh.count, receivedSats: seenSats, inputFeeSats: inputFeeSats(record))
+ // Covered by confirmed funding alone, the next tick builds.
+ if seenSats >= total.covered, let pending = fresh.first(where: { !$0.status.confirmed }) ?? fresh.first {
+ return .seen(txid: pending.txid)
+ }
+ 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.
+ 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 rate a sweep refused for its fee is built at next: what the node's minimum asks for (`min relay fee not
+ /// met, 450 < 674` is the fee paid and the least it takes, at `paidRate`), and at least one sat/vB more.
+ static func refusedSweepRate(_ refusal: String, paidRate: UInt64) -> UInt64 {
+ let bumped = max(paidRate, minRelayFeeRate - 1) + 1
+ guard let match = refusal.firstMatch(of: #/(?:min relay fee|mempool min fee) not met[^0-9]*(\d+)\s*<\s*(\d+)/#),
+ let paid = UInt64(match.1), let least = UInt64(match.2), paid > 0
+ else { return bumped }
+ // A host's number can be anything: one too big asks for more than any cap, which the caller applies.
+ let (scaled, isScaledOver) = least.multipliedReportingOverflow(by: max(paidRate, 1))
+ let (rounded, isRoundedOver) = scaled.addingReportingOverflow(paid - 1)
+ guard !isScaledOver, !isRoundedOver else { return .max }
+ return max(rounded / paid, bumped)
+ }
+
+ /// What a refused rate lifts the sweep to: never above the next block's rate or the exit's own, whichever is higher,
+ /// so a host's absurd ask never spends the leftover on fees.
+ static func refusedSweepFloor(_ refusedRate: UInt64, nextBlock: UInt64?, exitRate: UInt64) -> UInt64 {
+ min(refusedRate, max(nextBlock ?? 0, exitRate))
+ }
+
+ /// The UTXOs worth sending at `rate`: each holds more than the fee of its own input.
+ static func economicalUtxos(_ utxos: [EsploraUtxo], rate: UInt64) -> [EsploraUtxo] {
+ utxos.filter { $0.value > inputVbytes * rate }
+ }
+
+ /// 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) }
+ }
+
+ /// 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) {
+ 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
+ /// it covers `requiredSats`.
+ static func partialFundingSats(receivedSats: UInt64, requiredSats: UInt64) -> UInt64? {
+ receivedSats > 0 && receivedSats < requiredSats ? max(requiredSats - receivedSats, fundingDustSats) : nil
+ }
+
+ /// Unused funding addresses a scan reads past the last used one before it stops.
+ static let fundingScanGapLimit: UInt32 = 20
+ /// The most funding addresses one scan reads, even when a host reports every one used.
+ static let fundingScanMaxAddresses: UInt32 = 1000
+
+ /// The funding index past the last one `isUsed` reports used, read from 0 until `fundingScanGapLimit` unused ones
+ /// follow it, for a wallet that kept no index, such as one restored from the phrase or an older backup.
+ static func scanNextFundingIndex(isUsed: (UInt32) async throws -> Bool) async rethrows -> UInt32 {
+ var next: UInt32 = 0
+ var index: UInt32 = 0
+ while index < next + fundingScanGapLimit, index < fundingScanMaxAddresses {
+ if try await isUsed(index) {
+ next = index + 1
+ }
+ index += 1
+ }
+ return next
+ }
+
+ /// The funding key index of a new exit: past the counter the last start left and every index the stored record
+ /// used. Records from before per-exit indexes used 0.
+ static func nextFundingIndex(counter: Int, record: SparkExitRecord?) -> UInt32 {
+ let afterRecord = record?.funding.map(\.index).max().map { $0 + 1 } ?? 0
+ return max(UInt32(clamping: max(0, counter)), afterRecord)
+ }
+
+ /// What stays on Spark once a quote's exit took its leaves: the balance beyond them, skipped leaves included, less
+ /// the lost leaves it still counts until a sync drops them. Nil when nothing.
+ static func outsideSats(sparkBalanceSats: UInt64, quote: SparkExitQuote) -> UInt64? {
+ let outside = beyondLostLeaves(sparkBalanceSats.saturatingSub(quote.leafTotalSats), lostLeafSats: quote.lostLeafSats)
+ return outside > 0 ? outside : nil
+ }
+
+ /// The Spark balance a quote cannot exit: the leaves it skips, and what the balance holds beyond every leaf the
+ /// SDK lists, such as a counter-swap still unclaimed. Nil when there is none.
+ static func unexitableSats(sparkBalanceSats: UInt64, quote: SparkExitQuote) -> UInt64? {
+ let listed = quote.leafTotalSats + quote.leftBehindSats
+ 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")
+ }
+
+ /// 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 exit still brings to savings: what it expects, less what already reached savings, less the first fee
+ /// funding nobody paid yet (the expected amount counts it as coming back), less leaves an older state claimed,
+ /// which never arrive, and leaves the exit left behind, which stay on Spark. Only confirmed steps count as reached: the sweep is in the plan from
+ /// its first build.
+ static func inTransferSats(_ record: SparkExitRecord, fundingSeenSats: UInt64) -> UInt64 {
+ let confirmed = (record.recovery?.response.transactions ?? []).filter {
+ guard case .confirmed = $0.status else { return false }
+ return true
+ }
+ let current = paidToDestination(confirmed, destination: record.destination)
+ let received = (record.earlierReceivedSats ?? [:]).merging(current) { $1 }.values.reduce(0, +)
+ var unpaid: UInt64 = 0
+ if case let .awaitingFunding(requiredSats) = record.phase, record.fundingPaymentTxid == nil {
+ unpaid = requiredSats.saturatingSub(max(record.fundedSats, fundingSeenSats))
+ }
+ return record.expectedSats.saturatingSub(received).saturatingSub(unpaid).saturatingSub(record.lostSats)
+ .saturatingSub(record.leftBehindSats)
+ }
+
+ /// What stays on Spark while the exit runs: the Spark balance beyond the exit's leaves, such as a counter-swap
+ /// claimed late, or, once published leaves left the balance, what the start saw beyond them (`outsideSats`),
+ /// whichever is more. Nil when nothing is beyond them.
+ static func outsideExitSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? {
+ guard record.isActive else { return nil }
+ let live = staysOnSparkSats(sparkBalanceSats: sparkBalanceSats, record: record) ?? 0
+ // The start's snapshot predates any leaf the exit left behind, which stays on Spark too.
+ let outside = max(live, (record.outsideSats ?? 0) + record.leftBehindSats)
+ return outside > 0 ? outside : nil
+ }
+
+ /// 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`); a leaf the exit left behind stays on
+ /// Spark, so it counts here, not in the transfer.
+ static func staysOnSparkSats(sparkBalanceSats: UInt64, record: SparkExitRecord) -> UInt64? {
+ guard let leafTotal = record.leafTotalSats else { return nil }
+ let liveLeafTotal = leafTotal.saturatingSub(record.lostSats).saturatingSub(record.leftBehindSats)
+ 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.
+ 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 Spark reads Bitkit's chain, which the payment goes out on
+ /// (`readsBitkitChain`), and its spendable balance covers the funding and the payment's own fee.
+ static func canFundFromSavings(spendableSats: UInt64, fundingSats: UInt64, paymentFeeSats: UInt64, readsBitkitChain: Bool) -> Bool {
+ readsBitkitChain && fundingSats > 0 && spendableSats >= fundingSats.saturatingAdd(paymentFeeSats)
+ }
+
+ /// Savings can pay a higher fee's `totalSats` (its extra funding and that payment's fee) on the same terms: Spark
+ /// reads Bitkit's chain and the spendable balance covers it. A rate with nothing to pay needs no payment.
+ static func canPayFeeBump(totalSats: UInt64?, spendableSats: UInt64, readsBitkitChain: Bool) -> Bool {
+ guard let totalSats else { return false }
+ return totalSats == 0 || (readsBitkitChain && spendableSats >= totalSats)
+ }
+}
+
+/// Each wallet's next exit funding index in the metadata backup, keyed `/wallet` as bitkit-android keys its
+/// `sparkExitNextFundingIndexes`, so a backup from either app restores on the other.
+enum SparkExitFundingIndexes {
+ private static let defaultsPrefix = "sparkExitNextFundingIndex-"
+
+ /// How the metadata backup keys a wallet's exit state, as bitkit-android does.
+ static func walletKey(network: String, walletIndex: Int) -> String {
+ "\(network)/wallet\(walletIndex)"
+ }
+
+ /// Set when a restore brought the counter back, so the exit looks once for funding addresses that still hold coins.
+ static func recoveryPendingKey(network: String, walletIndex: Int) -> String {
+ "sparkExitFundingRecoveryPending-\(network)-\(walletIndex)"
+ }
+
+ /// The counter the last exit start left, per network and wallet.
+ static func defaultsKey(network: String, walletIndex: Int) -> String {
+ "\(defaultsPrefix)\(network)-\(walletIndex)"
+ }
+
+ /// The counters kept in `defaults`, nil with none.
+ static func backup(_ defaults: UserDefaults) -> [String: Int]? {
+ let kept = defaults.dictionaryRepresentation().reduce(into: [String: Int]()) { kept, entry in
+ guard entry.key.hasPrefix(defaultsPrefix), let index = entry.value as? Int else { return }
+ let rest = entry.key.dropFirst(defaultsPrefix.count)
+ guard let dash = rest.lastIndex(of: "-"), let wallet = Int(rest[rest.index(after: dash)...]) else { return }
+ kept["\(rest[.. 0 || isLeftoverKept) && isResultSeen != true
+ }
+
+ /// The fee change stays at the funding address for good, shown with the path that spends it.
+ var isChangeKept: Bool {
+ !leftover.isEmpty && leftoverSweep?.isDustKept == true
+ }
+
+ /// The leftover stays at the funding address for good: change too small to send, or a sweep refused for a reason
+ /// other than its fee, which nothing retries.
+ var isLeftoverKept: Bool {
+ isChangeKept || (!leftover.isEmpty && leftoverSweep?.rejection != nil && !isLeftoverSweepPending)
+ }
+
+ /// Whether Emergency exit opens this exit rather than a new one: it runs, its leftover is still on its way to
+ /// savings, or it failed, lost leaves or kept its leftover at the funding address and the user has not seen that
+ /// yet. A kept leftover's address and path stay in the earlier exit addresses (`SparkExitEarlierAddress`), so a
+ /// later exit may replace this record.
+ var showsResult: Bool {
+ needsDriving || 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, its leftover fee funding is on its way to savings, or a
+ /// savings payment to the funding address is still to arrive and join that leftover.
+ var needsDriving: Bool {
+ isActive || isLeftoverSweepPending || isEndedPaymentPending
+ }
+
+ /// The exit ended while a savings payment Bitkit sent to its funding address had not arrived: it is followed
+ /// until it lands (and joins the leftover) or the own-payment expiry forgets it.
+ var isEndedPaymentPending: Bool {
+ guard !isActive, let paid = fundingPaymentTxid else { return false }
+ return !funding.contains { $0.txid == paid } && !leftover.contains { $0.txid == paid }
+ }
+
+ var fundingInputs: [CpfpInput] {
+ 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.chainedTxids(recovery.response)
+ 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 }
+ }
+ if let sweep = recovery.response.transactions.first(where: { $0.kind == .sweep }), case .confirmed = sweep.status {
+ landedSweepTxid = sweep.txid
+ }
+ }
+ generation += 1
+ recovery = StoredRecovery(built)
+ broadcastLog = []
+ phase = .running
+ isRebuilding = false
+ lastBuildAt = date
+ failedLeafCount = built.failed.count
+ if isRebuiltFromChain == true {
+ rebuiltFromChainConfirmedCount = SparkExitMath.confirmedCount(built)
+ }
+ isRedoRebuild = nil
+ isUnverifiedAsk = nil
+ isFeeBumpPending = nil
+ feeBumpBuiltAtHeight = nil
+ }
+
+ /// Ends the rebuilt-from-chain note once the rebuilt exit moved: a step confirmed beyond those already in a block.
+ 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
+ }
+
+ /// 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
+ }
+
+ /// Leaves are still on their way to savings outside the running recovery's steps: cooperative ones the operators
+ /// could not co-sign (the recovery lists them, or their retry runs), or ones a build skipped while they may move.
+ var hasLeavesOutstanding: Bool {
+ isRetryingFailedLeaves || (isActive && (!(recovery?.response.failed.isEmpty ?? true) || !(waitingLeafIds ?? []).isEmpty))
+ }
+
+ /// Waiting for the operators to co-sign leaves they moved on-chain: nothing of the exit's own is left to send or
+ /// confirm, so a retry whose steps still confirm keeps their reminders.
+ var isWaitingForOperators: Bool {
+ guard isRetryingFailedLeaves, let recovery = recovery?.response, !recovery.failed.isEmpty else { return false }
+ return recovery.transactions.allSatisfy {
+ if case .confirmed = $0.status {
+ return true
+ }
+ return false
+ }
+ }
+
+ var failedLeavesSats: UInt64 {
+ (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.
+ /// The bump stays pending until a build at the new rate is adopted.
+ mutating func applyFeeBump(rate: UInt64, extraSats: UInt64, paymentTxid: String?) {
+ feeRateSatPerVbyte = rate
+ isRedoRebuild = nil
+ isFeeBumpPending = true
+ feeBumpBuiltAtHeight = nil
+ fundingPaymentSeenAtHeight = nil
+ fundingPaymentMissingSince = nil
+ if extraSats > 0 {
+ 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? {
+ 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 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 }
+ }
+
+ /// A top-up is awaited for a generation that is still valid (a higher fee, not a diverged rebuild): its steps keep
+ /// being checked, sent and reminded.
+ var isDrivingWhileFunding: Bool {
+ if case .needsFunding = phase {
+ return recovery != nil && !isRebuilding && !isFundingWaitingLeaves
+ }
+ return false
+ }
+
+ /// Fee funding asked for `waitingLeafIds` once every step of the recovery confirmed: only those leaves are built
+ /// when it arrives.
+ var isFundingWaitingLeaves: Bool {
+ guard case .needsFunding = phase, !(waitingLeafIds ?? []).isEmpty, let recovery else { return false }
+ return recovery.response.transactions.allSatisfy {
+ if case .confirmed = $0.status {
+ return true
+ }
+ return false
+ }
+ }
+
+ /// Progress says the exit is underpriced: it runs at its rate, nothing rebuilds it and no higher fee is on its way.
+ func showsFeeLow(tip: UInt32?) -> Bool {
+ phase == .running && !isRebuilding && underpricedStep(tip: tip) != nil
+ }
+
+ /// 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 recovery != nil else { return nil }
+ let confirmed = confirmedTxids
+ return broadcastLog.first {
+ 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
+ }
+ }
+}
+
+// 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)
+ }
+
+ /// 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 }
+ do {
+ return try Data(contentsOf: url)
+ } catch {
+ throw Unavailable(underlying: error)
+ }
+ }
+
+ /// Keeps a copy of the record beside it for support, leaving the record in place: a salvaged record then replaces it
+ /// in one atomic write, so a kill in between never leaves the exit without a record.
+ func keepCopyAside() throws {
+ let aside = url.deletingPathExtension().appendingPathExtension("unreadable-\(Int(Date().timeIntervalSince1970)).json")
+ try FileManager.default.copyItem(at: url, to: aside)
+ }
+
+ /// Moves an unreadable record out of the way, keeping it for support.
+ func setAside() throws {
+ let aside = url.deletingPathExtension().appendingPathExtension("unreadable-\(Int(Date().timeIntervalSince1970)).json")
+ 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)
+ }
+
+ /// Deletes the record and the copies kept aside for support (`keepCopyAside`, `setAside`), for a wallet being
+ /// wiped: they hold its addresses and signed transactions.
+ func deleteWithCopies() throws {
+ try delete()
+ let dir = url.deletingLastPathComponent()
+ guard let names = try? FileManager.default.contentsOfDirectory(atPath: dir.path) else { return }
+ let prefix = url.deletingPathExtension().lastPathComponent + ".unreadable-"
+ for name in names where name.hasPrefix(prefix) {
+ try FileManager.default.removeItem(at: dir.appendingPathComponent(name))
+ }
+ }
+}
+
+/// The ids of the leaves an older state of the wallet claimed on-chain, across exits. The operators keep listing such
+/// 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
+ }
+
+ 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
+ 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.fundingStrategy = kept.fundingStrategy
+ record.earlierReceivedSats = kept.earlierReceivedSats
+ record.outsideSats = kept.outsideSats
+ // Change and fan-out outputs of earlier generations at the funding address stay the exit's own, not new funding.
+ let chained = (kept.earlierCpfpTxids ?? []) + (kept.recovery.map { SparkExitTxid.chainedTxids($0.response) } ?? [])
+ record.earlierCpfpTxids = chained.isEmpty ? nil : chained
+ 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?
+ let earlierCpfpTxids: [String]?
+ let recovery: StoredRecovery?
+ let fundingStrategy: String?
+ let earlierReceivedSats: [String: UInt64]?
+ let outsideSats: UInt64?
+
+ private enum CodingKeys: String, CodingKey {
+ case network, createdAt, phase, feeRateSatPerVbyte, destination, leafIds, leafTotalSats, funding, transferId, expectedSats
+ case lostLeaves, leftBehindLeaves, exitStateSnapshot, earlierCpfpTxids, recovery, fundingStrategy, earlierReceivedSats
+ case outsideSats
+ }
+
+ init(from decoder: Decoder) throws {
+ 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)
+ earlierCpfpTxids = try? c.decodeIfPresent([String].self, forKey: .earlierCpfpTxids)
+ recovery = try? c.decodeIfPresent(StoredRecovery.self, forKey: .recovery)
+ fundingStrategy = try? c.decodeIfPresent(String.self, forKey: .fundingStrategy)
+ earlierReceivedSats = try? c.decodeIfPresent([String: UInt64].self, forKey: .earlierReceivedSats)
+ outsideSats = try? c.decodeIfPresent(UInt64.self, forKey: .outsideSats)
+ }
+ }
+}
+
+/// 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
+
+/// `RecoverFundsResponse` as spark-sdk serializes it with serde (unchanged from 0.27.2 to 0.28.0): snake_case fields and externally tagged
+/// enums (`"Ready"`, `{"Confirmed":{"block_height":120}}`). A stored recovery is then readable by the Breez CLI's
+/// `check-recover-funds` and shared with Android as one format. The SDK's Swift types are not `Codable`.
+struct StoredRecovery: Codable, Equatable {
+ 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/CurrencyViewModel.swift b/Bitkit/ViewModels/CurrencyViewModel.swift
index e2472fff2..cfd569a64 100644
--- a/Bitkit/ViewModels/CurrencyViewModel.swift
+++ b/Bitkit/ViewModels/CurrencyViewModel.swift
@@ -169,6 +169,12 @@ extension CurrencyViewModel {
Self.primaryAmountText(sats: sats, converted: convert(sats: sats), primaryDisplay: primaryDisplay, displayUnit: displayUnit)
}
+ /// An amount the user sends from another wallet, always in bitcoin: it must be exact, which a fiat figure converted
+ /// at another wallet's rate is not.
+ func bitcoinAmountText(sats: UInt64) -> String {
+ Self.primaryAmountText(sats: sats, converted: convert(sats: sats), primaryDisplay: .bitcoin, displayUnit: displayUnit)
+ }
+
nonisolated static func primaryAmountText(
sats: UInt64,
converted: ConvertedAmount?,
diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift
index 9288dc98a..6cf988e5e 100644
--- a/Bitkit/ViewModels/NavigationViewModel.swift
+++ b/Bitkit/ViewModels/NavigationViewModel.swift
@@ -104,6 +104,12 @@ enum Route: Hashable {
case connections
case connectionDetail(channelId: String)
case sparkConnectionDetail
+ case sparkExitIntro
+ case sparkExitQuote
+ case sparkExitFunding
+ case sparkExitConfirm
+ case sparkExitProgress
+ case sparkExitFeeBump
case closeConnection(channel: ChannelDetails)
case node
case electrumSettings
diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift
index f65b63719..a21e0b3bd 100644
--- a/Bitkit/ViewModels/SettingsViewModel.swift
+++ b/Bitkit/ViewModels/SettingsViewModel.swift
@@ -1000,7 +1000,8 @@ class SettingsViewModel: NSObject, ObservableObject {
dismissedSuggestions: defaults.stringArray(forKey: "dismissedSuggestions") ?? [],
lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [],
quickPayLedger: spend,
- detachedActivityContacts: CoreService.shared.activity.detachedContactsBackupSnapshot()
+ detachedActivityContacts: CoreService.shared.activity.detachedContactsBackupSnapshot(),
+ sparkExitNextFundingIndexes: SparkExitFundingIndexes.backup(defaults)
)
}
@@ -1030,5 +1031,6 @@ class SettingsViewModel: NSObject, ObservableObject {
defaults.set(cache.lastUsedTags, forKey: "lastUsedTags")
QuickPaySpendStore.shared.restoreFromBackup(ledger: cache.quickPayLedger)
CoreService.shared.activity.restoreDetachedContacts(cache.detachedActivityContacts)
+ SparkExitFundingIndexes.restore(cache.sparkExitNextFundingIndexes, into: defaults)
}
}
diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift
index e62d34efe..bfef8b1a5 100644
--- a/Bitkit/ViewModels/TransferViewModel.swift
+++ b/Bitkit/ViewModels/TransferViewModel.swift
@@ -469,7 +469,7 @@ class TransferViewModel: ObservableObject {
sparkFeeTask?.cancel()
sparkFeeTask = nil
isPricingSparkWithdrawal = false
- guard sparkBalanceSats() > 0 else { return }
+ guard sparkBalanceSats() > 0, !SparkManager.shared.isExiting else { return }
guard let confirmedFeeSats = confirmedSparkFeeSats, let address = sparkWithdrawAddress else {
throw AppError(message: "lightning__spending_confirm__error_quote", debugMessage: "Spark withdrawal not priced")
}
@@ -960,7 +960,8 @@ struct SavingsSelection: Equatable {
}
/// Without a pick (`selectedChannelIds` nil) every ready channel moves. Spark moves when picked and holding a balance
- /// its withdrawal fee leaves something of, so one too small to move never holds the channels back.
+ /// its withdrawal fee leaves something of, so one too small to move never holds the channels back. Its balance 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 d30e6d257..3cb2de741 100644
--- a/Bitkit/ViewModels/WalletViewModel.swift
+++ b/Bitkit/ViewModels/WalletViewModel.swift
@@ -1418,23 +1418,38 @@ class WalletViewModel: ObservableObject {
}
}
+ /// Spark's part of Spending: its balance, or while an exit runs only what it holds beyond the exit's leaves.
+ nonisolated static func spendingSparkSats(isExiting: Bool, balanceSats: UInt64, outsideExitSats: UInt64?) -> UInt64 {
+ isExiting ? (outsideExitSats ?? 0) : balanceSats
+ }
+
func updateBalanceState() async {
// Ensure we have fresh data from LDK before computing balance
await lightningService.refreshCache()
do {
try? await transferService.syncTransferStates()
- let state = try await balanceManager.deriveBalanceState()
+ let state = try await balanceManager.deriveBalanceState(exitInTransfer: SparkExitManager.shared.inTransfer)
let spark = SparkManager.shared
balanceInTransferToSavings = Int(state.balanceInTransferToSavings)
balanceInTransferToSpending = Int(state.balanceInTransferToSpending + spark.pendingDepositSats)
+ // During an emergency exit the exit's leaves are on their way to savings and counted there instead; what
+ // Spark holds beyond them stays in Spending.
+ let outsideExitSats = SparkExitManager.shared.record.flatMap {
+ SparkExitMath.outsideExitSats(sparkBalanceSats: spark.balanceSats, record: $0)
+ }
+ let sparkBalanceSats = Self.spendingSparkSats(
+ isExiting: spark.isExiting,
+ balanceSats: spark.shownBalanceSats,
+ outsideExitSats: outsideExitSats
+ )
// Update display values with adjusted balances
totalOnchainSats = Int(state.totalOnchainSats)
- totalLightningSats = Int(state.totalLightningSats + spark.shownBalanceSats)
- totalBalanceSats = Int(state.totalBalanceSats + spark.shownBalanceSats + 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()
@@ -1449,6 +1464,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/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift
index 57392d2eb..7d52a29f8 100644
--- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift
+++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift
@@ -240,7 +240,7 @@ struct LightningConnectionsView: View {
// MARK: - Computed Properties
private var spendingBalance: UInt64 {
- let sparkSats = SparkManager.shared.shownBalanceSats
+ let sparkSats = SparkManager.shared.shownSpendingSats
guard let balanceDetails = wallet.balanceDetails else { return sparkSats }
return balanceDetails.totalLightningBalanceSats + sparkSats
}
diff --git a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift
index 1214bddad..8e1c11aa0 100644
--- a/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift
+++ b/Bitkit/Views/Settings/Advanced/SparkConnectionViews.swift
@@ -9,7 +9,11 @@ struct SparkConnectionRow: View {
static var isVisible: Bool {
let spark = SparkManager.shared
- return spark.isReady && (spark.shownBalanceSats > 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
+ // An earlier exit's address stays reachable once Spark holds nothing.
+ return SparkExitManager.shared.showsResult || !SparkExitManager.shared.shownEarlierAddresses.isEmpty
+ || spark.isReady && (spark.shownBalanceSats > 0 || !spark.deposits.isEmpty || hasExit || Env.network == .regtest)
}
var body: some View {
@@ -112,6 +116,12 @@ struct SparkConnectionDetailView: View {
private let spark = SparkManager.shared
+ /// The emergency exit is offered while Spark holds funds, also a kept balance while it reads 0, and leads to the
+ /// exit's progress or result while it runs or its leftover fee funding is still on its way to savings.
+ private var showsExit: Bool {
+ spark.shownBalanceSats > 0 || spark.recoverableFundsSats > 0 || SparkExitManager.shared.showsResult
+ }
+
var body: some View {
VStack(alignment: .leading, spacing: 0) {
NavigationBar(title: t("lightning__spark"))
@@ -176,6 +186,26 @@ struct SparkConnectionDetailView: View {
}
}
+ let earlier = SparkExitManager.shared.shownEarlierAddresses
+ if !earlier.isEmpty {
+ VStack(alignment: .leading, spacing: 0) {
+ SettingsSectionHeader(t("lightning__spark_exit__earlier_addresses"))
+ BodySText(t("lightning__spark_exit__earlier_addresses_text"))
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.bottom, 8)
+ ForEach(earlier) { entry in
+ copyRow(label: t("wallet__activity_address"), value: entry.address)
+ .accessibilityIdentifier("SparkExitEarlierAddress")
+ copyRow(label: t("lightning__spark_exit__earlier_path"), value: entry.path)
+ .accessibilityIdentifier("SparkExitEarlierPath")
+ if let sats = entry.sats {
+ amountRow(label: t("lightning__balance"), amount: sats)
+ }
+ }
+ .padding(.bottom, 8)
+ }
+ }
+
if !spark.withdrawals.isEmpty {
VStack(alignment: .leading, spacing: 0) {
SettingsSectionHeader(t("lightning__savings_confirm__label"))
@@ -198,18 +228,30 @@ 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)
.bottomSafeAreaPadding()
.refreshable {
await spark.refreshState()
+ await SparkExitManager.shared.refreshEarlierAddresses()
}
.task {
await loadDepositAddress()
}
+ .task {
+ await SparkExitManager.shared.refreshEarlierAddresses()
+ }
}
/// On regtest, shows Spark's deposit address so testers can fund Spark from Lightspark's regtest faucet.
@@ -236,6 +278,18 @@ struct SparkConnectionDetailView: View {
}
}
+ private func copyRow(label: String, value: String) -> some View {
+ Button {
+ UIPasteboard.general.string = value
+ Haptics.play(.copiedToClipboard)
+ app.toast(type: .success, title: t("common__copied"), description: value)
+ } label: {
+ detailRow(label: label, value: value)
+ .contentShape(.rect)
+ }
+ .buttonStyle(PlainButtonStyle())
+ }
+
private func amountRow(label: String, amount: UInt64) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center, spacing: 0) {
diff --git a/Bitkit/Views/Settings/Advanced/SparkExitViews.swift b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift
new file mode 100644
index 000000000..e5800f5c3
--- /dev/null
+++ b/Bitkit/Views/Settings/Advanced/SparkExitViews.swift
@@ -0,0 +1,1162 @@
+import BitkitCore
+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: .brandAccent)
+ .padding(.top, 32)
+
+ BodyMText(t("lightning__spark_exit__intro_text"), accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, SparkExitLayout.headlineGap)
+
+ 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: .brandAccent)
+ .padding(.top, 32)
+ .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": sparkExitAmountAccent(currency.primaryAmountText(sats: unexitableSats))]
+ ), accentColor: .white, accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitQuoteOutside")
+ }
+ } else if exit.isQuoting {
+ let key = SparkManager.shared.isSettlingForExit ? "lightning__spark_exit__quote_settling" : "lightning__spark_exit__quote_loading"
+ 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, 32)
+ }
+
+ 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
+ }
+
+ /// What savings pays: the funding less what another wallet already sent to the address, plus the savings
+ /// payment's own input fee then, as the start pays it.
+ private var savingsSats: UInt64 {
+ exit.savingsSats
+ }
+
+ /// 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(
+ spendableSats: UInt64(max(0, wallet.spendableOnchainBalanceSats)),
+ fundingSats: savingsSats,
+ paymentFeeSats: savingsFeeSats,
+ readsBitkitChain: SparkManager.shared.canFundFromChain
+ )
+ }
+
+ 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: .brandAccent)
+ .padding(.top, 32)
+
+ BodyMText(
+ t("lightning__spark_exit__funding_text", variables: ["amount": currency.bitcoinAmountText(sats: fundingSats)]),
+ accentFont: Fonts.bold
+ )
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, SparkExitLayout.headlineGap)
+
+ 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: prestartFunding.toSend
+ )
+ .padding(.top, 24)
+ }
+ }
+ .padding(.bottom, 32)
+ }
+
+ if showsAddress {
+ CustomButton(title: t("common__continue")) {
+ exit.fundFromSavings = false
+ navigation.navigate(.sparkExitConfirm)
+ }
+ .disabled(exit.fundingSeenSats < prestartFunding.covered)
+ .accessibilityIdentifier("SparkExitFundingContinue")
+ } else {
+ // Savings on a chain Spark does not read is off without a balance line, as on the transfer screens.
+ if SparkManager.shared.canFundFromChain, hasSavingsFeeFailed || (savingsFeeSats != nil && !canFundFromSavings) {
+ CaptionText(t("lightning__spark_exit__fund_insufficient"))
+ .padding(.bottom, 16)
+ }
+
+ 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(id: savingsSats) {
+ await estimateSavingsFee()
+ }
+ .task(id: showsAddress) {
+ guard showsAddress else { return }
+ await exit.watchFundingAddress()
+ }
+ }
+
+ private func estimateSavingsFee() async {
+ let wallet = wallet
+ let feeEstimatesManager = feeEstimatesManager
+ let priced = await Self.priceSavingsFee(
+ address: exit.fundingAddress,
+ fundingSats: savingsSats,
+ refusedFeeSats: exit.savingsFundingRefusedFeeSats,
+ estimates: { await feeEstimatesManager.getEstimates(refresh: true) },
+ calculate: { try await wallet.calculateTotalFee(address: $0, amountSats: $1, satsPerVByte: $2) }
+ )
+ switch priced {
+ case .unavailable:
+ break
+ case .failed:
+ hasSavingsFeeFailed = true
+ case let .priced(rate, feeSats):
+ exit.savingsFeeRate = rate
+ exit.savingsFeeSats = feeSats
+ savingsFeeSats = feeSats
+ }
+ }
+
+ enum SavingsFee: Equatable {
+ /// Nothing to price: no funding address or amount yet.
+ case unavailable
+ case failed
+ case priced(rate: UInt32, feeSats: UInt64)
+ }
+
+ /// What savings pays to send the fee funding at the fast rate.
+ nonisolated static func priceSavingsFee(
+ address: String?,
+ fundingSats: UInt64,
+ refusedFeeSats: UInt64? = nil,
+ estimates: () async -> FeeRates?,
+ calculate: (String, UInt64, UInt32) async throws -> UInt64
+ ) async -> SavingsFee {
+ guard let address, fundingSats > 0 else { return .unavailable }
+ // Without rates there is no fee to confirm: Pay from savings shows its error rather than loading for good.
+ guard let feeRates = await estimates() else {
+ Logger.warn("Failed to read fee rates for the Spark exit funding from savings", context: "SparkExitFundingView")
+ return .failed
+ }
+ let rate = TransactionSpeed.fast.getFeeRate(from: feeRates)
+ do {
+ // Coin selection can need more fee than the estimate: after a refused start, at least the fee it asked.
+ let feeSats = try await calculate(address, fundingSats, rate)
+ return .priced(rate: rate, feeSats: max(feeSats, refusedFeeSats ?? 0))
+ } catch {
+ Logger.warn("Failed to price the Spark exit funding from savings: \(error)", context: "SparkExitFundingView")
+ return .failed
+ }
+ }
+}
+
+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) {
+ // The receive screen's tile, at a fixed size with black space around it.
+ QR(content: address)
+ .frame(width: 200, height: 200)
+ .padding(.vertical, 16)
+ .frame(maxWidth: .infinity)
+
+ BodySText(
+ t("lightning__spark_exit__fund_external_text",
+ variables: ["amount": sparkExitAmountAccent(currency.bitcoinAmountText(sats: requiredSats))]),
+ accentColor: .white,
+ accentFont: Fonts.bold
+ )
+
+ Button {
+ UIPasteboard.general.string = address
+ Haptics.play(.copiedToClipboard)
+ app.toast(type: .success, title: t("common__copied"), description: address)
+ } label: {
+ CaptionBText(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": sparkExitAmountAccent(currency.bitcoinAmountText(sats: receivedSats)),
+ "required": sparkExitAmountAccent(currency.bitcoinAmountText(sats: targetSats)),
+ "missing": sparkExitAmountAccent(currency.bitcoinAmountText(sats: missingSats)),
+ ]), accentColor: .white, accentFont: Fonts.bold)
+ .accessibilityIdentifier("SparkExitFundingPartial")
+ } else if showsSeen, receivedSats >= targetSats, targetSats > 0 {
+ CaptionText(t("lightning__spark_exit__fund_seen"), textColor: .greenAccent)
+ }
+ }
+ }
+}
+
+/// The amount marked as an inline accent: bold white on a grey line, bold red on a red one.
+func sparkExitAmountAccent(_ amount: String) -> String {
+ "\(amount)"
+}
+
+/// A fee funding payment on its way: no QR and no amount to send, only what is awaited and its transaction.
+struct SparkExitWaitingPayment: View {
+ let text: String
+ let txid: String
+ let testId: String
+
+ var body: some View {
+ SparkExitTxNote(text: text, txid: txid)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier(testId)
+ }
+}
+
+/// A line of progress with the transaction it is about, in the activity explore cell whose id copies on tap.
+struct SparkExitTxNote: View {
+ let text: String
+ let txid: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ BodySText(text, accentColor: .white, accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ ActivityInfoSection(title: t("wallet__activity_tx_id"), content: txid, usesCustomDivider: true, bottomSpacing: 0)
+ .padding(.top, 16)
+ }
+ }
+}
+
+/// 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 path: String {
+ SparkExitFundingKey.path(index: record.fundingIndex, network: Env.bitkitCoreNetwork)
+ }
+
+ /// One line with the total of every UTXO at the funding address, never one per UTXO, after what earlier sweeps sent.
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(record.sweptLeftover ?? [], id: \.txid) { swept in
+ SparkExitTxNote(
+ text: t("lightning__spark_exit__leftover_swept",
+ variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: swept.valueSats))]),
+ txid: swept.txid
+ )
+ }
+ switch record.leftoverLine {
+ case let .sending(sats, txid):
+ if let txid {
+ SparkExitTxNote(
+ text: t("lightning__spark_exit__leftover_sending",
+ variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]),
+ txid: txid
+ )
+ } else {
+ BodySText(
+ t(
+ "lightning__spark_exit__leftover_sending",
+ variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]
+ ),
+ accentColor: .white,
+ accentFont: Fonts.bold
+ )
+ }
+ case let .swept(sats, txid):
+ SparkExitTxNote(
+ text: t("lightning__spark_exit__leftover_swept",
+ variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]),
+ txid: txid
+ )
+ case let .waiting(sats):
+ BodySText(
+ t("lightning__spark_exit__leftover_waiting",
+ variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats))]),
+ accentColor: .white,
+ accentFont: Fonts.bold
+ )
+ .fixedSize(horizontal: false, vertical: true)
+ case let .kept(sats):
+ BodySText(t("lightning__spark_exit__leftover_dust", variables: [
+ "amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats)), "address": address, "path": path,
+ ]), accentColor: .white, accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ case let .rejected(sats):
+ // Refused for good: where the coins stay, and the path that spends them with the recovery phrase.
+ BodySText(t("lightning__spark_exit__leftover_rejected", variables: [
+ "amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats)), "address": address, "path": path,
+ ]), accentColor: .white, accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ case let .stillAt(sats):
+ BodySText(
+ t("lightning__spark_exit__leftover", variables: [
+ "amount": sparkExitAmountAccent(currency.primaryAmountText(sats: sats)),
+ "address": address,
+ ]),
+ accentColor: .white,
+ accentFont: Fonts.bold
+ )
+ .fixedSize(horizontal: false, vertical: true)
+ case nil:
+ EmptyView()
+ }
+ }
+ .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: .brandAccent)
+ .padding(.top, 32)
+
+ BodyMText(t("lightning__spark_exit__confirm_text"), accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, SparkExitLayout.headlineGap)
+ .padding(.bottom, 32)
+
+ if let quote = exit.quote {
+ SparkExitQuoteRows(quote: quote)
+ }
+
+ if let savingsPayment = exit.savingsPayment {
+ SparkExitAmountRow(label: t("lightning__spark_exit__savings_fee"), sats: savingsPayment.feeSats)
+ SparkExitAmountRow(label: t("lightning__spark_exit__savings_total"), sats: savingsPayment.totalSats)
+ }
+ }
+ .padding(.bottom, 32)
+ }
+
+ SwipeButton(title: t("lightning__spark_exit__swipe"), accentColor: .brandAccent) {
+ do {
+ try await exit.start()
+ navigation.navigate(.sparkExitProgress)
+ } catch let error as SparkExitStartCancelled {
+ // Cancelled while the start waited: nothing was paid, nothing to say. Rethrown so the swipe resets.
+ throw error
+ } catch {
+ if case let OnchainSendAttemptError.preDispatch(refused) = error, let changed = refused as? SparkFundingChanged {
+ // Savings never pays more than the fee confirmed: the next funding asks for the refused one.
+ exit.noteSavingsFundingRefused(preparedFeeSats: changed.preparedFeeSats)
+ app.toast(type: .info, title: t("lightning__spending_confirm__fees_changed_title"))
+ } else {
+ app.toast(error)
+ }
+ // The exit started but paying its fee funding failed: the progress screen shows where to fund it.
+ if exit.isExiting {
+ navigation.navigate(.sparkExitProgress)
+ 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 app: AppViewModel
+ @ObservedObject private var notifications = PushNotificationManager.shared
+ @EnvironmentObject var navigation: NavigationViewModel
+ @EnvironmentObject var currency: CurrencyViewModel
+
+ private let exit = SparkExitManager.shared
+ @State private var isConfirmingCancel = false
+
+ 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": sparkExitAmountAccent(currency.primaryAmountText(sats: record.lostSats))]
+ )
+ }
+
+ /// Lost leaves are gone, so the text claims only what Spark still holds.
+ private var failedText: String {
+ if record?.isCancelled == true {
+ return t("lightning__spark_exit__cancelled_text")
+ }
+ let staysSats = record.flatMap { SparkExitMath.staysOnSparkSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: $0) }
+ return [
+ t("lightning__spark_exit__failed_text"),
+ staysSats.map { t(
+ "lightning__spark_exit__failed_stays",
+ variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: $0))]
+ ) },
+ ].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 where record?.isCancelled == true: t("lightning__spark_exit__cancelled_title")
+ case .failed: t("lightning__spark_exit__failed_title")
+ case .needsFunding where !isFundingOnItsWay: t("lightning__spark_exit__needs_funding_title")
+ default: t("lightning__spark_exit__progress_title")
+ }
+ }
+
+ private var text: String {
+ switch record?.phase {
+ case .done:
+ [
+ // 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": sparkExitAmountAccent(currency.primaryAmountText(sats: $0.sats))])
+ } ?? t("lightning__spark_exit__done_text"),
+ (record?.skippedLeafCount ?? 0) > 0
+ ? t("lightning__spark_exit__partial_text", variables: ["count": "\(record?.skippedLeafCount ?? 0)"]) : nil,
+ record.flatMap { $0.isFailedLeavesReleased == true && $0.failedLeavesSats > 0 ? $0.failedLeavesSats : nil }.map {
+ t("lightning__spark_exit__failed_leaves", variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: $0))])
+ },
+ ].compactMap { $0 }.joined(separator: " ")
+ case .failed: failedText
+ case .needsFunding where !isFundingOnItsWay:
+ t(
+ "lightning__spark_exit__needs_funding_text",
+ variables: ["amount": currency.bitcoinAmountText(sats: record.flatMap { SparkExitMath.neededSats($0, state: fundingState) } ?? 0)]
+ )
+ default: t("lightning__spark_exit__progress_text")
+ }
+ }
+
+ 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(title, accentColor: .brandAccent)
+ .padding(.top, 32)
+
+ BodyMText(text, accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, SparkExitLayout.headlineGap)
+ .padding(.bottom, 24)
+
+ if let record {
+ SparkExitSteps(record: record, tipHeight: exit.tipHeight)
+
+ if let sweepTxid = SparkExitMath.received(record)?.sweepTxid {
+ ActivityInfoSection(
+ title: t("wallet__activity_tx_id"),
+ content: sweepTxid,
+ testId: "SparkExitSweepTxid",
+ usesCustomDivider: true,
+ bottomSpacing: 0
+ )
+ .padding(.top, 24)
+ }
+
+ if exit.isSavingsPaymentRefused {
+ BodySText(t("lightning__spark_exit__savings_refused"), textColor: .redAccent)
+ .padding(.top, 24)
+ .accessibilityIdentifier("SparkExitSavingsRefused")
+ }
+
+ if let address = record.funding.first?.address {
+ switch fundingState {
+ case let .ask(sats, receivedSats):
+ // The ask counts the input fee of each payment, so it is what is missing; the hint
+ // names the full total, not the dust-floored ask added to what arrived.
+ SparkExitFundingAddress(
+ address: address,
+ requiredSats: sats,
+ receivedSats: receivedSats,
+ targetSats: exit.fundingAskTotal ?? sats + receivedSats,
+ showsSeen: false
+ )
+ .padding(.top, 24)
+ case let .paid(txid):
+ SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_paid"), txid: txid, testId: "SparkExitFundingPaid")
+ .padding(.top, 24)
+ case let .seen(txid):
+ SparkExitWaitingPayment(text: t("lightning__spark_exit__fund_pending"), txid: txid, testId: "SparkExitFundingSeen")
+ .padding(.top, 24)
+ case nil:
+ EmptyView()
+ }
+ }
+
+ if record.isActive, record.isRebuiltFromChain == true {
+ BodySText(t("lightning__spark_exit__rebuilt_from_chain"))
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitRebuiltFromChain")
+ }
+
+ if record.isWaitingForOperators {
+ BodySText(t(
+ "lightning__spark_exit__waiting_operators",
+ variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.failedLeavesSats))]
+ ), accentColor: .white, accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitWaitingOperators")
+ }
+
+ if record.isActive, record.isWaitingForLeaves {
+ BodySText(t("lightning__spark_exit__waiting_leaves"))
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitWaitingLeaves")
+ }
+
+ if let rejection = record.surfacedRejection {
+ BodySText(t("lightning__spark_exit__not_moving", variables: ["error": rejection]), textColor: .redAccent)
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitNotMoving")
+ }
+
+ if record.showsFeeLow(tip: exit.tipHeight) {
+ BodySText(t("lightning__spark_exit__fee_low", variables: ["rate": "\(record.feeRateSatPerVbyte)"]))
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitFeeLow")
+ } else if exit.canPayHigherFee {
+ Button {
+ navigation.navigate(.sparkExitFeeBump)
+ } label: {
+ BodySText(t("lightning__spark_exit__fee_rose"), textColor: .yellowAccent)
+ .fixedSize(horizontal: false, vertical: true)
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitFeeRose")
+ }
+
+ if let outsideSats = SparkExitMath.outsideExitSats(sparkBalanceSats: SparkManager.shared.balanceSats, record: record) {
+ BodySText(t(
+ "lightning__spark_exit__outside_exit",
+ variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: outsideSats))]
+ ), accentColor: .white, accentFont: Fonts.bold)
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitOutsideExit")
+ }
+
+ if let lostText {
+ BodySText(lostText, textColor: .redAccent, accentColor: .redAccent, accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitLostLeaves")
+ }
+
+ if record.leftBehindSats > 0 {
+ BodySText(t(
+ "lightning__spark_exit__left_behind_leaves",
+ variables: ["amount": sparkExitAmountAccent(currency.primaryAmountText(sats: record.leftBehindSats))]
+ ), accentColor: .white, accentFont: Fonts.bold)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 16)
+ .accessibilityIdentifier("SparkExitLeftBehind")
+ }
+
+ if record.isActive {
+ SparkExitBackgroundNotes()
+ .padding(.top, 16)
+ }
+
+ if !record.isActive, !record.leftover.isEmpty || !(record.sweptLeftover ?? []).isEmpty,
+ let address = record.funding.first?.address
+ {
+ SparkExitLeftoverStatus(record: record, address: address)
+ .padding(.top, 16)
+ }
+ }
+ }
+ .padding(.bottom, 32)
+ }
+ .refreshable {
+ await SparkManager.shared.refreshState()
+ await exit.tick()
+ }
+ // Over the bottom inset: content cut at the edge fades out above the buttons, and scrolled to the end the
+ // last line clears it.
+ .overlay(alignment: .bottom) {
+ LinearGradient(colors: [.customBlack.opacity(0), .customBlack], startPoint: .top, endPoint: .bottom)
+ .frame(height: 32)
+ .allowsHitTesting(false)
+ }
+
+ if record?.isActive == true, notifications.authorizationStatus == .denied {
+ CustomButton(title: t("lightning__spark_exit__note_notifications_button"), variant: .secondary) {
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ await UIApplication.shared.open(url)
+ }
+ }
+ .padding(.bottom, 16)
+ .accessibilityIdentifier("SparkExitNotificationSettings")
+ }
+
+ if exit.isSavingsPaymentRefused {
+ CustomButton(title: t("lightning__spark_exit__fund_savings"), variant: .secondary) {
+ do {
+ try await exit.retrySavingsPayment()
+ } catch {
+ app.toast(error)
+ }
+ }
+ .padding(.bottom, 16)
+ .accessibilityIdentifier("SparkExitRetrySavings")
+ }
+
+ if exit.canPayHigherFee {
+ CustomButton(title: t("lightning__spark_exit__fee_bump_button"), variant: .secondary) {
+ navigation.navigate(.sparkExitFeeBump)
+ }
+ .padding(.bottom, 16)
+ .accessibilityIdentifier("SparkExitFeeBump")
+ }
+
+ if exit.canCancel {
+ CustomButton(title: t("lightning__spark_exit__cancel"), variant: .secondary) {
+ isConfirmingCancel = true
+ }
+ .padding(.bottom, 16)
+ .accessibilityIdentifier("SparkExitCancel")
+ }
+
+ CustomButton(title: t("common__ok")) {
+ navigation.reset()
+ }
+ .accessibilityIdentifier("SparkExitProgressOk")
+ }
+ .navigationBarHidden(true)
+ .padding(.horizontal, 16)
+ .bottomSafeAreaPadding()
+ .alert(t("lightning__spark_exit__cancel_title"), isPresented: $isConfirmingCancel) {
+ Button(t("lightning__spark_exit__cancel"), role: .destructive) {
+ Task {
+ // A funding address that cannot be read keeps the exit: a payment there is never orphaned.
+ switch await exit.cancel() {
+ case .keep: app.toast(type: .error, title: t("lightning__spark_exit__cancel_failed"), description: nil)
+ // Funding that reached the address is swept back on this screen, as the exit's leftover.
+ case .sweepFunding: break
+ case .forget: navigation.reset()
+ case .notCancellable: break
+ }
+ }
+ }
+ .accessibilityIdentifier("SparkExitCancelConfirm")
+
+ Button(t("common__dialog_cancel"), role: .cancel) {}
+ } message: {
+ Text(t("lightning__spark_exit__cancel_text"))
+ }
+ .task {
+ await SparkManager.shared.refreshState()
+ await exit.tick()
+ }
+ .task(id: record?.hasUnseenResult) {
+ exit.markResultSeen()
+ }
+ }
+}
+
+/// 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 {
+ SparkExitMath.canPayFeeBump(
+ totalSats: option.totalSats,
+ spendableSats: UInt64(max(0, wallet.spendableOnchainBalanceSats)),
+ readsBitkitChain: SparkManager.shared.canFundFromChain
+ )
+ }
+
+ /// Savings can't pay the selected rate, or, with none selected, any rate priced so far. Savings on a chain Spark
+ /// does not read is off without a balance line, as on the start's funding screen.
+ private var isInsufficient: Bool {
+ guard SparkManager.shared.canFundFromChain else { return false }
+ if let selected {
+ return selected.totalSats != nil && !canPay(selected)
+ }
+ let priced = exit.feeBumpOptions.filter { $0.totalSats != nil }
+ return !priced.isEmpty && !priced.contains(where: canPay)
+ }
+
+ 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: .brandAccent)
+ .padding(.top, 32)
+
+ BodyMText(
+ t("lightning__spark_exit__fee_bump_text", variables: ["rate": "\(exit.record?.feeRateSatPerVbyte ?? 0)"]),
+ accentFont: Fonts.bold
+ )
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, SparkExitLayout.headlineGap)
+ .padding(.bottom, 24)
+
+ if exit.isPricingFeeBump {
+ ProgressView()
+ .tint(.white32)
+ .frame(maxWidth: .infinity)
+ } else if exit.isFeeBumpLeavingLeaves {
+ BodyMText(t("lightning__spark_exit__fee_bump_leaves_behind"))
+ .fixedSize(horizontal: false, vertical: true)
+ .accessibilityIdentifier("SparkExitFeeBumpLeavesBehind")
+ } else if let error = exit.feeBumpError {
+ BodyMText(error)
+ .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, 32)
+ }
+
+ if isInsufficient {
+ CaptionText(t("lightning__spark_exit__fund_insufficient"))
+ .padding(.bottom, 16)
+ .accessibilityIdentifier("SparkExitFeeBumpInsufficient")
+ }
+
+ if exit.feeBumpError != nil {
+ CustomButton(title: t("common__try_again")) {
+ await exit.loadFeeBumpOptions()
+ await priceSavingsPayments()
+ selectedRate = exit.feeBumpOptions.first(where: canPay)?.rate
+ }
+ .accessibilityIdentifier("SparkExitFeeBumpRetry")
+ } else if !exit.isFeeBumpLeavingLeaves {
+ CustomButton(
+ title: t(selected?.extraSats == 0 ? "lightning__spark_exit__fee_bump_confirm" : "lightning__spark_exit__fund_savings"),
+ isLoading: isPaying || exit.isPricingFeeBump
+ ) {
+ await pay()
+ }
+ .disabled(selected.map { !canPay($0) } ?? true)
+ .accessibilityIdentifier("SparkExitFeeBumpPay")
+ }
+ }
+ .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 let OnchainSendAttemptError.preDispatch(error) where error is SparkFundingChanged {
+ // Savings never pays more than the fee confirmed: the rates are priced again for another confirm.
+ app.toast(type: .info, title: t("lightning__spending_confirm__fees_changed_title"))
+ exit.noteFeeBumpRefused(rate: selected.rate, preparedFeeSats: (error as? SparkFundingChanged)?.preparedFeeSats)
+ await exit.loadFeeBumpOptions()
+ await priceSavingsPayments()
+ selectedRate = exit.feeBumpOptions.first(where: canPay)?.rate
+ } 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 {
+ @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)
+ }
+ }
+ .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)
+ }
+
+ /// 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: isLanded
+ )
+ step(
+ t("lightning__spark_exit__step_timelock"),
+ detail: remainingDuration.map { t("lightning__spark_exit__ready_in", variables: ["duration": $0]) },
+ isDone: isLanded || SparkExitMath.areTimelocksMatured(transactions)
+ )
+ step(t("lightning__spark_exit__step_sweep"), isDone: isLanded)
+ }
+ }
+
+ 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-simple")
+ .resizable()
+ .foregroundColor(isDone ? .greenAccent : .textSecondary)
+ .frame(width: 20, height: 20)
+ CaptionBText(title, textColor: .textPrimary)
+ Spacer()
+ if let detail {
+ CaptionBText(detail)
+ }
+ }
+ .frame(height: 50)
+ CustomDivider()
+ }
+ }
+}
+
+enum SparkExitLayout {
+ /// The gap under an exit headline: master's 8, plus 1 so it renders like Android's, whose Display line height
+ /// adds 9 dp under a headline. Measured on 2x media devices.
+ static let headlineGap: CGFloat = 9
+}
diff --git a/Bitkit/Views/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 a8530c185..435572447 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/Activity/ActivityExplorerView.swift b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift
index 52466387d..635813029 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityExplorerView.swift
@@ -118,38 +118,6 @@ struct ActivityExplorerView: View {
}
}
- private struct InfoSection: View {
- let title: String
- let content: String
- let testId: String?
- @EnvironmentObject var app: AppViewModel
-
- init(title: String, content: String, testId: String? = nil) {
- self.title = title
- self.content = content
- self.testId = testId
- }
-
- var body: some View {
- Button {
- UIPasteboard.general.string = content
- app.toast(type: .success, title: t("common__copied"), description: content)
- } label: {
- VStack(alignment: .leading, spacing: 0) {
- CaptionMText(title)
- .padding(.bottom, 8)
- BodySSBText(content)
- .padding(.bottom, 16)
-
- Divider()
- .padding(.bottom, 16)
- }
- }
- .buttonStyle(.plain)
- .accessibilityIdentifierIfPresent(testId)
- }
- }
-
private var navTitle: String {
switch item {
case let .lightning(activity) where activity.status == .pending:
@@ -181,7 +149,7 @@ struct ActivityExplorerView: View {
.padding(.bottom, 32)
if let onchain {
- InfoSection(
+ ActivityInfoSection(
title: t("wallet__activity_tx_id"),
content: onchain.txId,
testId: "TXID"
@@ -227,7 +195,7 @@ struct ActivityExplorerView: View {
.padding(.bottom, 16)
ForEach(Array(onchain.boostTxIds.enumerated()), id: \.offset) { index, boostTxId in
let isRBF = onchain.txType == .sent || !(boostTxDoesExist[boostTxId] ?? true)
- InfoSection(
+ ActivityInfoSection(
title: t(isRBF ? "wallet__activity_boosted_rbf" : "wallet__activity_boosted_cpfp", variables: ["num": String(index + 1)]),
content: boostTxId,
testId: isRBF ? "RBFBoosted" : "CPFPBoosted"
@@ -236,20 +204,20 @@ struct ActivityExplorerView: View {
}
} else if let lightning {
if let preimage = lightning.preimage {
- InfoSection(
+ ActivityInfoSection(
title: t("wallet__activity_preimage"),
content: preimage
)
}
if let paymentHash {
- InfoSection(
+ ActivityInfoSection(
title: t("wallet__activity_payment_hash"),
content: paymentHash
)
}
- InfoSection(
+ ActivityInfoSection(
title: t("wallet__activity_invoice"),
content: lightning.invoice
)
@@ -346,3 +314,48 @@ struct ActivityExplorer_Previews: PreviewProvider {
.preferredColorScheme(.dark)
}
}
+
+/// A detail of an activity, its title above its value, that copies the value on tap.
+struct ActivityInfoSection: View {
+ let title: String
+ let content: String
+ let testId: String?
+ /// The step lists' divider instead of the system one, for screens whose rows use it.
+ let usesCustomDivider: Bool
+ /// Space under the divider; a screen whose next row brings its own top spacing passes 0.
+ let bottomSpacing: CGFloat
+ @EnvironmentObject var app: AppViewModel
+
+ init(title: String, content: String, testId: String? = nil, usesCustomDivider: Bool = false, bottomSpacing: CGFloat = 16) {
+ self.title = title
+ self.content = content
+ self.testId = testId
+ self.usesCustomDivider = usesCustomDivider
+ self.bottomSpacing = bottomSpacing
+ }
+
+ var body: some View {
+ Button {
+ UIPasteboard.general.string = content
+ app.toast(type: .success, title: t("common__copied"), description: content)
+ } label: {
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(title)
+ .padding(.bottom, 8)
+ BodySSBText(content)
+ .padding(.bottom, 16)
+
+ Group {
+ if usesCustomDivider {
+ CustomDivider()
+ } else {
+ Divider()
+ }
+ }
+ .padding(.bottom, bottomSpacing)
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifierIfPresent(testId)
+ }
+}
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index 3dde24f4b..45ea98437 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -335,7 +335,7 @@ struct SendSheet: View {
private var sparkCanPayScannedInvoice: Bool {
let spark = SparkManager.shared
let amountSats = wallet.sendAmountSats ?? app.scannedLightningInvoice?.amountSatoshis ?? 0
- return Self.sparkCanPay(amountSats: amountSats, isReady: spark.isReady, balanceSats: spark.balanceSats)
+ return !spark.isExiting && Self.sparkCanPay(amountSats: amountSats, isReady: spark.isReady, balanceSats: spark.balanceSats)
}
/// Leaves room for Spark's fee, as `LightningService.canSend` does.
diff --git a/Bitkit/Views/Wallets/SpendingWalletScreen.swift b/Bitkit/Views/Wallets/SpendingWalletScreen.swift
index dbaf93389..ccc54f710 100644
--- a/Bitkit/Views/Wallets/SpendingWalletScreen.swift
+++ b/Bitkit/Views/Wallets/SpendingWalletScreen.swift
@@ -41,7 +41,7 @@ struct SpendingWalletScreen: View {
}
if !shouldShowOnboarding {
- if wallet.totalLightningSats > 0, wallet.channels?.isEmpty == false || SparkManager.shared.balanceSats > 0 {
+ if wallet.totalLightningSats > 0, wallet.channels?.isEmpty == false || SparkManager.shared.shownSpendingSats > 0 {
transferButton
.transition(.move(edge: .leading).combined(with: .opacity))
.padding(.top, 28)
diff --git a/BitkitTests/ChannelFreeRailTests.swift b/BitkitTests/ChannelFreeRailTests.swift
index d8731960b..74edbb889 100644
--- a/BitkitTests/ChannelFreeRailTests.swift
+++ b/BitkitTests/ChannelFreeRailTests.swift
@@ -926,6 +926,25 @@ extension ChannelFreeRailTests {
XCTAssertNil(rail.sentBolt11)
}
+ /// An emergency exit takes Spark off Lightning: an invoice Spark was paying when it started is still not paid again
+ /// over a channel.
+ func testAnInvoiceSparkWasPayingIsNotSentAgainThroughTheNodeDuringAnExit() async {
+ let hash = try? String(Bolt11Invoice.fromStr(invoiceStr: Self.invoice).paymentHash())
+ rail.payments = [Bitkit.ChannelFreeRailPayment(paymentHash: hash ?? "", direction: .outbound, status: .pending, preimage: nil)]
+ let previousHistory = Bitkit.LightningService.shared.channelFreePaymentHistory
+ Bitkit.LightningService.shared.channelFreePaymentHistory = rail
+ Bitkit.LightningService.shared.channelFreeRail = nil
+ defer { Bitkit.LightningService.shared.channelFreePaymentHistory = previousHistory }
+
+ do {
+ _ = try await Bitkit.LightningService.shared.send(bolt11: Self.invoice)
+ XCTFail("Expected the pending Spark payment to refuse the retry")
+ } catch {
+ XCTAssertTrue(PrivatePaykitService.isDuplicatePaymentError(error), "\(error)")
+ }
+ XCTAssertNil(rail.sentBolt11)
+ }
+
func testUserSendOverTheRailHasAFeeLimit() async throws {
// WalletViewModel.send and sendWithTimeout pass no route parameters, so LDK applies its default
// limit (1% + 50 sats); the rail must not be left without one.
diff --git a/BitkitTests/OnchainSendAttemptServiceTests.swift b/BitkitTests/OnchainSendAttemptServiceTests.swift
index c3cc178f4..1bd24052a 100644
--- a/BitkitTests/OnchainSendAttemptServiceTests.swift
+++ b/BitkitTests/OnchainSendAttemptServiceTests.swift
@@ -380,6 +380,30 @@ final class OnchainSendAttemptServiceTests: XCTestCase {
}
}
+ /// The emergency exit's fee funding from savings never pays more mining fee than the user confirmed: a changed
+ /// input set asks again instead.
+ func testSparkExitFundingThatGrewAfterApprovalIsRefusedBeforeBroadcast() async throws {
+ for (fee, valid): (UInt64?, Bool) in [(500, true), (400, true), (501, false), (nil, false)] {
+ let store = MemoryAttemptStore()
+ let service = OnchainSendAttemptService(store: store)
+ let sender = PreparedAttemptNodeMock()
+ sender.amount = 20000
+ sender.miningFee = fee
+ sender.result = .accepted(txid: sender.txid)
+ do {
+ _ = try await SparkExitManager.sendFunding(
+ using: sender, attempts: service, address: "bcrt1qfunding", sats: 20000, satsPerVbyte: 2, feeSats: 500
+ )
+ XCTAssertTrue(valid, "A funding for a \(String(describing: fee)) fee needs a new approval")
+ XCTAssertEqual(sender.broadcasts, 1)
+ } catch let OnchainSendAttemptError.preDispatch(error) {
+ XCTAssertFalse(valid, "The approved funding must still go out")
+ XCTAssertEqual((error as? SparkFundingChanged)?.preparedFeeSats, fee, "Refused with \(error)")
+ XCTAssertEqual(sender.broadcasts, 0)
+ }
+ }
+ }
+
func testPreparedReceiptIsDurableBeforeNativeDispatch() async throws {
for max in [false, true] {
let store = MemoryAttemptStore()
diff --git a/BitkitTests/PaykitRouteTests.swift b/BitkitTests/PaykitRouteTests.swift
index 39d557b91..be77164c1 100644
--- a/BitkitTests/PaykitRouteTests.swift
+++ b/BitkitTests/PaykitRouteTests.swift
@@ -520,6 +520,25 @@ final class PaykitRouteTests: XCTestCase {
XCTAssertEqual(steps, ["settle later"], "An outcome that may have sent it settles from the sync after it")
}
+ @MainActor
+ func testAContactWithdrawalIsRefusedOnceAnExitHoldsSpark() async {
+ let spends = SparkSpends { true }
+ var steps: [String] = []
+ do {
+ _ = try await SparkManager.sendJournaledWithdrawal(
+ spends: spends,
+ send: { steps.append("sent") },
+ closeUnsent: { steps.append("closed") },
+ settleLater: { steps.append("settle later") }
+ )
+ XCTFail("A contact withdrawal went out while an exit held Spark")
+ } catch {
+ XCTAssertTrue(error is SparkWithdrawalNotSent, "Nothing left, so the allowance attempt fails instead of staying unknown")
+ }
+ XCTAssertEqual(steps, ["closed"], "The exit's leaves stay unspent and the journal closes at once")
+ XCTAssertEqual(spends.inFlight, 0)
+ }
+
@MainActor
func testASecondContactWithdrawalIsRefusedWhileTheFirstPrepares() throws {
snapshotAppDefaultsDomain()
diff --git a/BitkitTests/SparkConfigTests.swift b/BitkitTests/SparkConfigTests.swift
index b6504ecda..d153d0ac6 100644
--- a/BitkitTests/SparkConfigTests.swift
+++ b/BitkitTests/SparkConfigTests.swift
@@ -5,18 +5,20 @@ 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)")
XCTAssertFalse(config.useDefaultExternalInputParsers, "Parsing input must not look it up with a third party")
XCTAssertEqual(config.apiKey, "test-key")
+ XCTAssertTrue(config.leafOptimizationConfig.autoEnabled)
}
+ XCTAssertFalse(SparkManager.sdkConfig(network: .regtest, apiKey: nil, isExiting: true).leafOptimizationConfig.autoEnabled)
}
func testInvoicesCarryNoSparkAddressOnAnyNetworkSoEveryPaymentIsALightningPaymentBitkitRecords() {
for network in [BreezSdkSpark.Network.mainnet, .regtest] {
- XCTAssertFalse(SparkManager.sdkConfig(network: network, apiKey: nil).preferSparkOverLightning, "\(network)")
+ XCTAssertFalse(SparkManager.sdkConfig(network: network, apiKey: nil, isExiting: false).preferSparkOverLightning, "\(network)")
}
}
diff --git a/BitkitTests/SparkExitBackupTests.swift b/BitkitTests/SparkExitBackupTests.swift
index 13de88791..cf102eb52 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 testAFailedExitRestoreBlocksReplacingTheRemoteBackupUntilItRecovers() async throws {
let suite = "SparkExitBackupTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
@@ -59,6 +82,42 @@ final class SparkExitBackupTests: XCTestCase {
XCTAssertEqual(uploads, ["SPARK_EXIT"])
}
+ /// Both rules at once: a pending restore during an exit uploads nothing; once the restore completes and the exit
+ /// ends, the upload goes through.
+ func testAPendingRestoreDuringAnExitUploadsNothingUntilBothClear() async throws {
+ let suite = "SparkExitBackupTests.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let export = try JSONEncoder().encode(SparkExitBackupV1(version: 1, createdAt: 1000, network: "regtest", exitState: "fresh"))
+ var remoteIsReadable = false
+ var isExiting = true
+ var uploads: [String] = []
+ let service = BackupService(
+ defaults: defaults,
+ backupData: { _ in export },
+ isSparkExitActive: { isExiting },
+ uploadBackup: { key, _ in uploads.append(key) },
+ waitForTemporaryFailureDelay: { _ in },
+ fetchBackup: { _ in
+ guard remoteIsReadable else { throw URLError(.notConnectedToInternet) }
+ return nil
+ }
+ )
+
+ await service.restoreSparkExit()
+ _ = await service.triggerBackup(category: .sparkExit)
+ XCTAssertTrue(uploads.isEmpty, "the restore is pending and an exit runs")
+
+ // The restore completes, but the exit still runs: still nothing.
+ remoteIsReadable = true
+ _ = await service.triggerBackup(category: .sparkExit)
+ XCTAssertTrue(uploads.isEmpty, "an exit still runs")
+
+ isExiting = false
+ _ = await service.triggerBackup(category: .sparkExit)
+ XCTAssertEqual(uploads, ["SPARK_EXIT"])
+ }
+
func testAnExitStateSparkHasNotImportedYetBlocksReplacingTheRemoteBackup() async throws {
let suite = "SparkExitBackupTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
diff --git a/BitkitTests/SparkExitFeeBumpTests.swift b/BitkitTests/SparkExitFeeBumpTests.swift
new file mode 100644
index 000000000..f571c2d4f
--- /dev/null
+++ b/BitkitTests/SparkExitFeeBumpTests.swift
@@ -0,0 +1,493 @@
+@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")
+ }
+
+ /// A rate at which the prepare succeeds but skips an exiting leaf for costing more than it holds is never offered.
+ func testFeeBumpRefusesARateAtWhichThePrepareSkipsAnExitingLeafForItsFee() async throws {
+ var record = runningRecord()
+ record.leafIds = ["leaf-1", "leaf-2"]
+ var prepared = SparkExitFixtures.prepared(funding: nil)
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 2000, reason: .feeExceedsValue)]
+ sdk.preparedResponse = prepared
+
+ do {
+ _ = try await driver().feeBumpShortfall(record, rate: 10)
+ XCTFail("leaf-2 would stay on Spark at this rate")
+ } catch let error as SparkExitFeeBumpError {
+ XCTAssertEqual(error, .leavesBehind(sats: 2000))
+ }
+ }
+
+ /// A failed-leaf retry with steps of its own still to confirm keeps their reminders: it is not waiting only on the
+ /// operators.
+ func testAFailedLeafRetryWithStepsToConfirmIsNotWaitingOnlyOnTheOperators() {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)], failed: [failure]))
+ record.failedLeafRetrySince = 1000
+ XCTAssertFalse(record.isWaitingForOperators, "c1 still has to confirm")
+
+ record.recovery = StoredRecovery(SparkExitFixtures.recovery(
+ [SparkExitFixtures.tx("c1", kind: .cooperative, status: .confirmed(blockHeight: 1001))],
+ failed: [failure]
+ ))
+ XCTAssertTrue(record.isWaitingForOperators)
+ }
+
+ /// Short at the higher rate: what the inputs lack, plus the input fee of the payment to come at the new rate
+ /// (68 vB at 10 sat/vB).
+ func testFeeBumpShortfallIsWhatTheInputsLackPlusTheNextInput() async throws {
+ 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), ["n1", "n1b"], "the live generation's ready step keeps going until the rebuild")
+ XCTAssertNil(record.stallRebuilds)
+ 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))
+ XCTAssertFalse(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).contains { $0.isFunding })
+
+ // Adopted, the payment no longer waits; a shortfall left after it reminds as before.
+ record.funding.append(.init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "bump", vout: 0, valueSats: 5000))
+ XCTAssertFalse(record.isAwaitingOwnPayment)
+ XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).filter(\.isFunding).count, 1)
+ }
+
+ /// 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)
+ XCTAssertEqual(record.landedSweepTxid, "s", "Done still names the sweep the retry replaced")
+ 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)
+ }
+
+ /// A retry of failed leaves that builds one and skips another while it may still move keeps the skipped one,
+ /// and tries it again once the built one landed.
+ func testAFailedLeafRetryKeepsALeafItSkipped() async throws {
+ let other = CooperativeRecoveryFailure(
+ leafId: "coop-2",
+ outputTxid: "watchtower",
+ outputVout: 1,
+ error: .operatorsUnavailable(message: "down")
+ )
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["coop-1", "coop-2"]
+ record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("s", kind: .sweep)], failed: [failure, other]))
+ let done = SparkExitFixtures.recovery(
+ [SparkExitFixtures.tx("s", kind: .sweep, status: .confirmed(blockHeight: 990))],
+ failed: [failure, other]
+ )
+ sdk.checks = [CheckRecoverFundsResponse(recovery: done, verdict: .done)]
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: [])
+ prepared.leaves = [RecoverFundsLeaf(leafId: "coop-1", valueSats: 5000, method: .cooperative)]
+ prepared.skipped = [SkippedLeaf(leafId: "coop-2", valueSats: 4000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)])]
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.waitingLeafIds, ["coop-2"])
+
+ let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative, status: .confirmed(blockHeight: 1001))])
+ sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)]
+ prepared.leaves = []
+ sdk.preparedResponse = prepared
+ chain.tip = 1001
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.phase, .running, "coop-2 may still move")
+ XCTAssertEqual(sdk.prepareRequests.last?.selection, .specific(leafIds: ["coop-2"]))
+ }
+
+ /// A failed-leaf retry that built a recovery of its own can be sped up like any live recovery; its timing stays.
+ /// Coin selection needed more than the estimate: the next confirm approves the fee the refused one asked.
+ func testTheNextConfirmAfterARefusedHigherFeeApprovesTheFeeItWasRefusedFor() {
+ XCTAssertEqual(SparkExitManager.feeBumpPaymentFee(priced: 420, refusedFeeSats: 600), 600, "the same refused fee was confirmed again")
+ XCTAssertEqual(SparkExitManager.feeBumpPaymentFee(priced: 700, refusedFeeSats: 600), 700)
+ XCTAssertEqual(SparkExitManager.feeBumpPaymentFee(priced: 420, refusedFeeSats: nil), 420)
+ }
+
+ func testAFailedLeafRetryThatBuiltCanPayAHigherFee() async throws {
+ var record = doneWithFailedLeaf()
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("c1", kind: .cooperative)])]
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 2)
+ XCTAssertNotNil(record.failedLeafRetrySince)
+
+ XCTAssertTrue(SparkExitManager.canPayHigherFee(record, tip: 1000, isNetworkAboveExitRate: true))
+ var waiting = doneWithFailedLeaf()
+ waiting.failedLeafRetrySince = 1000
+ waiting.recovery = StoredRecovery(SparkExitFixtures.recovery([], failed: [failure]))
+ XCTAssertFalse(SparkExitManager.canPayHigherFee(waiting, tip: 1000, isNetworkAboveExitRate: true), "nothing of its own is left to speed up")
+ }
+
+ /// After about a week (1,008 blocks) the exit lets the leaf go and ends, saying what is left.
+ func testAFailedLeafIsReleasedAfterAWeek() async throws {
+ var record = doneWithFailedLeaf()
+ 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
+ record.landedSweepTxid = "s"
+ // Whole milliseconds, as the record stores dates.
+ record.lastBuildAt = clock
+
+ 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"))
+ }
+}
+
+/// 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)
+ }
+}
diff --git a/BitkitTests/SparkExitFundingIndexTests.swift b/BitkitTests/SparkExitFundingIndexTests.swift
new file mode 100644
index 000000000..a77c690cb
--- /dev/null
+++ b/BitkitTests/SparkExitFundingIndexTests.swift
@@ -0,0 +1,276 @@
+@testable import Bitkit
+import XCTest
+
+/// The next exit funding index across a restore: carried by the metadata backup, else found by a scan of the funding
+/// addresses.
+@MainActor
+final class SparkExitFundingIndexTests: XCTestCase {
+ private let suiteName = "SparkExitFundingIndexTests"
+ private var defaults: UserDefaults!
+ private let chain = FakeSparkExitChain()
+ private let key = SparkExitFundingIndexes.defaultsKey(network: "regtest", walletIndex: 0)
+ private let pendingKey = SparkExitFundingIndexes.recoveryPendingKey(network: "regtest", walletIndex: 0)
+ private var earlierAddressStore: SparkExitEarlierAddressStore!
+ private let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ override func setUp() {
+ super.setUp()
+ UserDefaults().removePersistentDomain(forName: suiteName)
+ defaults = UserDefaults(suiteName: suiteName)
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("SparkExitFundingIndexTests-\(UUID().uuidString).json")
+ earlierAddressStore = SparkExitEarlierAddressStore(url: url)
+ }
+
+ override func tearDown() {
+ UserDefaults().removePersistentDomain(forName: suiteName)
+ try? earlierAddressStore.delete()
+ super.tearDown()
+ }
+
+ private func address(_ index: UInt32) -> String {
+ "bcrt1qfunding\(index)"
+ }
+
+ private func path(_ index: UInt32) -> String {
+ "m/84'/1'/7797'/0/\(index)"
+ }
+
+ private var recovery: SparkExitManager.FundingRecovery {
+ SparkExitManager.FundingRecovery(
+ defaults: defaults,
+ key: key,
+ pendingKey: pendingKey,
+ record: nil,
+ chain: chain,
+ earlierAddressStore: earlierAddressStore,
+ funding: { [unowned self] in (address($0), path($0)) }
+ )
+ }
+
+ private func nextFundingIndex() async throws -> UInt32 {
+ try await SparkExitManager.nextFundingIndex(recovery, now: now)
+ }
+
+ private func keptCoins(_ index: UInt32, sats: UInt64) -> SparkExitEarlierAddress {
+ SparkExitEarlierAddress(address: address(index), path: path(index), kind: .keptChange, sats: sats, date: now)
+ }
+
+ /// What a new device holds after a restore: the app's data is gone, and only the backed-up cache comes back.
+ private func restore(_ cache: AppCacheData) {
+ UserDefaults().removePersistentDomain(forName: suiteName)
+ defaults = UserDefaults(suiteName: suiteName)
+ SparkExitFundingIndexes.restore(cache.sparkExitNextFundingIndexes, into: defaults)
+ }
+
+ private func roundTrip(_ cache: AppCacheData) throws -> AppCacheData {
+ try JSONDecoder().decode(AppCacheData.self, from: JSONEncoder().encode(cache))
+ }
+
+ private func cache(sparkExitNextFundingIndexes: [String: Int]?) -> AppCacheData {
+ AppCacheData(
+ hasSeenContactsIntro: false,
+ hasSeenProfileIntro: false,
+ hasSeenNotificationsIntro: false,
+ hasSeenQuickpayIntro: false,
+ hasSeenShopIntro: false,
+ hasSeenTransferToSavingsIntro: false,
+ hasSeenWidgetsIntro: false,
+ hasDismissedWidgetsOnboardingHint: false,
+ appUpdateIgnoreTimestamp: 0,
+ backupIgnoreTimestamp: 0,
+ highBalanceIgnoreCount: 0,
+ highBalanceIgnoreTimestamp: 0,
+ dismissedSuggestions: [],
+ lastUsedTags: [],
+ sparkExitNextFundingIndexes: sparkExitNextFundingIndexes
+ )
+ }
+
+ /// Funding indexes 0 to 2 were used before the restore; index 2 still holds what a lost exit received.
+ func testARestoreFromABackupWithoutTheIndexScansPastTheFundingItsLastExitUsed() async throws {
+ defaults.set(3, forKey: key)
+ let oldBackup = try JSONDecoder().decode(AppCacheData.self, from: Data("{}".utf8))
+ restore(oldBackup)
+ chain.usedAddresses = [address(0), address(1)]
+ chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)]
+
+ let next = try await nextFundingIndex()
+
+ XCTAssertEqual(next, 3)
+ XCTAssertTrue(chain.addressReads.contains(address(2)), "the funding at index 2 goes unseen")
+ XCTAssertEqual(chain.addressReads, (0 ... 2 + SparkExitMath.fundingScanGapLimit).map(address))
+ XCTAssertEqual(defaults.object(forKey: key) as? Int, 3, "the next quote scans again")
+ }
+
+ func testAFundingIndexRestoredFromTheBackupIsUsedWithoutAScan() async throws {
+ defaults.set(3, forKey: key)
+ let backup = try roundTrip(cache(sparkExitNextFundingIndexes: SparkExitFundingIndexes.backup(defaults)))
+ restore(backup)
+ chain.usedAddresses = [address(0), address(1), address(2)]
+
+ let next = try await nextFundingIndex()
+
+ XCTAssertEqual(backup.sparkExitNextFundingIndexes, ["regtest/wallet0": 3], "keyed as bitkit-android keys it")
+ XCTAssertEqual(next, 3)
+ XCTAssertTrue(chain.addressReads.isEmpty, "a backed-up index needs no scan")
+ }
+
+ func testARestoreKeepsTheHigherIndexOfTheDeviceAndTheBackup() {
+ defaults.set(5, forKey: key)
+
+ SparkExitFundingIndexes.restore(["regtest/wallet0": 3, "regtest/wallet1": 2], into: defaults)
+
+ XCTAssertEqual(defaults.integer(forKey: key), 5, "an exit would fund from an address an earlier exit used")
+ XCTAssertEqual(defaults.integer(forKey: SparkExitFundingIndexes.defaultsKey(network: "regtest", walletIndex: 1)), 2)
+ }
+
+ func testAMetadataBackupWrittenBeforeTheIndexDecodesWithNone() throws {
+ let json = #"{"version":1,"createdAt":1,"tagMetadata":[],"cache":{"hasSeenContactsIntro":true}}"#
+
+ let backup = try JSONDecoder().decode(MetadataBackupV1.self, from: Data(json.utf8))
+
+ XCTAssertTrue(backup.cache.hasSeenContactsIntro)
+ XCTAssertNil(backup.cache.sparkExitNextFundingIndexes)
+ }
+
+ func testAFundingScanTheChainFailsKeepsNoIndex() async {
+ chain.isAddressReadUnreadable = true
+
+ do {
+ _ = try await nextFundingIndex()
+ XCTFail("an exit could fund from an address an earlier exit used")
+ } catch {}
+
+ XCTAssertNil(defaults.object(forKey: key))
+ }
+
+ func testAFundingScanReadsTheGapLimitPastTheLastUsedAddress() async {
+ var reads: [UInt32] = []
+
+ let next = await SparkExitMath.scanNextFundingIndex { index in
+ reads.append(index)
+ return [0, 2, 5].contains(index)
+ }
+
+ XCTAssertEqual(next, 6)
+ XCTAssertEqual(reads, Array(0 ... 5 + SparkExitMath.fundingScanGapLimit))
+ }
+
+ func testAFundingScanStopsAtItsCeilingWhenEveryAddressReadsAsUsed() async {
+ var reads: UInt32 = 0
+
+ let next = await SparkExitMath.scanNextFundingIndex { _ in
+ reads += 1
+ return true
+ }
+
+ XCTAssertEqual(next, SparkExitMath.fundingScanMaxAddresses)
+ XCTAssertEqual(reads, SparkExitMath.fundingScanMaxAddresses)
+ }
+
+ func testAnAddressWithATransactionInABlockOrTheMempoolReadsAsUsed() async throws {
+ let http = SparkExitBroadcasterTests.FakeHTTP()
+ let stats = { (chain: Int, mempool: Int) in
+ #"{"chain_stats":{"tx_count":\#(chain)},"mempool_stats":{"tx_count":\#(mempool)}}"#
+ }
+ http.responses = [
+ ("a.example/api/address/bcrt1qmempool", 200, stats(0, 1)),
+ ("a.example/api/address/bcrt1qspent", 200, stats(2, 0)),
+ ("a.example/api/address/bcrt1qfresh", 200, stats(0, 0)),
+ ]
+ let esplora = try EsploraSparkExitChain(hosts: [XCTUnwrap(URL(string: "https://a.example/api"))], http: http)
+
+ let isMempoolUsed = try await esplora.isAddressUsed(address: "bcrt1qmempool")
+ let isSpentUsed = try await esplora.isAddressUsed(address: "bcrt1qspent")
+ let isFreshUsed = try await esplora.isAddressUsed(address: "bcrt1qfresh")
+
+ XCTAssertTrue(isMempoolUsed)
+ XCTAssertTrue(isSpentUsed)
+ XCTAssertFalse(isFreshUsed)
+ }
+
+ // MARK: Earlier exit addresses after a restore
+
+ func testARestoredWalletWithNoCounterWatchesTheFundingAddressesThatStillHoldCoins() async throws {
+ // Index 2 still holds what a lost exit received; 0 and 1 were spent by earlier exits.
+ chain.usedAddresses = [address(0), address(1)]
+ chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)]
+
+ let next = try await SparkExitManager.recoverFunding(recovery, now: now)
+
+ XCTAssertEqual(next, 3)
+ XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(2, sats: 5000)], "the lost funding goes unwatched")
+ XCTAssertEqual(defaults.object(forKey: key) as? Int, 3)
+ }
+
+ func testAFundingAddressWithHistoryButNoCoinsIsNotWatched() async throws {
+ chain.usedAddresses = [address(0)]
+ chain.utxosByAddress[address(1)] = [SparkExitFixtures.utxo("lost", value: 700)]
+
+ try await SparkExitManager.recoverFunding(recovery, now: now)
+
+ XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(1, sats: 700)])
+ }
+
+ func testARestoredCounterBringsBackTheFundingAddressesThatStillHoldCoins() async throws {
+ SparkExitFundingIndexes.restore(["regtest/wallet0": 3], into: defaults)
+ chain.usedAddresses = [address(0), address(1)]
+ chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)]
+
+ try await SparkExitManager.recoverFunding(recovery, now: now)
+
+ XCTAssertEqual(try earlierAddressStore.load(), [keptCoins(2, sats: 5000)], "the lost funding goes unwatched")
+ XCTAssertTrue(chain.addressReads.isEmpty, "a restored counter needs no scan for the next index")
+ XCTAssertNil(defaults.object(forKey: pendingKey), "every start reads the funding addresses again")
+ XCTAssertEqual(defaults.integer(forKey: key), 3)
+ }
+
+ func testAKeptCounterWithNoRestorePendingReadsNoFundingAddress() async throws {
+ defaults.set(3, forKey: key)
+ chain.utxosByAddress[address(2)] = [SparkExitFixtures.utxo("lost", value: 5000)]
+
+ try await SparkExitManager.recoverFunding(recovery, now: now)
+
+ XCTAssertTrue(chain.addressReads.isEmpty)
+ XCTAssertEqual(try earlierAddressStore.load(), [])
+ }
+
+ func testTheMetadataBackupCarriesTheEarlierExitAddresses() throws {
+ try earlierAddressStore.add(keptCoins(2, sats: 600))
+ try earlierAddressStore.add(SparkExitEarlierAddress(address: address(0), path: path(0), kind: .cancelledFunding, sats: nil, date: now))
+
+ let backup = SparkExitEarlierAddressBackup.backup(earlierAddressStore, walletKey: "regtest/wallet0")
+
+ let millis: Int64 = 1_800_000_000_000
+ XCTAssertEqual(backup, ["regtest/wallet0": [
+ SparkExitEarlierAddressBackupV1(address: address(2), path: path(2), kind: "keptChange", atMillis: millis),
+ SparkExitEarlierAddressBackupV1(address: address(0), path: path(0), kind: "cancelledFunding", atMillis: millis),
+ ]])
+ }
+
+ func testARestoreListsTheBackedUpEarlierAddressesAndKeepsTheDevices() throws {
+ let onDevice = keptCoins(1, sats: 600)
+ try earlierAddressStore.add(onDevice)
+ let backedUp = [
+ SparkExitEarlierAddressBackupV1(address: address(1), path: path(1), kind: "cancelledFunding", atMillis: 1000),
+ SparkExitEarlierAddressBackupV1(address: address(2), path: path(2), kind: "keptChange", atMillis: 2000),
+ SparkExitEarlierAddressBackupV1(address: address(3), path: path(3), kind: "someLaterKind", atMillis: 3000),
+ ]
+
+ SparkExitEarlierAddressBackup.restore(["regtest/wallet0": backedUp, "bitcoin/wallet0": backedUp], network: "regtest") { _ in
+ self.earlierAddressStore
+ }
+
+ let restored = SparkExitEarlierAddress(address: address(2), path: path(2), kind: .keptChange, sats: nil, date: Date(timeIntervalSince1970: 2))
+ XCTAssertEqual(try earlierAddressStore.load(), [onDevice, restored])
+ }
+
+ func testAMetadataBackupWithAnUnreadableEarlierAddressListKeepsTheRest() throws {
+ let json = #"{"version":1,"createdAt":1,"tagMetadata":[],"cache":{"hasSeenContactsIntro":true},"sparkExitEarlierAddresses":{"regtest/wallet0":[{"address":1}]}}"#
+
+ let backup = try JSONDecoder().decode(MetadataBackupV1.self, from: Data(json.utf8))
+
+ XCTAssertTrue(backup.cache.hasSeenContactsIntro, "one bad entry fails the whole metadata restore")
+ XCTAssertNil(backup.sparkExitEarlierAddresses)
+ }
+}
diff --git a/BitkitTests/SparkExitRegtestTests.swift b/BitkitTests/SparkExitRegtestTests.swift
new file mode 100644
index 000000000..16135a8b6
--- /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.network(.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.network(.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/SparkExitReviewTests.swift b/BitkitTests/SparkExitReviewTests.swift
new file mode 100644
index 000000000..b05084581
--- /dev/null
+++ b/BitkitTests/SparkExitReviewTests.swift
@@ -0,0 +1,1265 @@
+@testable import Bitkit
+import BitkitCore
+import BreezSdkSpark
+import XCTest
+
+/// Findings of the independent review of the exit: cancel, heights without a tip, funding waits, reminders, sweep
+/// bounds and salvage.
+final class SparkExitReviewTests: XCTestCase {
+ private var chain: FakeSparkExitChain!
+ private var sdk: FakeSparkExitRecovering!
+ private let clock = Date(timeIntervalSince1970: 1_800_000_000)
+
+ override func setUp() {
+ super.setUp()
+ chain = FakeSparkExitChain()
+ sdk = FakeSparkExitRecovering()
+ }
+
+ private func driver(withSdk: Bool = true) -> SparkExitDriver {
+ SparkExitDriver(chain: chain, recovering: withSdk ? sdk : nil, makeSigner: { _ in StubCpfpSigner() }, save: { _ in })
+ }
+
+ // MARK: Review 518bf31e5
+
+ /// Funding sent from another wallet before the user picked savings is not paid twice.
+ func testSavingsPaysOnlyWhatTheFundingAddressDoesNotHoldYet() {
+ let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000))
+ XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 25000, seenCount: 1, feeRate: 2), 0)
+ }
+
+ /// Savings completing a partial payment from another wallet is one more funding input: it pays that input's fee
+ /// too, so both payments together are adopted. A 20,000 quote at 2 sat/vB with 8,000 received needs 20,136.
+ func testSavingsCompletingPartialFundingPaysTheExtraInputsFee() {
+ let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000))
+ let sats = SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 8000, seenCount: 1, feeRate: 2)
+
+ XCTAssertEqual(sats, 12136, "savings left the exit short of the second input's fee")
+ let paid = [
+ SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 8000),
+ SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: sats),
+ ]
+ XCTAssertNotNil(SparkExitMath.fundingTopUp(paid, requiredSats: 20000, inputFeeSats: SparkExitMath.inputVbytes * 2))
+ XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 0, seenCount: 0, feeRate: 2), 20000)
+ }
+
+ /// Savings never pays under the 294-sat dust limit, which the onchain wallet refuses: a 20,000 quote at 2 sat/vB
+ /// with 19,900 received misses 236 sats and pays 294.
+ func testSavingsCompletingAlmostFullFundingPaysAtLeastTheDustLimit() {
+ let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000))
+ let sats = SparkExitManager.savingsFundingSats(confirmed: quote, fresh: quote, seenSats: 19900, seenCount: 1, feeRate: 2)
+ XCTAssertEqual(sats, SparkExitMath.fundingDustSats, "savings paid a dust amount")
+ }
+
+ /// A tapped notification for an exit whose result was seen already is let go once the record loaded, so a later
+ /// exit's result never jumps to Progress.
+ func testATappedNotificationIsLetGoOnceTheLoadedExitShowsNoResult() {
+ XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: false, hasLoadedRecord: false), .wait)
+ XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: true, hasLoadedRecord: true), .open)
+ XCTAssertEqual(SparkExitManager.notificationOpen(opensProgress: true, showsResult: false, hasLoadedRecord: true), .drop)
+ }
+
+ /// A cancel of an exit that can no longer be cancelled says so, not that the address could not be read.
+ @MainActor func testACancelOfAnExitNoLongerCancellableIsNotAFailedRead() async {
+ let exit = SparkExitManager(walletIndex: 9999)
+ let outcome = await exit.cancel()
+ XCTAssertEqual(outcome, .notCancellable)
+ }
+
+ /// A forgotten exit gives its funding address back, so a payment that lands there late is seen by the next exit.
+ func testAForgottenExitGivesItsFundingAddressBack() {
+ XCTAssertEqual(SparkExitManager.fundingCounterAfterForget(4, index: 3), 3)
+ XCTAssertEqual(SparkExitManager.fundingCounterAfterForget(6, index: 3), 6, "a later address is in use")
+ }
+
+ /// A wipe deletes the copies of an unreadable record kept aside, which hold the wiped wallet's addresses.
+ func testAWipeDeletesTheRecordsCopiesKeptAside() throws {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json"))
+ try store.save(SparkExitFixtures.record())
+ try store.keepCopyAside()
+
+ try store.deleteWithCopies()
+
+ XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: dir.path), [])
+ }
+
+ // MARK: H1 cancel
+
+ func testCancelIsOffAfterAPaymentFromSavings() {
+ var record = SparkExitFixtures.record()
+ XCTAssertTrue(SparkExitManager.canCancel(record, fundingSeenSats: 0))
+ XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 1000), "a payment was seen")
+ record.fundingPaymentTxid = "paid"
+ XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0), "paid from savings, not seen yet")
+ }
+
+ /// Cancel reads the address first: funding that arrived since the last tick goes back to savings, and an address
+ /// that cannot be read keeps the exit.
+ func testCancelNeverOrphansFundingAtTheAddress() {
+ XCTAssertEqual(SparkExitManager.cancelOutcome(utxos: []), .forget)
+ XCTAssertEqual(SparkExitManager.cancelOutcome(utxos: nil), .keep)
+ XCTAssertEqual(
+ SparkExitManager.cancelOutcome(utxos: [SparkExitFixtures.utxo("late", value: 5000, confirmed: false)]),
+ .sweepFunding([.init(txid: "late", vout: 0, valueSats: 5000)])
+ )
+ }
+
+ /// A cancelled exit with funding sweeps it like any ended exit's leftover, and posts no "stopped" notification.
+ func testACancelledExitSweepsItsFundingAndPostsNoFailure() {
+ var record = SparkExitFixtures.record()
+ record.phase = .failed(reason: SparkExitRecord.cancelledReason)
+ record.isCancelled = true
+ record.isResultSeen = true
+ record.leftover = [.init(txid: "late", vout: 0, valueSats: 5000)]
+
+ XCTAssertTrue(record.isLeftoverSweepPending)
+ XCTAssertTrue(record.showsResult)
+ XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).isEmpty)
+ }
+
+ // MARK: H2 no height without a tip
+
+ /// A tip read that fails while the sweep would wait writes no height, so the next tick does not read a 0 as four
+ /// weeks gone and keep the change.
+ func testASweepWithoutATipWritesNoHeightAndKeepsNothing() async throws {
+ var record = SparkExitFixtures.record(phase: .done)
+ record.feeRateSatPerVbyte = 20
+ record.leftover = [.init(txid: "c1", vout: 0, valueSats: 300)]
+ chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("c1", value: 300)]
+ chain.isTipUnreadable = true
+
+ record = try await driver().tick(record)
+ XCTAssertNil(record.leftoverSweep, "nothing changes without the tip")
+
+ chain.isTipUnreadable = false
+ chain.economyFee = 20
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.leftoverSweep?.waitingSince, 1000)
+
+ chain.isTipUnreadable = true
+ chain.tip = 900_000
+ record = try await driver().tick(record)
+ XCTAssertNotEqual(record.leftoverSweep?.isDustKept, true)
+ XCTAssertTrue(record.isLeftoverSweepPending)
+ }
+
+ // MARK: H3 driving while a top-up is awaited
+
+ private func bumpedRecord() -> SparkExitRecord {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)
+ record.adopt(SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990)),
+ SparkExitFixtures.tx("n2", cpfp: "03c2", dependsOn: ["n1"]),
+ ]), at: .distantPast)
+ record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump")
+ return record
+ }
+
+ func testTheLiveGenerationKeepsGoingWhileAHigherFeesTopUpIsAwaited() async throws {
+ var record = bumpedRecord()
+ chain.mempoolTxids = ["bump"]
+ sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid)]
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.phase, .needsFunding(requiredSats: 25680))
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "the ready step still goes out")
+ XCTAssertTrue(sdk.recoverRequests.isEmpty, "no rebuild before the top-up confirms")
+ let reminders = SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest)
+ XCTAssertFalse(reminders.contains { $0.isFunding }, "the payment is on its way")
+ XCTAssertTrue(reminders.contains { $0.id.hasPrefix("1-") }, "the live steps keep their reminders")
+ }
+
+ /// A Redo while a higher fee's top-up is awaited: the diverged generation is never sent again, and when the funding
+ /// held pays for less, its rebuild waits for the top-up as a Redo rebuild.
+ func testARedoWhileAHigherFeesTopUpIsAwaitedStopsDrivingTheDivergedGeneration() async throws {
+ var record = bumpedRecord()
+ chain.mempoolTxids = ["bump"]
+ sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))]
+ sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 25680, docsUrl: "")
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.phase, .needsFunding(requiredSats: 46360))
+ XCTAssertEqual(record.fundingPaymentTxid, "bump", "the awaited top-up was dropped")
+ XCTAssertTrue(record.isRebuilding)
+ XCTAssertEqual(record.isRedoRebuild, true)
+ XCTAssertFalse(record.isDrivingWhileFunding, "the diverged generation would be sent again")
+ }
+
+ /// A payment no host knows for three blocks is forgotten: the ask and the funding reminder come back.
+ func testADroppedSavingsPaymentBringsTheAskBack() async throws {
+ var record = bumpedRecord()
+ sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 3)
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.fundingPaymentMissingSince, 1000)
+ XCTAssertEqual(record.fundingPaymentTxid, "bump", "one read is not enough")
+
+ chain.tip = 1003
+ record = try await driver().tick(record)
+ XCTAssertNil(record.fundingPaymentTxid)
+ XCTAssertFalse(record.isAwaitingOwnPayment)
+ XCTAssertTrue(SparkExitMath.reminders(record, tip: 1003, now: clock, network: .regtest).contains { $0.isFunding })
+ }
+
+ /// A payment one host misses just before a block and just after it, seconds apart, may still be indexing: it is
+ /// kept. A read that finds it starts the count over; only a miss that lasts three blocks forgets it.
+ func testASavingsPaymentMissedForABlockOrASingleReadIsKept() async throws {
+ var record = bumpedRecord()
+ sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 6)
+
+ record = try await driver().tick(record)
+ chain.tip = 1001
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.fundingPaymentTxid, "bump", "a one-block miss asked for the funding again")
+
+ chain.mempoolTxids = ["bump"]
+ chain.tip = 1002
+ record = try await driver().tick(record)
+ chain.mempoolTxids = []
+ chain.tip = 1003
+ record = try await driver().tick(record)
+ chain.tip = 1005
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.fundingPaymentTxid, "bump")
+ chain.tip = 1006
+ record = try await driver().tick(record)
+ XCTAssertNil(record.fundingPaymentTxid)
+ }
+
+ /// One still unadopted after a day is forgotten too, known or not.
+ func testASavingsPaymentNotAdoptedInADayIsForgotten() async throws {
+ var record = bumpedRecord()
+ chain.mempoolTxids = ["bump"]
+ sdk.checks = try Array(repeating: CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .valid), count: 2)
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.fundingPaymentSeenAtHeight, 1000)
+
+ chain.tip = 1000 + SparkExitDriver.ownPaymentTimeoutBlocks
+ record = try await driver().tick(record)
+ XCTAssertNil(record.fundingPaymentTxid)
+ }
+
+ /// A savings payment that confirms short of a higher re-quoted funding is no longer waited for: the rest is asked
+ /// for, and reminded about at once.
+ func testASavingsPaymentConfirmedShortRemindsForTheRestAtOnce() async throws {
+ var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 35000))
+ record.fundingPaymentTxid = "paid"
+ chain.mempoolTxids = ["paid"]
+ chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("paid", value: 20000, confirmed: false)]
+
+ record = try await driver().tick(record)
+ XCTAssertTrue(record.isAwaitingOwnPayment, "still confirming")
+ XCTAssertTrue(SparkExitMath.reminders(record, tip: 1000, now: record.createdAt, network: .regtest).isEmpty)
+
+ chain.confirmedTxids = ["paid"]
+ chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("paid", value: 20000)]
+ record = try await driver().tick(record)
+
+ XCTAssertFalse(record.isAwaitingOwnPayment)
+ let reminders = SparkExitMath.reminders(record, tip: 1000, now: record.createdAt, network: .regtest)
+ XCTAssertEqual(reminders.map(\.isFunding), [true])
+ XCTAssertEqual(reminders.first?.fireAt, record.createdAt, "at once, not 4 hours after the start")
+ }
+
+ /// A short rebuild after a Redo stays parked: its generation diverged and is never sent again.
+ func testADivergedRebuildsTopUpSendsNothing() async throws {
+ var record = bumpedRecord()
+ record.isRebuilding = true
+ chain.mempoolTxids = ["bump"]
+
+ record = try await driver().tick(record)
+
+ XCTAssertTrue(chain.broadcasts.isEmpty)
+ XCTAssertTrue(sdk.checkedRecoveries.isEmpty)
+ }
+
+ // MARK: M2 snapshot before pricing and the failed-leaf retry
+
+ func testFeeBumpPricingImportsTheExitDataSnapshotFirst() async throws {
+ var record = bumpedRecord()
+ record.phase = .running
+ record.exitStateSnapshot = "snapshot-1"
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2b")])]
+
+ _ = try await driver().feeBumpShortfall(record, rate: 12)
+
+ XCTAssertEqual(sdk.imports, ["snapshot-1"])
+ }
+
+ // MARK: M4 a started exit never funded
+
+ func testAnExitNeverFundedReminds() {
+ let record = SparkExitFixtures.record()
+ let reminders = SparkExitMath.reminders(record, tip: 1000, now: record.createdAt, network: .regtest)
+ XCTAssertEqual(reminders.map(\.id), ["await-funding-20000"])
+ XCTAssertEqual(reminders.first?.isFunding, true)
+ XCTAssertEqual(reminders.first?.fireAt, record.createdAt.addingTimeInterval(SparkExitMath.reminderSeconds(network: .regtest)))
+
+ var paid = record
+ paid.fundingPaymentTxid = "paid"
+ XCTAssertTrue(SparkExitMath.reminders(paid, tip: 1000, now: record.createdAt, network: .regtest).isEmpty)
+ }
+
+ // MARK: M3 fired reminders
+
+ func testAFiredReminderStaysRememberedWhenItLeavesThePlanForARun() {
+ let now = clock
+ let remembered = ["spark-exit-1-slow-n1": now.addingTimeInterval(-60).timeIntervalSince1970]
+ let kept = SparkExitManager.keepingFired([:], remembered: remembered, now: now)
+ XCTAssertEqual(kept, remembered)
+
+ let old = ["spark-exit-1-slow-n1": now.addingTimeInterval(-SparkExitManager.firedReminderMemory - 1).timeIntervalSince1970]
+ XCTAssertTrue(SparkExitManager.keepingFired([:], remembered: old, now: now).isEmpty, "forgotten after 30 days")
+ let ahead = ["spark-exit-x": now.addingTimeInterval(600).timeIntervalSince1970]
+ XCTAssertTrue(SparkExitManager.keepingFired([:], remembered: ahead, now: now).isEmpty, "one not fired yet follows the plan")
+ }
+
+ // MARK: M6 sweep bounds
+
+ /// One unconfirmed output holds the sweep for one recheck at most; then the confirmed ones go.
+ func testAnUnconfirmedOutputHoldsTheSweepBackForOneRecheckAtMost() async throws {
+ var record = SparkExitFixtures.record(phase: .done)
+ record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000)]
+ chain.economyFee = 1
+ chain.utxosByAddress["bcrt1qfunding"] = [
+ SparkExitFixtures.utxo("c1", value: 5000),
+ SparkExitFixtures.utxo("stuck", value: 400, confirmed: false),
+ ]
+ var signed = 0
+ let driver = SparkExitDriver(chain: chain, recovering: nil, makeSigner: { _ in
+ signed += 1
+ return StubCpfpSigner()
+ }, save: { _ in })
+
+ record = try await driver.tick(record)
+ XCTAssertEqual(record.leftoverSweep?.waitingSince, 1000)
+ XCTAssertEqual(signed, 0)
+
+ chain.tip = 1000 + SparkExitDriver.sweepRecheckBlocks
+ _ = try? await driver.tick(record)
+ XCTAssertEqual(signed, 1, "the confirmed output is swept")
+ }
+
+ /// A built sweep a node answers with missing inputs is built again from the address from the next block.
+ func testABuiltSweepWithMissingInputsIsBuiltAgain() async throws {
+ var record = SparkExitFixtures.record(phase: .done)
+ record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000)]
+ record.leftoverSweep = .init(txid: "s", txHex: "02", valueSats: 4700, feeRateSatPerVbyte: 2, builtAtHeight: 1000)
+ chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent")
+
+ record = try await driver(withSdk: false).tick(record)
+
+ XCTAssertNil(record.leftoverSweep?.txid)
+ XCTAssertEqual(record.leftoverSweep?.retryAtHeight, 1001)
+ XCTAssertTrue(record.isLeftoverSweepPending)
+ }
+
+ /// A replacement refused for another reason while the sweep it replaced is still held is not final.
+ func testARefusedReplacementKeepsFollowingTheSweepItReplaced() async throws {
+ var record = SparkExitFixtures.record(phase: .done)
+ record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000)]
+ record.leftoverSweep = .init(txid: "s2", txHex: "02", valueSats: 4700, feeRateSatPerVbyte: 3, replacedTxids: ["s1"], builtAtHeight: 1000)
+ chain.mempoolTxids = ["s1"]
+ chain.outcome = .rejected("non-mandatory-script-verify-flag")
+
+ record = try await driver(withSdk: false).tick(record)
+
+ XCTAssertNil(record.leftoverSweep?.rejection)
+ XCTAssertEqual(record.leftoverSweep?.replacedTxids, ["s1"])
+ XCTAssertTrue(record.isLeftoverSweepPending)
+ }
+
+ // MARK: M7 salvage
+
+ func testASalvagedRecordKeepsTheChainedTxidsAndReplacesTheOriginalInOneWrite() throws {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json"))
+ let lost = SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json"))
+ var record = SparkExitFixtures.record(phase: .running)
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)
+ record.earlierCpfpTxids = ["child-1"]
+ var json = try XCTUnwrap(JSONSerialization.jsonObject(with: SparkExitRecordCoder.encode(record)) as? [String: Any])
+ json["version"] = 99
+ try JSONSerialization.data(withJSONObject: json).write(to: store.url)
+
+ let loaded = try XCTUnwrap(SparkExitManager.loadRecord(store: store, lostLeafStore: lost))
+
+ XCTAssertEqual(loaded.earlierCpfpTxids, ["child-1"])
+ XCTAssertEqual(try store.load()?.isRebuiltFromChain, true, "the salvaged record is in place")
+ let files = try FileManager.default.contentsOfDirectory(atPath: dir.path)
+ XCTAssertTrue(files.contains { $0.contains("unreadable") }, "the original is kept aside")
+ }
+
+ // MARK: Bot review threads
+
+ /// The background connect's timeout returns on time, without waiting for the connect it gives up on.
+ func testTheConnectTimeoutReturnsWithoutWaitingForTheConnect() async {
+ let started = Date()
+ let result: Bool? = await SparkManager.firstOf(timeout: 0.02) {
+ try? await Task.sleep(nanoseconds: 600_000_000)
+ return true
+ }
+ XCTAssertNil(result)
+ XCTAssertLessThan(Date().timeIntervalSince(started), 0.3)
+ let quick: Bool? = await SparkManager.firstOf(timeout: 5) { true }
+ XCTAssertEqual(quick, true)
+ }
+
+ /// A tick that built or published meanwhile leaves nothing cancel can act on: the reloaded record is revalidated.
+ func testCancelRevalidatesARecordATickBuiltMeanwhile() {
+ var record = SparkExitFixtures.record()
+ XCTAssertTrue(SparkExitManager.canCancel(record, fundingSeenSats: 0))
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)
+ record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")]))
+ XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0))
+ }
+
+ /// A sweep that confirms while it left outputs out (too small, or unconfirmed when built) keeps following them.
+ func testAConfirmedPartialSweepKeepsFollowingWhatItLeftOut() async throws {
+ var record = SparkExitFixtures.record(phase: .done)
+ record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000), .init(txid: "c2", vout: 0, valueSats: 300)]
+ record.leftoverSweep = .init(txid: "s1", txHex: "02", valueSats: 4700, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000)
+ chain.confirmedTxids = ["s1"]
+ chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("c2", value: 300)]
+
+ record = try await driver(withSdk: false).tick(record)
+
+ XCTAssertEqual(record.sweptLeftover, [.init(txid: "s1", valueSats: 4700)])
+ XCTAssertEqual(record.leftover, [.init(txid: "c2", vout: 0, valueSats: 300)])
+ XCTAssertEqual(record.leftoverSweep?.isWaitingForFees, true)
+ XCTAssertTrue(record.isLeftoverSweepPending)
+ XCTAssertEqual(record.leftoverLine, .waiting(sats: 300))
+ }
+
+ /// A partial sweep that confirms while the funding address cannot be read stays driven, and follows what it left
+ /// out once the address reads again.
+ func testAConfirmedPartialSweepStaysDrivenUntilTheAddressReads() async throws {
+ var record = SparkExitFixtures.record(phase: .done)
+ record.leftover = [.init(txid: "c1", vout: 0, valueSats: 5000), .init(txid: "c2", vout: 0, valueSats: 300)]
+ record.leftoverSweep = .init(txid: "s1", txHex: "02", valueSats: 4700, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000)
+ chain.confirmedTxids = ["s1"]
+ chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("c2", value: 300)]
+
+ chain.isTipUnreadable = true
+ record = try await driver(withSdk: false).tick(record)
+ XCTAssertTrue(record.needsDriving, "the tip could not be read")
+
+ chain.isTipUnreadable = false
+ chain.isUtxosUnreadable = true
+ record = try await driver(withSdk: false).tick(record)
+ XCTAssertTrue(record.needsDriving, "the funding address could not be read")
+ XCTAssertNil(record.sweptLeftover)
+ record = try JSONDecoder().decode(SparkExitRecord.self, from: JSONEncoder().encode(record))
+ XCTAssertTrue(record.needsDriving, "after a restart")
+
+ chain.isUtxosUnreadable = false
+ record = try await driver(withSdk: false).tick(record)
+ XCTAssertEqual(record.sweptLeftover, [.init(txid: "s1", valueSats: 4700)])
+ XCTAssertEqual(record.leftover, [.init(txid: "c2", vout: 0, valueSats: 300)])
+ XCTAssertEqual(record.leftoverSweep?.isWaitingForFees, true)
+ XCTAssertTrue(record.needsDriving)
+ }
+
+ /// A first build holding only cooperative leaves the operators could not co-sign enters the bounded retry.
+ func testAFirstBuildOfOnlyFailedCooperativeLeavesWaitsForTheOperators() async throws {
+ let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "w", outputVout: 0, error: .operatorsUnavailable(message: "down"))
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: [])
+ prepared.leaves = [RecoverFundsLeaf(leafId: "coop-1", valueSats: 5000, method: .cooperative)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([], failed: [failure]), SparkExitFixtures.recovery([], failed: [failure])]
+ var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 0))
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertEqual(record.recovery?.response.failed.count, 1)
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.failedLeafRetrySince, 1000)
+ XCTAssertTrue(record.isWaitingForOperators)
+ XCTAssertTrue(sdk.checkedRecoveries.isEmpty, "nothing to check on-chain")
+
+ chain.tip = 1000 + SparkExitDriver.releaseBlocks
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .done)
+ XCTAssertEqual(record.isFailedLeavesReleased, true)
+ }
+
+ /// A rate at which a leaf can no longer pay its own exit would leave it on Spark: it is not offered (Android review).
+ func testFeeBumpPricingRefusesARateThatLeavesALeafBehind() async throws {
+ var record = bumpedRecord()
+ record = SparkExitRecord(
+ network: record.network, createdAt: record.createdAt, phase: .running, feeRateSatPerVbyte: 2,
+ destination: record.destination, leafIds: ["leaf-1", "small"], funding: record.funding,
+ transferId: record.transferId, expectedSats: record.expectedSats
+ )
+ sdk.prepareErrors = [SdkError.SparkError("Leaf small cannot be exited: its refund cannot pay its own fee")]
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2b")])]
+
+ do {
+ _ = try await driver().feeBumpShortfall(record, rate: 12)
+ XCTFail("a rate that leaves a leaf behind is not priced")
+ } catch let SparkExitFeeBumpError.leavesBehind(sats) {
+ XCTAssertEqual(sats, 0, "the small leaf's value is unknown before the first build")
+ }
+ XCTAssertTrue(sdk.recoverRequests.isEmpty)
+
+ // A build that refuses a leaf the prepare kept is not offered either.
+ sdk = FakeSparkExitRecovering()
+ sdk.recoverError = SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee")
+ do {
+ _ = try await driver().feeBumpShortfall(bumpedRecord(), rate: 12)
+ XCTFail("expected leavesBehind")
+ } catch let SparkExitFeeBumpError.leavesBehind(sats) {
+ XCTAssertEqual(sats, 100_000)
+ }
+ }
+
+ /// The keyless pass finding every step in a block ends the exit without waiting for the SDK (Android review).
+ func testAKeylessPassThatSeesEveryStepConfirmedEndsTheExit() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)
+ record.adopt(SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990)),
+ SparkExitFixtures.tx("s", kind: .sweep, dependsOn: ["n1"]),
+ ]))
+ chain.confirmedTxids = ["n1", "s"]
+ chain.utxosByAddress["bcrt1qfunding"] = []
+
+ record = try await driver(withSdk: false).tick(record)
+
+ XCTAssertEqual(record.phase, .done)
+ XCTAssertTrue(chain.broadcasts.isEmpty)
+ }
+
+ /// A wipe closes the exit's writes: a tick still running can't write the deleted record back (Android review).
+ func testAClosedWriteGateDropsTheSaveOfATickStillRunning() throws {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let store = SparkExitStore(url: dir.appendingPathComponent("spark-exit.json"))
+ let gate = SparkExitWriteGate()
+ let save = SparkExitManager.gatedSave(
+ store: store,
+ lostLeafStore: SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")),
+ gate: gate
+ )
+
+ try save(SparkExitFixtures.record())
+ XCTAssertNotNil(try store.load())
+ try store.delete()
+
+ gate.close()
+ XCTAssertThrowsError(try save(SparkExitFixtures.record()))
+ XCTAssertNil(try store.load())
+ }
+
+ /// Reminder ids are scoped per exit, so a later exit's `failed` is never taken for an earlier exit's.
+ func testReminderIdsAreScopedPerExit() {
+ let reminder = SparkExitReminder(id: "failed", fireAt: clock, isFailed: true)
+ let first = SparkExitFixtures.record()
+ let second = SparkExitRecord(
+ network: "regtest", createdAt: first.createdAt.addingTimeInterval(86400), phase: .done, feeRateSatPerVbyte: 2,
+ destination: "bcrt1qdestination", leafIds: ["leaf-2"], funding: first.funding, transferId: nil, expectedSats: 0
+ )
+ XCTAssertNotEqual(SparkExitManager.notificationId(reminder, record: first), SparkExitManager.notificationId(reminder, record: second))
+ XCTAssertTrue(SparkExitManager.notificationId(reminder, record: first).hasPrefix("spark-exit-"))
+ }
+
+ /// Salvage keeps the funding strategy and what earlier generations paid to savings.
+ func testSalvageKeepsTheFundingStrategyAndEarlierReceived() throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)
+ record.fundingStrategy = SparkExitRecord.perNodeStrategy
+ record.earlierReceivedSats = ["sweep-1": 40000]
+ record.outsideSats = 14167
+ let salvaged = try XCTUnwrap(SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(record)))
+ XCTAssertEqual(salvaged.fundingStrategy, SparkExitRecord.perNodeStrategy)
+ XCTAssertEqual(salvaged.earlierReceivedSats, ["sweep-1": 40000])
+ XCTAssertEqual(salvaged.outsideSats, 14167)
+ }
+
+ /// A salvaged exit imports the exit data it kept before it is prepared again: the SDK may no longer hold a leaf
+ /// being exited.
+ func testASalvagedExitImportsItsSnapshotBeforeItsFirstRebuild() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 25000)
+ record.exitStateSnapshot = "snapshot-1"
+ let salvaged = try XCTUnwrap(SparkExitRecordCoder.salvage(SparkExitRecordCoder.encode(record)))
+ chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f0", vout: 1, value: 25000)]
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])]
+ var importsAtPrepare: [[String]] = []
+ sdk.preparedByRequest = { [sdk] _ in
+ importsAtPrepare.append(sdk?.imports ?? [])
+ return SparkExitFixtures.prepared()
+ }
+
+ _ = try await driver().tick(salvaged)
+
+ XCTAssertFalse(importsAtPrepare.isEmpty, "the salvaged exit is prepared again")
+ XCTAssertEqual(importsAtPrepare.first, ["snapshot-1"], "its kept exit data is imported before it is prepared")
+ XCTAssertEqual(sdk.exports, 0)
+ }
+
+ /// Background runs wrap the chain to time it: the wrapper forwards the economy tier, so their sweeps pay it.
+ func testTheTimedBackgroundChainForwardsTheNetworkFees() async throws {
+ chain.fastestFee = 9
+ chain.economyFee = 1
+ let timed = TimedSparkExitChain(base: chain, timings: SparkExitTimings())
+
+ let fees = try await timed.networkFees()
+
+ XCTAssertEqual(fees.economy, 1)
+ XCTAssertEqual(SparkExitMath.sweepFeeRate(economy: fees.economy, exitRate: 5), 1)
+ }
+
+ // MARK: Unknown funding broadcast (review thread r4498875767)
+
+ /// A savings broadcast with an unknown outcome is kept as paid (pending): it may still have gone out.
+ func testAnUnknownFundingBroadcastIsKeptAsPaid() {
+ XCTAssertEqual(SparkExitManager.fundingTxid(.unknown(txid: "unk")), "unk")
+ XCTAssertEqual(SparkExitManager.fundingTxid(.accepted(txid: "ok")), "ok")
+ XCTAssertNil(SparkExitManager.fundingTxid(.rejected(txid: "no", reason: "insufficient funds")))
+ }
+
+ /// With that payment pending, or any unresolved savings send to the exit's address, cancel is off: the record stays.
+ func testAPendingFundingSendKeepsTheExit() {
+ var record = SparkExitFixtures.record()
+ XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0, unresolvedSendAddress: "bcrt1qfunding"))
+ XCTAssertTrue(SparkExitManager.canCancel(record, fundingSeenSats: 0, unresolvedSendAddress: "bcrt1qelsewhere"))
+ record.fundingPaymentTxid = "unk"
+ XCTAssertFalse(SparkExitManager.canCancel(record, fundingSeenSats: 0))
+ XCTAssertTrue(record.isAwaitingOwnPayment)
+ }
+
+ /// The unknown payment confirming late is adopted like any funding, and the exit builds.
+ func testALateConfirmationOfAnUnknownFundingSendIsAdopted() async throws {
+ var record = SparkExitFixtures.record()
+ record.fundingPaymentTxid = "unk"
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])]
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .paid(txid: "unk"))
+ XCTAssertNil(record.recovery)
+
+ chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("unk", value: 20000)]
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertEqual(record.funding.first?.txid, "unk")
+ XCTAssertNotNil(record.recovery)
+ }
+
+ // MARK: Wipe (review thread 4511769434)
+
+ /// A quote taken before a wipe names the wiped wallet's address: the next wallet's exit must never pay to it.
+ @MainActor
+ func testAWipeForgetsTheQuotesDestinationAndFunding() async {
+ let exit = SparkExitManager(walletIndex: 9999)
+ exit.useQuote(
+ SparkExitQuote(prepared: SparkExitFixtures.prepared()),
+ destination: "bcrt1qwalleta",
+ feeRate: 4,
+ fundingIndex: 3,
+ fundingAddress: "bcrt1qfundinga"
+ )
+ exit.noteFundingSeen([SparkExitFixtures.utxo("seen", value: 5000)])
+ exit.fundFromSavings = true
+ exit.savingsFeeRate = 7
+ exit.savingsFeeSats = 300
+
+ await exit.wipe()
+
+ XCTAssertNil(exit.quote)
+ XCTAssertNil(exit.pendingDestination)
+ XCTAssertNil(exit.quoteFeeRate)
+ XCTAssertNil(exit.pendingFundingIndex)
+ XCTAssertNil(exit.fundingAddress)
+ XCTAssertFalse(exit.fundFromSavings)
+ XCTAssertEqual(exit.savingsFeeRate, 0)
+ XCTAssertEqual(exit.savingsFeeSats, 0)
+ XCTAssertEqual(exit.fundingSeenSats, 0)
+ XCTAssertEqual(exit.fundingSeenCount, 0)
+ }
+
+ // MARK: Wallet totals (pwltr, android#1782)
+
+ /// Funding seen at the address changes what is in transfer to savings: the wallet recomputes its totals.
+ @MainActor func testAFundingSeenOnlyChangeRefreshesTheWalletBalance() {
+ var refreshes = 0
+ let watch = SparkExitBalanceWatch { refreshes += 1 }
+ var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 20000))
+ record.transferId = "transfer-1"
+ watch.update(record: record, fundingSeenSats: 0, sparkBalanceSats: 0)
+ let before = refreshes
+
+ watch.update(record: record, fundingSeenSats: 5000, sparkBalanceSats: 0)
+
+ XCTAssertEqual(refreshes, before + 1, "the wallet kept the in-transfer amount from before the funding was seen")
+ }
+
+ /// A record write that leaves the totals' amounts alone does not recompute them.
+ @MainActor func testAnExitWriteThatLeavesTheTotalsAloneDoesNotRefreshTheWalletBalance() {
+ var refreshes = 0
+ let watch = SparkExitBalanceWatch { refreshes += 1 }
+ var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 20000))
+ record.transferId = "transfer-1"
+ watch.update(record: record, fundingSeenSats: 0, sparkBalanceSats: 0)
+ let before = refreshes
+
+ record.isResultSeen = true
+ record.lastBuildAt = Date(timeIntervalSince1970: 1_800_000_000)
+ watch.update(record: record, fundingSeenSats: 0, sparkBalanceSats: 0)
+
+ XCTAssertEqual(refreshes, before)
+ }
+
+ // MARK: Round 3 (Corey 5859257501)
+
+ /// A quote for a new funding address starts with nothing seen there: an earlier exit's funding never lowers what
+ /// savings pays.
+ @MainActor func testAQuoteForANewFundingAddressStartsWithNothingSeen() {
+ let exit = SparkExitManager(walletIndex: 9999)
+ let quote = SparkExitQuote(prepared: SparkExitFixtures.prepared())
+ exit.useQuote(quote, destination: "bcrt1qdestination", feeRate: 2, fundingIndex: 3, fundingAddress: "bcrt1qearlier")
+ exit.noteFundingSeen([SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 8000)])
+ exit.useQuote(quote, destination: "bcrt1qdestination", feeRate: 2, fundingIndex: 3, fundingAddress: "bcrt1qearlier")
+ XCTAssertEqual(exit.fundingSeenSats, 8000, "a quote again for the same address keeps what reached it")
+
+ exit.useQuote(quote, destination: "bcrt1qdestination", feeRate: 2, fundingIndex: 4, fundingAddress: "bcrt1qnext")
+
+ XCTAssertEqual(exit.fundingSeenSats, 0, "the earlier exit's funding was taken as already sent")
+ XCTAssertEqual(exit.fundingSeenCount, 0)
+ }
+
+ /// Quoted leaves A (65,536) and B (32,768) and C (1,696) outside; the first build leaves B behind. Once A is
+ /// published and leaves the balance (34,464), Spending still holds B and C.
+ func testALeafLeftBehindStaysInSpendingOnceThePublishedLeavesLeftTheBalance() {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["A", "B"]
+ record.leafTotalSats = 98304
+ record.outsideSats = 1696
+ record.leftBehindLeaves = [.init(leafId: "B", valueSats: 32768, reason: "its refund cannot pay")]
+
+ XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 34464, record: record), 34464, "B was in neither total")
+ }
+
+ /// A Redo while a top-up is awaited rebuilds from the funding the exit already has, so a top-up that is dropped
+ /// never leaves it waiting.
+ func testARedoWhileATopUpIsAwaitedRebuildsWithoutWaitingForADroppedTopUp() async throws {
+ var record = bumpedRecord()
+ sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))]
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("m1")])]
+
+ record = try await driver().tick(record)
+
+ XCTAssertFalse(sdk.recoverRequests.isEmpty, "the rebuild waited for a top-up that was dropped")
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertFalse(record.isRebuilding)
+ }
+
+ /// A Redo rebuild the held funding pays too little for still follows the awaited top-up: one that confirmed short
+ /// asks for the rest, as while waiting for it.
+ func testARedoRebuildShortOfFundingAsksForTheRestOfAShortTopUp() async throws {
+ var record = bumpedRecord()
+ chain.utxosByAddress["bcrt1qfunding"] = [
+ SparkExitFixtures.utxo("f0", vout: 1, value: 20000),
+ SparkExitFixtures.utxo("bump", value: 3000),
+ ]
+ sdk.checks = try [CheckRecoverFundsResponse(recovery: XCTUnwrap(record.recovery?.response), verdict: .redo(reason: .onChainStateDiverged))]
+ sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 25680, docsUrl: "")
+
+ record = try await driver().tick(record)
+
+ XCTAssertFalse(sdk.recoverRequests.isEmpty)
+ XCTAssertNil(record.fundingPaymentTxid, "the short top-up was still awaited")
+ XCTAssertEqual(record.isFundingPaymentShort, true)
+ }
+
+ /// A cancelled exit's funding address stays listed, and for four weeks the foreground reads it: a late payment
+ /// shows there with its balance. Kept change is read every time, so change the user spent by hand shows as spent.
+ func testACancelledExitsAddressShowsALatePaymentForFourWeeks() async throws {
+ let record = SparkExitFixtures.record()
+ let cancelled = try XCTUnwrap(SparkExitEarlierAddress.cancelledFunding(record, network: .regtest, at: clock.addingTimeInterval(-86400)))
+ XCTAssertEqual(cancelled.address, record.funding[0].address)
+ XCTAssertEqual(cancelled.kind, .cancelledFunding)
+ let old = SparkExitEarlierAddress(address: "bcrt1qold", path: "m/84'/1'/7797'/0/1", kind: .cancelledFunding, sats: nil,
+ date: clock.addingTimeInterval(-29 * 86400))
+ let kept = SparkExitEarlierAddress(address: "bcrt1qkept", path: "m/84'/1'/7797'/0/2", kind: .keptChange, sats: 600,
+ date: clock.addingTimeInterval(-86400))
+
+ let refreshed = await SparkExitEarlierAddress.refreshed([cancelled, old, kept], now: clock) { address in
+ address == cancelled.address ? [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5000, confirmed: false)]
+ : [SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: 9)]
+ }
+
+ XCTAssertEqual(refreshed.map(\.sats), [5000, nil, 9], "a late payment, or kept change spent by hand, showed nowhere")
+ }
+
+ /// Kept change whose result was seen stays listed with its address and path: the ended record still holding that
+ /// address hides it only while its result is what Emergency exit opens.
+ func testASeenKeptChangeResultStillListsItsAddress() throws {
+ var record = SparkExitFixtures.record(phase: .done)
+ record.leftover = [.init(txid: String(repeating: "a", count: 64), vout: 0, valueSats: 600)]
+ record.leftoverSweep = .init()
+ record.leftoverSweep?.isDustKept = true
+ record.isResultSeen = true
+ let kept = try XCTUnwrap(SparkExitEarlierAddress.keptChange(record, network: .regtest, at: clock))
+ XCTAssertFalse(record.showsResult)
+
+ XCTAssertEqual(SparkExitManager.shownEarlierAddresses([kept], record: record), [kept], "the kept change's address and path are out of reach")
+ record.isResultSeen = nil
+ XCTAssertEqual(SparkExitManager.shownEarlierAddresses([kept], record: record), [], "shown twice, on the result and in the list")
+ }
+
+ /// An address added while a refresh reads another stays listed: the refresh writes only the balances it read.
+ func testAnAddressRefreshKeepsAnAddressAddedDuringItsReads() async throws {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let store = SparkExitEarlierAddressStore(url: dir.appendingPathComponent("earlier.json"))
+ let now = Date()
+ let first = SparkExitEarlierAddress(address: "bcrt1qfirst", path: "m/84'/1'/7797'/0/1", kind: .cancelledFunding, sats: nil, date: now)
+ let added = SparkExitEarlierAddress(address: "bcrt1qadded", path: "m/84'/1'/7797'/0/2", kind: .cancelledFunding, sats: nil, date: now)
+ try store.add(first)
+
+ let refreshed = await SparkExitManager.refreshEarlierAddresses(store: store, fallback: [], now: now) { _ in
+ try store.add(added)
+ return [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5000)]
+ }
+
+ XCTAssertEqual(try store.load().map(\.address), [first.address, added.address], "the refresh erased the address added meanwhile")
+ XCTAssertEqual(try store.load().map(\.sats), [5000, nil])
+ XCTAssertEqual(refreshed?.map(\.address), [first.address, added.address])
+ }
+
+ /// Kept change at a cancelled exit's address, which a later exit reused, is read for good: it replaces the
+ /// cancel's entry, which is read only for about four weeks.
+ func testKeptChangeAtACancelledExitsReusedAddressIsReadPastTheCancelsFourWeeks() throws {
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let store = SparkExitEarlierAddressStore(url: dir.appendingPathComponent("earlier.json"))
+ let cancelledAt = Date(timeIntervalSince1970: 1_800_000_000)
+ let keptAt = cancelledAt.addingTimeInterval(20 * 24 * 3600)
+ let path = "m/84'/1'/7797'/0/0"
+ try store.add(SparkExitEarlierAddress(address: "bcrt1qreused", path: path, kind: .cancelledFunding, sats: nil, date: cancelledAt))
+
+ try store.add(SparkExitEarlierAddress(address: "bcrt1qreused", path: path, kind: .keptChange, sats: 600, date: keptAt))
+
+ let listed = try XCTUnwrap(store.load().first)
+ XCTAssertEqual(try store.load().count, 1)
+ XCTAssertEqual(listed.kind, .keptChange, "the kept change stays a cancel's entry")
+ XCTAssertEqual(listed.date, keptAt)
+ XCTAssertEqual(listed.sats, 600)
+ XCTAssertTrue(listed.isWatched(now: cancelledAt.addingTimeInterval(60 * 24 * 3600)), "the kept change is no longer read")
+ }
+
+ /// The foreground tick keeps the driver's reset of a seen result: change kept after the result was opened shows
+ /// that result again with its path.
+ @MainActor func testTheForegroundTickKeepsTheDriversResetOfASeenResult() async throws {
+ let walletIndex = 9996
+ let store = SparkExitStore(walletIndex: walletIndex)
+ defer {
+ try? store.delete()
+ try? SparkExitEarlierAddressStore(walletIndex: walletIndex).delete()
+ try? SparkExitLostLeafStore(walletIndex: walletIndex).delete()
+ }
+ var record = SparkExitFixtures.record(phase: .failed(reason: "Nothing could be recovered"))
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 9000)
+ record.leftover = [.init(txid: String(repeating: "a", count: 64), vout: 0, valueSats: 600)]
+ record.leftoverSweep = SparkExitRecord.LeftoverSweep(waitingSince: 1000, checkedAtHeight: 1000)
+ record.isResultSeen = true
+ XCTAssertTrue(record.needsDriving)
+ XCTAssertFalse(record.hasUnseenResult)
+ try store.save(record)
+ chain.tip = 1000 + SparkExitDriver.sweepWaitBlocks
+ let exit = SparkExitManager(walletIndex: walletIndex, chain: chain)
+ exit.load(walletIndex: walletIndex)
+
+ await exit.tick()
+
+ XCTAssertEqual(exit.record?.isChangeKept, true)
+ XCTAssertEqual(exit.record?.hasUnseenResult, true, "the tick hid the kept change's result again")
+ XCTAssertEqual(try store.load()?.hasUnseenResult, true, "the stored record hid the kept change's result again")
+ }
+
+ // MARK: What stays on Spark (Android 124d66170, caa349d4b)
+
+ /// The start and the requote save what stays on Spark without the lost leaves Spark still counts: 100,000 sats
+ /// selected, 32,768 lost and 1,696 skipped save 1,696.
+ func testAStartSavesWhatStaysOnSparkWithoutTheLostLeavesSparkStillCounts() throws {
+ var prepared = SparkExitFixtures.prepared()
+ prepared.skipped = [SkippedLeaf(leafId: "dust", valueSats: 1696, reason: .feeExceedsValue)]
+ let quote = SparkExitQuote(prepared: prepared, lostLeafSats: [32768])
+ XCTAssertEqual(quote.leafTotalSats, 100_000)
+
+ let record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: quote, sparkBalanceSats: 134_464))
+
+ XCTAssertEqual(record.outsideSats, 1696, "the lost leaf was kept as staying on Spark")
+ }
+
+ /// A leaf the exit left behind stays on Spark: Spending counts it while it is out of the transfer.
+ func testALeafTheExitLeftBehindCountsOnSpark() {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "leaf-2"]
+ record.leafTotalSats = 108_192
+ record.leftBehindLeaves = [.init(leafId: "leaf-2", valueSats: 8192, reason: "its refund cannot pay")]
+
+ XCTAssertEqual(SparkExitMath.staysOnSparkSats(sparkBalanceSats: 108_192, record: record), 8192, "the leaf is in neither total")
+ XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 108_192, record: record), 8192)
+ }
+
+ // MARK: Requote at the start (review 1ac10966a, Android 64004d7)
+
+ /// An optimization swapped the leaves while the start waited: the exit is quoted again over the leaves Spark holds now.
+ func testAStartQuotesAgainWhenTheLeavesChanged() async throws {
+ let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared())
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding: 35000, leaves: ["leaf-2", "leaf-3"])
+
+ let requote = await SparkExitManager.requote(sdk, confirmed: confirmed, feeRate: 2, destination: "bcrt1qdestination", lostLeafIds: [])
+ let fresh = try XCTUnwrap(requote)
+ let record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: fresh, sparkBalanceSats: 214_167))
+
+ XCTAssertEqual(record.leafIds, ["leaf-2", "leaf-3"])
+ XCTAssertEqual(record.phase, .awaitingFunding(requiredSats: 35000))
+ XCTAssertEqual(record.expectedSats, fresh.incomingSats)
+ XCTAssertEqual(record.leafTotalSats, 200_000)
+ XCTAssertEqual(record.outsideSats, 14167, "what stays on Spark beyond the fresh leaves")
+ XCTAssertEqual(record.leafSats?.keys.sorted(), ["leaf-2", "leaf-3"])
+ XCTAssertEqual(sdk.prepareRequests.last?.destination, "bcrt1qdestination")
+ }
+
+ /// The same leaves in another order, no leaf left, or a quote that fails: the exit goes on with the confirmed quote.
+ func testAStartKeepsTheConfirmedQuoteWhenTheLeavesDidNotChange() async {
+ let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared(leaves: ["leaf-1", "leaf-2"]))
+ func requote() async -> SparkExitQuote? {
+ await SparkExitManager.requote(sdk, confirmed: confirmed, feeRate: 2, destination: "bcrt1qdestination", lostLeafIds: [])
+ }
+
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2", "leaf-1"])
+ let reordered = await requote()
+ XCTAssertNil(reordered)
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves: [])
+ let empty = await requote()
+ XCTAssertNil(empty)
+ sdk.prepareErrors = [URLError(.timedOut)]
+ let failed = await requote()
+ XCTAssertNil(failed)
+ }
+
+ /// A requote whose write fails leaves the start going on with the confirmed quote, its transfer kept and no new
+ /// one left counted.
+ func testAFailedRequoteWriteGoesOnWithTheConfirmedQuote() async throws {
+ struct WriteFailed: Error {}
+ let started = SparkExitFixtures.record()
+ let fresh = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 25000, leaves: ["leaf-2"]))
+ let requoted = try XCTUnwrap(SparkExitManager.requoted(started, fresh: fresh, sparkBalanceSats: 0))
+ var settled: [String] = []
+
+ let noTransfer = await SparkExitManager.writeRequote(
+ requoted,
+ replacing: started,
+ incomingSats: fresh.incomingSats,
+ createTransfer: { _ in throw WriteFailed() },
+ save: { _ in },
+ markSettled: { settled.append($0) }
+ )
+ XCTAssertNil(noTransfer)
+
+ let notSaved = await SparkExitManager.writeRequote(
+ requoted,
+ replacing: started,
+ incomingSats: fresh.incomingSats,
+ createTransfer: { _ in "transfer-2" },
+ save: { _ in throw WriteFailed() },
+ markSettled: { settled.append($0) }
+ )
+ XCTAssertNil(notSaved)
+ XCTAssertEqual(settled, ["transfer-2"], "the confirmed quote's transfer stays; the unsaved one is settled")
+
+ let written = await SparkExitManager.writeRequote(
+ requoted,
+ replacing: started,
+ incomingSats: fresh.incomingSats,
+ createTransfer: { _ in "transfer-3" },
+ save: { _ in },
+ markSettled: { settled.append($0) }
+ )
+ XCTAssertEqual(written?.transferId, "transfer-3")
+ XCTAssertEqual(written?.leafIds, ["leaf-2"])
+ XCTAssertEqual(settled, ["transfer-2", "transfer-1"])
+ }
+
+ /// A start killed before its transfer is written stores no exit: none runs on without the transfer counting it.
+ func testAStartCutShortBeforeItsTransferStoresNoExit() async {
+ struct WriteFailed: Error {}
+ var record = SparkExitFixtures.record()
+ record.transferId = nil
+ var saved: [SparkExitRecord] = []
+
+ let started = try? await SparkExitManager.writeStart(
+ record,
+ incomingSats: 111_000,
+ createTransfer: { _ in throw WriteFailed() },
+ save: { saved.append($0) },
+ markSettled: { _ in }
+ )
+
+ XCTAssertNil(started)
+ XCTAssertTrue(saved.isEmpty, "no exit is stored before its transfer exists")
+ }
+
+ /// A start killed after its transfer is written leaves only that transfer, settled as an orphan on the next load;
+ /// a failed save settles it at once. Every exit stored carries its transfer.
+ func testAStartCutShortAfterItsTransferLeavesOnlyAnOrphanToSettle() async throws {
+ struct WriteFailed: Error {}
+ var record = SparkExitFixtures.record()
+ record.transferId = nil
+ var saved: [SparkExitRecord] = []
+ var settled: [String] = []
+
+ let notSaved = try? await SparkExitManager.writeStart(
+ record,
+ incomingSats: 111_000,
+ createTransfer: { _ in "transfer-1" },
+ save: { _ in throw WriteFailed() },
+ markSettled: { settled.append($0) }
+ )
+ XCTAssertNil(notSaved)
+ XCTAssertEqual(settled, ["transfer-1"])
+
+ let started = try await SparkExitManager.writeStart(
+ record,
+ incomingSats: 111_000,
+ createTransfer: { _ in "transfer-2" },
+ save: { saved.append($0) },
+ markSettled: { settled.append($0) }
+ )
+ XCTAssertEqual(started.transferId, "transfer-2")
+ XCTAssertEqual(saved.map(\.transferId), ["transfer-2"], "every exit stored carries its transfer")
+ }
+
+ /// No tick drives an exit while its start still settles its leaves: the optimization wait, the requote and the
+ /// reconnect.
+ func testNoTickDrivesAnExitWhileItsStartSettles() {
+ let record = SparkExitFixtures.record()
+ XCTAssertFalse(SparkExitManager.canDrive(record, startingCreatedAt: record.createdAt))
+ XCTAssertTrue(SparkExitManager.canDrive(record, startingCreatedAt: nil))
+ XCTAssertTrue(SparkExitManager.canDrive(record, startingCreatedAt: record.createdAt.addingTimeInterval(-60)))
+ }
+
+ /// Funding read for a cancelled exit, such as its savings payment, never shows for the next one.
+ func testACancelledExitsFundingNeverShowsForTheNextExit() {
+ let cancelled = SparkExitFixtures.record()
+ var next = SparkExitFixtures.record()
+ next = SparkExitRecord(
+ network: next.network,
+ createdAt: cancelled.createdAt.addingTimeInterval(60),
+ phase: next.phase,
+ feeRateSatPerVbyte: next.feeRateSatPerVbyte,
+ destination: next.destination,
+ leafIds: next.leafIds,
+ funding: next.funding,
+ expectedSats: next.expectedSats
+ )
+ XCTAssertTrue(SparkExitManager.isFundingOfExit(cancelled.createdAt, record: cancelled))
+ XCTAssertFalse(SparkExitManager.isFundingOfExit(cancelled.createdAt, record: next))
+ XCTAssertFalse(SparkExitManager.isFundingOfExit(nil, record: next))
+ }
+
+ private func refusedPayment(status: OnchainSendAttempt.Status = .rejected, address: String = "bcrt1qfunding") -> OnchainSendAttempt {
+ var attempt = OnchainSendAttempt(
+ id: UUID(), walletId: "wallet", requestId: nil, orderId: nil, address: address, amountSats: 9000, isMaxAmount: false,
+ status: status, txid: "refused"
+ )
+ attempt.recoveryContext = OnchainSendRecoveryContext(
+ inputs: [OnchainSendInput(txid: String(repeating: "a", count: 64), vout: 0)],
+ satsPerVbyte: 9,
+ paymentIdentity: nil,
+ candidateTxids: ["refused"]
+ )
+ return attempt
+ }
+
+ /// Progress says a savings payment was refused only while the exit still waits for its first funding.
+ func testARefusedSavingsPaymentIsShownUntilAPaymentIsKept() {
+ let record = SparkExitFixtures.record()
+ XCTAssertTrue(SparkExitManager.isSavingsPaymentRefused(record, attempt: refusedPayment()))
+ XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(record, attempt: refusedPayment(status: .unknown)), "it may still go out")
+ XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(record, attempt: refusedPayment(address: "bcrt1qother")))
+ var paid = record
+ paid.fundingPaymentTxid = "paid"
+ XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(paid, attempt: refusedPayment()))
+ XCTAssertFalse(SparkExitManager.isSavingsPaymentRefused(SparkExitFixtures.record(phase: .running), attempt: refusedPayment()))
+ }
+
+ /// Paying again re-signs the refused attempt itself, over the inputs it already spends, so at most one confirms.
+ func testPayingAgainRetriesOnlyTheRefusedAttempt() async throws {
+ let refused = refusedPayment()
+ var contexts: [OnchainSendPendingContext] = []
+ let txid = try await SparkExitManager.payRefusedAgain(refused, record: SparkExitFixtures.record()) { context in
+ contexts.append(context)
+ return .accepted(txid: "again")
+ }
+ XCTAssertEqual(txid, "again")
+ XCTAssertEqual(contexts, [OnchainSendPendingContext(attemptId: refused.id, walletId: "wallet", txid: "refused")])
+
+ do {
+ _ = try await SparkExitManager.payRefusedAgain(refusedPayment(status: .unknown), record: SparkExitFixtures.record()) { context in
+ contexts.append(context)
+ return .accepted(txid: "again")
+ }
+ XCTFail("a payment that may still go out is never paid again")
+ } catch {}
+ XCTAssertEqual(contexts.count, 1)
+ }
+
+ /// A wipe closes Spark's connects first, then lets exit work (a tick that may reconnect) finish, and only then
+ /// disconnects and deletes: nothing can reconnect the wiped wallet after its SDK is cleared.
+ @MainActor
+ func testAWipeClosesConnectsAndDrainsExitWorkBeforeItDisconnects() async throws {
+ var steps: [String] = []
+ try await SparkManager.wipeInOrder(
+ closeAdmission: { steps.append("close") },
+ drainExitWork: { steps.append("drain") },
+ disconnect: { steps.append("disconnect") },
+ deleteData: { steps.append("delete") }
+ )
+ XCTAssertEqual(steps, ["close", "drain", "disconnect", "delete"])
+ }
+
+ /// Fee rates that cannot be read fail the savings price, so Pay from savings shows its error instead of loading.
+ func testSavingsFeeFailsWithoutFeeRates() async {
+ let rates = FeeRates(fast: 9, mid: 6, slow: 1)
+ func price(address: String? = "bcrt1qfunding", fundingSats: UInt64 = 20000, rates: FeeRates?) async
+ -> SparkExitFundingView.SavingsFee
+ {
+ await SparkExitFundingView.priceSavingsFee(
+ address: address,
+ fundingSats: fundingSats,
+ estimates: { rates },
+ calculate: { _, _, _ in 300 }
+ )
+ }
+
+ let noRates = await price(rates: nil)
+ XCTAssertEqual(noRates, .failed)
+ let noAddress = await price(address: nil, rates: rates)
+ XCTAssertEqual(noAddress, .unavailable)
+ let noFunding = await price(fundingSats: 0, rates: rates)
+ XCTAssertEqual(noFunding, .unavailable)
+ let priced = await price(rates: rates)
+ XCTAssertEqual(priced, .priced(rate: TransactionSpeed.fast.getFeeRate(from: rates), feeSats: 300))
+ }
+
+ /// A start whose savings payment was refused for a higher prepared fee: the next funding prices at least that fee,
+ /// so Funding shows it and the next swipe approves it.
+ @MainActor
+ func testTheNextFundingAfterAStartRefusedForAHigherSavingsFeePricesAtLeastThatFee() async {
+ let rates = FeeRates(fast: 9, mid: 6, slow: 1)
+ let exit = SparkExitManager(walletIndex: 9999)
+ func price() async -> SparkExitFundingView.SavingsFee {
+ await SparkExitFundingView.priceSavingsFee(
+ address: "bcrt1qfunding",
+ fundingSats: 20000,
+ refusedFeeSats: exit.savingsFundingRefusedFeeSats,
+ estimates: { rates },
+ calculate: { _, _, _ in 420 }
+ )
+ }
+ let rate = TransactionSpeed.fast.getFeeRate(from: rates)
+ let first = await price()
+ XCTAssertEqual(first, .priced(rate: rate, feeSats: 420))
+
+ exit.noteSavingsFundingRefused(preparedFeeSats: 600)
+ let next = await price()
+
+ // Savings' coin selection needs 600 sats where the estimate says 420.
+ XCTAssertEqual(next, .priced(rate: rate, feeSats: 600), "the same refused estimate was confirmed again")
+ }
+
+ /// Savings never pays more than the funding the user confirmed; the rest is asked for on Progress once the payment
+ /// confirms.
+ func testSavingsPaysAtMostTheConfirmedFundingAndProgressAsksForTheRest() throws {
+ let confirmed = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 20000))
+ let higher = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 35000, leaves: ["leaf-1", "leaf-2"]))
+ let lower = SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 15000, leaves: ["leaf-2"]))
+ XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: higher, seenSats: 0, seenCount: 0, feeRate: 2), 20000)
+ XCTAssertEqual(SparkExitManager.savingsFundingSats(confirmed: confirmed, fresh: lower, seenSats: 0, seenCount: 0, feeRate: 2), 15000)
+
+ var record = try XCTUnwrap(SparkExitManager.requoted(SparkExitFixtures.record(), fresh: higher, sparkBalanceSats: 0))
+ record.fundingPaymentTxid = "paid"
+ let paid = [SparkExitFixtures.utxo("paid", value: 20000)]
+ guard case let .ask(sats, receivedSats) = SparkExitMath.fundingState(record, utxos: paid) else {
+ return XCTFail("the shortfall is asked for")
+ }
+ XCTAssertEqual(receivedSats, 20000)
+ XCTAssertGreaterThanOrEqual(sats, 15000)
+ }
+
+ // MARK: Savings payment on Confirm (review thread 4511769453)
+
+ /// Pay from savings shows its own fee and the total that leaves savings; funding from another wallet shows neither.
+ @MainActor
+ func testConfirmShowsTheSavingsPaymentFeeAndTotal() {
+ let exit = SparkExitManager(walletIndex: 9999)
+ exit.useQuote(
+ SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: 6000)),
+ destination: "bcrt1qdestination",
+ feeRate: 2,
+ fundingIndex: 0,
+ fundingAddress: "bcrt1qfunding"
+ )
+ exit.savingsFeeSats = 453
+ XCTAssertNil(exit.savingsPayment, "paid from another wallet")
+
+ exit.fundFromSavings = true
+ XCTAssertEqual(exit.savingsPayment?.feeSats, 453)
+ XCTAssertEqual(exit.savingsPayment?.totalSats, 6453)
+
+ exit.useQuote(
+ SparkExitQuote(prepared: SparkExitFixtures.prepared(funding: nil)),
+ destination: "bcrt1qdestination",
+ feeRate: 2,
+ fundingIndex: 0,
+ fundingAddress: "bcrt1qfunding"
+ )
+ XCTAssertNil(exit.savingsPayment, "nothing to fund")
+ }
+
+ // MARK: Transfer while failed leaves are retried (pwltr's Android finding on c554a0185)
+
+ /// A confirmed sweep never ends the transfer while failed cooperative leaves are still retried: their value stays
+ /// in transfer to savings until the exit ends.
+ func testTheTransferStaysWhileFailedLeavesAreRetried() {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.failedLeafRetrySince = 1000
+ XCTAssertTrue(record.isRetryingFailedLeaves)
+ XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: true))
+ XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: false))
+
+ record.failedLeafRetrySince = nil
+ XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: true), "the sweep reached savings")
+ XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: false))
+
+ record.phase = .done
+ XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: false), "the exit ended")
+ }
+
+ /// A sweep seen before the failed-leaf retry starts never ends the transfer: the running recovery already lists
+ /// cooperative leaves the operators could not co-sign, and their value stays in transfer.
+ func testTheTransferStaysWhileTheRecoveryListsFailedLeavesBeforeTheirRetry() {
+ var record = SparkExitFixtures.record(phase: .running)
+ let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "w", outputVout: 0, error: .operatorsUnavailable(message: "down"))
+ record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")], failed: [failure]))
+ XCTAssertNil(record.failedLeafRetrySince)
+ XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: true))
+
+ record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")]))
+ XCTAssertTrue(SparkExitManager.isTransferFinished(record, isSweepSeen: true), "every leaf is on its way to savings")
+ }
+}
diff --git a/BitkitTests/SparkExitSweepTests.swift b/BitkitTests/SparkExitSweepTests.swift
new file mode 100644
index 000000000..a48db3b05
--- /dev/null
+++ b/BitkitTests/SparkExitSweepTests.swift
@@ -0,0 +1,1065 @@
+@testable import Bitkit
+import BreezSdkSpark
+import CryptoKit
+import libsecp256k1
+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(trustedFeeRate: UInt64? = 10) -> SparkExitDriver {
+ SparkExitDriver(
+ chain: chain,
+ recovering: nil,
+ makeSigner: { [unowned self] _ in try signer() },
+ save: { [unowned self] in saved.append($0) },
+ trustedFeeRate: { trustedFeeRate }
+ )
+ }
+
+ 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: "non-mandatory-script-verify-flag")
+ 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)
+ }
+
+ /// R7: a sweep refused for its fee is built again at the network's current rate; any other refusal is final.
+ func testASweepRefusedForItsFeeIsRebuiltAtTheRateTheNodeTakes() async throws {
+ chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)]
+ chain.outcome = .rejected("min relay fee not met, 300 < 330")
+ chain.fastestFee = 5
+
+ var record = try await driver().tick(endedRecord())
+ let refused = try XCTUnwrap(record.leftoverSweep)
+ XCTAssertTrue(refused.isWaitingForFees, "a fee refusal is not final")
+ XCTAssertEqual(refused.refusedFeeRate, 3, "330 over the 300 paid at 2 sat/vB, and at least one more")
+ XCTAssertTrue(record.needsDriving)
+
+ chain.outcome = .accepted
+ chain.tip = 1001
+ record = try await driver().tick(record)
+
+ let rebuilt = try XCTUnwrap(record.leftoverSweep)
+ XCTAssertNotNil(rebuilt.txid)
+ XCTAssertEqual(rebuilt.feeRateSatPerVbyte, 3)
+ XCTAssertEqual(rebuilt.valueSats, 5035 - SparkExitLeftoverSweep.feeSats(inputs: 1, outputScriptLength: 22, feeRateSatPerVbyte: 3))
+ XCTAssertTrue(rebuilt.isAccepted)
+ XCTAssertNil(rebuilt.rejection)
+ XCTAssertEqual(rebuilt.sentAtHeight, 1001)
+ }
+
+ /// 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)
+ 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)
+ 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, 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),
+ 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(trustedFeeRate: 50).tick(accepted)
+ XCTAssertEqual(accepted.leftoverSweep?.txid, "s")
+ XCTAssertTrue(accepted.needsDriving)
+ }
+
+ /// The replaced sweep that confirms is the one shown and followed: its txid, amount and inputs, not the replacement's.
+ func testAReplacedSweepThatConfirmsIsTheOneRecorded() async throws {
+ let utxo = SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 5035)
+ chain.utxosByAddress[key.address] = [utxo]
+ chain.fastestFee = 1
+ var record = try await driver().tick(endedRecord())
+ let first = try XCTUnwrap(record.leftoverSweep)
+ chain.tip = 1144
+ record = try await driver().tick(record)
+ let replacement = try XCTUnwrap(record.leftoverSweep)
+ XCTAssertLessThan(replacement.valueSats, first.valueSats)
+
+ // The first confirms, with a later payment still at the address: that one is followed on its own.
+ let late = SparkExitFixtures.utxo(String(repeating: "b", count: 64), value: 7000)
+ chain.utxosByAddress[key.address] = [utxo, late]
+ chain.confirmedTxids = try [XCTUnwrap(first.txid)]
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.sweptLeftover, try [.init(txid: XCTUnwrap(first.txid), valueSats: first.valueSats)])
+ XCTAssertEqual(record.leftover.map(\.txid), [late.txid])
+ let decoded = try SparkExitRecordCoder.decode(SparkExitRecordCoder.encode(record))
+ XCTAssertEqual(decoded, record)
+ }
+
+ /// R11: the Done screen reads what the sweep paid to savings, and its txid.
+ func testDoneShowsWhatReachedSavingsAndTheSweepTxid() throws {
+ let script = try SparkExitAddress.scriptPubKey(destination)
+ 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))
+ }
+
+ /// 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)
+ 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)
+
+ // 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] ?? []
+ )
+ }
+
+ /// Run E (case 6): six 294-sat change outputs, swept at 1 sat/vB, refused "min relay fee not met, 450 < 674". The
+ /// sweep is not given up: it waits for the next block, then goes again at the rate the refusal asks for.
+ func testAFeeChangeSweepRefusedForItsFeeIsRetriedAtTheRateTheNodeAsksFor() async throws {
+ var record = endedRecord()
+ record.leftover = (1 ... 6).map { SparkExitRecord.Leftover(txid: String(repeating: "\($0)", count: 64), vout: 0, valueSats: 294) }
+ chain.economyFee = 1
+ chain.utxosByAddress[key.address] = (1 ... 6).map { SparkExitFixtures.utxo(String(repeating: "\($0)", count: 64), value: 294) }
+ chain.outcome = .rejected("min relay fee not met, 450 < 674")
+
+ record = try await driver().tick(record)
+
+ let waiting = try XCTUnwrap(record.leftoverSweep)
+ XCTAssertNil(waiting.txid)
+ XCTAssertNil(waiting.rejection, "a fee refusal is never final")
+ XCTAssertTrue(waiting.isWaitingForFees)
+ XCTAssertEqual(waiting.refusedFeeRate, 2, "674 sats over the 450 vB paid at 1 sat/vB")
+ XCTAssertEqual(waiting.retryAtHeight, 1001)
+ XCTAssertTrue(record.needsDriving, "launch and background runs keep trying")
+ XCTAssertEqual(record.leftoverLine, .waiting(sats: 1764))
+ XCTAssertEqual(chain.broadcasts.count, 1)
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(chain.broadcasts.count, 1, "not again before the next block")
+
+ chain.tip = 1001
+ chain.outcome = .accepted
+ record = try await driver().tick(record)
+
+ let sweep = try XCTUnwrap(record.leftoverSweep)
+ XCTAssertEqual(sweep.feeRateSatPerVbyte, 2)
+ XCTAssertEqual(sweep.isAccepted, true)
+ XCTAssertEqual(sweep.valueSats, 1764 - 900)
+ XCTAssertEqual(sweep.waitingSince, 1000, "the 4-week bound still counts from the first wait")
+ try assertValidP2wpkhSpend(
+ .init(txid: XCTUnwrap(sweep.txid), txHex: XCTUnwrap(sweep.txHex), valueSats: sweep.valueSats),
+ utxos: chain.utxosByAddress[key.address] ?? []
+ )
+ }
+
+ /// A record from Run E's build, with the fee refusal stored as final, waits and retries on the next run too.
+ func testAStoredFeeRefusalWaitsAndRetries() async throws {
+ var record = endedRecord()
+ record.leftover = (1 ... 6).map { SparkExitRecord.Leftover(txid: String(repeating: "\($0)", count: 64), vout: 0, valueSats: 294) }
+ record.leftoverSweep = .init(
+ txid: "d770cd89",
+ txHex: "02",
+ valueSats: 1314,
+ rejection: "min relay fee not met, 450 < 674",
+ feeRateSatPerVbyte: 1
+ )
+ chain.utxosByAddress[key.address] = (1 ... 6).map { SparkExitFixtures.utxo(String(repeating: "\($0)", count: 64), value: 294) }
+ chain.economyFee = 1
+ XCTAssertEqual(record.leftoverLine, .waiting(sats: 1764), "one line with the total, never one per output")
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.leftoverSweep?.refusedFeeRate, 2)
+ XCTAssertTrue(chain.broadcasts.isEmpty)
+
+ chain.tip = 1001
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.leftoverSweep?.feeRateSatPerVbyte, 2)
+ XCTAssertEqual(record.leftoverSweep?.isAccepted, true)
+ }
+
+ /// Run F (case 6): the fee-change sweep went out at 2 sat/vB and was accepted, then the relay floor rose to 3 and
+ /// evicted it. Its rebroadcast is refused "492 < 738": it waits, goes again at 3 from the next block (RBF), and the
+ /// result never says it reached savings before it confirms.
+ func testASweepEvictedFromTheMempoolIsRetriedAtTheNewFloor() async throws {
+ var record = endedRecord()
+ let utxos = (1 ... 3).map { SparkExitFixtures.utxo(String(repeating: "\($0)", count: 64), value: 600) }
+ record.leftover = utxos.map { SparkExitRecord.Leftover(txid: $0.txid, vout: 0, valueSats: 600) }
+ chain.utxosByAddress[key.address] = utxos
+ chain.economyFee = 2
+
+ record = try await driver().tick(record)
+ let first = try XCTUnwrap(record.leftoverSweep)
+ let firstTxid = try XCTUnwrap(first.txid)
+ XCTAssertEqual(first.feeRateSatPerVbyte, 2)
+ XCTAssertEqual(first.valueSats, 1800 - 492)
+ XCTAssertTrue(first.isAccepted)
+ XCTAssertEqual(record.leftoverLine, .sending(sats: 1308, txid: firstTxid), "accepted is not yet in savings")
+
+ // Still in the mempool: a fee refusal from another node changes nothing.
+ chain.mempoolTxids = [firstTxid]
+ chain.outcome = .rejected("min relay fee not met, 492 < 738")
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.leftoverSweep?.txid, firstTxid)
+
+ // Evicted: no host holds it, and the rebroadcast is refused for its fee.
+ chain.mempoolTxids = []
+ record = try await driver().tick(record)
+ let waiting = try XCTUnwrap(record.leftoverSweep)
+ XCTAssertTrue(waiting.isWaitingForFees)
+ XCTAssertEqual(waiting.refusedFeeRate, 3, "738 over the 492 paid at 2 sat/vB")
+ XCTAssertEqual(waiting.retryAtHeight, 1001)
+ XCTAssertEqual(waiting.replacedTxids, [firstTxid], "the evicted sweep confirming would still end it")
+ XCTAssertEqual(record.leftoverLine, .waiting(sats: 1800))
+
+ chain.tip = 1001
+ chain.outcome = .accepted
+ record = try await driver().tick(record)
+ let retried = try XCTUnwrap(record.leftoverSweep)
+ let retriedTxid = try XCTUnwrap(retried.txid)
+ XCTAssertNotEqual(retriedTxid, firstTxid)
+ XCTAssertEqual(retried.feeRateSatPerVbyte, 3)
+ XCTAssertEqual(retried.valueSats, 1800 - 738)
+ XCTAssertEqual(record.leftoverLine, .sending(sats: 1062, txid: retriedTxid))
+
+ chain.confirmedTxids = [retriedTxid]
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.leftoverLine, .swept(sats: 1062, txid: retriedTxid))
+ XCTAssertFalse(record.needsDriving)
+ }
+
+ func testTheRefusedRateFollowsTheNodesMinimum() {
+ XCTAssertEqual(SparkExitMath.refusedSweepRate("min relay fee not met, 450 < 674", paidRate: 1), 2)
+ XCTAssertEqual(SparkExitMath.refusedSweepRate("mempool min fee not met, 900 < 2700", paidRate: 2), 6)
+ XCTAssertEqual(SparkExitMath.refusedSweepRate("insufficient fee", paidRate: 3), 4, "no numbers: one sat/vB more")
+ // A host's number past any real rate asks for more than every cap, and never traps.
+ XCTAssertEqual(SparkExitMath.refusedSweepRate("mempool min fee not met, 450 < 10000000000000000000", paidRate: 2), .max)
+ let absurd = SparkExitMath.refusedSweepRate("min relay fee not met, 110 < 22000", paidRate: 1)
+ XCTAssertEqual(absurd, 200)
+ XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: 5, exitRate: 2), 5, "capped at the next block's rate")
+ XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: 5, exitRate: 8), 8, "or the exit's, if higher")
+ XCTAssertEqual(SparkExitMath.refusedSweepFloor(absurd, nextBlock: nil, exitRate: 2), 2, "the exit's without a next-block rate")
+ XCTAssertEqual(SparkExitMath.refusedSweepFloor(2, nextBlock: 5, exitRate: 2), 2, "a modest ask is kept")
+ }
+
+ /// A host answers a 1 sat/vB sweep with an absurd ask, 22,000 sats for ~110 vB: the retry pays the next block's
+ /// rate at most, never the 200 sat/vB the refusal names.
+ func testARefusalCannotPushTheSweepAboveTheNextBlockRate() async throws {
+ var record = endedRecord()
+ let utxo = SparkExitFixtures.utxo(String(repeating: "1", count: 64), value: 25000)
+ record.leftover = [.init(txid: utxo.txid, vout: 0, valueSats: 25000)]
+ chain.utxosByAddress[key.address] = [utxo]
+ chain.economyFee = 1
+ chain.fastestFee = 5
+ chain.outcome = .rejected("min relay fee not met, 110 < 22000")
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.leftoverSweep?.refusedFeeRate, 200)
+
+ chain.tip = 1001
+ chain.outcome = .accepted
+ record = try await driver().tick(record)
+
+ let sweep = try XCTUnwrap(record.leftoverSweep)
+ XCTAssertEqual(sweep.isAccepted, true)
+ XCTAssertEqual(sweep.feeRateSatPerVbyte, 5)
+ }
+
+ /// The host's next block asks 5,000 sat/vB and its refusal 550,000 sats for ~110 vB of a 2M-sat leftover: the sweep
+ /// pays Bitkit's own fast rate at most, and is not replaced above it.
+ func testAHostQuotingAnAbsurdFeeRateCannotPushTheSweepPastBitkitsOwnRate() async throws {
+ var record = endedRecord()
+ let utxo = SparkExitFixtures.utxo(String(repeating: "1", count: 64), value: 2_000_000)
+ record.leftover = [.init(txid: utxo.txid, vout: 0, valueSats: 2_000_000)]
+ chain.utxosByAddress[key.address] = [utxo]
+ chain.economyFee = 1
+ chain.fastestFee = 5000
+ chain.outcome = .rejected("min relay fee not met, 110 < 550000")
+
+ record = try await driver().tick(record)
+ chain.tip = 1001
+ chain.outcome = .accepted
+ record = try await driver().tick(record)
+
+ let sweep = try XCTUnwrap(record.leftoverSweep)
+ XCTAssertEqual(sweep.isAccepted, true)
+ XCTAssertEqual(sweep.feeRateSatPerVbyte, 10, "the refused sweep went out at the host's rate")
+
+ // A day unconfirmed: no replacement above the ceiling, the same sweep is sent again.
+ chain.tip = 1145
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.leftoverSweep?.txid, sweep.txid)
+ XCTAssertEqual(record.leftoverSweep?.feeRateSatPerVbyte, 10)
+ }
+
+ /// The result names the leftover once, with its total, whatever state it is in.
+ func testTheLeftoverIsOneLineWithTheTotal() {
+ var record = endedRecord()
+ record.leftover = (1 ... 6).map { SparkExitRecord.Leftover(txid: "u\($0)", vout: 0, valueSats: 294) }
+ XCTAssertEqual(record.leftoverLine, .stillAt(sats: 1764))
+ record.leftoverSweep = .init(waitingSince: 1000, checkedAtHeight: 1000)
+ XCTAssertEqual(record.leftoverLine, .waiting(sats: 1764))
+ record.leftoverSweep?.isDustKept = true
+ XCTAssertEqual(record.leftoverLine, .kept(sats: 1764))
+ record.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "non-mandatory-script-verify-flag")
+ XCTAssertEqual(record.leftoverLine, .rejected(sats: 1764))
+ }
+
+ /// The local regtest mempool answers `{}`: every rate falls back, never failing the step.
+ func testFeeEstimatesFallBackToTheNextTargetThenTheRelayFloor() {
+ XCTAssertEqual(SparkExitFeeEstimates.rate([:], target: 1), 1)
+ XCTAssertEqual(SparkExitFeeEstimates.economy([:]), 1)
+ let sparse = ["2": 7.2, "6": 3.0, "144": 1.4]
+ XCTAssertEqual(SparkExitFeeEstimates.rate(sparse, target: 1), 8, "the next target up, rounded up")
+ XCTAssertEqual(SparkExitFeeEstimates.rate(sparse, target: 3), 3)
+ XCTAssertEqual(SparkExitFeeEstimates.rate(sparse, target: 1008), 2, "none longer: the longest there is")
+ XCTAssertEqual(SparkExitFeeEstimates.economy(sparse), 2)
+ XCTAssertEqual(SparkExitFeeEstimates.rate(["1": 0.2], target: 1), 1)
+ }
+
+ /// A host's rate out of range is never converted: such a rate is skipped, and an answer with nothing usable is
+ /// unavailable.
+ func testAnOutOfRangeFeeEstimateIsUnavailable() {
+ XCTAssertThrowsError(try SparkExitFeeEstimates.validated(["1": 1e30]), "an out-of-range rate was taken as an estimate")
+ XCTAssertThrowsError(try SparkExitFeeEstimates.validated(["1": .nan, "3": -2, "6": 0]))
+ XCTAssertEqual(try SparkExitFeeEstimates.validated(["1": 1e30, "6": 4]), ["6": 4])
+ XCTAssertEqual(try SparkExitFeeEstimates.validated([:]), [:], "an empty answer still falls back to the floor")
+ XCTAssertEqual(SparkExitFeeEstimates.rate(["1": 1e30, "6": 4], target: 1), 4)
+ XCTAssertEqual(SparkExitFeeEstimates.economy(["1": 4, "144": .infinity]), 4)
+ }
+
+ func testAnEmptyEstimateMapStillPricesEveryStep() async throws {
+ let http = SparkExitBroadcasterTests.FakeHTTP()
+ http.responses = [("a.example/api/fee-estimates", 200, "{}"), ("a.example/api/fee-estimates", 200, "{}")]
+ let chain = try EsploraSparkExitChain(hosts: [XCTUnwrap(URL(string: "https://a.example/api"))], http: http)
+
+ let fees = try await chain.networkFees()
+ let fastest = try await chain.fastestFeeRate()
+
+ XCTAssertEqual(fees, SparkExitNetworkFees(fastest: 1, halfHour: 1, economy: 1))
+ XCTAssertEqual(fastest, 1)
+ XCTAssertEqual(SparkExitFeeBumpOption.options(exitRate: 2, fees: fees).map(\.rate), [3], "a bump still offers a higher rate")
+ }
+
+ /// Change kept at the funding address shows its result once, then lets a later exit start: its address and path
+ /// stay in the earlier exit addresses, which no new exit replaces.
+ func testKeptChangeShowsOnceAndItsAddressStaysListed() async throws {
+ var record = endedRecord()
+ record.isResultSeen = true
+ record.feeRateSatPerVbyte = 20
+ chain.economyFee = 20
+ chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(String(repeating: "a", count: 64), value: 600)]
+ record = try await driver().tick(record)
+ chain.tip = 1000 + SparkExitDriver.sweepWaitBlocks
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.leftoverLine, .kept(sats: 600))
+ XCTAssertTrue(record.showsResult, "the funding address and its path were out of reach")
+ record.isResultSeen = true
+ XCTAssertFalse(record.showsResult, "the kept change blocked every later exit")
+
+ let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: dir) }
+ let earlier = SparkExitEarlierAddressStore(url: dir.appendingPathComponent("earlier.json"))
+ let save = SparkExitManager.gatedSave(
+ store: SparkExitStore(url: dir.appendingPathComponent("spark-exit.json")),
+ lostLeafStore: SparkExitLostLeafStore(url: dir.appendingPathComponent("lost.json")),
+ earlierAddressStore: earlier,
+ gate: SparkExitWriteGate()
+ )
+ try save(record)
+ try save(record)
+
+ let listed = try earlier.load()
+ XCTAssertEqual(listed.map(\.address), [key.address], "the kept change's address is in no list")
+ XCTAssertEqual(listed.first?.kind, .keptChange)
+ XCTAssertEqual(listed.first?.sats, 600)
+ XCTAssertEqual(listed.first?.path, SparkExitFundingKey.path(index: 0, network: Env.bitkitCoreNetwork))
+ }
+
+ /// A leftover sweep refused for good shows its result once, then lets a later exit start: the funding address and
+ /// its path stay in the earlier exit addresses.
+ func testARefusedLeftoverSweepShowsOnceAndItsAddressStaysListed() throws {
+ var record = endedRecord()
+ record.leftoverSweep = .init(txid: "t", txHex: "02", valueSats: 4000, rejection: "non-mandatory-script-verify-flag")
+ XCTAssertTrue(record.showsResult, "the leftover's address and path were out of reach")
+ XCTAssertTrue(record.hasUnseenResult, "opening the result never lets the refused sweep go")
+ record.isResultSeen = true
+ XCTAssertFalse(record.showsResult, "the refused sweep blocked every later exit")
+
+ let listed = try XCTUnwrap(SparkExitEarlierAddress.keptChange(record, network: .regtest, at: Date()), "the leftover's address is in no list")
+ XCTAssertEqual(listed.address, key.address)
+ XCTAssertEqual(listed.kind, .keptChange)
+ XCTAssertEqual(listed.sats, 5035)
+ }
+
+ /// After about four weeks (4,032 blocks) the change stays at the funding address and the exit is let go.
+ func testChangeTooSmallForFourWeeksIsKept() async throws {
+ var record = endedRecord()
+ 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)
+ 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 {
+ 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) }
+ }
+
+ // MARK: Ended with a savings payment on its way
+
+ private func endedWithPayment(_ paid: String) -> SparkExitRecord {
+ var record = endedRecord()
+ record.leftover = []
+ record.leftoverSweep = nil
+ record.fundingPaymentTxid = paid
+ return record
+ }
+
+ /// An exit that ended before its savings payment arrived keeps being driven; once it lands it is swept back.
+ func testEndedExitSweepsASavingsPaymentThatArrivesLater() async throws {
+ let paid = String(repeating: "d", count: 64)
+ chain.mempoolTxids = [paid]
+ var record = endedWithPayment(paid)
+ XCTAssertTrue(record.needsDriving)
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.fundingPaymentTxid, paid)
+ XCTAssertTrue(record.needsDriving)
+ XCTAssertNil(record.leftoverSweep)
+
+ chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(paid, value: 20000)]
+ record = try await driver().tick(record)
+ XCTAssertNil(record.fundingPaymentTxid)
+ XCTAssertEqual(record.leftover.map(\.txid), [paid])
+ XCTAssertNotNil(record.leftoverSweep?.txid)
+ XCTAssertTrue(record.needsDriving)
+ }
+
+ /// A fee bump's savings payment that went out after the exit ended (pwltr's Android finding on c554a0185): its txid
+ /// is kept as pending, so the exit is driven until the payment reaches the funding address and is swept back, even
+ /// after the leftover sweep confirmed.
+ func testAFeeBumpPaymentSentAfterTheExitEndedIsFollowedUntilItArrives() async throws {
+ let late = String(repeating: "e", count: 64)
+ var record = endedRecord()
+ record.leftover = [.init(txid: String(repeating: "1", count: 64), vout: 0, valueSats: 600)]
+ record.leftoverSweep = .init(txid: "swept", txHex: "02", valueSats: 400, isAccepted: true, isConfirmed: true)
+ XCTAssertFalse(record.needsDriving, "the leftover sweep confirmed")
+
+ record = try XCTUnwrap(SparkExitManager.followingLatePayment(record, txid: late))
+ XCTAssertTrue(record.needsDriving)
+ chain.mempoolTxids = [late]
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.fundingPaymentTxid, late, "still on its way")
+ XCTAssertTrue(record.needsDriving)
+
+ chain.confirmedTxids = ["swept"]
+ chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(late, value: 5000)]
+ record = try await driver().tick(record)
+ XCTAssertNil(record.fundingPaymentTxid)
+ XCTAssertTrue(record.leftover.contains { $0.txid == late })
+ XCTAssertTrue(record.isLeftoverSweepPending, "swept back to savings")
+ }
+
+ func testOnlyAnEndedExitFollowsALateFeeBumpPayment() {
+ XCTAssertNil(SparkExitManager.followingLatePayment(SparkExitFixtures.record(phase: .running), txid: "late"), "funding adopts it")
+ XCTAssertNil(SparkExitManager.followingLatePayment(endedRecord(), txid: nil))
+ var following = endedRecord()
+ following.leftover = []
+ following.fundingPaymentTxid = "earlier"
+ XCTAssertNil(SparkExitManager.followingLatePayment(following, txid: "late"), "an earlier payment is still followed")
+ }
+
+ /// A payment no host sees is forgotten by the own-payment expiry, and the ended exit stops being driven.
+ func testEndedExitForgetsASavingsPaymentNoHostSees() async throws {
+ var record = endedWithPayment(String(repeating: "d", count: 64))
+
+ record = try await driver().tick(record)
+ XCTAssertNotNil(record.fundingPaymentTxid)
+ chain.tip = 1003
+ record = try await driver().tick(record)
+ XCTAssertNil(record.fundingPaymentTxid)
+ XCTAssertFalse(record.needsDriving)
+ }
+
+ /// A payment already in the leftover needs no following.
+ func testEndedPaymentAlreadyInTheLeftoverIsNotFollowed() {
+ var record = endedRecord()
+ record.fundingPaymentTxid = record.leftover[0].txid
+ XCTAssertFalse(record.isEndedPaymentPending)
+ }
+
+ /// With a sweep in flight the payment waits for it; once it confirms, the sweep is kept and the payment swept.
+ func testEndedPaymentWaitsForTheSweepInFlight() async throws {
+ let paid = String(repeating: "d", count: 64)
+ var record = endedRecord()
+ record.leftoverSweep = .init(txid: "s", txHex: "02", valueSats: 4800, isAccepted: true, feeRateSatPerVbyte: 2, sentAtHeight: 1000)
+ record.fundingPaymentTxid = paid
+ chain.mempoolTxids = [paid, "s"]
+ chain.utxosByAddress[key.address] = [SparkExitFixtures.utxo(paid, value: 20000)]
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.fundingPaymentTxid, paid)
+ XCTAssertEqual(record.leftoverSweep?.txid, "s")
+
+ chain.confirmedTxids = ["s"]
+ record = try await driver().tick(record)
+ XCTAssertNil(record.fundingPaymentTxid)
+ XCTAssertEqual(record.sweptLeftover?.map(\.txid), ["s"])
+ XCTAssertEqual(record.leftover.map(\.txid), [paid])
+ XCTAssertNotEqual(record.leftoverSweep?.txid, "s")
+ }
+
+ /// The exit's sweeps whose activity is marked as a transfer: the recovery's, else the one that landed before a
+ /// retry, and those of replaced generations.
+ func testSweepTxidsIncludeTheLandedSweep() {
+ var record = endedRecord()
+ record.landedSweepTxid = "landed"
+ record.earlierReceivedSats = ["coop": 1000]
+ XCTAssertEqual(SparkExitMath.sweepTxids(record), ["landed", "coop"])
+ record.recovery = StoredRecovery(SparkExitFixtures.recovery([SparkExitFixtures.tx("sweep", kind: .sweep)]))
+ XCTAssertEqual(SparkExitMath.sweepTxids(record), ["sweep", "coop"])
+ }
+
+ // MARK: Home balances
+
+ /// The exit's own transfer counts what is still to come: a planned sweep counts only once it confirmed.
+ func testInTransferCountsOnlyWhatConfirmedToSavings() throws {
+ let script = try SparkExitAddress.scriptPubKey(destination)
+ // Pays 50,000 sats to savings, little-endian.
+ let hex = "02000000" + "01" + String(repeating: "11", count: 32) + "00000000" + "00" + "ffffffff"
+ + "01" + "50c3000000000000" + String(format: "%02x", script.count) + script.hex + "00000000"
+ func sweep(_ status: ExitTransactionStatus) -> RecoveryTransaction {
+ RecoveryTransaction(kind: .sweep, nodeId: nil, txid: "sweep", txHex: hex, cpfpTxHex: nil, csvTimelockBlocks: nil,
+ dependsOn: [], status: status)
+ }
+ var record = endedRecord(phase: .running)
+ record.recovery = StoredRecovery(SparkExitFixtures.recovery([sweep(.waitingForTimelock(spendableAtHeight: 900))]))
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 90000)
+
+ record.recovery = StoredRecovery(SparkExitFixtures.recovery([sweep(.confirmed(blockHeight: 50))]))
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 40000)
+ record.earlierReceivedSats = ["coop": 30000]
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 10000)
+ }
+
+ /// A leaf the exit left behind stays on Spark: it leaves the transfer to savings.
+ func testInTransferLeavesOutALeafLeftBehind() {
+ var record = endedRecord(phase: .running)
+ record.leftBehindLeaves = [.init(leafId: "leaf-2", valueSats: 30000, reason: "fee rate")]
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 60000, "the left-behind leaf is still counted in transfer")
+ }
+
+ /// The first fee funding nobody paid yet is not in transfer; once paid, or once the exit pays it, it is.
+ func testInTransferLeavesOutUnpaidFirstFunding() {
+ var record = endedRecord(phase: .awaitingFunding(requiredSats: 20000))
+ record.funding = [.init(index: 0, address: key.address, pubkey: key.pubkey)]
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 70000)
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 5000), 75000)
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 20000), 90000)
+
+ record.fundingPaymentTxid = "paid"
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 90000)
+ record.fundingPaymentTxid = nil
+ record.phase = .running
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 90000)
+ }
+
+ /// Only the running exit's own transfer swaps its amount; any other exit transfer keeps it.
+ func testOnlyTheExitsOwnTransferCountsItsInTransferAmount() {
+ let own = Bitkit.Transfer(id: "own", type: .sparkExit, amountSats: 90000, createdAt: 0)
+ let other = Bitkit.Transfer(id: "other", type: .sparkExit, amountSats: 12000, createdAt: 0)
+
+ XCTAssertEqual(BalanceManager.sparkExitInTransferSats(own, exitInTransfer: ("own", 40000)), 40000)
+ XCTAssertEqual(BalanceManager.sparkExitInTransferSats(other, exitInTransfer: ("own", 40000)), 12000)
+ XCTAssertEqual(BalanceManager.sparkExitInTransferSats(own, exitInTransfer: nil), 90000)
+ }
+
+ /// While an exit runs, Spending keeps what Spark holds beyond the exit's leaves.
+ func testSpendingKeepsSparkValueOutsideTheExit() {
+ XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: false, balanceSats: 50000, outsideExitSats: nil), 50000)
+ XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 50000, outsideExitSats: 32768), 32768)
+ XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 50000, outsideExitSats: nil), 0)
+ }
+
+ /// While an exit runs, Spark counts as 0 where Spending and its connections show it, as on Android.
+ func testSparkShowsZeroInSpendingDuringAnExit() {
+ XCTAssertEqual(SparkManager.shownSpendingSats(isExiting: false, shownBalanceSats: 50000), 50000)
+ XCTAssertEqual(SparkManager.shownSpendingSats(isExiting: true, shownBalanceSats: 50000), 0, "Spending showed Spark during an exit")
+ }
+
+ /// A start goes on only with its own exit still stored and active: one cancelled while it waited stops it.
+ func testStartGoesOnOnlyWithItsExitStillRunning() throws {
+ let createdAt = Date(timeIntervalSince1970: 1_700_000_000.123_456)
+ let record = SparkExitRecord(
+ network: "regtest",
+ createdAt: createdAt,
+ phase: .awaitingFunding(requiredSats: 20000),
+ feeRateSatPerVbyte: 2,
+ destination: destination,
+ leafIds: ["leaf-1"],
+ funding: [.init(index: 0, address: key.address, pubkey: key.pubkey)],
+ expectedSats: 90000
+ )
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("spark-exit-start-\(UUID().uuidString).json")
+ defer { try? FileManager.default.removeItem(at: url) }
+ let store = SparkExitStore(url: url)
+ try store.save(record)
+ let stored = try XCTUnwrap(store.load())
+
+ XCTAssertNotNil(SparkExitManager.startedExit(stored: stored, createdAt: createdAt))
+ XCTAssertNil(SparkExitManager.startedExit(stored: nil, createdAt: createdAt))
+ XCTAssertNil(SparkExitManager.startedExit(stored: stored, createdAt: createdAt.addingTimeInterval(1)))
+ var cancelled = stored
+ cancelled.phase = .done
+ XCTAssertNil(SparkExitManager.startedExit(stored: cancelled, createdAt: createdAt))
+ }
+}
+
+private extension Data {
+ init(hex: String) {
+ self.init(hex.hexaBytes)
+ }
+}
diff --git a/BitkitTests/SparkExitTests.swift b/BitkitTests/SparkExitTests.swift
new file mode 100644
index 000000000..bff9dcb48
--- /dev/null
+++ b/BitkitTests/SparkExitTests.swift
@@ -0,0 +1,3465 @@
+@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: [], strategy: .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
+ )
+ }
+
+ /// 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))
+ }
+}
+
+final class FakeSparkExitChain: SparkExitChain, @unchecked Sendable {
+ private let lock = NSLock()
+ 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] = []
+ var onBroadcast: (() -> Void)?
+
+ /// The tip read fails, as on a host timeout.
+ var isTipUnreadable = false
+
+ func tipHeight() async throws -> UInt32 {
+ if isTipUnreadable {
+ throw URLError(.timedOut)
+ }
+ return tip
+ }
+
+ func utxos(address: String) async throws -> [EsploraUtxo] {
+ if isUtxosUnreadable {
+ throw URLError(.timedOut)
+ }
+ return lock.withLock { utxosByAddress[address] ?? [] }
+ }
+
+ /// Addresses with a transaction though they hold nothing now.
+ var usedAddresses: Set = []
+ /// Every address read for its transactions, in order.
+ private(set) var addressReads: [String] = []
+ /// The address read fails, as on a host timeout.
+ var isAddressReadUnreadable = false
+
+ func isAddressUsed(address: String) async throws -> Bool {
+ if isAddressReadUnreadable {
+ throw URLError(.timedOut)
+ }
+ return lock.withLock {
+ addressReads.append(address)
+ return usedAddresses.contains(address) || !(utxosByAddress[address] ?? []).isEmpty
+ }
+ }
+
+ /// Transactions every host answers not found for, as for one a node dropped.
+ var droppedTxids: Set = []
+ /// Block heights of confirmed transactions; 900 for any other.
+ var heights: [String: UInt32] = [:]
+ /// Confirmed transactions whose host answers no block height.
+ var unknownHeightTxids: Set = []
+
+ func txStatus(txid: String) async throws -> EsploraTxStatus {
+ if droppedTxids.contains(txid) {
+ throw URLError(.fileDoesNotExist)
+ }
+ return lock.withLock {
+ let isConfirmed = confirmedTxids.contains(txid)
+ let height = isConfirmed && !unknownHeightTxids.contains(txid) ? heights[txid] ?? 900 : nil
+ return EsploraTxStatus(confirmed: isConfirmed, blockHeight: height)
+ }
+ }
+
+ func isKnown(txid: String) async throws -> Bool {
+ lock.withLock { confirmedTxids.contains(txid) || mempoolTxids.contains(txid) }
+ }
+
+ func broadcast(_ tx: RecoveryTransaction) async -> SparkExitBroadcastOutcome {
+ onBroadcast?()
+ return lock.withLock {
+ broadcasts.append(tx)
+ return outcome
+ }
+ }
+
+ 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 {
+ var preparedResponse = SparkExitFixtures.prepared()
+ var builds: [RecoverFundsResponse] = []
+ var checks: [CheckRecoverFundsResponse] = []
+ var recoverError: Error?
+ /// Errors the next recovers throw, in order, before `recoverError`.
+ var recoverErrors: [Error] = []
+ /// Thrown by the next prepares, in order.
+ var prepareErrors: [Error] = []
+ var preparedByRequest: ((PrepareRecoverFundsRequest) -> PrepareRecoverFundsResponse)?
+ 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 !recoverErrors.isEmpty {
+ throw recoverErrors.removeFirst()
+ }
+ if let recoverError {
+ throw recoverError
+ }
+ return builds.removeFirst()
+ }
+
+ /// Answers the queued checks in order, then `Valid` with the recovery unchanged.
+ func check(_ recovery: RecoverFundsResponse) async throws -> CheckRecoverFundsResponse {
+ checkedRecoveries.append(recovery)
+ return checks.isEmpty ? CheckRecoverFundsResponse(recovery: recovery, verdict: .valid) : 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 {
+ 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)]
+ record.outsideSats = 14167
+ 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))
+ }
+ }
+
+ /// 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)
+ defer { try? store.delete() }
+ XCTAssertNil(try store.load())
+
+ try store.save(SparkExitFixtures.record())
+
+ 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(), [])
+ }
+
+ /// 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
+
+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)
+ // 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)
+ }
+
+ /// 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: 30)
+ sdk.builds = Array(repeating: recovery, count: 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(sdk.recoverRequests.count, 3)
+ XCTAssertEqual(record.generation, 4)
+ XCTAssertEqual(record.stallRebuilds, 3)
+ 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"])]))
+ 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)]))
+ 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: 3136))
+ XCTAssertTrue(record.isActive)
+ }
+
+ /// 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(record.fundedSats, 9035)
+ 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: 150), SparkExitFixtures.utxo("t2", value: 150)]
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .needsFunding(requiredSats: 9221), "300 is short of 186 plus a second input's 136")
+
+ 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)
+
+ // The fewest coins that cover: t3 alone; t1 and t2 stay for the leftover sweep.
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertEqual(sdk.recoverRequests.last?.fundingInputs.count, 2)
+ XCTAssertEqual(record.funding.compactMap(\.txid), ["u0", "t3"])
+ }
+
+ /// After a Redo the first inputs are partly spent: what they hold now is the CPFP change at the address.
+ 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: 22136))
+ XCTAssertEqual(record.fundingShortfallSats, 2136)
+ }
+
+ /// 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, 2136)
+ 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)))
+
+ // Kept as the exit's recovery, so the next check puts the leaf through the bounded failed-leaf retry.
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertEqual(record.failedLeafCount, 1)
+ XCTAssertEqual(record.recovery?.response.failed.map(\.leafId), ["leaf-1"])
+ }
+
+ 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, "n1"]
+ chain.heights["n1"] = 995
+
+ 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.confirmedTxids = ["n1"]
+ chain.unknownHeightTxids = ["n1"]
+ 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.confirmedTxids = ["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"])
+ }
+
+ private func reorgedParentRecord() -> SparkExitRecord {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.adopt(SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("r1", dependsOn: ["n1"], status: .waitingForDependencies),
+ ]))
+ return record
+ }
+
+ /// A reorg undid a confirmation the record saved: the keyless pass asks the chain again before sending its child.
+ func testWithoutTheSdkASavedConfirmationAReorgUndidIsAskedAgain() async throws {
+ var record = reorgedParentRecord()
+ chain.outcome = .inputsMissing("bad-txns-inputs-missingorspent")
+
+ record = try await driver(withSdk: false).tick(record)
+
+ XCTAssertFalse(chain.broadcasts.contains { $0.txid == "r1" })
+ if case .confirmed = record.recovery?.response.transactions.first(where: { $0.txid == "n1" })?.status {
+ XCTFail("the reorged parent stayed confirmed")
+ }
+ XCTAssertFalse(record.broadcastLog.contains { $0.txid == "r1" && $0.isInputSpent == true })
+
+ chain.confirmedTxids = ["n1"]
+ chain.outcome = .accepted
+ record = try await driver(withSdk: false).tick(record)
+ XCTAssertEqual(chain.broadcasts.map(\.txid).filter { $0 == "r1" }, ["r1"])
+ }
+
+ /// A child refused as spent while its parent's confirmation was a reorged one is sent again once it reconfirms.
+ func testWithoutTheSdkAReorgClearsTheSpentInputItsChildWasRefusedFor() async throws {
+ var record = reorgedParentRecord()
+ record.broadcastLog = [.init(
+ generation: 1,
+ txid: "r1",
+ attempts: 1,
+ rejections: 0,
+ lastAttemptAt: clock,
+ lastOutcome: "inputs missing",
+ isAccepted: false,
+ isInputSpent: true
+ )]
+
+ record = try await driver(withSdk: false).tick(record)
+ XCTAssertNotEqual(record.broadcastLog.first { $0.txid == "r1" }?.isInputSpent, true)
+
+ chain.confirmedTxids = ["n1"]
+ record = try await driver(withSdk: false).tick(record)
+ XCTAssertEqual(chain.broadcasts.map(\.txid).filter { $0 == "r1" }, ["r1"])
+ }
+
+ /// No host answering for a saved confirmation keeps it.
+ func testWithoutTheSdkASavedConfirmationStaysWhileNoHostAnswers() async throws {
+ chain.droppedTxids = ["n1"]
+
+ let record = try await driver(withSdk: false).tick(reorgedParentRecord())
+
+ XCTAssertEqual(record.recovery?.response.transactions.first?.status, .confirmed(blockHeight: 900))
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["r1"])
+ }
+
+ /// qa-03 shared-ancestor race, with the operators down so nothing else diverges: another wallet's CPFP child
+ /// confirmed node n1, ours never will, and n2's child spends our child's change. `checkRecoverFunds` answers
+ /// Valid, so the exit rebuilds from the same funding and never sends the dead chain again.
+ 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,
+ /// 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)),
+ 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.stallRebuilds, 1)
+
+ record = try await driver().tick(record)
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 2)
+ 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
+ /// 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)
+ XCTAssertEqual(SparkExitMath.inTransferSats(record, fundingSeenSats: 0), 50000, "the lost leaf leaves the transfer")
+ XCTAssertTrue(record.isRebuilding)
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertEqual(SparkExitMath.reminders(record, tip: 1000, now: clock, network: .regtest).first?.id, "lost-1")
+
+ 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"])
+ }
+
+ /// The first build has no recovery to price a leaf it leaves behind from: the quote's value is kept.
+ func testALeafTheFirstBuildLeavesBehindKeepsItsValue() async throws {
+ var record = twoLeafRecord()
+ record.leafSats = ["leaf-1": 100_000, "leaf-2": 50000]
+ record.isRebuilding = true
+ sdk.prepareErrors = [SdkError.SparkError("Leaf leaf-1 cannot be exited: its refund cannot pay its own fee at this fee rate")]
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("new")])]
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-1"])
+ XCTAssertEqual(record.leftBehindSats, 100_000, "the leaf left behind counted as 0 sats")
+ }
+
+ /// An error that names no leaf falls back to `RecoverableOnly`, and what it leaves out stays behind.
+ func testARebuildFallsBackToRecoverableOnlyWhenTheErrorNamesNoLeaf() async throws {
+ var record = twoLeafRecord()
+ 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)
+ }
+
+ /// `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.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")])
+ 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
+ )
+ }
+
+ // 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")
+ }
+
+ /// An unverified rebuild that builds only failed cooperative leaves, beside the leaf it could not read, keeps the
+ /// valid generation: its signed steps are still sent once the chain reads again.
+ func testAnUnverifiedRebuildOfOnlyFailedLeavesKeepsTheValidGeneration() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "coop-1"]
+ let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down"))
+ let unread = SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified),
+ ], failed: [failure])
+ let readable = SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("n2", dependsOn: ["n1"]),
+ ], failed: [failure])
+ record.adopt(unread, at: clock)
+ sdk.checks = [
+ CheckRecoverFundsResponse(recovery: unread, verdict: .valid),
+ CheckRecoverFundsResponse(recovery: readable, verdict: .valid),
+ ]
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["coop-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = Array(repeating: SparkExitFixtures.recovery([], failed: [failure]), count: 2)
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 1, "rebuild adopted a recovery without leaf-1's signed steps")
+
+ chain.tip = 1001
+ record = try await driver().tick(record)
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent")
+ }
+
+ /// An unverified rebuild that skips the leaf it could not read but builds another unilateral leaf keeps the valid
+ /// generation: the unread leaf's signed steps are still sent once the chain reads again.
+ func testAnUnverifiedRebuildBesideAnotherUnilateralLeafKeepsTheValidGeneration() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "leaf-2"]
+ let unread = SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified),
+ SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("m2", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)),
+ ])
+ record.adopt(unread, at: clock)
+ sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)]
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = Array(repeating: SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("m2b", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)),
+ ]), count: 2)
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 1, "rebuild adopted a recovery without leaf-1's signed steps")
+
+ // The chain reads again: whatever the record holds, its unverified steps now read as ready.
+ var reread = try XCTUnwrap(record.recovery?.response)
+ reread.transactions = reread.transactions.map {
+ var tx = $0
+ if tx.status == .unverified {
+ tx.status = .ready
+ }
+ return tx
+ }
+ sdk.checks = [CheckRecoverFundsResponse(recovery: reread, verdict: .valid)]
+ chain.tip = 1001
+ record = try await driver().tick(record)
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent")
+ }
+
+ /// The adopted rebuild without leaf-1 never reaches the store, even for the moment before the guard restores it:
+ /// a process killed in that window relaunches from what was saved.
+ func testAnUnverifiedRebuildBesideAnotherUnilateralLeafNeverSavesTheRebuild() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "leaf-2"]
+ let unread = SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified),
+ SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("m2", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)),
+ ])
+ record.adopt(unread, at: clock)
+ sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)]
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("m2b", dependsOn: ["m1"], status: .waitingForTimelock(spendableAtHeight: 2000)),
+ ])]
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.generation, 1)
+ let rebuilt = saved.filter { $0.generation != 1 }
+ XCTAssertTrue(
+ rebuilt.isEmpty,
+ "saved generation \(rebuilt.map(\.generation)) without leaf-1's steps: \(rebuilt.first?.recovery?.response.transactions.map(\.txid) ?? [])"
+ )
+ }
+
+ /// Funding for an unverified rebuild's ask: a rebuild without leaf-1 is neither saved nor broadcast.
+ func testFundingAnUnverifiedRebuildsAskNeverSavesNorSendsARebuildWithoutTheUnreadLeaf() async throws {
+ var (record, _, _) = try await unverifiedAsk()
+ let unread = try XCTUnwrap(record.recovery?.response)
+ sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)]
+ chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 50000)]
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("m1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("m2b", cpfp: "03c1", dependsOn: ["m1"]),
+ ])]
+ chain.tip = 1001
+ saved = []
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.generation, 1, "funding adopted a rebuild without leaf-1's signed steps")
+ let rebuilt = saved.filter { $0.generation != 1 }
+ XCTAssertTrue(rebuilt.isEmpty, "saved generation \(rebuilt.map(\.generation)) without leaf-1's steps")
+ XCTAssertFalse(
+ chain.broadcasts.map(\.txid).contains("m2b"),
+ "broadcast m2b of a generation the record does not keep: \(chain.broadcasts.map(\.txid))"
+ )
+ }
+
+ /// The valid generation beside a step the SDK could not read, whose rebuild of only failed cooperative leaves asks
+ /// for fee funding first.
+ private func unverifiedAsk() async throws -> (record: SparkExitRecord, readable: RecoverFundsResponse, failure: CooperativeRecoveryFailure) {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "coop-1"]
+ let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down"))
+ let unread = SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: .unverified),
+ ], failed: [failure])
+ let readable = SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("n2", dependsOn: ["n1"]),
+ ], failed: [failure])
+ record.adopt(unread, at: clock)
+ sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)]
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["coop-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "")
+
+ record = try await driver().tick(record)
+ guard case .needsFunding = record.phase else {
+ XCTFail("the unverified rebuild asked for no funding: \(record.phase)")
+ return (record, readable, failure)
+ }
+ XCTAssertEqual(record.generation, 1)
+ sdk.recoverError = nil
+ return (record, readable, failure)
+ }
+
+ /// Confirmed funding for an unverified rebuild's fee ask resumes that rebuild with its guard: a build of only failed
+ /// cooperative leaves beside the waiting leaf is never adopted, and the exit runs on with the valid generation.
+ func testFundingAnUnverifiedRebuildsAskKeepsTheValidGenerationOverAFailedOnlyBuild() async throws {
+ var (record, _, failure) = try await unverifiedAsk()
+ XCTAssertEqual(record.isUnverifiedAsk, true)
+ let unread = try XCTUnwrap(record.recovery?.response)
+ sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)]
+ chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 50000)]
+ sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])]
+ chain.tip = 1001
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.generation, 1, "funding adopted a failed-only build without leaf-1's signed steps")
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertNil(record.isUnverifiedAsk)
+ }
+
+ /// An unverified rebuild's fee ask ends once every step reads again and nothing was paid: the exit runs again.
+ func testAnUnverifiedRebuildsFeeAskEndsOnceItsStepsReadAgain() async throws {
+ var (record, readable, _) = try await unverifiedAsk()
+ let asked = record
+ sdk.checks = [CheckRecoverFundsResponse(recovery: readable, verdict: .valid)]
+ chain.tip = 1001
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.phase, .running, "the fee ask stayed after every step read again")
+ XCTAssertNil(record.isUnverifiedAsk)
+ XCTAssertTrue(SparkExitManager.isFundingAskWithdrawn(record, previous: asked))
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"])
+ }
+
+ /// The steps of leaf-1 as the chain reads them: n2 unread, waiting for its timelock, then ready.
+ private func leafOneSteps(_ status: ExitTransactionStatus) -> RecoverFundsResponse {
+ SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 900)),
+ SparkExitFixtures.tx("n2", dependsOn: ["n1"], status: status),
+ ])
+ }
+
+ /// A prepare that skips leaf-1, as the SDK does while it cannot read leaf-1's chain.
+ private var skipsUnreadLeaf: PrepareRecoverFundsResponse {
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: [])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ return prepared
+ }
+
+ /// Funding an unverified rebuild's ask resumes a rebuild that waits for the unread leaf: the valid generation keeps
+ /// no retry clock from it, so a week on the exit still sends leaf-1's signed step once it matures.
+ func testAFundedUnverifiedRebuildThatWaitsForTheUnreadLeafLeavesNoStaleRetryClock() async throws {
+ let unread = leafOneSteps(.unverified)
+ var record = SparkExitFixtures.record(phase: .running)
+ record.adopt(unread, at: clock)
+ sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)]
+ sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 30000, docsUrl: "")
+ record = try await driver().tick(record)
+ guard case .needsFunding = record.phase else { return XCTFail("the unverified rebuild asked for no funding: \(record.phase)") }
+ XCTAssertEqual(record.isUnverifiedAsk, true)
+
+ sdk.recoverError = nil
+ sdk.preparedResponse = skipsUnreadLeaf
+ sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)]
+ chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 100_000)]
+ chain.tip = 1001
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 1)
+ XCTAssertNil(record.retrySince, "the funded unverified rebuild saved a retry clock at '\(record.retrySince.map(String.init) ?? "")'")
+
+ try await runsUntilLeafOneMatures(&record)
+ }
+
+ /// A check's unverified rebuild that waits for the unread leaf keeps the valid generation without a retry clock.
+ func testAnUnverifiedRebuildThatWaitsForTheUnreadLeafLeavesNoStaleRetryClock() async throws {
+ let unread = leafOneSteps(.unverified)
+ var record = SparkExitFixtures.record(phase: .running)
+ record.adopt(unread, at: clock)
+ sdk.checks = [CheckRecoverFundsResponse(recovery: unread, verdict: .valid)]
+ sdk.preparedResponse = skipsUnreadLeaf
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 1)
+ XCTAssertNil(record.retrySince, "the unverified rebuild saved a retry clock at '\(record.retrySince.map(String.init) ?? "")'")
+
+ try await runsUntilLeafOneMatures(&record)
+ }
+
+ /// A paid fee bump whose build skips leaf-1 as unread and builds only failed cooperative leaves keeps the valid
+ /// generation: adopted, the build would drop leaf-1's signed refund n2.
+ func testAPaidFeeBumpKeepsTheValidGenerationOverABuildThatDropsLeafOnesRefund() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "coop-1"]
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)
+ record.adopt(leafOneSteps(.unverified), at: clock)
+ record.applyFeeBump(rate: 10, extraSats: 5680, paymentTxid: "bump")
+ chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("bump", value: 5680)]
+ let failure = CooperativeRecoveryFailure(leafId: "coop-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down"))
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["coop-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])]
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.generation, 1, "the paid bump adopted a recovery without leaf-1's refund n2")
+ XCTAssertTrue(saved.allSatisfy { $0.generation == 1 }, "a killed run would resume the recovery without leaf-1")
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertFalse(record.isRebuilding)
+ XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"])
+ }
+
+ /// A fee bump with nothing to pay whose build skips leaf-1 as unread and builds leaf-2 keeps the valid generation,
+ /// which goes on being checked and sends leaf-1's refund n2 once the chain reads it.
+ func testAFeeBumpWithNothingToPayKeepsTheValidGenerationOverABuildThatDropsLeafOnesRefund() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "leaf-2"]
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)
+ record.adopt(leafOneSteps(.unverified), at: clock)
+ record.applyFeeBump(rate: 10, extraSats: 0, paymentTxid: nil)
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ // The second build is the bump's again, at the next block.
+ sdk.builds = Array(repeating: SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")]), count: 2)
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.generation, 1, "the bump adopted a recovery without leaf-1's refund n2")
+ XCTAssertTrue(saved.allSatisfy { $0.generation == 1 }, "a killed run would resume the recovery without leaf-1")
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertFalse(record.isRebuilding, "the valid generation is no longer checked or sent")
+
+ sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.ready), verdict: .valid)]
+ chain.tip = 1001
+ record = try await driver().tick(record)
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's refund n2 was not sent")
+ }
+
+ /// A fee bump kept over a waiting leaf stays to be built: once a block it rebuilds at the bumped rate, the valid
+ /// generation sending leaf-1's refund n2 meanwhile, and once leaf-1 reads that rebuild is adopted.
+ func testAFeeBumpKeptOverAWaitingLeafBuildsAtTheBumpedRateOnceTheLeafReads() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "leaf-2"]
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: "f0", vout: 1, valueSats: 20000)
+ record.adopt(leafOneSteps(.unverified), at: clock)
+ record.applyFeeBump(rate: 10, extraSats: 0, paymentTxid: nil)
+ var skipsLeafOne = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"])
+ skipsLeafOne.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = skipsLeafOne
+ sdk.builds = Array(repeating: SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")]), count: 2)
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 1)
+
+ sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.ready), verdict: .valid)]
+ chain.tip = 1001
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 1, "the rebuild that drops leaf-1's refund n2 was adopted")
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's refund n2 was not sent while the bump waits")
+
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-1", "leaf-2"])
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("b1"), SparkExitFixtures.tx("b2")])]
+ let preparesBefore = sdk.prepareRequests.count
+ chain.tip = 1002
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.generation, 2, "the bump was never built at its rate")
+ XCTAssertEqual(sdk.prepareRequests.dropFirst(preparesBefore).map(\.feeRateSatPerVbyte), [10])
+ XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["b1", "b2"])
+ }
+
+ /// A fee chain that lost a race beside a leaf the rebuild leaves waiting: the valid generation stays, so leaf-1's
+ /// signed steps are not dropped, its rebuilds stop once stalled, and once leaf-1's step confirms the exit rebuilds
+ /// without the dead chain.
+ func testADeadFeeChainBesideAWaitingLeafRebuildsOnceTheLeafsStepConfirms() async throws {
+ let funding = String(repeating: "f0", count: 32)
+ let ourChild = SparkExitFixtures.rawTx(spending: [funding])
+ let chained = try SparkExitFixtures.rawTx(spending: [XCTUnwrap(SparkExitTxid.txid(hex: ourChild))])
+ let bothLeaves = [
+ RecoverFundsLeaf(leafId: "leaf-1", valueSats: 100_000, method: .unilateral),
+ RecoverFundsLeaf(leafId: "leaf-2", valueSats: 100_000, method: .unilateral),
+ ]
+ func steps(_ l1: ExitTransactionStatus, n1: ExitTransactionStatus = .confirmed(blockHeight: 10)) -> RecoverFundsResponse {
+ var recovery = SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", cpfp: ourChild, status: n1),
+ SparkExitFixtures.tx("n2", cpfp: chained, dependsOn: ["n1"]),
+ SparkExitFixtures.tx("l1", status: l1),
+ ])
+ recovery.leaves = bothLeaves
+ return recovery
+ }
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "leaf-2"]
+ record.funding[0] = .init(index: 0, address: "bcrt1qfunding", pubkey: "02ab", txid: funding, vout: 0, valueSats: 20000)
+ record.adopt(steps(.unverified, n1: .ready), at: Date(timeIntervalSince1970: 1_700_000_050))
+ chain.confirmedTxids = ["n1"]
+ var skipsLeafOne = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"])
+ skipsLeafOne.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = skipsLeafOne
+ let fresh = SparkExitFixtures.rawTx(spending: [funding])
+ var rebuilt = SparkExitFixtures.recovery([
+ SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 10)),
+ SparkExitFixtures.tx("n2", cpfp: fresh, dependsOn: ["n1"]),
+ ])
+ rebuilt.leaves = [bothLeaves[1]]
+ sdk.builds = Array(repeating: rebuilt, count: SparkExitDriver.stallRebuildsToSurface + 1)
+
+ for i in 0 ... SparkExitDriver.stallRebuildsToSurface {
+ sdk.checks = [CheckRecoverFundsResponse(recovery: steps(.unverified), verdict: .valid)]
+ chain.tip = 1000 + UInt32(i)
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 1, "a rebuild dropped leaf-1's signed step l1")
+ }
+ XCTAssertTrue(record.isStalled)
+ let rebuildsWhileStalled = sdk.recoverRequests.count
+
+ // leaf-1 reads again and its step confirms: the exit moves, so the dead chain is rebuilt over.
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2"])
+ chain.confirmedTxids.insert("l1")
+ sdk.checks = [CheckRecoverFundsResponse(recovery: steps(.confirmed(blockHeight: 1004)), verdict: .valid)]
+ chain.tip = 1005
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(sdk.recoverRequests.count, rebuildsWhileStalled + 1)
+ XCTAssertEqual(record.generation, 2, "the dead fee chain was never rebuilt over")
+ XCTAssertEqual(chain.broadcasts.last?.cpfpTxHex, fresh)
+ }
+
+ /// An unverified rebuild that cannot exit leaf-3 leaves it behind and rebuilds on the next tick: with leaf-1 still
+ /// unread, that rebuild keeps the valid generation and leaf-1's refund n2.
+ func testARebuildAfterAnUnverifiedRebuildLeftALeafBehindKeepsLeafOnesRefund() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "leaf-2", "leaf-3"]
+ record.adopt(leafOneSteps(.unverified), at: clock)
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2", "leaf-3"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.recoverErrors = [SdkError.SparkError("Leaf leaf-3 cannot be exited: its refund cannot pay its own fee at this fee rate")]
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("m1", cpfp: "03c1")])]
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-3"])
+ chain.tip = 1001
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.generation, 1, "the rebuild adopted a recovery without leaf-1's refund n2")
+ XCTAssertTrue(saved.allSatisfy { $0.generation == 1 }, "a killed run would resume the recovery without leaf-1")
+ XCTAssertFalse(record.isRebuilding, "the valid generation is no longer checked or sent")
+ XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"])
+ }
+
+ /// An unverified rebuild that cannot exit leaf-3 leaves it behind and keeps the valid generation running, with
+ /// leaf-1 still unread: leaf-1's refund n2 stays and nothing rebuilds without the guard.
+ func testAnUnverifiedRebuildThatCannotExitALeafKeepsTheValidGenerationRunning() async throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafIds = ["leaf-1", "leaf-2", "leaf-3"]
+ record.adopt(leafOneSteps(.unverified), at: clock)
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-2", "leaf-3"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-1", valueSats: 100_000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.recoverErrors = [SdkError.SparkError("Leaf leaf-3 cannot be exited: its refund cannot pay its own fee at this fee rate")]
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.generation, 1)
+ XCTAssertFalse(record.isRebuilding, "the next tick rebuilds without the valid generation's guard")
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertEqual(record.recovery?.response.transactions.map(\.txid), ["n1", "n2"], "leaf-1's refund n2 is gone")
+ XCTAssertEqual(record.leftBehindLeaves?.map(\.leafId), ["leaf-3"])
+ }
+
+ /// Funding an unverified rebuild's ask resumes a rebuild that waits for the unread leaf once a block, as the
+ /// check's rebuild does: a second tick at the same tip prepares nothing.
+ func testAFundedUnverifiedRebuildWaitingForTheUnreadLeafPreparesOnceABlock() async throws {
+ let unread = leafOneSteps(.unverified)
+ var record = SparkExitFixtures.record(phase: .running)
+ record.adopt(unread, at: clock)
+ sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 30000, docsUrl: "")
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.isUnverifiedAsk, true)
+ sdk.recoverError = nil
+ sdk.preparedResponse = skipsUnreadLeaf
+ chain.utxosByAddress[record.funding[0].address] = [SparkExitFixtures.utxo(String(repeating: "f", count: 64), value: 100_000)]
+
+ chain.tip = 1001
+ record = try await driver().tick(record)
+ XCTAssertEqual(sdk.prepareRequests.count, 2)
+ record = try await driver().tick(record)
+ XCTAssertEqual(sdk.prepareRequests.count, 2, "the funded resume prepared again at the same tip")
+
+ chain.tip = 1002
+ record = try await driver().tick(record)
+ XCTAssertEqual(sdk.prepareRequests.count, 3, "the funded resume stopped trying at the next block")
+ XCTAssertEqual(record.isUnverifiedAsk, true)
+ }
+
+ /// Leaf-1 reads as waiting for its timelock, unread again a week later, then ready: n2 goes out.
+ private func runsUntilLeafOneMatures(_ record: inout SparkExitRecord) async throws {
+ sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.waitingForTimelock(spendableAtHeight: 3000)), verdict: .valid)]
+ chain.tip = 1002
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .running)
+ XCTAssertNil(record.retrySince, "the exit runs with a stale retry clock '\(record.retrySince.map(String.init) ?? "")'")
+
+ sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.unverified), verdict: .valid)]
+ chain.tip = 2100
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .running, "the exit ended before leaf-1's signed step n2 was sent")
+ XCTAssertEqual(record.generation, 1)
+
+ sdk.checks = [CheckRecoverFundsResponse(recovery: leafOneSteps(.ready), verdict: .valid)]
+ chain.tip = 3000
+ record = try await driver().tick(record)
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["n2"], "leaf-1's matured step n2 was not sent")
+ }
+
+ /// R2: while a unilateral step is ready, a rebuild would conflict with it, so nothing rebuilds; a cooperative step
+ /// the SDK could not read is still sent.
+ func testAReadyStepHoldsTheUnverifiedRebuildAndCooperativeStepsAreStillSent() async throws {
+ 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)
+ }
+
+ /// 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)
+ }
+
+ /// R3 with a leaf that builds: one skipped while it may still move is kept, and tried again before the exit ends.
+ func testAPartialBuildRetriesTheSkippedLeafBeforeTheExitEnds() async throws {
+ var record = rebuildingRecord()
+ record.leafIds = ["leaf-1", "leaf-2"]
+ var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])]
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.generation, 2)
+
+ let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))])
+ sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 3)
+ sdk.preparedResponse.leaves = []
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .running, "leaf-2 may still move")
+ XCTAssertTrue(record.isWaitingForLeaves)
+ XCTAssertFalse(SparkExitManager.isTransferFinished(record, isSweepSeen: true), "leaf-2 is still in transfer")
+ guard case let .specific(leafIds) = sdk.prepareRequests.last?.selection else { return XCTFail("Expected a specific selection") }
+ XCTAssertEqual(leafIds, ["leaf-2"])
+
+ record = try await driver(withSdk: false).tick(record)
+ XCTAssertEqual(record.phase, .running, "the exit waits for the SDK to retry leaf-2")
+
+ chain.tip = 1006
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"])
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])]
+ record = try await driver().tick(record)
+ XCTAssertNil(record.retrySince)
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n2"])
+
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .done)
+ }
+
+ /// A skipped leaf whose retry needs more fee funding asks for it, and is built on its own once it arrives.
+ func testASkippedLeafWhoseRetryNeedsMoreFeesAsksForThem() async throws {
+ var record = rebuildingRecord()
+ record.leafIds = ["leaf-1", "leaf-2"]
+ var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])]
+ record = try await driver().tick(record)
+
+ let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))])
+ sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 2)
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"])
+ sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "")
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136))
+ XCTAssertEqual(record.waitingLeafIds, ["leaf-2"])
+
+ sdk.recoverError = nil
+ chain.utxosByAddress["bcrt1qfunding"] = [SparkExitFixtures.utxo("f9", value: 3136)]
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n2")])]
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.phase, .running)
+ guard case let .specific(leafIds) = sdk.prepareRequests.last?.selection else { return XCTFail("Expected a specific selection") }
+ XCTAssertEqual(leafIds, ["leaf-2"])
+ XCTAssertEqual(chain.broadcasts.map(\.txid), ["n1", "n2"])
+ }
+
+ /// A skipped leaf's fee ask nobody pays is let go after the same week as its retries, and the exit ends.
+ func testASkippedLeafsFeeAskNobodyPaysIsLetGoAfterAWeek() async throws {
+ var record = rebuildingRecord()
+ record.leafIds = ["leaf-1", "leaf-2"]
+ var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])]
+ record = try await driver().tick(record)
+
+ let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))])
+ sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)]
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"])
+ sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "")
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136))
+
+ chain.tip = 999 + SparkExitDriver.releaseBlocks
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136))
+ chain.tip = 1000 + SparkExitDriver.releaseBlocks
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .done)
+ XCTAssertNil(record.waitingLeafIds)
+ }
+
+ /// A partial build whose only built leaf fails cooperatively still keeps the leaf it skipped.
+ func testAPartialBuildOfOnlyFailedLeavesKeepsTheSkippedLeaf() async throws {
+ var record = rebuildingRecord()
+ record.leafIds = ["leaf-1", "leaf-2"]
+ var prepared = SparkExitFixtures.prepared(funding: nil, leaves: ["leaf-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ let failure = CooperativeRecoveryFailure(leafId: "leaf-1", outputTxid: "o", outputVout: 0, error: .operatorsUnavailable(message: "down"))
+ sdk.builds = [SparkExitFixtures.recovery([], failed: [failure])]
+
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.waitingLeafIds, ["leaf-2"])
+ }
+
+ /// A waiting leaf let go after a week adds to the leaves that stayed on Spark, the quote's skips included.
+ func testAWaitingLeafLetGoAddsToTheLeavesThatStayedOnSpark() async throws {
+ var record = rebuildingRecord()
+ record.leafIds = ["leaf-1", "leaf-2"]
+ record.skippedLeafCount = 13
+ var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .fundsNotFound)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])]
+ record = try await driver().tick(record)
+
+ let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))])
+ sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 2)
+ sdk.preparedResponse.leaves = []
+ record = try await driver().tick(record)
+ chain.tip = 1000 + SparkExitDriver.releaseBlocks
+ record = try await driver().tick(record)
+
+ XCTAssertEqual(record.phase, .done)
+ XCTAssertEqual(record.skippedLeafCount, 14)
+ }
+
+ private func fundingAskForASkippedLeaf() async throws -> SparkExitRecord {
+ var record = rebuildingRecord()
+ record.leafIds = ["leaf-1", "leaf-2"]
+ record.skippedLeafCount = 3
+ var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .unverified)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])]
+ record = try await driver().tick(record)
+ let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))])
+ sdk.checks = [CheckRecoverFundsResponse(recovery: landed, verdict: .done)]
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves: ["leaf-2"])
+ sdk.recoverError = SdkError.InsufficientCpfpFunds(requiredSats: 3000, docsUrl: "")
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136))
+ return record
+ }
+
+ /// The week's let-go of an unpaid fee ask needs only the chain: it comes while the SDK can't connect too.
+ func testAnUnpaidFeeAskIsLetGoWithoutTheSdk() async throws {
+ var record = try await fundingAskForASkippedLeaf()
+ chain.tip = 1000 + SparkExitDriver.releaseBlocks
+ record = try await driver(withSdk: false).tick(record)
+ XCTAssertEqual(record.phase, .done)
+ }
+
+ /// A let-go tick that can't read the funding address changes nothing; the next one lets go once.
+ func testAnUnpaidFeeAskLetGoIsAllOrNothing() async throws {
+ var record = try await fundingAskForASkippedLeaf()
+ chain.tip = 1000 + SparkExitDriver.releaseBlocks
+ chain.isUtxosUnreadable = true
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .needsFunding(requiredSats: 3136))
+ XCTAssertEqual(record.waitingLeafIds, ["leaf-2"])
+ XCTAssertEqual(record.skippedLeafCount, 3)
+
+ chain.isUtxosUnreadable = false
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .done)
+ XCTAssertEqual(record.skippedLeafCount, 4)
+ }
+
+ /// A skipped leaf still unverified after about a week is let go, and the exit ends.
+ func testAPartialBuildLetsTheSkippedLeafGoAfterAWeek() async throws {
+ var record = rebuildingRecord()
+ record.leafIds = ["leaf-1", "leaf-2"]
+ var prepared = SparkExitFixtures.prepared(leaves: ["leaf-1"])
+ prepared.skipped = [SkippedLeaf(leafId: "leaf-2", valueSats: 50000, reason: .fundsNotFound)]
+ sdk.preparedResponse = prepared
+ sdk.builds = [SparkExitFixtures.recovery([SparkExitFixtures.tx("n1")])]
+ record = try await driver().tick(record)
+
+ let landed = SparkExitFixtures.recovery([SparkExitFixtures.tx("n1", status: .confirmed(blockHeight: 990))])
+ sdk.checks = Array(repeating: CheckRecoverFundsResponse(recovery: landed, verdict: .done), count: 2)
+ sdk.preparedResponse.leaves = []
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .running)
+
+ chain.tip = 1000 + SparkExitDriver.releaseBlocks
+ record = try await driver().tick(record)
+ XCTAssertEqual(record.phase, .done)
+ XCTAssertNil(record.retrySince)
+ }
+
+ func testIsRetryDueDoublesUpToADay() {
+ XCTAssertFalse(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 105))
+ XCTAssertTrue(SparkExitDriver.isRetryDue(since: 100, retriedAt: 100, tip: 106))
+ 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))
+
+ 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
+ }
+
+ /// A tick that saved progress and then threw (a Redo saves the rebuild flag, then the rebuild's prepare fails): the
+ /// run hands back the record on disk, never the copy it started with.
+ func testAFailedTickHandsBackTheRecordItSaved() async throws {
+ let recovery = try savedRunningExit([SparkExitFixtures.tx("n1", cpfp: "03c", status: .waitingForDependencies)])
+ sdk.checks = [CheckRecoverFundsResponse(recovery: recovery, verdict: .redo(reason: .onChainStateDiverged))]
+ sdk.prepareErrors = [SdkError.Generic("operators unreachable")]
+
+ let outcome = await backgroundRun().run()
+
+ XCTAssertEqual(outcome.record?.isRebuilding, true)
+ XCTAssertEqual(try store.load()?.isRebuilding, true)
+ }
+
+ func testColdStartSendsStoredStepsThenChecksWithTheSdkOnly() async throws {
+ let recovery = try savedRunningExit([SparkExitFixtures.tx("n1", cpfp: "03c")])
+ sdk.checks = [CheckRecoverFundsResponse(recovery: recovery, verdict: .valid)]
+
+ 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 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.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.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() {
+ 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
+ )]
+ // 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, 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)),
+ ])
+ 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)
+ 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 testFundingTopUpTakesTheFewestConfirmedPaymentsThatCover() {
+ let utxos = [
+ SparkExitFixtures.utxo("a", value: 999),
+ SparkExitFixtures.utxo("pending", value: 50000, confirmed: false),
+ SparkExitFixtures.utxo("b", value: 1000),
+ ]
+
+ XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999)?.map(\.txid), ["b", "a"])
+ XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1000)?.map(\.txid), ["b"])
+ XCTAssertNil(SparkExitMath.fundingTopUp(utxos, requiredSats: 2000))
+ XCTAssertNil(SparkExitMath.fundingTopUp([], requiredSats: 0))
+ // Each payment beyond the first is one more input to pay for: 68 vB at the exit's rate.
+ XCTAssertNil(SparkExitMath.fundingTopUp(utxos, requiredSats: 1999, inputFeeSats: 136))
+ XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 1863, inputFeeSats: 136)?.map(\.txid), ["b", "a"])
+ XCTAssertEqual(SparkExitMath.inputFeeSats(SparkExitFixtures.record()), 136)
+ }
+
+ /// Largest first, and never a coin worth no more than its own input fee: that one stays for the leftover sweep.
+ func testFundingTopUpLeavesUneconomicalCoins() {
+ let utxos = [
+ SparkExitFixtures.utxo("dust", value: 136),
+ SparkExitFixtures.utxo("small", value: 500),
+ SparkExitFixtures.utxo("big", value: 3000),
+ SparkExitFixtures.utxo("mid", value: 1500),
+ ]
+
+ XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 2500, inputFeeSats: 136)?.map(\.txid), ["big"])
+ XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 4300, inputFeeSats: 136)?.map(\.txid), ["big", "mid"])
+ XCTAssertEqual(SparkExitMath.fundingTopUp(utxos, requiredSats: 4700, inputFeeSats: 136)?.map(\.txid), ["big", "mid", "small"])
+ // A coin worth only its input fee never counts.
+ XCTAssertNil(SparkExitMath.fundingTopUp([utxos[0]], requiredSats: 1, inputFeeSats: 136))
+ }
+
+ func testShowsTheSparkBalanceBeyondTheExitsLeaves() {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafTotalSats = 17097
+
+ 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))
+ }
+
+ /// The hosted-regtest run: 14,167 sats stay on Spark, and the exit's 100,000 sats of leaves are published, so they
+ /// left the balance.
+ func testSpendingKeepsWhatStaysOnSparkOnceTheExitsPublishedLeavesLeftItsBalance() {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.leafTotalSats = 100_000
+ record.outsideSats = 14167
+
+ let outside = SparkExitMath.outsideExitSats(sparkBalanceSats: 14167, record: record)
+
+ XCTAssertEqual(outside, 14167)
+ XCTAssertEqual(WalletViewModel.spendingSparkSats(isExiting: true, balanceSats: 14167, outsideExitSats: outside), 14167)
+ // While the leaves still count in the balance, a late claim beyond them still shows.
+ XCTAssertEqual(SparkExitMath.outsideExitSats(sparkBalanceSats: 120_000, record: record), 20000)
+ }
+
+ /// A failed exit claims only what Spark still holds beyond its leaves, never the leaves it lost.
+ func testFailedExitNamesWhatStaysOnSpark() {
+ var record = SparkExitFixtures.record(phase: .failed(reason: "Claimed by an older state"))
+ 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 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() {
+ var record = SparkExitFixtures.record(phase: .awaitingFunding(requiredSats: 9000))
+
+ XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .ask(sats: 9000))
+ // 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: 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)]
+ record.phase = .needsFunding(requiredSats: 12000)
+ XCTAssertEqual(SparkExitMath.fundingState(record, utxos: []), .ask(sats: 3000))
+
+ record.phase = .running
+ 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))
+ 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: 2636, 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"))
+ }
+
+ /// A run that cannot read the tip guesses each timelock's reminder about 6 hours out: the guess never takes the
+ /// date of the reminder at maturity, which a run with the tip plans.
+ func testARunWithoutTheTipNeverTakesATimelocksReminderEarly() throws {
+ var record = SparkExitFixtures.record(phase: .running)
+ record.adopt(SparkExitFixtures.recovery([SparkExitFixtures.tx("r1", kind: .refund, status: .waitingForTimelock(spendableAtHeight: 3000))]))
+ let now = Date(timeIntervalSince1970: 1_700_000_000)
+ let id = "\(record.generation)-h3000"
+ let guessed = try XCTUnwrap(SparkExitMath.reminders(record, tip: nil, now: now, network: .bitcoin).first { $0.id == id })
+ let known = try XCTUnwrap(SparkExitMath.reminders(record, tip: 2000, now: now, network: .bitcoin).first { $0.id == id })
+
+ let first = SparkExitManager.reminderDate(guessed, rememberedAt: nil, now: now)
+ let second = SparkExitManager.reminderDate(known, rememberedAt: first.remembered, now: now)
+ let third = SparkExitManager.reminderDate(guessed, rememberedAt: second.remembered, now: now)
+
+ XCTAssertNil(first.remembered, "a date guessed without the tip was kept")
+ XCTAssertEqual(second.fireAt, known.fireAt, "the reminder at maturity was taken early")
+ XCTAssertEqual(third.fireAt, known.fireAt)
+ }
+
+ /// With the operators down an older state's refund can take a leaf 100 blocks after ours matures, and the
+ /// watchtower may publish after 50: reminders land well inside the smaller window.
+ func testRemindersLandWellInsideTheDeadline() {
+ 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, readsBitkitChain: true))
+ XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10499, fundingSats: 10000, paymentFeeSats: 500, readsBitkitChain: true))
+ XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10000, fundingSats: 0, paymentFeeSats: 0, readsBitkitChain: true))
+ }
+
+ /// A Savings payment goes out on Bitkit's chain: while Spark reads another chain, such as its hosted regtest, it
+ /// would never fund the exit.
+ func testSavingsCannotFundAnExitOnAChainSparkDoesNotRead() {
+ XCTAssertFalse(SparkExitMath.canFundFromSavings(spendableSats: 10500, fundingSats: 10000, paymentFeeSats: 500, readsBitkitChain: false))
+ }
+
+ /// A higher fee's extra funding goes out from savings on Bitkit's chain too: off while Spark reads another chain.
+ /// A rate with nothing to pay needs no payment.
+ func testSavingsPaysAHigherFeeOnlyOnAChainSparkReads() {
+ XCTAssertTrue(SparkExitMath.canPayFeeBump(totalSats: 5980, spendableSats: 5980, readsBitkitChain: true))
+ XCTAssertFalse(SparkExitMath.canPayFeeBump(totalSats: 5980, spendableSats: 5979, readsBitkitChain: true))
+ XCTAssertFalse(SparkExitMath.canPayFeeBump(totalSats: nil, spendableSats: 5980, readsBitkitChain: true), "not priced")
+ XCTAssertFalse(SparkExitMath.canPayFeeBump(totalSats: 5980, spendableSats: 5980, readsBitkitChain: false))
+ XCTAssertTrue(SparkExitMath.canPayFeeBump(totalSats: 0, spendableSats: 0, readsBitkitChain: false))
+ }
+
+ func testEtaUsesTheNetworksBlockTime() {
+ XCTAssertEqual(SparkExitMath.secondsUntil(height: 110, tip: 100, network: .bitcoin), 6000)
+ XCTAssertEqual(SparkExitMath.secondsUntil(height: 110, tip: 100, network: .regtest), 300)
+ 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")
+ }
+
+ /// 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 = [
+ ("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)") }
+ }
+
+ /// Esplora hosts (bitkit.to, blockstream.info) answer a step that already confirmed in Core's newer plain-text form.
+ func testReadsAStepAlreadyInTheUtxoSetAsAccepted() {
+ let text = "sendrawtransaction RPC error -27: Transaction outputs already in utxo set"
+ XCTAssertEqual(SparkExitBroadcastClassifier.single(status: 400, body: Data(text.utf8)), .accepted)
+ XCTAssertEqual(SparkExitBroadcastClassifier.classify(error: "sendrawtransaction RPC error -27: unknown"), .accepted)
+ }
+
+ func testClassifiesNodeAnswers() {
+ XCTAssertEqual(SparkExitBroadcastClassifier.single(status: 400, body: Data(#"sendrawtransaction RPC error: {"code":-27}"#.utf8)), .accepted)
+ XCTAssertEqual(
+ 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 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 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, regtestChainServiceUrl: nil).readHosts.map(\.host),
+ ["regtest-mempool.us-west-2.sparkinfra.net"]
+ )
+ }
+
+ /// On regtest the exit reads and broadcasts on the chain Spark connects with when `SPARK_CHAIN_SERVICE_URL` names one.
+ func testTheRegtestExitUsesTheConfiguredSparkChain() {
+ let chain = EsploraSparkExitChain.network(.regtest, regtestChainServiceUrl: "http://localhost:3002")
+ XCTAssertEqual(chain.readHosts.map(\.absoluteString), ["http://localhost:3002"])
+ XCTAssertEqual(chain.broadcastHosts.map(\.url.absoluteString), ["http://localhost:3002"])
+ }
+
+ func testSendsAPackageToBitkitElectrsWithoutTheV1Path() async {
+ let http = FakeHTTP()
+ http.responses = [("bitkit.to/electrs/txs/package", 200, #"{"package_msg":"success"}"#)]
+ 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 = [
+ ("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))
+ })
+ }
+}
diff --git a/BitkitTests/SparkPaymentSafetyTests.swift b/BitkitTests/SparkPaymentSafetyTests.swift
index af6b93ae2..c900a9a92 100644
--- a/BitkitTests/SparkPaymentSafetyTests.swift
+++ b/BitkitTests/SparkPaymentSafetyTests.swift
@@ -381,3 +381,82 @@ extension SparkPaymentSafetyTests {
)
}
}
+
+// MARK: - Exit hold
+
+extension SparkPaymentSafetyTests {
+ func testAnExitStartingWhileASendPreparesSendsNothing() async {
+ var isExiting = false
+ let spends = SparkSpends { isExiting }
+ var sent = false
+
+ // The exit starts while the send prepares, after any check the send made before it.
+ isExiting = true
+ do {
+ try await spends.run { sent = true }
+ XCTFail("a send went out once an exit held Spark")
+ } catch {
+ XCTAssertTrue(error is Bitkit.ChannelFreeRailSendRefused)
+ }
+
+ XCTAssertFalse(sent)
+ XCTAssertEqual(spends.inFlight, 0)
+ }
+
+ func testAnExitStartWaitsForASendAlreadyPastItsExitCheck() async throws {
+ var isExiting = false
+ let spends = SparkSpends { isExiting }
+ var release: CheckedContinuation?
+ let send = Task { @MainActor in
+ try await spends.run {
+ await withCheckedContinuation { release = $0 }
+ }
+ }
+ while release == nil {
+ await Task.yield()
+ }
+
+ isExiting = true
+ var isIdle = false
+ let idle = Task { @MainActor in
+ try await spends.holdForExit(timeout: 120)
+ isIdle = true
+ }
+ try await Task.sleep(nanoseconds: 300_000_000)
+
+ XCTAssertFalse(isIdle, "the exit start went on while a send past its check still spent leaves")
+ release?.resume()
+ try await send.value
+ try await idle.value
+ XCTAssertTrue(isIdle)
+ }
+
+ /// A send still sending after the wait refuses the exit start before its record exists; Spark is let go, so the
+ /// user can start again once the send ended.
+ func testAnExitStartIsRefusedWhileASendStaysActivePastTheWait() async throws {
+ let spends = SparkSpends { false }
+ var release: CheckedContinuation?
+ let send = Task { @MainActor in
+ try await spends.run {
+ await withCheckedContinuation { release = $0 }
+ }
+ }
+ while release == nil {
+ await Task.yield()
+ }
+
+ var isRefused = false
+ do {
+ try await spends.holdForExit(timeout: 0.3)
+ } catch {
+ isRefused = true
+ }
+
+ XCTAssertTrue(isRefused, "the exit start went on while a send could still spend its leaves")
+ XCTAssertFalse(spends.isHeld, "Spark stayed held after the refused start")
+ release?.resume()
+ try await send.value
+ try await spends.holdForExit(timeout: 1)
+ XCTAssertTrue(spends.isHeld)
+ }
+}
diff --git a/BitkitTests/SparkTransferActivityTests.swift b/BitkitTests/SparkTransferActivityTests.swift
index e12ed5820..74142b411 100644
--- a/BitkitTests/SparkTransferActivityTests.swift
+++ b/BitkitTests/SparkTransferActivityTests.swift
@@ -59,18 +59,31 @@ final class SparkTransferActivityTests: XCTestCase {
func testAZeroReadAfterAFailedWithdrawalOrSyncKeepsTheLastKnownBalance() {
XCTAssertEqual(
- SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: false),
- 9000,
+ SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: false, isExiting: false), 9000,
"The balance read 0 while stale"
)
- XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false, withdrewAll: false))
- XCTAssertNil(SparkManager.keptBalanceSats(read: 4000, lastShown: 9000, isStale: true, withdrewAll: false))
- XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 0, isStale: true, withdrewAll: false))
+ XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: false, withdrewAll: false, isExiting: false))
+ XCTAssertNil(SparkManager.keptBalanceSats(read: 4000, lastShown: 9000, isStale: true, withdrewAll: false, isExiting: false))
+ XCTAssertNil(SparkManager.keptBalanceSats(read: 0, lastShown: 0, isStale: true, withdrewAll: false, isExiting: false))
+ }
+
+ func testAZeroReadDuringAnEmergencyExitKeepsNoBalance() {
+ XCTAssertNil(
+ SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: false, isExiting: true),
+ "The balance the exit moves to savings stayed shown in Spending"
+ )
+ }
+
+ func testACancelledExitShowsTheBalanceKeptBeforeIt() {
+ XCTAssertEqual(SparkManager.keptBalanceSats(afterExitKept: 10000, read: 0, isCancel: true), 10000, "The cancel dropped the kept balance")
+ XCTAssertNil(SparkManager.keptBalanceSats(afterExitKept: 10000, read: 0, isCancel: false), "The exit's balance came back after it ended")
+ XCTAssertNil(SparkManager.keptBalanceSats(afterExitKept: 10000, read: 4000, isCancel: true))
+ XCTAssertNil(SparkManager.keptBalanceSats(afterExitKept: nil, read: 0, isCancel: true))
}
func testAZeroReadAfterASuccessfulWithdrawalDropsTheLastShownBalance() {
XCTAssertNil(
- SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: true),
+ SparkManager.keptBalanceSats(read: 0, lastShown: 9000, isStale: true, withdrewAll: true, isExiting: false),
"The withdrawn balance stayed shown while Spark's last sync had failed"
)
}
diff --git a/changelog.d/next/1217.added.md b/changelog.d/next/1217.added.md
new file mode 100644
index 000000000..ebd705d08
--- /dev/null
+++ b/changelog.d/next/1217.added.md
@@ -0,0 +1 @@
+Add Emergency exit, which moves your Spark balance to savings on-chain without the Spark operators.
diff --git a/journeys/README.md b/journeys/README.md
index 7d652dd10..e340b0dc8 100644
--- a/journeys/README.md
+++ b/journeys/README.md
@@ -217,6 +217,7 @@ journey PR, which is what made this file conflict on every merge.
| Controlled USDT quotes, provider history, funded submission and recovery | an explicitly configured local USDT gateway/RPC/bundler fixture with owned test accounts and funds; absent from the standard journey environment. Report these steps as blocked unless that setup and transfer authorization are supplied — [USDT QA](../Docs/USDT-QA.md) |
| On-chain funds and blocks on regtest | `../bitkit-android/lsp` deposit and mine, borrowed from the sibling Android checkout until #694 lands an iOS copy — [Backend preconditions](#backend-preconditions) |
| Spending through Spark (transfer to spending, Spark Lightning payments) | a Debug build, which connects Spark to its regtest with no key. Transfers from regtest Savings also need the build launched with `SPARK_CHAIN_SERVICE_URL` set to the regtest's Esplora URL, as Spark's default regtest is its own chain; without it they show the Transfer Unavailable sheet. Spark's claim of a deposit (Instant or after 3 confirmations) depends on the provider's quote, so the setting-up states are covered by previews and unit tests — [transfer](transfer/spark-transfer-to-spending.xml) |
+| Spark emergency exit up to the swipe | a Debug build with Spark connected on its regtest and a Spark balance from Lightspark's regtest faucet; the states after the swipe take days of blocks, so they are covered by unit tests — [transfer](transfer/spark-emergency-exit.xml) |
| Lightning channels | a channel opened through Manual Setup to the node of the LSP the `E2E_BUILD` app targets, as an external LN peer — [Backend preconditions](#backend-preconditions) |
| A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker`, reached through Trezor Bridge on the host; the simulator has no Bluetooth LE and iOS cannot do WebUSB, so BLE pairing and USB are not covered — [hardware-wallet](hardware-wallet/README.md) |
| The OS notification-permission dialog | the one-shot `UNUserNotificationCenter` alert, reset with `xcrun simctl uninstall to.bitkit` and a rebuild |
diff --git a/journeys/transfer/spark-emergency-exit.xml b/journeys/transfer/spark-emergency-exit.xml
new file mode 100644
index 000000000..a5b390195
--- /dev/null
+++ b/journeys/transfer/spark-emergency-exit.xml
@@ -0,0 +1,31 @@
+
+
+ Verifies the Spark emergency exit up to the swipe. With a Spark balance, the Spark connection screen shows
+ "Emergency exit" (testTag "SparkEmergencyExit") under "Transfer to Savings". It opens the explainer, then the
+ quote, which asks the SDK's prepareRecoverFunds for every leaf and lists "Amount to exit", "Network fees",
+ "You receive about" and, when the exit needs CPFP fee funding, "Fee funding needed". Continue leads to the
+ funding screen, whose "Fund from another wallet" shows the funding address as a QR code. The journey stops before
+ the swipe: starting an exit takes the Spark balance out of Spending for days. The states after the swipe are
+ covered by SparkExitDriverTests, SparkExitBackgroundRunTests (a background run from a cold start),
+ SparkExitFeeBumpTests and SparkExitSweepTests. iOS shows the notes on Background App Refresh, Low Power Mode and
+ notifications in place of Android's battery notes.
+
+ Precondition: dev build with Spark connected on its regtest (no key needed) and a Spark balance, funded at the
+ deposit address the Spark connection screen shows (testTag "SparkDepositAddress") from Lightspark's regtest
+ faucet. Start on the wallet home screen.
+
+
+ Tap the menu icon (testTag "HeaderMenu")
+ Tap Settings (testTag "DrawerSettings")
+ Tap the Advanced tab (testTag "Tab-advanced")
+ Tap Lightning Connections (testTag "Channels")
+ Tap the Spark connection (testTag "SparkConnection")
+ Scroll down and verify "Transfer to Savings" (testTag "SparkTransferToSavings") is enabled and "Emergency exit" (testTag "SparkEmergencyExit") shows under it
+ Tap "Emergency exit" and verify the "Leave Spark on-chain" explainer says the exit takes up to 4 weeks, runs in the background and that Bitkit notifies you if it needs you
+ Tap "Continue" (testTag "SparkExitIntroContinue") and verify the "Exit quote" screen shows "Getting a quote…" and then "Amount to exit", "Network fees" and "You receive about"
+ Verify no reason shows in red (testTag "SparkExitQuoteBlocked") and "Continue" (testTag "SparkExitQuoteContinue") is enabled
+ Tap "Continue" and verify the "Fund the fees" screen names the fee funding amount, with "Pay from savings" (testTag "SparkExitFundSavings") and "Fund from another wallet" (testTag "SparkExitFundExternal")
+ Tap "Fund from another wallet" and verify a QR code and a bcrt1 address (testTag "SparkExitFundingAddress") show, and "Continue" (testTag "SparkExitFundingContinue") is disabled
+ Go back to the Spark connection screen and verify "Transfer to Savings" is still enabled: nothing started
+
+