diff --git a/app/src/androidTest/java/to/bitkit/services/SparkExitRegtestTest.kt b/app/src/androidTest/java/to/bitkit/services/SparkExitRegtestTest.kt
new file mode 100644
index 0000000000..e6dffa1a13
--- /dev/null
+++ b/app/src/androidTest/java/to/bitkit/services/SparkExitRegtestTest.kt
@@ -0,0 +1,110 @@
+package to.bitkit.services
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import breez_sdk_spark.ConnectRequest
+import breez_sdk_spark.CpfpFundingKind
+import breez_sdk_spark.ExitLeafSelection
+import breez_sdk_spark.PrepareRecoverFundsRequest
+import breez_sdk_spark.Seed
+import breez_sdk_spark.connect
+import breez_sdk_spark.defaultConfig
+import breez_sdk_spark.singleKeyCpfpSigner
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.runBlocking
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.lightningdevkit.ldknode.Network
+import to.bitkit.repositories.EsploraSparkExitChain
+import to.bitkit.repositories.SparkExitFundingKey
+import to.bitkit.repositories.SparkExitQuote
+import to.bitkit.repositories.SparkExitSweep
+import to.bitkit.test.annotations.DeviceIntegration
+import to.bitkit.utils.SignedTransactionId
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import breez_sdk_spark.Network as SparkNetwork
+
+/**
+ * Runs the exit's SDK, key and chain calls on a device against Spark's hosted regtest, for a new wallet with nothing
+ * to exit. It needs the network, so it only runs with the instrumentation argument `sparkExitRegtest true`.
+ */
+@RunWith(AndroidJUnit4::class)
+@DeviceIntegration
+class SparkExitRegtestTest {
+ private val onchain = OnchainService()
+
+ @Before
+ fun requireOptIn() {
+ val isOptedIn = InstrumentationRegistry.getArguments().getString("sparkExitRegtest") == "true"
+ assumeTrue("Pass -e sparkExitRegtest true to run against Spark's regtest", isOptedIn)
+ }
+
+ @Test
+ fun quotesAnEmptyWalletAndReadsItsFundingAddress(): Unit = runBlocking {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val storage = File(context.cacheDir, "spark-exit-regtest-${System.nanoTime()}").apply { mkdirs() }
+ val mnemonic = onchain.generateMnemonic()
+ val config = defaultConfig(SparkNetwork.REGTEST).apply {
+ leafOptimizationConfig = leafOptimizationConfig.copy(autoEnabled = false)
+ }
+ val sdk = connect(ConnectRequest(config, Seed.Mnemonic(mnemonic, null), storage.path))
+ try {
+ val path = SparkExitFundingKey.path(0, Network.REGTEST)
+ val address = onchain.deriveBitcoinAddress(mnemonic, path, Network.REGTEST, null)
+ val again = onchain.deriveBitcoinAddress(mnemonic, path, Network.REGTEST, null)
+ val secret = SparkExitFundingKey.secretKey(onchain.derivePrivateKey(mnemonic, path, Network.REGTEST, null))
+ singleKeyCpfpSigner(secret)
+ assertEquals("m/84'/1'/7797'/0/0", path)
+ assertTrue(address.address.startsWith("bcrt1q"), address.address)
+ assertEquals(66, address.publicKey.length)
+ assertEquals(32, secret.size)
+ assertEquals(address, again)
+
+ val feeRate = maxOf(1uL, sdk.recommendedFees().fastestFee)
+ val prepared = BreezSparkExitRecovering(sdk).prepare(
+ PrepareRecoverFundsRequest(feeRate, CpfpFundingKind.P2wpkh, address.address, ExitLeafSelection.All),
+ )
+ assertTrue(prepared.leaves.isEmpty())
+ assertFalse(SparkExitQuote(prepared).isWorthExiting)
+
+ val chain = EsploraSparkExitChain(EsploraSparkExitChain.defaultHosts("regtest"), HttpClient())
+ assertTrue(chain.tipHeight() > 0u)
+ assertTrue(chain.utxos(address.address).isEmpty())
+ } finally {
+ sdk.disconnect()
+ storage.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun signsALeftoverSweepWithTheFundingKey(): Unit = runBlocking {
+ val mnemonic = onchain.generateMnemonic()
+ val path = SparkExitFundingKey.path(0, Network.REGTEST)
+ val address = onchain.deriveBitcoinAddress(mnemonic, path, Network.REGTEST, null)
+ val secret = SparkExitFundingKey.secretKey(onchain.derivePrivateKey(mnemonic, path, Network.REGTEST, null))
+ val plan = requireNotNull(
+ SparkExitSweep.plan(
+ listOf(
+ SparkExitSweep.Input("11".repeat(32), 0u, 3_000uL),
+ SparkExitSweep.Input("22".repeat(32), 2u, 2_035uL),
+ ),
+ destination = "bcrt1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqc8gma6",
+ feeRateSatPerVbyte = 2uL,
+ ),
+ )
+
+ val signed = singleKeyCpfpSigner(secret).signPsbt(SparkExitSweep.psbt(plan, address.address))
+ val hex = SparkExitSweep.signedTxHex(plan, signed)
+
+ // Two inputs, each a P2WPKH witness of a DER signature and the funding key.
+ val witness = Regex("02(4[4-9])30[0-9a-f]+?01(21${address.publicKey})").findAll(hex).toList()
+ assertEquals(2, witness.size, hex)
+ assertTrue(hex.startsWith("020000000001"), hex)
+ assertEquals(64, SignedTransactionId.fromHex(hex).length)
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b845c2d308..eecc4b0cb0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -228,6 +228,15 @@
+
+
+
+
+
+
+
@@ -60,6 +64,7 @@ internal open class App : Application(), Configuration.Provider {
SingletonImageLoader.setSafe { imageLoader }
currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) }
appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.APP_START)
+ sparkExitWork.ensureScheduled()
// Initialize btleplug for Bluetooth support (required before any BLE usage)
BluetoothInit.ensureInitialized()
pubkyAuthHandlerRegistrar.start()
diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt
index 1c55b18947..eab61feb87 100644
--- a/app/src/main/java/to/bitkit/data/CacheStore.kt
+++ b/app/src/main/java/to/bitkit/data/CacheStore.kt
@@ -169,6 +169,17 @@ data class AppCacheData(
val quickPayLedger: QuickPayLedger? = null,
/** LNURL-pay comments by payment hash, kept until the sent payment's activity stores them. */
val pendingLightningMessages: Map = mapOf(),
+ /**
+ * When a Spark leaf optimization last ran or was cut short, in epoch milliseconds; cleared once it completed or
+ * an exit quote waited out the counter-swap grace. Kept across processes, since a swap the app's close cut short
+ * leaves value in flight.
+ */
+ val sparkOptimizationActivityAt: Long? = null,
+ /**
+ * The funding key index the next Spark emergency exit takes, per `{network}/wallet{n}`, so each exit pays its fees
+ * from an address of its own.
+ */
+ val sparkExitNextFundingIndexes: Map = mapOf(),
) {
fun isContactDetached(activityId: String, walletId: String): Boolean =
scopedActivityId(walletId, activityId) in detachedActivityContacts
diff --git a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt
index 84536fee46..0c27ba4540 100644
--- a/app/src/main/java/to/bitkit/data/dao/TransferDao.kt
+++ b/app/src/main/java/to/bitkit/data/dao/TransferDao.kt
@@ -41,6 +41,9 @@ interface TransferDao {
@Query("UPDATE transfers SET isSettled = 1, settledAt = :settledAt WHERE id = :id")
suspend fun markSettled(id: String, settledAt: Long)
+ @Query("UPDATE transfers SET claimableAtHeight = :claimableAtHeight WHERE id = :id")
+ suspend fun updateClaimableAtHeight(id: String, claimableAtHeight: Int?)
+
@Query("DELETE FROM transfers WHERE isSettled = 1 AND settledAt < :expirationTimestamp")
suspend fun deleteOldSettled(expirationTimestamp: Long)
}
diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt
index 09b2fd0dab..234701f6e8 100644
--- a/app/src/main/java/to/bitkit/env/Env.kt
+++ b/app/src/main/java/to/bitkit/env/Env.kt
@@ -240,6 +240,19 @@ internal object Env {
fun sparkExitRestorePath(walletIndex: Int) =
Path(storagePathOf(walletIndex, network.name.lowercase(), "spark-restore"), "exit-state.json").toString()
+ /** The running Spark emergency exit, if any: its signed recovery and how far it got. */
+ fun sparkExitRecordPath(walletIndex: Int): String {
+ require(::appStoragePath.isInitialized) { "App storage path should be 'context.filesDir.absolutePath'." }
+ return Path(appStoragePath, network.name.lowercase(), "wallet$walletIndex", "spark-exit.json").toString()
+ }
+
+ /** Leaves an older state of the wallet claimed on-chain, kept past the exit record that found them. */
+ fun sparkExitLostLeavesPath(walletIndex: Int): String {
+ require(::appStoragePath.isInitialized) { "App storage path should be 'context.filesDir.absolutePath'." }
+ val wallet = "wallet$walletIndex"
+ return Path(appStoragePath, network.name.lowercase(), wallet, "spark-exit-lost-leaves.json").toString()
+ }
+
fun bitkitCoreStoragePath(walletIndex: Int): String {
return storagePathOf(walletIndex, network.name.lowercase(), "core")
}
diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt
index c3d6f252f6..edc146cbe8 100644
--- a/app/src/main/java/to/bitkit/ext/Context.kt
+++ b/app/src/main/java/to/bitkit/ext/Context.kt
@@ -14,6 +14,7 @@ import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.hardware.usb.UsbManager
+import android.net.ConnectivityManager
import android.os.PowerManager
import android.provider.Settings
import android.text.format.DateFormat
@@ -41,6 +42,9 @@ val Context.activityManager: ActivityManager
val Context.alarmManager: AlarmManager
get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager
+val Context.connectivityManager: ConnectivityManager
+ get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
val Context.usbManager: UsbManager
get() = getSystemService(Context.USB_SERVICE) as UsbManager
diff --git a/app/src/main/java/to/bitkit/models/TransferType.kt b/app/src/main/java/to/bitkit/models/TransferType.kt
index cc3ceecdc8..2c88fce680 100644
--- a/app/src/main/java/to/bitkit/models/TransferType.kt
+++ b/app/src/main/java/to/bitkit/models/TransferType.kt
@@ -5,8 +5,11 @@ enum class TransferType {
TO_SAVINGS,
MANUAL_SETUP,
FORCE_CLOSE,
- COOP_CLOSE;
+ COOP_CLOSE,
- fun isToSavings() = this in listOf(TO_SAVINGS, COOP_CLOSE, FORCE_CLOSE)
+ /** The Spark balance leaving on-chain without the Spark operators. */
+ SPARK_EXIT;
+
+ fun isToSavings() = this in listOf(TO_SAVINGS, COOP_CLOSE, FORCE_CLOSE, SPARK_EXIT)
fun isToSpending() = this in listOf(TO_SPENDING, MANUAL_SETUP)
}
diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitChain.kt b/app/src/main/java/to/bitkit/repositories/SparkExitChain.kt
new file mode 100644
index 0000000000..55a1678c4d
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/SparkExitChain.kt
@@ -0,0 +1,309 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.RecoveryTransaction
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsText
+import io.ktor.content.TextContent
+import io.ktor.http.ContentType
+import io.ktor.http.HttpHeaders
+import io.ktor.http.isSuccess
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.longOrNull
+import to.bitkit.ext.runSuspendCatching
+import to.bitkit.utils.AppError
+
+data class EsploraTxStatus(val confirmed: Boolean, val blockHeight: UInt? = null)
+
+data class EsploraUtxo(val txid: String, val vout: UInt, val value: ULong, val status: EsploraTxStatus)
+
+/** What a broadcast came to. Accepted includes a transaction the node already had. */
+sealed interface SparkExitBroadcastOutcome {
+ val summary: String
+
+ data object Accepted : SparkExitBroadcastOutcome {
+ override val summary = "accepted"
+ }
+
+ /** The host or node could not take it now: the next tick tries again. */
+ data class RetryLater(val reason: String) : SparkExitBroadcastOutcome {
+ override val summary get() = "retry: $reason"
+ }
+
+ /** Its relative timelock has not matured (`non-BIP68-final`): sent again once it has, never a rejection. */
+ data class NotMature(val reason: String) : SparkExitBroadcastOutcome {
+ override val summary get() = "not mature: $reason"
+ }
+
+ /**
+ * An input is missing or already spent: its parent is not visible yet, or, once every parent confirmed, someone
+ * else (the operators) spent it first. Never a rejection.
+ */
+ data class InputsMissing(val reason: String) : SparkExitBroadcastOutcome {
+ override val summary get() = "inputs missing: $reason"
+ }
+
+ /** Refused for good at this fee or policy; a later check or rebuild replaces it. */
+ data class Rejected(val reason: String) : SparkExitBroadcastOutcome {
+ override val summary get() = "rejected: $reason"
+ }
+}
+
+/** The chain the Spark SDK reads, for what an exit does outside the SDK: broadcasting and the funding address. */
+interface SparkExitChain {
+ suspend fun tipHeight(): UInt
+ suspend fun utxos(address: String): List
+ suspend fun txStatus(txid: String): EsploraTxStatus
+
+ /**
+ * Whether a host has [txid] in a block or its mempool, from `/tx/{txid}/hex`: false only when every host answers
+ * 404. Throws when a host fails otherwise, since that host may know it.
+ */
+ suspend fun isTxKnown(txid: String): Boolean
+ suspend fun broadcast(tx: RecoveryTransaction): SparkExitBroadcastOutcome
+
+ /** A transaction of the app's own, such as the sweep of leftover fee funding, as a single `/tx`. */
+ suspend fun broadcastHex(hex: String): SparkExitBroadcastOutcome
+}
+
+class SparkExitChainError(message: String) : AppError(message)
+
+/**
+ * esplora and mempool.space REST, on the hosts the Spark SDK uses by default: a transaction with a CPFP child goes
+ * out as a package through `/v1/txs/package` (a JSON array of hex), one without as a single `/tx`.
+ */
+class EsploraSparkExitChain(
+ private val hosts: List,
+ private val client: HttpClient,
+) : SparkExitChain {
+ companion object {
+ private val json = Json { ignoreUnknownKeys = true }
+
+ /** The Spark SDK's default chain service per network (breez spark-sdk `default_rest_chain_services`). */
+ fun defaultHosts(sparkNetwork: String?): List = when (sparkNetwork) {
+ "mainnet" -> listOf("https://mempool.space/api", "https://blockstream.info/api")
+ "regtest" -> listOf("https://regtest-mempool.us-west-2.sparkinfra.net/api")
+ else -> emptyList()
+ }
+
+ private val NOT_HERE = setOf(404, 405)
+
+ private const val NOT_FOUND = 404
+ }
+
+ override suspend fun tipHeight(): UInt = get("/blocks/tip/height").trim().toUInt()
+
+ override suspend fun utxos(address: String): List =
+ json.parseToJsonElement(get("/address/$address/utxo")).jsonArray.map {
+ val o = it.jsonObject
+ EsploraUtxo(
+ txid = o.getValue("txid").jsonPrimitive.content,
+ vout = o.getValue("vout").jsonPrimitive.content.toUInt(),
+ value = o.getValue("value").jsonPrimitive.content.toULong(),
+ status = status(o.getValue("status").jsonObject),
+ )
+ }
+
+ override suspend fun txStatus(txid: String): EsploraTxStatus =
+ status(json.parseToJsonElement(get("/tx/$txid/status")).jsonObject)
+
+ override suspend fun isTxKnown(txid: String): Boolean {
+ if (hosts.isEmpty()) throw SparkExitChainError("No chain host configured")
+ val path = "/tx/$txid/hex"
+ var lastError: Throwable? = null
+ for (host in hosts) {
+ val status = runSuspendCatching { client.get("$host$path").status }
+ .onFailure { lastError = it }
+ .getOrNull()
+ ?: continue
+ if (status.isSuccess()) return true
+ if (status.value != NOT_FOUND) lastError = SparkExitChainError("HTTP '${status.value}' for '$path'")
+ }
+ lastError?.let { throw it }
+ return false
+ }
+
+ /** Tries each host until one accepts. Otherwise a node's refusal wins over a host that was only unavailable. */
+ override suspend fun broadcast(tx: RecoveryTransaction): SparkExitBroadcastOutcome {
+ val outcomes = mutableListOf()
+ for (host in hosts) {
+ val child = tx.cpfpTxHex
+ val attempt = if (child != null) {
+ submitPackage(host, listOf(tx.txHex, child), tx.txid)
+ } else {
+ submitSingle(host, tx.txHex)
+ }
+ // null: this host cannot take it (no package endpoint, unreachable).
+ if (attempt == SparkExitBroadcastOutcome.Accepted) return attempt
+ attempt?.let { outcomes += it }
+ }
+ return outcomes.firstOrNull { it is SparkExitBroadcastOutcome.Rejected }
+ ?: outcomes.firstOrNull()
+ ?: SparkExitBroadcastOutcome.RetryLater("no chain host reachable")
+ }
+
+ override suspend fun broadcastHex(hex: String): SparkExitBroadcastOutcome {
+ val outcomes = hosts.mapNotNull { host ->
+ submitSingle(host, hex).also { if (it == SparkExitBroadcastOutcome.Accepted) return it }
+ }
+ return outcomes.firstOrNull { it is SparkExitBroadcastOutcome.Rejected }
+ ?: outcomes.firstOrNull()
+ ?: SparkExitBroadcastOutcome.RetryLater("no chain host reachable")
+ }
+
+ private suspend fun submitPackage(
+ host: String,
+ hexes: List,
+ parentTxid: String,
+ ): SparkExitBroadcastOutcome? {
+ val body = JsonArray(hexes.map(::JsonPrimitive)).toString()
+ for (path in listOf("/v1/txs/package", "/txs/package")) {
+ val response = post("$host$path", TextContent(body, ContentType.Application.Json)) ?: return null
+ if (response.status.value in NOT_HERE) continue
+ return SparkExitBroadcastClassifier.pkg(response.status.value, response.bodyAsText(), parentTxid)
+ }
+ return null
+ }
+
+ private suspend fun submitSingle(host: String, hex: String): SparkExitBroadcastOutcome? {
+ val response = post("$host/tx", TextContent(hex, ContentType.Text.Plain)) ?: return null
+ if (response.status.value in NOT_HERE) return null
+ return SparkExitBroadcastClassifier.single(response.status.value, response.bodyAsText())
+ }
+
+ private suspend fun post(url: String, content: TextContent): HttpResponse? =
+ runSuspendCatching {
+ client.post(url) {
+ // The shared client defaults to JSON; the body carries its own type.
+ headers.remove(HttpHeaders.ContentType)
+ setBody(content)
+ }
+ }.getOrNull()
+
+ private suspend fun get(path: String): String {
+ var lastError: Throwable = SparkExitChainError("No chain host configured")
+ for (host in hosts) {
+ runSuspendCatching {
+ val response = client.get("$host$path")
+ if (!response.status.isSuccess()) {
+ throw SparkExitChainError(
+ "HTTP '${response.status.value}' for '$path'"
+ )
+ }
+ response.bodyAsText()
+ }.onSuccess { return it }.onFailure { lastError = it }
+ }
+ throw lastError
+ }
+
+ private fun status(o: JsonObject) = EsploraTxStatus(
+ confirmed = o["confirmed"]?.jsonPrimitive?.booleanOrNull == true,
+ blockHeight = (o["block_height"] as? JsonPrimitive)?.longOrNull?.toUInt(),
+ )
+}
+
+/**
+ * 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.
+ */
+object SparkExitBroadcastClassifier {
+ private val knownMessages = listOf(
+ "txn-already-in-mempool",
+ "txn-already-known",
+ "txn-same-nonwitness-data-in-mempool",
+ "already in block chain",
+ )
+ private val notMatureMessages = listOf("non-BIP68-final", "non-final")
+ private val inputsMissingMessages = listOf("bad-txns-inputs-missingorspent", "missing-inputs", "missingorspent")
+ private val json = Json { ignoreUnknownKeys = true }
+
+ fun single(status: Int, body: String): SparkExitBroadcastOutcome {
+ if (status in 200..299) return SparkExitBroadcastOutcome.Accepted
+ return unavailable(status) ?: classify(body)
+ }
+
+ /**
+ * A package's outcome. An error on the step itself, [parentTxid], decides it. Missing inputs reported on the CPFP
+ * child alone (its fee funding or chained change) say nothing about the step, which is then retried.
+ */
+ fun pkg(status: Int, body: String, parentTxid: String? = null): SparkExitBroadcastOutcome {
+ unavailable(status)?.let { return it }
+ val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
+ if (status !in 200..299 || root == null) return classify(body)
+ val message = (root["package_msg"] as? JsonPrimitive)?.contentOrNull
+ if (message == "success") return SparkExitBroadcastOutcome.Accepted
+ val results = (root["tx-results"] as? JsonObject)?.values?.mapNotNull { it as? JsonObject }.orEmpty()
+ // The step's own error decides, whatever its child reports and in whatever order the results come.
+ val own = results.firstOrNull { (it["txid"] as? JsonPrimitive)?.contentOrNull == parentTxid }
+ (own?.get("error") as? JsonPrimitive)?.contentOrNull?.let(::classify)
+ ?.takeIf { it != SparkExitBroadcastOutcome.Accepted }
+ ?.let { return it }
+ val outcomes = results.mapNotNull { result ->
+ val error = (result["error"] as? JsonPrimitive)?.contentOrNull ?: return@mapNotNull null
+ val txid = (result["txid"] as? JsonPrimitive)?.contentOrNull
+ val isChild = parentTxid != null && txid != null && txid != parentTxid
+ val outcome = classify(error)
+ if (isChild && outcome is SparkExitBroadcastOutcome.InputsMissing) {
+ SparkExitBroadcastOutcome.RetryLater(error)
+ } else {
+ outcome
+ }
+ }
+ if (outcomes.isEmpty()) {
+ return if (results.isEmpty()) {
+ SparkExitBroadcastOutcome.RetryLater(
+ body
+ )
+ } else {
+ SparkExitBroadcastOutcome.Accepted
+ }
+ }
+ // A child reports missing inputs, or another error, when its parent is the one not yet mature.
+ return outcomes.firstOrNull { it is SparkExitBroadcastOutcome.NotMature }
+ ?: outcomes.firstOrNull { it is SparkExitBroadcastOutcome.Rejected }
+ ?: outcomes.firstOrNull { it != SparkExitBroadcastOutcome.Accepted }
+ ?: SparkExitBroadcastOutcome.Accepted
+ }
+
+ /** A host that is down, rate limiting or behind a challenge page says nothing about the transaction. */
+ private fun unavailable(status: Int): SparkExitBroadcastOutcome? =
+ if (status >= 500 || status == 429 || status == 403) {
+ SparkExitBroadcastOutcome.RetryLater(
+ "HTTP $status"
+ )
+ } else {
+ null
+ }
+
+ private val unpaid = Regex("""min relay fee not met,\s*0\s*<""")
+
+ /**
+ * A step refused for paying no fee at all, with no child paying for it: the child's chained change is gone, as
+ * when an earlier child of the exit lost a race. Read by the driver; a nonzero fee stays a plain rejection.
+ */
+ fun isUnpaid(outcome: SparkExitBroadcastOutcome): Boolean =
+ outcome is SparkExitBroadcastOutcome.Rejected && unpaid.containsMatchIn(outcome.reason)
+
+ fun classify(text: String): SparkExitBroadcastOutcome = when {
+ knownMessages.any { it in text } || hasRpcCode(-27, text) -> SparkExitBroadcastOutcome.Accepted
+ notMatureMessages.any { it in text } -> SparkExitBroadcastOutcome.NotMature(text)
+ inputsMissingMessages.any { it in text } || hasRpcCode(-25, text) -> SparkExitBroadcastOutcome.InputsMissing(
+ text
+ )
+ else -> SparkExitBroadcastOutcome.Rejected(text)
+ }
+
+ /** The RPC code, also when the node's error sits JSON-escaped inside mempool's own error object. */
+ private fun hasRpcCode(code: Int, text: String) = Regex("""code\\?"\s*:\s*$code\b""").containsMatchIn(text)
+}
diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt b/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt
new file mode 100644
index 0000000000..eb691cc0c4
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt
@@ -0,0 +1,682 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.CheckRecoverFundsResponse
+import breez_sdk_spark.CpfpFundingKind
+import breez_sdk_spark.CpfpSigner
+import breez_sdk_spark.ExitLeafSelection
+import breez_sdk_spark.ExitTransactionStatus
+import breez_sdk_spark.PrepareRecoverFundsRequest
+import breez_sdk_spark.PrepareRecoverFundsResponse
+import breez_sdk_spark.RecoverFundsRequest
+import breez_sdk_spark.RecoverFundsResponse
+import breez_sdk_spark.RecoveryTransaction
+import breez_sdk_spark.RecoveryVerdict
+import breez_sdk_spark.SdkException
+import breez_sdk_spark.SkippedLeafReason
+import to.bitkit.ext.runSuspendCatching
+import to.bitkit.models.safe
+import to.bitkit.utils.Logger
+import to.bitkit.utils.SignedTransactionId
+import kotlin.time.Duration.Companion.minutes
+
+/** The SDK calls an exit makes. The Breez SDK in the app, a fake in tests. */
+interface SparkExitRecovering {
+ suspend fun prepare(request: PrepareRecoverFundsRequest): PrepareRecoverFundsResponse
+ suspend fun recover(request: RecoverFundsRequest, signer: CpfpSigner?): RecoverFundsResponse
+ suspend fun check(recovery: RecoverFundsResponse): CheckRecoverFundsResponse
+}
+
+/**
+ * Prepares every leaf the operators list less [lost], the leaves an older state of this wallet claimed on-chain, which
+ * the operators keep listing. A quote that lists one is prepared again for the rest, so its amount, fees and funding
+ * leave it out, dropping a leaf the SDK refuses to exit; nothing is left to exit when every listed leaf is lost.
+ */
+suspend fun SparkExitRecovering.prepareAll(
+ request: PrepareRecoverFundsRequest,
+ lost: Set,
+): PrepareRecoverFundsResponse {
+ val all = prepare(request.copy(selection = ExitLeafSelection.All))
+ val skipped = all.skipped.filter { it.leafId !in lost }
+ if (all.leaves.none { it.leafId in lost }) return all.copy(skipped = skipped)
+ var ids = all.leaves.map { it.leafId }.filter { it !in lost }
+ while (ids.isNotEmpty()) {
+ val result = runSuspendCatching { prepare(request.copy(selection = ExitLeafSelection.Specific(ids))) }
+ result.getOrNull()?.let { prepared ->
+ return prepared.copy(skipped = (prepared.skipped + skipped).distinctBy { it.leafId })
+ }
+ val error = requireNotNull(result.exceptionOrNull())
+ val refusal = SparkExitUnexitable.parse(error)?.takeIf { it.leafId in ids } ?: throw error
+ ids = ids - refusal.leafId
+ }
+ return all.copy(
+ leaves = emptyList(),
+ skipped = skipped,
+ recoverableValueSats = 0uL,
+ totalFeeSats = 0uL,
+ cooperativeFeeSats = 0uL,
+ cpfpFeeSats = 0uL,
+ fanoutFeeSats = 0uL,
+ refundFeeSats = 0uL,
+ sweepFeeSats = 0uL,
+ funding = null,
+ )
+}
+
+/**
+ * Moves an exit one step forward each tick: builds it once its fee funding confirms, broadcasts what is ready,
+ * rebuilds when the chain diverged or its fee chain lost a race, and finishes when everything confirmed. Without the
+ * SDK (a background start, 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 each broadcast as it happens, so a run killed
+ * partway, as background runs can be, never sends the same transactions again before they are due.
+ */
+class SparkExitDriver(
+ private val chain: SparkExitChain,
+ /** Null while the Spark SDK is not connected. */
+ private val recovering: SparkExitRecovering?,
+ private val makeSigner: suspend () -> CpfpSigner,
+ private val save: (SparkExitRecord) -> Unit,
+ private val nowMillis: () -> Long,
+) {
+ companion object {
+ private const val TAG = "SparkExitDriver"
+
+ /** A ready transaction accepted in this generation is sent again after this long, in case a node dropped it. */
+ val REBROADCAST_INTERVAL = 10.minutes
+
+ /** After this many rejections of one transaction the progress screen shows the error. */
+ const val REJECTIONS_TO_SURFACE = 3
+
+ /** After this many unpaid sends in a row of a step whose parents confirmed, the exit rebuilds once for it. */
+ const val FEE_CHAIN_FAILURES_TO_REBUILD = 3
+
+ /** The least time between two builds, so a divergence that persists does not rebuild every tick. */
+ val REBUILD_INTERVAL = 10.minutes
+
+ const val LOST_REASON = "Claimed by an older state"
+
+ const val NO_LEAF_REASON = "No leaf can be exited"
+ }
+
+ suspend fun tick(start: SparkExitRecord): SparkExitRecord {
+ var record = start
+ when (record.phase) {
+ SparkExitRecord.Phase.Done, is SparkExitRecord.Phase.Failed -> {
+ if (!record.isLeftoverSweepPending) return record
+ record = sweepLeftover(record)
+ save(record)
+ return record
+ }
+ is SparkExitRecord.Phase.AwaitingFunding, is SparkExitRecord.Phase.NeedsFunding -> {
+ val sdk = recovering ?: return record
+ val shortfall = record.fundingShortfallSats
+ // Nothing to fund when every leaf is recovered cooperatively.
+ if (shortfall != null) record = adoptFunding(record, shortfall) ?: return record
+ record = build(record, sdk)
+ }
+ SparkExitRecord.Phase.Running if record.isRebuilding -> {
+ // The diverged generation is never broadcast again; only a rebuild moves on from here.
+ val sdk = recovering ?: return record
+ if (!isRebuildDue(record)) return record
+ record = build(record, sdk)
+ }
+ SparkExitRecord.Phase.Running -> {
+ record = recovering?.let { check(record, it) } ?: broadcastWithoutSdk(record)
+ }
+ }
+ record = record.copy(claimableAtHeight = record.recovery?.let(SparkExitMath::claimableAtHeight))
+ // The tick that ends the exit also sends its leftover fee funding to savings.
+ if (record.isLeftoverSweepPending) record = sweepLeftover(record)
+ save(record)
+ return record
+ }
+
+ /**
+ * Adds every confirmed UTXO of new funding at the funding address once together they cover [requiredSats]; null
+ * while they do not. Several small payments count: the SDK spends every funding input it is given.
+ */
+ private suspend fun adoptFunding(record: SparkExitRecord, requiredSats: ULong): SparkExitRecord? {
+ val first = record.funding.firstOrNull() ?: return null
+ val candidates = SparkExitMath.newFunding(chain.utxos(first.address), record)
+ val utxos = SparkExitMath.fundingTopUp(candidates, requiredSats) ?: return null
+ val adopted = utxos.map { first.copy(txid = it.txid, vout = it.vout, valueSats = it.value) }
+ val funding = if (record.funding.size == 1 && first.txid == null) adopted else record.funding + adopted
+ return record.copy(funding = funding)
+ }
+
+ private suspend fun build(start: SparkExitRecord, sdk: SparkExitRecovering): SparkExitRecord {
+ val (record, prepared) = prepare(start, sdk)
+ if (prepared == null) {
+ // The last leaf lost on a tick that could not read the funding address ends here, still as lost.
+ val reason = if (record.isEveryLeafLost) LOST_REASON else NO_LEAF_REASON
+ val failed = record.copy(phase = SparkExitRecord.Phase.Failed(reason), isRebuilding = false)
+ return end(failed, record)
+ }
+ if (prepared.leaves.isEmpty()) {
+ // A leaf the chain could not be read for comes back on a later tick; finished leaves are not listed.
+ if (prepared.skipped.any { it.reason == SkippedLeafReason.Unverified }) return record
+ val ended = record.copy(
+ skippedLeafCount = maxOf(record.skippedLeafCount, prepared.skipped.size),
+ phase = if (record.recovery == null) {
+ SparkExitRecord.Phase.Failed("Nothing left to recover")
+ } else {
+ SparkExitRecord.Phase.Done
+ },
+ )
+ // Fee funding this exit no longer spends, such as a payment adopted just before, goes back to savings.
+ return end(ended, record)
+ }
+ val needsFunding = prepared.funding != null
+ val built = runSuspendCatching {
+ sdk.recover(
+ RecoverFundsRequest(prepared, if (needsFunding) record.fundingInputs else emptyList()),
+ if (needsFunding) makeSigner() else null,
+ )
+ }.getOrElse {
+ // A leaf the SDK refuses to build is left behind, and the rest rebuilt on the next tick.
+ SparkExitUnexitable.parse(it)?.takeIf { refusal -> refusal.leafId in record.exitLeafIds }?.let { refusal ->
+ Logger.warn("Leaving behind Spark exit leaf '${refusal.leafId}': '${refusal.reason}'", context = TAG)
+ return record.leaveBehind(refusal.leafId, refusal.reason).copy(isRebuilding = true, lastBuildAt = null)
+ }
+ if (it !is SdkException.InsufficientCpfpFunds) throw it
+ // The SDK names the total its funding inputs must hold (CPFP cost plus change dust), not what is missing.
+ // After a Redo the inputs are partly spent, so what the address still holds decides the shortfall.
+ Logger.warn("Needs '${it.requiredSats}' sats of fee funding in total for the Spark exit", context = TAG)
+ val held = record.funding.firstOrNull()?.address?.let { address ->
+ runSuspendCatching { chain.utxos(address) }.getOrNull()?.let { SparkExitMath.heldSats(it, record) }
+ }
+ val needed = SparkExitMath.neededFunding(it.requiredSats, record.fundedSats, held)
+ return record.copy(phase = SparkExitRecord.Phase.NeedsFunding(needed))
+ }
+ if (built.transactions.isEmpty()) {
+ // Cooperative leaves the operators could not co-sign now are tried again on the next tick.
+ if (built.failed.isNotEmpty()) return record.copy(failedLeafCount = built.failed.size)
+ return end(record.copy(phase = SparkExitRecord.Phase.Failed("Nothing to recover")), record)
+ }
+ val lost = record.lostLeaves(built)
+ if (lost.isNotEmpty()) return loseLeaves(record, lost)
+ val adopted = record.adopt(built, nowMillis())
+ // Saved before any of it is broadcast, so a crash never leaves broadcast transactions without their record.
+ save(adopted)
+ Logger.info(
+ "Built Spark exit generation '${adopted.generation}' of '${built.transactions.size}' transactions",
+ context = TAG,
+ )
+ return broadcastReady(adopted)
+ }
+
+ private fun prepareRequest(record: SparkExitRecord, selection: ExitLeafSelection) = PrepareRecoverFundsRequest(
+ feeRateSatPerVbyte = record.feeRateSatPerVbyte,
+ fundingKind = CpfpFundingKind.P2wpkh,
+ destination = record.destination,
+ selection = selection,
+ )
+
+ /**
+ * Prepares the exit's leaves. A leaf the SDK refuses to exit, such as a fee-ladder leaf whose refund cannot pay its
+ * own fee at this rate, fails the whole prepare: it is dropped and the rest prepared again. When the refused leaf
+ * cannot be told, the leaves still recoverable are prepared instead, and the exit's leaves left out are dropped.
+ * The prepared response is null once no leaves remain.
+ */
+ private suspend fun prepare(
+ start: SparkExitRecord,
+ sdk: SparkExitRecovering,
+ ): Pair {
+ var record = start
+ while (true) {
+ val ids = record.exitLeafIds
+ if (ids.isEmpty()) return record to null
+ val result = runSuspendCatching { sdk.prepare(prepareRequest(record, ExitLeafSelection.Specific(ids))) }
+ result.getOrNull()?.let { return record to it }
+ val error = requireNotNull(result.exceptionOrNull())
+ val refusal = SparkExitUnexitable.parse(error) ?: throw error
+ Logger.warn("Leaving behind Spark exit leaf '${refusal.leafId}': '${refusal.reason}'", context = TAG)
+ if (refusal.leafId in ids) {
+ record = record.leaveBehind(refusal.leafId, refusal.reason)
+ continue
+ }
+ val prepared = sdk.prepare(prepareRequest(record, ExitLeafSelection.RecoverableOnly))
+ val listed = prepared.leaves.map { it.leafId }.toSet()
+ ids.filter { it !in listed }.forEach {
+ record = record.leaveBehind(it, SparkExitUnexitable.NOT_RECOVERABLE)
+ }
+ if (listed.all { it in ids }) return record to prepared
+ // It also lists leaves outside the exit, such as one an older state claimed: only the exit's are built.
+ val remaining = record.exitLeafIds
+ if (remaining.isEmpty()) return record to null
+ return record to sdk.prepare(prepareRequest(record, ExitLeafSelection.Specific(remaining)))
+ }
+ }
+
+ /**
+ * Leaves the rebuild unadopted and drops [lost] from the exit: an older state of this wallet claimed them, and
+ * every rebuild would reproduce the same spent refund. The next tick rebuilds at once without them.
+ */
+ private suspend fun loseLeaves(record: SparkExitRecord, lost: List): SparkExitRecord {
+ Logger.warn("Dropping Spark exit leaves '${lost.map { it.leafId }}' claimed by an older state", context = TAG)
+ val updated = record.copy(lostLeaves = record.lostLeaves + lost, isRebuilding = true, lastBuildAt = null)
+ if (updated.exitLeafIds.isNotEmpty()) return updated
+ return end(updated.copy(phase = SparkExitRecord.Phase.Failed(LOST_REASON), isRebuilding = false), updated)
+ }
+
+ private suspend fun check(record: SparkExitRecord, sdk: SparkExitRecovering): SparkExitRecord {
+ val stored = record.recovery ?: return record.copy(phase = SparkExitRecord.Phase.AwaitingFunding(0uL))
+ val checked = sdk.check(withConfirmations(stored))
+ Logger.debug("Checked Spark exit generation '${record.generation}': '${checked.verdict}'", context = TAG)
+ val updated = record.copy(recovery = checked.recovery)
+ return when (checked.verdict) {
+ RecoveryVerdict.Valid -> {
+ val confirmed = checked.recovery.transactions
+ .filter { it.status is ExitTransactionStatus.Confirmed }
+ .map { it.txid }
+ .toSet()
+ val dead = deadChildren(stored) { it in confirmed }
+ if (dead.isNotEmpty()) {
+ // The SDK still calls it valid, but the steps that spend a dead child's change can never confirm.
+ Logger.warn("Rebuilding Spark exit whose CPFP children '$dead' lost a race", context = TAG)
+ return rebuild(updated, sdk)
+ }
+ val sent = broadcastReady(updated)
+ val stuck = stuckStep(sent) ?: return sent
+ Logger.warn("Rebuilding Spark exit once for step '$stuck' whose fee went unpaid", context = TAG)
+ rebuild(sent.copy(feeChainRebuildTxids = sent.feeChainRebuildTxids + stuck), sdk)
+ }
+ RecoveryVerdict.Done -> end(updated.copy(phase = SparkExitRecord.Phase.Done), updated)
+ is RecoveryVerdict.Redo -> {
+ Logger.warn(
+ "Rebuilding Spark exit diverged on-chain as generation '${record.generation + 1}'",
+ context = TAG,
+ )
+ rebuild(updated, sdk)
+ }
+ }
+ }
+
+ /**
+ * Saves the exit as rebuilding, so nothing of this generation goes out again, and rebuilds it once the last build
+ * is old enough, so a rebuild that reproduces the same problem cannot loop every tick.
+ */
+ private suspend fun rebuild(record: SparkExitRecord, sdk: SparkExitRecovering): SparkExitRecord {
+ val rebuilding = record.copy(isRebuilding = true)
+ save(rebuilding)
+ if (!isRebuildDue(rebuilding)) return rebuilding
+ return build(rebuilding, sdk)
+ }
+
+ /** A step of this generation whose fee went unpaid too many times in a row, and that has not rebuilt yet. */
+ private fun stuckStep(record: SparkExitRecord): String? = record.broadcastLog.firstOrNull {
+ it.generation == record.generation &&
+ it.feeChainFailures >= FEE_CHAIN_FAILURES_TO_REBUILD &&
+ it.txid !in record.feeChainRebuildTxids
+ }?.txid
+
+ /**
+ * Our CPFP children that can never confirm, and every child that spends their change. A wallet whose leaves share
+ * a node with ours broadcast its own package for it and won: the node is in a block, and our child for it is in
+ * neither a block nor the mempool. Only children whose change a pending step's child spends are looked up; a dead
+ * child nothing builds on harms nothing, and its funding ends up in the leftover sweep.
+ */
+ private suspend fun deadChildren(
+ recovery: RecoverFundsResponse,
+ isConfirmed: (String) -> Boolean,
+ ): Set {
+ val children = SparkExitFeeChain.children(recovery)
+ val spends = SparkExitFeeChain.spends(recovery)
+ val stepOf = children.entries.associate { (step, child) -> child to step }
+ val suspects = children
+ .filterKeys { !isConfirmed(it) }
+ .values
+ .flatMap { spends[it].orEmpty() }
+ .filter { spent -> stepOf[spent]?.let(isConfirmed) == true }
+ .distinct()
+ val lost = suspects.filter { isUnknownToChain(it) }.toSet()
+ return SparkExitFeeChain.withDescendants(spends, lost)
+ }
+
+ /** Every host answered it does not know [txid]; a host that failed otherwise leaves it alive for this tick. */
+ private suspend fun isUnknownToChain(txid: String): Boolean =
+ runSuspendCatching { !chain.isTxKnown(txid) }.getOrDefault(false)
+
+ /**
+ * 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 suspend fun withConfirmations(recovery: RecoverFundsResponse): RecoverFundsResponse {
+ val confirmed = recovery.transactions
+ .filter { it.status is ExitTransactionStatus.Confirmed }
+ .map { it.txid }
+ .toMutableSet()
+ val transactions = recovery.transactions.map { tx ->
+ if (tx.txid in confirmed || !tx.dependsOn.all { it in confirmed }) return@map tx
+ val status = runSuspendCatching { chain.txStatus(tx.txid) }.getOrNull()
+ if (status?.confirmed != true) return@map tx
+ confirmed += tx.txid
+ tx.copy(status = ExitTransactionStatus.Confirmed(status.blockHeight))
+ }
+ return recovery.copy(transactions = transactions)
+ }
+
+ /**
+ * Sends what the ended exit left at its funding address to [SparkExitRecord.destination] in one transaction, once
+ * every UTXO there confirmed, at the exit's fee rate. Built and saved before it is sent, then sent again each tick
+ * until a node accepts it; not after a rejection. Dust is not sent.
+ */
+ private suspend fun sweepLeftover(record: SparkExitRecord): SparkExitRecord {
+ val built = record.leftoverSweep
+ if (built?.txHex != null) {
+ if (built.isAccepted) {
+ val status = runSuspendCatching { chain.txStatus(requireNotNull(built.txid)) }.getOrNull()
+ if (status?.confirmed == true) return record.copy(leftoverSweep = built.copy(isConfirmed = true))
+ }
+ // Sent again until it confirms; a node that already has it accepts it again.
+ return sendSweep(record, built, built.txHex)
+ }
+ val address = record.funding.firstOrNull()?.address ?: return record
+ val utxos = runSuspendCatching { chain.utxos(address) }.getOrNull() ?: return record
+ if (utxos.isEmpty()) return record.copy(leftover = emptyList())
+ if (utxos.any { !it.status.confirmed }) return record
+ val inputs = utxos.map { SparkExitSweep.Input(it.txid, it.vout, it.value) }
+ val total = utxos.fold(0uL) { sum, utxo -> sum.safe() + utxo.value.safe() }
+ val plan = runSuspendCatching { SparkExitSweep.plan(inputs, record.destination, record.feeRateSatPerVbyte) }
+ .getOrElse {
+ Logger.error("Failed to plan the Spark exit leftover sweep", it, context = TAG)
+ return record
+ }
+ ?: return record.copy(leftoverSweep = SparkExitRecord.LeftoverSweep(null, null, total))
+ val sweep = runSuspendCatching {
+ val signed = makeSigner().signPsbt(SparkExitSweep.psbt(plan, address))
+ val hex = SparkExitSweep.signedTxHex(plan, signed)
+ SparkExitRecord.LeftoverSweep(SignedTransactionId.fromHex(hex), hex, plan.amountSats)
+ }.getOrElse {
+ Logger.error("Failed to build the Spark exit leftover sweep", it, context = TAG)
+ return record
+ }
+ val hex = requireNotNull(sweep.txHex)
+ val saved = record.copy(leftoverSweep = sweep)
+ save(saved)
+ return sendSweep(saved, sweep, hex)
+ }
+
+ private suspend fun sendSweep(
+ record: SparkExitRecord,
+ sweep: SparkExitRecord.LeftoverSweep,
+ hex: String,
+ ): SparkExitRecord {
+ val outcome = chain.broadcastHex(hex)
+ Logger.info("Broadcast Spark exit leftover sweep '${sweep.txid}': '${outcome.summary}'", context = TAG)
+ return when (outcome) {
+ SparkExitBroadcastOutcome.Accepted -> record.copy(leftoverSweep = sweep.copy(isAccepted = true))
+ is SparkExitBroadcastOutcome.Rejected -> record.copy(leftoverSweep = sweep.copy(rejection = outcome.reason))
+ is SparkExitBroadcastOutcome.RetryLater,
+ is SparkExitBroadcastOutcome.NotMature,
+ is SparkExitBroadcastOutcome.InputsMissing,
+ -> record
+ }
+ }
+
+ private fun isRebuildDue(record: SparkExitRecord): Boolean {
+ val last = record.lastBuildAt ?: return true
+ return nowMillis() - last >= REBUILD_INTERVAL.inWholeMilliseconds
+ }
+
+ /** What is left at the funding address, null when it could not be read. */
+ private suspend fun leftover(record: SparkExitRecord): List? {
+ val address = record.funding.firstOrNull()?.address ?: return emptyList()
+ return runSuspendCatching { chain.utxos(address) }.getOrNull()
+ ?.map { SparkExitRecord.Leftover(it.txid, it.vout, it.value) }
+ }
+
+ /**
+ * [ended] with the leftover at its funding address, or [unchanged] while the address cannot be read: an exit that
+ * ended without its leftover read would never sweep it.
+ */
+ private suspend fun end(ended: SparkExitRecord, unchanged: SparkExitRecord): SparkExitRecord {
+ val leftover = leftover(ended) ?: return unchanged
+ return ended.copy(leftover = leftover)
+ }
+
+ private suspend fun broadcastReady(record: SparkExitRecord): SparkExitRecord {
+ var current = record
+ val recovery = current.recovery ?: return current
+ // Unverified: the SDK could not read its parent from the chain; nodes refuse it harmlessly if it is not final.
+ recovery.transactions
+ .filter { it.status == ExitTransactionStatus.Ready || it.status == ExitTransactionStatus.Unverified }
+ .filter { isDue(it.txid, current) }
+ // A ready step's parents are confirmed, as the SDK saw them.
+ .forEach {
+ current = broadcastAndSave(it, current, parentsConfirmed = it.status == ExitTransactionStatus.Ready)
+ }
+ return current
+ }
+
+ /**
+ * Without the SDK: each unconfirmed transaction whose parents are confirmed on the chain host and whose relative
+ * timelock has matured, by the SDK's own rule. A stale one is refused by nodes as a conflict, and only the current
+ * generation is stored. A step whose input someone else spent is not sent again; the next SDK check rebuilds. A fee
+ * chain that lost a race is left to that check too, since only the SDK can rebuild it.
+ */
+ private suspend fun broadcastWithoutSdk(record: SparkExitRecord): SparkExitRecord {
+ val recovery = record.recovery ?: return record
+ val tip = chain.tipHeight()
+ val statuses = recovery.transactions
+ .mapNotNull { tx -> (tx.status as? ExitTransactionStatus.Confirmed)?.let { tx.txid to it.blockHeight } }
+ .associate { (txid, height) -> txid to EsploraTxStatus(true, height) }
+ .toMutableMap()
+ suspend fun status(txid: String): EsploraTxStatus? {
+ if (txid !in statuses) statuses[txid] = runSuspendCatching { chain.txStatus(txid) }.getOrNull()
+ return statuses[txid]
+ }
+ var current = record
+ val transactions = recovery.transactions.map { tx ->
+ if (tx.status is ExitTransactionStatus.Confirmed) return@map tx
+ val own = status(tx.txid)
+ if (own?.confirmed == true) return@map tx.copy(status = ExitTransactionStatus.Confirmed(own.blockHeight))
+ if (!tx.dependsOn.all { status(it)?.confirmed == true }) return@map tx
+ // A height the SDK already worked out still holds.
+ val known = (tx.status as? ExitTransactionStatus.WaitingForTimelock)?.spendableAtHeight
+ if (known != null && !SparkExitMath.isMature(known, tip)) return@map tx
+ if (tx.csvTimelockBlocks != null) {
+ val parents = SparkExitTxInputs.decode(tx.txHex)?.filter { it.relativeLockBlocks > 0u }
+ ?.map { it.prevTxid } ?: tx.dependsOn
+ val heights = parents.associateWith { parentHeight(it, status(it)) }
+ // A parent whose height is unknown leaves maturity unknown: wait.
+ val spendable = SparkExitMath.spendableAtHeight(tx, heights) ?: return@map tx
+ if (!SparkExitMath.isMature(spendable, tip)) {
+ return@map tx.copy(status = ExitTransactionStatus.WaitingForTimelock(spendable))
+ }
+ }
+ if (isInputSpent(tx.txid, current)) return@map tx
+ if (isDue(tx.txid, current)) current = broadcastAndSave(tx, current, parentsConfirmed = true)
+ tx
+ }
+ return current.copy(recovery = recovery.copy(transactions = transactions))
+ }
+
+ /** A confirmed parent's height, read from the chain host when the stored confirmation carries none. */
+ private suspend fun parentHeight(txid: String, known: EsploraTxStatus?): UInt? {
+ if (known?.confirmed != true) return null
+ return known.blockHeight ?: runSuspendCatching { chain.txStatus(txid) }.getOrNull()?.blockHeight
+ }
+
+ private fun isInputSpent(txid: String, record: SparkExitRecord): Boolean =
+ record.broadcastLog.any { it.generation == record.generation && it.txid == txid && it.isInputSpent }
+
+ private fun isDue(txid: String, record: SparkExitRecord): Boolean {
+ val last = record.broadcastLog
+ .firstOrNull { it.generation == record.generation && it.txid == txid }
+ ?: return true
+ return !last.isAccepted || nowMillis() - last.lastAttemptAt >= REBROADCAST_INTERVAL.inWholeMilliseconds
+ }
+
+ private suspend fun broadcastAndSave(
+ tx: RecoveryTransaction,
+ record: SparkExitRecord,
+ parentsConfirmed: Boolean,
+ ): SparkExitRecord = broadcast(tx, record, parentsConfirmed).also(save)
+
+ /**
+ * Sends [tx] and logs the outcome. With every parent confirmed, missing inputs mean someone else spent one first:
+ * the operators, which a rebuild recovers, or an older state of this wallet, which rebuilds keep reproducing.
+ */
+ private suspend fun broadcast(
+ tx: RecoveryTransaction,
+ record: SparkExitRecord,
+ parentsConfirmed: Boolean,
+ ): SparkExitRecord {
+ val outcome = chain.broadcast(tx)
+ val isInputSpent = parentsConfirmed && outcome is SparkExitBroadcastOutcome.InputsMissing
+ val isUnpaid = SparkExitBroadcastClassifier.isUnpaid(outcome)
+ val isStuck = parentsConfirmed && (isUnpaid || outcome is SparkExitBroadcastOutcome.InputsMissing)
+ Logger.info("Broadcast Spark exit '${tx.kind}' '${tx.txid}': '${outcome.summary}'", context = TAG)
+ val isAccepted = outcome == SparkExitBroadcastOutcome.Accepted
+ // Unpaid counts as a rejection only once the step had its rebuild, so an underpriced exit still surfaces.
+ val isRejected = outcome is SparkExitBroadcastOutcome.Rejected &&
+ (!isUnpaid || tx.txid in record.feeChainRebuildTxids)
+ val rejection = if (isRejected) 1 else 0
+ val existing = record.broadcastLog.firstOrNull { it.generation == record.generation && it.txid == tx.txid }
+ val entry = existing?.copy(
+ attempts = existing.attempts + 1,
+ rejections = existing.rejections + rejection,
+ lastAttemptAt = nowMillis(),
+ lastOutcome = outcome.summary,
+ isAccepted = isAccepted,
+ isInputSpent = existing.isInputSpent || isInputSpent,
+ feeChainFailures = if (isStuck) existing.feeChainFailures + 1 else 0,
+ ) ?: SparkExitRecord.BroadcastAttempt(
+ generation = record.generation,
+ txid = tx.txid,
+ attempts = 1,
+ rejections = rejection,
+ lastAttemptAt = nowMillis(),
+ lastOutcome = outcome.summary,
+ isAccepted = isAccepted,
+ isInputSpent = isInputSpent,
+ feeChainFailures = if (isStuck) 1 else 0,
+ )
+ val log = if (existing == null) {
+ record.broadcastLog + entry
+ } else {
+ record.broadcastLog.map { if (it === existing) entry else it }
+ }
+ return record.copy(broadcastLog = log)
+ }
+}
+
+/** The rejection to show on the progress screen once a transaction was refused several times. */
+val SparkExitRecord.surfacedRejection: String?
+ get() = broadcastLog.firstOrNull {
+ it.generation == generation && it.rejections >= SparkExitDriver.REJECTIONS_TO_SURFACE && !it.isAccepted
+ }?.lastOutcome
+
+/**
+ * A step of this generation went unpaid too many times in a row. Without the SDK nothing rebuilds it and it counts no
+ * rejection, so the exit is not keeping up: the deadline alarm must be left to fire.
+ */
+val SparkExitRecord.isFeeChainStuck: Boolean
+ get() = broadcastLog.any {
+ it.generation == generation && it.feeChainFailures >= SparkExitDriver.FEE_CHAIN_FAILURES_TO_REBUILD
+ }
+
+/** Transaction ids of the CPFP children, which the SDK returns as hex only. */
+object SparkExitTxids {
+ fun cpfpChildTxids(recovery: RecoverFundsResponse): List =
+ recovery.transactions.mapNotNull { tx ->
+ tx.cpfpTxHex?.let { runCatching { SignedTransactionId.fromHex(it) }.getOrNull() }
+ }
+}
+
+/** How the exit's CPFP children chain: each spends its node's anchor and the change of the child before it. */
+object SparkExitFeeChain {
+ /** Each step's CPFP child txid, by the step's txid. */
+ fun children(recovery: RecoverFundsResponse): Map =
+ recovery.transactions.mapNotNull { tx ->
+ val child = tx.cpfpTxHex?.let { runCatching { SignedTransactionId.fromHex(it) }.getOrNull() }
+ child?.let { tx.txid to it }
+ }.toMap()
+
+ /** The txids each CPFP child spends outputs of, by the child's txid. */
+ fun spends(recovery: RecoverFundsResponse): Map> =
+ recovery.transactions.mapNotNull { tx ->
+ val hex = tx.cpfpTxHex ?: return@mapNotNull null
+ val child = runCatching { SignedTransactionId.fromHex(hex) }.getOrNull() ?: return@mapNotNull null
+ val inputs = SparkExitTxInputs.decode(hex) ?: return@mapNotNull null
+ child to inputs.map { it.prevTxid }.toSet()
+ }.toMap()
+
+ /** [dead] and every child that spends one of their outputs, directly or through other children. */
+ fun withDescendants(spends: Map>, dead: Set): Set {
+ val all = dead.toMutableSet()
+ while (true) {
+ val added = spends.filter { (child, inputs) -> child !in all && inputs.any { it in all } }.keys
+ if (added.isEmpty()) return all
+ all += added
+ }
+ }
+}
+
+/** A leaf the SDK refused to exit, read from its untyped `SparkError`: `Leaf {id} cannot be exited: {reason}`. */
+object SparkExitUnexitable {
+ const val NOT_RECOVERABLE = "Not recoverable at this fee rate"
+
+ private val LEAF = Regex("""Leaf (\S+?) cannot be exited""")
+ private const val MARKER = "cannot be exited"
+
+ /** [leafId] is empty when the message names no leaf that can be read. */
+ data class Refusal(val leafId: String, val reason: String)
+
+ /** The refusal [error] reports, or null for any other error. */
+ fun parse(error: Throwable): Refusal? {
+ if (error !is SdkException.SparkException) return null
+ val message = error.v1
+ if (MARKER !in message) return null
+ return Refusal(LEAF.find(message)?.groupValues?.get(1).orEmpty(), message)
+ }
+}
+
+/** The inputs of a raw transaction, for relative timelocks: what each spends and its BIP 68 block lock. */
+object SparkExitTxInputs {
+ private const val DISABLE_FLAG = 1L shl 31
+ private const val TYPE_FLAG = 1L shl 22
+ private const val VALUE_MASK = 0xffffL
+
+ data class Input(val prevTxid: String, val vout: UInt, val sequence: Long) {
+ /** The relative lock in blocks, 0 when the input has none or a time-based one. */
+ val relativeLockBlocks: UInt
+ get() = if (sequence and DISABLE_FLAG != 0L || sequence and TYPE_FLAG != 0L) {
+ 0u
+ } else {
+ (sequence and VALUE_MASK).toUInt()
+ }
+ }
+
+ /** The inputs, with no relative lock before version 2 (BIP 68), or null when [hex] is not a transaction. */
+ fun decode(hex: String): List? = runCatching {
+ val bytes = hex.hexToByteArray()
+ var offset = 0
+ fun take(length: Int): ByteArray {
+ require(length >= 0 && offset + length <= bytes.size)
+ return bytes.copyOfRange(offset, offset + length).also { offset += length }
+ }
+ fun le(length: Int): Long = take(
+ length
+ ).foldIndexed(0L) { i, sum, b -> sum or ((b.toLong() and 0xff) shl (8 * i)) }
+ fun compactSize(): Int = when (val prefix = le(1).toInt()) {
+ 0xfd -> le(2).toInt()
+ 0xfe -> le(4).toInt()
+ 0xff -> error("Too many items")
+ else -> prefix
+ }
+ val version = le(4)
+ if (bytes[offset] == 0.toByte()) take(2)
+ val count = compactSize()
+ require(count > 0)
+ List(count) {
+ val prevTxid = take(32).reversedArray().toHexString()
+ val vout = le(4).toUInt()
+ take(compactSize())
+ val sequence = le(4)
+ Input(prevTxid, vout, if (version >= 2) sequence else DISABLE_FLAG)
+ }
+ }.getOrNull()
+}
diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitFunding.kt b/app/src/main/java/to/bitkit/repositories/SparkExitFunding.kt
new file mode 100644
index 0000000000..f00fea449b
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/SparkExitFunding.kt
@@ -0,0 +1,354 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.ExitTransactionStatus
+import breez_sdk_spark.PrepareRecoverFundsResponse
+import breez_sdk_spark.RecoverFundsResponse
+import breez_sdk_spark.RecoveryTransaction
+import breez_sdk_spark.RecoveryTxKind
+import org.lightningdevkit.ldknode.Network
+import to.bitkit.models.safe
+import to.bitkit.utils.AppError
+import to.bitkit.utils.BlockTimeHelpers
+import java.math.BigInteger
+import java.security.MessageDigest
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * 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.
+ */
+object SparkExitFundingKey {
+ /** The BIP84 account of the funding key, outside every account ldk-node and the sweep tools derive. */
+ const val ACCOUNT = 7797
+
+ private const val WIF_LENGTH = 38
+ private const val PAYLOAD_LENGTH = 34
+ private const val SECRET_LENGTH = 32
+ private const val COMPRESSED_FLAG: Byte = 0x01
+ private const val ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
+
+ data class Derived(
+ val address: String,
+ /** Compressed public key, hex, as `CpfpInput.P2wpkh` takes it. */
+ val pubkey: String,
+ val secretKey: ByteArray,
+ )
+
+ class InvalidWif : AppError("Invalid WIF private key")
+
+ fun path(index: Int, network: Network): String {
+ val coin = if (network == Network.BITCOIN) 0 else 1
+ return "m/84'/$coin'/$ACCOUNT'/0/$index"
+ }
+
+ /** The 32-byte secret of a compressed-key WIF: base58check, version byte first, `0x01` compression flag last. */
+ fun secretKey(wif: String): ByteArray {
+ val decoded = base58Decode(wif) ?: throw InvalidWif()
+ if (decoded.size != WIF_LENGTH) throw InvalidWif()
+ val payload = decoded.copyOfRange(0, PAYLOAD_LENGTH)
+ val sha256 = MessageDigest.getInstance("SHA-256")
+ val checksum = sha256.digest(sha256.digest(payload)).copyOfRange(0, 4)
+ if (!checksum.contentEquals(decoded.copyOfRange(PAYLOAD_LENGTH, WIF_LENGTH))) throw InvalidWif()
+ if (payload[PAYLOAD_LENGTH - 1] != COMPRESSED_FLAG) throw InvalidWif()
+ return payload.copyOfRange(1, 1 + SECRET_LENGTH)
+ }
+
+ internal fun base58Decode(text: String): ByteArray? {
+ var value = BigInteger.ZERO
+ for (char in text) {
+ val digit = ALPHABET.indexOf(char).takeIf { it >= 0 } ?: return null
+ value = value.multiply(BigInteger.valueOf(58)).add(BigInteger.valueOf(digit.toLong()))
+ }
+ val bytes = value.toByteArray().dropWhile { it == 0.toByte() }.toByteArray()
+ val leadingZeros = text.takeWhile { it == '1' }.length
+ return ByteArray(leadingZeros) + bytes
+ }
+}
+
+/**
+ * What an exit costs and returns, from a `prepareRecoverFunds` quote, next to Spark's balance when it was taken
+ * ([sparkBalanceSats]), so value the quote cannot exit is named rather than silently left out.
+ */
+data class SparkExitQuote(val prepared: PrepareRecoverFundsResponse, val sparkBalanceSats: ULong? = null) {
+ /** The value of the leaves the exit takes on-chain. */
+ val exitSats: ULong get() = prepared.recoverableValueSats
+
+ val networkFeeSats: ULong get() = prepared.totalFeeSats
+
+ /** What savings gains overall: the exited value less every fee, those paid from the fee funding included. */
+ val receiveSats: ULong get() = exitSats.safe() - networkFeeSats.safe()
+
+ /** The on-chain payment that funds the CPFP fees, or 0 when every leaf is recovered cooperatively. */
+ val fundingSats: ULong get() = prepared.funding?.singleUtxoSats ?: 0uL
+
+ /**
+ * What the sweep and the cooperative recoveries pay to savings: [receiveSats] plus the fee funding the exit did
+ * not spend, which the sweep returns.
+ */
+ val incomingSats: ULong get() = receiveSats.safe() + fundingSats.safe()
+
+ val leftBehindSats: ULong get() = prepared.skipped.fold(0uL) { sum, leaf -> sum.safe() + leaf.valueSats.safe() }
+
+ val leafIds: List get() = prepared.leaves.map { it.leafId }
+
+ /**
+ * What the quote cannot exit now: the leaves it skips, and Spark's balance beyond every leaf it lists, such as a
+ * counter-swap still unclaimed. Null when nothing.
+ */
+ val unexitableSats: ULong? get() = SparkExitMath.unexitableSats(sparkBalanceSats, this)
+
+ /** Spark's balance beyond every leaf the quote lists, skipped ones included. */
+ val beyondLeavesSats: ULong?
+ get() {
+ val leaves = prepared.leaves.fold(0uL) { sum, leaf -> sum.safe() + leaf.valueSats.safe() }
+ return SparkExitMath.outsideExitSats(leaves.safe() + leftBehindSats.safe(), sparkBalanceSats)
+ }
+
+ /** Breez's rule: only exit when the value is more than its fees. */
+ val isWorthExiting: Boolean get() = prepared.leaves.isNotEmpty() && exitSats > networkFeeSats
+}
+
+object SparkExitMath {
+ /** The block time of Spark's regtest, which mines one every ~30 seconds. */
+ private val REGTEST_BLOCK_TIME = 30.seconds
+
+ private val BLOCK_TIME = 10.minutes
+
+ /** The countdown target: the latest block at which a timelocked step of the exit can confirm. */
+ fun claimableAtHeight(recovery: RecoverFundsResponse): UInt? = recovery.transactions
+ .mapNotNull { (it.status as? ExitTransactionStatus.WaitingForTimelock)?.spendableAtHeight }
+ .maxOrNull()
+
+ /** The next height above [tip] at which a timelocked step matures, for the reminder. */
+ fun nextMaturityHeight(recovery: RecoverFundsResponse, tip: UInt): UInt? = recovery.transactions
+ .mapNotNull { (it.status as? ExitTransactionStatus.WaitingForTimelock)?.spendableAtHeight }
+ .filter { it > tip }
+ .minOrNull()
+
+ /** Whether every refund's timelock is behind it: none still waits for its parents or its height. */
+ fun areTimelocksMatured(transactions: List): Boolean {
+ val refunds = transactions.filter { it.kind == RecoveryTxKind.REFUND }
+ return refunds.isNotEmpty() && refunds.none {
+ it.status == ExitTransactionStatus.WaitingForDependencies ||
+ it.status is ExitTransactionStatus.WaitingForTimelock
+ }
+ }
+
+ /**
+ * The first block that can include [tx], as the SDK computes it: for each input with a BIP 68 block lock, the
+ * confirmation height of the transaction it spends plus the lock, the highest of them. [parentHeights] holds the
+ * heights read for the parents, null where unknown, which leaves the whole step unknown. A transaction that cannot
+ * be decoded counts [RecoveryTransaction.csvTimelockBlocks] from each of its `dependsOn`. Zero when nothing is
+ * locked.
+ */
+ fun spendableAtHeight(tx: RecoveryTransaction, parentHeights: Map): UInt? {
+ val locks = SparkExitTxInputs.decode(tx.txHex)
+ ?.filter { it.relativeLockBlocks > 0u }
+ ?.map { it.prevTxid to it.relativeLockBlocks }
+ ?: tx.dependsOn.map { it to (tx.csvTimelockBlocks ?: 0u) }
+ var spendable = 0u
+ for ((parent, blocks) in locks) {
+ val height = parentHeights[parent] ?: return null
+ spendable = maxOf(spendable, height + blocks)
+ }
+ return spendable
+ }
+
+ /** Nodes accept a transaction that would be valid in the next block. */
+ fun isMature(spendableAtHeight: UInt, tip: UInt): Boolean = tip + 1u >= spendableAtHeight
+
+ /** The steps already in a block, for progress. */
+ fun confirmedCount(recovery: RecoverFundsResponse): Int =
+ recovery.transactions.count { it.status is ExitTransactionStatus.Confirmed }
+
+ /**
+ * UTXOs at the funding address that are new funding: neither an adopted input nor change a CPFP child of this or
+ * an earlier generation chained there.
+ */
+ fun newFunding(utxos: List, record: SparkExitRecord): List {
+ val adopted = record.funding.mapNotNull { f -> f.txid?.let { "$it:${f.vout ?: 0u}" } }.toSet()
+ val chained = (record.recovery?.let(SparkExitTxids::cpfpChildTxids).orEmpty() + record.earlierCpfpTxids).toSet()
+ return utxos.filter { "${it.txid}:${it.vout}" !in adopted && it.txid !in chained }
+ }
+
+ /** The confirmed value at the funding address that is not new funding: unspent inputs and their CPFP change. */
+ fun heldSats(utxos: List, record: SparkExitRecord): ULong {
+ val fresh = newFunding(utxos, record).toSet()
+ return utxos
+ .filter { it.status.confirmed && it !in fresh }
+ .fold(0uL) { sum, utxo -> sum.safe() + utxo.value.safe() }
+ }
+
+ /**
+ * The funding a rebuild needs in all, from the SDK's [requiredSats]: the total its inputs must hold, not the extra.
+ * What is still [held] at the address counts, so the shortfall is `requiredSats - held`, at least 1; without a
+ * reading of the address, `max(requiredSats, fundedSats)`, right for a first build where nothing is spent yet.
+ */
+ fun neededFunding(requiredSats: ULong, fundedSats: ULong, held: ULong?): ULong {
+ if (held == null) return maxOf(requiredSats, fundedSats)
+ val missing = if (requiredSats > held) requiredSats - held else 0uL
+ return fundedSats.safe() + maxOf(missing, 1uL).safe()
+ }
+
+ /** The skipped leaves of [quote] plus Spark's [balanceSats] beyond every leaf it lists, or null when nothing. */
+ fun unexitableSats(balanceSats: ULong?, quote: SparkExitQuote): ULong? {
+ val beyond = quote.copy(sparkBalanceSats = balanceSats).beyondLeavesSats ?: 0uL
+ return (quote.leftBehindSats.safe() + beyond.safe()).takeIf { it > 0uL }
+ }
+
+ /**
+ * What Spark's [balanceSats] holds beyond [record]'s leaves, in any phase: what a stopped exit leaves on Spark,
+ * such as value it could not exit. Lost leaves stay in the leaf total, so they are never claimed to stay.
+ */
+ fun staysOnSparkSats(balanceSats: ULong?, record: SparkExitRecord?): ULong? =
+ record?.let { outsideExitSats(it.leafTotalSats, balanceSats) }
+
+ /** Spark's balance beyond the exit's leaves while it still counts them, or null. */
+ fun outsideExitSats(leafTotalSats: ULong?, balanceSats: ULong?): ULong? {
+ if (leafTotalSats == null || balanceSats == null || balanceSats <= leafTotalSats) return null
+ return balanceSats - leafTotalSats
+ }
+
+ /**
+ * What progress shows while the exit still needs fee funding, or null when it does not. [utxos] is what the funding
+ * address holds, null when it could not be read. A payment from savings that has not confirmed, or new funding
+ * covering the shortfall that has not, is waited for; only what is still missing is asked for, at least dust.
+ */
+ fun fundingState(record: SparkExitRecord, utxos: List?): SparkExitFundingState? {
+ val shortfall = record.fundingShortfallSats ?: return null
+ record.fundingPaymentTxid?.let { txid ->
+ val isAdopted = record.funding.any { it.txid == txid }
+ val isConfirmed = utxos?.any { it.txid == txid && it.status.confirmed } == true
+ if (!isAdopted && !isConfirmed) return SparkExitFundingState.Paid(txid)
+ }
+ val fresh = utxos?.let { newFunding(it, record) }.orEmpty()
+ val seen = fresh.fold(0uL) { sum, utxo -> sum.safe() + utxo.value.safe() }
+ if (fresh.isNotEmpty() && seen >= shortfall) {
+ // Covered: waiting for it to confirm, or for the next tick to take it once it has.
+ val txid = (fresh.firstOrNull { !it.status.confirmed } ?: fresh.first()).txid
+ return SparkExitFundingState.Seen(txid)
+ }
+ // Nodes refuse an output under its dust limit, so a smaller remainder is asked as the funding output's dust.
+ return SparkExitFundingState.Ask(maxOf(shortfall - seen, FUNDING_DUST_SATS), seen, shortfall)
+ }
+
+ /** What reached the funding address against [requiredSats], once some but not all of it did; else null. */
+ fun partialFunding(receivedSats: ULong, requiredSats: ULong): SparkExitPartialFunding? {
+ if (receivedSats == 0uL || receivedSats >= requiredSats) return null
+ return SparkExitPartialFunding(
+ receivedSats,
+ requiredSats,
+ maxOf(requiredSats - receivedSats, FUNDING_DUST_SATS)
+ )
+ }
+
+ /** Every confirmed UTXO of new funding, the largest first, once together they cover [requiredSats]. */
+ fun fundingTopUp(utxos: List, requiredSats: ULong): List? {
+ val confirmed = utxos.filter { it.status.confirmed }.sortedByDescending { it.value }
+ if (confirmed.isEmpty()) return null
+ val total = confirmed.fold(0uL) { sum, utxo -> sum.safe() + utxo.value.safe() }
+ return confirmed.takeIf { total >= requiredSats }
+ }
+
+ /** About how long until [height]. */
+ fun timeUntil(height: UInt, tip: UInt, network: Network): Duration {
+ val blockTime = if (network == Network.REGTEST) REGTEST_BLOCK_TIME else BLOCK_TIME
+ return blockTime * BlockTimeHelpers.blocksRemaining(height, tip)
+ }
+
+ /** The dust limit of the funding address's P2WPKH output, the least a top-up can pay. */
+ const val FUNDING_DUST_SATS = 294uL
+
+ /** About how many blocks after a step matures the operators may publish their own version of it. */
+ private const val OPERATOR_WINDOW_BLOCKS = 50
+
+ /**
+ * How many blocks a matured refund leads an older state's refund of the same leaf by, the hard limit while the
+ * operators are down.
+ */
+ private const val OLDER_STATE_LEAD_BLOCKS = 100
+
+ /** Blocks after a step matures before Bitkit warns that it was not published: 38 of the 50 are left to publish. */
+ private const val WARN_GRACE_BLOCKS = 12
+
+ /**
+ * About how long until a step can be published: zero while one already can (its parents confirmed, and ready or
+ * its timelock behind [tip]) or when it is unknown, else until the next step matures.
+ */
+ fun untilNextMaturity(recovery: RecoverFundsResponse?, tip: UInt?, network: Network): Duration {
+ if (recovery == null || tip == null) return Duration.ZERO
+ val height: UInt = tip
+ val confirmed = recovery.transactions.filter { it.status is ExitTransactionStatus.Confirmed }.map { it.txid }
+ val isPublishable = recovery.transactions.any { tx ->
+ val maturesAt = (tx.status as? ExitTransactionStatus.WaitingForTimelock)?.spendableAtHeight
+ val isDue = tx.status == ExitTransactionStatus.Ready || tx.status == ExitTransactionStatus.Unverified ||
+ (maturesAt != null && maturesAt <= height)
+ isDue && tx.dependsOn.all { it in confirmed }
+ }
+ if (isPublishable) return Duration.ZERO
+ val maturity = nextMaturityHeight(recovery, height) ?: return Duration.ZERO
+ return timeUntil(maturity, height, network)
+ }
+
+ /**
+ * How long a matured step is ours to publish: the smaller of the operators' window and an older state's lead, about
+ * 8 hours on mainnet, 25 minutes on Spark's regtest.
+ */
+ fun operatorWindow(network: Network): Duration =
+ (if (network == Network.REGTEST) REGTEST_BLOCK_TIME else BLOCK_TIME) *
+ minOf(OPERATOR_WINDOW_BLOCKS, OLDER_STATE_LEAD_BLOCKS)
+
+ /**
+ * When Bitkit warns that the background run fell behind, and the deadline it names: [untilMaturity] is how long
+ * until the next step matures, zero while steps already wait to be published. The deadline is the end of the
+ * window; the warning comes 12 blocks after maturity, well before the operators or an older state may publish.
+ */
+ fun deadline(untilMaturity: Duration, network: Network): SparkExitDeadline {
+ val grace = blockTime(network) * WARN_GRACE_BLOCKS
+ return SparkExitDeadline(warnIn = untilMaturity + grace, deadlineIn = untilMaturity + operatorWindow(network))
+ }
+
+ private fun blockTime(network: Network) = if (network == Network.REGTEST) REGTEST_BLOCK_TIME else BLOCK_TIME
+
+ /**
+ * The funding key index of the next exit: past the one the last exit took ([kept]), and past the [stored] record's,
+ * which covers exits started before the index was kept.
+ */
+ fun nextFundingIndex(kept: Int?, stored: SparkExitRecord?): UInt {
+ val pastStored = stored?.funding?.maxOfOrNull { it.index + 1u } ?: 0u
+ return maxOf(kept?.toUInt() ?: 0u, pastStored)
+ }
+
+ /** Savings can pay the fee funding when its spendable balance covers the funding and the payment's own fee. */
+ fun canFundFromSavings(spendableSats: ULong, fundingSats: ULong, paymentFeeSats: ULong): Boolean =
+ fundingSats > 0uL && spendableSats >= fundingSats.safe() + paymentFeeSats.safe()
+}
+
+/** The fee funding an exit still needs, as progress shows it. */
+sealed interface SparkExitFundingState {
+ /** Savings paid it in [txid], which has not confirmed yet. */
+ data class Paid(val txid: String) : SparkExitFundingState
+
+ /** A payment covering it, [txid] among them, was seen and waits to confirm. */
+ data class Seen(val txid: String) : SparkExitFundingState
+
+ /**
+ * [sats] are still missing at the funding address, at least dust, after [receivedSats] of new funding seen there
+ * against a shortfall of [requiredSats].
+ */
+ data class Ask(
+ val sats: ULong,
+ val receivedSats: ULong = 0uL,
+ val requiredSats: ULong = sats,
+ ) : SparkExitFundingState {
+ val partial: SparkExitPartialFunding? get() = SparkExitMath.partialFunding(receivedSats, requiredSats)
+ }
+}
+
+/** A funding payment short of the need: [receivedSats] of [requiredSats] arrived, [missingSats] (at least dust) due. */
+data class SparkExitPartialFunding(val receivedSats: ULong, val requiredSats: ULong, val missingSats: ULong)
+
+/** When to warn that the exit fell behind, and when its next step's operator window closes, both from now. */
+data class SparkExitDeadline(val warnIn: Duration, val deadlineIn: Duration)
diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt b/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt
new file mode 100644
index 0000000000..1703b17d12
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt
@@ -0,0 +1,824 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.CooperativeRecoveryError
+import breez_sdk_spark.CooperativeRecoveryFailure
+import breez_sdk_spark.CpfpInput
+import breez_sdk_spark.ExitTransactionStatus
+import breez_sdk_spark.RecoverFundsLeaf
+import breez_sdk_spark.RecoverFundsResponse
+import breez_sdk_spark.RecoveryMethod
+import breez_sdk_spark.RecoveryTransaction
+import breez_sdk_spark.RecoveryTxKind
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.put
+import to.bitkit.models.safe
+import to.bitkit.utils.AppError
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+/**
+ * Everything a Spark emergency exit needs to continue after a restart. The SDK keeps none of it: the signed
+ * recovery, the request it came from, how its fees were funded and what this device already broadcast. Times are
+ * epoch milliseconds. The JSON shape is shared with Bitkit iOS.
+ */
+data class SparkExitRecord(
+ val version: Int = CURRENT_VERSION,
+ val network: String,
+ val createdAt: Long,
+ val phase: Phase,
+ val feeRateSatPerVbyte: ULong,
+ val destination: String,
+ /** The leaves of the quote the user confirmed; every rebuild selects exactly these. */
+ val leafIds: List,
+ val funding: List,
+ /**
+ * Bumped on every rebuild. Broadcasts are logged per generation and a rebuilt recovery replaces the old one
+ * entirely, so older hex is never sent again.
+ */
+ val generation: Int = 0,
+ val recovery: RecoverFundsResponse? = null,
+ val broadcastLog: List = emptyList(),
+ val transferId: String? = null,
+ /** What reaches savings: the exited value less the fees paid out of it, plus the unspent fee funding. */
+ val expectedSats: ULong,
+ val claimableAtHeight: UInt? = null,
+ val leftover: List = emptyList(),
+ val skippedLeafCount: Int = 0,
+ /** Cooperative leaves the last build could not recover. */
+ val failedLeafCount: Int = 0,
+ /** Set and saved before a rebuild, so nothing of the diverged generation is broadcast while it is replaced. */
+ val isRebuilding: Boolean = false,
+ val lastBuildAt: Long? = null,
+ /** The value of the quote's leaves, to show what Spark holds outside the exit. */
+ val leafTotalSats: ULong? = null,
+ /** The CPFP child txids of replaced generations, whose change at the funding address is not new funding. */
+ val earlierCpfpTxids: List = emptyList(),
+ /** The sweep of [leftover] to [destination] once the exit ended. */
+ val leftoverSweep: LeftoverSweep? = null,
+ /**
+ * Per step txid, how many rebuilds in a row reproduced a step whose input was already spent. At two, an older
+ * state of this wallet claimed its leaf, which no rebuild can recover.
+ */
+ val spentStepStrikes: Map = emptyMap(),
+ /** The payment from savings that funded the exit, kept so progress never asks for it again while it confirms. */
+ val fundingPaymentTxid: String? = null,
+ /** Leaves an older state of this wallet claimed on-chain; builds leave them out. */
+ val lostLeaves: List = emptyList(),
+ /** The user opened the result of an exit that failed or lost leaves, so Emergency exit may start a new one. */
+ val isResultSeen: Boolean = false,
+ /** Leaves a rebuild could not exit, such as a fee-ladder leaf whose refund cannot pay its fee; left on Spark. */
+ val leftBehindLeaves: List = emptyList(),
+ /** Steps that already started a rebuild after their fee went unpaid several times in a row; each starts one. */
+ val feeChainRebuildTxids: List = emptyList(),
+) {
+ companion object {
+ const val CURRENT_VERSION = 1
+
+ /** Rebuilds in a row that reproduce a spent step before its leaf counts as claimed by an older state. */
+ const val LOST_AFTER_STRIKES = 2
+ }
+
+ sealed interface Phase {
+ /** Waiting for confirmed fee funding of [requiredSats] in all at the funding address before the first build. */
+ data class AwaitingFunding(val requiredSats: ULong) : Phase
+
+ /** Built and being driven to the chain. */
+ data object Running : Phase
+
+ /** A rebuild needs fee funding of [requiredSats] in all, more than the funding address received so far. */
+ data class NeedsFunding(val requiredSats: ULong) : Phase
+
+ data object Done : Phase
+
+ data class Failed(val reason: String) : Phase
+
+ val requiredFundingSats: ULong?
+ get() = when (this) {
+ is AwaitingFunding -> requiredSats
+ is NeedsFunding -> requiredSats
+ else -> null
+ }
+ }
+
+ data class Funding(
+ /** The last path component under the funding key's account. */
+ val index: UInt,
+ val address: String,
+ val pubkey: String,
+ val txid: String? = null,
+ val vout: UInt? = null,
+ val valueSats: ULong? = null,
+ ) {
+ val input: CpfpInput?
+ get() {
+ if (txid == null || vout == null || valueSats == null) return null
+ return CpfpInput.P2wpkh(txid, vout, valueSats, pubkey)
+ }
+ }
+
+ data class BroadcastAttempt(
+ val generation: Int,
+ val txid: String,
+ val attempts: Int,
+ val rejections: Int,
+ val lastAttemptAt: Long,
+ val lastOutcome: String,
+ val isAccepted: Boolean,
+ /** Its parents confirmed but an input was already spent: the keyless pass stops sending it. */
+ val isInputSpent: Boolean = false,
+ /** Sends in a row, parents confirmed, that came back unpaid or with missing inputs; other outcomes reset it. */
+ val feeChainFailures: Int = 0,
+ )
+
+ data class Leftover(
+ val txid: String,
+ val vout: UInt,
+ val valueSats: ULong,
+ )
+
+ data class LostLeaf(val leafId: String, val valueSats: ULong)
+
+ data class LeftBehindLeaf(val leafId: String, val valueSats: ULong, val reason: String)
+
+ /** A built sweep of the leftover, saved before it is sent; a null [txid] means it was dust and nothing went. */
+ data class LeftoverSweep(
+ val txid: String?,
+ val txHex: String?,
+ val valueSats: ULong,
+ val isAccepted: Boolean = false,
+ val rejection: String? = null,
+ val isConfirmed: Boolean = false,
+ )
+
+ /** The leftover still has to be swept: not built yet, or built and neither confirmed nor rejected. */
+ val isLeftoverSweepPending: Boolean
+ get() {
+ if (leftover.isEmpty()) return false
+ val sweep = leftoverSweep ?: return true
+ return sweep.txid != null && sweep.rejection == null && !sweep.isConfirmed
+ }
+
+ /**
+ * Emergency exit opens progress, not a new exit, while this one runs and until its leftover fee funding is swept
+ * and confirmed, or kept as dust. A rejected sweep keeps it too: progress names the address the leftover is at.
+ * An exit that failed or lost leaves keeps it until the user opened it once.
+ */
+ val showsResult: Boolean
+ get() = isActive || isLeftoverSweepPending || (leftover.isNotEmpty() && leftoverSweep?.rejection != null) ||
+ hasUnseenResult
+
+ /** The exit ended failed or with leaves lost, and the user has not opened its result yet. */
+ val hasUnseenResult: Boolean
+ get() = !isActive && (phase is Phase.Failed || lostSats > 0uL) && !isResultSeen
+
+ /** Background runs and the foreground loop keep going while the exit runs and until its leftover is swept. */
+ val needsDriving: Boolean get() = isActive || isLeftoverSweepPending
+
+ val isActive: Boolean
+ get() = when (phase) {
+ is Phase.AwaitingFunding, Phase.Running, is Phase.NeedsFunding -> true
+ Phase.Done, is Phase.Failed -> false
+ }
+
+ val fundingInputs: List get() = funding.mapNotNull { it.input }
+
+ /** The funding key index of this exit; records from before exits had their own index hold 0. */
+ val fundingIndex: UInt get() = funding.firstOrNull()?.index ?: 0u
+
+ /** The leaves builds select: the quote's, less those an older state claimed and those that cannot be exited. */
+ val exitLeafIds: List
+ get() = leafIds - (lostLeaves.map { it.leafId } + leftBehindLeaves.map { it.leafId }).toSet()
+
+ /** Every leaf of the exit was claimed by an older state, none left behind. */
+ val isEveryLeafLost: Boolean
+ get() = lostLeaves.isNotEmpty() && lostLeaves.map { it.leafId }.containsAll(leafIds)
+
+ val lostSats: ULong get() = lostLeaves.fold(0uL) { sum, leaf -> sum.safe() + leaf.valueSats.safe() }
+
+ val leftBehindLeafSats: ULong get() = leftBehindLeaves.fold(0uL) { sum, leaf -> sum.safe() + leaf.valueSats.safe() }
+
+ /** Leaves [leafId] on Spark for [reason], valued from the current recovery, else 0 before the first build. */
+ fun leaveBehind(leafId: String, reason: String): SparkExitRecord =
+ copy(leftBehindLeaves = leftBehindLeaves + LeftBehindLeaf(leafId, leafValueSats(leafId), reason))
+
+ /** What the exit knows of [leafId]'s value, from its current recovery, else 0. */
+ fun leafValueSats(leafId: String): ULong = recovery?.leaves?.firstOrNull { it.leafId == leafId }?.valueSats ?: 0uL
+
+ val fundedSats: ULong get() = funding.fold(0uL) { sum, f -> sum.safe() + (f.valueSats ?: 0uL).safe() }
+
+ /** What the funding address still has to receive before the exit can build, or null when nothing is missing. */
+ val fundingShortfallSats: ULong?
+ get() {
+ val required = phase.requiredFundingSats ?: return null
+ val shortfall = required.safe() - fundedSats.safe()
+ return shortfall.takeIf { it > 0uL }
+ }
+
+ /** Starts a new generation from a fresh build, forgetting what was broadcast for the previous one. */
+ fun adopt(built: RecoverFundsResponse, atMillis: Long): SparkExitRecord = copy(
+ spentStepStrikes = spentStepStrikes(built),
+ earlierCpfpTxids = (earlierCpfpTxids + recovery?.let(SparkExitTxids::cpfpChildTxids).orEmpty()).distinct(),
+ generation = generation + 1,
+ recovery = built,
+ broadcastLog = emptyList(),
+ phase = Phase.Running,
+ isRebuilding = false,
+ lastBuildAt = atMillis,
+ failedLeafCount = built.failed.size,
+ )
+
+ /**
+ * The strikes after adopting [built]: a step of it that had its input spent in the generation it replaces gets one
+ * more; every other step drops out.
+ */
+ fun spentStepStrikes(built: RecoverFundsResponse): Map {
+ val spent = broadcastLog.filter { it.generation == generation && it.isInputSpent }.map { it.txid }.toSet()
+ return built.transactions
+ .filter { it.txid in spent }
+ .associate { it.txid to (spentStepStrikes[it.txid] ?: 0) + 1 }
+ }
+
+ /** The leaves [built] would lose again: a refund of an exiting leaf reproduced with its input spent twice. */
+ fun lostLeaves(built: RecoverFundsResponse): List {
+ val strikes = spentStepStrikes(built)
+ val values = (recovery?.leaves.orEmpty() + built.leaves).associate { it.leafId to it.valueSats }
+ return built.transactions
+ .filter { it.kind == RecoveryTxKind.REFUND && (strikes[it.txid] ?: 0) >= LOST_AFTER_STRIKES }
+ .mapNotNull { it.nodeId?.takeIf { id -> id in exitLeafIds } }
+ .distinct()
+ .map { LostLeaf(it, values[it] ?: 0uL) }
+ }
+}
+
+/**
+ * Reads and writes the exit record of one wallet. Writes go to a temporary file first and replace the record in one
+ * move, so a crash never leaves half a record. Files in the app's storage are readable after the first unlock, so
+ * the background worker can drive the exit while the device is locked.
+ */
+class SparkExitStore(private val file: File) {
+ fun load(): SparkExitRecord? {
+ if (!file.exists()) return null
+ return SparkExitJson.decode(file.readText())
+ }
+
+ /** Whether an exit runs, false when the record is missing or unreadable. */
+ fun isActive(): Boolean = runCatching { load()?.isActive }.getOrNull() == true
+
+ /** Moves an unreadable record out of the way, keeping it for support. */
+ fun setAside(nowMillis: Long) {
+ val aside = File(file.parentFile, "${file.nameWithoutExtension}.unreadable-$nowMillis.json")
+ Files.move(file.toPath(), aside.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+
+ fun save(record: SparkExitRecord) {
+ file.parentFile?.mkdirs()
+ val temp = File(file.parentFile, "${file.name}.tmp")
+ temp.writeText(SparkExitJson.encode(record))
+ Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
+ }
+
+ fun delete() {
+ file.delete()
+ }
+}
+
+/**
+ * The ids of the leaves an older state of one wallet claimed on-chain. The operators keep listing such a leaf, so it
+ * outlives the exit record that found it: a later exit neither quotes nor builds it again. Not backed up; a restored
+ * wallet finds a lost leaf again in one rebuild.
+ */
+class SparkExitLostLeafStore(private val file: File) {
+ fun load(): Set {
+ if (!file.exists()) return emptySet()
+ return Json.parseToJsonElement(file.readText()).jsonArray.map { it.jsonPrimitive.content }.toSet()
+ }
+
+ /** Adds [leafIds], writing only when one is new; an unreadable file is written anew. */
+ fun add(leafIds: Collection) {
+ val known = runCatching { load() }.getOrDefault(emptySet())
+ if (known.containsAll(leafIds)) return
+ file.parentFile?.mkdirs()
+ val temp = File(file.parentFile, "${file.name}.tmp")
+ temp.writeText(JsonArray((known + leafIds).sorted().map(::JsonPrimitive)).toString())
+ Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
+ }
+
+ fun delete() {
+ file.delete()
+ }
+}
+
+class SparkExitFormatError(message: String) : AppError(message)
+
+class SparkExitUnsupportedVersion(val version: Int) : AppError("Unsupported Spark exit record version '$version'")
+
+/**
+ * The exit record as JSON. The record's own fields follow Swift's synthesized `Codable` (camelCase, an enum case as
+ * `{"running":{}}`, absent optionals left out), and `recovery` follows spark-sdk 0.27.2's serde shape: snake_case
+ * fields, explicit nulls and externally tagged enums (`"Ready"`, `{"Confirmed":{"block_height":120}}`), so a stored
+ * recovery is readable by the Breez CLI's `check-recover-funds`. Built by hand so the app's `Json` settings cannot
+ * change it.
+ */
+object SparkExitJson {
+ private val json = Json { ignoreUnknownKeys = true }
+
+ fun encode(record: SparkExitRecord): String = recordToJson(record).toString()
+
+ fun decode(text: String): SparkExitRecord {
+ val root = json.parseToJsonElement(text).jsonObject
+ val version = root.int("version")
+ if (version != SparkExitRecord.CURRENT_VERSION) throw SparkExitUnsupportedVersion(version)
+ return recordFromJson(root)
+ }
+
+ fun encodeRecovery(recovery: RecoverFundsResponse): String = recoveryToJson(recovery).toString()
+
+ fun decodeRecovery(text: String): RecoverFundsResponse =
+ recoveryFromJson(json.parseToJsonElement(text).jsonObject)
+
+ private fun recordToJson(r: SparkExitRecord) = buildJsonObject {
+ put("version", r.version)
+ put("network", r.network)
+ put("createdAt", r.createdAt)
+ put("phase", phaseToJson(r.phase))
+ put("feeRateSatPerVbyte", r.feeRateSatPerVbyte.toLong())
+ put("destination", r.destination)
+ put("leafIds", buildJsonArray { r.leafIds.forEach { add(JsonPrimitive(it)) } })
+ put(
+ "funding",
+ buildJsonArray {
+ r.funding.forEach {
+ add(
+ buildJsonObject {
+ put("index", it.index.toLong())
+ put("address", it.address)
+ put("pubkey", it.pubkey)
+ it.txid?.let { txid -> put("txid", txid) }
+ it.vout?.let { vout -> put("vout", vout.toLong()) }
+ it.valueSats?.let { value -> put("valueSats", value.toLong()) }
+ },
+ )
+ }
+ },
+ )
+ put("generation", r.generation)
+ r.recovery?.let { put("recovery", recoveryToJson(it)) }
+ put(
+ "broadcastLog",
+ buildJsonArray {
+ r.broadcastLog.forEach {
+ add(
+ buildJsonObject {
+ put("generation", it.generation)
+ put("txid", it.txid)
+ put("attempts", it.attempts)
+ put("rejections", it.rejections)
+ put("lastAttemptAt", it.lastAttemptAt)
+ put("lastOutcome", it.lastOutcome)
+ put("isAccepted", it.isAccepted)
+ if (it.isInputSpent) put("isInputSpent", true)
+ if (it.feeChainFailures > 0) put("feeChainFailures", it.feeChainFailures)
+ },
+ )
+ }
+ },
+ )
+ r.transferId?.let { put("transferId", it) }
+ put("expectedSats", r.expectedSats.toLong())
+ r.claimableAtHeight?.let { put("claimableAtHeight", it.toLong()) }
+ put(
+ "leftover",
+ buildJsonArray {
+ r.leftover.forEach {
+ add(
+ buildJsonObject {
+ put("txid", it.txid)
+ put("vout", it.vout.toLong())
+ put("valueSats", it.valueSats.toLong())
+ },
+ )
+ }
+ },
+ )
+ put("skippedLeafCount", r.skippedLeafCount)
+ put("failedLeafCount", r.failedLeafCount)
+ put("isRebuilding", r.isRebuilding)
+ r.lastBuildAt?.let { put("lastBuildAt", it) }
+ r.leafTotalSats?.let { put("leafTotalSats", it.toLong()) }
+ if (r.earlierCpfpTxids.isNotEmpty()) {
+ put("earlierCpfpTxids", buildJsonArray { r.earlierCpfpTxids.forEach { add(JsonPrimitive(it)) } })
+ }
+ r.leftoverSweep?.let { sweep ->
+ put(
+ "leftoverSweep",
+ buildJsonObject {
+ sweep.txid?.let { put("txid", it) }
+ sweep.txHex?.let { put("txHex", it) }
+ put("valueSats", sweep.valueSats.toLong())
+ put("isAccepted", sweep.isAccepted)
+ sweep.rejection?.let { put("rejection", it) }
+ if (sweep.isConfirmed) put("isConfirmed", true)
+ },
+ )
+ }
+ r.fundingPaymentTxid?.let { put("fundingPaymentTxid", it) }
+ if (r.isResultSeen) put("isResultSeen", true)
+ if (r.spentStepStrikes.isNotEmpty()) {
+ put(
+ "spentStepStrikes",
+ buildJsonObject { r.spentStepStrikes.forEach { (txid, strikes) -> put(txid, strikes) } },
+ )
+ }
+ if (r.feeChainRebuildTxids.isNotEmpty()) {
+ put("feeChainRebuildTxids", buildJsonArray { r.feeChainRebuildTxids.forEach { add(JsonPrimitive(it)) } })
+ }
+ if (r.leftBehindLeaves.isNotEmpty()) {
+ put(
+ "leftBehindLeaves",
+ buildJsonArray {
+ r.leftBehindLeaves.forEach {
+ add(
+ buildJsonObject {
+ put("leafId", it.leafId)
+ put("valueSats", it.valueSats.toLong())
+ put("reason", it.reason)
+ },
+ )
+ }
+ },
+ )
+ }
+ if (r.lostLeaves.isNotEmpty()) {
+ put(
+ "lostLeaves",
+ buildJsonArray {
+ r.lostLeaves.forEach {
+ add(
+ buildJsonObject {
+ put("leafId", it.leafId)
+ put("valueSats", it.valueSats.toLong())
+ },
+ )
+ }
+ },
+ )
+ }
+ }
+
+ private fun recordFromJson(o: JsonObject) = SparkExitRecord(
+ version = o.int("version"),
+ network = o.string("network"),
+ createdAt = o.millis("createdAt"),
+ phase = phaseFromJson(o.getValue("phase")),
+ feeRateSatPerVbyte = o.ulong("feeRateSatPerVbyte"),
+ destination = o.string("destination"),
+ leafIds = o.array("leafIds").map { it.jsonPrimitive.content },
+ funding = o.array("funding").map {
+ val f = it.jsonObject
+ SparkExitRecord.Funding(
+ index = f.ulong("index").toUInt(),
+ address = f.string("address"),
+ pubkey = f.string("pubkey"),
+ txid = f.stringOrNull("txid"),
+ vout = f.ulongOrNull("vout")?.toUInt(),
+ valueSats = f.ulongOrNull("valueSats"),
+ )
+ },
+ generation = o.intOrNull("generation") ?: 0,
+ recovery = o.objectOrNull("recovery")?.let(::recoveryFromJson),
+ broadcastLog = o.arrayOrEmpty("broadcastLog").map {
+ val b = it.jsonObject
+ SparkExitRecord.BroadcastAttempt(
+ generation = b.int("generation"),
+ txid = b.string("txid"),
+ attempts = b.int("attempts"),
+ rejections = b.intOrNull("rejections") ?: 0,
+ lastAttemptAt = b.millis("lastAttemptAt"),
+ lastOutcome = b.string("lastOutcome"),
+ isAccepted = b.booleanOrNull("isAccepted") ?: false,
+ isInputSpent = b.booleanOrNull("isInputSpent") ?: false,
+ feeChainFailures = b.intOrNull("feeChainFailures") ?: 0,
+ )
+ },
+ transferId = o.stringOrNull("transferId"),
+ expectedSats = o.ulong("expectedSats"),
+ claimableAtHeight = o.ulongOrNull("claimableAtHeight")?.toUInt(),
+ leftover = o.arrayOrEmpty("leftover").map {
+ val l = it.jsonObject
+ SparkExitRecord.Leftover(l.string("txid"), l.ulong("vout").toUInt(), l.ulong("valueSats"))
+ },
+ skippedLeafCount = o.intOrNull("skippedLeafCount") ?: 0,
+ failedLeafCount = o.intOrNull("failedLeafCount") ?: 0,
+ isRebuilding = o.booleanOrNull("isRebuilding") ?: false,
+ lastBuildAt = o.millisOrNull("lastBuildAt"),
+ leafTotalSats = o.ulongOrNull("leafTotalSats"),
+ earlierCpfpTxids = o.arrayOrEmpty("earlierCpfpTxids").map { it.jsonPrimitive.content },
+ leftoverSweep = o.objectOrNull("leftoverSweep")?.let {
+ SparkExitRecord.LeftoverSweep(
+ txid = it.stringOrNull("txid"),
+ txHex = it.stringOrNull("txHex"),
+ valueSats = it.ulong("valueSats"),
+ isAccepted = it.booleanOrNull("isAccepted") ?: false,
+ rejection = it.stringOrNull("rejection"),
+ isConfirmed = it.booleanOrNull("isConfirmed") ?: false,
+ )
+ },
+ fundingPaymentTxid = o.stringOrNull("fundingPaymentTxid"),
+ isResultSeen = o.booleanOrNull("isResultSeen") ?: false,
+ spentStepStrikes = o.objectOrNull("spentStepStrikes")
+ ?.mapValues { it.value.jsonPrimitive.content.toInt() }
+ .orEmpty(),
+ lostLeaves = o.arrayOrEmpty("lostLeaves").map {
+ val l = it.jsonObject
+ SparkExitRecord.LostLeaf(l.string("leafId"), l.ulong("valueSats"))
+ },
+ leftBehindLeaves = o.arrayOrEmpty("leftBehindLeaves").map {
+ val l = it.jsonObject
+ SparkExitRecord.LeftBehindLeaf(l.string("leafId"), l.ulong("valueSats"), l.string("reason"))
+ },
+ feeChainRebuildTxids = o.arrayOrEmpty("feeChainRebuildTxids").map { it.jsonPrimitive.content },
+ )
+
+ private fun phaseToJson(phase: SparkExitRecord.Phase): JsonObject = when (phase) {
+ is SparkExitRecord.Phase.AwaitingFunding ->
+ tagged("awaitingFunding", buildJsonObject { put("requiredSats", phase.requiredSats.toLong()) })
+ SparkExitRecord.Phase.Running -> tagged("running", JsonObject(emptyMap()))
+ is SparkExitRecord.Phase.NeedsFunding ->
+ tagged("needsFunding", buildJsonObject { put("requiredSats", phase.requiredSats.toLong()) })
+ SparkExitRecord.Phase.Done -> tagged("done", JsonObject(emptyMap()))
+ is SparkExitRecord.Phase.Failed -> tagged("failed", buildJsonObject { put("reason", phase.reason) })
+ }
+
+ private fun phaseFromJson(element: JsonElement): SparkExitRecord.Phase {
+ val (tag, fields) = variant(element)
+ return when (tag) {
+ "awaitingFunding" -> SparkExitRecord.Phase.AwaitingFunding(requireFields(fields).ulong("requiredSats"))
+ "running" -> SparkExitRecord.Phase.Running
+ "needsFunding" -> SparkExitRecord.Phase.NeedsFunding(requireFields(fields).ulong("requiredSats"))
+ "done" -> SparkExitRecord.Phase.Done
+ "failed" -> SparkExitRecord.Phase.Failed(requireFields(fields).string("reason"))
+ else -> throw unknown(tag)
+ }
+ }
+
+ private fun recoveryToJson(r: RecoverFundsResponse) = buildJsonObject {
+ put("recoverable_value_sats", r.recoverableValueSats.toLong())
+ put("total_fee_sats", r.totalFeeSats.toLong())
+ put("cooperative_fee_sats", r.cooperativeFeeSats.toLong())
+ put("cpfp_fee_sats", r.cpfpFeeSats.toLong())
+ put("fanout_fee_sats", r.fanoutFeeSats.toLong())
+ put("refund_fee_sats", r.refundFeeSats.toLong())
+ put("sweep_fee_sats", r.sweepFeeSats.toLong())
+ put("leaves", JsonArray(r.leaves.map(::leafToJson)))
+ put("failed", JsonArray(r.failed.map(::failureToJson)))
+ put("transactions", JsonArray(r.transactions.map(::transactionToJson)))
+ put("funding_inputs", JsonArray(r.fundingInputs.map(::cpfpInputToJson)))
+ put("fee_rate_sat_per_vbyte", r.feeRateSatPerVbyte.toLong())
+ put("destination", r.destination)
+ }
+
+ private fun recoveryFromJson(o: JsonObject) = RecoverFundsResponse(
+ recoverableValueSats = o.ulong("recoverable_value_sats"),
+ totalFeeSats = o.ulong("total_fee_sats"),
+ cooperativeFeeSats = o.ulong("cooperative_fee_sats"),
+ cpfpFeeSats = o.ulong("cpfp_fee_sats"),
+ fanoutFeeSats = o.ulong("fanout_fee_sats"),
+ refundFeeSats = o.ulong("refund_fee_sats"),
+ sweepFeeSats = o.ulong("sweep_fee_sats"),
+ leaves = o.array("leaves").map { leafFromJson(it.jsonObject) },
+ failed = o.array("failed").map { failureFromJson(it.jsonObject) },
+ transactions = o.array("transactions").map { transactionFromJson(it.jsonObject) },
+ fundingInputs = o.array("funding_inputs").map(::cpfpInputFromJson),
+ feeRateSatPerVbyte = o.ulong("fee_rate_sat_per_vbyte"),
+ destination = o.string("destination"),
+ )
+
+ private val methods = mapOf(RecoveryMethod.COOPERATIVE to "Cooperative", RecoveryMethod.UNILATERAL to "Unilateral")
+
+ private val kinds = mapOf(
+ RecoveryTxKind.COOPERATIVE to "Cooperative",
+ RecoveryTxKind.FAN_OUT to "FanOut",
+ RecoveryTxKind.NODE to "Node",
+ RecoveryTxKind.REFUND to "Refund",
+ RecoveryTxKind.SWEEP to "Sweep",
+ )
+
+ private fun leafToJson(leaf: RecoverFundsLeaf) = buildJsonObject {
+ put("leaf_id", leaf.leafId)
+ put("value_sats", leaf.valueSats.toLong())
+ put("method", methods.getValue(leaf.method))
+ }
+
+ private fun leafFromJson(o: JsonObject): RecoverFundsLeaf {
+ val name = o.string("method")
+ val method = methods.entries.firstOrNull { it.value == name }?.key ?: throw unknown(name)
+ return RecoverFundsLeaf(o.string("leaf_id"), o.ulong("value_sats"), method)
+ }
+
+ private fun failureToJson(failure: CooperativeRecoveryFailure) = buildJsonObject {
+ put("leaf_id", failure.leafId)
+ put("output_txid", failure.outputTxid)
+ put("output_vout", failure.outputVout.toLong())
+ put(
+ "error",
+ when (val error = failure.error) {
+ is CooperativeRecoveryError.ReplacementFeeTooLow -> tagged(
+ "ReplacementFeeTooLow",
+ buildJsonObject {
+ put("required_fee_sats", error.requiredFeeSats.toLong())
+ put("required_fee_rate_sat_per_vbyte", error.requiredFeeRateSatPerVbyte.toLong())
+ },
+ )
+ is CooperativeRecoveryError.OperatorsUnavailable ->
+ tagged("OperatorsUnavailable", buildJsonObject { put("message", error.message) })
+ is CooperativeRecoveryError.Generic ->
+ tagged("Generic", buildJsonObject { put("message", error.message) })
+ },
+ )
+ }
+
+ private fun failureFromJson(o: JsonObject): CooperativeRecoveryFailure {
+ val (tag, fields) = variant(o.getValue("error"))
+ val error = when (tag) {
+ "ReplacementFeeTooLow" -> requireFields(fields).let {
+ CooperativeRecoveryError.ReplacementFeeTooLow(
+ it.ulong("required_fee_sats"),
+ it.ulong("required_fee_rate_sat_per_vbyte"),
+ )
+ }
+ "OperatorsUnavailable" -> CooperativeRecoveryError.OperatorsUnavailable(
+ requireFields(fields).string("message")
+ )
+ "Generic" -> CooperativeRecoveryError.Generic(requireFields(fields).string("message"))
+ else -> throw unknown(tag)
+ }
+ return CooperativeRecoveryFailure(
+ o.string("leaf_id"),
+ o.string("output_txid"),
+ o.ulong("output_vout").toUInt(),
+ error
+ )
+ }
+
+ private fun transactionToJson(tx: RecoveryTransaction) = buildJsonObject {
+ put("kind", kinds.getValue(tx.kind))
+ put("node_id", tx.nodeId?.let(::JsonPrimitive) ?: JsonNull)
+ put("txid", tx.txid)
+ put("tx_hex", tx.txHex)
+ put("cpfp_tx_hex", tx.cpfpTxHex?.let(::JsonPrimitive) ?: JsonNull)
+ put("csv_timelock_blocks", tx.csvTimelockBlocks?.let { JsonPrimitive(it.toLong()) } ?: JsonNull)
+ put("depends_on", JsonArray(tx.dependsOn.map(::JsonPrimitive)))
+ put("status", statusToJson(tx.status))
+ }
+
+ private fun transactionFromJson(o: JsonObject): RecoveryTransaction {
+ val kindName = o.string("kind")
+ return RecoveryTransaction(
+ kind = kinds.entries.firstOrNull { it.value == kindName }?.key ?: throw unknown(kindName),
+ nodeId = o.stringOrNull("node_id"),
+ txid = o.string("txid"),
+ txHex = o.string("tx_hex"),
+ cpfpTxHex = o.stringOrNull("cpfp_tx_hex"),
+ csvTimelockBlocks = o.ulongOrNull("csv_timelock_blocks")?.toUInt(),
+ dependsOn = o.array("depends_on").map { it.jsonPrimitive.content },
+ status = statusFromJson(o.getValue("status")),
+ )
+ }
+
+ private fun statusToJson(status: ExitTransactionStatus): JsonElement = when (status) {
+ is ExitTransactionStatus.Confirmed -> tagged(
+ "Confirmed",
+ buildJsonObject { put("block_height", status.blockHeight?.let { JsonPrimitive(it.toLong()) } ?: JsonNull) },
+ )
+ ExitTransactionStatus.Ready -> JsonPrimitive("Ready")
+ ExitTransactionStatus.WaitingForDependencies -> JsonPrimitive("WaitingForDependencies")
+ is ExitTransactionStatus.WaitingForTimelock -> tagged(
+ "WaitingForTimelock",
+ buildJsonObject {
+ put("spendable_at_height", status.spendableAtHeight?.let { JsonPrimitive(it.toLong()) } ?: JsonNull)
+ },
+ )
+ ExitTransactionStatus.Unverified -> JsonPrimitive("Unverified")
+ }
+
+ private fun statusFromJson(element: JsonElement): ExitTransactionStatus {
+ val (tag, fields) = variant(element)
+ return when (tag) {
+ "Confirmed" -> ExitTransactionStatus.Confirmed(requireFields(fields).ulongOrNull("block_height")?.toUInt())
+ "Ready" -> ExitTransactionStatus.Ready
+ "WaitingForDependencies" -> ExitTransactionStatus.WaitingForDependencies
+ "WaitingForTimelock" -> ExitTransactionStatus.WaitingForTimelock(
+ requireFields(fields).ulongOrNull("spendable_at_height")?.toUInt(),
+ )
+ "Unverified" -> ExitTransactionStatus.Unverified
+ else -> throw unknown(tag)
+ }
+ }
+
+ private fun cpfpInputToJson(input: CpfpInput): JsonObject = when (input) {
+ is CpfpInput.P2wpkh -> tagged(
+ "P2wpkh",
+ buildJsonObject {
+ put("txid", input.txid)
+ put("vout", input.vout.toLong())
+ put("value_sats", input.valueSats.toLong())
+ put("pubkey", input.pubkey)
+ },
+ )
+ is CpfpInput.P2tr -> tagged(
+ "P2tr",
+ buildJsonObject {
+ put("txid", input.txid)
+ put("vout", input.vout.toLong())
+ put("value_sats", input.valueSats.toLong())
+ put("pubkey", input.pubkey)
+ },
+ )
+ is CpfpInput.Custom -> tagged(
+ "Custom",
+ buildJsonObject {
+ put("txid", input.txid)
+ put("vout", input.vout.toLong())
+ put("value_sats", input.valueSats.toLong())
+ put("script_pubkey_hex", input.scriptPubkeyHex)
+ put("signed_input_weight", input.signedInputWeight.toLong())
+ },
+ )
+ }
+
+ private fun cpfpInputFromJson(element: JsonElement): CpfpInput {
+ val (tag, fields) = variant(element)
+ val f = requireFields(fields)
+ val txid = f.string("txid")
+ val vout = f.ulong("vout").toUInt()
+ val value = f.ulong("value_sats")
+ return when (tag) {
+ "P2wpkh" -> CpfpInput.P2wpkh(txid, vout, value, f.string("pubkey"))
+ "P2tr" -> CpfpInput.P2tr(txid, vout, value, f.string("pubkey"))
+ "Custom" -> CpfpInput.Custom(
+ txid,
+ vout,
+ value,
+ f.string("script_pubkey_hex"),
+ f.ulong("signed_input_weight")
+ )
+ else -> throw unknown(tag)
+ }
+ }
+
+ /** serde's default enum shape: a unit variant is its name, a variant with fields an object keyed by its name. */
+ private fun variant(element: JsonElement): Pair {
+ if (element is JsonPrimitive && element.isString) return element.content to null
+ val o = element.jsonObject
+ require(o.size == 1) { "Expected one variant key, got '${o.keys}'" }
+ val (tag, fields) = o.entries.first()
+ return tag to (fields as? JsonObject)
+ }
+
+ private fun tagged(tag: String, fields: JsonObject) = JsonObject(mapOf(tag to fields))
+
+ private fun requireFields(fields: JsonObject?): JsonObject = requireNotNull(
+ fields
+ ) { "Variant is missing its fields" }
+
+ private fun unknown(tag: String) = SparkExitFormatError("Unknown variant '$tag'")
+
+ private fun JsonObject.value(key: String): JsonPrimitive? =
+ (this[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }
+
+ private fun JsonObject.string(key: String) = requireNotNull(value(key)) { "Missing '$key'" }.content
+
+ private fun JsonObject.stringOrNull(key: String) = value(key)?.content
+
+ private fun JsonObject.ulong(key: String) = string(key).toULong()
+
+ private fun JsonObject.ulongOrNull(key: String) = value(key)?.content?.toULong()
+
+ private fun JsonObject.int(key: String) = string(key).toInt()
+
+ private fun JsonObject.intOrNull(key: String) = value(key)?.content?.toInt()
+
+ private fun JsonObject.booleanOrNull(key: String) = value(key)?.content?.toBooleanStrictOrNull()
+
+ /** Swift writes dates as a millisecond count that may carry a fraction. */
+ private fun JsonObject.millis(key: String) = string(key).toBigDecimal().toLong()
+
+ private fun JsonObject.millisOrNull(key: String) = value(key)?.content?.toBigDecimal()?.toLong()
+
+ private fun JsonObject.array(key: String) = requireNotNull(this[key]) { "Missing '$key'" }.jsonArray
+
+ private fun JsonObject.arrayOrEmpty(key: String) = (this[key] as? JsonArray).orEmpty()
+
+ private fun JsonObject.objectOrNull(key: String) = this[key] as? JsonObject
+}
diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt
new file mode 100644
index 0000000000..d8c2dfcc40
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt
@@ -0,0 +1,597 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.CpfpFundingKind
+import breez_sdk_spark.CpfpSigner
+import breez_sdk_spark.ExitLeafSelection
+import breez_sdk_spark.PrepareRecoverFundsRequest
+import breez_sdk_spark.RecoveryTxKind
+import breez_sdk_spark.singleKeyCpfpSigner
+import com.synonym.bitkitcore.Activity
+import io.ktor.client.HttpClient
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import to.bitkit.async.appScope
+import to.bitkit.data.CacheStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.di.BgDispatcher
+import to.bitkit.env.Env
+import to.bitkit.ext.nowMs
+import to.bitkit.ext.runSuspendCatching
+import to.bitkit.models.TransactionSpeed
+import to.bitkit.models.TransferType
+import to.bitkit.models.safe
+import to.bitkit.services.CoreService
+import to.bitkit.services.SparkError
+import to.bitkit.services.SparkService
+import to.bitkit.utils.AppError
+import to.bitkit.utils.Logger
+import to.bitkit.utils.ServiceError
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * 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, by WorkManager while it is closed,
+ * and after a notification brings the user back when a timelock matures.
+ */
+@Singleton
+class SparkExitRepo @Inject constructor(
+ private val work: SparkExitWork,
+ @BgDispatcher private val bgDispatcher: CoroutineDispatcher,
+ private val sparkService: SparkService,
+ private val sparkRepo: SparkRepo,
+ private val lightningRepo: LightningRepo,
+ private val transferRepo: TransferRepo,
+ private val activityRepo: ActivityRepo,
+ private val coreService: CoreService,
+ private val keychain: Keychain,
+ private val httpClient: HttpClient,
+ private val clock: Clock,
+ private val sparkExitTip: SparkExitTip,
+ private val cacheStore: CacheStore,
+) {
+ companion object {
+ private const val TAG = "SparkExitRepo"
+
+ /** How often the exit is driven while Bitkit is in the foreground. */
+ private val FOREGROUND_TICK_INTERVAL = 60.seconds
+
+ /** How long a background run waits for Spark to connect before it gives up on the SDK steps. */
+ private val BACKGROUND_CONNECT_TIMEOUT = 2.minutes
+ }
+
+ private val scope = appScope(bgDispatcher, TAG)
+ private val tickMutex = Mutex()
+ private var foregroundJob: Job? = null
+
+ @Volatile
+ private var walletIndex = 0
+
+ @Volatile
+ private var isLoaded = false
+
+ private val _state = MutableStateFlow(SparkExitState())
+ val state: StateFlow = _state.asStateFlow()
+
+ init {
+ scope.launch {
+ sparkRepo.walletWiped.collect {
+ if (it != walletIndex) return@collect
+ work.cancelAll()
+ _state.update { SparkExitState() }
+ }
+ }
+ }
+
+ private val store get() = SparkExitStore(File(Env.sparkExitRecordPath(walletIndex)))
+
+ private val lostLeafStore get() = SparkExitLostLeafStore(File(Env.sparkExitLostLeavesPath(walletIndex)))
+
+ private val chain: SparkExitChain?
+ get() = EsploraSparkExitChain.defaultHosts(sparkService.networkName)
+ .takeIf { it.isNotEmpty() }
+ ?.let { EsploraSparkExitChain(it, httpClient) }
+
+ // region lifecycle
+
+ /** Loads the exit of [walletIndex] once, takes Spark off Lightning while it runs, and settles a finished one. */
+ suspend fun load(walletIndex: Int = sparkRepo.walletIndex): Result = withContext(bgDispatcher) {
+ runSuspendCatching {
+ if (isLoaded && this@SparkExitRepo.walletIndex == walletIndex) return@runSuspendCatching _state.value.record
+ this@SparkExitRepo.walletIndex = walletIndex
+ val record = runSuspendCatching { store.load() }.getOrElse {
+ // Kept aside for support, so a new exit never overwrites it; a new exit prepares from chain state.
+ Logger.error("Failed to read the Spark exit record", it, context = TAG)
+ runSuspendCatching { store.setAside(clock.nowMs()) }
+ null
+ }
+ isLoaded = true
+ _state.update { it.copy(record = record) }
+ rememberLostLeaves(record)
+ record?.let { settleIfFinished(it) }
+ settleOrphanedTransfers(record)
+ if (record?.needsDriving == true) work.schedulePeriodic()
+ record
+ }
+ }
+
+ /** Drives the exit every minute while Bitkit is in the foreground. */
+ fun onAppForegrounded() {
+ if (foregroundJob?.isActive == true) return
+ foregroundJob = scope.launch {
+ load().onFailure { Logger.error("Failed to load the Spark exit", it, context = TAG) }
+ while (isActive && _state.value.record?.needsDriving == true) {
+ tick()
+ delay(FOREGROUND_TICK_INTERVAL)
+ }
+ }
+ }
+
+ fun onAppBackgrounded() {
+ foregroundJob?.cancel()
+ foregroundJob = null
+ }
+
+ /**
+ * One background run: the steps the stored recovery allows without the SDK first, then, once Spark connects, the
+ * SDK check. It never starts the Lightning node. An ended exit only sweeps its leftover, which needs no SDK.
+ */
+ suspend fun runInBackground(): Result = withContext(bgDispatcher) {
+ runSuspendCatching {
+ load().getOrThrow()
+ if (_state.value.record?.needsDriving != true) return@runSuspendCatching _state.value.record
+ tick()
+ if (_state.value.isExiting && !sparkService.isConnected) {
+ withTimeoutOrNull(BACKGROUND_CONNECT_TIMEOUT) { sparkRepo.start(walletIndex) }
+ if (sparkService.isConnected) tick()
+ }
+ _state.value.record
+ }
+ }
+
+ // endregion
+
+ // region tick
+
+ /** Moves the exit one step forward; a tick already running makes this one a no-op. */
+ suspend fun tick(): Result = withContext(bgDispatcher) {
+ if (!tickMutex.tryLock()) return@withContext Result.success(_state.value.record)
+ try {
+ runSuspendCatching { tickLocked() }
+ .onFailure {
+ Logger.error("Failed to drive the Spark exit", it, context = TAG)
+ _state.update { state -> state.copy(lastError = it.message) }
+ }
+ } finally {
+ tickMutex.unlock()
+ // Every tick keeps the background work queued, so one failed or skipped run never ends the chain.
+ if (_state.value.record?.needsDriving == true) work.schedulePeriodic()
+ }
+ }
+
+ private suspend fun tickLocked(): SparkExitRecord? {
+ val current = _state.value.record?.takeIf { it.needsDriving } ?: return _state.value.record
+ val chain = chain ?: return current
+ Logger.debug("Driving Spark exit in phase '${current.phase}'", context = TAG)
+ val tip = runSuspendCatching { chain.tipHeight() }.getOrNull()
+ _state.update { it.copy(tipHeight = tip ?: it.tipHeight) }
+ tip?.let { height -> sparkExitTip.height.update { height } }
+ refreshFundingSeen(chain, current)
+ val store = store
+ val recovering = sparkService.exitRecovering()
+ val driver = SparkExitDriver(
+ chain = chain,
+ recovering = recovering,
+ // The record's own funding key, also for exits started before each exit had an index of its own.
+ makeSigner = { signer(current.fundingIndex) },
+ save = store::save,
+ nowMillis = clock::nowMs,
+ )
+ // Read before this tick's losses are kept, so a leaf lost now is still reported once.
+ val reportedLost = reportedLostLeafIds()
+ val updated = driver.tick(current)
+ rememberLostLeaves(updated)
+ _state.update { it.copy(record = updated, lastError = null) }
+ // A tick that changed the phase, such as a build short of fees, needs the funding state of the new phase.
+ if (updated.phase != current.phase) refreshFundingSeen(chain, updated)
+ // Spark's balance next to the exit's leaves shows value outside the exit.
+ if (recovering != null) _state.update { it.copy(sparkBalanceSats = sparkRepo.state.value.balanceSats) }
+ // A rebuild needs the SDK; every other running step can go out from the stored recovery without it. Steps
+ // refused again and again keep the exit from moving, however often it runs.
+ val canMove = recovering != null || !updated.isRebuilding
+ val isOnTrack = tip != null && canMove && updated.surfacedRejection == null && !updated.isFeeChainStuck
+ afterTick(updated, current, isOnTrack, reportedLost)
+ return updated
+ }
+
+ private suspend fun afterTick(
+ record: SparkExitRecord,
+ previous: SparkExitRecord,
+ isOnTrack: Boolean,
+ reportedLost: Set,
+ ) {
+ record.transferId?.let { transferRepo.updateClaimableAtHeight(it, record.claimableAtHeight) }
+ settleIfFinished(record)
+ updateAlerts(record, previous, isOnTrack, reportedLost)
+ if (!record.isActive && previous.isActive) {
+ Logger.info("Ended Spark exit with phase '${record.phase}'", context = TAG)
+ sparkRepo.setExiting(false)
+ }
+ if (!record.needsDriving) work.cancelAll()
+ }
+
+ /**
+ * 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 load until it holds.
+ */
+ private suspend fun settleIfFinished(record: SparkExitRecord) {
+ val transferId = record.transferId ?: return
+ if (transferRepo.isSettled(transferId).getOrDefault(true)) return
+ var isFinished = !record.isActive
+ val sweep = record.recovery?.transactions?.firstOrNull { it.kind == RecoveryTxKind.SWEEP }
+ val sweepActivity = sweep?.let { activityRepo.getOnchainActivityByTxId(it.txid) }
+ if (sweepActivity != null) {
+ if (!sweepActivity.isTransfer) {
+ activityRepo.updateActivity(sweepActivity.id, Activity.Onchain(sweepActivity.copy(isTransfer = true)))
+ }
+ isFinished = true
+ }
+ if (isFinished) transferRepo.markSettled(transferId)
+ }
+
+ /**
+ * Settles 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.
+ */
+ private suspend fun settleOrphanedTransfers(record: SparkExitRecord?) {
+ val owned = record?.takeIf { it.isActive }?.transferId
+ transferRepo.activeTransfers.first()
+ .filter { it.type == TransferType.SPARK_EXIT && it.id != owned }
+ .forEach {
+ Logger.warn("Settling Spark exit transfer '${it.id}' with no running exit", context = TAG)
+ transferRepo.markSettled(it.id)
+ }
+ }
+
+ private suspend fun refreshFundingSeen(chain: SparkExitChain, record: SparkExitRecord) {
+ val address = record.funding.firstOrNull()?.address ?: return
+ _state.update { it.copy(fundingAddress = address) }
+ if (record.fundingShortfallSats == null) {
+ _state.update { it.copy(fundingState = null) }
+ return
+ }
+ val utxos = runSuspendCatching { chain.utxos(address) }.getOrNull()
+ // Only new funding counts as seen; adopted inputs and their CPFP change are already part of the exit.
+ val seen = utxos?.let { SparkExitMath.newFunding(it, record) }?.fold(0uL) { sum, utxo ->
+ sum.safe() + utxo.value.safe()
+ }
+ _state.update {
+ it.copy(
+ fundingSeenSats = seen ?: it.fundingSeenSats,
+ fundingState = SparkExitMath.fundingState(record, utxos),
+ )
+ }
+ }
+
+ /**
+ * Pushes back the warning that the exit fell behind, to 12 blocks into the operators' window of the next step to
+ * mature (about 50 blocks after it, when they may publish their own version), or of the steps already waiting to
+ * be published. Only a run that kept the exit on track pushes it; a stalled exit lets it fire. Needing more fee
+ * funding is notified once, at once.
+ */
+ private fun updateAlerts(
+ record: SparkExitRecord,
+ previous: SparkExitRecord,
+ isOnTrack: Boolean,
+ reportedLost: Set,
+ ) {
+ SparkExitAlerts.losses(record, previous, reportedLost).forEach {
+ work.notify(it, amountSats = record.lostSats.takeIf { _ -> it == SparkExitAlert.Lost })
+ }
+ when (record.phase) {
+ SparkExitRecord.Phase.Running -> {
+ if (!isOnTrack) return
+ val tip = _state.value.tipHeight
+ val untilMaturity = SparkExitMath.untilNextMaturity(record.recovery, tip, Env.network)
+ work.armDeadline(
+ SparkExitMath.deadline(untilMaturity, Env.network),
+ clock.nowMs(),
+ )
+ }
+ is SparkExitRecord.Phase.NeedsFunding -> {
+ work.cancelDeadline()
+ if (previous.phase !is SparkExitRecord.Phase.NeedsFunding) work.notify(SparkExitAlert.Funding)
+ }
+ else -> work.cancelDeadline()
+ }
+ }
+
+ private fun reportedLostLeafIds(): Set = runCatching { lostLeafStore.load() }
+ .onFailure { Logger.warn("Failed to read the lost Spark exit leaves", it, context = TAG) }
+ .getOrDefault(emptySet())
+
+ private fun rememberLostLeaves(record: SparkExitRecord?) {
+ val ids = record?.lostLeaves?.map { it.leafId }?.takeIf { it.isNotEmpty() } ?: return
+ runCatching { lostLeafStore.add(ids) }
+ .onFailure { Logger.warn("Failed to keep the lost Spark exit leaves '$ids'", it, context = TAG) }
+ }
+
+ /** What Android lets the exit do while Bitkit is closed, for the progress screen. */
+ fun backgroundLimits(): SparkExitBackgroundLimits = work.backgroundLimits()
+
+ // endregion
+
+ // region quote and start
+
+ /** Quotes an exit of every leaf to a fresh savings address at the SDK's fastest fee rate. */
+ suspend fun loadQuote(): Result = withContext(bgDispatcher) {
+ runSuspendCatching {
+ val recovering = sparkService.exitRecovering() ?: throw SparkError.NotConnected()
+ if (sparkRepo.needsSettling()) _state.update { it.copy(isSettling = true) }
+ sparkRepo.settleForExit().also { _state.update { it.copy(isSettling = false) } }.getOrThrow()
+ val feeRate = maxOf(1uL, sparkService.fastestFeeRate())
+ val destination = _state.value.pending?.destination ?: lightningRepo.newAddress().getOrThrow()
+ // Leaves an earlier exit lost to an older state stay listed by the operators, but cannot be exited.
+ val lost = reportedLostLeafIds() + _state.value.record?.lostLeaves?.map { it.leafId }.orEmpty()
+ val prepared = recovering.prepareAll(
+ PrepareRecoverFundsRequest(
+ feeRateSatPerVbyte = feeRate,
+ fundingKind = CpfpFundingKind.P2wpkh,
+ destination = destination,
+ selection = ExitLeafSelection.All,
+ ),
+ lost,
+ )
+ val quote = SparkExitQuote(prepared, sparkRepo.state.value.balanceSats)
+ // Chosen here, so the funding screen, the fee estimate and the record all use one address.
+ val fundingIndex = nextFundingIndex()
+ val fundingAddress = fundingKey(fundingIndex).address
+ _state.update {
+ it.copy(
+ pending = SparkExitPending(quote, destination, feeRate, fundingIndex),
+ fundingAddress = fundingAddress,
+ )
+ }
+ quote
+ }.onFailure {
+ Logger.error("Failed to quote the Spark exit", it, context = TAG)
+ }
+ }
+
+ /** The fee Savings pays to send the fee funding at the fast rate, or null when it cannot. */
+ suspend fun savingsFundingFee(): Result = withContext(bgDispatcher) {
+ runSuspendCatching {
+ val pending = requireNotNull(_state.value.pending) { "No Spark exit quote" }
+ val address = requireNotNull(_state.value.fundingAddress) { "No Spark exit funding address" }
+ val fundingSats = pending.quote.fundingSats
+ val feeSats = lightningRepo.calculateTotalFee(fundingSats, address, TransactionSpeed.Fast).getOrThrow()
+ val spendable = lightningRepo.getBalancesAsync().getOrThrow().spendableOnchainBalanceSats
+ SparkExitSavingsFunding(
+ feeSats = feeSats,
+ canPay = SparkExitMath.canFundFromSavings(spendable, fundingSats, feeSats),
+ )
+ }
+ }
+
+ /** Reads what reached the funding address, for the funding screen while the user pays from another wallet. */
+ suspend fun refreshFundingAddress(): Result = withContext(bgDispatcher) {
+ runSuspendCatching {
+ val chain = requireNotNull(chain) { "No chain host for this network" }
+ val address = requireNotNull(_state.value.fundingAddress) { "No Spark exit funding address" }
+ val seen = chain.utxos(address).fold(0uL) { sum, utxo -> sum.safe() + utxo.value.safe() }
+ _state.update { it.copy(fundingSeenSats = seen) }
+ seen
+ }
+ }
+
+ /**
+ * 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.
+ */
+ suspend fun start(fundFromSavings: Boolean): Result = withContext(bgDispatcher) {
+ runSuspendCatching {
+ if (_state.value.isExiting) throw SparkExitError("A Spark exit is already running")
+ val pending = requireNotNull(_state.value.pending) { "No Spark exit quote" }
+ val network = requireNotNull(sparkService.networkName) { "Spark is not available on this network" }
+ val key = fundingKey(pending.fundingIndex)
+ val quote = pending.quote
+ var record = SparkExitRecord(
+ network = network,
+ createdAt = clock.nowMs(),
+ phase = SparkExitRecord.Phase.AwaitingFunding(quote.fundingSats),
+ feeRateSatPerVbyte = pending.feeRate,
+ destination = pending.destination,
+ leafIds = quote.leafIds,
+ funding = listOf(
+ SparkExitRecord.Funding(index = pending.fundingIndex, address = key.address, pubkey = key.pubkey),
+ ),
+ expectedSats = quote.incomingSats,
+ skippedLeafCount = quote.prepared.skipped.size,
+ leafTotalSats = quote.prepared.leaves.fold(0uL) { sum, leaf -> sum.safe() + leaf.valueSats.safe() },
+ )
+ // Saved before the transfer exists, so a failed save never leaves a transfer counted with no exit behind.
+ store.save(record)
+ cacheStore.update {
+ val next = it.sparkExitNextFundingIndexes + (fundingIndexKey to pending.fundingIndex.toInt() + 1)
+ it.copy(sparkExitNextFundingIndexes = next)
+ }
+ val transferId = transferRepo.createTransfer(
+ TransferType.SPARK_EXIT,
+ quote.incomingSats.toLong()
+ ).getOrThrow()
+ record = record.copy(transferId = transferId)
+ store.save(record)
+ _state.update { it.copy(record = record, pending = null, fundingAddress = key.address) }
+ isLoaded = true
+ // Reconnecting for the exit disconnects Spark, which would cut a running leaf optimization short.
+ sparkRepo.awaitOptimizationIdle()
+ sparkRepo.setExiting(true)
+ work.schedulePeriodic()
+ Logger.info(
+ "Started Spark exit of '${quote.exitSats}' sats over '${quote.leafIds.size}' leaves",
+ context = TAG,
+ )
+ // Driven before paying, so a failed payment from savings still leaves the exit watching its address.
+ scope.launch { tick() }
+ onAppForegrounded()
+ if (fundFromSavings && quote.fundingSats > 0uL) payFunding(key.address, quote.fundingSats)
+ }.onFailure {
+ Logger.error("Failed to start the Spark exit", it, context = TAG)
+ }
+ }
+
+ /** Pays the fee funding from savings as an ordinary payment: it is a fee, not a transfer to spending. */
+ private suspend fun payFunding(address: String, sats: ULong) {
+ val outcome = lightningRepo.sendOnChain(
+ address = address,
+ sats = sats,
+ speed = TransactionSpeed.Fast,
+ ).getOrThrow()
+ if (outcome !is OnchainSendOutcome.Accepted) {
+ throw SparkExitError(
+ "Spark exit funding was not accepted: '$outcome'"
+ )
+ }
+ // After the tick in flight, so its save cannot drop the txid; progress then waits for it instead of asking.
+ tickMutex.withLock {
+ val record = _state.value.record ?: return@withLock
+ val paid = record.copy(fundingPaymentTxid = outcome.txid)
+ store.save(paid)
+ _state.update {
+ it.copy(record = paid, fundingState = SparkExitMath.fundingState(paid, utxos = null))
+ }
+ }
+ runSuspendCatching { lightningRepo.completeAcceptedOrdinaryFollowup(outcome.txid) }
+ .onFailure { Logger.warn("Failed to finish the Spark exit funding locally", it, context = TAG) }
+ Logger.info("Funded the Spark exit with '$sats' sats in '${outcome.txid}'", context = TAG)
+ }
+
+ /**
+ * Whether the exit can stop at no cost: nothing was built and its funding address received nothing, so Spark
+ * comes back as it was.
+ */
+ val canCancel: Boolean
+ get() {
+ val exit = _state.value
+ val record = exit.record ?: return false
+ return record.phase is SparkExitRecord.Phase.AwaitingFunding &&
+ record.recovery == null &&
+ record.fundedSats == 0uL &&
+ exit.fundingSeenSats == 0uL
+ }
+
+ /** The user opened the result of an exit that failed or lost leaves: Emergency exit may start a new one now. */
+ suspend fun markResultSeen(): Result = withContext(bgDispatcher) {
+ runSuspendCatching {
+ tickMutex.withLock {
+ val record = _state.value.record?.takeIf { it.hasUnseenResult }
+ ?: return@withLock
+ val seen = record.copy(isResultSeen = true)
+ store.save(seen)
+ _state.update { it.copy(record = seen) }
+ }
+ }
+ }
+
+ suspend fun cancel(): Result = withContext(bgDispatcher) {
+ runSuspendCatching {
+ if (!canCancel) throw SparkExitError("The Spark exit can no longer be cancelled")
+ val record = requireNotNull(_state.value.record)
+ store.delete()
+ _state.update { it.copy(record = null) }
+ record.transferId?.let { transferRepo.markSettled(it) }
+ work.cancelAll()
+ sparkRepo.setExiting(false)
+ Logger.info("Cancelled the Spark exit before it was funded", context = TAG)
+ }
+ }
+
+ // endregion
+
+ // region keys
+
+ private val fundingIndexKey get() = "${Env.network.name.lowercase()}/wallet$walletIndex"
+
+ /** A funding key index no exit of this wallet used. */
+ private suspend fun nextFundingIndex(): UInt = SparkExitMath.nextFundingIndex(
+ kept = cacheStore.data.first().sparkExitNextFundingIndexes[fundingIndexKey],
+ stored = runSuspendCatching { store.load() }.getOrNull(),
+ )
+
+ /** Every funding UTXO of one exit goes to its index: the SDK's single-key signer signs all inputs with one key. */
+ private suspend fun fundingKey(index: UInt): SparkExitFundingKey.Derived {
+ val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound()
+ val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
+ val path = SparkExitFundingKey.path(index = index.toInt(), network = Env.network)
+ val address = coreService.onchain.deriveBitcoinAddress(mnemonic, path, Env.network, passphrase)
+ val wif = coreService.onchain.derivePrivateKey(mnemonic, path, Env.network, passphrase)
+ return SparkExitFundingKey.Derived(address.address, address.publicKey, SparkExitFundingKey.secretKey(wif))
+ }
+
+ private suspend fun signer(index: UInt): CpfpSigner = singleKeyCpfpSigner(fundingKey(index).secretKey)
+
+ // endregion
+}
+
+class SparkExitError(message: String) : AppError(message)
+
+/** A quote the user is looking at, with what the exit is built from once it starts. */
+data class SparkExitPending(
+ val quote: SparkExitQuote,
+ val destination: String,
+ val feeRate: ULong,
+ /** The funding key index this exit takes. */
+ val fundingIndex: UInt,
+)
+
+data class SparkExitSavingsFunding(
+ val feeSats: ULong,
+ val canPay: Boolean,
+)
+
+data class SparkExitState(
+ val record: SparkExitRecord? = null,
+ val pending: SparkExitPending? = null,
+ val tipHeight: UInt? = null,
+ /** Confirmed or not, what reached the funding address. */
+ val fundingSeenSats: ULong = 0uL,
+ /** What progress shows while the exit needs fee funding. */
+ val fundingState: SparkExitFundingState? = null,
+ val fundingAddress: String? = null,
+ val lastError: String? = null,
+ /** Spark's balance as last seen while the SDK drove the exit. */
+ val sparkBalanceSats: ULong? = null,
+ /** The quote waits for Spark to finish moving leaves, so value in flight is not left out of the exit. */
+ val isSettling: Boolean = false,
+) {
+ val isExiting: Boolean get() = record?.isActive == true
+
+ /** Emergency exit opens progress rather than a new exit. */
+ val showsResult: Boolean get() = record?.showsResult == true
+
+ /**
+ * Spark's balance beyond the quote's leaves while the exit runs: value outside the exit, which stays on Spark. A
+ * signal only, exact while the exit's leaves still count in the balance; published leaves leave it.
+ */
+ val outsideExitSats: ULong?
+ get() {
+ val active = record?.takeIf { it.isActive } ?: return null
+ return SparkExitMath.outsideExitSats(active.leafTotalSats, sparkBalanceSats)
+ }
+}
diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitSweep.kt b/app/src/main/java/to/bitkit/repositories/SparkExitSweep.kt
new file mode 100644
index 0000000000..6dc251444a
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/SparkExitSweep.kt
@@ -0,0 +1,255 @@
+package to.bitkit.repositories
+
+import to.bitkit.models.safe
+import to.bitkit.utils.AppError
+import java.io.ByteArrayOutputStream
+import java.security.MessageDigest
+
+/**
+ * Moves the fee funding an exit left at its funding address to savings once the exit is done: change from a branch
+ * whose refund the operators published, or funding a rebuild no longer needed. The SDK's single-key CPFP signer signs
+ * the P2WPKH inputs of the PSBT built here and finalizes them, so the funding key never leaves the signer.
+ */
+object SparkExitSweep {
+ private const val SEQUENCE_RBF = 0xfffffffdL
+
+ /** Bitcoin Core's spend sizes for its dust rule: a witness program's input, and any other. */
+ private const val WITNESS_SPEND_BYTES = 67uL
+ private const val LEGACY_SPEND_BYTES = 148uL
+
+ data class Input(val txid: String, val vout: UInt, val valueSats: ULong)
+
+ /** An unsigned sweep of [inputs] to [destination], or null when what it pays would be under the dust limit. */
+ class Plan(val inputs: List, val destinationScript: ByteArray, val amountSats: ULong, val feeSats: ULong)
+
+ fun plan(inputs: List, destination: String, feeRateSatPerVbyte: ULong): Plan? {
+ if (inputs.isEmpty()) return null
+ val script = AddressScript.of(destination)
+ val fee = vsize(inputs.size, script.size) * maxOf(1uL, feeRateSatPerVbyte)
+ val total = inputs.fold(0uL) { sum, input -> sum.safe() + input.valueSats.safe() }
+ if (total < fee.safe() + dustLimit(script).safe()) return null
+ return Plan(inputs, script, total - fee, fee)
+ }
+
+ /** P2WPKH inputs sized for 72-byte signatures and one output: ceil((4 · base + witness) / 4). */
+ fun vsize(inputCount: Int, outputScriptSize: Int): ULong {
+ val n = inputCount.toULong()
+ val base = 10uL + 41uL * n + 9uL + outputScriptSize.toULong()
+ val witness = 2uL + 108uL * n
+ return (4uL * base + witness + 3uL) / 4uL
+ }
+
+ /** Bitcoin Core's dust limit of an output: 3 · (output size + spend size), 294 for P2WPKH, 330 for P2TR. */
+ fun dustLimit(script: ByteArray): ULong {
+ val isWitness = script.size >= 4 && (script[0] == 0.toByte() || script[0] in 0x51.toByte()..0x60.toByte())
+ val spend = if (isWitness) WITNESS_SPEND_BYTES else LEGACY_SPEND_BYTES
+ return 3uL * (9uL + script.size.toULong() + spend)
+ }
+
+ /** A BIP 174 PSBT of [plan], each input carrying its P2WPKH `witness_utxo` at [fundingAddress] for the signer. */
+ fun psbt(plan: Plan, fundingAddress: String): ByteArray {
+ val fundingScript = AddressScript.of(fundingAddress)
+ val out = ByteArrayOutputStream()
+ out.write(byteArrayOf(0x70, 0x73, 0x62, 0x74, 0xff.toByte()))
+ out.writeKeyValue(byteArrayOf(0x00), unsignedTx(plan))
+ out.write(0x00)
+ plan.inputs.forEach {
+ out.writeKeyValue(byteArrayOf(0x01), le64(it.valueSats.toLong()) + varBytes(fundingScript))
+ out.write(0x00)
+ }
+ out.write(0x00)
+ return out.toByteArray()
+ }
+
+ /** The signed transaction hex from a PSBT whose every input the signer finalized. */
+ fun signedTxHex(plan: Plan, signedPsbt: ByteArray): String {
+ val witnesses = PsbtReader(signedPsbt).finalWitnesses(plan.inputs.size)
+ val out = ByteArrayOutputStream()
+ out.write(le32(2))
+ out.write(byteArrayOf(0x00, 0x01))
+ out.write(txBody(plan))
+ witnesses.forEach { out.write(it) }
+ out.write(le32(0))
+ return out.toByteArray().toHexString()
+ }
+
+ private fun unsignedTx(plan: Plan): ByteArray = le32(2) + txBody(plan) + le32(0)
+
+ private fun txBody(plan: Plan): ByteArray {
+ val out = ByteArrayOutputStream()
+ out.write(compactSize(plan.inputs.size.toLong()))
+ plan.inputs.forEach {
+ out.write(it.txid.hexToByteArray().reversedArray())
+ out.write(le32(it.vout.toLong()))
+ out.write(0x00)
+ out.write(le32(SEQUENCE_RBF))
+ }
+ out.write(compactSize(1))
+ out.write(le64(plan.amountSats.toLong()))
+ out.write(varBytes(plan.destinationScript))
+ return out.toByteArray()
+ }
+
+ private fun ByteArrayOutputStream.writeKeyValue(key: ByteArray, value: ByteArray) {
+ write(varBytes(key))
+ write(varBytes(value))
+ }
+
+ private fun varBytes(bytes: ByteArray) = compactSize(bytes.size.toLong()) + bytes
+
+ private fun compactSize(n: Long): ByteArray = when {
+ n < 0xfd -> byteArrayOf(n.toByte())
+ n <= 0xffff -> byteArrayOf(0xfd.toByte()) + le(n, 2)
+ n <= 0xffffffffL -> byteArrayOf(0xfe.toByte()) + le(n, 4)
+ else -> byteArrayOf(0xff.toByte()) + le(n, 8)
+ }
+
+ private fun le32(n: Long) = le(n, 4)
+ private fun le64(n: Long) = le(n, 8)
+ private fun le(n: Long, size: Int) = ByteArray(size) { ((n ushr (8 * it)) and 0xff).toByte() }
+
+ /** Reads the `final_scriptwitness` of each input, already serialized as the transaction carries it. */
+ private class PsbtReader(private val bytes: ByteArray) {
+ private var offset = 5
+
+ fun finalWitnesses(inputCount: Int): List {
+ if (!bytes.copyOfRange(0, 5).contentEquals(byteArrayOf(0x70, 0x73, 0x62, 0x74, 0xff.toByte()))) {
+ throw SparkExitSweepError("Not a PSBT")
+ }
+ skipMap()
+ return List(inputCount) { index ->
+ var witness: ByteArray? = null
+ while (true) {
+ val key = readVarBytes()
+ if (key.isEmpty()) break
+ val value = readVarBytes()
+ if (key[0] == FINAL_SCRIPTWITNESS) witness = value
+ }
+ witness ?: throw SparkExitSweepError("Input '$index' was not signed")
+ }
+ }
+
+ private fun skipMap() {
+ while (true) {
+ if (readVarBytes().isEmpty()) return
+ readVarBytes()
+ }
+ }
+
+ private fun readVarBytes(): ByteArray {
+ val length = readCompactSize()
+ if (length < 0 || length > bytes.size - offset) throw SparkExitSweepError("Truncated PSBT")
+ return bytes.copyOfRange(offset, offset + length).also { offset += length }
+ }
+
+ private fun readCompactSize(): Int {
+ if (offset >= bytes.size) throw SparkExitSweepError("Truncated PSBT")
+ val prefix = bytes[offset++].toInt() and 0xff
+ val size = when (prefix) {
+ 0xfd -> 2
+ 0xfe -> 4
+ 0xff -> 8
+ else -> return prefix
+ }
+ if (size > bytes.size - offset) throw SparkExitSweepError("Truncated PSBT")
+ var value = 0L
+ repeat(size) { value = value or ((bytes[offset++].toLong() and 0xff) shl (8 * it)) }
+ if (value > Int.MAX_VALUE) throw SparkExitSweepError("PSBT field too large")
+ return value.toInt()
+ }
+
+ private companion object {
+ const val FINAL_SCRIPTWITNESS: Byte = 0x08
+ }
+ }
+}
+
+class SparkExitSweepError(message: String) : AppError(message)
+
+/** An address to the output script it pays: segwit (BIP 173 and 350), P2PKH or P2SH. */
+object AddressScript {
+ private val P2PKH_VERSIONS = setOf(0x00, 0x6f)
+ private val P2SH_VERSIONS = setOf(0x05, 0xc4)
+ private const val HASH_LENGTH = 20
+ private const val CHECKSUM_LENGTH = 4
+
+ fun of(address: String): ByteArray {
+ val hrp = address.lowercase().substringBeforeLast('1', "")
+ if (hrp in setOf("bc", "tb", "bcrt")) return Bech32.scriptPubKey(address)
+ val decoded = SparkExitFundingKey.base58Decode(address)
+ ?.takeIf { it.size == 1 + HASH_LENGTH + CHECKSUM_LENGTH }
+ ?: throw SparkExitSweepError("Invalid address '$address'")
+ val payload = decoded.copyOfRange(0, 1 + HASH_LENGTH)
+ val sha256 = MessageDigest.getInstance("SHA-256")
+ val checksum = sha256.digest(sha256.digest(payload)).copyOfRange(0, CHECKSUM_LENGTH)
+ if (!checksum.contentEquals(decoded.copyOfRange(1 + HASH_LENGTH, decoded.size))) {
+ throw SparkExitSweepError("Invalid checksum in '$address'")
+ }
+ val hash = payload.copyOfRange(1, payload.size)
+ return when (payload[0].toInt() and 0xff) {
+ in P2PKH_VERSIONS -> {
+ byteArrayOf(0x76, 0xa9.toByte(), 0x14) + hash + byteArrayOf(0x88.toByte(), 0xac.toByte())
+ }
+ in P2SH_VERSIONS -> byteArrayOf(0xa9.toByte(), 0x14) + hash + byteArrayOf(0x87.toByte())
+ else -> throw SparkExitSweepError("Unsupported address '$address'")
+ }
+ }
+}
+
+/** Segwit addresses (BIP 173 and BIP 350) to the output script they pay. */
+object Bech32 {
+ private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
+ private const val BECH32_CONST = 1
+ private const val BECH32M_CONST = 0x2bc830a3
+ private const val MIN_PROGRAM = 2
+ private const val MAX_PROGRAM = 40
+
+ fun scriptPubKey(address: String): ByteArray {
+ val lower = address.lowercase()
+ val separator = lower.lastIndexOf('1')
+ if (separator < 1 || separator + 7 > lower.length) throw SparkExitSweepError("Invalid address '$address'")
+ val hrp = lower.substring(0, separator)
+ val data = lower.substring(separator + 1).map {
+ CHARSET.indexOf(it).takeIf { i -> i >= 0 } ?: throw SparkExitSweepError("Invalid address '$address'")
+ }
+ val version = data.first()
+ val checksum = polymod(expand(hrp) + data)
+ val expected = if (version == 0) BECH32_CONST else BECH32M_CONST
+ if (checksum != expected) throw SparkExitSweepError("Invalid checksum in '$address'")
+ val program = convertBits(data.subList(1, data.size - 6))
+ if (program.size !in MIN_PROGRAM..MAX_PROGRAM || (version == 0 && program.size != 20 && program.size != 32)) {
+ throw SparkExitSweepError("Invalid program in '$address'")
+ }
+ val opVersion = if (version == 0) 0 else 0x50 + version
+ return byteArrayOf(opVersion.toByte(), program.size.toByte()) + program
+ }
+
+ private fun expand(hrp: String): List = hrp.map { it.code shr 5 } + 0 + hrp.map { it.code and 31 }
+
+ private fun polymod(values: List): Int {
+ val generators = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
+ var chk = 1
+ values.forEach { value ->
+ val top = chk ushr 25
+ chk = (chk and 0x1ffffff) shl 5 xor value
+ generators.forEachIndexed { i, g -> if ((top ushr i) and 1 == 1) chk = chk xor g }
+ }
+ return chk
+ }
+
+ private fun convertBits(data: List): ByteArray {
+ var acc = 0
+ var bits = 0
+ val out = ByteArrayOutputStream()
+ data.forEach {
+ acc = (acc shl 5) or it
+ bits += 5
+ while (bits >= 8) {
+ bits -= 8
+ out.write((acc ushr bits) and 0xff)
+ }
+ }
+ if (bits >= 5 || ((acc shl (8 - bits)) and 0xff) != 0) throw SparkExitSweepError("Invalid padding")
+ return out.toByteArray()
+ }
+}
diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitWorker.kt b/app/src/main/java/to/bitkit/repositories/SparkExitWorker.kt
new file mode 100644
index 0000000000..cae829f698
--- /dev/null
+++ b/app/src/main/java/to/bitkit/repositories/SparkExitWorker.kt
@@ -0,0 +1,353 @@
+package to.bitkit.repositories
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.net.NetworkCapabilities
+import android.text.format.DateFormat
+import androidx.core.content.edit
+import androidx.hilt.work.HiltWorker
+import androidx.work.Constraints
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.launch
+import to.bitkit.App
+import to.bitkit.R
+import to.bitkit.async.appScope
+import to.bitkit.di.BgDispatcher
+import to.bitkit.env.Env
+import to.bitkit.ext.alarmManager
+import to.bitkit.ext.connectivityManager
+import to.bitkit.ext.nowMs
+import to.bitkit.models.BITCOIN_SYMBOL
+import to.bitkit.models.formatToModernDisplay
+import to.bitkit.ui.areNotificationsEnabled
+import to.bitkit.ui.pushNotification
+import to.bitkit.utils.Logger
+import to.bitkit.utils.batterySettingsSnapshot
+import to.bitkit.utils.logBatterySettings
+import java.io.File
+import java.util.Date
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Clock
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.minutes
+
+/**
+ * Runs a Spark exit while Bitkit is closed, and tells the user when it cannot.
+ *
+ * A unique periodic work drives the exit every 15 minutes while it runs; WorkManager keeps it across app kills and
+ * reboots, and app start queues it again if it is missing. Each run that moves the exit forward re-arms a deadline
+ * alarm 12 blocks into the operators' window of the next step. The alarm fires only when no run made it in time
+ * (no network, battery settings, background restrictions, failures), and then posts a notification naming the
+ * deadline. Needing more fee funding is notified at once.
+ */
+@Singleton
+class SparkExitWork @Inject constructor(
+ @ApplicationContext private val context: Context,
+ @BgDispatcher private val bgDispatcher: CoroutineDispatcher,
+ private val clock: Clock,
+) {
+ companion object {
+ private const val TAG = "SparkExitWork"
+ private const val PERIODIC_WORK = "spark-exit-tick"
+ private const val CATCH_UP_WORK = "spark-exit-catch-up"
+ const val DEADLINE_ACTION = "to.bitkit.spark-exit.DEADLINE"
+ private const val PREFERENCES = "spark-exit-work"
+ private const val KEY_WARN_AT = "warn-at"
+ private const val KEY_DEADLINE_AT = "deadline-at"
+ private const val NOTIFICATION_ID = 7797
+
+ /** The shortest period WorkManager runs periodic work at. */
+ val PERIOD = 15.minutes
+
+ /** The exit of the wallet Bitkit runs, read before the wallet graph exists. */
+ private fun store() = SparkExitStore(File(Env.sparkExitRecordPath(0)))
+ }
+
+ private val scope = appScope(bgDispatcher, TAG)
+ private val preferences get() = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
+ private val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
+
+ fun schedulePeriodic() {
+ val request = PeriodicWorkRequestBuilder(PERIOD.inWholeMinutes, TimeUnit.MINUTES)
+ .setConstraints(constraints)
+ .build()
+ WorkManager.getInstance(context)
+ .enqueueUniquePeriodicWork(PERIODIC_WORK, ExistingPeriodicWorkPolicy.KEEP, request)
+ }
+
+ /**
+ * At app start, boot and app update: queues the periodic work again when an exit runs, and re-arms its deadline
+ * alarm, which a reboot clears.
+ */
+ fun ensureScheduled() {
+ scope.launch {
+ val record = runCatching { store().load() }.getOrNull()
+ if (record?.needsDriving != true) {
+ if (preferences.contains(KEY_WARN_AT)) cancelAll()
+ return@launch
+ }
+ schedulePeriodic()
+ val now = clock.nowMs()
+ val warnAt = preferences.getLong(KEY_WARN_AT, 0L)
+ val deadlineAt = preferences.getLong(KEY_DEADLINE_AT, 0L)
+ if (record.phase == SparkExitRecord.Phase.Running && (warnAt == 0L || !isDeadlineArmed())) {
+ if (warnAt == 0L) {
+ armDeadline(SparkExitMath.deadline(Duration.ZERO, Env.network), now)
+ } else {
+ setAlarm(maxOf(warnAt, now), deadlineAt)
+ }
+ }
+ Logger.debug("Ensured Spark exit background work (${context.logBatterySettings()})", context = TAG)
+ }
+ }
+
+ /** Re-arms the warning that the exit fell behind; every run that moves the exit forward pushes it back. */
+ fun armDeadline(deadline: SparkExitDeadline, nowMillis: Long = clock.nowMs()) {
+ setAlarm(
+ warnAt = nowMillis + deadline.warnIn.inWholeMilliseconds,
+ deadlineAt = nowMillis + deadline.deadlineIn.inWholeMilliseconds,
+ )
+ }
+
+ fun cancelDeadline() {
+ deadlinePendingIntent(PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE)?.let {
+ context.alarmManager.cancel(it)
+ it.cancel()
+ }
+ preferences.edit {
+ remove(KEY_WARN_AT)
+ remove(KEY_DEADLINE_AT)
+ }
+ }
+
+ fun cancelAll() {
+ WorkManager.getInstance(context).run {
+ cancelUniqueWork(PERIODIC_WORK)
+ cancelUniqueWork(CATCH_UP_WORK)
+ }
+ cancelDeadline()
+ }
+
+ /**
+ * The deadline alarm fired, so no run moved the exit forward in time: tells the user why and until when, tries a
+ * run now, and warns again later if it stays behind.
+ */
+ suspend fun onDeadline() {
+ val record = runCatching { store().load() }.getOrNull()
+ val alert = SparkExitAlerts.reason(record, isOnline(), backgroundLimits())
+ Logger.warn(
+ "Spark exit deadline alarm fired with alert '$alert' (${context.logBatterySettings()})",
+ context = TAG,
+ )
+ if (alert == null || record?.phase != SparkExitRecord.Phase.Running) {
+ cancelDeadline()
+ return
+ }
+ val deadlineAt = preferences.getLong(KEY_DEADLINE_AT, 0L).takeIf { it > clock.nowMs() }
+ notify(alert, deadlineAt)
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ CATCH_UP_WORK,
+ ExistingWorkPolicy.KEEP,
+ OneTimeWorkRequestBuilder().setConstraints(constraints).build(),
+ )
+ armDeadline(SparkExitMath.deadline(Duration.ZERO, Env.network))
+ }
+
+ /** Posts the exit's notification, unless Bitkit is on screen, where the progress screen says the same. */
+ fun notify(alert: SparkExitAlert, deadlineAtMillis: Long? = null, amountSats: ULong? = null) {
+ // Losses are posted on screen too: the user may be anywhere in the app when an exit fails or loses a leaf.
+ if (App.currentActivity?.value != null && alert !in ALWAYS_POSTED) return
+ val time = deadlineAtMillis?.let { DateFormat.getTimeFormat(context).format(Date(it)) }
+ val amount = amountSats?.let { "$BITCOIN_SYMBOL ${it.formatToModernDisplay()}" }
+ val text = context.getString(alert.textRes(hasDeadline = time != null))
+ .replace("{time}", time.orEmpty())
+ .replace("{amount}", amount.orEmpty())
+ context.pushNotification(
+ title = context.getString(R.string.lightning__spark_exit__notification_title),
+ text = text,
+ bigText = text,
+ id = NOTIFICATION_ID,
+ )
+ }
+
+ fun backgroundLimits(): SparkExitBackgroundLimits {
+ val battery = context.batterySettingsSnapshot()
+ return SparkExitBackgroundLimits(
+ isRestricted = battery.isBackgroundRestricted == true,
+ isPowerSaveMode = battery.isPowerSaveMode == true,
+ isOptimized = battery.isIgnoringBatteryOptimizations == false,
+ areNotificationsEnabled = context.areNotificationsEnabled(),
+ )
+ }
+
+ private fun isOnline(): Boolean {
+ val connectivity = context.connectivityManager
+ val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork) ?: return false
+ return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+
+ private fun setAlarm(warnAt: Long, deadlineAt: Long) {
+ preferences.edit {
+ putLong(KEY_WARN_AT, warnAt)
+ putLong(KEY_DEADLINE_AT, deadlineAt)
+ }
+ val operation = checkNotNull(
+ deadlinePendingIntent(PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE),
+ ) { "Expected the Spark exit deadline PendingIntent" }
+ // Inexact but allowed in Doze, which needs no exact alarm permission; the window is hours on mainnet.
+ runCatching { context.alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, warnAt, operation) }
+ .onFailure { Logger.error("Failed to arm the Spark exit deadline alarm", it, context = TAG) }
+ }
+
+ private fun isDeadlineArmed(): Boolean =
+ deadlinePendingIntent(PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE) != null
+
+ private fun deadlinePendingIntent(flags: Int): PendingIntent? = PendingIntent.getBroadcast(
+ context,
+ 0,
+ Intent(context, SparkExitReceiver::class.java).setAction(DEADLINE_ACTION),
+ flags,
+ )
+}
+
+/** What Android lets Bitkit do in the background, for the notification and the progress screen. */
+data class SparkExitBackgroundLimits(
+ /** The user restricted Bitkit's background use: no work or alarm runs while it is closed. */
+ val isRestricted: Boolean = false,
+ val isPowerSaveMode: Boolean = false,
+ /** Battery optimization is on, Android's default, so background runs wait for Doze's windows. */
+ val isOptimized: Boolean = false,
+ val areNotificationsEnabled: Boolean = true,
+)
+
+/** Why the user is asked to open Bitkit. */
+enum class SparkExitAlert { Funding, Lost, Failed, Rejected, Offline, Restricted, Battery, Behind }
+
+private val ALWAYS_POSTED = setOf(SparkExitAlert.Lost, SparkExitAlert.Failed)
+
+object SparkExitAlerts {
+ /**
+ * What a tick that moved the exit from [previous] to [record] lost, each posted once: leaves an older owner
+ * claimed, and the exit failing, unless it failed because every leaf was claimed, which the lost leaves say. A leaf
+ * in [reportedLost] was already reported by an earlier exit.
+ */
+ fun losses(
+ record: SparkExitRecord,
+ previous: SparkExitRecord,
+ reportedLost: Set = emptySet(),
+ ): List {
+ val phase = record.phase
+ val known = previous.lostLeaves.map { it.leafId }.toSet() + reportedLost
+ return listOfNotNull(
+ SparkExitAlert.Lost.takeIf { record.lostLeaves.any { it.leafId !in known } },
+ SparkExitAlert.Failed.takeIf {
+ phase is SparkExitRecord.Phase.Failed && !record.isEveryLeafLost &&
+ previous.phase !is SparkExitRecord.Phase.Failed
+ },
+ )
+ }
+
+ /** Why a running exit fell behind its deadline, or null when it needs nothing from the user. */
+ fun reason(record: SparkExitRecord?, isOnline: Boolean, limits: SparkExitBackgroundLimits): SparkExitAlert? {
+ if (record?.isActive != true) return null
+ return when {
+ record.phase is SparkExitRecord.Phase.NeedsFunding -> SparkExitAlert.Funding
+ record.phase != SparkExitRecord.Phase.Running -> null
+ record.surfacedRejection != null -> SparkExitAlert.Rejected
+ !isOnline -> SparkExitAlert.Offline
+ limits.isRestricted -> SparkExitAlert.Restricted
+ // Battery optimization alone is Android's default for nearly every app, so it names no cause.
+ limits.isPowerSaveMode -> SparkExitAlert.Battery
+ else -> SparkExitAlert.Behind
+ }
+ }
+}
+
+private fun SparkExitAlert.textRes(hasDeadline: Boolean): Int = when (this) {
+ SparkExitAlert.Funding -> R.string.lightning__spark_exit__notification_funding
+ SparkExitAlert.Lost -> R.string.lightning__spark_exit__notification_lost
+ SparkExitAlert.Failed -> R.string.lightning__spark_exit__notification_failed
+ SparkExitAlert.Rejected if hasDeadline -> R.string.lightning__spark_exit__notification_rejected
+ SparkExitAlert.Offline if hasDeadline -> R.string.lightning__spark_exit__notification_offline
+ SparkExitAlert.Restricted if hasDeadline -> R.string.lightning__spark_exit__notification_restricted
+ SparkExitAlert.Battery if hasDeadline -> R.string.lightning__spark_exit__notification_battery
+ SparkExitAlert.Behind if hasDeadline -> R.string.lightning__spark_exit__notification_behind
+ else -> R.string.lightning__spark_exit__notification_late
+}
+
+@HiltWorker
+class SparkExitWorker @AssistedInject constructor(
+ @Assisted appContext: Context,
+ @Assisted workerParams: WorkerParameters,
+ private val sparkExitRepo: SparkExitRepo,
+) : CoroutineWorker(appContext, workerParams) {
+ companion object {
+ private const val TAG = "SparkExitWorker"
+ }
+
+ override suspend fun doWork(): Result {
+ Logger.debug("Driving the Spark exit in background (${applicationContext.logBatterySettings()})", context = TAG)
+ sparkExitRepo.runInBackground().onFailure {
+ Logger.warn("Failed to drive the Spark exit in background", it, context = TAG)
+ return Result.retry()
+ }
+ return Result.success()
+ }
+}
+
+/** The deadline alarm, and boot and app updates, which clear alarms. */
+class SparkExitReceiver : BroadcastReceiver() {
+ @EntryPoint
+ @InstallIn(SingletonComponent::class)
+ interface SparkExitReceiverEntryPoint {
+ fun sparkExitWork(): SparkExitWork
+
+ @BgDispatcher
+ fun bgDispatcher(): CoroutineDispatcher
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ val entryPoint = EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ SparkExitReceiverEntryPoint::class.java,
+ )
+ val work = entryPoint.sparkExitWork()
+ when (intent.action) {
+ SparkExitWork.DEADLINE_ACTION -> {
+ val pending = goAsync()
+ appScope(entryPoint.bgDispatcher(), TAG).launch {
+ try {
+ work.onDeadline()
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+ Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> work.ensureScheduled()
+ }
+ }
+
+ private companion object {
+ const val TAG = "SparkExitReceiver"
+ }
+}
diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt
index 398d92f4b5..a907433ce0 100644
--- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt
@@ -1,5 +1,6 @@
package to.bitkit.repositories
+import breez_sdk_spark.AutoOptimizationEvent
import breez_sdk_spark.ClaimDeferredReason
import breez_sdk_spark.ClaimDepositOutcome
import breez_sdk_spark.FeePolicy
@@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import org.lightningdevkit.ldknode.Event
import org.lightningdevkit.ldknode.PaymentFailureReason
import org.lightningdevkit.ldknode.PaymentId
@@ -56,6 +58,7 @@ import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
import kotlin.time.Clock
+import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
import breez_sdk_spark.PaymentType as SparkPaymentType
@@ -87,6 +90,12 @@ class SparkRepo @Inject constructor(
private val SEND_STATUS_PRIORITY = listOf(PaymentStatus.COMPLETED, PaymentStatus.PENDING, PaymentStatus.FAILED)
+ /** How long the SDK leaves the counter-swap of a leaf swap cut short unclaimed (its 300 s), plus a margin. */
+ private val COUNTER_SWAP_GRACE = 330.seconds
+
+ /** The longest an exit quote waits for a running leaf optimization to end. */
+ private val OPTIMIZATION_WAIT = 2.minutes
+
/** Seconds a withdrawal quote must still hold to be reused, so it can't expire mid-send. */
private const val QUOTE_EXPIRY_MARGIN_SECS = 30uL
}
@@ -107,6 +116,9 @@ class SparkRepo @Inject constructor(
private var eventsJob: Job? = null
+ /** The SDK is swapping leaves with the operators; their value is in flight until it ends. */
+ private val isOptimizing = MutableStateFlow(false)
+
/** Watchers of the claims of deposits this app sent, by txid; they outlive the screens that start them. */
private val claimJobs = ConcurrentHashMap()
@@ -126,11 +138,24 @@ class SparkRepo @Inject constructor(
/** Fires once Spark connects and whenever its unilateral exit data changes, so the exit data backup is redone. */
val exitStateChanged: SharedFlow = _exitStateChanged.asSharedFlow()
+ /** The wallet Spark last started for. */
+ @Volatile
+ var walletIndex: Int = 0
+ private set
+
+ private val _walletWiped = MutableSharedFlow(extraBufferCapacity = 1)
+
+ /** Fires with the wallet index once its Spark wallet, and any emergency exit of it, was wiped. */
+ val walletWiped: SharedFlow = _walletWiped.asSharedFlow()
+
suspend fun start(walletIndex: Int): Result = withContext(bgDispatcher) {
if (!sparkService.isSupported) return@withContext Result.success(Unit)
+ this@SparkRepo.walletIndex = walletIndex
runSuspendCatching {
if (eventsJob == null) eventsJob = scope.launch { sparkService.events.collect { onSdkEvent(it) } }
- sparkService.connect(walletIndex)
+ val isExiting = exitStore(walletIndex).isActive()
+ _state.update { it.copy(isExiting = isExiting) }
+ sparkService.connect(walletIndex, isExiting)
importRestoredExitState(walletIndex)
_exitStateChanged.emit(Unit)
refresh()
@@ -141,6 +166,7 @@ class SparkRepo @Inject constructor(
}
suspend fun stop() = withContext(bgDispatcher) {
+ onOptimizationCutShort()
runSuspendCatching { sparkService.disconnect() }
.onFailure { Logger.warn("Failed to stop Spark wallet", it, context = TAG) }
_state.update { it.copy(isReady = false, hasSynced = false) }
@@ -149,10 +175,91 @@ class SparkRepo @Inject constructor(
suspend fun wipe(walletIndex: Int) = withContext(bgDispatcher) {
runSuspendCatching { sparkService.wipeStorage(walletIndex) }
.onFailure { Logger.error("Failed to wipe Spark wallet", it, context = TAG) }
+ runSuspendCatching { exitStore(walletIndex).delete() }
+ .onFailure { Logger.error("Failed to wipe Spark exit record", it, context = TAG) }
+ runSuspendCatching { SparkExitLostLeafStore(File(Env.sparkExitLostLeavesPath(walletIndex))).delete() }
+ .onFailure { Logger.error("Failed to wipe the lost Spark exit leaves", it, context = TAG) }
_state.update { SparkState(isSupported = sparkService.isSupported) }
+ _walletWiped.emit(walletIndex)
+ }
+
+ /**
+ * Takes Spark off the Lightning rail while an emergency exit runs, so no payment moves leaves the exit is
+ * spending, and connects Spark again so its leaf optimization follows: off while exiting, on once it ends.
+ */
+ suspend fun setExiting(isExiting: Boolean): Result = withContext(bgDispatcher) {
+ if (_state.value.isExiting == isExiting && sparkService.isConnected) return@withContext Result.success(Unit)
+ _state.update { it.copy(isExiting = isExiting) }
+ runSuspendCatching {
+ onOptimizationCutShort()
+ sparkService.disconnect()
+ sparkService.connect(walletIndex, isExiting)
+ refresh()
+ }.onFailure {
+ Logger.error("Failed to reconnect Spark with exit '$isExiting'", it, context = TAG)
+ }
+ }
+
+ /** Whether an exit quote has to wait first: a leaf optimization runs, or one ran or was cut short recently. */
+ suspend fun needsSettling(): Boolean =
+ isOptimizing.value || cacheStore.data.first().sparkOptimizationActivityAt != null
+
+ /**
+ * Settles the leaves for an emergency exit quote, which exits the leaves listed then. The balance leaves out an
+ * unclaimed counter-swap, and a leaf optimization cut short leaves its counter-swap unclaimed for the SDK's 300 s
+ * grace. So: wait for a running optimization, then until the last one is [COUNTER_SWAP_GRACE] old, then sync,
+ * which claims counter-swaps past the grace.
+ */
+ suspend fun settleForExit(): Result = withContext(bgDispatcher) {
+ runSuspendCatching {
+ awaitOptimizationIdle()
+ cacheStore.data.first().sparkOptimizationActivityAt?.let {
+ delay(Instant.fromEpochMilliseconds(it) + COUNTER_SWAP_GRACE - clock.now())
+ }
+ sparkService.syncWallet()
+ cacheStore.update { it.copy(sparkOptimizationActivityAt = null) }
+ refresh()
+ }
+ }
+
+ /** Waits, up to [OPTIMIZATION_WAIT], for a running leaf optimization to end. */
+ suspend fun awaitOptimizationIdle() {
+ if (withTimeoutOrNull(OPTIMIZATION_WAIT) { isOptimizing.first { !it } } == null) {
+ Logger.warn("Gave up waiting for a Spark leaf optimization to end", context = TAG)
+ }
+ }
+
+ private suspend fun onOptimization(event: AutoOptimizationEvent) {
+ when (event) {
+ is AutoOptimizationEvent.Started, is AutoOptimizationEvent.RoundCompleted -> {
+ isOptimizing.update { true }
+ stampOptimizationActivity()
+ }
+ AutoOptimizationEvent.Cancelled, is AutoOptimizationEvent.Failed -> {
+ isOptimizing.update { false }
+ stampOptimizationActivity()
+ }
+ AutoOptimizationEvent.Completed, AutoOptimizationEvent.Skipped -> {
+ isOptimizing.update { false }
+ cacheStore.update { it.copy(sparkOptimizationActivityAt = null) }
+ }
+ }
+ }
+
+ private suspend fun onOptimizationCutShort() {
+ if (!isOptimizing.value) return
+ isOptimizing.update { false }
+ stampOptimizationActivity()
}
- fun canReceive(): Boolean = _state.value.isReady
+ private suspend fun stampOptimizationActivity() {
+ val now = clock.now().toEpochMilliseconds()
+ cacheStore.update { it.copy(sparkOptimizationActivityAt = now) }
+ }
+
+ private fun exitStore(walletIndex: Int) = SparkExitStore(File(Env.sparkExitRecordPath(walletIndex)))
+
+ fun canReceive(): Boolean = _state.value.isReady && !_state.value.isExiting
fun canSend(amountSats: ULong): Boolean = _state.value.isReady && _state.value.sendableSats >= amountSats
@@ -187,6 +294,7 @@ class SparkRepo @Inject constructor(
runSuspendCatching {
ensurePaymentDeadline(paymentDeadlineAt)
if (!sparkService.isConnected) throw SparkError.SendRefused("Spark is not connected")
+ if (_state.value.isExiting) throw SparkError.SendRefused("Spark is in an emergency exit")
val prepared = runSuspendCatching { sparkService.prepareSend(bolt11, amountSats) }
.getOrElse { throw SparkError.SendRefused("Spark could not prepare the payment", it) }
val method = prepared.paymentMethod as? SendPaymentMethod.Bolt11Invoice
@@ -317,6 +425,7 @@ class SparkRepo @Inject constructor(
/** Moves the whole Spark balance to the on-chain [address], fees included, for Spending to Savings. */
suspend fun withdrawAll(address: String): Result = withContext(bgDispatcher) {
runSuspendCatching {
+ if (_state.value.isExiting) throw SparkError.SendRefused("Spark is in an emergency exit")
val payment = sparkService.send(prepareWithdrawAll(address))
preparedWithdrawal = null
(payment.details as? PaymentDetails.Withdraw)?.txId?.let { withdrawalTxids += it }
@@ -576,6 +685,7 @@ class SparkRepo @Inject constructor(
is SdkEvent.NewDeposits -> refresh()
is SdkEvent.UnilateralExitStateChanged -> _exitStateChanged.emit(Unit)
is SdkEvent.ClaimedDeposits -> refresh()
+ is SdkEvent.AutoOptimization -> onOptimization(event.optimizationEvent)
else -> Unit
}
}.onFailure {
@@ -670,9 +780,17 @@ data class SparkState(
val deposits: ImmutableList = persistentListOf(),
val withdrawals: ImmutableList = persistentListOf(),
val recoverableFundsSats: ULong = 0uL,
+ /** An emergency exit is moving the balance on-chain: Spark then neither sends nor receives. */
+ val isExiting: Boolean = false,
) {
/** The most Spark can send once its service fee is paid, so a payment near the full balance isn't offered. */
- val sendableSats: ULong get() = SparkFees.sendableSats(balanceSats)
+ val sendableSats: ULong get() = if (isExiting) 0uL else SparkFees.sendableSats(balanceSats)
+
+ /** Spark can take payments and transfers: connected, and no emergency exit runs. */
+ val isUsable: Boolean get() = isReady && !isExiting
+
+ /** The balance Spending counts; during an emergency exit it is in transfer to Savings instead. */
+ val spendingSats: ULong get() = if (isExiting) 0uL else balanceSats
}
object SparkFees {
diff --git a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt
index 041def1907..4e998dc4bd 100644
--- a/app/src/main/java/to/bitkit/repositories/TransferRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/TransferRepo.kt
@@ -9,6 +9,7 @@ import com.synonym.bitkitcore.PaymentType
import com.synonym.bitkitcore.SortDirection
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -44,19 +45,27 @@ class TransferRepo @Inject constructor(
private val coreService: CoreService,
private val transferDao: TransferDao,
private val clock: Clock,
+ private val sparkExitTip: SparkExitTip = SparkExitTip(),
) {
private val lastOrdersFetchMs = AtomicLong(0L)
val activeTransfers: Flow> = transferDao.getActiveTransfers()
+ /** The countdown of a force close or, without one, a Spark exit, whose heights are read on Spark's chain. */
val forceCloseRemainingDuration: Flow = combine(
activeTransfers,
lightningRepo.lightningState,
- ) { transfers, lightningState ->
+ sparkExitTip.height,
+ ) { transfers, lightningState, sparkExitHeight ->
val forceClose = transfers.firstOrNull { it.type == TransferType.FORCE_CLOSE }
- ?: return@combine null
- val targetHeight = forceClose.claimableAtHeight?.toUInt() ?: return@combine null
- val currentHeight = lightningState.block()?.height ?: return@combine null
+ val sparkExit = transfers.firstOrNull { it.type == TransferType.SPARK_EXIT }
+ val (transfer, currentHeight) = when {
+ forceClose != null -> forceClose to lightningState.block()?.height
+ sparkExit != null -> sparkExit to sparkExitHeight
+ else -> return@combine null
+ }
+ val targetHeight = transfer.claimableAtHeight?.toUInt() ?: return@combine null
+ currentHeight ?: return@combine null
val remaining = BlockTimeHelpers.blocksRemaining(targetHeight, currentHeight)
if (remaining <= 0) return@combine null
BlockTimeHelpers.getDurationForBlocks(remaining)
@@ -110,6 +119,18 @@ class TransferRepo @Inject constructor(
}
}
+ /** Moves a transfer's countdown target, as a Spark exit learns when its timelocks mature. */
+ suspend fun updateClaimableAtHeight(
+ id: String,
+ claimableAtHeight: UInt?
+ ): Result = withContext(bgDispatcher) {
+ runSuspendCatching { transferDao.updateClaimableAtHeight(id, claimableAtHeight?.toInt()) }
+ }
+
+ suspend fun isSettled(id: String): Result = withContext(bgDispatcher) {
+ runSuspendCatching { transferDao.getById(id)?.isSettled ?: true }
+ }
+
suspend fun findLspOrderIdByFundingTxId(fundingTxId: String): Result = withContext(bgDispatcher) {
runSuspendCatching {
transferDao.getByFundingTxId(fundingTxId)?.lspOrderId
@@ -269,7 +290,8 @@ class TransferRepo @Inject constructor(
channels: List,
balances: BalanceDetails?,
) {
- val toSavings = activeTransfers.filter { it.type.isToSavings() }
+ // A Spark exit has no channel; the exit settles its transfer once the sweep reaches savings.
+ val toSavings = activeTransfers.filter { it.type.isToSavings() && it.type != TransferType.SPARK_EXIT }
if (toSavings.isEmpty()) return
val balanceDetails = balances ?: run {
@@ -407,6 +429,12 @@ class TransferRepo @Inject constructor(
}
}
+/** The tip of the chain the Spark SDK reads, as a running Spark exit last saw it. */
+@Singleton
+class SparkExitTip @Inject constructor() {
+ val height = MutableStateFlow(null)
+}
+
private fun IBtOrder.hasClosedChannel(): Boolean {
val channel = channel ?: return false
return channel.state == BtOpenChannelState.CLOSED || channel.closingTxId != null || channel.close != null
diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt
index d988881a95..ca628a434d 100644
--- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt
@@ -124,9 +124,9 @@ class WalletRepo @Inject constructor(
}
}
repoScope.launch {
- sparkRepo.state.map { it.balanceSats to it.pendingDepositSats }.distinctUntilChanged().collect {
- if (walletExists()) syncBalances()
- }
+ sparkRepo.state.map { Triple(it.balanceSats, it.pendingDepositSats, it.isExiting) }
+ .distinctUntilChanged()
+ .collect { if (walletExists()) syncBalances() }
}
}
diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt
index e11294d0b8..4d6d5aae56 100644
--- a/app/src/main/java/to/bitkit/services/SparkService.kt
+++ b/app/src/main/java/to/bitkit/services/SparkService.kt
@@ -1,10 +1,13 @@
package to.bitkit.services
import breez_sdk_spark.BreezSdk
+import breez_sdk_spark.CheckRecoverFundsRequest
+import breez_sdk_spark.CheckRecoverFundsResponse
import breez_sdk_spark.ClaimDepositOutcome
import breez_sdk_spark.ClaimDepositRequest
import breez_sdk_spark.Config
import breez_sdk_spark.ConnectRequest
+import breez_sdk_spark.CpfpSigner
import breez_sdk_spark.DepositInfo
import breez_sdk_spark.EventListener
import breez_sdk_spark.FeePolicy
@@ -21,10 +24,14 @@ import breez_sdk_spark.MaxOnchainFee
import breez_sdk_spark.OnchainConfirmationSpeed
import breez_sdk_spark.Payment
import breez_sdk_spark.PaymentRequest
+import breez_sdk_spark.PrepareRecoverFundsRequest
+import breez_sdk_spark.PrepareRecoverFundsResponse
import breez_sdk_spark.PrepareSendPaymentRequest
import breez_sdk_spark.PrepareSendPaymentResponse
import breez_sdk_spark.ReceivePaymentMethod
import breez_sdk_spark.ReceivePaymentRequest
+import breez_sdk_spark.RecoverFundsRequest
+import breez_sdk_spark.RecoverFundsResponse
import breez_sdk_spark.SdkEvent
import breez_sdk_spark.Seed
import breez_sdk_spark.SendPaymentMethod
@@ -42,6 +49,7 @@ import org.lightningdevkit.ldknode.Network
import to.bitkit.data.keychain.Keychain
import to.bitkit.env.Env
import to.bitkit.models.safe
+import to.bitkit.repositories.SparkExitRecovering
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import to.bitkit.utils.ServiceError
@@ -66,7 +74,7 @@ class SparkService @Inject constructor(
* Bitkit's settings over the SDK's [defaults]. Breez's real-time data sync server and its Lightning address
* (LNURL) server stay off on every network.
*/
- internal fun configure(defaults: Config, apiKey: String?): Config = defaults.apply {
+ internal fun configure(defaults: Config, apiKey: String?, isExiting: Boolean): Config = defaults.apply {
this.apiKey = apiKey
realTimeSyncServerUrl = null
lnurlDomain = null
@@ -76,6 +84,7 @@ class SparkService @Inject constructor(
onchain = MaxOnchainFee.NetworkRecommended(DEPOSIT_CLAIM_FEE_LEEWAY_SAT_PER_VB),
proportionalPpm = null,
)
+ leafOptimizationConfig = leafOptimizationConfig.copy(autoEnabled = !isExiting)
}
/** Leeway in sat/vB over the recommended fee that an automatic deposit claim may pay. */
@@ -136,12 +145,16 @@ class SparkService @Inject constructor(
*/
val prefersSpark: Boolean get() = Env.network == Network.REGTEST
- suspend fun connect(walletIndex: Int) = mutex.withLock {
+ /**
+ * Connects the Spark wallet. While [isExiting], leaf optimization stays off: it swaps leaves with the operators,
+ * which would hand the leaves an emergency exit is spending to someone else, whose refunds then confirm first.
+ */
+ suspend fun connect(walletIndex: Int, isExiting: Boolean = false) = mutex.withLock {
if (sdk != null) return@withLock
val network = sparkNetwork() ?: throw SparkError.Unsupported()
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound()
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
- val config = configure(defaultConfig(network), Env.breezApiKey)
+ val config = configure(defaultConfig(network), Env.breezApiKey, isExiting)
val storageDir = File(Env.sparkStoragePath(walletIndex)).apply { mkdirs() }.path
val connected = connect(ConnectRequest(config, Seed.Mnemonic(mnemonic, passphrase), storageDir))
listenerId = connected.addEventListener(
@@ -152,7 +165,7 @@ class SparkService @Inject constructor(
},
)
sdk = connected
- Logger.info("Connected Spark wallet on '$network'", context = TAG)
+ Logger.info("Connected Spark wallet on '$network' with exit '$isExiting'", context = TAG)
}
suspend fun disconnect() = mutex.withLock {
@@ -242,6 +255,9 @@ class SparkService @Inject constructor(
suspend fun importExitState(exitState: String): ImportUnilateralExitStateResponse =
requireSdk().importUnilateralExitState(ImportUnilateralExitStateRequest(exitState))
+ /** The SDK calls of an emergency exit, or null while Spark is not connected. */
+ fun exitRecovering(): SparkExitRecovering? = sdk?.let(::BreezSparkExitRecovering)
+
/** The Spark network this build runs on, as exit data backups record it. */
val networkName: String? get() = sparkNetwork()?.name?.lowercase()
@@ -254,6 +270,17 @@ class SparkService @Inject constructor(
}
}
+class BreezSparkExitRecovering(private val sdk: BreezSdk) : SparkExitRecovering {
+ override suspend fun prepare(request: PrepareRecoverFundsRequest): PrepareRecoverFundsResponse =
+ sdk.prepareRecoverFunds(request)
+
+ override suspend fun recover(request: RecoverFundsRequest, signer: CpfpSigner?): RecoverFundsResponse =
+ sdk.recoverFunds(request, signer)
+
+ override suspend fun check(recovery: RecoverFundsResponse): CheckRecoverFundsResponse =
+ sdk.checkRecoverFunds(CheckRecoverFundsRequest(recovery))
+}
+
sealed class SparkError(message: String, cause: Throwable? = null) : AppError(message, cause) {
class Unsupported : SparkError("Spark is not available on this network or build")
class NotConnected : SparkError("Spark wallet is not connected")
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index c0789dd495..911d7c5071 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -38,6 +38,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraphBuilder
@@ -205,6 +206,12 @@ import to.bitkit.ui.settings.lightning.CloseConnectionScreen
import to.bitkit.ui.settings.lightning.LightningConnectionsScreen
import to.bitkit.ui.settings.lightning.LightningConnectionsViewModel
import to.bitkit.ui.settings.lightning.SparkDetailScreen
+import to.bitkit.ui.settings.lightning.SparkExitConfirmScreen
+import to.bitkit.ui.settings.lightning.SparkExitFundingScreen
+import to.bitkit.ui.settings.lightning.SparkExitIntroScreen
+import to.bitkit.ui.settings.lightning.SparkExitProgressScreen
+import to.bitkit.ui.settings.lightning.SparkExitQuoteScreen
+import to.bitkit.ui.settings.lightning.SparkExitViewModel
import to.bitkit.ui.settings.pin.PinManagementScreen
import to.bitkit.ui.settings.quickPay.QuickPayIntroScreen
import to.bitkit.ui.settings.quickPay.QuickPaySettingsScreen
@@ -330,10 +337,12 @@ fun ContentView(
appViewModel.checkAdoptedPubkySource()
appViewModel.refreshPrivatePaykitEndpoints()
appViewModel.startPaykitPaymentRequestPolling()
+ appViewModel.startSparkExitDriving()
}
Lifecycle.Event.ON_STOP -> {
appViewModel.stopPaykitPaymentRequestPolling()
+ appViewModel.stopSparkExitDriving()
val keptAliveByService = notificationsGranted &&
keepActiveInBackground &&
appViewModel.isForegroundServiceRunning()
@@ -1109,7 +1118,7 @@ private fun RootNavHost(
val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle()
val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle()
FundingScreen(
- canFundWithSpark = sparkState.isReady,
+ canFundWithSpark = sparkState.isUsable,
onTransfer = { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) },
onFund = {
scope.launch {
@@ -1191,7 +1200,7 @@ private fun RootNavHost(
val sparkState by walletViewModel.sparkState.collectAsStateWithLifecycle()
TransferLearnMoreScreen(
viewModel = hiltViewModel(parentEntry),
- spendingBalanceSats = sparkState.balanceSats.toLong(),
+ spendingBalanceSats = sparkState.spendingSats.toLong(),
onBackClick = { navController.popBackStack() },
onContinueClick = { navController.popBackStack() },
)
@@ -1346,7 +1355,7 @@ private fun NavGraphBuilder.home(
onActivityItemClick = { navController.navToActivityDetail(it) },
onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) },
onTransferToSpendingClick = {
- if (sparkState.isReady) {
+ if (sparkState.isUsable) {
navController.navigateToTransferSpendingStart(hasSeenSpendingIntro)
} else {
appViewModel.showSheet(Sheet.TransferUnavailable)
@@ -1365,7 +1374,7 @@ private fun NavGraphBuilder.home(
SpendingWalletScreen(
channels = lightningState.channels,
- hasSparkBalance = sparkState.balanceSats > 0uL,
+ hasSparkBalance = sparkState.spendingSats > 0uL,
lightningActivities = lightningActivities ?: persistentListOf(),
onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) },
onActivityItemClick = { navController.navToActivityDetail(it) },
@@ -1378,7 +1387,7 @@ private fun NavGraphBuilder.home(
}
},
onTransferFromSavingsClick = {
- if (sparkState.isReady) {
+ if (sparkState.isUsable) {
navController.navigateToTransferSpendingStart(hasSeenSpendingIntro)
} else {
appViewModel.showSheet(Sheet.TransferUnavailable)
@@ -1395,7 +1404,7 @@ private fun NavGraphBuilder.home(
walletId = walletId,
onActivityItemClick = { navController.navToActivityDetail(it) },
onTransferToSpendingClick = { walletId ->
- if (sparkState.isReady) {
+ if (sparkState.isUsable) {
navController.navigateToTransferSpendingStart(hasSeenSpendingIntro, walletId)
} else {
appViewModel.showSheet(Sheet.TransferUnavailable)
@@ -2001,8 +2010,12 @@ private fun NavGraphBuilder.lightningConnections(
navController.navigateToTransferSavingsIntro()
}
},
+ onEmergencyExit = { showsResult ->
+ navController.navigateTo(if (showsResult) Routes.SparkExitProgress else Routes.SparkExitIntro)
+ },
)
}
+ sparkExit(navController)
deepLinkableComposable {
val route = it.toRoute()
CloseConnectionScreen(
@@ -2013,6 +2026,54 @@ private fun NavGraphBuilder.lightningConnections(
}
}
+private fun NavGraphBuilder.sparkExit(navController: NavHostController) {
+ @Composable
+ fun exitViewModel(entry: NavBackStackEntry): SparkExitViewModel {
+ val parentEntry = remember(entry) { navController.getBackStackEntry(Routes.ConnectionsNav) }
+ return hiltViewModel(parentEntry)
+ }
+ val leaveExit = { navController.popBackStack(inclusive = false) }
+ composableWithDefaultTransitions {
+ SparkExitIntroScreen(
+ onBack = { navController.popBackStack() },
+ onContinue = { navController.navigateTo(Routes.SparkExitQuote) },
+ )
+ }
+ composableWithDefaultTransitions {
+ SparkExitQuoteScreen(
+ viewModel = exitViewModel(it),
+ onBack = { navController.popBackStack() },
+ onContinue = { needsFunding ->
+ navController.navigateTo(if (needsFunding) Routes.SparkExitFunding else Routes.SparkExitConfirm)
+ },
+ )
+ }
+ composableWithDefaultTransitions {
+ SparkExitFundingScreen(
+ viewModel = exitViewModel(it),
+ onBack = { navController.popBackStack() },
+ onContinue = { navController.navigateTo(Routes.SparkExitConfirm) },
+ )
+ }
+ composableWithDefaultTransitions {
+ SparkExitConfirmScreen(
+ viewModel = exitViewModel(it),
+ onBack = { navController.popBackStack() },
+ onStart = {
+ leaveExit()
+ navController.navigateTo(Routes.SparkExitProgress)
+ },
+ )
+ }
+ composableWithDefaultTransitions {
+ SparkExitProgressScreen(
+ viewModel = exitViewModel(it),
+ onBack = { navController.popBackStack() },
+ onDone = { leaveExit() },
+ )
+ }
+}
+
private fun NavGraphBuilder.activityItem(
activityListViewModel: ActivityListViewModel,
navController: NavHostController,
@@ -2474,6 +2535,21 @@ sealed interface Routes {
@Serializable
data class CloseConnection(val channelId: String) : Routes.DeepLinkable
+ @Serializable
+ data object SparkExitIntro : Routes.InternalOnly
+
+ @Serializable
+ data object SparkExitQuote : Routes.InternalOnly
+
+ @Serializable
+ data object SparkExitFunding : Routes.InternalOnly
+
+ @Serializable
+ data object SparkExitConfirm : Routes.InternalOnly
+
+ @Serializable
+ data object SparkExitProgress : Routes.InternalOnly
+
@Serializable
data object DevSettings : Routes.DeepLinkable
diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt
index b2c7f99962..303f5eaf8c 100644
--- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsAdvancedScreen.kt
@@ -63,7 +63,7 @@ fun SavingsAdvancedScreen(
val openChannels = lightningState.channels.filterOpen()
val sparkState by wallet.sparkState.collectAsStateWithLifecycle()
- val sparkSats = sparkState.balanceSats
+ val sparkSats = sparkState.spendingSats
var selectedChannelIds by remember { mutableStateOf(setOf()) }
var isSparkSelected by remember { mutableStateOf(true) }
diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt
index 743cdc390a..85b9632e9b 100644
--- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsConfirmScreen.kt
@@ -82,7 +82,7 @@ fun SavingsConfirmScreen(
val openChannels = lightningState.channels.filterOpen()
val sparkState by wallet.sparkState.collectAsStateWithLifecycle()
- val sparkSats = sparkState.balanceSats
+ val sparkSats = sparkState.spendingSats
// Spark counts as one more item: the selection list is offered once there are two to choose from.
val hasMultiple = openChannels.size + (if (sparkSats > 0uL) 1 else 0) > 1
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
index 5f23a8830e..98b166b01f 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt
@@ -317,7 +317,7 @@ fun HomeScreen(
}
Suggestion.LIGHTNING -> when {
- !sparkState.isReady -> appViewModel.showSheet(Sheet.TransferUnavailable)
+ !sparkState.isUsable -> appViewModel.showSheet(Sheet.TransferUnavailable)
!hasSeenTransferIntro -> rootNavController.navigateToTransferIntro()
else -> rootNavController.navigateToTransferFunding()
}
diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt
index 11306c9312..f0feb93258 100644
--- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt
+++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveSheet.kt
@@ -149,7 +149,7 @@ fun ReceiveSheet(
onUsdtSelected = onUsdtSelected,
walletState = walletState,
lightningState = lightningState,
- canReceiveWithSpark = sparkState.isReady,
+ canReceiveWithSpark = sparkState.isUsable,
onClickReceiveCjit = { navController.navigateTo(ReceiveRoute.TransferUnavailable) },
onClickEditInvoice = {
editInvoiceSourceTab = it
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt
index 2687b39880..dda86e17ba 100644
--- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt
@@ -38,6 +38,8 @@ import to.bitkit.repositories.BlocktankRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.LogsRepo
import to.bitkit.repositories.SparkDeposit
+import to.bitkit.repositories.SparkExitRepo
+import to.bitkit.repositories.SparkExitState
import to.bitkit.repositories.SparkRepo
import to.bitkit.repositories.SparkState
import to.bitkit.repositories.SparkWithdrawal
@@ -55,6 +57,7 @@ class LightningConnectionsViewModel @Inject constructor(
private val logsRepo: LogsRepo,
private val activityRepo: ActivityRepo,
private val sparkRepo: SparkRepo,
+ private val sparkExitRepo: SparkExitRepo,
) : ViewModel() {
companion object {
@@ -101,9 +104,11 @@ class LightningConnectionsViewModel @Inject constructor(
lightningRepo.lightningState,
lightningRepo.lastKnownChannels,
sparkRepo.state,
- ) { lightningState, channels, sparkState ->
- Triple(lightningState.nodeLifecycleState.isRunning(), channels, sparkState)
- }.collect { (isNodeRunning, channels, sparkState) ->
+ sparkExitRepo.state,
+ ) { lightningState, channels, sparkState, exitState ->
+ ConnectionsSnapshot(lightningState.nodeLifecycleState.isRunning(), channels, sparkState, exitState)
+ }.collect { snapshot ->
+ val (isNodeRunning, channels, sparkState) = snapshot
val connectionText = context.getString(R.string.lightning__connection)
_uiState.update {
@@ -115,10 +120,10 @@ class LightningConnectionsViewModel @Inject constructor(
pendingConnections = channels.filterPending()
.map { it.mapToUiModel(channels, connectionText) }
.reversed().toImmutableList(),
- localBalance = calculateLocalBalance(channels).safe() + sparkState.balanceSats.safe(),
+ localBalance = calculateLocalBalance(channels).safe() + sparkState.spendingSats.safe(),
remoteBalance = channels.calculateRemoteBalance(),
- spark = sparkState.toUi(lightningRepo),
- isSparkReceiving = sparkState.isReady,
+ spark = sparkState.toUi(lightningRepo, snapshot.exitState.showsResult),
+ isSparkReceiving = sparkState.isUsable,
)
}
}
@@ -238,7 +243,14 @@ data class SparkUi(
val balanceSats: ULong,
val deposits: ImmutableList = persistentListOf(),
val withdrawals: ImmutableList = persistentListOf(),
+ val recoverableFundsSats: ULong = 0uL,
+ val isExiting: Boolean = false,
+ /** An exit runs, or ended with leftover fee funding not yet swept: Emergency exit opens its progress. */
+ val showsExitResult: Boolean = false,
) {
+ /** The emergency exit is offered while Spark holds funds, and leads to its progress while it shows one. */
+ val showsExit get() = balanceSats > 0uL || recoverableFundsSats > 0uL || showsExitResult
+
val pendingDeposits get() = deposits.filterNot { it.deposit.isClaimed }
val claimedDeposits get() = deposits.filter { it.deposit.isClaimed }
}
@@ -249,19 +261,32 @@ data class SparkDepositUi(
val confirmations: UInt? = null,
)
-internal suspend fun SparkState.toUi(lightningRepo: LightningRepo): SparkUi? {
- if (!isReady) return null
+internal suspend fun SparkState.toUi(lightningRepo: LightningRepo, showsExitResult: Boolean = false): SparkUi? {
+ val showsExit = isExiting || showsExitResult
+ // An emergency exit keeps Spark listed, also while it cannot connect, so its progress stays reachable.
+ if (!isReady && !showsExit) return null
// On regtest Spark always shows, so testers reach its deposit address to fund it from the faucet.
- if (balanceSats == 0uL && deposits.isEmpty() && Env.network != Network.REGTEST) return null
+ val hasExit = showsExit || recoverableFundsSats > 0uL
+ if (balanceSats == 0uL && deposits.isEmpty() && !hasExit && Env.network != Network.REGTEST) return null
return SparkUi(
balanceSats = balanceSats,
deposits = deposits.map {
SparkDepositUi(it, if (it.isClaimed) null else lightningRepo.onchainConfirmations(it.txid))
}.toImmutableList(),
withdrawals = withdrawals,
+ recoverableFundsSats = recoverableFundsSats,
+ isExiting = isExiting,
+ showsExitResult = showsExit,
)
}
+private data class ConnectionsSnapshot(
+ val isNodeRunning: Boolean,
+ val channels: List,
+ val sparkState: SparkState,
+ val exitState: SparkExitState,
+)
+
data class ChannelUi(
val name: String,
val details: ChannelDetails,
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt
index e78fc3abaa..6adbdaceea 100644
--- a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailScreen.kt
@@ -35,6 +35,7 @@ import to.bitkit.ui.components.ChannelStatusUi
import to.bitkit.ui.components.LightningChannel
import to.bitkit.ui.components.MoneyCaptionB
import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.TertiaryButton
import to.bitkit.ui.components.VerticalSpacer
import to.bitkit.ui.scaffold.AppTopBar
import to.bitkit.ui.scaffold.DrawerNavIcon
@@ -47,6 +48,7 @@ import to.bitkit.ui.utils.getBlockExplorerUrl
fun SparkDetailScreen(
onBack: () -> Unit,
onTransferToSavings: () -> Unit,
+ onEmergencyExit: (showsResult: Boolean) -> Unit,
viewModel: SparkDetailViewModel = hiltViewModel(),
) {
val context = LocalContext.current
@@ -59,6 +61,7 @@ fun SparkDetailScreen(
onOpenTx = { txId -> context.startActivity(Intent(Intent.ACTION_VIEW, getBlockExplorerUrl(txId).toUri())) },
onCopyText = { context.setClipboardText(it) },
onTransferToSavings = onTransferToSavings,
+ onEmergencyExit = { onEmergencyExit(uiState.spark?.showsExitResult == true) },
)
}
@@ -71,6 +74,7 @@ private fun Content(
onOpenTx: (String) -> Unit = {},
onCopyText: (String) -> Unit = {},
onTransferToSavings: () -> Unit = {},
+ onEmergencyExit: () -> Unit = {},
) {
val spark = uiState.spark ?: SparkUi(balanceSats = 0uL)
ScreenColumn {
@@ -178,12 +182,19 @@ private fun Content(
PrimaryButton(
text = stringResource(R.string.wallet__transfer_to_savings),
onClick = onTransferToSavings,
- enabled = spark.balanceSats > 0uL,
+ enabled = spark.balanceSats > 0uL && !spark.isExiting,
modifier = Modifier
.fillMaxWidth()
.testTag("SparkTransferToSavings")
)
-
+ if (spark.showsExit) {
+ VerticalSpacer(8.dp)
+ TertiaryButton(
+ text = stringResource(R.string.lightning__spark_exit__button),
+ onClick = onEmergencyExit,
+ modifier = Modifier.testTag("SparkEmergencyExit")
+ )
+ }
VerticalSpacer(16.dp)
}
}
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt
index ac14080045..dbea63b108 100644
--- a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkDetailViewModel.kt
@@ -8,12 +8,14 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.lightningdevkit.ldknode.Network
import to.bitkit.di.BgDispatcher
import to.bitkit.env.Env
import to.bitkit.repositories.LightningRepo
+import to.bitkit.repositories.SparkExitRepo
import to.bitkit.repositories.SparkRepo
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@@ -23,13 +25,17 @@ class SparkDetailViewModel @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val lightningRepo: LightningRepo,
private val sparkRepo: SparkRepo,
+ private val sparkExitRepo: SparkExitRepo,
) : ViewModel() {
private val _uiState = MutableStateFlow(SparkDetailUiState())
val uiState = _uiState.asStateFlow()
init {
viewModelScope.launch(bgDispatcher) {
- sparkRepo.state.collect { state -> _uiState.update { it.copy(spark = state.toUi(lightningRepo)) } }
+ combine(sparkRepo.state, sparkExitRepo.state) { state, exit -> state to exit.showsResult }
+ .collect { (state, showsResult) ->
+ _uiState.update { it.copy(spark = state.toUi(lightningRepo, showsResult)) }
+ }
}
if (Env.network == Network.REGTEST) {
viewModelScope.launch(bgDispatcher) {
@@ -48,7 +54,8 @@ class SparkDetailViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(isRefreshing = true) }
sparkRepo.refreshState()
- _uiState.update { it.copy(spark = sparkRepo.state.value.toUi(lightningRepo)) }
+ val showsResult = sparkExitRepo.state.value.showsResult
+ _uiState.update { it.copy(spark = sparkRepo.state.value.toUi(lightningRepo, showsResult)) }
delay(500.milliseconds)
_uiState.update { it.copy(isRefreshing = false) }
}
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitComponents.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitComponents.kt
new file mode 100644
index 0000000000..ef9d0932b2
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitComponents.kt
@@ -0,0 +1,181 @@
+package to.bitkit.ui.settings.lightning
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import breez_sdk_spark.RecoverFundsResponse
+import to.bitkit.R
+import to.bitkit.ext.setClipboardText
+import to.bitkit.repositories.SparkExitMath
+import to.bitkit.repositories.SparkExitPartialFunding
+import to.bitkit.repositories.SparkExitQuote
+import to.bitkit.repositories.SparkExitRecord
+import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.CaptionB
+import to.bitkit.ui.components.CaptionM
+import to.bitkit.ui.components.MoneyCaptionB
+import to.bitkit.ui.components.QrCodeImage
+import to.bitkit.ui.components.rememberMoneyText
+import to.bitkit.ui.shared.modifiers.clickableAlpha
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+import to.bitkit.utils.BlockTimeHelpers
+
+/** An amount as plain text for copy that names it, in the user's primary unit. */
+@Composable
+internal fun sparkExitAmountText(sats: ULong): String =
+ rememberMoneyText(sats.toLong(), showSymbol = true)?.replace(Regex("?accent>"), "") ?: sats.toString()
+
+@Composable
+internal fun SparkExitQuoteRows(quote: SparkExitQuote) {
+ Column {
+ SparkExitAmountRow(stringResource(R.string.lightning__spark_exit__amount), quote.exitSats)
+ SparkExitAmountRow(stringResource(R.string.lightning__spark_exit__fees), quote.networkFeeSats)
+ SparkExitAmountRow(stringResource(R.string.lightning__spark_exit__receive), quote.receiveSats)
+ if (quote.fundingSats > 0uL) {
+ SparkExitAmountRow(stringResource(R.string.lightning__spark_exit__funding), quote.fundingSats)
+ }
+ if (quote.leftBehindSats > 0uL) {
+ SparkExitAmountRow(stringResource(R.string.lightning__spark_exit__left_behind), quote.leftBehindSats)
+ }
+ }
+}
+
+@Composable
+private fun SparkExitAmountRow(label: String, sats: ULong) {
+ SectionRow(name = label, valueContent = { MoneyCaptionB(sats = sats.toLong(), symbol = true) })
+}
+
+/** The funding address as QR and text, which copies it on tap, with what it must receive and what arrived so far. */
+@Composable
+internal fun SparkExitFundingAddress(
+ address: String,
+ requiredSats: ULong,
+ seenSats: ULong,
+ modifier: Modifier = Modifier,
+ partial: SparkExitPartialFunding? = SparkExitMath.partialFunding(seenSats, requiredSats),
+) {
+ val context = LocalContext.current
+ Column(
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = modifier.fillMaxWidth()
+ ) {
+ QrCodeImage(content = address, size = 200.dp)
+ BodyS(
+ text = stringResource(R.string.lightning__spark_exit__fund_external_text)
+ .replace("{amount}", sparkExitAmountText(requiredSats)),
+ color = Colors.White64,
+ modifier = Modifier.fillMaxWidth()
+ )
+ CaptionB(
+ text = address,
+ maxLines = 2,
+ overflow = TextOverflow.MiddleEllipsis,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickableAlpha { context.setClipboardText(address) }
+ .testTag("SparkExitFundingAddress")
+ )
+ partial?.let {
+ BodyS(
+ text = stringResource(R.string.lightning__spark_exit__fund_partial)
+ .replace("{received}", sparkExitAmountText(it.receivedSats))
+ .replace("{required}", sparkExitAmountText(it.requiredSats))
+ .replace("{missing}", sparkExitAmountText(it.missingSats)),
+ color = Colors.White64,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("SparkExitFundingPartial")
+ )
+ }
+ if (requiredSats > 0uL && seenSats >= requiredSats) {
+ CaptionM(
+ text = stringResource(R.string.lightning__spark_exit__fund_seen).withAccent(),
+ color = Colors.Green,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
+/** A line of progress with the transaction it is about, whose id copies on tap. */
+@Composable
+internal fun SparkExitTxNote(text: String, txid: String, @StringRes txidRes: Int, testTag: String) {
+ val context = LocalContext.current
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
+ BodyS(text = text, color = Colors.White64, modifier = Modifier.testTag(testTag))
+ CaptionB(
+ text = stringResource(txidRes).replace("{txid}", txid),
+ maxLines = 1,
+ overflow = TextOverflow.MiddleEllipsis,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickableAlpha { context.setClipboardText(txid) }
+ )
+ }
+}
+
+/** The exit's steps: funding, publishing, the timelock and the sweep. */
+@Composable
+internal fun SparkExitSteps(record: SparkExitRecord, tipHeight: UInt?) {
+ val recovery: RecoverFundsResponse? = record.recovery
+ val transactions = recovery?.transactions.orEmpty()
+ val isFunded = record.phase !is SparkExitRecord.Phase.AwaitingFunding
+ val isDone = record.phase == SparkExitRecord.Phase.Done
+ val remaining = record.claimableAtHeight?.let { target ->
+ tipHeight?.let { BlockTimeHelpers.blocksRemaining(target, it) }
+ }?.takeIf { it > 0 }?.let(BlockTimeHelpers::getDurationForBlocks)
+ Column {
+ SparkExitStep(stringResource(R.string.lightning__spark_exit__step_funding), isDone = isFunded)
+ SparkExitStep(
+ title = stringResource(R.string.lightning__spark_exit__step_publish),
+ detail = recovery?.let { "${SparkExitMath.confirmedCount(it)}/${transactions.size}" },
+ isDone = isDone,
+ )
+ SparkExitStep(
+ title = stringResource(R.string.lightning__spark_exit__step_timelock),
+ detail = remaining?.let {
+ stringResource(R.string.lightning__spark_exit__ready_in).replace("{duration}", it)
+ },
+ isDone = isDone || SparkExitMath.areTimelocksMatured(transactions),
+ )
+ SparkExitStep(stringResource(R.string.lightning__spark_exit__step_sweep), isDone = isDone)
+ }
+}
+
+@Composable
+private fun SparkExitStep(title: String, isDone: Boolean, detail: String? = null) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp)
+ ) {
+ Icon(
+ painter = painterResource(if (isDone) R.drawable.ic_check else R.drawable.ic_hourglass_simple),
+ contentDescription = null,
+ tint = if (isDone) Colors.Green else Colors.White64,
+ modifier = Modifier.size(20.dp)
+ )
+ CaptionB(text = title, modifier = Modifier.weight(1f))
+ detail?.let { CaptionB(text = it, color = Colors.White64) }
+ }
+ HorizontalDivider()
+}
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitConfirmScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitConfirmScreen.kt
new file mode 100644
index 0000000000..e9f6c0bbf6
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitConfirmScreen.kt
@@ -0,0 +1,100 @@
+package to.bitkit.ui.settings.lightning
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.SwipeToConfirm
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.rememberSubscriptionNotificationsRequest
+import to.bitkit.ui.utils.withAccent
+import to.bitkit.ui.utils.withAccentBoldBright
+
+/** The last look before the exit starts: what arrives and for how long the funds are out of reach. */
+@Composable
+fun SparkExitConfirmScreen(
+ viewModel: SparkExitViewModel,
+ onBack: () -> Unit,
+ onStart: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { if (it == SparkExitEffect.Started) onStart() }
+ }
+ // Asked as the exit starts: the notifications are how a stalled background run reaches the user.
+ val requestNotifications = rememberSubscriptionNotificationsRequest()
+ Content(
+ uiState = uiState,
+ onBack = onBack,
+ onSwipe = {
+ requestNotifications()
+ viewModel.start()
+ },
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: SparkExitUiState,
+ onBack: () -> Unit = {},
+ onSwipe: () -> Unit = {},
+) {
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.lightning__spark_exit__nav_title),
+ onBackClick = onBack,
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ VerticalSpacer(32.dp)
+ Display(text = stringResource(R.string.lightning__spark_exit__confirm_title).withAccent())
+ VerticalSpacer(8.dp)
+ BodyM(
+ text = stringResource(R.string.lightning__spark_exit__confirm_text).withAccentBoldBright(),
+ color = Colors.White64,
+ )
+ VerticalSpacer(32.dp)
+ uiState.quote?.let { SparkExitQuoteRows(it) }
+ VerticalSpacer(32.dp)
+ }
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ SwipeToConfirm(
+ text = stringResource(R.string.lightning__spark_exit__swipe),
+ color = Colors.Brand,
+ loading = uiState.isStarting,
+ enabled = uiState.quote != null && !uiState.isStarting,
+ onConfirm = onSwipe,
+ modifier = Modifier.testTag("SparkExitSwipe")
+ )
+ VerticalSpacer(16.dp)
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(uiState = SparkExitUiState())
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitFundingScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitFundingScreen.kt
new file mode 100644
index 0000000000..941a988aab
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitFundingScreen.kt
@@ -0,0 +1,143 @@
+package to.bitkit.ui.settings.lightning
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.CaptionM
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+import to.bitkit.ui.utils.withAccentBoldBright
+
+/** Pays the CPFP fee funding from savings, or shows the funding address to pay from another wallet. */
+@Composable
+fun SparkExitFundingScreen(
+ viewModel: SparkExitViewModel,
+ onBack: () -> Unit,
+ onContinue: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ LaunchedEffect(Unit) {
+ viewModel.onFundingShown()
+ viewModel.loadSavingsFunding()
+ }
+ DisposableEffect(Unit) { onDispose { viewModel.stopWatchingFundingAddress() } }
+ Content(
+ uiState = uiState,
+ onBack = onBack,
+ onFundFromSavings = {
+ viewModel.onFundFromSavings()
+ onContinue()
+ },
+ onFundExternally = viewModel::onFundExternally,
+ onContinue = onContinue,
+ )
+}
+
+@Composable
+private fun Content(
+ uiState: SparkExitUiState,
+ onBack: () -> Unit = {},
+ onFundFromSavings: () -> Unit = {},
+ onFundExternally: () -> Unit = {},
+ onContinue: () -> Unit = {},
+) {
+ val fundingSats = uiState.fundingSats
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.lightning__spark_exit__nav_title),
+ onBackClick = onBack,
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ VerticalSpacer(32.dp)
+ Display(text = stringResource(R.string.lightning__spark_exit__funding_title).withAccent())
+ VerticalSpacer(8.dp)
+ BodyM(
+ text = stringResource(R.string.lightning__spark_exit__funding_text)
+ .replace("{amount}", sparkExitAmountText(fundingSats))
+ .withAccentBoldBright(),
+ color = Colors.White64,
+ )
+ val address = uiState.fundingAddress
+ if (uiState.showsFundingAddress && address != null) {
+ VerticalSpacer(24.dp)
+ SparkExitFundingAddress(
+ address = address,
+ requiredSats = fundingSats,
+ seenSats = uiState.fundingSeenSats
+ )
+ }
+ VerticalSpacer(32.dp)
+ }
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ if (uiState.showsFundingAddress) {
+ PrimaryButton(
+ text = stringResource(R.string.common__continue),
+ onClick = onContinue,
+ enabled = uiState.canContinueFunding,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("SparkExitFundingContinue")
+ )
+ } else {
+ val isPricing = uiState.savingsFeeSats == null && !uiState.hasSavingsFeeFailed
+ if (!isPricing && !uiState.canFundFromSavings) {
+ CaptionM(
+ text = stringResource(R.string.lightning__spark_exit__fund_insufficient).withAccent(),
+ color = Colors.White64,
+ )
+ VerticalSpacer(16.dp)
+ }
+ PrimaryButton(
+ text = stringResource(R.string.lightning__spark_exit__fund_savings),
+ onClick = onFundFromSavings,
+ isLoading = isPricing,
+ enabled = uiState.canFundFromSavings,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("SparkExitFundSavings")
+ )
+ VerticalSpacer(16.dp)
+ SecondaryButton(
+ text = stringResource(R.string.lightning__spark_exit__fund_external),
+ onClick = onFundExternally,
+ testTag = "SparkExitFundExternal",
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(uiState = SparkExitUiState(hasSavingsFeeFailed = true))
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitIntroScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitIntroScreen.kt
new file mode 100644
index 0000000000..11078db7f2
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitIntroScreen.kt
@@ -0,0 +1,111 @@
+package to.bitkit.ui.settings.lightning
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import to.bitkit.R
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.FillHeight
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+import to.bitkit.ui.utils.withAccentBoldBright
+
+/** When to use an emergency exit, what it costs and how long it takes. */
+@Composable
+fun SparkExitIntroScreen(
+ onBack: () -> Unit,
+ onContinue: () -> Unit,
+) {
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.lightning__spark_exit__nav_title),
+ onBackClick = onBack,
+ )
+ BoxWithConstraints(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 16.dp)
+ ) {
+ VerticalSpacer(32.dp)
+ Display(
+ text = stringResource(R.string.lightning__spark_exit__intro_title).withAccent(),
+ modifier = Modifier.fillMaxWidth()
+ )
+ VerticalSpacer(8.dp)
+ BodyM(
+ text = stringResource(R.string.lightning__spark_exit__intro_text).withAccentBoldBright(),
+ color = Colors.White64,
+ modifier = Modifier.fillMaxWidth()
+ )
+ FillHeight()
+ Image(
+ painter = painterResource(R.drawable.exclamation_mark),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier.size(256.dp)
+ )
+ FillHeight()
+ }
+ }
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ SecondaryButton(
+ text = stringResource(R.string.common__cancel),
+ onClick = onBack,
+ modifier = Modifier.weight(1f)
+ )
+ PrimaryButton(
+ text = stringResource(R.string.common__continue),
+ onClick = onContinue,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("SparkExitIntroContinue")
+ )
+ }
+ VerticalSpacer(16.dp)
+ }
+ }
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ SparkExitIntroScreen(onBack = {}, onContinue = {})
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitProgressScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitProgressScreen.kt
new file mode 100644
index 0000000000..09c665b752
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitProgressScreen.kt
@@ -0,0 +1,331 @@
+package to.bitkit.ui.settings.lightning
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.repositories.SparkExitBackgroundLimits
+import to.bitkit.repositories.SparkExitFundingState
+import to.bitkit.repositories.SparkExitRecord
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.SecondaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.openNotificationSettings
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+import to.bitkit.ui.utils.withAccentBoldBright
+
+/** The running exit's steps and countdown, and its result once it ends. */
+@Composable
+fun SparkExitProgressScreen(
+ viewModel: SparkExitViewModel,
+ onBack: () -> Unit,
+ onDone: () -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ LaunchedEffect(Unit) {
+ viewModel.refresh()
+ viewModel.effects.collect { if (it == SparkExitEffect.Cancelled) onDone() }
+ }
+ val isEnded = uiState.record?.isActive == false
+ LaunchedEffect(isEnded) { if (isEnded) viewModel.onResultShown() }
+ LifecycleResumeEffect(Unit) {
+ viewModel.refreshBackgroundLimits()
+ onPauseOrDispose {}
+ }
+ Content(
+ uiState = uiState,
+ onBack = onBack,
+ onRefresh = viewModel::refresh,
+ onCancel = viewModel::cancel,
+ onDone = onDone,
+ onNotificationSettings = context::openNotificationSettings,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun Content(
+ uiState: SparkExitUiState,
+ onBack: () -> Unit = {},
+ onRefresh: () -> Unit = {},
+ onCancel: () -> Unit = {},
+ onDone: () -> Unit = {},
+ onNotificationSettings: () -> Unit = {},
+) {
+ val record = uiState.record
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.lightning__spark_exit__nav_title),
+ onBackClick = onBack,
+ )
+ PullToRefreshBox(
+ isRefreshing = uiState.isRefreshing,
+ onRefresh = onRefresh,
+ modifier = Modifier.weight(1f)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState())
+ .testTag("SparkExitProgress")
+ ) {
+ VerticalSpacer(32.dp)
+ Display(text = progressTitle(record?.phase).withAccent())
+ VerticalSpacer(8.dp)
+ BodyM(
+ text = progressText(
+ record = record,
+ progressFunding = uiState.progressFunding,
+ staysOnSparkSats = uiState.staysOnSparkSats,
+ ).withAccentBoldBright(),
+ color = Colors.White64,
+ )
+ VerticalSpacer(24.dp)
+ record?.let { ProgressDetails(it, uiState) }
+ if (record?.isActive == true) {
+ BackgroundNotes(
+ limits = uiState.backgroundLimits,
+ onNotificationSettings = onNotificationSettings,
+ )
+ }
+ VerticalSpacer(32.dp)
+ }
+ }
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ if (uiState.canCancel) {
+ SecondaryButton(
+ text = stringResource(R.string.lightning__spark_exit__cancel),
+ onClick = onCancel,
+ testTag = "SparkExitCancel",
+ )
+ VerticalSpacer(16.dp)
+ }
+ PrimaryButton(
+ text = stringResource(R.string.common__ok),
+ onClick = onDone,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("SparkExitProgressOk")
+ )
+ VerticalSpacer(16.dp)
+ }
+ }
+}
+
+@Composable
+private fun ProgressDetails(record: SparkExitRecord, uiState: SparkExitUiState) {
+ SparkExitSteps(record = record, tipHeight = uiState.tipHeight)
+ val address = record.funding.firstOrNull()?.address
+ val funding = uiState.progressFunding
+ if (funding != null && address != null) {
+ VerticalSpacer(24.dp)
+ when (funding) {
+ is SparkExitFundingState.Ask -> SparkExitFundingAddress(
+ address = address,
+ requiredSats = funding.sats,
+ seenSats = 0uL,
+ partial = funding.partial,
+ )
+ is SparkExitFundingState.Paid -> SparkExitTxNote(
+ text = stringResource(R.string.lightning__spark_exit__fund_paid),
+ txid = funding.txid,
+ txidRes = R.string.lightning__spark_exit__funding_txid,
+ testTag = "SparkExitFundingPaid",
+ )
+ is SparkExitFundingState.Seen -> SparkExitTxNote(
+ text = stringResource(R.string.lightning__spark_exit__fund_pending),
+ txid = funding.txid,
+ txidRes = R.string.lightning__spark_exit__funding_txid,
+ testTag = "SparkExitFundingSeen",
+ )
+ }
+ }
+ uiState.rejection?.let {
+ VerticalSpacer(16.dp)
+ BodyS(text = stringResource(R.string.lightning__spark_exit__errors).replace("{error}", it), color = Colors.Red)
+ }
+ uiState.outsideExitSats?.let {
+ VerticalSpacer(16.dp)
+ BodyS(
+ text = stringResource(R.string.lightning__spark_exit__outside_exit)
+ .replace("{amount}", sparkExitAmountText(it)),
+ color = Colors.White64,
+ )
+ }
+ if (record.lostSats > 0uL) {
+ VerticalSpacer(16.dp)
+ BodyS(
+ text = stringResource(R.string.lightning__spark_exit__lost_leaves)
+ .replace("{amount}", sparkExitAmountText(record.lostSats)),
+ color = Colors.Red,
+ modifier = Modifier.testTag("SparkExitLostLeaves")
+ )
+ }
+ if (record.leftBehindLeafSats > 0uL) {
+ VerticalSpacer(16.dp)
+ BodyS(
+ text = stringResource(R.string.lightning__spark_exit__left_behind_leaves)
+ .replace("{amount}", sparkExitAmountText(record.leftBehindLeafSats)),
+ color = Colors.White64,
+ modifier = Modifier.testTag("SparkExitLeftBehindLeaves")
+ )
+ }
+ if (!record.isActive && address != null) LeftoverNote(record, address)
+}
+
+/** Where the leftover fee funding of an ended exit is: being sent, sent, kept as dust, or still at its address. */
+@Composable
+private fun LeftoverNote(record: SparkExitRecord, address: String) {
+ if (record.leftover.isEmpty()) return
+ val total = record.leftover.fold(0uL) { sum, leftover -> sum + leftover.valueSats }
+ val sweep = record.leftoverSweep
+ VerticalSpacer(16.dp)
+ when {
+ sweep != null && sweep.txid == null -> BodyS(
+ text = stringResource(R.string.lightning__spark_exit__leftover_dust)
+ .replace("{amount}", sparkExitAmountText(total))
+ .replace("{address}", address),
+ color = Colors.White64,
+ )
+ sweep?.txid != null && sweep.isAccepted -> SparkExitTxNote(
+ text = stringResource(R.string.lightning__spark_exit__leftover_swept)
+ .replace("{amount}", sparkExitAmountText(sweep.valueSats)),
+ txid = sweep.txid,
+ txidRes = R.string.lightning__spark_exit__leftover_txid,
+ testTag = "SparkExitLeftoverSwept",
+ )
+ sweep?.txid != null && sweep.rejection == null -> BodyS(
+ text = stringResource(R.string.lightning__spark_exit__leftover_sending)
+ .replace("{amount}", sparkExitAmountText(sweep.valueSats)),
+ color = Colors.White64,
+ )
+ else -> BodyS(
+ text = stringResource(R.string.lightning__spark_exit__leftover)
+ .replace("{amount}", sparkExitAmountText(total))
+ .replace("{address}", address),
+ color = Colors.White64,
+ )
+ }
+}
+
+@Composable
+private fun BackgroundNotes(
+ limits: SparkExitBackgroundLimits,
+ onNotificationSettings: () -> Unit,
+) {
+ val notes = listOfNotNull(
+ R.string.lightning__spark_exit__note_restricted.takeIf { limits.isRestricted },
+ R.string.lightning__spark_exit__note_battery_saver.takeIf { limits.isPowerSaveMode },
+ R.string.lightning__spark_exit__note_battery_optimized.takeIf {
+ limits.isOptimized && !limits.isRestricted && !limits.isPowerSaveMode
+ },
+ R.string.lightning__spark_exit__note_notifications.takeIf { !limits.areNotificationsEnabled },
+ )
+ notes.forEach {
+ VerticalSpacer(16.dp)
+ BodyS(text = stringResource(it), color = Colors.White64)
+ }
+ if (!limits.areNotificationsEnabled) {
+ VerticalSpacer(16.dp)
+ SecondaryButton(
+ text = stringResource(R.string.lightning__spark_exit__note_notifications_button),
+ onClick = onNotificationSettings,
+ testTag = "SparkExitNotificationSettings",
+ )
+ }
+}
+
+@Composable
+private fun progressTitle(phase: SparkExitRecord.Phase?): String = stringResource(
+ when (phase) {
+ SparkExitRecord.Phase.Done -> R.string.lightning__spark_exit__done_title
+ is SparkExitRecord.Phase.Failed -> R.string.lightning__spark_exit__failed_title
+ is SparkExitRecord.Phase.NeedsFunding -> R.string.lightning__spark_exit__needs_funding_title
+ else -> R.string.lightning__spark_exit__progress_title
+ },
+)
+
+@Composable
+private fun progressText(
+ record: SparkExitRecord?,
+ progressFunding: SparkExitFundingState?,
+ staysOnSparkSats: ULong?,
+): String = when (record?.phase) {
+ SparkExitRecord.Phase.Done -> listOfNotNull(
+ stringResource(R.string.lightning__spark_exit__done_text),
+ record.skippedLeafCount.takeIf { it > 0 }?.let {
+ stringResource(R.string.lightning__spark_exit__partial_text).replace("{count}", "$it")
+ },
+ record.failedLeafCount.takeIf { it > 0 }?.let {
+ stringResource(R.string.lightning__spark_exit__failed_leaves).replace("{count}", "$it")
+ },
+ ).joinToString(" ")
+ is SparkExitRecord.Phase.Failed -> sparkExitFailedText(
+ nothingText = stringResource(R.string.lightning__spark_exit__failed_text),
+ staysText = stringResource(R.string.lightning__spark_exit__failed_stays),
+ outsideAmount = staysOnSparkSats?.let { sparkExitAmountText(it) },
+ )
+ is SparkExitRecord.Phase.NeedsFunding -> when (val funding = progressFunding) {
+ // A payment that covers it waits to confirm: nothing to ask for.
+ is SparkExitFundingState.Ask -> stringResource(R.string.lightning__spark_exit__needs_funding_text)
+ .replace("{amount}", sparkExitAmountText(funding.sats))
+ else -> stringResource(R.string.lightning__spark_exit__progress_text)
+ }
+ else -> stringResource(R.string.lightning__spark_exit__progress_text)
+}
+
+/**
+ * The stopped exit's text: nothing was recovered, and what Spark still holds beyond the exit when it holds any. Lost
+ * leaves are gone, so they are not said to stay on Spark.
+ */
+internal fun sparkExitFailedText(nothingText: String, staysText: String, outsideAmount: String?): String =
+ listOfNotNull(nothingText, outsideAmount?.let { staysText.replace("{amount}", it) }).joinToString(" ")
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(
+ uiState = SparkExitUiState(
+ record = SparkExitRecord(
+ network = "regtest",
+ createdAt = 0L,
+ phase = SparkExitRecord.Phase.Running,
+ feeRateSatPerVbyte = 2uL,
+ destination = "bcrt1q",
+ leafIds = listOf("leaf"),
+ funding = listOf(SparkExitRecord.Funding(0u, "bcrt1qfunding", "02ab")),
+ expectedSats = 91_000uL,
+ claimableAtHeight = 2_100u,
+ ),
+ tipHeight = 1_900u,
+ backgroundLimits = SparkExitBackgroundLimits(isOptimized = true, areNotificationsEnabled = false),
+ ),
+ )
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitQuoteScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitQuoteScreen.kt
new file mode 100644
index 0000000000..ccf7e7f921
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitQuoteScreen.kt
@@ -0,0 +1,121 @@
+package to.bitkit.ui.settings.lightning
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import to.bitkit.R
+import to.bitkit.ui.components.BodyM
+import to.bitkit.ui.components.BodyS
+import to.bitkit.ui.components.Display
+import to.bitkit.ui.components.PrimaryButton
+import to.bitkit.ui.components.VerticalSpacer
+import to.bitkit.ui.scaffold.AppTopBar
+import to.bitkit.ui.scaffold.ScreenColumn
+import to.bitkit.ui.theme.AppThemeSurface
+import to.bitkit.ui.theme.Colors
+import to.bitkit.ui.utils.withAccent
+
+/** What the exit takes on-chain, what it costs, and what stays behind. */
+@Composable
+fun SparkExitQuoteScreen(
+ viewModel: SparkExitViewModel,
+ onBack: () -> Unit,
+ onContinue: (needsFunding: Boolean) -> Unit,
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ LaunchedEffect(Unit) { viewModel.loadQuote() }
+ Content(uiState = uiState, onBack = onBack, onContinue = { onContinue(uiState.fundingSats > 0uL) })
+}
+
+@Composable
+private fun Content(
+ uiState: SparkExitUiState,
+ onBack: () -> Unit = {},
+ onContinue: () -> Unit = {},
+) {
+ val block = uiState.quoteBlock
+ ScreenColumn {
+ AppTopBar(
+ titleText = stringResource(R.string.lightning__spark_exit__nav_title),
+ onBackClick = onBack,
+ )
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ VerticalSpacer(32.dp)
+ Display(text = stringResource(R.string.lightning__spark_exit__quote_title).withAccent())
+ VerticalSpacer(32.dp)
+ when {
+ uiState.quote != null -> {
+ SparkExitQuoteRows(uiState.quote)
+ uiState.quote.unexitableSats?.let { UnexitableNote(it) }
+ }
+ uiState.isQuoting && uiState.isSettling -> BodyM(
+ text = stringResource(R.string.lightning__spark_exit__quote_settling),
+ )
+ uiState.isQuoting -> BodyM(text = stringResource(R.string.lightning__spark_exit__quote_loading))
+ }
+ block?.let {
+ VerticalSpacer(16.dp)
+ BodyM(
+ text = stringResource(
+ when (it) {
+ SparkExitQuoteBlock.Error -> R.string.lightning__spark_exit__quote_error
+ SparkExitQuoteBlock.Nothing -> R.string.lightning__spark_exit__nothing
+ SparkExitQuoteBlock.Uneconomic -> R.string.lightning__spark_exit__uneconomic
+ },
+ ),
+ color = Colors.Red,
+ modifier = Modifier.testTag("SparkExitQuoteBlocked")
+ )
+ }
+ VerticalSpacer(32.dp)
+ }
+ Column(modifier = Modifier.padding(horizontal = 16.dp)) {
+ PrimaryButton(
+ text = stringResource(R.string.common__continue),
+ onClick = onContinue,
+ isLoading = uiState.isQuoting,
+ enabled = uiState.quote != null && block == null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag("SparkExitQuoteContinue")
+ )
+ VerticalSpacer(16.dp)
+ }
+ }
+}
+
+/** What the quote cannot exit: skipped leaves, and value still moving inside Spark. */
+@Composable
+private fun UnexitableNote(sats: ULong) {
+ VerticalSpacer(16.dp)
+ BodyS(
+ text = stringResource(R.string.lightning__spark_exit__quote_outside)
+ .replace("{amount}", sparkExitAmountText(sats)),
+ color = Colors.White64,
+ modifier = Modifier.testTag("SparkExitQuoteOutside")
+ )
+}
+
+@Preview(showSystemUi = true)
+@Composable
+private fun Preview() {
+ AppThemeSurface {
+ Content(uiState = SparkExitUiState(isQuoting = true))
+ }
+}
diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitViewModel.kt
new file mode 100644
index 0000000000..4a10af579a
--- /dev/null
+++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitViewModel.kt
@@ -0,0 +1,234 @@
+package to.bitkit.ui.settings.lightning
+
+import androidx.compose.runtime.Stable
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import to.bitkit.repositories.SparkExitBackgroundLimits
+import to.bitkit.repositories.SparkExitFundingState
+import to.bitkit.repositories.SparkExitMath
+import to.bitkit.repositories.SparkExitQuote
+import to.bitkit.repositories.SparkExitRecord
+import to.bitkit.repositories.SparkExitRepo
+import to.bitkit.repositories.SparkExitState
+import to.bitkit.repositories.SparkRepo
+import to.bitkit.repositories.surfacedRejection
+import to.bitkit.ui.shared.toast.ToastEventBus
+import javax.inject.Inject
+import kotlin.time.Duration.Companion.seconds
+
+/** Drives the emergency exit screens, from the quote to the progress of a running exit. */
+@HiltViewModel
+class SparkExitViewModel @Inject constructor(
+ private val sparkExitRepo: SparkExitRepo,
+ private val sparkRepo: SparkRepo,
+) : ViewModel() {
+ companion object {
+ /** How often the funding screen looks at the funding address while the user pays from another wallet. */
+ private val FUNDING_POLL_INTERVAL = 15.seconds
+ }
+
+ private val _uiState = MutableStateFlow(SparkExitUiState())
+ val uiState = _uiState.asStateFlow()
+
+ private val _effects = MutableSharedFlow(extraBufferCapacity = 1)
+ val effects = _effects.asSharedFlow()
+
+ private var fundingWatch: Job? = null
+
+ init {
+ viewModelScope.launch {
+ // Spark's live balance, for what a stopped exit leaves on Spark after a relaunch too.
+ combine(sparkExitRepo.state, sparkRepo.state) { exit, spark -> exit to spark.balanceSats }
+ .collect { (exit, balanceSats) -> showExit(exit, balanceSats) }
+ }
+ }
+
+ private fun showExit(exit: SparkExitState, balanceSats: ULong) {
+ _uiState.update {
+ it.copy(
+ record = exit.record,
+ tipHeight = exit.tipHeight,
+ fundingSeenSats = exit.fundingSeenSats,
+ fundingState = exit.fundingState,
+ fundingAddress = exit.fundingAddress,
+ canCancel = sparkExitRepo.canCancel,
+ outsideExitSats = exit.outsideExitSats,
+ staysOnSparkSats = SparkExitMath.staysOnSparkSats(balanceSats, exit.record),
+ isSettling = exit.isSettling,
+ )
+ }
+ }
+
+ fun loadQuote() {
+ viewModelScope.launch {
+ // A new quote starts the funding choice over.
+ stopWatchingFundingAddress()
+ _uiState.update {
+ it.copy(isQuoting = true, hasQuoteError = false, fundFromSavings = false, showsFundingAddress = false)
+ }
+ sparkExitRepo.loadQuote()
+ .onSuccess { quote -> _uiState.update { it.copy(quote = quote, isQuoting = false) } }
+ .onFailure { _uiState.update { it.copy(quote = null, isQuoting = false, hasQuoteError = true) } }
+ }
+ }
+
+ fun loadSavingsFunding() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(savingsFeeSats = null, hasSavingsFeeFailed = false) }
+ sparkExitRepo.savingsFundingFee()
+ .onSuccess { funding ->
+ _uiState.update { it.copy(savingsFeeSats = funding.feeSats, canFundFromSavings = funding.canPay) }
+ }
+ .onFailure { _uiState.update { it.copy(hasSavingsFeeFailed = true, canFundFromSavings = false) } }
+ }
+ }
+
+ /**
+ * Entering the funding step offers both choices again, unless the funding address already received a payment:
+ * then it keeps showing the address and what reached it.
+ */
+ fun onFundingShown() {
+ stopWatchingFundingAddress()
+ _uiState.update { it.copy(fundFromSavings = false, showsFundingAddress = false) }
+ viewModelScope.launch {
+ val seen = sparkExitRepo.refreshFundingAddress().getOrNull() ?: return@launch
+ if (seen > 0uL) onFundExternally()
+ }
+ }
+
+ fun onFundFromSavings() {
+ _uiState.update { it.copy(fundFromSavings = true) }
+ }
+
+ fun onFundExternally() {
+ _uiState.update { it.copy(fundFromSavings = false, showsFundingAddress = true) }
+ watchFundingAddress()
+ }
+
+ fun watchFundingAddress() {
+ if (fundingWatch?.isActive == true) return
+ fundingWatch = viewModelScope.launch {
+ while (isActive) {
+ sparkExitRepo.refreshFundingAddress()
+ delay(FUNDING_POLL_INTERVAL)
+ }
+ }
+ }
+
+ fun stopWatchingFundingAddress() {
+ fundingWatch?.cancel()
+ fundingWatch = null
+ }
+
+ fun start() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isStarting = true) }
+ sparkExitRepo.start(_uiState.value.fundFromSavings)
+ .onSuccess { _effects.emit(SparkExitEffect.Started) }
+ .onFailure {
+ ToastEventBus.send(it)
+ // The exit started but paying its fee funding failed: progress shows where to fund it.
+ if (sparkExitRepo.state.value.isExiting) _effects.emit(SparkExitEffect.Started)
+ }
+ _uiState.update { it.copy(isStarting = false) }
+ }
+ }
+
+ /** Progress showed the result of an ended exit, so a later Emergency exit can start a new one. */
+ fun onResultShown() {
+ viewModelScope.launch { sparkExitRepo.markResultSeen() }
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isRefreshing = true) }
+ sparkExitRepo.load()
+ sparkExitRepo.tick()
+ _uiState.update { it.copy(isRefreshing = false, canCancel = sparkExitRepo.canCancel) }
+ }
+ }
+
+ fun refreshBackgroundLimits() {
+ _uiState.update { it.copy(backgroundLimits = sparkExitRepo.backgroundLimits()) }
+ }
+
+ fun cancel() {
+ viewModelScope.launch {
+ sparkExitRepo.cancel()
+ .onSuccess { _effects.emit(SparkExitEffect.Cancelled) }
+ .onFailure { ToastEventBus.send(it) }
+ }
+ }
+}
+
+sealed interface SparkExitEffect {
+ data object Started : SparkExitEffect
+ data object Cancelled : SparkExitEffect
+}
+
+@Stable
+data class SparkExitUiState(
+ val quote: SparkExitQuote? = null,
+ val isQuoting: Boolean = false,
+ val hasQuoteError: Boolean = false,
+ val savingsFeeSats: ULong? = null,
+ val hasSavingsFeeFailed: Boolean = false,
+ val canFundFromSavings: Boolean = false,
+ val fundFromSavings: Boolean = false,
+ val showsFundingAddress: Boolean = false,
+ val isStarting: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val record: SparkExitRecord? = null,
+ val tipHeight: UInt? = null,
+ val fundingSeenSats: ULong = 0uL,
+ /** What progress shows while the exit needs fee funding. */
+ val fundingState: SparkExitFundingState? = null,
+ val fundingAddress: String? = null,
+ val canCancel: Boolean = false,
+ val backgroundLimits: SparkExitBackgroundLimits = SparkExitBackgroundLimits(),
+ val outsideExitSats: ULong? = null,
+ /** Spark's balance beyond the exit's leaves in any phase: what a stopped exit leaves on Spark. */
+ val staysOnSparkSats: ULong? = null,
+ /** The quote waits for Spark to finish moving leaves first. */
+ val isSettling: Boolean = false,
+) {
+ val fundingSats: ULong get() = quote?.fundingSats ?: 0uL
+
+ /** Continue after funding from another wallet: once the funding address holds what the quote asks for. */
+ val canContinueFunding: Boolean get() = fundingSats > 0uL && fundingSeenSats >= fundingSats
+
+ /** Why the quote cannot continue, or null when it can. */
+ val quoteBlock: SparkExitQuoteBlock?
+ get() {
+ if (hasQuoteError) return SparkExitQuoteBlock.Error
+ val quote = quote ?: return null
+ if (quote.prepared.leaves.isEmpty()) return SparkExitQuoteBlock.Nothing
+ if (!quote.isWorthExiting) return SparkExitQuoteBlock.Uneconomic
+ return null
+ }
+
+ val rejection: String? get() = record?.surfacedRejection
+
+ /**
+ * The fee funding progress shows, asking for the whole shortfall, at least the funding output's dust, until the
+ * funding address was read.
+ */
+ val progressFunding: SparkExitFundingState?
+ get() {
+ val shortfall = record?.fundingShortfallSats ?: return null
+ return fundingState ?: SparkExitFundingState.Ask(maxOf(shortfall, SparkExitMath.FUNDING_DUST_SATS))
+ }
+}
+
+enum class SparkExitQuoteBlock { Error, Nothing, Uneconomic }
diff --git a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt
index 53080db8ac..15a8445721 100644
--- a/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt
+++ b/app/src/main/java/to/bitkit/ui/utils/RequestNotificationPermissions.kt
@@ -124,7 +124,7 @@ fun rememberNotificationToggleClick(
}
}
-/** Asks for notification permission when a Paykit subscription starts, unless it is already granted. */
+/** Asks for notification permission when a Paykit subscription or a Spark emergency exit starts, unless granted. */
@Composable
fun rememberSubscriptionNotificationsRequest(
settingsViewModel: SettingsViewModel = hiltViewModel(),
diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt
index 56c740e63f..831c8d8d8c 100644
--- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt
+++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt
@@ -52,7 +52,8 @@ class DeriveBalanceStateUseCase @Inject constructor(
val channelFundableBalance = getMaxChannelFundableAmount(lightningRepo.getChannelFundableBalance())
val afterPendingChannels = balanceDetails.totalLightningBalanceSats.safe() - pendingChannelsSats.safe()
val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe()
- val sparkSats = sparkRepo.state.value.balanceSats
+ val sparkSats = sparkRepo.state.value.spendingSats
+ val sparkExitSats = getSparkExitSats(activeTransfers)
val afterLingeringCoopClose = afterClosingChannels.safe() - lingeringCoopCloseSats.safe()
val totalLightningSats = afterLingeringCoopClose.safe() + sparkSats.safe()
@@ -66,7 +67,8 @@ class DeriveBalanceStateUseCase @Inject constructor(
sparkRepo.state.value.sendableSats,
),
maxSendOnchainSats = getMaxSendAmount(balanceDetails),
- balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(),
+ balanceInTransferToSavings = (toSavingsAmount.safe() - coopCloseSavingsSats.safe()).safe() +
+ sparkExitSats.safe(),
balanceInTransferToSpending = pendingChannelsSats.safe() +
sparkRepo.state.value.pendingDepositSats.safe(),
hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() },
@@ -107,7 +109,7 @@ class DeriveBalanceStateUseCase @Inject constructor(
balanceDetails: BalanceDetails,
): ULong {
var toSavingsAmount = 0uL
- val toSavings = transfers.filter { it.type.isToSavings() }
+ val toSavings = transfers.filter { it.type.isToSavings() && it.type != TransferType.SPARK_EXIT }
for (transfer in toSavings) {
val channelId = transferRepo.resolveChannelIdForTransfer(transfer, channels)
@@ -118,6 +120,11 @@ class DeriveBalanceStateUseCase @Inject constructor(
return toSavingsAmount
}
+ /** A Spark emergency exit has no channel: its amount is in transfer until the exit settles it. */
+ private fun getSparkExitSats(transfers: List): ULong = transfers
+ .filter { it.type == TransferType.SPARK_EXIT }
+ .fold(0uL) { sum, transfer -> sum.safe() + transfer.amountSats.toULong().safe() }
+
private suspend fun getMaxChannelFundableAmount(fundableBalance: ULong): ULong {
if (fundableBalance == 0uL) return 0u
diff --git a/app/src/main/java/to/bitkit/utils/BatterySettingsSnapshot.kt b/app/src/main/java/to/bitkit/utils/BatterySettingsSnapshot.kt
index 8ab222a8dd..aa9bc50d1c 100644
--- a/app/src/main/java/to/bitkit/utils/BatterySettingsSnapshot.kt
+++ b/app/src/main/java/to/bitkit/utils/BatterySettingsSnapshot.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import kotlinx.serialization.Serializable
+import to.bitkit.ext.activityManager
import to.bitkit.ext.powerManager
import to.bitkit.ext.usageStatsManager
@@ -17,6 +18,7 @@ data class BatterySettingsSnapshot(
val isLowPowerStandbyEnabled: Boolean?,
val isExemptFromLowPowerStandby: Boolean?,
val appStandbyBucket: String?,
+ val isBackgroundRestricted: Boolean? = null,
) {
val logSummary: String
get() = listOf(
@@ -27,6 +29,7 @@ data class BatterySettingsSnapshot(
"lowPowerStandbyEnabled='${isLowPowerStandbyEnabled.logValue()}'",
"exemptFromLowPowerStandby='${isExemptFromLowPowerStandby.logValue()}'",
"appStandbyBucket='${appStandbyBucket.logValue()}'",
+ "backgroundRestricted='${isBackgroundRestricted.logValue()}'",
).joinToString()
}
@@ -54,6 +57,7 @@ fun Context.batterySettingsSnapshot(): BatterySettingsSnapshot {
null
},
appStandbyBucket = runCatching { usageStatsManager.appStandbyBucket.toAppStandbyBucketName() }.getOrNull(),
+ isBackgroundRestricted = runCatching { activityManager.isBackgroundRestricted }.getOrNull(),
)
}
diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
index 6fabcf9943..0006a43f36 100644
--- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
@@ -220,6 +220,7 @@ import to.bitkit.repositories.PublicPaykitRepo
import to.bitkit.repositories.QuickPayPaymentFailedError
import to.bitkit.repositories.QuickPayRepo
import to.bitkit.repositories.SamRockRepo
+import to.bitkit.repositories.SparkExitRepo
import to.bitkit.repositories.TransferRepo
import to.bitkit.repositories.UsdtPreferencesRepo
import to.bitkit.repositories.UsdtRepo
@@ -331,6 +332,7 @@ class AppViewModel @Inject constructor(
private val formatMoneyValue: FormatMoneyValue,
private val widgetsRepo: WidgetsRepo,
private val timeSource: TimeSource,
+ private val sparkExitRepo: SparkExitRepo,
) : ViewModel() {
val healthState = healthRepo.healthState
@@ -1409,6 +1411,11 @@ class AppViewModel @Inject constructor(
private fun isAutomaticPaykitWorkPaused(): Boolean =
isPaymentRequestPollingStopped.value || privatePaykitRepo.isProfileDeletionInProgress.value
+ /** Drives a Spark emergency exit every minute while Bitkit is in the foreground. */
+ fun startSparkExitDriving() = sparkExitRepo.onAppForegrounded()
+
+ fun stopSparkExitDriving() = sparkExitRepo.onAppBackgrounded()
+
fun startPaykitPaymentRequestPolling() {
privatePaykitRepo.setContactPreparationActive(true)
isPaymentRequestPollingStopped.update { false }
diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
index b11f8cc82c..4b44013d1b 100644
--- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
+++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt
@@ -367,7 +367,7 @@ class TransferViewModel @Inject constructor(
private suspend fun withdrawSparkToSavings() {
sparkFeeJob?.join()
- val balance = sparkRepo.state.value.balanceSats
+ val balance = sparkRepo.state.value.spendingSats
if (balance == 0uL) return
val address = sparkWithdrawAddress()
if (address == null) {
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 39227d7601..8c0f093dba 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -228,6 +228,77 @@
انتظرإيداعات تمت المطالبة بهاإيداعات قيد التنفيذ
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.المزوّدSpark عبر Breezبلا حد
diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml
index dac1da7456..9f7ac30481 100644
--- a/app/src/main/res/values-b+es+419/strings.xml
+++ b/app/src/main/res/values-b+es+419/strings.xml
@@ -235,6 +235,77 @@
EsperarDepósitos reclamadosDepósitos en curso
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.ProveedorSpark mediante BreezSin límite
diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml
index 334505505c..9fd7a3a182 100644
--- a/app/src/main/res/values-ca/strings.xml
+++ b/app/src/main/res/values-ca/strings.xml
@@ -232,6 +232,77 @@
EsperaDipòsits reclamatsDipòsits en curs
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.ProveïdorSpark mitjançant BreezSense límit
diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml
index 91f4805c90..aa0d4a6999 100644
--- a/app/src/main/res/values-cs/strings.xml
+++ b/app/src/main/res/values-cs/strings.xml
@@ -235,6 +235,77 @@
PočkatPřipsané vkladyProbíhající vklady
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.PoskytovatelSpark přes BreezBez limitu
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 5cea87a889..cc1c538648 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -112,6 +112,77 @@
WartenEingelöste EinzahlungenLaufende Einzahlungen
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.AnbieterSpark über BreezKein Limit
diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml
index 13bc6c27e3..8ba813824e 100644
--- a/app/src/main/res/values-el/strings.xml
+++ b/app/src/main/res/values-el/strings.xml
@@ -228,6 +228,77 @@
ΑναμονήΕξαργυρωμένες καταθέσειςΚαταθέσεις σε εξέλιξη
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.ΠάροχοςSpark μέσω BreezΧωρίς όριο
diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml
index e50e8de4c2..c657437678 100644
--- a/app/src/main/res/values-es-rES/strings.xml
+++ b/app/src/main/res/values-es-rES/strings.xml
@@ -234,6 +234,77 @@
EsperarDepósitos reclamadosDepósitos en curso
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.ProveedorSpark mediante BreezSin límite
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index fa41c6b382..5711a16930 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -176,6 +176,77 @@
EsperarDepósitos reclamadosDepósitos en curso
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.ProveedorSpark mediante BreezSin límite
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index 205a538624..098cc2782d 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -111,6 +111,77 @@
AttendreDépôts réclamésDépôts en cours
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.FournisseurSpark via BreezSans limite
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index f46eaf89c8..968c37e4be 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -233,6 +233,77 @@
AttendiDepositi riscattatiDepositi in corso
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.FornitoreSpark tramite BreezNessun limite
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index fc7c27129e..41f2ed287b 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -232,6 +232,77 @@
WachtenGeclaimde stortingenLopende stortingen
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.AanbiederSpark via BreezGeen limiet
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index 61316f843c..0be5b1583f 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -113,6 +113,77 @@
CzekajOdebrane wpłatyWpłaty w toku
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.DostawcaSpark przez BreezBez limitu
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 3e7b7deb6b..ca5d45a471 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -236,6 +236,77 @@
EsperarDepósitos resgatadosDepósitos em andamento
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.ProvedorSpark via BreezSem limite
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index 81321c2947..ff5a3de4c9 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -108,6 +108,77 @@
EsperarDepósitos resgatadosDepósitos em curso
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.FornecedorSpark via BreezSem limite
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 3f8d38a426..e0ad2ee1eb 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -244,6 +244,77 @@
ЖдатьЗачисленные депозитыДепозиты в обработке
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.ПровайдерSpark через BreezБез ограничений
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2fb23120cf..2b35ac1cd3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -392,6 +392,77 @@
WaitClaimed depositsDeposits in progress
+ Amount to exit
+ Emergency exit
+ Cancel exit
+ You won\'t be able to use these funds for <accent>up to 4 weeks</accent>.
+ Start\n<accent>exit?</accent>
+ Your funds are in savings.
+ Exit\n<accent>complete</accent>
+ Some transactions were rejected: {error}
+ {count} amounts the Spark operators could not sign for stayed on Spark.
+ {amount} stays on Spark.
+ Nothing could be recovered.
+ Exit\n<accent>stopped</accent>
+ Network fees
+ Fund from another wallet
+ Send at least {amount} to this address. The exit starts once it confirms.
+ Your savings can\'t cover the fee funding.
+ Paid from savings, waiting for confirmation.
+ {received} of {required} received, send {missing} more.
+ Payment seen, waiting for confirmation.
+ Pay from savings
+ Payment seen.
+ Fee funding needed
+ The exit pays its fees from a separate on-chain payment of <accent>{amount}</accent>. What it doesn\'t spend comes back to savings at the end.
+ Fund the\n<accent>fees</accent>
+ Transaction ID: {txid}
+ Use this only if Spark stops working for you. It moves your spending balance to savings <accent>without the Spark operators</accent>.\n\nIt takes <accent>up to 4 weeks</accent>, 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.
+ Leave Spark\n<accent>on-chain</accent>
+ Stays on Spark
+ {amount} can\'t be exited at this fee rate and stays on Spark.
+ {amount} of fee funding is still at {address}.
+ {amount} of leftover fee funding is too small to send and stays at {address}.
+ Sending {amount} of leftover fee funding to savings.
+ {amount} of leftover fee funding was sent to savings.
+ Transaction ID: {txid}
+ {amount} was claimed on-chain by an older owner of these funds and can\'t be recovered.
+ Emergency Exit
+ The exit needs <accent>{amount}</accent> more to pay its fees. Fund it to continue.
+ More fees\n<accent>needed</accent>
+ Android\'s battery optimization can delay the exit while Bitkit is closed. Bitkit notifies you if it falls behind.
+ Battery saver is on, so Android may hold back the exit while Bitkit is closed.
+ 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.
+ Turn on notifications
+ Android restricts Bitkit in the background, so the exit only moves while Bitkit is open. Allow background use for Bitkit in Android settings.
+ Nothing can be exited right now.
+ Android\'s battery settings held back your emergency exit. Open Bitkit before {time} to keep it on track.
+ Your emergency exit could not continue in the background. Open Bitkit before {time} to keep it on track.
+ Your emergency exit stopped. Open Bitkit to see why.
+ Your emergency exit needs more fee funding. Open Bitkit to fund it.
+ Your emergency exit fell behind in the background. Open Bitkit to continue it.
+ {amount} of your exit can\'t be recovered. Open Bitkit to see why.
+ Your emergency exit can\'t continue without internet. Connect and open Bitkit before {time} to keep it on track.
+ Some of your emergency exit\'s transactions were refused. Open Bitkit before {time} to see why.
+ Android stops Bitkit from running in the background. Open Bitkit before {time} to keep your emergency exit on track.
+ Emergency exit
+ {amount} on Spark is not part of this exit and stays on Spark.
+ {count} small amounts stayed on Spark.
+ The exit runs in the background, also while Bitkit is closed. Bitkit notifies you if it needs you.
+ Exit in\n<accent>progress</accent>
+ Could not get an exit quote. Try again.
+ Getting a quote…
+ {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.
+ Waiting for Spark to finish moving your balance. This can take up to 5 minutes.
+ Exit\n<accent>quote</accent>
+ Ready in {duration}
+ You receive about
+ Fee funding confirms
+ Publishing exit transactions
+ Sending to savings
+ Waiting for the timelock
+ Swipe To Exit
+ The fees are higher than what you would receive.ProviderSpark via BreezNo limit
diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitAlertsTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitAlertsTest.kt
new file mode 100644
index 0000000000..3420bb9229
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SparkExitAlertsTest.kt
@@ -0,0 +1,94 @@
+package to.bitkit.repositories
+
+import org.junit.Test
+import to.bitkit.repositories.SparkExitRecord.Phase
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class SparkExitAlertsTest {
+ private val running = SparkExitFixtures.record(Phase.Running)
+ private val unlimited = SparkExitBackgroundLimits()
+
+ @Test
+ fun `names why a running exit fell behind, the most specific first`() {
+ val refused = running.copy(
+ broadcastLog = listOf(
+ SparkExitRecord.BroadcastAttempt(
+ generation = running.generation,
+ txid = "n1",
+ attempts = 3,
+ rejections = 3,
+ lastAttemptAt = 0L,
+ lastOutcome = "rejected: min relay fee not met",
+ isAccepted = false,
+ ),
+ ),
+ )
+ assertEquals(SparkExitAlert.Rejected, SparkExitAlerts.reason(refused, isOnline = false, unlimited))
+ assertEquals(SparkExitAlert.Offline, SparkExitAlerts.reason(running, isOnline = false, unlimited))
+ assertEquals(
+ SparkExitAlert.Restricted,
+ SparkExitAlerts.reason(running, isOnline = true, unlimited.copy(isRestricted = true, isOptimized = true)),
+ )
+ assertEquals(
+ SparkExitAlert.Battery,
+ SparkExitAlerts.reason(running, isOnline = true, unlimited.copy(isPowerSaveMode = true)),
+ )
+ // Battery optimization is Android's default, so alone it is no cause to name.
+ assertEquals(
+ SparkExitAlert.Behind,
+ SparkExitAlerts.reason(running, isOnline = true, unlimited.copy(isOptimized = true)),
+ )
+ assertEquals(SparkExitAlert.Behind, SparkExitAlerts.reason(running, isOnline = true, unlimited))
+ }
+
+ @Test
+ fun `asks for fee funding and stays quiet while nothing needs the user`() {
+ assertEquals(
+ SparkExitAlert.Funding,
+ SparkExitAlerts.reason(SparkExitFixtures.record(Phase.NeedsFunding(5_000uL)), isOnline = false, unlimited),
+ )
+ assertNull(SparkExitAlerts.reason(SparkExitFixtures.record(), isOnline = false, unlimited))
+ assertNull(SparkExitAlerts.reason(SparkExitFixtures.record(Phase.Done), isOnline = false, unlimited))
+ assertNull(SparkExitAlerts.reason(null, isOnline = false, unlimited))
+ }
+
+ @Test
+ fun `posts each lost leaf event, and a failure unless every leaf was lost`() {
+ val lost = running.copy(lostLeaves = listOf(SparkExitRecord.LostLeaf("leaf-1", 16_384uL)))
+ val claimed = lost.copy(phase = Phase.Failed(SparkExitDriver.LOST_REASON))
+ val stopped = running.copy(phase = Phase.Failed(SparkExitDriver.NO_LEAF_REASON))
+
+ assertEquals(listOf(SparkExitAlert.Lost), SparkExitAlerts.losses(lost, running))
+ assertEquals(listOf(SparkExitAlert.Lost), SparkExitAlerts.losses(claimed, running))
+ assertEquals(emptyList(), SparkExitAlerts.losses(claimed, lost))
+ assertEquals(listOf(SparkExitAlert.Failed), SparkExitAlerts.losses(stopped, running))
+ // Every leaf lost says it alone, whatever the reason; a leaf left behind as well also fails the exit.
+ val stoppedLost = stopped.copy(lostLeaves = lost.lostLeaves)
+ assertEquals(listOf(SparkExitAlert.Lost), SparkExitAlerts.losses(stoppedLost, running))
+ val leftBehind = SparkExitRecord.LeftBehindLeaf("leaf-2", 4_000uL, "Not recoverable at this fee rate")
+ assertEquals(
+ listOf(SparkExitAlert.Lost, SparkExitAlert.Failed),
+ SparkExitAlerts.losses(
+ stoppedLost.copy(leafIds = listOf("leaf-1", "leaf-2"), leftBehindLeaves = listOf(leftBehind)),
+ running,
+ ),
+ )
+ assertEquals(emptyList(), SparkExitAlerts.losses(stopped, stopped))
+ assertEquals(emptyList(), SparkExitAlerts.losses(SparkExitFixtures.record(Phase.Done), running))
+ }
+
+ @Test
+ fun `posts no second loss for a leaf an earlier exit already reported`() {
+ val lost = running.copy(lostLeaves = listOf(SparkExitRecord.LostLeaf("leaf-1", 65_536uL)))
+ val claimed = lost.copy(phase = Phase.Failed(SparkExitDriver.LOST_REASON))
+
+ assertEquals(emptyList(), SparkExitAlerts.losses(lost, running, reportedLost = setOf("leaf-1")))
+ assertEquals(emptyList(), SparkExitAlerts.losses(claimed, running, reportedLost = setOf("leaf-1")))
+ val another = lost.copy(lostLeaves = lost.lostLeaves + SparkExitRecord.LostLeaf("leaf-2", 4_000uL))
+ assertEquals(
+ listOf(SparkExitAlert.Lost),
+ SparkExitAlerts.losses(another, running, reportedLost = setOf("leaf-1")),
+ )
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitChainTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitChainTest.kt
new file mode 100644
index 0000000000..cc067ff19e
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SparkExitChainTest.kt
@@ -0,0 +1,226 @@
+package to.bitkit.repositories
+
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.MockRequestHandleScope
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.engine.mock.toByteArray
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.HttpRequestData
+import io.ktor.client.request.HttpResponseData
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.utils.io.errors.IOException
+import org.junit.Test
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+class SparkExitChainTest : BaseUnitTest() {
+ private val hostA = "https://a.example/api"
+ private val hostB = "https://b.example/api"
+
+ private class Route(val url: String, val status: Int, val body: String)
+
+ private val requests = mutableListOf()
+ private val bodies = mutableListOf()
+
+ /** Answers each request with the first route whose URL it ends with; an unknown URL fails like a dead host. */
+ private fun chain(vararg routes: Route, hosts: List = listOf(hostA)): EsploraSparkExitChain {
+ val pending = routes.toMutableList()
+ val engine = MockEngine { request -> answer(request, pending) }
+ // Like the app's shared client, which defaults every request to JSON.
+ val client = HttpClient(engine) { defaultRequest { contentType(ContentType.Application.Json) } }
+ return EsploraSparkExitChain(hosts, client)
+ }
+
+ private suspend fun MockRequestHandleScope.answer(
+ request: HttpRequestData,
+ pending: MutableList,
+ ): HttpResponseData {
+ requests += request
+ bodies += String(request.body.toByteArray())
+ val url = request.url.toString()
+ val route = pending.firstOrNull { url.endsWith(it.url) } ?: throw IOException("unreachable '$url'")
+ pending.remove(route)
+ return respond(route.body, HttpStatusCode.fromValue(route.status))
+ }
+
+ @Test
+ fun `sends a package as a raw json array of hex`() = test {
+ val sut = chain(Route("a.example/api/v1/txs/package", 200, """{"package_msg":"success","tx-results":{}}"""))
+
+ val outcome = sut.broadcast(SparkExitFixtures.tx("n1", cpfp = "03child"))
+
+ assertEquals(SparkExitBroadcastOutcome.Accepted, outcome)
+ assertEquals(HttpMethod.Post, requests.single().method)
+ assertEquals("""["02n1","03child"]""", bodies.single())
+ assertEquals(ContentType.Application.Json, requests.single().body.contentType?.withoutParameters())
+ }
+
+ @Test
+ fun `sends a step without cpfp as plain hex`() = test {
+ val sut = chain(Route("a.example/api/tx", 200, "s1"))
+
+ val outcome = sut.broadcast(SparkExitFixtures.tx("s1", kind = breez_sdk_spark.RecoveryTxKind.SWEEP))
+
+ assertEquals(SparkExitBroadcastOutcome.Accepted, outcome)
+ assertEquals("02s1", bodies.single())
+ assertEquals(ContentType.Text.Plain, requests.single().body.contentType?.withoutParameters())
+ assertEquals(null, requests.single().headers["Content-Type"])
+ }
+
+ @Test
+ fun `falls back to the unversioned package path then the next host`() = test {
+ val sut = chain(
+ Route("a.example/api/v1/txs/package", 404, "Not Found"),
+ Route("a.example/api/txs/package", 404, "Not Found"),
+ Route("b.example/api/v1/txs/package", 200, """{"package_msg":"success"}"""),
+ hosts = listOf(hostA, hostB),
+ )
+
+ val outcome = sut.broadcast(SparkExitFixtures.tx("n1", cpfp = "03c"))
+
+ assertEquals(SparkExitBroadcastOutcome.Accepted, outcome)
+ assertEquals(listOf("a.example", "a.example", "b.example"), requests.map { it.url.host })
+ }
+
+ @Test
+ fun `tries the next host after a rejection or an unavailable host`() = test {
+ val rejected = chain(
+ Route(
+ "a.example/api/tx",
+ 400,
+ """sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}"""
+ ),
+ Route("b.example/api/tx", 200, "s1"),
+ hosts = listOf(hostA, hostB),
+ )
+ assertEquals(SparkExitBroadcastOutcome.Accepted, rejected.broadcast(SparkExitFixtures.tx("s1")))
+
+ val limited = chain(
+ Route("a.example/api/tx", 429, "Too Many Requests"),
+ Route("b.example/api/tx", 200, "s1"),
+ hosts = listOf(hostA, hostB),
+ )
+ assertEquals(SparkExitBroadcastOutcome.Accepted, limited.broadcast(SparkExitFixtures.tx("s1")))
+ }
+
+ @Test
+ fun `a node refusal wins over an unavailable or unreachable host`() = test {
+ val sut = chain(
+ Route(
+ "a.example/api/tx",
+ 400,
+ """sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}"""
+ ),
+ Route("b.example/api/tx", 503, ""),
+ hosts = listOf(hostA, hostB, "https://c.example/api"),
+ )
+
+ assertIs(sut.broadcast(SparkExitFixtures.tx("s1")))
+ }
+
+ @Test
+ fun `no reachable host is retried later`() = test {
+ val outcome = chain(hosts = listOf(hostA)).broadcast(SparkExitFixtures.tx("s1"))
+
+ assertEquals(SparkExitBroadcastOutcome.RetryLater("no chain host reachable"), outcome)
+ }
+
+ @Test
+ fun `reads the tip, funding utxos and a transaction status`() = test {
+ val sut = chain(
+ Route("a.example/api/blocks/tip/height", 200, "3681536\n"),
+ Route(
+ "a.example/api/address/bcrt1qf/utxo",
+ 200,
+ """[{"txid":"t","vout":1,"value":5000,""" +
+ """"status":{"confirmed":true,"block_height":7,"block_hash":"h"}}]""",
+ ),
+ Route("a.example/api/tx/t/status", 200, """{"confirmed":false}"""),
+ )
+
+ assertEquals(3_681_536u, sut.tipHeight())
+ assertEquals(listOf(EsploraUtxo("t", 1u, 5_000uL, EsploraTxStatus(true, 7u))), sut.utxos("bcrt1qf"))
+ assertEquals(EsploraTxStatus(false, null), sut.txStatus("t"))
+ }
+
+ @Test
+ fun `classifies node answers`() {
+ val failedPackage = """{"package_msg":"transaction failed","tx-results":"""
+ val classifier = SparkExitBroadcastClassifier
+ assertEquals(
+ SparkExitBroadcastOutcome.Accepted,
+ classifier.single(400, """sendrawtransaction RPC error: {"code":-27}"""),
+ )
+ assertEquals(
+ SparkExitBroadcastOutcome.InputsMissing("""sendrawtransaction RPC error: {"code":-25}"""),
+ classifier.single(400, """sendrawtransaction RPC error: {"code":-25}"""),
+ )
+ assertEquals(SparkExitBroadcastOutcome.RetryLater("HTTP 503"), classifier.single(503, ""))
+ assertIs(
+ classifier.single(400, """{"code":-26,"message":"min relay fee not met"}""")
+ )
+
+ val missing = failedPackage + """{"w1":{"txid":"a","error":"bad-txns-inputs-missingorspent"},""" +
+ """"w2":{"txid":"b","error":"bad-txns-inputs-missingorspent"}},"replaced-transactions":[]}"""
+ assertIs(classifier.pkg(200, missing))
+ val known = failedPackage + """{"w1":{"txid":"a","error":"txn-already-in-mempool"},""" +
+ """"w2":{"txid":"b"}}}"""
+ assertEquals(SparkExitBroadcastOutcome.Accepted, classifier.pkg(200, known))
+ val nonFinal = failedPackage + """{"w1":{"txid":"a","error":"non-BIP68-final"}}}"""
+ assertIs(classifier.pkg(200, nonFinal))
+ // A child of an immature parent can fail with any error; the parent's timelock decides.
+ val nonFinalParent = failedPackage + """{"w1":{"txid":"a","error":"non-BIP68-final"},""" +
+ """"w2":{"txid":"b","error":"min relay fee not met"}}}"""
+ assertIs(classifier.pkg(200, nonFinalParent))
+ val escaped = """{"error":"submitpackage RPC error: {\"code\":-25}"}"""
+ assertEquals(SparkExitBroadcastOutcome.InputsMissing(escaped), classifier.pkg(400, escaped))
+ // Missing inputs on the CPFP child alone say nothing about the step.
+ val childMissing = failedPackage + """{"w1":{"txid":"parent"},""" +
+ """"w2":{"txid":"child","error":"bad-txns-inputs-missingorspent"}}}"""
+ assertIs(classifier.pkg(200, childMissing, parentTxid = "parent"))
+ val parentMissing = failedPackage + """{"w1":{"txid":"parent","error":"bad-txns-inputs-missingorspent"}}}"""
+ assertIs(classifier.pkg(200, parentMissing, parentTxid = "parent"))
+ // The step's own missing inputs decide over its child's error, in either order.
+ val parentFirst = failedPackage + """{"w1":{"txid":"parent","error":"bad-txns-inputs-missingorspent"},""" +
+ """"w2":{"txid":"child","error":"package-not-validated"}}}"""
+ assertIs(classifier.pkg(200, parentFirst, parentTxid = "parent"))
+ val childFirst = failedPackage + """{"w2":{"txid":"child","error":"package-not-validated"},""" +
+ """"w1":{"txid":"parent","error":"bad-txns-inputs-missingorspent"}}}"""
+ assertIs(classifier.pkg(200, childFirst, parentTxid = "parent"))
+ val lowFee = failedPackage + """{"w1":{"txid":"a","error":"min relay fee not met"}}}"""
+ assertIs(classifier.pkg(200, lowFee))
+ }
+
+ @Test
+ fun `only a step that paid no fee at all is unpaid`() {
+ val classifier = SparkExitBroadcastClassifier
+ // The qa-03 shared-ancestor race: the node pays nothing, and its child's chained change is gone.
+ val lostChain = """{"package_msg":"transaction failed","tx-results":""" +
+ """{"w1":{"txid":"parent","error":"min relay fee not met, 0 < 17"},""" +
+ """"w2":{"txid":"child","error":"bad-txns-inputs-missingorspent"}}}"""
+ assertTrue(classifier.isUnpaid(classifier.pkg(200, lostChain, parentTxid = "parent")))
+ assertFalse(classifier.isUnpaid(SparkExitBroadcastOutcome.Rejected("min relay fee not met, 100 < 110")))
+ assertFalse(classifier.isUnpaid(SparkExitBroadcastOutcome.RetryLater("min relay fee not met, 0 < 17")))
+ }
+
+ @Test
+ fun `a transaction is unknown only when every host says so`() = test {
+ val unknown = Route("a.example/api/tx/t/hex", 404, "Transaction not found")
+ assertFalse(chain(unknown).isTxKnown("t"))
+
+ val known = Route("b.example/api/tx/t/hex", 200, "02aa")
+ assertTrue(chain(unknown, known, hosts = listOf(hostA, hostB)).isTxKnown("t"))
+
+ val down = Route("b.example/api/tx/t/hex", 503, "")
+ assertFailsWith { chain(unknown, down, hosts = listOf(hostA, hostB)).isTxKnown("t") }
+ assertFailsWith { chain(hosts = listOf(hostA)).isTxKnown("t") }
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt
new file mode 100644
index 0000000000..65b0285ef1
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt
@@ -0,0 +1,1058 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.CheckRecoverFundsResponse
+import breez_sdk_spark.CooperativeRecoveryError
+import breez_sdk_spark.CooperativeRecoveryFailure
+import breez_sdk_spark.CpfpInput
+import breez_sdk_spark.ExitLeafSelection
+import breez_sdk_spark.ExitTransactionStatus
+import breez_sdk_spark.RecoverFundsResponse
+import breez_sdk_spark.RecoveryRedoReason
+import breez_sdk_spark.RecoveryTxKind
+import breez_sdk_spark.RecoveryVerdict
+import breez_sdk_spark.SdkException
+import breez_sdk_spark.SkippedLeaf
+import breez_sdk_spark.SkippedLeafReason
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import to.bitkit.repositories.SparkExitRecord.Phase
+import to.bitkit.test.BaseUnitTest
+import to.bitkit.ui.settings.lightning.SparkExitQuoteBlock
+import to.bitkit.ui.settings.lightning.SparkExitUiState
+import to.bitkit.utils.SignedTransactionId
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+class SparkExitDriverTest : BaseUnitTest() {
+ private companion object {
+ const val SWEEP_DESTINATION = "bcrt1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqc8gma6"
+ const val SWEEP_FUNDING = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"
+ val UNPAID = SparkExitBroadcastOutcome.Rejected("min relay fee not met, 0 < 17")
+ }
+
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private val chain = FakeSparkExitChain()
+ private val sdk = FakeSparkExitRecovering()
+ private val saved = mutableListOf()
+ private var now = 1_800_000_000_000L
+
+ private fun driver(withSdk: Boolean = true, save: (SparkExitRecord) -> Unit = { saved += it }) = SparkExitDriver(
+ chain = chain,
+ recovering = sdk.takeIf { withSdk },
+ makeSigner = { StubCpfpSigner() },
+ save = save,
+ nowMillis = { now },
+ )
+
+ private fun running(recovery: RecoverFundsResponse, builtAt: Long = now) =
+ SparkExitFixtures.record(Phase.Running).adopt(recovery, builtAt)
+
+ private fun check(recovery: RecoverFundsResponse, verdict: RecoveryVerdict) =
+ CheckRecoverFundsResponse(recovery, verdict)
+
+ private val redo = RecoveryVerdict.Redo(RecoveryRedoReason.ON_CHAIN_STATE_DIVERGED)
+
+ @Test
+ fun `waits for a confirmed funding utxo`() = test {
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] =
+ listOf(SparkExitFixtures.utxo("f0", value = 20_000uL, confirmed = false))
+
+ val record = driver().tick(SparkExitFixtures.record())
+
+ assertEquals(Phase.AwaitingFunding(20_000uL), record.phase)
+ assertTrue(sdk.prepareRequests.isEmpty())
+ }
+
+ @Test
+ fun `builds once funded and saves before broadcasting`() = test {
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] = listOf(SparkExitFixtures.utxo("f0", 1u, 25_000uL))
+ sdk.builds += SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("n1", cpfp = "03c1"),
+ SparkExitFixtures.tx(
+ "n2",
+ dependsOn = listOf("n1"),
+ status = ExitTransactionStatus.WaitingForDependencies,
+ ),
+ ),
+ )
+ var savesAtFirstBroadcast: Int? = null
+ chain.onBroadcast = { savesAtFirstBroadcast = savesAtFirstBroadcast ?: saved.size }
+
+ val record = driver().tick(SparkExitFixtures.record())
+
+ assertEquals(Phase.Running, record.phase)
+ assertEquals(1, record.generation)
+ assertEquals(1, savesAtFirstBroadcast)
+ assertEquals(record.recovery, saved.first().recovery)
+ assertEquals(
+ listOf(CpfpInput.P2wpkh("f0", 1u, 25_000uL, "02ab")),
+ sdk.recoverRequests.first().fundingInputs
+ )
+ assertEquals(listOf("leaf-1"), (sdk.prepareRequests.first().selection as ExitLeafSelection.Specific).leafIds)
+ assertEquals(listOf("n1"), chain.broadcasts.map { it.txid })
+ assertEquals(listOf("n1"), record.broadcastLog.map { it.txid })
+ assertEquals(record, saved.last())
+ }
+
+ @Test
+ fun `valid broadcasts ready steps once until they are due again`() = test {
+ var record = running(SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1"))))
+ val checked = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("n1", status = ExitTransactionStatus.Confirmed(10u)),
+ SparkExitFixtures.tx("n2", cpfp = "03c2", dependsOn = listOf("n1")),
+ SparkExitFixtures.tx(
+ "r1",
+ kind = RecoveryTxKind.REFUND,
+ dependsOn = listOf("n2"),
+ status = ExitTransactionStatus.WaitingForTimelock(3_000u),
+ ),
+ ),
+ )
+ repeat(3) { sdk.checks += check(checked, RecoveryVerdict.Valid) }
+
+ record = driver().tick(record)
+ record = driver().tick(record)
+ now += SparkExitDriver.REBROADCAST_INTERVAL.inWholeMilliseconds
+ record = driver().tick(record)
+
+ assertEquals(listOf("n2", "n2"), chain.broadcasts.map { it.txid })
+ assertEquals(2, record.broadcastLog.single().attempts)
+ assertEquals(3_000u, record.claimableAtHeight)
+ assertEquals(checked, record.recovery)
+ }
+
+ @Test
+ fun `marks steps the chain host shows in a block before the sdk check`() = test {
+ val built = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("fan", kind = RecoveryTxKind.FAN_OUT),
+ SparkExitFixtures.tx(
+ "n1",
+ cpfp = "03c",
+ dependsOn = listOf("fan"),
+ status = ExitTransactionStatus.WaitingForDependencies,
+ ),
+ SparkExitFixtures.tx(
+ "n2",
+ cpfp = "03d",
+ dependsOn = listOf("n1"),
+ status = ExitTransactionStatus.WaitingForDependencies,
+ ),
+ ),
+ )
+ chain.confirmedTxids = setOf("fan", "n1")
+ sdk.checks += check(built, RecoveryVerdict.Valid)
+
+ driver().tick(running(built))
+
+ val sent = sdk.checkedRecoveries.single().transactions.map { it.status }
+ assertEquals(
+ listOf(
+ ExitTransactionStatus.Confirmed(900u),
+ ExitTransactionStatus.Confirmed(900u),
+ ExitTransactionStatus.WaitingForDependencies,
+ ),
+ sent,
+ )
+ }
+
+ @Test
+ fun `retries a rejected step and surfaces it after repeated rejections`() = test {
+ val recovery = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1", cpfp = "03c")))
+ var record = running(recovery)
+ chain.outcome = SparkExitBroadcastOutcome.Rejected("insufficient fee, rejecting replacement")
+ repeat(3) { sdk.checks += check(recovery, RecoveryVerdict.Valid) }
+
+ record = driver().tick(record)
+ assertNull(record.surfacedRejection)
+ repeat(2) { record = driver().tick(record) }
+
+ assertEquals(3, chain.broadcasts.size)
+ assertEquals("rejected: insufficient fee, rejecting replacement", record.surfacedRejection)
+ }
+
+ @Test
+ fun `done finishes and reads leftover funding`() = test {
+ var record = running(SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("s", kind = RecoveryTxKind.SWEEP))))
+ val done = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("s", kind = RecoveryTxKind.SWEEP, status = ExitTransactionStatus.Confirmed(50u))
+ ),
+ )
+ sdk.checks += check(done, RecoveryVerdict.Done)
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] =
+ listOf(SparkExitFixtures.utxo("change", value = 700uL))
+
+ record = driver().tick(record)
+
+ assertEquals(Phase.Done, record.phase)
+ assertFalse(record.isActive)
+ assertEquals(listOf(SparkExitRecord.Leftover("change", 0u, 700uL)), record.leftover)
+ assertTrue(chain.broadcasts.isEmpty())
+ assertEquals(record, saved.last())
+ }
+
+ @Test
+ fun `redo rebuilds as a new generation and never sends the old one`() = test {
+ val old = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("old")))
+ var record = running(old, builtAt = now - SparkExitDriver.REBUILD_INTERVAL.inWholeMilliseconds).copy(
+ funding = listOf(
+ SparkExitRecord.Funding(0u, SparkExitFixtures.FUNDING_ADDRESS, "02ab", "f0", 1u, 20_000uL)
+ ),
+ broadcastLog = listOf(SparkExitRecord.BroadcastAttempt(1, "old", 1, 0, now, "accepted", true)),
+ )
+ sdk.checks += check(old, redo)
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("new", cpfp = "03n")))
+
+ record = driver().tick(record)
+
+ assertEquals(2, record.generation)
+ assertFalse(record.isRebuilding)
+ assertEquals(listOf("new"), record.recovery?.transactions?.map { it.txid })
+ assertEquals(listOf("new"), record.broadcastLog.map { it.txid })
+ assertEquals(listOf("new"), chain.broadcasts.map { it.txid })
+ assertEquals(
+ listOf(CpfpInput.P2wpkh("f0", 1u, 20_000uL, "02ab")),
+ sdk.recoverRequests.first().fundingInputs
+ )
+ assertTrue(saved.first().isRebuilding)
+ }
+
+ @Test
+ fun `redo is saved before rebuilding so the old generation is never sent without the sdk`() = test {
+ val old = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("old")))
+ var record = running(old)
+ sdk.checks += check(old, redo)
+
+ // Built a minute ago: the rebuild waits, but the diverged state is already saved.
+ now += 60.seconds.inWholeMilliseconds
+ record = driver().tick(record)
+ assertTrue(record.isRebuilding)
+ assertTrue(saved.first().isRebuilding)
+ assertTrue(sdk.recoverRequests.isEmpty())
+
+ record = driver(withSdk = false).tick(record)
+ assertTrue(chain.broadcasts.isEmpty())
+
+ now += SparkExitDriver.REBUILD_INTERVAL.inWholeMilliseconds
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("new")))
+ record = driver().tick(record)
+ assertFalse(record.isRebuilding)
+ assertEquals(2, record.generation)
+ assertEquals(listOf("new"), chain.broadcasts.map { it.txid })
+ }
+
+ @Test
+ fun `rebuild that needs more fees asks for funding`() = test {
+ val old = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("old")))
+ var record = running(old, builtAt = 0L)
+ sdk.checks += check(SparkExitFixtures.recovery(emptyList()), redo)
+ sdk.recoverError = SdkException.InsufficientCpfpFunds(3_000uL, "")
+
+ record = driver().tick(record)
+
+ assertEquals(Phase.NeedsFunding(3_000uL), record.phase)
+ assertTrue(record.isActive)
+ assertEquals(3_000uL, record.fundingShortfallSats)
+ }
+
+ @Test
+ fun `asks only for what the sdk's total lacks and adopts small top ups together`() = test {
+ // A headless regtest run: the quote asked 8,541, five UTXOs held 9,035, and the SDK wanted 9,085 in total.
+ val funded = listOf(1_807uL, 1_807uL, 1_807uL, 1_807uL, 1_807uL).mapIndexed { i, value ->
+ SparkExitRecord.Funding(0u, SparkExitFixtures.FUNDING_ADDRESS, "02ab", "f$i", 0u, value)
+ }
+ var record = running(SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("old"))), builtAt = 0L)
+ .copy(funding = funded)
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] = funded.map {
+ SparkExitFixtures.utxo(requireNotNull(it.txid), value = requireNotNull(it.valueSats))
+ }
+ sdk.checks += check(SparkExitFixtures.recovery(emptyList()), redo)
+ sdk.recoverError = SdkException.InsufficientCpfpFunds(9_085uL, "")
+
+ record = driver().tick(record)
+
+ assertEquals(Phase.NeedsFunding(9_085uL), record.phase)
+ assertEquals(50uL, record.fundingShortfallSats)
+
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] = funded.map {
+ SparkExitFixtures.utxo(requireNotNull(it.txid), value = requireNotNull(it.valueSats))
+ } + listOf(SparkExitFixtures.utxo("t1", value = 30uL), SparkExitFixtures.utxo("t2", value = 20uL))
+ sdk.recoverError = null
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n2")))
+ record = driver().tick(record)
+
+ assertEquals(Phase.Running, record.phase)
+ assertEquals(7, sdk.recoverRequests.last().fundingInputs.size)
+ }
+
+ @Test
+ fun `a redo whose spent input lives on as change asks only for what that change lacks`() = test {
+ val built = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1", cpfp = SparkExitFixtures.PARENT_HEX)))
+ var record = running(built, builtAt = 0L).copy(
+ funding = listOf(
+ SparkExitRecord.Funding(0u, SparkExitFixtures.FUNDING_ADDRESS, "02ab", "f0", 1u, 20_000uL),
+ ),
+ )
+ // The input went into a CPFP child, whose 13,000 of change sits at the funding address.
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] =
+ listOf(SparkExitFixtures.utxo(SparkExitFixtures.PARENT_TXID, value = 13_000uL))
+ sdk.checks += check(built, redo)
+ sdk.recoverError = SdkException.InsufficientCpfpFunds(15_000uL, "")
+
+ record = driver().tick(record)
+
+ assertEquals(2_000uL, record.fundingShortfallSats)
+ }
+
+ @Test
+ fun `the tick that ends the exit sweeps its leftover to savings until a node accepts it`() = test {
+ val funding = "f0".repeat(32)
+ chain.utxosByAddress[SWEEP_FUNDING] = listOf(SparkExitFixtures.utxo(funding, 1u, 25_000uL))
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves = emptyList(), skipped = emptyList())
+ chain.outcome = SparkExitBroadcastOutcome.RetryLater("unreachable")
+ val start = SparkExitFixtures.record().copy(
+ destination = SWEEP_DESTINATION,
+ funding = listOf(SparkExitRecord.Funding(0u, SWEEP_FUNDING, "02ab")),
+ )
+
+ var record = driver().tick(start)
+
+ assertIs(record.phase)
+ assertEquals(listOf(SparkExitRecord.Leftover(funding, 1u, 25_000uL)), record.leftover)
+ val sweep = assertNotNull(record.leftoverSweep)
+ assertEquals(1, chain.rawBroadcasts.size)
+ assertTrue(record.isLeftoverSweepPending)
+ assertEquals(sweep, saved.first { it.leftoverSweep != null }.leftoverSweep)
+
+ chain.outcome = SparkExitBroadcastOutcome.Accepted
+ record = driver().tick(record)
+
+ assertEquals(listOf(sweep.txHex, sweep.txHex), chain.rawBroadcasts)
+ assertTrue(assertNotNull(record.leftoverSweep).isAccepted)
+ // Sent again until it confirms, and the result stays reachable until then.
+ assertTrue(record.needsDriving)
+ assertTrue(record.showsResult)
+ record = driver().tick(record)
+ assertEquals(3, chain.rawBroadcasts.size)
+
+ chain.confirmedTxids = setOf(requireNotNull(sweep.txid))
+ record = driver().tick(record)
+
+ assertTrue(assertNotNull(record.leftoverSweep).isConfirmed)
+ assertFalse(record.needsDriving)
+ // A failed exit stays reachable until its result was seen.
+ assertTrue(record.showsResult)
+ assertFalse(record.copy(isResultSeen = true).showsResult)
+ record = driver().tick(record)
+ assertEquals(3, chain.rawBroadcasts.size)
+ }
+
+ @Test
+ fun `a leftover under dust is not swept`() = test {
+ chain.utxosByAddress[SWEEP_FUNDING] = listOf(SparkExitFixtures.utxo("f0", 1u, 400uL))
+ val done = SparkExitFixtures.record(Phase.Done).copy(
+ destination = SWEEP_DESTINATION,
+ funding = listOf(SparkExitRecord.Funding(0u, SWEEP_FUNDING, "02ab")),
+ leftover = listOf(SparkExitRecord.Leftover("f0", 1u, 400uL)),
+ )
+
+ val record = driver().tick(done)
+
+ assertEquals(SparkExitRecord.LeftoverSweep(null, null, 400uL), record.leftoverSweep)
+ assertTrue(chain.rawBroadcasts.isEmpty())
+ assertFalse(record.needsDriving)
+ }
+
+ @Test
+ fun `top up ignores cpfp change chained to the funding address`() = test {
+ var record = SparkExitFixtures.record(Phase.NeedsFunding(23_000uL)).copy(
+ funding = listOf(
+ SparkExitRecord.Funding(0u, SparkExitFixtures.FUNDING_ADDRESS, "02ab", "f0", 1u, 20_000uL)
+ ),
+ recovery = SparkExitFixtures.recovery(
+ listOf(SparkExitFixtures.tx("n1", cpfp = SparkExitFixtures.PARENT_HEX))
+ ),
+ )
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] = listOf(
+ SparkExitFixtures.utxo("f0", 1u, 20_000uL),
+ SparkExitFixtures.utxo(SparkExitFixtures.PARENT_TXID, value = 9_000uL),
+ )
+
+ record = driver().tick(record)
+ assertEquals(Phase.NeedsFunding(23_000uL), record.phase)
+ assertEquals(3_000uL, record.fundingShortfallSats)
+ assertTrue(sdk.prepareRequests.isEmpty())
+
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] =
+ chain.utxosByAddress.getValue(SparkExitFixtures.FUNDING_ADDRESS) +
+ SparkExitFixtures.utxo("topup", value = 3_000uL)
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n2")))
+ record = driver().tick(record)
+
+ assertEquals(Phase.Running, record.phase)
+ assertEquals(2, sdk.recoverRequests.first().fundingInputs.size)
+ }
+
+ @Test
+ fun `cooperative leaves the operators could not sign are tried again`() = test {
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding = null)
+ sdk.builds += SparkExitFixtures.recovery(
+ emptyList(),
+ failed = listOf(
+ CooperativeRecoveryFailure("leaf-1", "o", 0u, CooperativeRecoveryError.OperatorsUnavailable("down")),
+ ),
+ )
+
+ val record = driver().tick(SparkExitFixtures.record(Phase.AwaitingFunding(0uL)))
+
+ assertEquals(Phase.AwaitingFunding(0uL), record.phase)
+ assertEquals(1, record.failedLeafCount)
+ assertNull(record.recovery)
+ }
+
+ @Test
+ fun `rebuild with nothing left ends the exit`() = test {
+ val record = running(SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("old"))), builtAt = 0L)
+ .copy(isRebuilding = true)
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves = emptyList())
+
+ assertEquals(Phase.Done, driver().tick(record).phase)
+ }
+
+ @Test
+ fun `rebuild waits while a leaf is unverified`() = test {
+ val record = running(SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("old"))), builtAt = 0L)
+ .copy(isRebuilding = true)
+ sdk.preparedResponse = SparkExitFixtures.prepared(
+ leaves = emptyList(),
+ skipped = listOf(SkippedLeaf("leaf-1", 100_000uL, SkippedLeafReason.Unverified)),
+ )
+
+ val ticked = driver().tick(record)
+
+ assertEquals(Phase.Running, ticked.phase)
+ assertTrue(ticked.isRebuilding)
+ }
+
+ @Test
+ fun `resumes after a restart mid-exit without repeating broadcasts`() = test {
+ val store = SparkExitStore(File(tempFolder.root, "spark-exit.json"))
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] = listOf(SparkExitFixtures.utxo("f0", value = 20_000uL))
+ val built = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1", cpfp = "03c")))
+ sdk.builds += built
+ driver(save = store::save).tick(SparkExitFixtures.record())
+
+ // A new process: only the file is left, and a fresh driver picks up from it.
+ val reloaded = requireNotNull(store.load())
+ sdk.checks += check(built, RecoveryVerdict.Valid)
+ driver(save = store::save).tick(reloaded)
+
+ assertEquals(Phase.Running, reloaded.phase)
+ assertEquals(1, reloaded.generation)
+ assertEquals(listOf("n1"), chain.broadcasts.map { it.txid })
+ assertEquals(1, sdk.recoverRequests.size)
+ assertEquals(1, requireNotNull(store.load()).broadcastLog.single().attempts)
+ }
+
+ @Test
+ fun `without the sdk broadcasts steps whose parents confirmed and timelocks matured`() = test {
+ var record = running(
+ SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("n1"),
+ SparkExitFixtures.tx(
+ "n2",
+ cpfp = "03c",
+ dependsOn = listOf("n1"),
+ status = ExitTransactionStatus.WaitingForDependencies,
+ ),
+ SparkExitFixtures.tx(
+ "r1",
+ kind = RecoveryTxKind.REFUND,
+ dependsOn = listOf("n2"),
+ status = ExitTransactionStatus.WaitingForTimelock(990u),
+ ),
+ SparkExitFixtures.tx(
+ "r2",
+ kind = RecoveryTxKind.REFUND,
+ dependsOn = listOf("n1"),
+ status = ExitTransactionStatus.WaitingForTimelock(1_500u),
+ ),
+ ),
+ ),
+ )
+ chain.tip = 1_000u
+ chain.confirmedTxids = setOf("n1")
+
+ record = driver(withSdk = false).tick(record)
+
+ assertEquals(listOf("n2"), chain.broadcasts.map { it.txid })
+ assertEquals(ExitTransactionStatus.Confirmed(900u), record.recovery?.transactions?.first()?.status)
+ assertEquals(Phase.Running, record.phase)
+ }
+
+ @Test
+ fun `does nothing once finished`() = test {
+ val record = driver().tick(SparkExitFixtures.record(Phase.Done))
+
+ assertIs(record.phase)
+ assertTrue(saved.isEmpty())
+ }
+
+ @Test
+ fun `a run killed after a broadcast does not send it again on restart`() = test {
+ val store = SparkExitStore(File(tempFolder.root, "spark-exit.json"))
+ store.save(running(SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1"), SparkExitFixtures.tx("n2")))))
+ // The process dies while it sends the second transaction, after the first went out.
+ chain.onBroadcast = { if (chain.broadcasts.size == 1) error("killed") }
+
+ runCatching { driver(withSdk = false, save = store::save).tick(requireNotNull(store.load())) }
+ chain.onBroadcast = {}
+ val record = driver(withSdk = false, save = store::save).tick(requireNotNull(store.load()))
+
+ assertEquals(listOf("n1", "n2"), chain.broadcasts.map { it.txid })
+ assertEquals(listOf("n1", "n2"), record.broadcastLog.map { it.txid })
+ assertEquals(record, store.load())
+ }
+
+ @Test
+ fun `done after a redo that needed no funding sweeps the earlier generation's change in one transaction`() = test {
+ val gen1 = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1", cpfp = "03c1")))
+ var record = running(gen1).copy(
+ destination = SWEEP_DESTINATION,
+ funding = listOf(SparkExitRecord.Funding(0u, SWEEP_FUNDING, "02ab", "f0".repeat(32), 0u, 20_000uL)),
+ )
+ sdk.checks += check(gen1, redo)
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding = null)
+ val gen2 = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n2")))
+ sdk.builds += gen2
+ now += SparkExitDriver.REBUILD_INTERVAL.inWholeMilliseconds
+
+ record = driver().tick(record)
+
+ assertEquals(2, record.generation)
+ assertTrue(sdk.recoverRequests.single().fundingInputs.isEmpty())
+
+ val change = (1..5).map { SparkExitFixtures.utxo("%064x".format(it), 1u, 700uL + it.toULong()) }
+ chain.utxosByAddress[SWEEP_FUNDING] = change
+ sdk.checks += check(gen2, RecoveryVerdict.Done)
+ record = driver().tick(record)
+
+ assertEquals(Phase.Done, record.phase)
+ assertEquals(5, record.leftover.size)
+ val sent = chain.rawBroadcasts.single()
+ assertEquals(change.map { it.txid }, SparkExitTxInputs.decode(sent)?.map { it.prevTxid })
+ assertTrue(assertNotNull(record.leftoverSweep).isAccepted)
+ assertTrue(record.showsResult)
+ }
+
+ @Test
+ fun `an exit that ended while its funding address could not be read ends again once it can`() = test {
+ val recovery = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1")))
+ var record = running(recovery)
+ chain.utxosError = IllegalStateException("unreachable")
+ sdk.checks += check(recovery, RecoveryVerdict.Done)
+
+ record = driver().tick(record)
+
+ assertEquals(Phase.Running, record.phase)
+ assertTrue(record.needsDriving)
+
+ chain.utxosError = null
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] = listOf(SparkExitFixtures.utxo("c1", value = 900uL))
+ sdk.checks += check(recovery, RecoveryVerdict.Done)
+ record = driver().tick(record)
+
+ assertEquals(Phase.Done, record.phase)
+ assertEquals(listOf(SparkExitRecord.Leftover("c1", 0u, 900uL)), record.leftover)
+ }
+
+ @Test
+ fun `adopted funding keeps the exit's own funding key index`() = test {
+ chain.utxosByAddress[SparkExitFixtures.FUNDING_ADDRESS] = listOf(SparkExitFixtures.utxo("f0", 1u, 25_000uL))
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1")))
+ val start = SparkExitFixtures.record().let { it.copy(funding = it.funding.map { f -> f.copy(index = 3u) }) }
+
+ val record = driver().tick(start)
+
+ assertEquals(listOf(3u), record.funding.map { it.index })
+ assertEquals(3u, record.fundingIndex)
+ }
+
+ @Test
+ fun `without the sdk a refund waits for its csv to mature from its parent's confirmation`() = test {
+ val node = "aa".repeat(32)
+ val refund = SparkExitFixtures.tx(
+ "r1",
+ kind = RecoveryTxKind.REFUND,
+ dependsOn = listOf(node),
+ status = ExitTransactionStatus.WaitingForDependencies,
+ csv = 100u,
+ ).copy(txHex = SparkExitFixtures.csvTxHex(node, csv = 100))
+ var record = running(SparkExitFixtures.recovery(listOf(refund)))
+ chain.confirmedTxids = setOf(node)
+ chain.heights[node] = 950u
+ chain.tip = 1_000u
+
+ record = driver(withSdk = false).tick(record)
+
+ assertTrue(chain.broadcasts.isEmpty())
+ val waiting = record.recovery?.transactions?.single()?.status
+ assertEquals(ExitTransactionStatus.WaitingForTimelock(1_050u), waiting)
+
+ // Nodes take it once it would be valid in the next block.
+ chain.tip = 1_049u
+ record = driver(withSdk = false).tick(record)
+
+ assertEquals(listOf("r1"), chain.broadcasts.map { it.txid })
+ assertEquals(0, record.broadcastLog.single().rejections)
+ }
+
+ @Test
+ fun `without the sdk a step whose input someone else spent is not sent again`() = test {
+ val recovery = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("n1"),
+ SparkExitFixtures.tx(
+ "r1",
+ kind = RecoveryTxKind.REFUND,
+ dependsOn = listOf("n1"),
+ status = ExitTransactionStatus.WaitingForDependencies,
+ ),
+ ),
+ )
+ var record = running(recovery)
+ chain.confirmedTxids = setOf("n1")
+ chain.outcome = SparkExitBroadcastOutcome.InputsMissing("bad-txns-inputs-missingorspent")
+
+ record = driver(withSdk = false).tick(record)
+ now += SparkExitDriver.REBROADCAST_INTERVAL.inWholeMilliseconds
+ record = driver(withSdk = false).tick(record)
+
+ assertEquals(listOf("r1"), chain.broadcasts.map { it.txid })
+ val attempt = record.broadcastLog.single()
+ assertTrue(attempt.isInputSpent)
+ assertEquals(0, attempt.rejections)
+ assertNull(record.surfacedRejection)
+ }
+
+ /** The qa-03 run: an older state's refund took the leaf, and every Redo rebuilt the same spent refund. */
+ private fun lostLeafRecovery(leafId: String = "leaf-1") = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("node", status = ExitTransactionStatus.Confirmed(11_522u)),
+ SparkExitFixtures.tx("refund", kind = RecoveryTxKind.REFUND, cpfp = "03c", dependsOn = listOf("node"))
+ .copy(nodeId = leafId),
+ SparkExitFixtures.tx(
+ "sweep",
+ kind = RecoveryTxKind.SWEEP,
+ dependsOn = listOf("refund"),
+ status = ExitTransactionStatus.WaitingForDependencies,
+ ),
+ ),
+ )
+
+ @Test
+ fun `a refund an older state already spent ends the exit after two rebuilds reproduce it`() = test {
+ val recovery = lostLeafRecovery()
+ var record = running(recovery)
+ chain.outcome = SparkExitBroadcastOutcome.InputsMissing("""sendrawtransaction RPC error: {"code":-25}""")
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding = null)
+ sdk.checks += check(recovery, RecoveryVerdict.Valid)
+ repeat(2) {
+ sdk.checks += check(recovery, redo)
+ sdk.builds += recovery
+ }
+
+ // Sent, refused as spent; then each Redo rebuilds the same refund.
+ record = driver().tick(record)
+ assertTrue(record.broadcastLog.single().isInputSpent)
+ now += SparkExitDriver.REBUILD_INTERVAL.inWholeMilliseconds
+ record = driver().tick(record)
+ assertEquals(2, record.generation)
+ assertEquals(mapOf("refund" to 1), record.spentStepStrikes)
+ now += SparkExitDriver.REBUILD_INTERVAL.inWholeMilliseconds
+ record = driver().tick(record)
+
+ assertEquals(2, record.generation)
+ assertEquals(Phase.Failed(SparkExitDriver.LOST_REASON), record.phase)
+ assertEquals(listOf(SparkExitRecord.LostLeaf("leaf-1", 100_000uL)), record.lostLeaves)
+ assertEquals(100_000uL, record.lostSats)
+ assertFalse(record.isActive)
+ }
+
+ @Test
+ fun `a single leaf lost while the funding address cannot be read ends lost through the no-leaf path`() = test {
+ val recovery = lostLeafRecovery()
+ var record = running(recovery)
+ chain.outcome = SparkExitBroadcastOutcome.InputsMissing("""sendrawtransaction RPC error: {"code":-25}""")
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding = null)
+ sdk.checks += check(recovery, RecoveryVerdict.Valid)
+ repeat(2) {
+ sdk.checks += check(recovery, redo)
+ sdk.builds += recovery
+ }
+ record = driver().tick(record)
+ now += SparkExitDriver.REBUILD_INTERVAL.inWholeMilliseconds
+ record = driver().tick(record)
+ now += SparkExitDriver.REBUILD_INTERVAL.inWholeMilliseconds
+ chain.utxosError = IllegalStateException("unreachable")
+ val lost = driver().tick(record)
+ assertEquals(Phase.Running, lost.phase)
+ assertEquals(listOf(SparkExitAlert.Lost), SparkExitAlerts.losses(lost, record))
+
+ chain.utxosError = null
+ val ended = driver().tick(lost)
+
+ assertEquals(Phase.Failed(SparkExitDriver.LOST_REASON), ended.phase)
+ assertEquals(emptyList(), SparkExitAlerts.losses(ended, lost))
+ val noLeaf = ended.copy(phase = Phase.Failed(SparkExitDriver.NO_LEAF_REASON))
+ assertEquals(emptyList(), SparkExitAlerts.losses(noLeaf, lost))
+ }
+
+ @Test
+ fun `a lost leaf is left out and the rest of the exit rebuilds at once`() = test {
+ val recovery = lostLeafRecovery()
+ val start = running(recovery).copy(leafIds = listOf("leaf-1", "leaf-2"))
+ val strikes = start.copy(
+ spentStepStrikes = mapOf("refund" to 1),
+ broadcastLog = listOf(
+ SparkExitRecord.BroadcastAttempt(1, "refund", 1, 0, now, "inputs missing", false, isInputSpent = true),
+ ),
+ isRebuilding = true,
+ lastBuildAt = null,
+ )
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding = null)
+ sdk.builds += recovery
+
+ var record = driver().tick(strikes)
+
+ assertEquals(Phase.Running, record.phase)
+ assertTrue(record.isRebuilding)
+ assertNull(record.lastBuildAt)
+ assertEquals(listOf("leaf-2"), record.exitLeafIds)
+
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n2")))
+ record = driver().tick(record)
+
+ val selection = sdk.prepareRequests.last().selection as ExitLeafSelection.Specific
+ assertEquals(listOf("leaf-2"), selection.leafIds)
+ assertEquals(2, record.generation)
+ }
+
+ private fun ladderRefusal(leafId: String) = SdkException.SparkException(
+ "Leaf $leafId cannot be exited: its refund cannot pay its own fee at this fee rate",
+ )
+
+ @Test
+ fun `a rebuild drops a fee-ladder leaf that cannot pay its refund and prepares the rest`() = test {
+ val recovery = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1")))
+ val start = running(
+ recovery
+ ).copy(leafIds = listOf("leaf-1", "leaf-2"), isRebuilding = true, lastBuildAt = null)
+ sdk.prepareErrors += ladderRefusal("leaf-1")
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding = null, leaves = listOf("leaf-2"))
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n2")))
+
+ val record = driver().tick(start)
+
+ assertEquals(
+ listOf(listOf("leaf-1", "leaf-2"), listOf("leaf-2")),
+ sdk.prepareRequests.map { (it.selection as ExitLeafSelection.Specific).leafIds },
+ )
+ val dropped = record.leftBehindLeaves.single()
+ assertEquals("leaf-1", dropped.leafId)
+ assertEquals(100_000uL, dropped.valueSats)
+ assertEquals(ladderRefusal("leaf-1").v1, dropped.reason)
+ assertEquals(2, record.generation)
+ }
+
+ @Test
+ fun `a rebuild whose every leaf cannot be exited fails`() = test {
+ val start = running(SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1"))))
+ .copy(isRebuilding = true, lastBuildAt = null)
+ sdk.prepareErrors += ladderRefusal("leaf-1")
+
+ val record = driver().tick(start)
+
+ assertEquals(Phase.Failed(SparkExitDriver.NO_LEAF_REASON), record.phase)
+ assertEquals(listOf("leaf-1"), record.leftBehindLeaves.map { it.leafId })
+ }
+
+ @Test
+ fun `a refusal naming no leaf of the exit prepares what is still recoverable`() = test {
+ val recovery = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1")))
+ val start = running(
+ recovery
+ ).copy(leafIds = listOf("leaf-1", "leaf-2"), isRebuilding = true, lastBuildAt = null)
+ sdk.prepareErrors += SdkException.SparkException("Leaf cannot be exited")
+ sdk.preparedResponse =
+ SparkExitFixtures.prepared(funding = null, leaves = listOf("leaf-1"), skipped = emptyList())
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n2")))
+
+ val record = driver().tick(start)
+
+ assertEquals(ExitLeafSelection.RecoverableOnly, sdk.prepareRequests.last().selection)
+ assertEquals(listOf("leaf-2"), record.leftBehindLeaves.map { it.leafId })
+ assertEquals(SparkExitUnexitable.NOT_RECOVERABLE, record.leftBehindLeaves.single().reason)
+ assertEquals(listOf("leaf-1"), record.exitLeafIds)
+ assertEquals(2, record.generation)
+ }
+
+ @Test
+ fun `a refusal fallback that lists a leaf outside the exit prepares only the exit's leaves`() = test {
+ val start = running(SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1"))))
+ .copy(leafIds = listOf("leaf-1", "leaf-2"), isRebuilding = true, lastBuildAt = null)
+ sdk.prepareErrors += SdkException.SparkException("Leaf cannot be exited")
+ // The operators still list a leaf an older state claimed, and recoverable-only picks it up.
+ sdk.preparedResponses += SparkExitFixtures.prepared(funding = null, leaves = listOf("leaf-1", "lost-1"))
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding = null, leaves = listOf("leaf-1"))
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n2")))
+
+ val record = driver().tick(start)
+
+ val selection = assertIs(sdk.prepareRequests.last().selection)
+ assertEquals(listOf("leaf-1"), selection.leafIds)
+ assertEquals(listOf("leaf-1"), sdk.recoverRequests.single().prepared.leaves.map { it.leafId })
+ assertEquals(listOf("leaf-2"), record.leftBehindLeaves.map { it.leafId })
+ }
+
+ @Test
+ fun `a quote leaves out lost leaves the operators still list`() = test {
+ val lostSkip = SkippedLeaf("lost-2", 65_536uL, SkippedLeafReason.Unverified)
+ val dust = SkippedLeaf("dust", 300uL, SkippedLeafReason.FeeExceedsValue)
+ sdk.preparedResponses += SparkExitFixtures.prepared(
+ leaves = listOf("leaf-1", "lost-1"),
+ skipped = listOf(dust, lostSkip),
+ totalFeeSats = 18_000uL,
+ )
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves = listOf("leaf-1"), skipped = emptyList())
+
+ val prepared = sdk.prepareAll(SparkExitFixtures.prepareRequest(), lost = setOf("lost-1", "lost-2"))
+
+ assertEquals(ExitLeafSelection.All, sdk.prepareRequests.first().selection)
+ val selection = assertIs(sdk.prepareRequests.last().selection)
+ assertEquals(listOf("leaf-1"), selection.leafIds)
+ assertEquals(listOf("leaf-1"), prepared.leaves.map { it.leafId })
+ assertEquals(9_000uL, prepared.totalFeeSats)
+ assertEquals(listOf(dust), prepared.skipped)
+ }
+
+ @Test
+ fun `a quote with no lost leaf listed is the operators' own`() = test {
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves = listOf("leaf-1"))
+
+ val prepared = sdk.prepareAll(SparkExitFixtures.prepareRequest(), lost = setOf("lost-1"))
+
+ assertEquals(listOf(ExitLeafSelection.All), sdk.prepareRequests.map { it.selection })
+ assertEquals(sdk.preparedResponse, prepared)
+ }
+
+ @Test
+ fun `a quote whose every leaf is lost shows nothing to exit`() = test {
+ sdk.preparedResponse = SparkExitFixtures.prepared(leaves = listOf("lost-1"))
+
+ val prepared = sdk.prepareAll(SparkExitFixtures.prepareRequest(), lost = setOf("lost-1"))
+
+ assertEquals(1, sdk.prepareRequests.size)
+ assertTrue(prepared.leaves.isEmpty())
+ assertEquals(0uL, prepared.recoverableValueSats)
+ assertEquals(0uL, prepared.totalFeeSats)
+ assertNull(prepared.funding)
+ assertEquals(SparkExitQuoteBlock.Nothing, SparkExitUiState(quote = SparkExitQuote(prepared)).quoteBlock)
+ }
+
+ @Test
+ fun `a leaf recoverFunds refuses is left behind and the rest rebuilt on the next tick`() = test {
+ val recovery = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1")))
+ val start = running(
+ recovery
+ ).copy(leafIds = listOf("leaf-1", "leaf-2"), isRebuilding = true, lastBuildAt = null)
+ sdk.preparedResponse = SparkExitFixtures.prepared(funding = null, leaves = listOf("leaf-1", "leaf-2"))
+ sdk.recoverError = ladderRefusal("leaf-1")
+
+ var record = driver().tick(start)
+
+ assertEquals(listOf("leaf-2"), record.exitLeafIds)
+ assertTrue(record.isRebuilding)
+ assertNull(record.lastBuildAt)
+ assertEquals(1, record.generation)
+
+ sdk.recoverError = null
+ sdk.builds += SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n2")))
+ record = driver().tick(record)
+
+ assertEquals(listOf("leaf-2"), (sdk.prepareRequests.last().selection as ExitLeafSelection.Specific).leafIds)
+ assertEquals(2, record.generation)
+ }
+
+ /**
+ * The qa-03 shared-ancestor race: our first node's package lost to another wallet's, so the node confirmed with
+ * their child and ours ([first] spends [FUNDING_TXID]) is gone; the next node's child spends its change.
+ */
+ private object Race {
+ val NODE_1 = "a1".repeat(32)
+ val NODE_2 = "a2".repeat(32)
+ val FUNDING_TXID = "f0".repeat(32)
+ val first = SparkExitFixtures.childTxHex(NODE_1, FUNDING_TXID)
+ val firstTxid = SignedTransactionId.fromHex(first)
+ val second = SparkExitFixtures.childTxHex(NODE_2, firstTxid)
+ val rebuiltSecond = SparkExitFixtures.childTxHex(NODE_2, FUNDING_TXID)
+
+ fun recovery(node1: ExitTransactionStatus = ExitTransactionStatus.Ready) = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx(NODE_1, cpfp = first, status = node1),
+ SparkExitFixtures.tx(
+ NODE_2,
+ cpfp = second,
+ dependsOn = listOf(NODE_1),
+ status = if (node1 is ExitTransactionStatus.Confirmed) {
+ ExitTransactionStatus.Ready
+ } else {
+ ExitTransactionStatus.WaitingForDependencies
+ },
+ ),
+ ),
+ )
+
+ val rebuilt = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx(NODE_1, status = ExitTransactionStatus.Confirmed(11_600u)),
+ SparkExitFixtures.tx(NODE_2, cpfp = rebuiltSecond, dependsOn = listOf(NODE_1)),
+ ),
+ )
+ }
+
+ @Test
+ fun `a fee chain that lost a race to another wallet's package rebuilds without sending its dead txids`() = test {
+ var record = running(Race.recovery()).copy(
+ funding = listOf(
+ SparkExitRecord.Funding(0u, SparkExitFixtures.FUNDING_ADDRESS, "02ab", Race.FUNDING_TXID, 0u, 20_000uL)
+ ),
+ )
+ sdk.checks += check(Race.recovery(), RecoveryVerdict.Valid)
+ sdk.checks += check(Race.recovery(ExitTransactionStatus.Confirmed(11_600u)), RecoveryVerdict.Valid)
+ sdk.builds += Race.rebuilt
+
+ // Both wallets sent a package for the shared node; ours was refused as a replacement.
+ chain.outcome = SparkExitBroadcastOutcome.Rejected("insufficient fee, rejecting replacement")
+ record = driver().tick(record)
+ // Theirs confirmed the node; our child is in neither a block nor the mempool.
+ chain.confirmedTxids = setOf(Race.NODE_1)
+ chain.unknownTxids = setOf(Race.firstTxid)
+ chain.outcome = SparkExitBroadcastOutcome.Accepted
+ now += SparkExitDriver.REBUILD_INTERVAL.inWholeMilliseconds
+ record = driver().tick(record)
+
+ assertEquals(2, record.generation)
+ assertEquals(listOf(Race.NODE_1, Race.NODE_2), chain.broadcasts.map { it.txid })
+ assertEquals(listOf(Race.first, Race.rebuiltSecond), chain.broadcasts.map { it.cpfpTxHex })
+ assertTrue(saved.any { it.isRebuilding && it.generation == 1 })
+ assertEquals(record.fundingInputs, sdk.recoverRequests.single().fundingInputs)
+ assertEquals(listOf("leaf-1"), (sdk.prepareRequests.single().selection as ExitLeafSelection.Specific).leafIds)
+ assertTrue(record.lostLeaves.isEmpty())
+ assertTrue(record.spentStepStrikes.isEmpty())
+ assertNull(record.surfacedRejection)
+ }
+
+ @Test
+ fun `a dead fee chain waits for the rebuild interval and sends nothing meanwhile`() = test {
+ val recovery = Race.recovery(ExitTransactionStatus.Confirmed(11_600u))
+ var record = running(recovery)
+ chain.unknownTxids = setOf(Race.firstTxid)
+ sdk.checks += check(recovery, RecoveryVerdict.Valid)
+
+ record = driver().tick(record)
+ record = driver().tick(record)
+
+ assertTrue(record.isRebuilding)
+ assertEquals(1, record.generation)
+ assertTrue(chain.broadcasts.isEmpty())
+ assertTrue(sdk.prepareRequests.isEmpty())
+ }
+
+ @Test
+ fun `a child still in the mempool of a confirmed node is not dead`() = test {
+ val recovery = Race.recovery(ExitTransactionStatus.Confirmed(11_600u))
+ var record = running(recovery)
+ sdk.checks += check(recovery, RecoveryVerdict.Valid)
+
+ record = driver().tick(record)
+
+ assertEquals(listOf(Race.firstTxid), chain.knownLookups)
+ assertEquals(1, record.generation)
+ assertEquals(listOf(Race.NODE_2), chain.broadcasts.map { it.txid })
+ }
+
+ private val unpaidRecovery = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("n1", status = ExitTransactionStatus.Confirmed(11_600u)),
+ SparkExitFixtures.tx("n2", cpfp = "03c2", dependsOn = listOf("n1")),
+ ),
+ )
+
+ @Test
+ fun `a step unpaid three times in a row rebuilds once, then surfaces`() = test {
+ var record = running(unpaidRecovery, builtAt = now - SparkExitDriver.REBUILD_INTERVAL.inWholeMilliseconds)
+ chain.outcome = UNPAID
+ repeat(5) { sdk.checks += check(unpaidRecovery, RecoveryVerdict.Valid) }
+ sdk.builds += unpaidRecovery
+
+ repeat(2) { record = driver().tick(record) }
+ assertEquals(2, record.broadcastLog.single().feeChainFailures)
+ assertEquals(0, record.broadcastLog.single().rejections)
+ assertTrue(sdk.prepareRequests.isEmpty())
+ record = driver().tick(record)
+ assertEquals(1, sdk.prepareRequests.size)
+ assertEquals(2, record.generation)
+ assertEquals(listOf("n2"), record.feeChainRebuildTxids)
+ // The rebuild reproduced the same step, which stays unpaid: it never rebuilds again, and now counts.
+ record = driver().tick(record)
+ assertNull(record.surfacedRejection)
+ record = driver().tick(record)
+
+ assertEquals(1, sdk.prepareRequests.size)
+ assertEquals(2, record.generation)
+ assertEquals(3, record.broadcastLog.single().rejections)
+ assertEquals("rejected: min relay fee not met, 0 < 17", record.surfacedRejection)
+ assertTrue(record.lostLeaves.isEmpty())
+ }
+
+ @Test
+ fun `any other outcome resets the unpaid streak`() = test {
+ var record = running(unpaidRecovery)
+ repeat(4) { sdk.checks += check(unpaidRecovery, RecoveryVerdict.Valid) }
+
+ chain.outcome = UNPAID
+ repeat(2) { record = driver().tick(record) }
+ chain.outcome = SparkExitBroadcastOutcome.RetryLater("HTTP 503")
+ record = driver().tick(record)
+ chain.outcome = UNPAID
+ record = driver().tick(record)
+
+ assertEquals(1, record.broadcastLog.single().feeChainFailures)
+ assertTrue(sdk.prepareRequests.isEmpty())
+ }
+
+ @Test
+ fun `only a spark error saying a leaf cannot be exited is read as one`() {
+ assertNull(SparkExitUnexitable.parse(SdkException.SparkException("connection refused")))
+ assertNull(SparkExitUnexitable.parse(IllegalStateException("Leaf a cannot be exited")))
+ val message = "Leaf abc-1 cannot be exited: its refund cannot pay its own fee at this fee rate"
+ assertEquals(
+ SparkExitUnexitable.Refusal("abc-1", message),
+ SparkExitUnexitable.parse(SdkException.SparkException(message)),
+ )
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt b/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt
new file mode 100644
index 0000000000..b657798247
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt
@@ -0,0 +1,245 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.CheckRecoverFundsResponse
+import breez_sdk_spark.CooperativeRecoveryFailure
+import breez_sdk_spark.CpfpFundingKind
+import breez_sdk_spark.CpfpInput
+import breez_sdk_spark.CpfpSigner
+import breez_sdk_spark.ExitChainState
+import breez_sdk_spark.ExitLeafSelection
+import breez_sdk_spark.ExitTransactionStatus
+import breez_sdk_spark.PrepareRecoverFundsRequest
+import breez_sdk_spark.PrepareRecoverFundsResponse
+import breez_sdk_spark.RecoverFundsLeaf
+import breez_sdk_spark.RecoverFundsRequest
+import breez_sdk_spark.RecoverFundsResponse
+import breez_sdk_spark.RecoveryFunding
+import breez_sdk_spark.RecoveryMethod
+import breez_sdk_spark.RecoveryTransaction
+import breez_sdk_spark.RecoveryTxKind
+import breez_sdk_spark.SkippedLeaf
+import breez_sdk_spark.SkippedLeafReason
+
+/** The same fixtures as Bitkit iOS's `SparkExitTests.swift`, so both suites assert the same values. */
+object SparkExitFixtures {
+ const val PARENT_HEX = "020000000111111111111111111111111111111111111111111111111111111111111111110000000000" +
+ "ffffffff01e803000000000000160014222222222222222222222222222222222222222200000000"
+
+ /** The id Spark's regtest node reported for [PARENT_HEX]. */
+ const val PARENT_TXID = "b91b720103788b9ae50dbff918bc375d185df91025b9f6e861efe208909b6adf"
+
+ const val FUNDING_ADDRESS = "bcrt1qfunding"
+
+ fun tx(
+ txid: String,
+ kind: RecoveryTxKind = RecoveryTxKind.NODE,
+ cpfp: String? = null,
+ dependsOn: List = emptyList(),
+ status: ExitTransactionStatus = ExitTransactionStatus.Ready,
+ csv: UInt? = null,
+ ) = RecoveryTransaction(
+ kind = kind,
+ nodeId = if (kind == RecoveryTxKind.SWEEP) null else "node-$txid",
+ txid = txid,
+ txHex = "02$txid",
+ cpfpTxHex = cpfp,
+ csvTimelockBlocks = csv,
+ dependsOn = dependsOn,
+ status = status,
+ )
+
+ fun recovery(
+ transactions: List,
+ failed: List = emptyList(),
+ ) = RecoverFundsResponse(
+ recoverableValueSats = 100_000uL,
+ totalFeeSats = 9_000uL,
+ cooperativeFeeSats = 0uL,
+ cpfpFeeSats = 7_000uL,
+ fanoutFeeSats = 500uL,
+ refundFeeSats = 1_000uL,
+ sweepFeeSats = 500uL,
+ leaves = listOf(RecoverFundsLeaf("leaf-1", 100_000uL, RecoveryMethod.UNILATERAL)),
+ failed = failed,
+ transactions = transactions,
+ fundingInputs = listOf(CpfpInput.P2wpkh("f0", 1u, 20_000uL, "02ab")),
+ feeRateSatPerVbyte = 2uL,
+ destination = "bcrt1qdestination",
+ )
+
+ fun prepared(
+ funding: ULong? = 20_000uL,
+ leaves: List = listOf("leaf-1"),
+ skipped: List = listOf(SkippedLeaf("dust", 300uL, SkippedLeafReason.FeeExceedsValue)),
+ totalFeeSats: ULong = 9_000uL,
+ ) = PrepareRecoverFundsResponse(
+ leaves = leaves.map { RecoverFundsLeaf(it, 100_000uL, RecoveryMethod.UNILATERAL) },
+ skipped = skipped,
+ recoverableValueSats = 100_000uL,
+ totalFeeSats = totalFeeSats,
+ cooperativeFeeSats = 0uL,
+ cpfpFeeSats = 7_000uL,
+ fanoutFeeSats = 500uL,
+ refundFeeSats = 1_000uL,
+ sweepFeeSats = 500uL,
+ funding = funding?.let { RecoveryFunding(it, emptyList()) },
+ feeRateSatPerVbyte = 2uL,
+ destination = "bcrt1qdestination",
+ exitChainState = ExitChainState(emptyList(), emptyList(), emptyList(), emptyList(), emptyList()),
+ )
+
+ fun prepareRequest() = PrepareRecoverFundsRequest(
+ feeRateSatPerVbyte = 2uL,
+ fundingKind = CpfpFundingKind.P2wpkh,
+ destination = "bcrt1qdestination",
+ selection = ExitLeafSelection.All,
+ )
+
+ fun record(phase: SparkExitRecord.Phase = SparkExitRecord.Phase.AwaitingFunding(20_000uL)) = SparkExitRecord(
+ network = "regtest",
+ createdAt = 1_700_000_000_000L,
+ phase = phase,
+ feeRateSatPerVbyte = 2uL,
+ destination = "bcrt1qdestination",
+ leafIds = listOf("leaf-1"),
+ funding = listOf(SparkExitRecord.Funding(index = 0u, address = FUNDING_ADDRESS, pubkey = "02ab")),
+ transferId = "transfer-1",
+ expectedSats = 111_000uL,
+ )
+
+ /**
+ * A version 3 transaction spending output 0 of [parent] with [csv] blocks of relative timelock in its nSequence,
+ * as a refund spends its node.
+ */
+ fun csvTxHex(parent: String, csv: Int): String {
+ fun le32(value: Long) = (0 until 4).joinToString("") { "%02x".format((value shr (8 * it)) and 0xff) }
+ val prevout = parent.chunked(2).reversed().joinToString("")
+ return "03000000" + "01" + prevout + le32(0) + "00" + le32(csv.toLong()) +
+ "01" + "e803000000000000" + "00" + le32(0)
+ }
+
+ /** A version 3 CPFP child spending output 0 of each of [spends]: its node's anchor and the change it chains on. */
+ fun childTxHex(vararg spends: String): String {
+ val inputs = spends.joinToString("") { parent ->
+ parent.chunked(2).reversed().joinToString("") + "00000000" + "00" + "ffffffff"
+ }
+ return "03000000" + "%02x".format(spends.size) + inputs + "01" + "e803000000000000" + "00" + "00000000"
+ }
+
+ fun utxo(txid: String, vout: UInt = 0u, value: ULong, confirmed: Boolean = true) =
+ EsploraUtxo(txid, vout, value, EsploraTxStatus(confirmed, if (confirmed) 100u else null))
+}
+
+class FakeSparkExitChain : SparkExitChain {
+ var tip = 1_000u
+ val utxosByAddress = mutableMapOf>()
+ var utxosError: Throwable? = null
+ var confirmedTxids = setOf()
+
+ /** Txids no host knows: in neither a block nor the mempool. */
+ var unknownTxids = setOf()
+ val knownLookups = mutableListOf()
+
+ /** Block heights of confirmed transactions; 900 for any other. */
+ val heights = mutableMapOf()
+ var outcome: SparkExitBroadcastOutcome = SparkExitBroadcastOutcome.Accepted
+ val broadcasts = mutableListOf()
+ var onBroadcast: () -> Unit = {}
+
+ override suspend fun tipHeight() = tip
+
+ override suspend fun utxos(address: String): List {
+ utxosError?.let { throw it }
+ return utxosByAddress[address].orEmpty()
+ }
+
+ override suspend fun txStatus(txid: String) =
+ EsploraTxStatus(txid in confirmedTxids, if (txid in confirmedTxids) heights[txid] ?: 900u else null)
+
+ override suspend fun isTxKnown(txid: String): Boolean {
+ knownLookups += txid
+ return txid !in unknownTxids
+ }
+
+ override suspend fun broadcast(tx: RecoveryTransaction): SparkExitBroadcastOutcome {
+ onBroadcast()
+ broadcasts += tx
+ return outcome
+ }
+
+ override suspend fun broadcastHex(hex: String): SparkExitBroadcastOutcome {
+ rawBroadcasts += hex
+ return outcome
+ }
+
+ val rawBroadcasts = mutableListOf()
+}
+
+class FakeSparkExitRecovering : SparkExitRecovering {
+ var preparedResponse = SparkExitFixtures.prepared()
+ val builds = ArrayDeque()
+ val checks = ArrayDeque()
+ var recoverError: Throwable? = null
+ val prepareRequests = mutableListOf()
+ val recoverRequests = mutableListOf()
+ val checkedRecoveries = mutableListOf()
+
+ /** Errors the next prepares throw, in order, before [preparedResponse] is returned. */
+ val prepareErrors = ArrayDeque()
+
+ /** Responses the next prepares return, in order, before [preparedResponse]. */
+ val preparedResponses = ArrayDeque()
+
+ override suspend fun prepare(request: PrepareRecoverFundsRequest): PrepareRecoverFundsResponse {
+ prepareRequests += request
+ prepareErrors.removeFirstOrNull()?.let { throw it }
+ return preparedResponses.removeFirstOrNull() ?: preparedResponse
+ }
+
+ override suspend fun recover(request: RecoverFundsRequest, signer: CpfpSigner?): RecoverFundsResponse {
+ recoverRequests += request
+ recoverError?.let { throw it }
+ return builds.removeFirst()
+ }
+
+ override suspend fun check(recovery: RecoverFundsResponse): CheckRecoverFundsResponse {
+ checkedRecoveries += recovery
+ return checks.removeFirst()
+ }
+}
+
+/** Finalizes every input of a PSBT with [witness], as the SDK's single-key signer does with its signature. */
+class StubCpfpSigner(private val witness: String = P2WPKH_WITNESS) : CpfpSigner {
+ companion object {
+ /** A P2WPKH witness: two items, a 72-byte signature with its sighash flag and a 33-byte key. */
+ val P2WPKH_WITNESS = "02" + "48" + "30".repeat(71) + "01" + "21" + "02".repeat(33)
+ }
+
+ override suspend fun signPsbt(psbtBytes: ByteArray): ByteArray {
+ var offset = 5
+ fun readCompactSize(): Int {
+ val prefix = psbtBytes[offset++].toInt() and 0xff
+ if (prefix < 0xfd) return prefix
+ check(prefix == 0xfd)
+ return (psbtBytes[offset++].toInt() and 0xff) or ((psbtBytes[offset++].toInt() and 0xff) shl 8)
+ }
+ var unsignedTx = ByteArray(0)
+ fun copyMap(extra: ByteArray): ByteArray {
+ val start = offset
+ while (true) {
+ val keyLength = readCompactSize()
+ if (keyLength == 0) return psbtBytes.copyOfRange(start, offset - 1) + extra + byteArrayOf(0x00)
+ val key = psbtBytes[offset]
+ offset += keyLength
+ val valueLength = readCompactSize()
+ if (start == 5 && key == 0x00.toByte()) unsignedTx = psbtBytes.copyOfRange(offset, offset + valueLength)
+ offset += valueLength
+ }
+ }
+ val witnessBytes = witness.hexToByteArray()
+ val finalWitness = byteArrayOf(0x01, 0x08, witnessBytes.size.toByte()) + witnessBytes
+ var signed = psbtBytes.copyOfRange(0, 5) + copyMap(ByteArray(0))
+ repeat(unsignedTx[4].toInt()) { signed += copyMap(finalWitness) }
+ return signed + psbtBytes.copyOfRange(offset, psbtBytes.size)
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitFundingTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitFundingTest.kt
new file mode 100644
index 0000000000..707be6239b
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SparkExitFundingTest.kt
@@ -0,0 +1,312 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.ExitTransactionStatus
+import breez_sdk_spark.RecoveryTxKind
+import org.junit.Test
+import org.lightningdevkit.ldknode.Network
+import kotlin.test.assertContentEquals
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+class SparkExitFundingTest {
+ @Test
+ fun `quote splits what arrives from what it costs`() {
+ val quote = SparkExitQuote(SparkExitFixtures.prepared(funding = 20_000uL))
+
+ assertEquals(100_000uL, quote.exitSats)
+ assertEquals(9_000uL, quote.networkFeeSats)
+ assertEquals(91_000uL, quote.receiveSats)
+ assertEquals(20_000uL, quote.fundingSats)
+ assertEquals(111_000uL, quote.incomingSats)
+ assertEquals(300uL, quote.leftBehindSats)
+ assertEquals(listOf("leaf-1"), quote.leafIds)
+ assertTrue(quote.isWorthExiting)
+ }
+
+ @Test
+ fun `quote is not worth exiting when fees reach the value or nothing is left`() {
+ val expensive = SparkExitQuote(SparkExitFixtures.prepared(totalFeeSats = 100_000uL))
+
+ assertFalse(expensive.isWorthExiting)
+ assertEquals(0uL, expensive.receiveSats)
+ assertFalse(SparkExitQuote(SparkExitFixtures.prepared(leaves = emptyList())).isWorthExiting)
+ }
+
+ @Test
+ fun `cooperative only quote needs no funding`() {
+ assertEquals(0uL, SparkExitQuote(SparkExitFixtures.prepared(funding = null)).fundingSats)
+ }
+
+ @Test
+ fun `shortfall counts what the funding address already holds`() {
+ val record = SparkExitFixtures.record(SparkExitRecord.Phase.NeedsFunding(23_000uL)).copy(
+ funding = listOf(
+ SparkExitRecord.Funding(0u, SparkExitFixtures.FUNDING_ADDRESS, "02ab", "f0", 1u, 20_000uL)
+ ),
+ )
+
+ assertEquals(20_000uL, record.fundedSats)
+ assertEquals(3_000uL, record.fundingShortfallSats)
+ assertNull(record.copy(phase = SparkExitRecord.Phase.NeedsFunding(20_000uL)).fundingShortfallSats)
+ assertNull(record.copy(phase = SparkExitRecord.Phase.Running).fundingShortfallSats)
+ }
+
+ @Test
+ fun `needed funding counts what the funding address still holds`() {
+ // The headless regtest run: 9,035 held, the SDK wants 9,085 in all.
+ assertEquals(9_085uL, SparkExitMath.neededFunding(9_085uL, fundedSats = 9_035uL, held = 9_035uL))
+ // A Redo: a 20,000 input lives on as 13,000 of change, and the rebuild needs 15,000.
+ assertEquals(22_000uL, SparkExitMath.neededFunding(15_000uL, fundedSats = 20_000uL, held = 13_000uL))
+ assertEquals(20_001uL, SparkExitMath.neededFunding(12_000uL, fundedSats = 20_000uL, held = 13_000uL))
+ assertEquals(20_000uL, SparkExitMath.neededFunding(15_000uL, fundedSats = 20_000uL, held = null))
+ }
+
+ @Test
+ fun `spark's balance beyond the exit's leaves is outside the exit`() {
+ assertEquals(17_097uL, SparkExitMath.outsideExitSats(leafTotalSats = 32_768uL, balanceSats = 49_865uL))
+ assertNull(SparkExitMath.outsideExitSats(leafTotalSats = 90_112uL, balanceSats = 9_789uL))
+ assertNull(SparkExitMath.outsideExitSats(leafTotalSats = null, balanceSats = 9_789uL))
+ }
+
+ @Test
+ fun `claimable height is the latest timelock`() {
+ val recovery = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("a", status = ExitTransactionStatus.WaitingForTimelock(2_000u)),
+ SparkExitFixtures.tx("b", status = ExitTransactionStatus.WaitingForTimelock(2_400u)),
+ SparkExitFixtures.tx("c", status = ExitTransactionStatus.Confirmed(5u)),
+ ),
+ )
+
+ assertEquals(2_400u, SparkExitMath.claimableAtHeight(recovery))
+ assertEquals(1, SparkExitMath.confirmedCount(recovery))
+ assertNull(SparkExitMath.claimableAtHeight(SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("d")))))
+ }
+
+ @Test
+ fun `timelocks are matured only once no refund waits`() {
+ val waiting = SparkExitFixtures.tx(
+ "r1",
+ kind = RecoveryTxKind.REFUND,
+ status = ExitTransactionStatus.WaitingForDependencies,
+ )
+ val ready = waiting.copy(status = ExitTransactionStatus.Ready)
+ val timelocked = waiting.copy(status = ExitTransactionStatus.WaitingForTimelock(2_000u))
+
+ assertFalse(SparkExitMath.areTimelocksMatured(listOf(SparkExitFixtures.tx("n1"), waiting)))
+ assertFalse(SparkExitMath.areTimelocksMatured(listOf(timelocked)))
+ assertFalse(SparkExitMath.areTimelocksMatured(listOf(SparkExitFixtures.tx("n1"))))
+ assertTrue(SparkExitMath.areTimelocksMatured(listOf(ready)))
+ }
+
+ @Test
+ fun `reminder targets the next timelock to mature`() {
+ val recovery = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("a", status = ExitTransactionStatus.WaitingForTimelock(900u)),
+ SparkExitFixtures.tx("b", status = ExitTransactionStatus.WaitingForTimelock(2_400u)),
+ SparkExitFixtures.tx("c", status = ExitTransactionStatus.WaitingForTimelock(2_000u)),
+ ),
+ )
+
+ assertEquals(2_000u, SparkExitMath.nextMaturityHeight(recovery, tip = 1_000u))
+ assertNull(SparkExitMath.nextMaturityHeight(recovery, tip = 2_400u))
+ }
+
+ @Test
+ fun `funding utxos are the confirmed ones once together they cover the amount`() {
+ val utxos = listOf(
+ SparkExitFixtures.utxo("small", value = 999uL),
+ SparkExitFixtures.utxo("pending", value = 50_000uL, confirmed = false),
+ SparkExitFixtures.utxo("big", value = 2_000uL),
+ )
+
+ assertEquals(listOf("big", "small"), SparkExitMath.fundingTopUp(utxos, requiredSats = 2_999uL)?.map { it.txid })
+ assertNull(SparkExitMath.fundingTopUp(utxos, requiredSats = 3_000uL))
+ assertNull(SparkExitMath.fundingTopUp(emptyList(), requiredSats = 0uL))
+ }
+
+ @Test
+ fun `savings must cover the funding and its fee`() {
+ assertTrue(SparkExitMath.canFundFromSavings(10_500uL, 10_000uL, 500uL))
+ assertFalse(SparkExitMath.canFundFromSavings(10_499uL, 10_000uL, 500uL))
+ assertFalse(SparkExitMath.canFundFromSavings(10_000uL, 0uL, 0uL))
+ }
+
+ @Test
+ fun `eta uses the network's block time`() {
+ assertEquals(100.minutes, SparkExitMath.timeUntil(110u, 100u, Network.BITCOIN))
+ assertEquals(5.minutes, SparkExitMath.timeUntil(110u, 100u, Network.REGTEST))
+ assertEquals(Duration.ZERO, SparkExitMath.timeUntil(90u, 100u, Network.BITCOIN))
+ }
+
+ @Test
+ fun `deadline warns 12 blocks into the operators' window of the next step`() {
+ val mainnet = SparkExitMath.deadline(2.hours, Network.BITCOIN)
+ assertEquals(SparkExitDeadline(warnIn = 4.hours, deadlineIn = 10.hours + 20.minutes), mainnet)
+ val regtest = SparkExitMath.deadline(Duration.ZERO, Network.REGTEST)
+ assertEquals(SparkExitDeadline(warnIn = 6.minutes, deadlineIn = 25.minutes), regtest)
+ }
+
+ @Test
+ fun `deadline warns under half of the smaller of the 50- and 100-block windows on every network`() {
+ listOf(Network.BITCOIN to 10.minutes, Network.REGTEST to 30.seconds).forEach { (network, block) ->
+ val maturity = 3.hours
+ val deadline = SparkExitMath.deadline(maturity, network)
+ assertTrue(deadline.warnIn - maturity < block * minOf(50, 100) / 2, "$network warns too late")
+ assertEquals(block * 50, deadline.deadlineIn - maturity)
+ }
+ }
+
+ @Test
+ fun `a step that can be published now puts the deadline in the current window`() {
+ val far = SparkExitFixtures.tx("far", status = ExitTransactionStatus.WaitingForTimelock(1_100u))
+ val waiting = SparkExitFixtures.recovery(listOf(far))
+ assertEquals(100.minutes * 10, SparkExitMath.untilNextMaturity(waiting, tip = 1_000u, Network.BITCOIN))
+
+ val ready = SparkExitFixtures.recovery(listOf(far, SparkExitFixtures.tx("n1")))
+ assertEquals(Duration.ZERO, SparkExitMath.untilNextMaturity(ready, tip = 1_000u, Network.BITCOIN))
+
+ val matured = SparkExitFixtures.tx("r1", status = ExitTransactionStatus.WaitingForTimelock(990u))
+ val behindTip = SparkExitFixtures.recovery(listOf(far, matured))
+ assertEquals(Duration.ZERO, SparkExitMath.untilNextMaturity(behindTip, tip = 1_000u, Network.BITCOIN))
+
+ val blocked = SparkExitFixtures.tx("n2", dependsOn = listOf("n1"))
+ val waitingForParent = SparkExitFixtures.recovery(listOf(far, blocked))
+ assertEquals(100.minutes * 10, SparkExitMath.untilNextMaturity(waitingForParent, tip = 1_000u, Network.BITCOIN))
+ }
+
+ @Test
+ fun `funding key derives on its own account`() {
+ assertEquals("m/84'/1'/7797'/0/0", SparkExitFundingKey.path(0, Network.REGTEST))
+ assertEquals("m/84'/0'/7797'/0/3", SparkExitFundingKey.path(3, Network.BITCOIN))
+ }
+
+ @Test
+ fun `decodes a compressed wif`() {
+ val key = SparkExitFundingKey.secretKey("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn")
+
+ assertContentEquals(ByteArray(31) + byteArrayOf(1), key)
+ }
+
+ @Test
+ fun `rejects a wif with a bad checksum or an uncompressed key`() {
+ assertFailsWith {
+ SparkExitFundingKey.secretKey("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWo")
+ }
+ assertFailsWith {
+ SparkExitFundingKey.secretKey("5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf")
+ }
+ assertFailsWith { SparkExitFundingKey.secretKey("0OIl") }
+ }
+
+ @Test
+ fun `cpfp child txids ignore witness data`() {
+ val segwit = "0200000000010111111111111111111111111111111111111111111111111111111111111111110000000000" +
+ "ffffffff01e80300000000000016001422222222222222222222222222222222222222220202abcd0301020300000000"
+ val recovery = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("a", cpfp = SparkExitFixtures.PARENT_HEX),
+ SparkExitFixtures.tx("b", cpfp = segwit),
+ SparkExitFixtures.tx("c", cpfp = "02zz"),
+ SparkExitFixtures.tx("d"),
+ ),
+ )
+
+ assertEquals(
+ listOf(SparkExitFixtures.PARENT_TXID, SparkExitFixtures.PARENT_TXID),
+ SparkExitTxids.cpfpChildTxids(recovery),
+ )
+ }
+
+ @Test
+ fun `progress waits for a payment from savings and asks only for what is still missing`() {
+ val record = SparkExitFixtures.record().copy(fundingPaymentTxid = "paid")
+ val paidPending = listOf(SparkExitFixtures.utxo("paid", value = 20_000uL, confirmed = false))
+ assertEquals(SparkExitFundingState.Paid("paid"), SparkExitMath.fundingState(record, paidPending))
+ assertEquals(SparkExitFundingState.Paid("paid"), SparkExitMath.fundingState(record, utxos = null))
+ // Confirmed but short: only the rest is asked for.
+ val paidShort = listOf(SparkExitFixtures.utxo("paid", value = 15_000uL))
+ assertEquals(
+ SparkExitFundingState.Ask(5_000uL, 15_000uL, 20_000uL),
+ SparkExitMath.fundingState(record, paidShort)
+ )
+ // Once adopted, a later shortfall no longer reads as paid.
+ val topUp = record.copy(
+ phase = SparkExitRecord.Phase.NeedsFunding(30_000uL),
+ funding = listOf(record.funding.single().copy(txid = "paid", vout = 0u, valueSats = 20_000uL)),
+ )
+ assertEquals(SparkExitFundingState.Ask(10_000uL), SparkExitMath.fundingState(topUp, emptyList()))
+ }
+
+ @Test
+ fun `progress waits for an external payment seen in the mempool`() {
+ val record = SparkExitFixtures.record()
+ val seen = listOf(SparkExitFixtures.utxo("ext", value = 20_000uL, confirmed = false))
+ assertEquals(SparkExitFundingState.Seen("ext"), SparkExitMath.fundingState(record, seen))
+ val partial = listOf(SparkExitFixtures.utxo("ext", value = 8_000uL, confirmed = false))
+ assertEquals(
+ SparkExitFundingState.Ask(12_000uL, 8_000uL, 20_000uL),
+ SparkExitMath.fundingState(record, partial)
+ )
+ assertNull(SparkExitMath.fundingState(record.copy(phase = SparkExitRecord.Phase.Running), seen))
+ }
+
+ @Test
+ fun `quote names what it skips and spark's balance beyond every leaf it lists`() {
+ val prepared = SparkExitFixtures.prepared()
+ val beyond = SparkExitQuote(prepared, sparkBalanceSats = 150_000uL)
+ assertEquals(50_000uL, beyond.unexitableSats)
+ assertEquals(49_700uL, beyond.beyondLeavesSats)
+ assertEquals(300uL, SparkExitQuote(prepared, sparkBalanceSats = 100_300uL).unexitableSats)
+ assertEquals(300uL, SparkExitQuote(prepared).unexitableSats)
+ assertNull(SparkExitQuote(SparkExitFixtures.prepared(skipped = emptyList())).unexitableSats)
+ }
+
+ @Test
+ fun `each exit takes a funding key index no earlier exit used`() {
+ assertEquals(0u, SparkExitMath.nextFundingIndex(kept = null, stored = null))
+ val old = SparkExitFixtures.record(SparkExitRecord.Phase.Done)
+ assertEquals(1u, SparkExitMath.nextFundingIndex(kept = null, stored = old))
+ assertEquals(4u, SparkExitMath.nextFundingIndex(kept = 4, stored = old))
+ val later = old.copy(funding = old.funding.map { it.copy(index = 6u) })
+ assertEquals(7u, SparkExitMath.nextFundingIndex(kept = 4, stored = later))
+ }
+
+ @Test
+ fun `a partial payment shows what arrived and what is still due`() {
+ val partial = SparkExitPartialFunding(8_000uL, 20_000uL, 12_000uL)
+ assertEquals(partial, SparkExitMath.partialFunding(8_000uL, 20_000uL))
+ assertNull(SparkExitMath.partialFunding(0uL, 20_000uL))
+ assertNull(SparkExitMath.partialFunding(20_000uL, 20_000uL))
+
+ // On Progress the top-up counts only new funding, unconfirmed included, against the shortfall.
+ val record = SparkExitFixtures.record()
+ val seen = listOf(SparkExitFixtures.utxo("ext", value = 8_000uL, confirmed = false))
+ val ask = assertIs(SparkExitMath.fundingState(record, seen))
+ assertEquals(partial, ask.partial)
+ assertNull(SparkExitFundingState.Ask(20_000uL).partial)
+ }
+
+ @Test
+ fun `a top up under dust is asked as the funding output's dust limit`() {
+ val record = SparkExitFixtures.record()
+ val almost = listOf(SparkExitFixtures.utxo("ext", value = 19_932uL))
+ val ask = SparkExitFundingState.Ask(294uL, 19_932uL, 20_000uL)
+ assertEquals(ask, SparkExitMath.fundingState(record, almost))
+ // The hint names the shortfall as it is and asks for dust at least.
+ assertEquals(SparkExitPartialFunding(19_932uL, 20_000uL, 294uL), ask.partial)
+ assertEquals(
+ SparkExitPartialFunding(19_932uL, 20_000uL, 294uL),
+ SparkExitMath.partialFunding(19_932uL, 20_000uL)
+ )
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitRecordTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitRecordTest.kt
new file mode 100644
index 0000000000..d7803d814a
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SparkExitRecordTest.kt
@@ -0,0 +1,251 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.CooperativeRecoveryError
+import breez_sdk_spark.CooperativeRecoveryFailure
+import breez_sdk_spark.CpfpInput
+import breez_sdk_spark.ExitTransactionStatus
+import breez_sdk_spark.RecoveryTxKind
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SparkExitRecordTest {
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ @Test
+ fun `record round trips with every phase and status`() {
+ val recovery = SparkExitFixtures.recovery(
+ transactions = listOf(
+ SparkExitFixtures.tx(
+ "fan",
+ kind = RecoveryTxKind.FAN_OUT,
+ status = ExitTransactionStatus.Confirmed(120u)
+ ),
+ SparkExitFixtures.tx("node", cpfp = "03cpfp", dependsOn = listOf("fan")),
+ SparkExitFixtures.tx(
+ "refund",
+ kind = RecoveryTxKind.REFUND,
+ cpfp = "03cpfp2",
+ dependsOn = listOf("node"),
+ status = ExitTransactionStatus.WaitingForTimelock(2_100u),
+ csv = 2_000u,
+ ),
+ SparkExitFixtures.tx(
+ "wait",
+ dependsOn = listOf("node"),
+ status = ExitTransactionStatus.WaitingForDependencies
+ ),
+ SparkExitFixtures.tx("unknown", status = ExitTransactionStatus.Unverified),
+ SparkExitFixtures.tx(
+ "sweep",
+ kind = RecoveryTxKind.SWEEP,
+ dependsOn = listOf("refund"),
+ status = ExitTransactionStatus.Confirmed(null),
+ ),
+ ),
+ failed = listOf(
+ CooperativeRecoveryFailure("a", "o1", 0u, CooperativeRecoveryError.ReplacementFeeTooLow(5uL, 6uL)),
+ CooperativeRecoveryFailure("b", "o2", 1u, CooperativeRecoveryError.OperatorsUnavailable("down")),
+ CooperativeRecoveryFailure("c", "o3", 2u, CooperativeRecoveryError.Generic("boom")),
+ ),
+ )
+ val adopted = SparkExitFixtures.record(SparkExitRecord.Phase.Running).adopt(recovery, 1_700_000_050_000L)
+ val phases = listOf(
+ SparkExitRecord.Phase.AwaitingFunding(20_000uL),
+ SparkExitRecord.Phase.Running,
+ SparkExitRecord.Phase.NeedsFunding(4_321uL),
+ SparkExitRecord.Phase.Done,
+ SparkExitRecord.Phase.Failed("Nothing left to recover"),
+ )
+
+ phases.forEach { phase ->
+ val record = adopted.copy(
+ phase = phase,
+ broadcastLog = listOf(
+ SparkExitRecord.BroadcastAttempt(1, "node", 2, 1, 1_700_000_100_000L, "accepted", true),
+ SparkExitRecord.BroadcastAttempt(
+ 1,
+ "refund",
+ 1,
+ 0,
+ 1L,
+ "inputs missing",
+ false,
+ isInputSpent = true,
+ feeChainFailures = 2,
+ ),
+ ),
+ claimableAtHeight = 2_100u,
+ leftover = listOf(SparkExitRecord.Leftover("change", 3u, 700uL)),
+ isRebuilding = true,
+ leafTotalSats = 90_112uL,
+ earlierCpfpTxids = listOf("child"),
+ leftoverSweep = SparkExitRecord.LeftoverSweep("sweep", "02sweep", 5_035uL, true, isConfirmed = true),
+ fundingPaymentTxid = "paid",
+ spentStepStrikes = mapOf("refund" to 1),
+ lostLeaves = listOf(SparkExitRecord.LostLeaf("leaf-1", 16_384uL)),
+ leftBehindLeaves = listOf(SparkExitRecord.LeftBehindLeaf("leaf-2", 8_192uL, "its refund cannot pay")),
+ feeChainRebuildTxids = listOf("refund"),
+ )
+
+ assertEquals(record, SparkExitJson.decode(SparkExitJson.encode(record)))
+ }
+ }
+
+ @Test
+ fun `recovery uses the spark-sdk serde shape`() {
+ val recovery = SparkExitFixtures.recovery(
+ listOf(
+ SparkExitFixtures.tx("a", cpfp = "03c", status = ExitTransactionStatus.Confirmed(7u)),
+ SparkExitFixtures.tx("b", kind = RecoveryTxKind.FAN_OUT),
+ SparkExitFixtures.tx("c", status = ExitTransactionStatus.WaitingForTimelock(null)),
+ ),
+ )
+
+ val json = Json.parseToJsonElement(SparkExitJson.encodeRecovery(recovery)).jsonObject
+
+ assertEquals(2, json.getValue("fee_rate_sat_per_vbyte").jsonPrimitive.int)
+ val transactions = json.getValue("transactions").jsonArray.map { it.jsonObject }
+ assertEquals("Node", transactions[0].getValue("kind").jsonPrimitive.content)
+ assertEquals("03c", transactions[0].getValue("cpfp_tx_hex").jsonPrimitive.content)
+ val confirmed = transactions[0].getValue("status").jsonObject.getValue("Confirmed").jsonObject
+ assertEquals(7, confirmed.getValue("block_height").jsonPrimitive.int)
+ assertEquals("FanOut", transactions[1].getValue("kind").jsonPrimitive.content)
+ assertEquals(JsonPrimitive("Ready"), transactions[1].getValue("status"))
+ assertEquals(JsonNull, transactions[1].getValue("cpfp_tx_hex"))
+ val timelock = transactions[2].getValue("status").jsonObject.getValue("WaitingForTimelock") as JsonObject
+ assertEquals(JsonNull, timelock.getValue("spendable_at_height"))
+ val input = json.getValue("funding_inputs").jsonArray[0].jsonObject.getValue("P2wpkh").jsonObject
+ assertEquals(20_000, input.getValue("value_sats").jsonPrimitive.int)
+ }
+
+ /** A recovery as the Breez CLI's `recover-funds --output-file` writes it (serde_json, spark-sdk 0.27.2). */
+ @Test
+ fun `reads a recovery written by spark-sdk`() {
+ val 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"}
+ """.trimIndent()
+
+ val recovery = SparkExitJson.decodeRecovery(json)
+
+ assertEquals(
+ listOf(RecoveryTxKind.NODE, RecoveryTxKind.REFUND, RecoveryTxKind.SWEEP),
+ recovery.transactions.map { it.kind }
+ )
+ assertEquals(ExitTransactionStatus.WaitingForTimelock(3_100u), recovery.transactions[1].status)
+ assertEquals(2_000u, recovery.transactions[1].csvTimelockBlocks)
+ assertNull(recovery.transactions[2].nodeId)
+ assertEquals(CooperativeRecoveryError.OperatorsUnavailable("timeout"), recovery.failed[0].error)
+ assertEquals(listOf(CpfpInput.P2wpkh("ff", 0u, 10_000uL, "02cd")), recovery.fundingInputs)
+ }
+
+ /** A record as Bitkit iOS writes it: Swift's synthesized Codable, a fractional millisecond date. */
+ @Test
+ fun `reads a record written by bitkit ios`() {
+ val text = requireNotNull(javaClass.classLoader?.getResource("spark-exit-record-ios.json")).readText()
+
+ val record = SparkExitJson.decode(text)
+
+ assertEquals(SparkExitRecord.Phase.Running, record.phase)
+ assertEquals(1_700_000_000_123L, record.createdAt)
+ assertEquals(1, record.generation)
+ assertEquals(listOf(CpfpInput.P2wpkh("f0", 1u, 20_000uL, "02ab")), record.fundingInputs)
+ assertEquals(3_100u, record.recovery?.let(SparkExitMath::claimableAtHeight))
+ assertEquals("transfer-1", record.transferId)
+ assertNull(record.claimableAtHeight)
+ assertTrue(record.broadcastLog.single().isAccepted)
+ // Written before exits had their own funding key index: it signs with index 0.
+ assertEquals(0u, record.fundingIndex)
+ assertEquals(record, SparkExitJson.decode(SparkExitJson.encode(record)))
+ }
+
+ @Test
+ fun `leaves absent optionals out of the record`() {
+ val json = Json.parseToJsonElement(SparkExitJson.encode(SparkExitFixtures.record())).jsonObject
+
+ assertFalse("recovery" in json)
+ assertFalse("claimableAtHeight" in json)
+ assertFalse("fundingPaymentTxid" in json)
+ assertFalse("spentStepStrikes" in json)
+ assertFalse("lostLeaves" in json)
+ assertFalse("feeChainRebuildTxids" in json)
+ assertEquals(
+ JsonPrimitive(20_000),
+ json.getValue("phase").jsonObject.getValue("awaitingFunding").jsonObject["requiredSats"]
+ )
+ assertFalse("txid" in json.getValue("funding").jsonArray[0].jsonObject)
+ }
+
+ @Test
+ fun `refuses an unknown record version`() {
+ val text = SparkExitJson.encode(SparkExitFixtures.record()).replace("\"version\":1", "\"version\":2")
+
+ val error = assertFailsWith { SparkExitJson.decode(text) }
+
+ assertEquals(2, error.version)
+ }
+
+ @Test
+ fun `store saves loads and sets an unreadable record aside`() {
+ val file = File(tempFolder.root, "regtest/wallet0/spark-exit.json")
+ val store = SparkExitStore(file)
+ assertNull(store.load())
+ assertFalse(store.isActive())
+
+ store.save(SparkExitFixtures.record())
+
+ assertEquals(SparkExitFixtures.record(), store.load())
+ assertTrue(store.isActive())
+ assertFalse(File(file.parentFile, "spark-exit.json.tmp").exists())
+
+ file.writeText("{")
+ assertFalse(store.isActive())
+ store.setAside(42L)
+ assertFalse(file.exists())
+ assertTrue(File(file.parentFile, "spark-exit.unreadable-42.json").exists())
+ }
+
+ @Test
+ fun `a failed or lossy exit stays reachable until its result was seen`() {
+ val done = SparkExitFixtures.record(SparkExitRecord.Phase.Done)
+ assertFalse(done.showsResult)
+
+ val failed = SparkExitFixtures.record(SparkExitRecord.Phase.Failed(SparkExitDriver.LOST_REASON))
+ assertTrue(failed.showsResult)
+ assertFalse(failed.copy(isResultSeen = true).showsResult)
+
+ val partial = done.copy(lostLeaves = listOf(SparkExitRecord.LostLeaf("leaf-1", 16_384uL)))
+ assertTrue(partial.showsResult)
+ assertFalse(partial.copy(isResultSeen = true).showsResult)
+
+ val seen = failed.copy(isResultSeen = true)
+ assertEquals(seen, SparkExitJson.decode(SparkExitJson.encode(seen)))
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitRegtestTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitRegtestTest.kt
new file mode 100644
index 0000000000..09343ca30a
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SparkExitRegtestTest.kt
@@ -0,0 +1,51 @@
+package to.bitkit.repositories
+
+import io.ktor.client.HttpClient
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Test
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+/**
+ * Runs the exit's chain client against Spark's hosted regtest mempool. It needs the network, so it only runs with
+ * `SPARK_EXIT_REGTEST=1` in the environment of `just test file SparkExitRegtestTest`.
+ */
+class SparkExitRegtestTest : BaseUnitTest() {
+ private val chain = EsploraSparkExitChain(EsploraSparkExitChain.defaultHosts("regtest"), HttpClient())
+
+ @Before
+ fun requireOptIn() {
+ assumeTrue(
+ "Set SPARK_EXIT_REGTEST=1 to run against Spark's regtest",
+ System.getenv("SPARK_EXIT_REGTEST") == "1"
+ )
+ }
+
+ @Test
+ fun `reads the tip and an unused funding address`() = test {
+ assertTrue(chain.tipHeight() > 0u)
+ assertTrue(chain.utxos("bcrt1qzyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3lgth6c").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.
+ */
+ @Test
+ fun `live node answers a package the classifier retries`() = test {
+ val parentId = SparkExitFixtures.PARENT_TXID.chunked(2).reversed().joinToString("")
+ val child = "0200000001${parentId}0000000000ffffffff01e8030000000000001600142222222222222222222222222222" +
+ "22222222222200000000"
+ val tx = SparkExitFixtures.tx(
+ SparkExitFixtures.PARENT_TXID,
+ cpfp = child
+ ).copy(txHex = SparkExitFixtures.PARENT_HEX)
+
+ val outcome = chain.broadcast(tx)
+
+ assertIs(outcome)
+ assertTrue("missingorspent" in outcome.reason, outcome.reason)
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt
new file mode 100644
index 0000000000..441bec38e2
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt
@@ -0,0 +1,356 @@
+package to.bitkit.repositories
+
+import breez_sdk_spark.CheckRecoverFundsResponse
+import breez_sdk_spark.RecoveryRedoReason
+import breez_sdk_spark.RecoveryVerdict
+import breez_sdk_spark.SdkException
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.mock.MockEngine
+import io.ktor.client.engine.mock.respond
+import io.ktor.client.engine.mock.toByteArray
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.utils.io.errors.IOException
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import to.bitkit.data.AppCacheData
+import to.bitkit.data.CacheStore
+import to.bitkit.data.entities.TransferEntity
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.env.Env
+import to.bitkit.models.TransferType
+import to.bitkit.repositories.SparkExitRecord.Phase
+import to.bitkit.services.CoreService
+import to.bitkit.services.OnchainService
+import to.bitkit.services.SparkService
+import to.bitkit.test.BaseUnitTest
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.time.Clock
+import kotlin.time.Instant
+
+class SparkExitRepoTest : BaseUnitTest() {
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private val transferRepo = mock()
+ private val sparkRepo = mock {
+ on { walletWiped } doReturn MutableSharedFlow()
+ on { walletIndex } doReturn 0
+ on { state } doReturn MutableStateFlow(SparkState())
+ }
+ private val work = mock()
+
+ @Before
+ fun setUp() {
+ Env.initAppStoragePath(tempFolder.root.absolutePath)
+ }
+
+ private fun createSut(
+ sparkService: SparkService = mock(),
+ httpClient: HttpClient = mock(),
+ keychain: Keychain = mock(),
+ coreService: CoreService = mock(),
+ ) = SparkExitRepo(
+ work = work,
+ bgDispatcher = testDispatcher,
+ sparkService = sparkService,
+ sparkRepo = sparkRepo,
+ lightningRepo = mock(),
+ transferRepo = transferRepo,
+ activityRepo = mock(),
+ coreService = coreService,
+ keychain = keychain,
+ httpClient = httpClient,
+ clock = object : Clock {
+ override fun now() = Instant.fromEpochSeconds(1_800_000_000)
+ },
+ sparkExitTip = SparkExitTip(),
+ cacheStore = mock { on { data } doReturn flowOf(AppCacheData()) },
+ )
+
+ private fun transfer(id: String, type: TransferType = TransferType.SPARK_EXIT) =
+ TransferEntity(id = id, type = type, amountSats = 111_000L, createdAt = 0L)
+
+ @Test
+ fun `load settles exit transfers no running exit owns and resumes the running one`() = test {
+ SparkExitStore(File(Env.sparkExitRecordPath(0))).save(SparkExitFixtures.record())
+ whenever(transferRepo.activeTransfers).thenReturn(
+ flowOf(listOf(transfer("transfer-1"), transfer("orphan"), transfer("close", TransferType.FORCE_CLOSE))),
+ )
+ whenever(transferRepo.isSettled(any())).thenReturn(Result.success(false))
+ whenever(transferRepo.markSettled(any())).thenReturn(Result.success(Unit))
+
+ val record = createSut().load(0).getOrThrow()
+
+ assertEquals("transfer-1", record?.transferId)
+ verify(transferRepo).markSettled("orphan")
+ verify(transferRepo, never()).markSettled("transfer-1")
+ verify(transferRepo, never()).markSettled("close")
+ verify(work).schedulePeriodic()
+ }
+
+ @Test
+ fun `load settles the transfer of an unreadable record it sets aside`() = test {
+ val file = File(Env.sparkExitRecordPath(0)).apply {
+ parentFile?.mkdirs()
+ writeText("{")
+ }
+ whenever(transferRepo.activeTransfers).thenReturn(flowOf(listOf(transfer("transfer-1"))))
+ whenever(transferRepo.markSettled(any())).thenReturn(Result.success(Unit))
+
+ val record = createSut().load(0).getOrThrow()
+
+ assertEquals(null, record)
+ verify(transferRepo).markSettled("transfer-1")
+ verify(work, never()).schedulePeriodic()
+ assertEquals(false, file.exists())
+ }
+
+ @Test
+ fun `leaves lost to an older state outlive the exit record that found them`() = test {
+ val recordFile = File(Env.sparkExitRecordPath(0))
+ val lost = SparkExitRecord.LostLeaf("leaf-1", 65_536uL)
+ SparkExitStore(recordFile).save(
+ SparkExitFixtures.record(Phase.Failed(SparkExitDriver.LOST_REASON)).copy(lostLeaves = listOf(lost)),
+ )
+ whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList()))
+ whenever(transferRepo.isSettled(any())).thenReturn(Result.success(true))
+ createSut().load(0).getOrThrow()
+
+ // A new exit replaces the record.
+ SparkExitStore(recordFile).save(SparkExitFixtures.record().copy(leafIds = listOf("leaf-2")))
+ createSut().load(0).getOrThrow()
+
+ assertEquals(setOf("leaf-1"), SparkExitLostLeafStore(File(Env.sparkExitLostLeavesPath(0))).load())
+ }
+
+ /** Spark's regtest mempool, where nothing is confirmed yet and every single transaction is accepted. */
+ private fun regtestMempool(posts: MutableList, refusal: String? = null) = HttpClient(
+ MockEngine { request ->
+ val url = request.url.toString()
+ when {
+ request.method == HttpMethod.Post && url.endsWith("/api/tx") -> {
+ posts += String(request.body.toByteArray())
+ if (refusal != null) return@MockEngine respond(refusal, HttpStatusCode.BadRequest)
+ respond(String(request.body.toByteArray()).removePrefix("02"))
+ }
+ url.endsWith("/blocks/tip/height") -> respond("1000")
+ url.endsWith("/status") -> respond("""{"confirmed":false}""")
+ else -> throw IOException("unreachable '$url'")
+ }
+ },
+ ) { defaultRequest { contentType(ContentType.Application.Json) } }
+
+ /** Spark in a process the worker woke: not connected until the run starts it, which never touches the node. */
+ private suspend fun coldSpark(sdk: FakeSparkExitRecovering, connects: Boolean = true): SparkService {
+ var isConnected = false
+ whenever(sparkRepo.start(any())).thenAnswer {
+ isConnected = connects
+ Result.success(Unit)
+ }
+ return mock {
+ on { networkName } doReturn "regtest"
+ on { this.isConnected } doAnswer { isConnected }
+ on { exitRecovering() } doAnswer { sdk.takeIf { isConnected } }
+ }
+ }
+
+ @Test
+ fun `a background run from a cold start drives the exit and a restart sends nothing twice`() = test {
+ val store = SparkExitStore(File(Env.sparkExitRecordPath(0)))
+ val recovery = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1")))
+ store.save(SparkExitFixtures.record(Phase.Running).adopt(recovery, 0L))
+ whenever(transferRepo.activeTransfers).thenReturn(flowOf(listOf(transfer("transfer-1"))))
+ whenever(transferRepo.isSettled(any())).thenReturn(Result.success(false))
+ whenever(transferRepo.updateClaimableAtHeight(any(), anyOrNull())).thenReturn(Result.success(Unit))
+ val posts = mutableListOf()
+
+ // First run: the keyless pass publishes from the stored recovery, then Spark connects and checks it.
+ val sdk = FakeSparkExitRecovering().apply {
+ checks += CheckRecoverFundsResponse(recovery, RecoveryVerdict.Valid)
+ }
+ createSut(coldSpark(sdk), regtestMempool(posts)).runInBackground().getOrThrow()
+
+ assertEquals(listOf("02n1"), posts)
+ assertEquals(1, sdk.checkedRecoveries.size)
+ assertEquals(listOf("n1"), store.load()?.broadcastLog?.filter { it.isAccepted }?.map { it.txid })
+ verify(work, atLeastOnce()).schedulePeriodic()
+ verify(work, atLeastOnce()).armDeadline(any(), any())
+
+ // The app was killed: a new process runs again within the rebroadcast interval.
+ val restarted = FakeSparkExitRecovering().apply {
+ checks += CheckRecoverFundsResponse(recovery, RecoveryVerdict.Valid)
+ }
+ createSut(coldSpark(restarted), regtestMempool(posts)).runInBackground().getOrThrow()
+
+ assertEquals(listOf("02n1"), posts)
+ assertEquals(1, restarted.checkedRecoveries.size)
+ }
+
+ @Test
+ fun `a background run whose steps keep being refused lets the deadline alarm fire`() = test {
+ val store = SparkExitStore(File(Env.sparkExitRecordPath(0)))
+ val recovery = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1")))
+ store.save(SparkExitFixtures.record(Phase.Running).adopt(recovery, 0L))
+ whenever(transferRepo.activeTransfers).thenReturn(flowOf(listOf(transfer("transfer-1"))))
+ whenever(transferRepo.isSettled(any())).thenReturn(Result.success(false))
+ whenever(transferRepo.updateClaimableAtHeight(any(), anyOrNull())).thenReturn(Result.success(Unit))
+ val posts = mutableListOf()
+ val refusal = """sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met"}"""
+
+ repeat(SparkExitDriver.REJECTIONS_TO_SURFACE) {
+ // The operators are down: Spark does not connect, and the keyless pass publishes alone.
+ val spark = coldSpark(FakeSparkExitRecovering(), connects = false)
+ createSut(spark, regtestMempool(posts, refusal)).runInBackground().getOrThrow()
+ }
+
+ assertEquals(3, posts.size)
+ assertEquals(3, store.load()?.broadcastLog?.single()?.rejections)
+ // The first two refusals still count as runs that kept up; the third surfaces and stops pushing the alarm.
+ verify(work, times(2)).armDeadline(any(), any())
+ }
+
+ @Test
+ fun `a background run whose step keeps going unpaid lets the deadline alarm fire`() = test {
+ val store = SparkExitStore(File(Env.sparkExitRecordPath(0)))
+ val recovery = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1")))
+ store.save(SparkExitFixtures.record(Phase.Running).adopt(recovery, 0L))
+ whenever(transferRepo.activeTransfers).thenReturn(flowOf(listOf(transfer("transfer-1"))))
+ whenever(transferRepo.isSettled(any())).thenReturn(Result.success(false))
+ whenever(transferRepo.updateClaimableAtHeight(any(), anyOrNull())).thenReturn(Result.success(Unit))
+ val posts = mutableListOf()
+ val refusal = """sendrawtransaction RPC error: {"code":-26,"message":"min relay fee not met, 0 < 17"}"""
+
+ repeat(SparkExitDriver.FEE_CHAIN_FAILURES_TO_REBUILD) {
+ // Only the SDK can rebuild the fee chain, and Spark does not connect.
+ val spark = coldSpark(FakeSparkExitRecovering(), connects = false)
+ createSut(spark, regtestMempool(posts, refusal)).runInBackground().getOrThrow()
+ }
+
+ val attempt = store.load()?.broadcastLog?.single()
+ assertEquals(0, attempt?.rejections)
+ assertEquals(3, attempt?.feeChainFailures)
+ verify(work, times(2)).armDeadline(any(), any())
+ }
+
+ @Test
+ fun `a tick that leaves the exit short of fees asks for at least dust at once`() = test {
+ val store = SparkExitStore(File(Env.sparkExitRecordPath(0)))
+ val recovery = SparkExitFixtures.recovery(listOf(SparkExitFixtures.tx("n1")))
+ val funded = "f0".repeat(32)
+ store.save(
+ SparkExitFixtures.record(Phase.Running).adopt(recovery, 0L).copy(
+ funding = listOf(
+ SparkExitRecord.Funding(0u, SparkExitFixtures.FUNDING_ADDRESS, "02ab", funded, 0u, 9_035uL)
+ ),
+ ),
+ )
+ whenever(transferRepo.activeTransfers).thenReturn(flowOf(listOf(transfer("transfer-1"))))
+ whenever(transferRepo.isSettled(any())).thenReturn(Result.success(false))
+ whenever(transferRepo.updateClaimableAtHeight(any(), anyOrNull())).thenReturn(Result.success(Unit))
+ val utxo = """[{"txid":"$funded","vout":0,"value":9035,"status":{"confirmed":true,"block_height":900}}]"""
+ val mempool = HttpClient(
+ MockEngine { request ->
+ val url = request.url.toString()
+ when {
+ request.method == HttpMethod.Post && url.endsWith("/api/tx") -> respond("n1")
+ url.endsWith("/address/${SparkExitFixtures.FUNDING_ADDRESS}/utxo") -> respond(utxo)
+ url.endsWith("/blocks/tip/height") -> respond("1000")
+ url.endsWith("/status") -> respond("""{"confirmed":false}""")
+ else -> throw IOException("unreachable '$url'")
+ }
+ },
+ ) { defaultRequest { contentType(ContentType.Application.Json) } }
+ // The qa-03 run: the rebuild needs 68 sats more than the address holds.
+ val sdk = FakeSparkExitRecovering().apply {
+ val redo = RecoveryVerdict.Redo(RecoveryRedoReason.ON_CHAIN_STATE_DIVERGED)
+ checks += CheckRecoverFundsResponse(recovery, redo)
+ preparedResponse = SparkExitFixtures.prepared(funding = null)
+ recoverError = SdkException.InsufficientCpfpFunds(9_103uL, "")
+ }
+ val sut = createSut(coldSpark(sdk), mempool)
+
+ sut.runInBackground().getOrThrow()
+
+ assertEquals(Phase.NeedsFunding(9_103uL), sut.state.value.record?.phase)
+ val ask = assertIs(sut.state.value.fundingState)
+ assertEquals(SparkExitMath.FUNDING_DUST_SATS, ask.sats)
+ }
+
+ @Test
+ fun `a background run of an ended exit with leftover to sweep keeps going without starting spark`() = test {
+ val store = SparkExitStore(File(Env.sparkExitRecordPath(0)))
+ store.save(
+ SparkExitFixtures.record(Phase.Done).copy(leftover = listOf(SparkExitRecord.Leftover("f0", 0u, 5_035uL))),
+ )
+ whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList()))
+ whenever(transferRepo.isSettled(any())).thenReturn(Result.success(true))
+ whenever(transferRepo.updateClaimableAtHeight(any(), anyOrNull())).thenReturn(Result.success(Unit))
+ val spark = coldSpark(FakeSparkExitRecovering())
+
+ createSut(spark, regtestMempool(mutableListOf())).runInBackground().getOrThrow()
+
+ verify(sparkRepo, never()).start(any())
+ verify(work, atLeastOnce()).schedulePeriodic()
+ verify(work, never()).cancelAll()
+ }
+
+ @Test
+ fun `a restarted exit signs with the funding key of its own record`() = test {
+ val address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"
+ val store = SparkExitStore(File(Env.sparkExitRecordPath(0)))
+ store.save(
+ SparkExitFixtures.record(Phase.Done).copy(
+ destination = "bcrt1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqc8gma6",
+ funding = listOf(SparkExitRecord.Funding(2u, address, "02ab")),
+ leftover = listOf(SparkExitRecord.Leftover("f0".repeat(32), 0u, 5_035uL)),
+ ),
+ )
+ whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList()))
+ whenever(transferRepo.isSettled(any())).thenReturn(Result.success(true))
+ val utxo = """[{"txid":"${"f0".repeat(32)}","vout":0,"value":5035,"status":{"confirmed":true}}]"""
+ val mempool = HttpClient(
+ MockEngine { request ->
+ val url = request.url.toString()
+ when {
+ url.endsWith("/address/$address/utxo") -> respond(utxo)
+ url.endsWith("/blocks/tip/height") -> respond("1000")
+ else -> throw IOException("unreachable '$url'")
+ }
+ },
+ )
+ val keychain = mock { on { loadString(any()) } doReturn "mnemonic" }
+ val onchain = mock()
+ whenever(onchain.deriveBitcoinAddress(any(), anyOrNull(), anyOrNull(), anyOrNull()))
+ .thenThrow(IllegalStateException("stub"))
+ val coreService = mock { on { this.onchain } doReturn onchain }
+ val spark = mock { on { networkName } doReturn "regtest" }
+
+ createSut(spark, mempool, keychain, coreService).runInBackground().getOrThrow()
+
+ verify(
+ onchain
+ ).deriveBitcoinAddress(any(), eq(SparkExitFundingKey.path(2, Env.network)), anyOrNull(), anyOrNull())
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitSweepTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitSweepTest.kt
new file mode 100644
index 0000000000..f77451d55f
--- /dev/null
+++ b/app/src/test/java/to/bitkit/repositories/SparkExitSweepTest.kt
@@ -0,0 +1,90 @@
+package to.bitkit.repositories
+
+import org.junit.Test
+import to.bitkit.test.BaseUnitTest
+import to.bitkit.utils.SignedTransactionId
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SparkExitSweepTest : BaseUnitTest() {
+ private val funding = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080"
+ private val savings = "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0"
+
+ @Test
+ fun `segwit addresses become the script they pay`() {
+ assertEquals(
+ "0014751e76e8199196d454941c45d1b3a323f1433bd6",
+ Bech32.scriptPubKey("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4").toHexString(),
+ )
+ assertEquals(
+ "512079be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ Bech32.scriptPubKey(savings).toHexString(),
+ )
+ assertFailsWith { Bech32.scriptPubKey("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5") }
+ assertEquals(
+ "76a91477bff20c60e522dfaa3350c39b030a5d004e839a88ac",
+ AddressScript.of("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2").toHexString(),
+ )
+ assertEquals(
+ "a914b472a266d0bd89c13706a4132ccfb16f7c3b9fcb87",
+ AddressScript.of("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy").toHexString(),
+ )
+ assertFailsWith { AddressScript.of("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3") }
+ }
+
+ @Test
+ fun `dust limits follow bitcoin core per output type`() {
+ assertEquals(294uL, SparkExitSweep.dustLimit(AddressScript.of("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4")))
+ assertEquals(330uL, SparkExitSweep.dustLimit(AddressScript.of(savings)))
+ assertEquals(540uL, SparkExitSweep.dustLimit(AddressScript.of("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy")))
+ assertEquals(546uL, SparkExitSweep.dustLimit(AddressScript.of("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2")))
+ }
+
+ @Test
+ fun `a sweep pays every input less its fee, and none that would leave dust`() {
+ val inputs = listOf(SparkExitSweep.Input("aa".repeat(32), 1u, 5_035uL))
+
+ val plan = assertNotNull(SparkExitSweep.plan(inputs, savings, feeRateSatPerVbyte = 2uL))
+
+ // 11 overhead + 68 for the input + 9 + 34 for the P2TR output, at 2 sat/vB.
+ assertEquals(244uL, plan.feeSats)
+ assertEquals(4_791uL, plan.amountSats)
+ // 500 less 244 of fee is under P2TR's dust limit of 330.
+ assertNull(SparkExitSweep.plan(listOf(inputs.first().copy(valueSats = 500uL)), savings, 2uL))
+ assertNull(SparkExitSweep.plan(emptyList(), savings, 2uL))
+ }
+
+ @Test
+ fun `the signed transaction carries the signer's witness and keeps the unsigned txid`() = test {
+ val plan = assertNotNull(
+ SparkExitSweep.plan(
+ listOf(
+ SparkExitSweep.Input("11".repeat(32), 0u, 3_000uL),
+ SparkExitSweep.Input("22".repeat(32), 2u, 2_035uL),
+ ),
+ savings,
+ 1uL,
+ ),
+ )
+ val psbt = SparkExitSweep.psbt(plan, funding)
+ val witness = StubCpfpSigner.P2WPKH_WITNESS
+
+ val hex = SparkExitSweep.signedTxHex(plan, StubCpfpSigner().signPsbt(psbt))
+
+ assertTrue(hex.startsWith("02000000" + "0001" + "02" + "11".repeat(32) + "00000000" + "00" + "fdffffff"))
+ assertTrue(hex.endsWith(witness + witness + "00000000"))
+ val unsigned = "02000000" + hex.substring(12, hex.indexOf(witness)) + "00000000"
+ assertEquals(SignedTransactionId.fromHex(unsigned), SignedTransactionId.fromHex(hex))
+ }
+
+ @Test
+ fun `an input the signer left unsigned is refused`() {
+ val inputs = listOf(SparkExitSweep.Input("11".repeat(32), 0u, 3_000uL))
+ val plan = assertNotNull(SparkExitSweep.plan(inputs, savings, 1uL))
+
+ assertFailsWith { SparkExitSweep.signedTxHex(plan, SparkExitSweep.psbt(plan, funding)) }
+ }
+}
diff --git a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt
index dc56da0703..da525bce67 100644
--- a/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt
+++ b/app/src/test/java/to/bitkit/repositories/SparkRepoTest.kt
@@ -1,5 +1,6 @@
package to.bitkit.repositories
+import breez_sdk_spark.AutoOptimizationEvent
import breez_sdk_spark.BitcoinAddressDetails
import breez_sdk_spark.Bolt11InvoiceDetails
import breez_sdk_spark.ClaimDeferredReason
@@ -26,9 +27,13 @@ import breez_sdk_spark.SparkHtlcStatus
import com.synonym.bitkitcore.Activity
import com.synonym.bitkitcore.OnchainActivity
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runCurrent
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -106,6 +111,10 @@ class SparkRepoTest : BaseUnitTest() {
whenever { activityRepo.upsertActivity(any()) }.thenReturn(Result.success(Unit))
whenever(sparkService.isSupported).thenReturn(true)
whenever(sparkService.networkName).thenReturn("regtest")
+ whenever { cacheStore.update(any()) }.thenAnswer {
+ val transform = it.getArgument<(AppCacheData) -> AppCacheData>(0)
+ cacheData.update(transform)
+ }
}
@Test
@@ -124,6 +133,28 @@ class SparkRepoTest : BaseUnitTest() {
assertFalse(File(Env.sparkExitRestorePath(0)).exists())
}
+ @Test
+ fun `an exit quote waits for a leaf optimization and the counter-swap of one cut short`() = test {
+ whenever(sparkService.isConnected).thenReturn(true)
+ val sut = createSut()
+ sut.start(0).getOrThrow()
+ sparkEvents.emit(SdkEvent.AutoOptimization(AutoOptimizationEvent.Started(3u)))
+
+ val settled = async { sut.settleForExit() }
+ runCurrent()
+ verify(sparkService, never()).syncWallet()
+
+ // Cut short: the SDK claims its counter-swap on a sync only after a 300 s grace.
+ sparkEvents.emit(SdkEvent.AutoOptimization(AutoOptimizationEvent.Cancelled))
+ advanceTimeBy(5.minutes)
+ runCurrent()
+ verify(sparkService, never()).syncWallet()
+
+ advanceTimeBy(1.minutes)
+ settled.await().getOrThrow()
+ verify(sparkService).syncWallet()
+ }
+
@Test
fun `records a send's fee once a sync reports it`() = test {
whenever(sparkService.isConnected).thenReturn(true)
diff --git a/app/src/test/java/to/bitkit/services/SparkServiceTest.kt b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt
index cb005cedc8..871cf61f7c 100644
--- a/app/src/test/java/to/bitkit/services/SparkServiceTest.kt
+++ b/app/src/test/java/to/bitkit/services/SparkServiceTest.kt
@@ -63,7 +63,7 @@ class SparkServiceTest {
@Test
fun `config turns off breez data sync and the lightning address server on every network`() {
Network.entries.forEach { network ->
- val config = SparkService.configure(defaults(network), apiKey = "key")
+ val config = SparkService.configure(defaults(network), apiKey = "key", isExiting = false)
assertNull(config.realTimeSyncServerUrl, "$network")
assertNull(config.lnurlDomain, "$network")
diff --git a/app/src/test/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModelTest.kt
index d5e0e15fd1..a594d2f6fe 100644
--- a/app/src/test/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModelTest.kt
+++ b/app/src/test/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModelTest.kt
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.advanceUntilIdle
import org.junit.Before
import org.junit.Test
+import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import to.bitkit.R
@@ -19,6 +20,8 @@ import to.bitkit.repositories.BlocktankRepo
import to.bitkit.repositories.LightningRepo
import to.bitkit.repositories.LightningState
import to.bitkit.repositories.LogsRepo
+import to.bitkit.repositories.SparkExitRepo
+import to.bitkit.repositories.SparkExitState
import to.bitkit.repositories.SparkRepo
import to.bitkit.repositories.SparkState
import to.bitkit.test.BaseUnitTest
@@ -79,5 +82,6 @@ class LightningConnectionsViewModelTest : BaseUnitTest() {
logsRepo = logsRepo,
activityRepo = activityRepo,
sparkRepo = sparkRepo,
+ sparkExitRepo = mock { on { state } doReturn MutableStateFlow(SparkExitState()) },
)
}
diff --git a/app/src/test/java/to/bitkit/ui/settings/lightning/SparkExitViewModelTest.kt b/app/src/test/java/to/bitkit/ui/settings/lightning/SparkExitViewModelTest.kt
new file mode 100644
index 0000000000..9a8489ac87
--- /dev/null
+++ b/app/src/test/java/to/bitkit/ui/settings/lightning/SparkExitViewModelTest.kt
@@ -0,0 +1,105 @@
+package to.bitkit.ui.settings.lightning
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.Test
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import to.bitkit.repositories.SparkExitFixtures
+import to.bitkit.repositories.SparkExitFundingState
+import to.bitkit.repositories.SparkExitMath
+import to.bitkit.repositories.SparkExitQuote
+import to.bitkit.repositories.SparkExitRecord
+import to.bitkit.repositories.SparkExitRepo
+import to.bitkit.repositories.SparkExitState
+import to.bitkit.repositories.SparkRepo
+import to.bitkit.repositories.SparkState
+import to.bitkit.test.BaseUnitTest
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class SparkExitViewModelTest : BaseUnitTest() {
+ private val exitState = MutableStateFlow(SparkExitState())
+ private val sparkState = MutableStateFlow(SparkState())
+ private val repo = mock { on { state } doReturn exitState }
+ private val sparkRepo = mock { on { state } doReturn sparkState }
+
+ @Test
+ fun `entering the funding step again offers both choices while nothing was paid`() = test {
+ whenever(repo.refreshFundingAddress()).thenReturn(Result.success(0uL))
+ val sut = SparkExitViewModel(repo, sparkRepo)
+ sut.onFundExternally()
+ sut.stopWatchingFundingAddress()
+
+ sut.onFundingShown()
+
+ assertFalse(sut.uiState.value.showsFundingAddress)
+ assertFalse(sut.uiState.value.fundFromSavings)
+ }
+
+ @Test
+ fun `entering the funding step keeps the address once a payment reached it`() = test {
+ whenever(repo.refreshFundingAddress()).thenReturn(Result.success(5_000uL))
+ val sut = SparkExitViewModel(repo, sparkRepo)
+
+ sut.onFundingShown()
+
+ assertTrue(sut.uiState.value.showsFundingAddress)
+ sut.stopWatchingFundingAddress()
+ }
+
+ @Test
+ fun `funding from another wallet continues only once the address holds what the quote asks`() {
+ val quote = SparkExitQuote(SparkExitFixtures.prepared(funding = 20_000uL))
+ assertFalse(SparkExitUiState().canContinueFunding)
+ assertFalse(SparkExitUiState(quote = quote, fundingSeenSats = 8_000uL).canContinueFunding)
+ assertTrue(SparkExitUiState(quote = quote, fundingSeenSats = 20_000uL).canContinueFunding)
+ }
+
+ @Test
+ fun `progress asks for at least dust before the funding address was read`() {
+ val record = SparkExitFixtures.record(SparkExitRecord.Phase.NeedsFunding(68uL))
+
+ assertEquals(
+ SparkExitFundingState.Ask(SparkExitMath.FUNDING_DUST_SATS),
+ SparkExitUiState(record = record).progressFunding,
+ )
+ }
+
+ /** The qa-03 rerun, case 5: one 32,768-sat leaf lost to an older owner, 1,696 sats Spark could not exit. */
+ @Test
+ fun `a stopped exit names what spark's live balance holds beyond its leaves`() = test {
+ val stopped = SparkExitFixtures.record(SparkExitRecord.Phase.Failed("Claimed by an older state")).copy(
+ leafTotalSats = 32_768uL,
+ lostLeaves = listOf(SparkExitRecord.LostLeaf("leaf-1", 32_768uL)),
+ )
+ exitState.value = SparkExitState(record = stopped)
+ val sut = SparkExitViewModel(repo, sparkRepo)
+
+ sparkState.value = SparkState(balanceSats = 34_464uL)
+
+ assertEquals(1_696uL, sut.uiState.value.staysOnSparkSats)
+ assertNull(sut.uiState.value.outsideExitSats)
+ assertEquals(
+ "Nothing could be recovered. ₿ 1 696 stays on Spark.",
+ sparkExitFailedText("Nothing could be recovered.", "{amount} stays on Spark.", outsideAmount = "₿ 1 696"),
+ )
+ // Only the lost leaf is left on Spark's books: nothing is claimed to stay.
+ sparkState.value = SparkState(balanceSats = 32_768uL)
+ assertNull(sut.uiState.value.staysOnSparkSats)
+ }
+
+ @Test
+ fun `a stopped exit says only what still stays on spark`() {
+ val nothing = "Nothing could be recovered."
+ val stays = "{amount} stays on Spark."
+
+ assertEquals(nothing, sparkExitFailedText(nothing, stays, outsideAmount = null))
+ assertEquals(
+ "Nothing could be recovered. ₿ 3 000 stays on Spark.",
+ sparkExitFailedText(nothing, stays, outsideAmount = "₿ 3 000"),
+ )
+ }
+}
diff --git a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
index ac1d2b580f..a19c89a289 100644
--- a/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
+++ b/app/src/test/java/to/bitkit/viewmodels/AppViewModelSendFlowTest.kt
@@ -971,6 +971,7 @@ class AppViewModelSendFlowTest : BaseUnitTest() {
widgetsRepo = widgetsRepo,
pubkyRepo = pubkyRepo,
timeSource = testDispatcher.scheduler.timeSource,
+ sparkExitRepo = mock(),
)
private suspend fun emitNodeEvent(
diff --git a/app/src/test/resources/spark-exit-record-ios.json b/app/src/test/resources/spark-exit-record-ios.json
new file mode 100644
index 0000000000..b349fb2e8d
--- /dev/null
+++ b/app/src/test/resources/spark-exit-record-ios.json
@@ -0,0 +1 @@
+{"broadcastLog":[{"attempts":2,"generation":1,"isAccepted":true,"lastAttemptAt":1700000100000,"lastOutcome":"accepted","rejections":0,"txid":"aa"}],"createdAt":1700000000123.456,"destination":"bcrt1qdestination","expectedSats":111000,"failedLeafCount":0,"feeRateSatPerVbyte":2,"funding":[{"address":"bcrt1qfunding","index":0,"pubkey":"02ab","txid":"f0","valueSats":20000,"vout":1}],"generation":1,"isRebuilding":false,"lastBuildAt":1700000050000,"leafIds":["leaf-1"],"leftover":[],"network":"regtest","phase":{"running":{}},"recovery":{"cooperative_fee_sats":0,"cpfp_fee_sats":7000,"destination":"bcrt1qdestination","failed":[],"fanout_fee_sats":500,"fee_rate_sat_per_vbyte":2,"funding_inputs":[{"P2wpkh":{"pubkey":"02ab","txid":"f0","value_sats":20000,"vout":1}}],"leaves":[{"leaf_id":"leaf-1","method":"Unilateral","value_sats":100000}],"recoverable_value_sats":100000,"refund_fee_sats":1000,"sweep_fee_sats":500,"total_fee_sats":9000,"transactions":[{"cpfp_tx_hex":"03bb","csv_timelock_blocks":null,"depends_on":[],"kind":"Node","node_id":"n1","status":"Ready","tx_hex":"02aa","txid":"aa"},{"cpfp_tx_hex":"03dd","csv_timelock_blocks":2000,"depends_on":["aa"],"kind":"Refund","node_id":"leaf-1","status":{"WaitingForTimelock":{"spendable_at_height":3100}},"tx_hex":"02cc","txid":"cc"}]},"skippedLeafCount":1,"transferId":"transfer-1","version":1}
diff --git a/changelog.d/next/1697.added.md b/changelog.d/next/1697.added.md
index e1e6a1d79d..9c2f388681 100644
--- a/changelog.d/next/1697.added.md
+++ b/changelog.d/next/1697.added.md
@@ -1 +1 @@
-Spending keeps Lightning without channels: transfers from Savings now fund a Spark wallet that sends and receives Lightning payments.
+Spending keeps Lightning without channels: transfers from Savings now fund a Spark wallet that sends and receives Lightning payments, with an emergency exit that moves its balance on-chain without the Spark operators.
diff --git a/docs/screens-map.md b/docs/screens-map.md
index b46984af02..58e64ae08b 100644
--- a/docs/screens-map.md
+++ b/docs/screens-map.md
@@ -317,6 +317,11 @@ Frame names are stable across handoff iterations; node ids are not, so the map l
| CloseConnectionScreen.kt | Settings > Advanced › Close connection |
| LightningConnectionsScreen.kt | Settings > Advanced › Lightning Connections overview |
| SparkDetailScreen.kt | `todo` |
+| SparkExitConfirmScreen.kt | `todo` |
+| SparkExitFundingScreen.kt | `todo` |
+| SparkExitIntroScreen.kt | `todo` |
+| SparkExitProgressScreen.kt | `todo` |
+| SparkExitQuoteScreen.kt | `todo` |
## ui/settings/pin
diff --git a/journeys/README.md b/journeys/README.md
index 93e69007f4..21a47c4612 100644
--- a/journeys/README.md
+++ b/journeys/README.md
@@ -198,6 +198,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691):
| `backup/confirm-mnemonic-clear-wrong-word.xml` | not ported — `BackupConfirmMnemonic.swift` clears only the last word by its chip, with no red-word tap |
| `coin-selection/manual-coin-selection-load.xml` | not ported — iOS has `SendUtxoSelectionView` but no load error, retry or identifiers to assert on |
| `transfer/spending-confirm-amount-change.xml` | same file — both platforms show the same "Fees changed" info toast; closing the received sheet re-sizes iOS's Spending Confirm, so the lower amounts can show before the swipe |
+| `transfer/spark-emergency-exit.xml` | not ported yet — iOS ships the same screens and identifiers in synonymdev/bitkit-ios#1128, without a journey |
| `transfer/savings-select-funds.xml` | same file and steps — iOS runs it as J8 on synonymdev/bitkit-ios#1128 |
| — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS |
diff --git a/journeys/transfer/spark-emergency-exit.xml b/journeys/transfer/spark-emergency-exit.xml
new file mode 100644
index 0000000000..827922da94
--- /dev/null
+++ b/journeys/transfer/spark-emergency-exit.xml
@@ -0,0 +1,30 @@
+
+
+ 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 SparkExitDriverTest, SparkExitRepoTest (a background run from a cold start) and the
+ SparkExitProgressScreen previews, which show the notes on battery and notification settings.
+
+ 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
+
+