From a92ee4381b0d1e0af421d9c49665a2edabbcd38e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 02:51:15 +0100 Subject: [PATCH 1/6] feat: spark emergency exit --- .../bitkit/services/SparkExitRegtestTest.kt | 110 +++ app/src/main/AndroidManifest.xml | 9 + app/src/main/java/to/bitkit/App.kt | 5 + .../main/java/to/bitkit/data/CacheStore.kt | 11 + .../java/to/bitkit/data/dao/TransferDao.kt | 3 + app/src/main/java/to/bitkit/env/Env.kt | 6 + app/src/main/java/to/bitkit/ext/Context.kt | 4 + .../java/to/bitkit/models/TransferType.kt | 7 +- .../to/bitkit/repositories/SparkExitChain.kt | 276 ++++++ .../to/bitkit/repositories/SparkExitDriver.kt | 535 +++++++++++ .../bitkit/repositories/SparkExitFunding.kt | 347 +++++++ .../to/bitkit/repositories/SparkExitRecord.kt | 788 ++++++++++++++++ .../to/bitkit/repositories/SparkExitRepo.kt | 566 ++++++++++++ .../to/bitkit/repositories/SparkExitSweep.kt | 255 ++++++ .../to/bitkit/repositories/SparkExitWorker.kt | 347 +++++++ .../java/to/bitkit/repositories/SparkRepo.kt | 122 ++- .../to/bitkit/repositories/TransferRepo.kt | 38 +- .../java/to/bitkit/repositories/WalletRepo.kt | 6 +- .../java/to/bitkit/services/SparkService.kt | 35 +- app/src/main/java/to/bitkit/ui/ContentView.kt | 88 +- .../screens/transfer/SavingsAdvancedScreen.kt | 2 +- .../screens/transfer/SavingsConfirmScreen.kt | 2 +- .../bitkit/ui/screens/wallets/HomeScreen.kt | 2 +- .../screens/wallets/receive/ReceiveSheet.kt | 2 +- .../LightningConnectionsViewModel.kt | 43 +- .../settings/lightning/SparkDetailScreen.kt | 15 +- .../lightning/SparkDetailViewModel.kt | 11 +- .../settings/lightning/SparkExitComponents.kt | 181 ++++ .../lightning/SparkExitConfirmScreen.kt | 100 +++ .../lightning/SparkExitFundingScreen.kt | 143 +++ .../lightning/SparkExitIntroScreen.kt | 111 +++ .../lightning/SparkExitProgressScreen.kt | 331 +++++++ .../lightning/SparkExitQuoteScreen.kt | 121 +++ .../settings/lightning/SparkExitViewModel.kt | 219 +++++ .../utils/RequestNotificationPermissions.kt | 2 +- .../usecases/DeriveBalanceStateUseCase.kt | 13 +- .../bitkit/utils/BatterySettingsSnapshot.kt | 4 + .../java/to/bitkit/viewmodels/AppViewModel.kt | 7 + .../to/bitkit/viewmodels/TransferViewModel.kt | 2 +- app/src/main/res/values-ar/strings.xml | 71 ++ app/src/main/res/values-b+es+419/strings.xml | 71 ++ app/src/main/res/values-ca/strings.xml | 71 ++ app/src/main/res/values-cs/strings.xml | 71 ++ app/src/main/res/values-de/strings.xml | 71 ++ app/src/main/res/values-el/strings.xml | 71 ++ app/src/main/res/values-es-rES/strings.xml | 71 ++ app/src/main/res/values-es/strings.xml | 71 ++ app/src/main/res/values-fr/strings.xml | 71 ++ app/src/main/res/values-it/strings.xml | 71 ++ app/src/main/res/values-nl/strings.xml | 71 ++ app/src/main/res/values-pl/strings.xml | 71 ++ app/src/main/res/values-pt-rBR/strings.xml | 71 ++ app/src/main/res/values-pt/strings.xml | 71 ++ app/src/main/res/values-ru/strings.xml | 71 ++ app/src/main/res/values/strings.xml | 71 ++ .../repositories/SparkExitAlertsTest.kt | 80 ++ .../bitkit/repositories/SparkExitChainTest.kt | 198 ++++ .../repositories/SparkExitDriverTest.kt | 843 ++++++++++++++++++ .../bitkit/repositories/SparkExitFixtures.kt | 216 +++++ .../repositories/SparkExitFundingTest.kt | 312 +++++++ .../repositories/SparkExitRecordTest.kt | 248 ++++++ .../repositories/SparkExitRegtestTest.kt | 51 ++ .../bitkit/repositories/SparkExitRepoTest.kt | 268 ++++++ .../bitkit/repositories/SparkExitSweepTest.kt | 90 ++ .../to/bitkit/repositories/SparkRepoTest.kt | 31 + .../to/bitkit/services/SparkServiceTest.kt | 2 +- .../LightningConnectionsViewModelTest.kt | 4 + .../lightning/SparkExitViewModelTest.kt | 63 ++ .../viewmodels/AppViewModelSendFlowTest.kt | 1 + .../test/resources/spark-exit-record-ios.json | 1 + changelog.d/next/1697.added.md | 2 +- docs/screens-map.md | 5 + journeys/README.md | 1 + journeys/transfer/spark-emergency-exit.xml | 30 + 74 files changed, 8404 insertions(+), 47 deletions(-) create mode 100644 app/src/androidTest/java/to/bitkit/services/SparkExitRegtestTest.kt create mode 100644 app/src/main/java/to/bitkit/repositories/SparkExitChain.kt create mode 100644 app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt create mode 100644 app/src/main/java/to/bitkit/repositories/SparkExitFunding.kt create mode 100644 app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt create mode 100644 app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt create mode 100644 app/src/main/java/to/bitkit/repositories/SparkExitSweep.kt create mode 100644 app/src/main/java/to/bitkit/repositories/SparkExitWorker.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitComponents.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitConfirmScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitFundingScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitIntroScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitProgressScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitQuoteScreen.kt create mode 100644 app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitViewModel.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SparkExitAlertsTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SparkExitChainTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SparkExitFundingTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SparkExitRecordTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SparkExitRegtestTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt create mode 100644 app/src/test/java/to/bitkit/repositories/SparkExitSweepTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/settings/lightning/SparkExitViewModelTest.kt create mode 100644 app/src/test/resources/spark-exit-record-ios.json create mode 100644 journeys/transfer/spark-emergency-exit.xml 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..53baea558e 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -240,6 +240,12 @@ 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() + } + 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..4c3d4e4728 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkExitChain.kt @@ -0,0 +1,276 @@ +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 + 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) + } + + 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) + + /** 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 + } + + 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..99193a71eb --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt @@ -0,0 +1,535 @@ +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 +} + +/** + * Moves an exit one step forward each tick: builds it once its fee funding confirms, broadcasts what is ready, + * rebuilds when the chain diverged, and finishes when everything confirmed. Without the SDK (a background 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 + + /** 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) + } + return record to prepared + } + } + + /** + * 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 -> broadcastReady(updated) + 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, + ) + val rebuilding = updated.copy(isRebuilding = true) + save(rebuilding) + if (!isRebuildDue(rebuilding)) return rebuilding + build(rebuilding, sdk) + } + } + } + + /** + * 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. + */ + 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 + Logger.info("Broadcast Spark exit '${tx.kind}' '${tx.txid}': '${outcome.summary}'", context = TAG) + val isAccepted = outcome == SparkExitBroadcastOutcome.Accepted + val rejection = if (outcome is SparkExitBroadcastOutcome.Rejected) 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, + ) ?: SparkExitRecord.BroadcastAttempt( + generation = record.generation, + txid = tx.txid, + attempts = 1, + rejections = rejection, + lastAttemptAt = nowMillis(), + lastOutcome = outcome.summary, + isAccepted = isAccepted, + isInputSpent = isInputSpent, + ) + 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 + +/** 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() } + } +} + +/** 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..8779121101 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkExitFunding.kt @@ -0,0 +1,347 @@ +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 } + } + + /** 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..541f972963 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt @@ -0,0 +1,788 @@ +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(), +) { + 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, + ) + + 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() + } +} + +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) + }, + ) + } + }, + ) + 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.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, + ) + }, + 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")) + }, + ) + + 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..0c637a1b92 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt @@ -0,0 +1,566 @@ +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 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) } + 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, + ) + val updated = driver.tick(current) + _state.update { it.copy(record = updated, lastError = null) } + // 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 + afterTick(updated, current, isOnTrack) + return updated + } + + private suspend fun afterTick(record: SparkExitRecord, previous: SparkExitRecord, isOnTrack: Boolean) { + record.transferId?.let { transferRepo.updateClaimableAtHeight(it, record.claimableAtHeight) } + settleIfFinished(record) + updateAlerts(record, previous, isOnTrack) + 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) { + SparkExitAlerts.losses(record, previous).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() + } + } + + /** 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() + val prepared = recovering.prepare( + PrepareRecoverFundsRequest( + feeRateSatPerVbyte = feeRate, + fundingKind = CpfpFundingKind.P2wpkh, + destination = destination, + selection = ExitLeafSelection.All, + ), + ) + 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..df2b540dc4 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/SparkExitWorker.kt @@ -0,0 +1,347 @@ +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. + */ + fun losses(record: SparkExitRecord, previous: SparkExitRecord): List { + val phase = record.phase + return listOfNotNull( + SparkExitAlert.Lost.takeIf { record.lostLeaves.size > previous.lostLeaves.size }, + 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..c630dd7dac 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,89 @@ 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) } _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 +292,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 +423,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 +683,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 +778,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..61c091e11a --- /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, + outsideExitSats = uiState.outsideExitSats, + ).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?, + outsideExitSats: 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 = outsideExitSats?.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..02c8cf870a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitViewModel.kt @@ -0,0 +1,219 @@ +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.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import to.bitkit.repositories.SparkExitBackgroundLimits +import to.bitkit.repositories.SparkExitFundingState +import to.bitkit.repositories.SparkExitQuote +import to.bitkit.repositories.SparkExitRecord +import to.bitkit.repositories.SparkExitRepo +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, +) : 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 { + sparkExitRepo.state.collect { exit -> + _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, + 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, + /** 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 until the funding address was read. */ + val progressFunding: SparkExitFundingState? + get() { + val shortfall = record?.fundingShortfallSats ?: return null + return fundingState ?: SparkExitFundingState.Ask(shortfall) + } +} + +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..c1d32a8a0e --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkExitAlertsTest.kt @@ -0,0 +1,80 @@ +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)) + } +} 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..0613bb8b56 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkExitChainTest.kt @@ -0,0 +1,198 @@ +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.assertIs + +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)) + } +} 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..93da862114 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt @@ -0,0 +1,843 @@ +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 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" + } + + @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("min relay fee not met") + 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: min relay fee not met", 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 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) + } + + @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..d2883d8a0d --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt @@ -0,0 +1,216 @@ +package to.bitkit.repositories + +import breez_sdk_spark.CheckRecoverFundsResponse +import breez_sdk_spark.CooperativeRecoveryFailure +import breez_sdk_spark.CpfpInput +import breez_sdk_spark.CpfpSigner +import breez_sdk_spark.ExitChainState +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 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) + } + + 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() + + /** 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 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() + + override suspend fun prepare(request: PrepareRecoverFundsRequest): PrepareRecoverFundsResponse { + prepareRequests += request + prepareErrors.removeFirstOrNull()?.let { throw it } + return 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..96e4df4980 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkExitRecordTest.kt @@ -0,0 +1,248 @@ +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 + ), + ), + 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")), + ) + + 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) + 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..304164ee64 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt @@ -0,0 +1,268 @@ +package to.bitkit.repositories + +import breez_sdk_spark.CheckRecoverFundsResponse +import breez_sdk_spark.RecoveryVerdict +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.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()) + } + + /** 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 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..8e4969ac71 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/settings/lightning/SparkExitViewModelTest.kt @@ -0,0 +1,63 @@ +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.SparkExitQuote +import to.bitkit.repositories.SparkExitRepo +import to.bitkit.repositories.SparkExitState +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SparkExitViewModelTest : BaseUnitTest() { + private val repo = mock { on { state } doReturn MutableStateFlow(SparkExitState()) } + + @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) + 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) + + 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 `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 + + From 2ed467377ad530e876051d78bd7767f39bf82183 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 04:12:02 +0100 Subject: [PATCH 2/6] fix: rebuild a spark exit whose fee chain lost a race When another wallet's package confirms a shared tree node, our CPFP child for it is dropped and every later step that spends its change can never confirm, while the SDK check still answers Valid. Detect that child on a Valid check and rebuild from the same funding. As a safety net, a step unpaid or missing inputs 3 times in a row rebuilds once, and those sends count as no rejection until then. --- .../to/bitkit/repositories/SparkExitChain.kt | 33 ++++ .../to/bitkit/repositories/SparkExitDriver.kt | 116 ++++++++++++- .../to/bitkit/repositories/SparkExitRecord.kt | 10 ++ .../bitkit/repositories/SparkExitChainTest.kt | 28 ++++ .../repositories/SparkExitDriverTest.kt | 154 +++++++++++++++++- .../bitkit/repositories/SparkExitFixtures.kt | 17 ++ .../repositories/SparkExitRecordTest.kt | 5 +- 7 files changed, 351 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitChain.kt b/app/src/main/java/to/bitkit/repositories/SparkExitChain.kt index 4c3d4e4728..55a1678c4d 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitChain.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitChain.kt @@ -65,6 +65,12 @@ 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`. */ @@ -92,6 +98,8 @@ class EsploraSparkExitChain( } private val NOT_HERE = setOf(404, 405) + + private const val NOT_FOUND = 404 } override suspend fun tipHeight(): UInt = get("/blocks/tip/height").trim().toUInt() @@ -110,6 +118,22 @@ class EsploraSparkExitChain( 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() @@ -262,6 +286,15 @@ object SparkExitBroadcastClassifier { 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) diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt b/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt index 99193a71eb..1f691b6f11 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt @@ -28,8 +28,9 @@ interface SparkExitRecovering { /** * Moves an exit one step forward each tick: builds it once its fee funding confirms, broadcasts what is ready, - * rebuilds when the chain diverged, and finishes when everything confirmed. Without the SDK (a background start, or - * operators down at connect) it still broadcasts the stored, already signed transactions whose parents confirmed. + * 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. */ @@ -50,6 +51,9 @@ class SparkExitDriver( /** 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 @@ -221,21 +225,78 @@ class SparkExitDriver( 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 -> broadcastReady(updated) + 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, ) - val rebuilding = updated.copy(isRebuilding = true) - save(rebuilding) - if (!isRebuildDue(rebuilding)) return rebuilding - build(rebuilding, sdk) + 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, @@ -352,7 +413,8 @@ class SparkExitDriver( /** * 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. + * 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 @@ -424,9 +486,14 @@ class SparkExitDriver( ): 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 - val rejection = if (outcome is SparkExitBroadcastOutcome.Rejected) 1 else 0 + // 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, @@ -435,6 +502,7 @@ class SparkExitDriver( 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, @@ -444,6 +512,7 @@ class SparkExitDriver( lastOutcome = outcome.summary, isAccepted = isAccepted, isInputSpent = isInputSpent, + feeChainFailures = if (isStuck) 1 else 0, ) val log = if (existing == null) { record.broadcastLog + entry @@ -468,6 +537,35 @@ object SparkExitTxids { } } +/** 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" diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt b/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt index 541f972963..90e7453329 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt @@ -79,6 +79,8 @@ data class SparkExitRecord( 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 @@ -135,6 +137,8 @@ data class SparkExitRecord( 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( @@ -358,6 +362,7 @@ object SparkExitJson { put("lastOutcome", it.lastOutcome) put("isAccepted", it.isAccepted) if (it.isInputSpent) put("isInputSpent", true) + if (it.feeChainFailures > 0) put("feeChainFailures", it.feeChainFailures) }, ) } @@ -409,6 +414,9 @@ object SparkExitJson { 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", @@ -474,6 +482,7 @@ object SparkExitJson { lastOutcome = b.string("lastOutcome"), isAccepted = b.booleanOrNull("isAccepted") ?: false, isInputSpent = b.booleanOrNull("isInputSpent") ?: false, + feeChainFailures = b.intOrNull("feeChainFailures") ?: 0, ) }, transferId = o.stringOrNull("transferId"), @@ -512,6 +521,7 @@ object SparkExitJson { 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) { diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitChainTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitChainTest.kt index 0613bb8b56..cc067ff19e 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitChainTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitChainTest.kt @@ -16,7 +16,10 @@ 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" @@ -195,4 +198,29 @@ class SparkExitChainTest : BaseUnitTest() { 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 index 93da862114..013e5b254a 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt @@ -18,6 +18,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import to.bitkit.repositories.SparkExitRecord.Phase import to.bitkit.test.BaseUnitTest +import to.bitkit.utils.SignedTransactionId import java.io.File import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -31,6 +32,7 @@ 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 @@ -167,7 +169,7 @@ class SparkExitDriverTest : BaseUnitTest() { 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("min relay fee not met") + chain.outcome = SparkExitBroadcastOutcome.Rejected("insufficient fee, rejecting replacement") repeat(3) { sdk.checks += check(recovery, RecoveryVerdict.Valid) } record = driver().tick(record) @@ -175,7 +177,7 @@ class SparkExitDriverTest : BaseUnitTest() { repeat(2) { record = driver().tick(record) } assertEquals(3, chain.broadcasts.size) - assertEquals("rejected: min relay fee not met", record.surfacedRejection) + assertEquals("rejected: insufficient fee, rejecting replacement", record.surfacedRejection) } @Test @@ -830,6 +832,154 @@ class SparkExitDriverTest : BaseUnitTest() { 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"))) diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt b/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt index d2883d8a0d..55eaeb9b42 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt @@ -109,6 +109,14 @@ object SparkExitFixtures { "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)) } @@ -119,6 +127,10 @@ class FakeSparkExitChain : SparkExitChain { 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 @@ -135,6 +147,11 @@ class FakeSparkExitChain : SparkExitChain { 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 diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitRecordTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitRecordTest.kt index 96e4df4980..d7803d814a 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitRecordTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitRecordTest.kt @@ -86,7 +86,8 @@ class SparkExitRecordTest { 1L, "inputs missing", false, - isInputSpent = true + isInputSpent = true, + feeChainFailures = 2, ), ), claimableAtHeight = 2_100u, @@ -99,6 +100,7 @@ class SparkExitRecordTest { 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))) @@ -193,6 +195,7 @@ class SparkExitRecordTest { 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"] From f541952b5dc652a82239ea42933a1cdc37317b99 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 04:20:11 +0100 Subject: [PATCH 3/6] fix: alarm a spark exit stuck on an unpaid step Unpaid sends no longer count as rejections, so without the SDK a step whose fee chain lost a race never surfaced and every background run looked on track. A step unpaid 3 times in a row now lets the deadline alarm fire. --- .../to/bitkit/repositories/SparkExitDriver.kt | 9 ++++++++ .../to/bitkit/repositories/SparkExitRepo.kt | 2 +- .../bitkit/repositories/SparkExitRepoTest.kt | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt b/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt index 1f691b6f11..a3755fc0e7 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt @@ -529,6 +529,15 @@ val SparkExitRecord.surfacedRejection: String? 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 = diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt index 0c637a1b92..b9248597f6 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt @@ -207,7 +207,7 @@ class SparkExitRepo @Inject constructor( // 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 + val isOnTrack = tip != null && canMove && updated.surfacedRejection == null && !updated.isFeeChainStuck afterTick(updated, current, isOnTrack) return updated } diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt index 304164ee64..2e539e95e0 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt @@ -210,6 +210,29 @@ class SparkExitRepoTest : BaseUnitTest() { 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 background run of an ended exit with leftover to sweep keeps going without starting spark`() = test { val store = SparkExitStore(File(Env.sparkExitRecordPath(0))) From 0baad3ff888f1b92b681e0b45bec65bbcc634334 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 04:33:27 +0100 Subject: [PATCH 4/6] fix: floor the spark exit funding ask at dust The funding state was read before the tick, so a tick that ended short of fees showed the raw shortfall (68 sats on qa-03) until the next tick. Read it again after a tick that changed the phase, and floor the fallback ask at the 294-sat dust limit. --- .../to/bitkit/repositories/SparkExitRepo.kt | 2 + .../settings/lightning/SparkExitViewModel.kt | 8 +++- .../bitkit/repositories/SparkExitRepoTest.kt | 47 +++++++++++++++++++ .../lightning/SparkExitViewModelTest.kt | 13 +++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt index b9248597f6..73654e44c6 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt @@ -202,6 +202,8 @@ class SparkExitRepo @Inject constructor( ) val updated = driver.tick(current) _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 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 index 02c8cf870a..83cd128cff 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitViewModel.kt @@ -15,6 +15,7 @@ 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 @@ -208,11 +209,14 @@ data class SparkExitUiState( val rejection: String? get() = record?.surfacedRejection - /** The fee funding progress shows, asking for the whole shortfall until the funding address was read. */ + /** + * 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(shortfall) + return fundingState ?: SparkExitFundingState.Ask(maxOf(shortfall, SparkExitMath.FUNDING_DUST_SATS)) } } diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt index 2e539e95e0..cf786ff30c 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt @@ -1,7 +1,9 @@ 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 @@ -43,6 +45,7 @@ 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 @@ -233,6 +236,50 @@ class SparkExitRepoTest : BaseUnitTest() { 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))) 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 index 8e4969ac71..68daeeb642 100644 --- a/app/src/test/java/to/bitkit/ui/settings/lightning/SparkExitViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/lightning/SparkExitViewModelTest.kt @@ -6,7 +6,10 @@ 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.test.BaseUnitTest @@ -49,6 +52,16 @@ class SparkExitViewModelTest : BaseUnitTest() { 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, + ) + } + @Test fun `a stopped exit says only what still stays on spark`() { val nothing = "Nothing could be recovered." From 4df5c75f4a031729b35a0436171fa76cdd9cdfb4 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 05:21:16 +0100 Subject: [PATCH 5/6] fix: name what a stopped spark exit leaves on spark The stopped result read the amount outside the exit, which holds only while the exit runs, from a balance kept only during SDK ticks. It now reads Spark's live balance beyond the exit's leaves in any phase, as iOS does. --- .../bitkit/repositories/SparkExitFunding.kt | 7 ++++ .../lightning/SparkExitProgressScreen.kt | 6 +-- .../settings/lightning/SparkExitViewModel.kt | 39 ++++++++++++------- .../lightning/SparkExitViewModelTest.kt | 35 +++++++++++++++-- 4 files changed, 67 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitFunding.kt b/app/src/main/java/to/bitkit/repositories/SparkExitFunding.kt index 8779121101..f00fea449b 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitFunding.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitFunding.kt @@ -198,6 +198,13 @@ object SparkExitMath { 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 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 index 61c091e11a..09c665b752 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitProgressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitProgressScreen.kt @@ -101,7 +101,7 @@ private fun Content( text = progressText( record = record, progressFunding = uiState.progressFunding, - outsideExitSats = uiState.outsideExitSats, + staysOnSparkSats = uiState.staysOnSparkSats, ).withAccentBoldBright(), color = Colors.White64, ) @@ -274,7 +274,7 @@ private fun progressTitle(phase: SparkExitRecord.Phase?): String = stringResourc private fun progressText( record: SparkExitRecord?, progressFunding: SparkExitFundingState?, - outsideExitSats: ULong?, + staysOnSparkSats: ULong?, ): String = when (record?.phase) { SparkExitRecord.Phase.Done -> listOfNotNull( stringResource(R.string.lightning__spark_exit__done_text), @@ -288,7 +288,7 @@ private fun progressText( is SparkExitRecord.Phase.Failed -> sparkExitFailedText( nothingText = stringResource(R.string.lightning__spark_exit__failed_text), staysText = stringResource(R.string.lightning__spark_exit__failed_stays), - outsideAmount = outsideExitSats?.let { sparkExitAmountText(it) }, + 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. 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 index 83cd128cff..4a10af579a 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/SparkExitViewModel.kt @@ -10,6 +10,7 @@ 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 @@ -19,6 +20,8 @@ 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 @@ -28,6 +31,7 @@ import kotlin.time.Duration.Companion.seconds @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. */ @@ -44,20 +48,25 @@ class SparkExitViewModel @Inject constructor( init { viewModelScope.launch { - sparkExitRepo.state.collect { exit -> - _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, - isSettling = exit.isSettling, - ) - } - } + // 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, + ) } } @@ -189,6 +198,8 @@ data class SparkExitUiState( 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, ) { 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 index 68daeeb642..9a8489ac87 100644 --- a/app/src/test/java/to/bitkit/ui/settings/lightning/SparkExitViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/settings/lightning/SparkExitViewModelTest.kt @@ -12,18 +12,24 @@ 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 repo = mock { on { state } doReturn MutableStateFlow(SparkExitState()) } + 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) + val sut = SparkExitViewModel(repo, sparkRepo) sut.onFundExternally() sut.stopWatchingFundingAddress() @@ -36,7 +42,7 @@ class SparkExitViewModelTest : BaseUnitTest() { @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) + val sut = SparkExitViewModel(repo, sparkRepo) sut.onFundingShown() @@ -62,6 +68,29 @@ class SparkExitViewModelTest : BaseUnitTest() { ) } + /** 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." From 8eac6db67fb16733bede43fe1a9b214fdd9db419 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 5 Nov 2026 11:07:52 +0100 Subject: [PATCH 6/6] fix: keep lost spark exit leaves out of later exits --- app/src/main/java/to/bitkit/env/Env.kt | 7 ++ .../to/bitkit/repositories/SparkExitDriver.kt | 42 +++++++++++- .../to/bitkit/repositories/SparkExitRecord.kt | 26 ++++++++ .../to/bitkit/repositories/SparkExitRepo.kt | 41 ++++++++++-- .../to/bitkit/repositories/SparkExitWorker.kt | 12 +++- .../java/to/bitkit/repositories/SparkRepo.kt | 2 + .../repositories/SparkExitAlertsTest.kt | 14 ++++ .../repositories/SparkExitDriverTest.kt | 65 +++++++++++++++++++ .../bitkit/repositories/SparkExitFixtures.kt | 14 +++- .../bitkit/repositories/SparkExitRepoTest.kt | 18 +++++ 10 files changed, 230 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 53baea558e..234701f6e8 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -246,6 +246,13 @@ internal object Env { 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/repositories/SparkExitDriver.kt b/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt index a3755fc0e7..eb691cc0c4 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitDriver.kt @@ -26,6 +26,42 @@ interface SparkExitRecovering { 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 @@ -204,7 +240,11 @@ class SparkExitDriver( ids.filter { it !in listed }.forEach { record = record.leaveBehind(it, SparkExitUnexitable.NOT_RECOVERABLE) } - return record to prepared + 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))) } } diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt b/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt index 90e7453329..1703b17d12 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitRecord.kt @@ -293,6 +293,32 @@ class SparkExitStore(private val file: File) { } } +/** + * 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'") diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt index 73654e44c6..d8c2dfcc40 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt @@ -101,6 +101,8 @@ class SparkExitRepo @Inject constructor( 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() } @@ -121,6 +123,7 @@ class SparkExitRepo @Inject constructor( } isLoaded = true _state.update { it.copy(record = record) } + rememberLostLeaves(record) record?.let { settleIfFinished(it) } settleOrphanedTransfers(record) if (record?.needsDriving == true) work.schedulePeriodic() @@ -200,7 +203,10 @@ class SparkExitRepo @Inject constructor( 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) @@ -210,14 +216,19 @@ class SparkExitRepo @Inject constructor( // 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) + afterTick(updated, current, isOnTrack, reportedLost) return updated } - private suspend fun afterTick(record: SparkExitRecord, previous: SparkExitRecord, isOnTrack: Boolean) { + 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) + updateAlerts(record, previous, isOnTrack, reportedLost) if (!record.isActive && previous.isActive) { Logger.info("Ended Spark exit with phase '${record.phase}'", context = TAG) sparkRepo.setExiting(false) @@ -284,8 +295,13 @@ class SparkExitRepo @Inject constructor( * 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) { - SparkExitAlerts.losses(record, previous).forEach { + 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) { @@ -306,6 +322,16 @@ class SparkExitRepo @Inject constructor( } } + 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() @@ -321,13 +347,16 @@ class SparkExitRepo @Inject constructor( 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() - val prepared = recovering.prepare( + // 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. diff --git a/app/src/main/java/to/bitkit/repositories/SparkExitWorker.kt b/app/src/main/java/to/bitkit/repositories/SparkExitWorker.kt index df2b540dc4..cae829f698 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkExitWorker.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkExitWorker.kt @@ -248,12 +248,18 @@ 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. + * 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): List { + 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.size > previous.lostLeaves.size }, + 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 diff --git a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt index c630dd7dac..a907433ce0 100644 --- a/app/src/main/java/to/bitkit/repositories/SparkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/SparkRepo.kt @@ -177,6 +177,8 @@ class SparkRepo @Inject constructor( .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) } diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitAlertsTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitAlertsTest.kt index c1d32a8a0e..3420bb9229 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitAlertsTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitAlertsTest.kt @@ -77,4 +77,18 @@ class SparkExitAlertsTest { 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/SparkExitDriverTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt index 013e5b254a..65b0285ef1 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitDriverTest.kt @@ -18,6 +18,8 @@ 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 @@ -808,6 +810,69 @@ class SparkExitDriverTest : BaseUnitTest() { 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"))) diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt b/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt index 55eaeb9b42..b657798247 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitFixtures.kt @@ -2,9 +2,11 @@ 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 @@ -86,6 +88,13 @@ object SparkExitFixtures { 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, @@ -178,10 +187,13 @@ class FakeSparkExitRecovering : SparkExitRecovering { /** 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 preparedResponse + return preparedResponses.removeFirstOrNull() ?: preparedResponse } override suspend fun recover(request: RecoverFundsRequest, signer: CpfpSigner?): RecoverFundsResponse { diff --git a/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt b/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt index cf786ff30c..441bec38e2 100644 --- a/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/SparkExitRepoTest.kt @@ -127,6 +127,24 @@ class SparkExitRepoTest : BaseUnitTest() { 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 ->