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(""), "") ?: 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 @@ Esperar Depósitos reclamados Depó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. Proveedor Spark mediante Breez Sin 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 @@ Espera Dipòsits reclamats Dipò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ïdor Spark mitjançant Breez Sense 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čkat Připsané vklady Probí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. Poskytovatel Spark přes Breez Bez 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 @@ Warten Eingelöste Einzahlungen Laufende 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. Anbieter Spark über Breez Kein 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 @@ Esperar Depósitos reclamados Depó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. Proveedor Spark mediante Breez Sin 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 @@ Esperar Depósitos reclamados Depó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. Proveedor Spark mediante Breez Sin 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 @@ Attendre Dépôts réclamés Dé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. Fournisseur Spark via Breez Sans 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 @@ Attendi Depositi riscattati Depositi 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. Fornitore Spark tramite Breez Nessun 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 @@ Wachten Geclaimde stortingen Lopende 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. Aanbieder Spark via Breez Geen 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 @@ Czekaj Odebrane wpłaty Wpł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. Dostawca Spark przez Breez Bez 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 @@ Esperar Depósitos resgatados Depó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. Provedor Spark via Breez Sem 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 @@ Esperar Depósitos resgatados Depó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. Fornecedor Spark via Breez Sem 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 @@ Wait Claimed deposits Deposits 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. Provider Spark via Breez No 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 + +