From 2eb40e7a5bebd3b795884aed75a7b31bf3e3672f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 10:09:30 -0300 Subject: [PATCH 01/11] build: add bark dependency and signet flavor Co-Authored-By: Claude Opus 5 (1M context) --- app/build.gradle.kts | 15 +++++++++++++++ gradle/libs.versions.toml | 2 ++ settings.gradle.kts | 2 ++ 3 files changed, 19 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29aed331ff..0c2b32ccbe 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -214,6 +214,19 @@ android { manifestPlaceholders["app_icon"] = "@mipmap/ic_launcher_testnet" manifestPlaceholders["app_icon_round"] = "@mipmap/ic_launcher_testnet_round" } + // Signet is the only test network with a Second-hosted Ark server, so this + // flavor exists to exercise the bark spending backend. It deliberately + // reuses the testnet applicationId (and therefore icons) so the checked-in + // google-services.json resolves without a signet Firebase client; the + // trade-off is that signet and tnet cannot be installed side by side. + create("signet") { + dimension = "network" + applicationIdSuffix = ".tnet" + buildConfigField("String", "NETWORK", "\"SIGNET\"") + resValue("string", "app_name", "Bitkit Signet") + manifestPlaceholders["app_icon"] = "@mipmap/ic_launcher_testnet" + manifestPlaceholders["app_icon_round"] = "@mipmap/ic_launcher_testnet_round" + } } signingConfigs { @@ -385,6 +398,8 @@ dependencies { implementation(libs.bitkit.core) implementation(libs.paykit) implementation(libs.vss.client) + // bark declares jna 5.15.0; keep the single app-wide jna aar declared above + implementation(libs.bark) { exclude(group = "net.java.dev.jna", module = "jna") } nativeDebugSymbols(libs.bitkit.core.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.ldk.node.android.nativeDebugSymbolsArtifact()) nativeDebugSymbols(libs.paykit.nativeDebugSymbolsArtifact()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62ea5f1487..92197ae5fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,8 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss activity-compose = { module = "androidx.activity:activity-compose", version = "1.12.2" } appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } +# Second's bark (Ark) Kotlin/UniFFI bindings. Version format: +bark- +bark = { module = "tech.second.bark:bark-android", version = "0.12.2+bark-0.4.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.5.5" } paykit = { module = "com.synonym:paykit-android", version = "0.1.0-rc40" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9790eeb9eb..ec81a8c397 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -71,6 +71,8 @@ dependencyResolutionManagement { password = pass } } + // Second's bark (Ark) bindings; public registry, no credentials required + maven { url = uri("https://gitlab.com/api/v4/projects/78057981/packages/maven") } } } rootProject.name = "bitkit-android" From 4159f02211b4f0200aa6ce12e305c8444ef9bc3b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 10:20:35 -0300 Subject: [PATCH 02/11] feat: add ark env config and signet network support Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/to/bitkit/async/ServiceQueue.kt | 2 +- app/src/main/java/to/bitkit/env/Env.kt | 38 +++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/async/ServiceQueue.kt b/app/src/main/java/to/bitkit/async/ServiceQueue.kt index ea3b4ad0a8..08aeb73a20 100644 --- a/app/src/main/java/to/bitkit/async/ServiceQueue.kt +++ b/app/src/main/java/to/bitkit/async/ServiceQueue.kt @@ -13,7 +13,7 @@ import java.util.concurrent.ThreadFactory import kotlin.coroutines.CoroutineContext enum class ServiceQueue { - LDK, CORE, FOREX, LOG, MIGRATION; + LDK, ARK, CORE, FOREX, LOG, MIGRATION; private val scope by lazy { CoroutineScope(newSingleThreadDispatcher(name) + SupervisorJob()) } diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index ba86bb38ad..ac22eb9eb6 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -69,7 +69,7 @@ internal object Env { if (isLocalE2eBackend) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG } Network.TESTNET -> ElectrumServers.TESTNET - else -> TODO("${network.name} network not implemented") + Network.SIGNET -> ElectrumServers.SIGNET } } @@ -88,6 +88,28 @@ internal object Env { else -> null } + /** + * Second's Ark server, used by the bark spending backend. Only mainnet and signet are hosted; + * there is no public regtest or testnet Ark server. + */ + val arkServerUrl + get() = when (network) { + Network.BITCOIN -> "https://ark.second.tech" + Network.SIGNET -> "https://ark.signet.2nd.dev" + else -> null + } + + /** Esplora chain source for bark. Must stay on the same chain as [electrumServerUrl]. */ + val arkEsploraUrl + get() = when (network) { + Network.BITCOIN -> "https://mempool.second.tech/api" + Network.SIGNET -> "https://esplora.signet.2nd.dev" + else -> null + } + + /** Whether the bark spending backend can be offered on this build's network. */ + val isArkSupported get() = arkServerUrl != null + val vssStoreIdPrefix get() = "bitkit_v1_${network.name.lowercase()}" val vssServerUrl @@ -105,7 +127,7 @@ internal object Env { val blockExplorerUrl get() = when (network) { Network.BITCOIN -> "https://mempool.space" - Network.SIGNET -> "https://mutinynet.com" + Network.SIGNET -> "https://mempool.space/signet" Network.TESTNET -> "https://mempool.space/testnet" Network.REGTEST -> "https://mempool.bitkit.stag0.blocktank.to/" } @@ -187,8 +209,8 @@ internal object Env { val isE2eLocal = isE2eTest && e2eBackend == "local" return when (network) { BitkitCoreNetwork.BITCOIN -> ElectrumServers.MAINNET.ESPLORA - BitkitCoreNetwork.TESTNET, BitkitCoreNetwork.TESTNET4, BitkitCoreNetwork.SIGNET -> - ElectrumServers.TESTNET + BitkitCoreNetwork.SIGNET -> ElectrumServers.SIGNET + BitkitCoreNetwork.TESTNET, BitkitCoreNetwork.TESTNET4 -> ElectrumServers.TESTNET BitkitCoreNetwork.REGTEST -> if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG } @@ -218,6 +240,8 @@ internal object Env { return storagePathOf(walletIndex, network.name.lowercase(), "core") } + fun arkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ark") + /** * Generates the storage path for a specified wallet index, network, and directory. * @@ -289,4 +313,10 @@ private object ElectrumServers { } const val TESTNET = "ssl://electrum.blockstream.info:60002" + + /** + * Public signet Electrum. Must stay on the same chain as [Env.arkEsploraUrl]: Second's Ark + * signet is the standard public signet, not a custom one such as mutinynet. + */ + const val SIGNET = "ssl://mempool.space:60602" } From d74a81eed6a2698f53b449dad3840bc67b6f0425 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 10:26:23 -0300 Subject: [PATCH 03/11] feat: add bark service wrapping ark wallet Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/services/BarkService.kt | 284 ++++++++++++++++++ app/src/main/java/to/bitkit/utils/Errors.kt | 15 + 2 files changed, 299 insertions(+) create mode 100644 app/src/main/java/to/bitkit/services/BarkService.kt diff --git a/app/src/main/java/to/bitkit/services/BarkService.kt b/app/src/main/java/to/bitkit/services/BarkService.kt new file mode 100644 index 0000000000..b9e86a2afe --- /dev/null +++ b/app/src/main/java/to/bitkit/services/BarkService.kt @@ -0,0 +1,284 @@ +package to.bitkit.services + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import to.bitkit.async.BaseCoroutineScope +import to.bitkit.async.ServiceQueue +import to.bitkit.data.keychain.Keychain +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching +import to.bitkit.utils.BarkError +import to.bitkit.utils.Logger +import to.bitkit.utils.ServiceError +import uniffi.bark.ArkInfo +import uniffi.bark.Balance +import uniffi.bark.Config +import uniffi.bark.FeeEstimate +import uniffi.bark.LightningInvoice +import uniffi.bark.LightningReceive +import uniffi.bark.LightningSendStatus +import uniffi.bark.Movement +import uniffi.bark.Network +import uniffi.bark.OnchainBalance +import uniffi.bark.OnchainWallet +import uniffi.bark.PendingBoard +import uniffi.bark.Vtxo +import uniffi.bark.Wallet +import uniffi.bark.WalletNotification +import uniffi.bark.WalletOpenArgs +import uniffi.bark.generateMnemonic +import uniffi.bark.validateArkAddress +import javax.inject.Inject +import javax.inject.Singleton +import org.lightningdevkit.ldknode.Network as LdkNetwork + +/** + * Thin wrapper over bark's UniFFI [Wallet], shaped after [LightningService] so both backends read + * the same way. Every call into rust runs on the dedicated [ServiceQueue.ARK] thread. + * + * bark keeps its own BDK on-chain wallet, separate from the ldk-node wallet that backs savings. + * That wallet is only used to fund boards, so it is exposed through [onchainAddress] and + * [onchainBalance] rather than being surfaced as a user-facing balance. + */ +@Suppress("TooManyFunctions") +@Singleton +class BarkService @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val keychain: Keychain, +) : BaseCoroutineScope(bgDispatcher, TAG) { + + companion object { + private const val TAG = "BarkService" + } + + @Volatile + var wallet: Wallet? = null + private set + + @Volatile + private var onchain: OnchainWallet? = null + + val isRunning: Boolean get() = wallet != null + + // region lifecycle + + suspend fun start(walletIndex: Int) = withContext(bgDispatcher) { + if (wallet != null) { + Logger.debug("Ark wallet already started", context = TAG) + return@withContext + } + val serverUrl = Env.arkServerUrl ?: throw BarkError.NotSupported(Env.network.name) + val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) + ?: throw ServiceError.MnemonicNotFound() + if (!keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name).isNullOrBlank()) { + throw BarkError.PassphraseUnsupported() + } + + val datadir = Env.arkStoragePath(walletIndex) + val config = barkConfig(serverUrl) + val network = Env.network.toBarkNetwork() + + Logger.info("Starting Ark wallet on '${network.name}' via '$serverUrl'", context = TAG) + ServiceQueue.ARK.background { + val onchainWallet = OnchainWallet.default(network, mnemonic, config, datadir) + onchain = onchainWallet + wallet = Wallet.open( + network = network, + mnemonicOrSeed = mnemonic, + config = config, + args = WalletOpenArgs(datadir = datadir, onchain = onchainWallet), + ) + } + Logger.info("Started Ark wallet", context = TAG) + } + + suspend fun stop() = withContext(bgDispatcher) { + val current = wallet ?: run { + Logger.debug("Ark wallet already stopped", context = TAG) + return@withContext + } + Logger.debug("Stopping Ark wallet…", context = TAG) + ServiceQueue.ARK.background { + runSuspendCatching { current.stopDaemon() } + .onFailure { Logger.warn("Failed to stop Ark daemon", it, context = TAG) } + } + wallet = null + onchain = null + Logger.info("Stopped Ark wallet", context = TAG) + } + + // endregion + + // region sync + + suspend fun sync() = call { it.sync() } + + suspend fun maintenance() = call { it.maintenance() } + + /** + * The mobile refresh path: co-signers refresh on the wallet's behalf, so the app does not have + * to be online during a round window. + */ + suspend fun maintenanceDelegated() = call { it.maintenanceDelegated() } + + suspend fun syncPendingBoards() = call { it.syncPendingBoards() } + + suspend fun syncExits() = call { it.syncExits() } + + // endregion + + // region balance and vtxos + + suspend fun balance(): Balance = call { it.balance() } + + suspend fun spendableVtxos(): List = call { it.spendableVtxos() } + + suspend fun vtxosToRefresh(): List = call { it.getVtxosToRefresh() } + + suspend fun nextRequiredRefreshBlockheight(): UInt? = call { it.getNextRequiredRefreshBlockheight() } + + suspend fun firstExpiringVtxoBlockheight(): UInt? = call { it.getFirstExpiringVtxoBlockheight() } + + suspend fun hasPendingExits(): Boolean = call { it.hasPendingExits() } + + // endregion + + // region receive + + suspend fun newArkAddress(): String = call { it.newAddress() } + + suspend fun bolt11Invoice(amountSats: ULong, description: String): LightningInvoice = + call { it.bolt11Invoice(amountSats, description, null) } + + suspend fun pendingLightningReceives(): List = call { it.pendingLightningReceives() } + + suspend fun tryClaimAllLightningReceives(wait: Boolean = false): List = + call { it.tryClaimAllLightningReceives(wait) } + + // endregion + + // region send + + suspend fun payLightningInvoice(invoice: String, amountSats: ULong?, wait: Boolean = true): LightningSendStatus = + call { it.payLightningInvoice(invoice, amountSats, wait) } + + suspend fun sendArkoorPayment(arkAddress: String, amountSats: ULong) = + call { it.sendArkoorPayment(arkAddress, amountSats) } + + suspend fun sendOnchain(address: String, amountSats: ULong): String = + call { it.sendOnchain(address, amountSats) } + + // endregion + + // region board and offboard + + suspend fun boardAmount(amountSats: ULong): PendingBoard = call { it.boardAmount(amountSats) } + + suspend fun pendingBoards(): List = call { it.pendingBoards() } + + suspend fun offboardAll(bitcoinAddress: String): String = call { it.offboardAll(bitcoinAddress).roundId } + + suspend fun estimateBoardFee(amountSats: ULong): FeeEstimate = call { it.estimateBoardFee(amountSats) } + + suspend fun estimateOffboardAllFee(bitcoinAddress: String): FeeEstimate = + call { it.estimateOffboardAllFee(bitcoinAddress) } + + // endregion + + // region bark's own onchain wallet (board funding only) + + suspend fun onchainAddress(): String = onchainCall { it.newAddress() } + + suspend fun onchainBalance(): OnchainBalance = onchainCall { it.balance() } + + suspend fun syncOnchain() = onchainCall { it.sync() } + + // endregion + + // region info + + suspend fun arkInfo(): ArkInfo? = call { it.arkInfo() } + + // endregion + + // region history + + suspend fun history(): List = call { it.history() } + + // endregion + + // region notifications + + /** + * bark exposes notifications as a single-consumer pull loop rather than a callback, so the + * holder is drained on the Ark queue and republished as a flow. + */ + fun notifications(): Flow = callbackFlow { + val holder = (wallet ?: throw BarkError.NotStarted()).notifications() + val job = launch { + while (isActive) { + val next = runSuspendCatching { ServiceQueue.ARK.background { holder.nextNotification() } } + .onFailure { Logger.warn("Failed to read Ark notification", it, context = TAG) } + .getOrNull() ?: continue + send(next) + } + } + awaitClose { + holder.cancelNextNotificationWait() + job.cancel() + } + } + + // endregion + + private suspend fun call(block: suspend (Wallet) -> T): T { + val current = wallet ?: throw BarkError.NotStarted() + return ServiceQueue.ARK.background { block(current) } + } + + private suspend fun onchainCall(block: suspend (OnchainWallet) -> T): T { + val current = onchain ?: throw BarkError.NotStarted() + return ServiceQueue.ARK.background { block(current) } + } +} + +/** + * bark's generated [Config] has no Kotlin defaults, so every optional knob has to be passed. All + * nulls fall back to `bark::Config::network_default`, which is what we want for the POC. + */ +private fun barkConfig(serverUrl: String) = Config( + serverAddress = serverUrl, + serverAccessToken = null, + esploraAddress = Env.arkEsploraUrl, + bitcoindAddress = null, + bitcoindCookiefile = null, + bitcoindUser = null, + bitcoindPass = null, + vtxoRefreshExpiryThreshold = null, + vtxoExitMargin = null, + htlcRecvClaimDelta = null, + fallbackFeeRate = null, + roundTxRequiredConfirmations = null, + daemonSyncIntervalSecs = null, + offboardRequiredConfirmations = null, + daemonManualSync = null, + lightningReceiveClaimRetries = null, + userAgent = "bitkit-android/${Env.version}", +) + +fun newBarkMnemonic(): String = generateMnemonic() + +fun isValidArkAddress(address: String): Boolean = runCatching { validateArkAddress(address) }.getOrDefault(false) + +fun LdkNetwork.toBarkNetwork(): Network = when (this) { + LdkNetwork.BITCOIN -> Network.BITCOIN + LdkNetwork.TESTNET -> Network.TESTNET + LdkNetwork.SIGNET -> Network.SIGNET + LdkNetwork.REGTEST -> Network.REGTEST +} diff --git a/app/src/main/java/to/bitkit/utils/Errors.kt b/app/src/main/java/to/bitkit/utils/Errors.kt index b42c09099c..7517e6f7bb 100644 --- a/app/src/main/java/to/bitkit/utils/Errors.kt +++ b/app/src/main/java/to/bitkit/utils/Errors.kt @@ -27,6 +27,21 @@ sealed class ServiceError(message: String) : AppError(message) { class HttpError(message: String, val code: Int = 500, cause: Throwable? = null) : AppError(message, cause) +// region ark +sealed class BarkError(message: String, cause: Throwable? = null) : AppError(message, cause) { + class NotSupported(network: String) : BarkError("Ark is not available on $network") + class NotStarted : BarkError("Ark wallet is not started") + + /** + * bark derives from a bare BIP39 mnemonic and exposes no passphrase parameter, so a + * passphrase-protected wallet would silently board a different key tree. + */ + class PassphraseUnsupported : BarkError("Ark does not support a BIP39 passphrase") + + class Rust(cause: Throwable) : BarkError(cause.message ?: "Unknown Ark error", cause) +} +// endregion + // region ldk class LdkError(private val inner: LdkException) : AppError("Unknown LDK error.") { constructor(inner: BuildException) : this(LdkException.Build(inner)) From 3c196ec3477b79dc1784b19bc1daa53a4b40f4bf Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 10:28:26 -0300 Subject: [PATCH 04/11] feat: add bark repo with ark wallet state Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/repositories/BarkRepo.kt | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 app/src/main/java/to/bitkit/repositories/BarkRepo.kt diff --git a/app/src/main/java/to/bitkit/repositories/BarkRepo.kt b/app/src/main/java/to/bitkit/repositories/BarkRepo.kt new file mode 100644 index 0000000000..8252c1d2cc --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/BarkRepo.kt @@ -0,0 +1,316 @@ +package to.bitkit.repositories + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +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.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.ext.runSuspendCatching +import to.bitkit.models.NodeLifecycleState +import to.bitkit.services.BarkService +import to.bitkit.utils.AppError +import to.bitkit.utils.BarkError +import to.bitkit.utils.Logger +import uniffi.bark.ArkInfo +import uniffi.bark.Movement +import uniffi.bark.Vtxo +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +/** + * Business layer for the bark (Ark) spending backend, mirroring [LightningRepo]. Wraps every + * [BarkService] call so `uniffi.bark` types never reach UI state. + */ +@Suppress("TooManyFunctions") +@Singleton +class BarkRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val barkService: BarkService, +) { + companion object { + private const val TAG = "BarkRepo" + } + + private val lifecycleMutex = Mutex() + + private val _barkState = MutableStateFlow(BarkState()) + val barkState: StateFlow = _barkState.asStateFlow() + + // region lifecycle + + suspend fun start(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + if (!Env.isArkSupported) { + return@withContext Result.failure(BarkError.NotSupported(Env.network.name)) + } + lifecycleMutex.withLock { + if (_barkState.value.lifecycleState.isRunning() && barkService.isRunning) { + Logger.debug("Ark wallet already running, skipping start", context = TAG) + return@withContext Result.success(Unit) + } + _barkState.update { it.copy(lifecycleState = NodeLifecycleState.Starting) } + + runSuspendCatching { + barkService.start(walletIndex) + }.onSuccess { + _barkState.update { it.copy(lifecycleState = NodeLifecycleState.Running) } + }.onFailure { e -> + Logger.error("Failed to start Ark wallet", e, context = TAG) + _barkState.update { it.copy(lifecycleState = NodeLifecycleState.ErrorStarting(e)) } + }.map { } + } + }.also { if (it.isSuccess) syncState() } + + suspend fun stop(): Result = withContext(bgDispatcher) { + lifecycleMutex.withLock { + _barkState.update { it.copy(lifecycleState = NodeLifecycleState.Stopping) } + runSuspendCatching { barkService.stop() } + .onFailure { Logger.error("Failed to stop Ark wallet", it, context = TAG) } + _barkState.update { BarkState() } + Result.success(Unit) + } + } + + // endregion + + // region sync + + suspend fun sync(): Result = executeWhenRunning("sync") { + runSuspendCatching { + _barkState.update { it.copy(isSyncing = true) } + barkService.sync() + barkService.syncPendingBoards() + barkService.tryClaimAllLightningReceives() + }.onFailure { e -> + if (e !is CancellationException) _barkState.update { it.copy(lastSyncError = e) } + }.also { + _barkState.update { it.copy(isSyncing = false) } + }.map { } + }.also { syncState() } + + /** + * Delegated maintenance is the mobile refresh path: designated co-signers refresh VTXOs on the + * wallet's behalf, so the app does not have to be online during a round window. Without it, + * VTXOs eventually expire and the server can sweep them. + */ + suspend fun runMaintenance(): Result = executeWhenRunning("runMaintenance") { + runSuspendCatching { barkService.maintenanceDelegated() } + }.also { syncState() } + + /** Refreshes [BarkState] from the wallet without triggering a network sync. */ + suspend fun syncState() { + if (!barkService.isRunning) return + runSuspendCatching { + val balance = barkService.balance() + val vtxos = barkService.spendableVtxos().map { it.toBarkVtxo() } + val arkInfo = barkService.arkInfo()?.toBarkArkInfo() + val nextRefreshHeight = barkService.nextRequiredRefreshBlockheight() + _barkState.update { state -> + state.copy( + spendableSats = balance.spendableSats, + pendingInRoundSats = balance.pendingInRoundSats, + pendingBoardSats = balance.pendingBoardSats, + pendingLightningSendSats = balance.pendingLightningSendSats, + claimableLightningReceiveSats = balance.claimableLightningReceiveSats, + pendingExitSats = balance.pendingExitSats, + vtxos = vtxos.toImmutableList(), + nextRequiredRefreshHeight = nextRefreshHeight, + arkInfo = arkInfo, + lastSyncError = null, + ) + } + }.onFailure { Logger.error("Failed to refresh Ark state", it, context = TAG) } + } + + // endregion + + // region receive + + suspend fun createInvoice(amountSats: ULong, description: String): Result = + executeWhenRunning("createInvoice") { + runSuspendCatching { barkService.bolt11Invoice(amountSats, description).invoice } + } + + suspend fun newArkAddress(): Result = executeWhenRunning("newArkAddress") { + runSuspendCatching { barkService.newArkAddress() } + } + + suspend fun claimPendingReceives(): Result = executeWhenRunning("claimPendingReceives") { + runSuspendCatching { barkService.tryClaimAllLightningReceives() }.map { } + }.also { syncState() } + + // endregion + + // region send + + suspend fun payInvoice(bolt11: String, amountSats: ULong? = null): Result = + executeWhenRunning("payInvoice") { + runSuspendCatching { barkService.payLightningInvoice(bolt11, amountSats) }.map { } + }.also { syncState() } + + suspend fun sendToArkAddress(address: String, amountSats: ULong): Result = + executeWhenRunning("sendToArkAddress") { + runSuspendCatching { barkService.sendArkoorPayment(address, amountSats) } + }.also { syncState() } + + // endregion + + // region board and offboard + + /** bark boards from its own on-chain wallet, so savings must be sent here first. */ + suspend fun onchainDepositAddress(): Result = executeWhenRunning("onchainDepositAddress") { + runSuspendCatching { barkService.onchainAddress() } + } + + suspend fun onchainSpendableSats(): Result = executeWhenRunning("onchainSpendableSats") { + runSuspendCatching { + barkService.syncOnchain() + barkService.onchainBalance().confirmedSats + } + } + + suspend fun board(amountSats: ULong): Result = executeWhenRunning("board") { + runSuspendCatching { barkService.boardAmount(amountSats).txid } + }.also { syncState() } + + suspend fun offboardAll(bitcoinAddress: String): Result = executeWhenRunning("offboardAll") { + runSuspendCatching { barkService.offboardAll(bitcoinAddress) } + }.also { syncState() } + + suspend fun estimateBoardFeeSats(amountSats: ULong): Result = executeWhenRunning("estimateBoardFee") { + runSuspendCatching { barkService.estimateBoardFee(amountSats).feeSats } + } + + suspend fun estimateOffboardAllFeeSats(bitcoinAddress: String): Result = + executeWhenRunning("estimateOffboardAllFee") { + runSuspendCatching { barkService.estimateOffboardAllFee(bitcoinAddress).feeSats } + } + + // endregion + + // region history and exits + + suspend fun history(): Result> = executeWhenRunning("history") { + runSuspendCatching { barkService.history() } + } + + suspend fun hasPendingExits(): Result = executeWhenRunning("hasPendingExits") { + runSuspendCatching { barkService.hasPendingExits() } + } + + // endregion + + /** + * Mirrors [LightningRepo.executeWhenNodeRunning]: waits out a start already in flight instead of + * failing callers that raced it. + */ + suspend fun executeWhenRunning( + operationName: String, + waitTimeout: Duration = 1.minutes, + operation: suspend () -> Result, + ): Result = withContext(bgDispatcher) { + val lifecycleState = _barkState.value.lifecycleState + if (lifecycleState.isRunning()) { + return@withContext executeOperation(operationName, operation) + } + + if (!lifecycleState.canRun()) { + return@withContext Result.failure( + AppError("Cannot execute '$operationName': Ark wallet is '$lifecycleState' and not starting") + ) + } + + val isRunning = withTimeoutOrNull(waitTimeout) { + Logger.verbose("Waiting for Ark wallet to run before executing '$operationName'", context = TAG) + _barkState.first { it.lifecycleState.isRunning() } + true + } ?: false + + if (!isRunning) { + return@withContext Result.failure(AppError("Timed out waiting for Ark wallet to run: '$operationName'")) + } + + return@withContext executeOperation(operationName, operation) + } + + private suspend fun executeOperation( + operationName: String, + operation: suspend () -> Result, + ): Result = runCatching { + operation().getOrThrow() + }.onFailure { + if (it is CancellationException) throw it + Logger.error("Error executing '$operationName'", it, context = TAG) + } +} + +@Immutable +data class BarkState( + val lifecycleState: NodeLifecycleState = NodeLifecycleState.Stopped, + val spendableSats: ULong = 0uL, + val pendingInRoundSats: ULong = 0uL, + val pendingBoardSats: ULong = 0uL, + val pendingLightningSendSats: ULong = 0uL, + val claimableLightningReceiveSats: ULong = 0uL, + val pendingExitSats: ULong = 0uL, + val vtxos: ImmutableList = persistentListOf(), + val nextRequiredRefreshHeight: UInt? = null, + val arkInfo: BarkArkInfo? = null, + val isSyncing: Boolean = false, + val lastSyncError: Throwable? = null, +) { + /** Sats that are neither spendable yet nor settled, shown as in-transfer in the UI. */ + val pendingIncomingSats: ULong get() = pendingBoardSats + pendingInRoundSats + + val hasAnyFunds: Boolean + get() = spendableSats > 0uL || + pendingIncomingSats > 0uL || + pendingLightningSendSats > 0uL || + claimableLightningReceiveSats > 0uL || + pendingExitSats > 0uL +} + +@Immutable +data class BarkVtxo( + val id: String, + val amountSats: ULong, + val expiryHeight: UInt, + val kind: String, + val state: String, +) + +@Immutable +data class BarkArkInfo( + val roundIntervalSecs: ULong, + val requiredBoardConfirmations: UInt, + val minBoardAmountSats: ULong, + val maxVtxoAmountSats: ULong?, +) + +private fun Vtxo.toBarkVtxo() = BarkVtxo( + id = id, + amountSats = amountSats, + expiryHeight = expiryHeight, + kind = kind, + state = state, +) + +private fun ArkInfo.toBarkArkInfo() = BarkArkInfo( + roundIntervalSecs = roundIntervalSecs, + requiredBoardConfirmations = requiredBoardConfirmations, + minBoardAmountSats = minBoardAmountSats, + maxVtxoAmountSats = maxVtxoAmountSats, +) From 4c74740997abfc98ceebf8a4d7ecb40b3d29df9d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 10:33:41 -0300 Subject: [PATCH 05/11] feat: route spending balance to bark backend Co-Authored-By: Claude Opus 5 (1M context) --- .../androidServices/LightningNodeService.kt | 5 ++++ .../main/java/to/bitkit/data/SettingsStore.kt | 4 +++ .../java/to/bitkit/repositories/BarkRepo.kt | 19 ++++++++++++ .../usecases/DeriveBalanceStateUseCase.kt | 30 +++++++++++++++---- .../to/bitkit/viewmodels/WalletViewModel.kt | 3 ++ .../java/to/bitkit/ui/WalletViewModelTest.kt | 6 ++++ .../usecases/DeriveBalanceStateUseCaseTest.kt | 5 ++++ 7 files changed, 67 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 79849ab9c9..f7e7295950 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -33,6 +33,7 @@ import to.bitkit.domain.commands.NotifyPendingPaymentResolvedHandler import to.bitkit.ext.activityManager import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NotificationDetails +import to.bitkit.repositories.BarkRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.NodeEventHandler @@ -58,6 +59,9 @@ class LightningNodeService : Service() { @Inject lateinit var lightningRepo: LightningRepo + @Inject + lateinit var barkRepo: BarkRepo + @Inject lateinit var walletRepo: WalletRepo @@ -98,6 +102,7 @@ class LightningNodeService : Service() { ).onSuccess { walletRepo.setWalletExistsState() walletRepo.refreshBip21() + barkRepo.startIfEnabled() walletRepo.syncBalances() } } diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 5904e48815..21e0392d03 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -157,8 +157,12 @@ data class SettingsData( val selectedAddressType: String = DEFAULT_ADDRESS_TYPE_STRING, val addressTypesToMonitor: List = listOf(DEFAULT_ADDRESS_TYPE_STRING), val pendingRestoreAddressTypePrune: Boolean = false, + val spendingBackend: SpendingBackend = SpendingBackend.LDK, ) +/** Which backend provides the spending balance. Savings stays on ldk-node either way. */ +enum class SpendingBackend { LDK, BARK } + fun SettingsData.resetPin() = this.copy( isPinEnabled = false, isPinForPaymentsEnabled = false, diff --git a/app/src/main/java/to/bitkit/repositories/BarkRepo.kt b/app/src/main/java/to/bitkit/repositories/BarkRepo.kt index 8252c1d2cc..34a547dee5 100644 --- a/app/src/main/java/to/bitkit/repositories/BarkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BarkRepo.kt @@ -6,15 +6,19 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import to.bitkit.data.SettingsStore +import to.bitkit.data.SpendingBackend import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.ext.runSuspendCatching @@ -40,6 +44,7 @@ import kotlin.time.Duration.Companion.minutes class BarkRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val barkService: BarkService, + private val settingsStore: SettingsStore, ) { companion object { private const val TAG = "BarkRepo" @@ -50,8 +55,22 @@ class BarkRepo @Inject constructor( private val _barkState = MutableStateFlow(BarkState()) val barkState: StateFlow = _barkState.asStateFlow() + val isEnabled: Flow = settingsStore.data.map { it.spendingBackend == SpendingBackend.BARK } + + suspend fun isEnabledNow(): Boolean = + Env.isArkSupported && settingsStore.data.first().spendingBackend == SpendingBackend.BARK + // region lifecycle + /** + * Single startup entry point for the callers that already start ldk-node. ldk-node keeps + * running in bark mode because it still owns the on-chain savings wallet. + */ + suspend fun startIfEnabled(walletIndex: Int = 0): Result { + if (!isEnabledNow()) return Result.success(Unit) + return start(walletIndex) + } + suspend fun start(walletIndex: Int = 0): Result = withContext(bgDispatcher) { if (!Env.isArkSupported) { return@withContext Result.failure(BarkError.NotSupported(Env.network.name)) diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 6ae7003a66..4c39090858 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -8,6 +8,7 @@ import org.lightningdevkit.ldknode.BalanceSource import org.lightningdevkit.ldknode.ChannelDetails import org.lightningdevkit.ldknode.LightningBalance import to.bitkit.data.SettingsStore +import to.bitkit.data.SpendingBackend import to.bitkit.data.entities.TransferEntity import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults @@ -18,6 +19,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.models.toBalance +import to.bitkit.repositories.BarkRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo @@ -30,6 +32,7 @@ import javax.inject.Singleton class DeriveBalanceStateUseCase @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, + private val barkRepo: BarkRepo, private val transferRepo: TransferRepo, private val settingsStore: SettingsStore, private val hwWalletRepo: HwWalletRepo, @@ -58,14 +61,31 @@ class DeriveBalanceStateUseCase @Inject constructor( val afterClosingChannels = afterPendingChannels.safe() - toSavingsAmount.safe() val totalLightningSats = afterClosingChannels.safe() - lingeringCoopCloseSats.safe() + // In bark mode the spending balance comes from Ark VTXOs instead of channels; savings + // stays on the ldk-node onchain wallet, so only the lightning half is swapped out. + val isBarkBackend = settingsStore.data.first().spendingBackend == SpendingBackend.BARK + val barkState = barkRepo.barkState.value + val balanceState = BalanceState( totalOnchainSats = totalOnchainSats, - channelFundableBalance = channelFundableBalance, - totalLightningSats = totalLightningSats, - maxSendLightningSats = lightningRepo.getChannels().totalNextOutboundHtlcLimitSats(), + channelFundableBalance = if (isBarkBackend) 0uL else channelFundableBalance, + totalLightningSats = if (isBarkBackend) barkState.spendableSats else totalLightningSats, + maxSendLightningSats = if (isBarkBackend) { + barkState.spendableSats + } else { + lightningRepo.getChannels().totalNextOutboundHtlcLimitSats() + }, maxSendOnchainSats = getMaxSendAmount(balanceDetails), - balanceInTransferToSavings = toSavingsAmount.safe() - coopCloseSavingsSats.safe(), - balanceInTransferToSpending = toSpendingAmount, + balanceInTransferToSavings = if (isBarkBackend) { + barkState.pendingExitSats + } else { + toSavingsAmount.safe() - coopCloseSavingsSats.safe() + }, + balanceInTransferToSpending = if (isBarkBackend) { + barkState.pendingIncomingSats + } else { + toSpendingAmount + }, hardwareWallets = hwWalletRepo.wallets.value.map { it.toBalance() }, ) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 9c99ef1d77..f6d31a13a0 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -32,6 +32,7 @@ import to.bitkit.ext.of import to.bitkit.ext.runSuspendCatching import to.bitkit.models.Toast import to.bitkit.repositories.BackupRepo +import to.bitkit.repositories.BarkRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState @@ -58,6 +59,7 @@ class WalletViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, + private val barkRepo: BarkRepo, private val settingsStore: SettingsStore, private val backupRepo: BackupRepo, private val blocktankRepo: BlocktankRepo, @@ -326,6 +328,7 @@ class WalletViewModel @Inject constructor( walletRepo.setWalletExistsState() connectMigrationPeers() migrationService.cleanupInvalidMigrationTransfers() + barkRepo.startIfEnabled(walletIndex) walletRepo.syncBalances() if (_restoreState.value.isIdle()) { walletRepo.refreshBip21() diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 46f82e2e24..145942dcb3 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -28,6 +28,7 @@ import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState +import to.bitkit.repositories.BarkRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PubkyRepo @@ -48,6 +49,7 @@ class WalletViewModelTest : BaseUnitTest() { private val context = mock() private val walletRepo = mock() private val lightningRepo = mock() + private val barkRepo = mock() private val settingsStore = mock() private val backupRepo = mock() private val blocktankRepo = mock() @@ -81,6 +83,7 @@ class WalletViewModelTest : BaseUnitTest() { bgDispatcher = testDispatcher, walletRepo = walletRepo, lightningRepo = lightningRepo, + barkRepo = barkRepo, settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, @@ -325,6 +328,7 @@ class WalletViewModelTest : BaseUnitTest() { bgDispatcher = testDispatcher, walletRepo = testWalletRepo, lightningRepo = testLightningRepo, + barkRepo = barkRepo, settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, @@ -391,6 +395,7 @@ class WalletViewModelTest : BaseUnitTest() { bgDispatcher = testDispatcher, walletRepo = testWalletRepo, lightningRepo = testLightningRepo, + barkRepo = barkRepo, settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, @@ -446,6 +451,7 @@ class WalletViewModelTest : BaseUnitTest() { bgDispatcher = testDispatcher, walletRepo = testWalletRepo, lightningRepo = testLightningRepo, + barkRepo = barkRepo, settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index 1e52cb0990..816069f2dd 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -22,6 +22,8 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.entities.TransferEntity import to.bitkit.models.TransferType import to.bitkit.repositories.HwWalletRepo +import to.bitkit.repositories.BarkRepo +import to.bitkit.repositories.BarkState import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.TransferRepo @@ -32,6 +34,7 @@ import kotlin.test.assertTrue class DeriveBalanceStateUseCaseTest : BaseUnitTest() { private val lightningRepo: LightningRepo = mock() + private val barkRepo: BarkRepo = mock() private val transferRepo: TransferRepo = mock() private val settingsStore: SettingsStore = mock() private val hwWalletRepo: HwWalletRepo = mock() @@ -43,6 +46,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { runBlocking { whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) + whenever(barkRepo.barkState).thenReturn(MutableStateFlow(BarkState())) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) @@ -58,6 +62,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { sut = DeriveBalanceStateUseCase( bgDispatcher = testDispatcher, lightningRepo = lightningRepo, + barkRepo = barkRepo, transferRepo = transferRepo, settingsStore = settingsStore, hwWalletRepo = hwWalletRepo, From 5d8613efd5a712387e04894e15d91f45238c7373 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 10:38:52 -0300 Subject: [PATCH 06/11] feat: show ark payments in the activity list Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/to/bitkit/ext/BarkMovements.kt | 54 ++++++++ .../to/bitkit/repositories/ActivityRepo.kt | 35 ++++- .../java/to/bitkit/ext/BarkMovementsTest.kt | 126 ++++++++++++++++++ .../bitkit/repositories/ActivityRepoTest.kt | 4 + 4 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ext/BarkMovements.kt create mode 100644 app/src/test/java/to/bitkit/ext/BarkMovementsTest.kt diff --git a/app/src/main/java/to/bitkit/ext/BarkMovements.kt b/app/src/main/java/to/bitkit/ext/BarkMovements.kt new file mode 100644 index 0000000000..4df7a9ec0b --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/BarkMovements.kt @@ -0,0 +1,54 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.LightningActivity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType +import to.bitkit.models.WalletScope +import uniffi.bark.Movement +import java.time.Instant +import kotlin.math.absoluteValue + +/** Prefix keeping bark movement ids from colliding with ldk-node payment ids in the same store. */ +const val BARK_ACTIVITY_ID_PREFIX = "bark:" + +/** + * Maps a bark [Movement] onto the bitkit-core [Activity] model so Ark payments show up in the + * existing activity list, detail screen, tags and contacts with no UI changes. + * + * bark reports balance deltas as signed sats: negative is outgoing. The FFI `Movement` carries no + * preimage, so [LightningActivity.preimage] stays null. + */ +fun Movement.toActivity(walletId: String = WalletScope.default): Activity { + val createdAtSecs = parseBarkTimestamp(createdAt) + val updatedAtSecs = parseBarkTimestamp(updatedAt) + + return Activity.Lightning( + LightningActivity.create( + walletId = walletId, + id = "$BARK_ACTIVITY_ID_PREFIX$id", + txType = if (intendedBalanceSats < 0) PaymentType.SENT else PaymentType.RECEIVED, + status = status.toPaymentState(), + value = effectiveBalanceSats.absoluteValue.toULong(), + fee = offchainFeeSats, + invoice = lightningInvoice ?: lightningOffer ?: sentToAddresses.firstOrNull().orEmpty(), + message = "$subsystemName/$subsystemKind", + timestamp = createdAtSecs, + createdAt = createdAtSecs, + updatedAt = updatedAtSecs, + ) + ) +} + +private fun String.toPaymentState(): PaymentState = when (this) { + "successful" -> PaymentState.SUCCEEDED + "failed", "canceled" -> PaymentState.FAILED + else -> PaymentState.PENDING +} + +/** + * bark emits RFC 3339 timestamps. A malformed value must not drop the whole activity, so it falls + * back to the epoch and the movement still shows up. + */ +private fun parseBarkTimestamp(value: String): ULong = + runCatching { Instant.parse(value).epochSecond.toULong() }.getOrDefault(0uL) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 7e2b6c2170..a7b892439a 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -41,6 +41,8 @@ import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.matchesPaymentId import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp +import to.bitkit.ext.toActivity +import uniffi.bark.Movement import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching import to.bitkit.ext.walletId @@ -66,6 +68,7 @@ class ActivityRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val coreService: CoreService, private val lightningRepo: LightningRepo, + private val barkRepo: BarkRepo, private val blocktankRepo: BlocktankRepo, private val cacheStore: CacheStore, private val transferRepo: TransferRepo, @@ -99,9 +102,20 @@ class ActivityRepo @Inject constructor( isSyncingLdkNodePayments.update { true } - lightningRepo.getPayments().mapCatching { payments -> - Logger.debug("Got payments with success, syncing activities", context = TAG) - syncLdkNodePayments(payments).getOrThrow() + // Onchain activity is unchanged in either mode; only the offchain source differs. + val offchainSync = if (barkRepo.isEnabledNow()) { + barkRepo.history().mapCatching { movements -> + Logger.debug("Got Ark movements with success, syncing activities", context = TAG) + syncBarkMovements(movements).getOrThrow() + } + } else { + lightningRepo.getPayments().mapCatching { payments -> + Logger.debug("Got payments with success, syncing activities", context = TAG) + syncLdkNodePayments(payments).getOrThrow() + } + } + + offchainSync.mapCatching { boostPendingActivities() transferRepo.syncTransferStates().getOrThrow() }.onSuccess { @@ -134,6 +148,21 @@ class ActivityRepo @Inject constructor( } } + /** + * Syncs bark [Movement]s to `bitkit-core` [Activity] items. bitkit-core only knows how to map + * ldk-node payments, so Ark movements are mapped here and inserted through the normal path. + */ + suspend fun syncBarkMovements(movements: List): Result = withContext(bgDispatcher) { + runSuspendCatching { + movements.forEach { movement -> + insertActivity(movement.toActivity()) + } + notifyActivitiesChanged() + }.onFailure { + Logger.error("Error syncing Ark movements:", it, context = TAG) + } + } + private suspend fun findChannelsForPayments( payments: List, ): Map = withContext(bgDispatcher) { diff --git a/app/src/test/java/to/bitkit/ext/BarkMovementsTest.kt b/app/src/test/java/to/bitkit/ext/BarkMovementsTest.kt new file mode 100644 index 0000000000..97fe99bfcf --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/BarkMovementsTest.kt @@ -0,0 +1,126 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.PaymentState +import com.synonym.bitkitcore.PaymentType +import org.junit.Test +import to.bitkit.test.BaseUnitTest +import uniffi.bark.Movement +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BarkMovementsTest : BaseUnitTest() { + + @Test + fun `maps a received movement to a received lightning activity`() { + val activity = newMovement( + id = 7u, + intendedBalanceSats = 25_000, + effectiveBalanceSats = 25_000, + lightningInvoice = "lnbc250u1p", + ).toActivity().asLightning() + + assertEquals("bark:7", activity.id) + assertEquals(PaymentType.RECEIVED, activity.txType) + assertEquals(PaymentState.SUCCEEDED, activity.status) + assertEquals(25_000uL, activity.value) + assertEquals("lnbc250u1p", activity.invoice) + } + + @Test + fun `maps a negative balance delta to a sent activity with absolute value`() { + val activity = newMovement( + intendedBalanceSats = -12_000, + effectiveBalanceSats = -12_000, + offchainFeeSats = 300uL, + ).toActivity().asLightning() + + assertEquals(PaymentType.SENT, activity.txType) + assertEquals(12_000uL, activity.value) + assertEquals(300uL, activity.fee) + } + + @Test + fun `maps bark statuses onto payment states`() { + assertEquals(PaymentState.SUCCEEDED, newMovement(status = "successful").toActivity().asLightning().status) + assertEquals(PaymentState.PENDING, newMovement(status = "pending").toActivity().asLightning().status) + assertEquals(PaymentState.FAILED, newMovement(status = "failed").toActivity().asLightning().status) + assertEquals(PaymentState.FAILED, newMovement(status = "canceled").toActivity().asLightning().status) + } + + @Test + fun `falls back to an ark address when there is no lightning invoice`() { + val activity = newMovement( + lightningInvoice = null, + lightningOffer = null, + sentToAddresses = listOf("ark1qexample"), + ).toActivity().asLightning() + + assertEquals("ark1qexample", activity.invoice) + } + + @Test + fun `leaves the invoice blank when the movement has no destination`() { + val activity = newMovement( + lightningInvoice = null, + lightningOffer = null, + sentToAddresses = emptyList(), + ).toActivity().asLightning() + + assertEquals("", activity.invoice) + // The FFI Movement carries no preimage, so it must not be invented. + assertNull(activity.preimage) + } + + @Test + fun `parses rfc 3339 timestamps into epoch seconds`() { + val activity = newMovement(createdAt = "2026-08-03T10:00:00Z").toActivity().asLightning() + + assertEquals(1_785_751_200uL, activity.timestamp) + assertEquals(activity.timestamp, activity.createdAt) + } + + @Test + fun `keeps the activity when a timestamp cannot be parsed`() { + val activity = newMovement(createdAt = "not-a-timestamp").toActivity().asLightning() + + assertEquals(0uL, activity.timestamp) + assertTrue(activity.id.startsWith(BARK_ACTIVITY_ID_PREFIX)) + } + + private fun Activity.asLightning() = (this as Activity.Lightning).v1 + + @Suppress("LongParameterList") + private fun newMovement( + id: UInt = 1u, + status: String = "successful", + intendedBalanceSats: Long = 1_000, + effectiveBalanceSats: Long = 1_000, + offchainFeeSats: ULong = 0uL, + sentToAddresses: List = emptyList(), + lightningInvoice: String? = null, + lightningOffer: String? = null, + createdAt: String = "2026-08-03T10:00:00Z", + ) = Movement( + id = id, + status = status, + subsystemName = "ark", + subsystemKind = "send", + metadataJson = "{}", + intendedBalanceSats = intendedBalanceSats, + effectiveBalanceSats = effectiveBalanceSats, + offchainFeeSats = offchainFeeSats, + sentToAddresses = sentToAddresses, + receivedOnAddresses = emptyList(), + inputVtxoIds = emptyList(), + outputVtxoIds = emptyList(), + exitedVtxoIds = emptyList(), + createdAt = createdAt, + updatedAt = createdAt, + completedAt = null, + paymentHash = null, + lightningInvoice = lightningInvoice, + lightningOffer = lightningOffer, + ) +} diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index 0fcd4e6112..ebf6f807c8 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -47,6 +47,7 @@ class ActivityRepoTest : BaseUnitTest() { private val coreService = mock() private val lightningRepo = mock() + private val barkRepo = mock() private val blocktankRepo = mock() private val transferRepo = mock() private val cacheStore = mock() @@ -131,12 +132,15 @@ class ActivityRepoTest : BaseUnitTest() { whenever(clock.now()).thenReturn(Clock.System.now()) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) + // These tests cover the ldk-node backend; bark has its own movement-sync path. + wheneverBlocking { barkRepo.isEnabledNow() }.thenReturn(false) sut = ActivityRepo( bgDispatcher = testDispatcher, ioDispatcher = testDispatcher, coreService = coreService, lightningRepo = lightningRepo, + barkRepo = barkRepo, blocktankRepo = blocktankRepo, cacheStore = cacheStore, transferRepo = transferRepo, From ca0e47310344b0c3c9d6e9a5d11c07f4e8870ff1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 10:46:17 -0300 Subject: [PATCH 07/11] feat: add ark spending backend toggle with switch gate Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/to/bitkit/ui/ContentView.kt | 14 +- .../ui/settings/AdvancedSettingsViewModel.kt | 58 +++++++ .../to/bitkit/ui/settings/SettingsScreen.kt | 155 +++++++++++++++--- .../CanSwitchSpendingBackendUseCase.kt | 70 ++++++++ app/src/main/res/values/strings.xml | 9 + .../test/java/to/bitkit/ui/ContentViewTest.kt | 6 + .../CanSwitchSpendingBackendUseCaseTest.kt | 121 ++++++++++++++ 7 files changed, 406 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCase.kt create mode 100644 app/src/test/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCaseTest.kt diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index f79e60ff67..26d4063fcd 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1030,11 +1030,7 @@ private fun NavGraphBuilder.home( onActivityItemClick = { navController.navToActivityDetail(it) }, onEmptyActivityRowClick = { appViewModel.showSheet(Sheet.Receive()) }, onTransferToSavingsClick = { - if (!hasSeenSavingsIntro) { - navController.navigateToTransferSavingsIntro() - } else { - navController.navigateToTransferSavingsAvailability() - } + navController.navigateToTransferSavingsStart(hasSeenSavingsIntro) }, onTransferFromSavingsClick = { navController.navigateToTransferSpendingStart(hasSeenSpendingIntro) @@ -1868,6 +1864,9 @@ fun NavController.navigateToTransferSavingsIntro() = navigateTo(Routes.SavingsIn fun NavController.navigateToTransferSavingsAvailability() = navigateTo(Routes.SavingsAvailability) +fun NavController.navigateToTransferSavingsStart(hasSeenSavingsIntro: Boolean) = + navigateTo(transferSavingsStartRoute(hasSeenSavingsIntro)) + fun NavController.navigateToTransferSpendingStart(hasSeenSpendingIntro: Boolean) = navigateTo(transferSpendingStartRoute(hasSeenSpendingIntro)) @@ -1882,6 +1881,11 @@ internal fun transferEffectDestination(effect: TransferEffect): Routes? = when ( else -> null } +internal fun transferSavingsStartRoute(hasSeenSavingsIntro: Boolean): Routes = when { + hasSeenSavingsIntro -> Routes.SavingsAvailability + else -> Routes.SavingsIntro +} + internal fun transferSpendingStartRoute(hasSeenSpendingIntro: Boolean): Routes = when { hasSeenSpendingIntro -> Routes.SpendingAmount else -> Routes.SpendingIntro diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt index d838f61b93..395633f447 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt @@ -3,18 +3,27 @@ package to.bitkit.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore +import to.bitkit.data.SpendingBackend import to.bitkit.env.Env import to.bitkit.ext.filterOpen import to.bitkit.models.ElectrumServer import to.bitkit.models.addressTypeInfo import to.bitkit.models.toAddressType +import to.bitkit.repositories.BarkRepo import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WatchOnlyAccountRepo +import to.bitkit.usecases.CanSwitchSpendingBackendUseCase +import to.bitkit.usecases.SpendingBackendSwitchState import javax.inject.Inject private const val NODE_ID_PREFIX_LENGTH = 5 @@ -24,6 +33,9 @@ private const val ELECTRUM_HOST_PREFIX_LENGTH = 5 class AdvancedSettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, private val lightningRepo: LightningRepo, + private val barkRepo: BarkRepo, + private val walletRepo: WalletRepo, + private val canSwitchSpendingBackend: CanSwitchSpendingBackendUseCase, watchOnlyAccountRepo: WatchOnlyAccountRepo, ) : ViewModel() { @@ -67,4 +79,50 @@ class AdvancedSettingsViewModel @Inject constructor( settingsStore.update { it.copy(dismissedSuggestions = emptyList()) } } } + + // region spending backend + + /** Hidden entirely on networks without a hosted Ark server, where bark cannot run at all. */ + val isArkSupported = Env.isArkSupported + + val isArkEnabled = settingsStore.data + .map { it.spendingBackend == SpendingBackend.BARK } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + private val _spendingBackendDialog = MutableStateFlow(null) + val spendingBackendDialog: StateFlow = _spendingBackendDialog.asStateFlow() + + fun onArkToggleClick() { + viewModelScope.launch { + val target = if (isArkEnabled.value) SpendingBackend.LDK else SpendingBackend.BARK + _spendingBackendDialog.update { + when (val switchState = canSwitchSpendingBackend(target)) { + is SpendingBackendSwitchState.Allowed -> SpendingBackendDialog.Confirm(target) + is SpendingBackendSwitchState.Blocked -> SpendingBackendDialog.Blocked(switchState) + } + } + } + } + + fun confirmSpendingBackendSwitch() { + val dialog = _spendingBackendDialog.value as? SpendingBackendDialog.Confirm ?: return + viewModelScope.launch { + settingsStore.update { it.copy(spendingBackend = dialog.target) } + when (dialog.target) { + SpendingBackend.BARK -> barkRepo.startIfEnabled() + SpendingBackend.LDK -> barkRepo.stop() + } + walletRepo.syncBalances() + _spendingBackendDialog.update { null } + } + } + + fun dismissSpendingBackendDialog() = run { _spendingBackendDialog.update { null } } + + // endregion +} + +sealed interface SpendingBackendDialog { + data class Confirm(val target: SpendingBackend) : SpendingBackendDialog + data class Blocked(val reason: SpendingBackendSwitchState.Blocked) : SpendingBackendDialog } diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index d8e88edde8..6438c01e4b 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -9,6 +9,9 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect @@ -51,6 +54,7 @@ import to.bitkit.ui.navigateToPinManagement import to.bitkit.ui.navigateToQuickPaySettings import to.bitkit.ui.navigateToTagsSettings import to.bitkit.ui.navigateToTransactionSpeedSettings +import to.bitkit.ui.navigateToTransferSavingsStart import to.bitkit.ui.navigateToWidgetsSettings import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -63,6 +67,7 @@ import to.bitkit.ui.settingsViewModel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.rememberBiometricAuthSupported +import to.bitkit.usecases.SpendingBackendSwitchState import to.bitkit.viewmodels.LanguageViewModel private enum class SettingsTab(@StringRes private val titleRes: Int) : TabItem { @@ -117,6 +122,9 @@ fun SettingsScreen( val electrumHost by advancedViewModel.electrumHost.collectAsStateWithLifecycle() val coinSelectAuto by advancedViewModel.coinSelectAuto.collectAsStateWithLifecycle() val watchOnlyAccountCount by advancedViewModel.watchOnlyAccountCount.collectAsStateWithLifecycle() + val isArkEnabled by advancedViewModel.isArkEnabled.collectAsStateWithLifecycle() + val spendingBackendDialog by advancedViewModel.spendingBackendDialog.collectAsStateWithLifecycle() + val hasSeenSavingsIntro by settings.hasSeenSavingsIntro.collectAsStateWithLifecycle() LaunchedEffect(Unit) { languageViewModel.fetchLanguageInfo() } @@ -158,6 +166,8 @@ fun SettingsScreen( truncatedNodeId = truncatedNodeId, electrumHost = electrumHost, watchOnlyAccountCount = watchOnlyAccountCount, + isArkSupported = advancedViewModel.isArkSupported, + isArkEnabled = isArkEnabled, ), onEvent = { event -> when (event) { @@ -204,6 +214,7 @@ fun SettingsScreen( SettingsEvent.CoinSelectionClick -> navController.navigateTo(Routes.CoinSelectPreference) SettingsEvent.AddressViewerClick -> navController.navigateTo(Routes.AddressViewer) SettingsEvent.WatchOnlyAccountsClick -> navController.navigateTo(Routes.WatchOnlyAccounts) + SettingsEvent.ArkBackendClick -> advancedViewModel.onArkToggleClick() SettingsEvent.LightningConnectionsClick -> navController.navigateTo(Routes.LightningConnections) SettingsEvent.LightningNodeClick -> navController.navigateTo(Routes.NodeInfo) SettingsEvent.ElectrumServerClick -> navController.navigateTo(Routes.ElectrumConfig) @@ -212,6 +223,90 @@ fun SettingsScreen( } }, ) + + SpendingBackendDialogs( + dialog = spendingBackendDialog, + onDismiss = { advancedViewModel.dismissSpendingBackendDialog() }, + onConfirm = { advancedViewModel.confirmSpendingBackendSwitch() }, + onTransferToSavings = { + advancedViewModel.dismissSpendingBackendDialog() + navController.navigateToTransferSavingsStart(hasSeenSavingsIntro) + }, + ) +} + +/** + * Blocked and confirm dialogs for the spending backend switch. The blocked variant offers a route + * into the existing transfer flow so the user can clear the balance that is holding the switch up. + */ +@Composable +private fun SpendingBackendDialogs( + dialog: SpendingBackendDialog?, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + onTransferToSavings: () -> Unit, +) { + when (dialog) { + null -> Unit + + is SpendingBackendDialog.Blocked -> AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings__adv__ark_blocked_title)) }, + text = { + Text( + stringResource( + when (dialog.reason) { + is SpendingBackendSwitchState.Blocked.SpendingBalance -> + R.string.settings__adv__ark_blocked_spending + SpendingBackendSwitchState.Blocked.OpenChannels -> + R.string.settings__adv__ark_blocked_channels + SpendingBackendSwitchState.Blocked.PendingTransfer -> + R.string.settings__adv__ark_blocked_transfer + SpendingBackendSwitchState.Blocked.PendingExit -> + R.string.settings__adv__ark_blocked_exit + } + ) + ) + }, + confirmButton = { + // Only a spending balance is something the user can act on from here. + if (dialog.reason is SpendingBackendSwitchState.Blocked.SpendingBalance) { + TextButton( + onClick = onTransferToSavings, + modifier = Modifier.testTag("SpendingBackendTransferToSavings") + ) { + Text(stringResource(R.string.wallet__transfer_to_savings)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common__dialog_cancel)) + } + }, + modifier = Modifier.testTag("SpendingBackendBlockedDialog") + ) + + is SpendingBackendDialog.Confirm -> AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.settings__adv__ark_warning_title)) }, + text = { Text(stringResource(R.string.settings__adv__ark_warning_text)) }, + confirmButton = { + TextButton( + onClick = onConfirm, + modifier = Modifier.testTag("SpendingBackendConfirm") + ) { + Text(stringResource(R.string.settings__adv__ark_warning_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.common__dialog_cancel)) + } + }, + modifier = Modifier.testTag("SpendingBackendConfirmDialog") + ) + } } @Composable @@ -584,28 +679,41 @@ private fun AdvancedTabContent( padding = PaddingValues(top = 16.dp) ) - SettingsButtonRow( - title = stringResource(R.string.settings__adv__lightning_connections), - icon = { SettingsIcon(R.drawable.ic_lightning) }, - value = if (state.openChannelCount > 0) { - SettingsButtonValue.StringValue(state.openChannelCount.toString()) - } else { - SettingsButtonValue.None - }, - onClick = { onEvent(SettingsEvent.LightningConnectionsClick) }, - modifier = Modifier.testTag("Channels") - ) - SettingsButtonRow( - title = stringResource(R.string.settings__adv__lightning_node), - icon = { SettingsIcon(R.drawable.ic_git_branch) }, - value = if (state.truncatedNodeId.isNotEmpty()) { - SettingsButtonValue.StringValue("${state.truncatedNodeId}...") - } else { - SettingsButtonValue.None - }, - onClick = { onEvent(SettingsEvent.LightningNodeClick) }, - modifier = Modifier.testTag("LightningNodeInfo") - ) + if (state.isArkSupported) { + SettingsSwitchRow( + title = stringResource(R.string.settings__adv__ark_toggle), + icon = { SettingsIcon(R.drawable.ic_lightning) }, + isChecked = state.isArkEnabled, + onClick = { onEvent(SettingsEvent.ArkBackendClick) }, + switchTestTag = "SpendingBackendToggleSwitch", + modifier = Modifier.testTag("SpendingBackendToggle") + ) + } + // Channels and the node identity are meaningless while Ark provides spending. + if (!state.isArkEnabled) { + SettingsButtonRow( + title = stringResource(R.string.settings__adv__lightning_connections), + icon = { SettingsIcon(R.drawable.ic_lightning) }, + value = if (state.openChannelCount > 0) { + SettingsButtonValue.StringValue(state.openChannelCount.toString()) + } else { + SettingsButtonValue.None + }, + onClick = { onEvent(SettingsEvent.LightningConnectionsClick) }, + modifier = Modifier.testTag("Channels") + ) + SettingsButtonRow( + title = stringResource(R.string.settings__adv__lightning_node), + icon = { SettingsIcon(R.drawable.ic_git_branch) }, + value = if (state.truncatedNodeId.isNotEmpty()) { + SettingsButtonValue.StringValue("${state.truncatedNodeId}...") + } else { + SettingsButtonValue.None + }, + onClick = { onEvent(SettingsEvent.LightningNodeClick) }, + modifier = Modifier.testTag("LightningNodeInfo") + ) + } SettingsButtonRow( title = stringResource(R.string.settings__adv__electrum_server), icon = { SettingsIcon(R.drawable.ic_hard_drives) }, @@ -704,6 +812,7 @@ sealed interface SettingsEvent { data object CoinSelectionClick : SettingsEvent data object AddressViewerClick : SettingsEvent data object WatchOnlyAccountsClick : SettingsEvent + data object ArkBackendClick : SettingsEvent data object LightningConnectionsClick : SettingsEvent data object LightningNodeClick : SettingsEvent data object ElectrumServerClick : SettingsEvent @@ -755,4 +864,6 @@ data class AdvancedTabState( val truncatedNodeId: String = "", val electrumHost: String = "", val watchOnlyAccountCount: Int = 0, + val isArkSupported: Boolean = false, + val isArkEnabled: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCase.kt b/app/src/main/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCase.kt new file mode 100644 index 0000000000..e06fff8bc5 --- /dev/null +++ b/app/src/main/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCase.kt @@ -0,0 +1,70 @@ +package to.bitkit.usecases + +import kotlinx.coroutines.flow.first +import to.bitkit.data.SpendingBackend +import to.bitkit.repositories.BarkRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.TransferRepo +import to.bitkit.repositories.WalletRepo +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Decides whether the spending backend can be swapped right now. + * + * Switching is only safe with an empty spending balance: the two backends hold funds in completely + * different places (channels vs VTXOs), and the inactive one is not synced, so any balance left + * behind would silently disappear from the UI. + */ +@Singleton +class CanSwitchSpendingBackendUseCase @Inject constructor( + private val walletRepo: WalletRepo, + private val lightningRepo: LightningRepo, + private val barkRepo: BarkRepo, + private val transferRepo: TransferRepo, +) { + suspend operator fun invoke(target: SpendingBackend): SpendingBackendSwitchState { + val hasActiveTransfers = transferRepo.activeTransfers.first().isNotEmpty() + if (hasActiveTransfers) return SpendingBackendSwitchState.Blocked.PendingTransfer + + return when (target) { + SpendingBackend.BARK -> checkLeavingLdk() + SpendingBackend.LDK -> checkLeavingBark() + } + } + + private fun checkLeavingLdk(): SpendingBackendSwitchState { + val spendingSats = walletRepo.balanceState.value.totalLightningSats + if (spendingSats > 0uL) return SpendingBackendSwitchState.Blocked.SpendingBalance(spendingSats) + + // A zero-balance channel still costs an on-chain close later, and would be orphaned while + // the app reports Ark as the spending backend. + if (lightningRepo.getChannels().orEmpty().isNotEmpty()) { + return SpendingBackendSwitchState.Blocked.OpenChannels + } + return SpendingBackendSwitchState.Allowed + } + + private suspend fun checkLeavingBark(): SpendingBackendSwitchState { + val state = barkRepo.barkState.value + if (state.hasAnyFunds) { + return SpendingBackendSwitchState.Blocked.SpendingBalance(state.spendableSats) + } + val hasPendingExits = barkRepo.hasPendingExits().getOrDefault(false) + if (hasPendingExits) return SpendingBackendSwitchState.Blocked.PendingExit + return SpendingBackendSwitchState.Allowed + } +} + +sealed interface SpendingBackendSwitchState { + data object Allowed : SpendingBackendSwitchState + + sealed interface Blocked : SpendingBackendSwitchState { + /** Funds must be moved to savings before the backend can change. */ + data class SpendingBalance(val sats: ULong) : Blocked + + data object OpenChannels : Blocked + data object PendingTransfer : Blocked + data object PendingExit : Blocked + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 71616e5db7..c089e1ba11 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -802,6 +802,15 @@ Coin Selection Method Largest First Sort by and use largest UTXO first. Potentially lower fee, but reveals your largest UTXOs. + Close your Lightning connections before switching payment backend. + An Ark emergency exit is still in progress. Wait for it to finish before switching payment backend. + Move your spending balance to savings before switching payment backend. + Can\'t Switch Yet + A transfer is still in progress. Wait for it to finish before switching payment backend. + Use Ark (experimental) + I Understand, Switch + Ark funds are not backed up. Your recovery phrase alone will not restore them, and losing this device means losing them. Use small amounts only. + Experimental Feature Auto Electrum Server Lightning Connections diff --git a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt index 995c778e03..2d013112d6 100644 --- a/app/src/test/java/to/bitkit/ui/ContentViewTest.kt +++ b/app/src/test/java/to/bitkit/ui/ContentViewTest.kt @@ -12,6 +12,12 @@ class ContentViewTest { assertEquals(Routes.SpendingAmount, transferSpendingStartRoute(hasSeenSpendingIntro = true)) } + @Test + fun `savings start route uses intro until seen`() { + assertEquals(Routes.SavingsIntro, transferSavingsStartRoute(hasSeenSavingsIntro = false)) + assertEquals(Routes.SavingsAvailability, transferSavingsStartRoute(hasSeenSavingsIntro = true)) + } + @Test fun `hardware spending start route keeps device id after intro`() { val deviceId = "trezor-1" diff --git a/app/src/test/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCaseTest.kt new file mode 100644 index 0000000000..bf34436001 --- /dev/null +++ b/app/src/test/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCaseTest.kt @@ -0,0 +1,121 @@ +package to.bitkit.usecases + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Before +import org.junit.Test +import org.lightningdevkit.ldknode.ChannelDetails +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking +import to.bitkit.data.SpendingBackend +import to.bitkit.models.BalanceState +import to.bitkit.repositories.BarkRepo +import to.bitkit.repositories.BarkState +import to.bitkit.repositories.LightningRepo +import to.bitkit.repositories.TransferRepo +import to.bitkit.repositories.WalletRepo +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class CanSwitchSpendingBackendUseCaseTest : BaseUnitTest() { + + private val walletRepo: WalletRepo = mock() + private val lightningRepo: LightningRepo = mock() + private val barkRepo: BarkRepo = mock() + private val transferRepo: TransferRepo = mock() + + private val balanceState = MutableStateFlow(BalanceState()) + private val barkState = MutableStateFlow(BarkState()) + + private lateinit var sut: CanSwitchSpendingBackendUseCase + + @Before + fun setUp() { + whenever(walletRepo.balanceState).thenReturn(balanceState) + whenever(barkRepo.barkState).thenReturn(barkState) + whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) + whenever(lightningRepo.getChannels()).thenReturn(emptyList()) + wheneverBlocking { barkRepo.hasPendingExits() }.thenReturn(Result.success(false)) + + sut = CanSwitchSpendingBackendUseCase( + walletRepo = walletRepo, + lightningRepo = lightningRepo, + barkRepo = barkRepo, + transferRepo = transferRepo, + ) + } + + // region ldk -> bark + + @Test + fun `allows switching to bark with no spending balance and no channels`() = test { + assertEquals(SpendingBackendSwitchState.Allowed, sut(SpendingBackend.BARK)) + } + + @Test + fun `blocks switching to bark while a spending balance exists`() = test { + balanceState.value = BalanceState(totalLightningSats = 42_000uL) + + val result = sut(SpendingBackend.BARK) + + val blocked = assertIs(result) + assertEquals(42_000uL, blocked.sats) + } + + @Test + fun `blocks switching to bark while a channel is still open`() = test { + whenever(lightningRepo.getChannels()).thenReturn(listOf(mock())) + + assertEquals(SpendingBackendSwitchState.Blocked.OpenChannels, sut(SpendingBackend.BARK)) + } + + // endregion + + // region bark -> ldk + + @Test + fun `allows switching back to ldk with an empty ark wallet`() = test { + assertEquals(SpendingBackendSwitchState.Allowed, sut(SpendingBackend.LDK)) + } + + @Test + fun `blocks switching back to ldk while vtxos are spendable`() = test { + barkState.value = BarkState(spendableSats = 1_000uL) + + val blocked = assertIs(sut(SpendingBackend.LDK)) + assertEquals(1_000uL, blocked.sats) + } + + @Test + fun `blocks switching back to ldk while a board is still confirming`() = test { + barkState.value = BarkState(pendingBoardSats = 5_000uL) + + assertIs(sut(SpendingBackend.LDK)) + } + + @Test + fun `blocks switching back to ldk while a lightning receive is claimable`() = test { + barkState.value = BarkState(claimableLightningReceiveSats = 250uL) + + assertIs(sut(SpendingBackend.LDK)) + } + + @Test + fun `blocks switching back to ldk while an exit is in progress`() = test { + wheneverBlocking { barkRepo.hasPendingExits() }.thenReturn(Result.success(true)) + + assertEquals(SpendingBackendSwitchState.Blocked.PendingExit, sut(SpendingBackend.LDK)) + } + + // endregion + + @Test + fun `blocks either direction while a transfer is in flight`() = test { + whenever(transferRepo.activeTransfers).thenReturn(flowOf(listOf(mock()))) + + assertEquals(SpendingBackendSwitchState.Blocked.PendingTransfer, sut(SpendingBackend.BARK)) + assertEquals(SpendingBackendSwitchState.Blocked.PendingTransfer, sut(SpendingBackend.LDK)) + } +} From 0124a9d1f24de933f74de7864d9cc05199e8749f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 10:51:22 -0300 Subject: [PATCH 08/11] feat: refresh ark vtxos in background to avoid expiry Co-Authored-By: Claude Opus 5 (1M context) --- .../java/to/bitkit/repositories/BarkRepo.kt | 13 +++ .../to/bitkit/viewmodels/WalletViewModel.kt | 10 ++- .../bitkit/workers/BarkMaintenanceWorker.kt | 80 +++++++++++++++++++ .../bitkit/repositories/ActivityRepoTest.kt | 2 +- .../java/to/bitkit/ui/WalletViewModelTest.kt | 5 +- .../CanSwitchSpendingBackendUseCaseTest.kt | 5 +- 6 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/to/bitkit/workers/BarkMaintenanceWorker.kt diff --git a/app/src/main/java/to/bitkit/repositories/BarkRepo.kt b/app/src/main/java/to/bitkit/repositories/BarkRepo.kt index 34a547dee5..ddf44f856e 100644 --- a/app/src/main/java/to/bitkit/repositories/BarkRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BarkRepo.kt @@ -71,6 +71,19 @@ class BarkRepo @Inject constructor( return start(walletIndex) } + /** + * Brings the wallet up to date after a period offline: claim anything received while away, + * then refresh VTXOs that are approaching expiry. + */ + suspend fun onForeground(): Result = executeWhenRunning("onForeground") { + runSuspendCatching { + barkService.sync() + barkService.syncPendingBoards() + barkService.tryClaimAllLightningReceives() + barkService.maintenanceDelegated() + } + }.also { syncState() } + suspend fun start(walletIndex: Int = 0): Result = withContext(bgDispatcher) { if (!Env.isArkSupported) { return@withContext Result.failure(BarkError.NotSupported(Env.network.name)) diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index f6d31a13a0..932443f13f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.work.WorkManager import com.synonym.bitkitcore.BoltzSwapEvent import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -47,6 +48,7 @@ import to.bitkit.ui.onboarding.LOADING_MS import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import to.bitkit.utils.isTxSyncTimeout +import to.bitkit.workers.BarkMaintenanceWorker import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.milliseconds @@ -328,7 +330,13 @@ class WalletViewModel @Inject constructor( walletRepo.setWalletExistsState() connectMigrationPeers() migrationService.cleanupInvalidMigrationTransfers() - barkRepo.startIfEnabled(walletIndex) + barkRepo.startIfEnabled(walletIndex).onSuccess { + if (barkRepo.isEnabledNow()) { + // VTXOs expire, so background maintenance is not optional. + BarkMaintenanceWorker.schedule(WorkManager.getInstance(context)) + barkRepo.onForeground() + } + } walletRepo.syncBalances() if (_restoreState.value.isIdle()) { walletRepo.refreshBip21() diff --git a/app/src/main/java/to/bitkit/workers/BarkMaintenanceWorker.kt b/app/src/main/java/to/bitkit/workers/BarkMaintenanceWorker.kt new file mode 100644 index 0000000000..c72de50bc7 --- /dev/null +++ b/app/src/main/java/to/bitkit/workers/BarkMaintenanceWorker.kt @@ -0,0 +1,80 @@ +package to.bitkit.workers + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import to.bitkit.repositories.BarkRepo +import to.bitkit.utils.Logger +import kotlin.time.Duration.Companion.hours +import kotlin.time.toJavaDuration + +/** + * Keeps Ark funds alive while the app is backgrounded. + * + * VTXOs expire (~28 days for round VTXOs, ~3 days for Lightning receives) and the Ark server can + * sweep them once they do, so a wallet that never comes online loses its balance. Delegated + * maintenance is the mobile path: co-signers refresh on the wallet's behalf, so this worker does + * not have to land inside a round window. + */ +@HiltWorker +class BarkMaintenanceWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters, + private val barkRepo: BarkRepo, +) : CoroutineWorker(appContext, workerParams) { + + companion object { + private const val TAG = "BarkMaintenanceWorker" + private const val WORK_NAME = "bark_maintenance" + + /** + * Well inside the ~3 day Lightning-receive VTXO lifetime, which is the shortest deadline + * the wallet has to meet. + */ + private val INTERVAL = 6.hours + + fun schedule(workManager: WorkManager) { + val request = PeriodicWorkRequestBuilder(INTERVAL.toJavaDuration()) + .setConstraints( + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() + ) + .build() + workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request) + } + + fun cancel(workManager: WorkManager) = run { workManager.cancelUniqueWork(WORK_NAME) } + } + + override suspend fun doWork(): Result { + if (!barkRepo.isEnabledNow()) { + Logger.debug("Skipped Ark maintenance because bark is not the spending backend", context = TAG) + cancel(WorkManager.getInstance(applicationContext)) + return Result.success() + } + + return barkRepo.startIfEnabled() + .mapCatching { + barkRepo.runMaintenance().getOrThrow() + barkRepo.claimPendingReceives().getOrThrow() + barkRepo.sync().getOrThrow() + } + .fold( + onSuccess = { + Logger.info("Completed Ark maintenance", context = TAG) + Result.success() + }, + onFailure = { + Logger.error("Failed Ark maintenance, will retry", it, context = TAG) + Result.retry() + }, + ) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index ebf6f807c8..f5d8390a58 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -133,7 +133,7 @@ class ActivityRepoTest : BaseUnitTest() { whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) // These tests cover the ldk-node backend; bark has its own movement-sync path. - wheneverBlocking { barkRepo.isEnabledNow() }.thenReturn(false) + whenever { barkRepo.isEnabledNow() }.thenReturn(false) sut = ActivityRepo( bgDispatcher = testDispatcher, diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 145942dcb3..c3e73db256 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -25,10 +25,10 @@ import to.bitkit.data.SettingsStore import to.bitkit.ext.of import to.bitkit.models.BalanceState import to.bitkit.repositories.BackupRepo +import to.bitkit.repositories.BarkRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState -import to.bitkit.repositories.BarkRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.PubkyRepo @@ -69,6 +69,9 @@ class WalletViewModelTest : BaseUnitTest() { whenever(context.getString(any())).thenReturn("") whenever(walletRepo.walletState).thenReturn(walletState) whenever(lightningRepo.lightningState).thenReturn(lightningState) + // These tests exercise the ldk-node startup path; bark stays disabled. + whenever { barkRepo.isEnabledNow() }.thenReturn(false) + whenever { barkRepo.startIfEnabled(any()) }.thenReturn(Result.success(Unit)) whenever(migrationService.isMigrationChecked()).thenReturn(true) whenever(migrationService.isChannelRecoveryChecked()).thenReturn(true) whenever(migrationService.tryFetchMigrationPeersFromBackup()).thenReturn(emptyList()) diff --git a/app/src/test/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCaseTest.kt index bf34436001..378b4d815e 100644 --- a/app/src/test/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/CanSwitchSpendingBackendUseCaseTest.kt @@ -7,7 +7,6 @@ import org.junit.Test import org.lightningdevkit.ldknode.ChannelDetails import org.mockito.kotlin.mock import org.mockito.kotlin.whenever -import org.mockito.kotlin.wheneverBlocking import to.bitkit.data.SpendingBackend import to.bitkit.models.BalanceState import to.bitkit.repositories.BarkRepo @@ -37,7 +36,7 @@ class CanSwitchSpendingBackendUseCaseTest : BaseUnitTest() { whenever(barkRepo.barkState).thenReturn(barkState) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(lightningRepo.getChannels()).thenReturn(emptyList()) - wheneverBlocking { barkRepo.hasPendingExits() }.thenReturn(Result.success(false)) + whenever { barkRepo.hasPendingExits() }.thenReturn(Result.success(false)) sut = CanSwitchSpendingBackendUseCase( walletRepo = walletRepo, @@ -104,7 +103,7 @@ class CanSwitchSpendingBackendUseCaseTest : BaseUnitTest() { @Test fun `blocks switching back to ldk while an exit is in progress`() = test { - wheneverBlocking { barkRepo.hasPendingExits() }.thenReturn(Result.success(true)) + whenever { barkRepo.hasPendingExits() }.thenReturn(Result.success(true)) assertEquals(SpendingBackendSwitchState.Blocked.PendingExit, sut(SpendingBackend.LDK)) } From 6b7b4290a828afd14f4adb6d556a991b2ef5c4f3 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 10:54:01 -0300 Subject: [PATCH 09/11] feat: hide lightning-only ui when ark is enabled Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/to/bitkit/ui/ContentView.kt | 2 ++ .../to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt | 9 +++++---- .../main/java/to/bitkit/viewmodels/SettingsViewModel.kt | 5 +++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 26d4063fcd..ce6b44f79e 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1022,9 +1022,11 @@ private fun NavGraphBuilder.home( val hasSeenSpendingIntro by settingsViewModel.hasSeenSpendingIntro.collectAsStateWithLifecycle() val lightningState by walletViewModel.lightningState.collectAsStateWithLifecycle() val lightningActivities by activityListViewModel.lightningActivities.collectAsStateWithLifecycle() + val isArkEnabled by settingsViewModel.isArkEnabled.collectAsStateWithLifecycle() SpendingWalletScreen( channels = lightningState.channels, + isArkEnabled = isArkEnabled, lightningActivities = lightningActivities ?: persistentListOf(), onAllActivityButtonClick = { navController.navigateToAllActivity(activityListViewModel::clearFilters) }, onActivityItemClick = { navController.navToActivityDetail(it) }, diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index 9432a30df7..3b31b554c2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -68,6 +68,7 @@ fun SpendingWalletScreen( onTransferToSavingsClick: () -> Unit, onTransferFromSavingsClick: () -> Unit, onBackClick: () -> Unit, + isArkEnabled: Boolean = false, balances: BalanceState = LocalBalances.current, ) { val showEmptyState by remember(balances.totalLightningSats, lightningActivities.size) { @@ -75,10 +76,10 @@ fun SpendingWalletScreen( val hasActivity = lightningActivities.isNotEmpty() mutableStateOf(hasLnFunds && !hasActivity) } - val canTransfer by remember(balances.totalLightningSats, channels.size) { - val hasLnBalance = balances.totalLightningSats > 0uL - val hasChannels = channels.isNotEmpty() - mutableStateOf(hasLnBalance && hasChannels) + val canTransfer by remember(balances.totalLightningSats, channels.size, isArkEnabled) { + val hasSpendingBalance = balances.totalLightningSats > 0uL + // Ark has no channels: a spendable VTXO balance is all an offboard needs. + mutableStateOf(hasSpendingBalance && (isArkEnabled || channels.isNotEmpty())) } val canTransferFromSavings by remember(showEmptyState, balances.totalOnchainSats) { mutableStateOf(showEmptyState && balances.totalOnchainSats > 0uL) diff --git a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt index 9410f5c5f2..893588bf22 100644 --- a/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/SettingsViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.SettingsStore +import to.bitkit.data.SpendingBackend import to.bitkit.data.WidgetsStore import to.bitkit.data.hasPaykitState import to.bitkit.data.hasPublicPaykitPublicationState @@ -393,6 +394,10 @@ class SettingsViewModel @Inject constructor( val enableSendAmountWarning = settingsStore.data.map { it.enableSendAmountWarning } .asStateFlow(initialValue = false) + /** Whether Ark (bark) currently provides the spending balance instead of ldk-node. */ + val isArkEnabled = settingsStore.data.map { it.spendingBackend == SpendingBackend.BARK } + .asStateFlow(initialValue = false) + fun setEnableSendAmountWarning(value: Boolean) { viewModelScope.launch { settingsStore.update { it.copy(enableSendAmountWarning = value) } From 2cc0ff3f2ba8b22746bbc2eaa0ba351f7108c0d2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 11:01:09 -0300 Subject: [PATCH 10/11] feat: add ark board and offboard transfer flows Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/to/bitkit/data/CacheStore.kt | 2 + .../to/bitkit/data/dto/PendingBarkBoard.kt | 18 ++ .../to/bitkit/repositories/ActivityRepo.kt | 4 +- .../bitkit/repositories/BarkTransferRepo.kt | 128 +++++++++++++ .../repositories/PreActivityMetadataRepo.kt | 2 +- .../transfer/hardware/SpendingHwSignScreen.kt | 2 +- .../ui/screens/trezor/TrezorPreviewData.kt | 6 +- .../usecases/DeriveBalanceStateUseCase.kt | 7 +- .../to/bitkit/viewmodels/WalletViewModel.kt | 4 + .../bitkit/workers/BarkMaintenanceWorker.kt | 3 + .../to/bitkit/ext/TrezorExceptionExtTest.kt | 2 +- .../repositories/BarkTransferRepoTest.kt | 178 ++++++++++++++++++ .../java/to/bitkit/ui/WalletViewModelTest.kt | 6 + .../sheets/BoostTransactionViewModelTest.kt | 3 +- .../usecases/DeriveBalanceStateUseCaseTest.kt | 6 +- 15 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/to/bitkit/data/dto/PendingBarkBoard.kt create mode 100644 app/src/main/java/to/bitkit/repositories/BarkTransferRepo.kt create mode 100644 app/src/test/java/to/bitkit/repositories/BarkTransferRepoTest.kt diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt index dc337a825f..6bfd8926ea 100644 --- a/app/src/main/java/to/bitkit/data/CacheStore.kt +++ b/app/src/main/java/to/bitkit/data/CacheStore.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.serialization.Serializable +import to.bitkit.data.dto.PendingBarkBoard import to.bitkit.data.dto.PendingBoostActivity import to.bitkit.data.serializers.AppCacheSerializer import to.bitkit.ext.scopedActivityId @@ -164,6 +165,7 @@ data class AppCacheData( val backgroundReceive: NewTransactionSheetDetails? = null, val addressSearchLastUsedReceiveIndexes: Map = mapOf(), val addressSearchLastUsedChangeIndexes: Map = mapOf(), + val pendingBarkBoard: PendingBarkBoard? = null, ) { fun isActivityDeleted(activityId: String, walletId: String): Boolean = scopedActivityId(walletId, activityId) in deletedActivities || diff --git a/app/src/main/java/to/bitkit/data/dto/PendingBarkBoard.kt b/app/src/main/java/to/bitkit/data/dto/PendingBarkBoard.kt new file mode 100644 index 0000000000..a88ce5750b --- /dev/null +++ b/app/src/main/java/to/bitkit/data/dto/PendingBarkBoard.kt @@ -0,0 +1,18 @@ +package to.bitkit.data.dto + +import kotlinx.serialization.Serializable + +/** + * A savings -> spending transfer in bark mode, which takes two on-chain steps: an ldk-node send into + * bark's own on-chain wallet, then a board of that amount onto Ark once it has confirmed. + * + * Persisted so the second step survives the app being killed between them; without it the sats + * would sit in bark's on-chain wallet, invisible in both balances. + */ +@Serializable +data class PendingBarkBoard( + /** Txid of the ldk-node send that funds bark's on-chain wallet. */ + val fundingTxId: String, + val amountSats: ULong, + val createdAtMillis: Long, +) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index a7b892439a..46170156b7 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -41,10 +41,9 @@ import to.bitkit.ext.isReplacedSentTransaction import to.bitkit.ext.matchesPaymentId import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp -import to.bitkit.ext.toActivity -import uniffi.bark.Movement import to.bitkit.ext.rawId import to.bitkit.ext.runSuspendCatching +import to.bitkit.ext.toActivity import to.bitkit.ext.walletId import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.PubkyPublicKeyFormat @@ -52,6 +51,7 @@ import to.bitkit.models.WalletScope import to.bitkit.services.CoreService import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import uniffi.bark.Movement import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Clock diff --git a/app/src/main/java/to/bitkit/repositories/BarkTransferRepo.kt b/app/src/main/java/to/bitkit/repositories/BarkTransferRepo.kt new file mode 100644 index 0000000000..1a70f79fca --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/BarkTransferRepo.kt @@ -0,0 +1,128 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import to.bitkit.data.CacheStore +import to.bitkit.data.dto.PendingBarkBoard +import to.bitkit.di.BgDispatcher +import to.bitkit.ext.nowMillis +import to.bitkit.ext.runSuspendCatching +import to.bitkit.utils.AppError +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +/** + * Moves funds between savings (ldk-node on-chain) and Ark spending. + * + * Offboarding is one step, because bark can send straight to any on-chain address. Boarding is two, + * because bark boards from its *own* on-chain wallet: savings must be sent there first, and only + * once that transaction confirms can the board happen. ldk-node exposes no PSBT API, so bark's + * on-chain wallet cannot be backed by the savings wallet to collapse this into one step. + */ +@OptIn(ExperimentalTime::class) +@Singleton +class BarkTransferRepo @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val lightningRepo: LightningRepo, + private val barkRepo: BarkRepo, + private val cacheStore: CacheStore, + private val clock: Clock, +) { + companion object { + private const val TAG = "BarkTransferRepo" + } + + /** + * Step 1 of savings -> spending: fund bark's on-chain wallet and record the intent. Step 2 runs + * from [resumePendingBoard] once the funding transaction has enough confirmations. + */ + suspend fun startBoard(amountSats: ULong): Result = withContext(bgDispatcher) { + runSuspendCatching { + if (cacheStore.data.first().pendingBarkBoard != null) { + throw AppError("A board is already in progress") + } + + val arkInfo = barkRepo.barkState.value.arkInfo + val minBoardSats = arkInfo?.minBoardAmountSats + if (minBoardSats != null && amountSats < minBoardSats) { + throw AppError("Amount is below the Ark minimum of $minBoardSats sats") + } + val maxVtxoSats = arkInfo?.maxVtxoAmountSats + if (maxVtxoSats != null && amountSats > maxVtxoSats) { + throw AppError("Amount is above the Ark maximum of $maxVtxoSats sats") + } + + val depositAddress = barkRepo.onchainDepositAddress().getOrThrow() + val txId = lightningRepo.sendOnChain( + address = depositAddress, + sats = amountSats, + isTransfer = true, + ).getOrThrow() + + cacheStore.update { + it.copy( + pendingBarkBoard = PendingBarkBoard( + fundingTxId = txId, + amountSats = amountSats, + createdAtMillis = nowMillis(clock), + ) + ) + } + Logger.info("Started Ark board of '$amountSats' sats via '$txId'", context = TAG) + txId + }.onFailure { + Logger.error("Failed to start Ark board", it, context = TAG) + } + } + + /** + * Step 2: board once bark's on-chain wallet actually holds the funds. Called on every sync, so + * it must be cheap and idempotent while the funding transaction is still unconfirmed. + */ + suspend fun resumePendingBoard(): Result = withContext(bgDispatcher) { + runSuspendCatching { + val pending = cacheStore.data.first().pendingBarkBoard ?: return@runSuspendCatching false + + val confirmedSats = barkRepo.onchainSpendableSats().getOrThrow() + if (confirmedSats < pending.amountSats) { + Logger.debug( + "Waiting on Ark board funding '${pending.fundingTxId}': " + + "confirmed '$confirmedSats' of '${pending.amountSats}' sats", + context = TAG, + ) + return@runSuspendCatching false + } + + barkRepo.board(pending.amountSats).getOrThrow() + cacheStore.update { it.copy(pendingBarkBoard = null) } + Logger.info("Completed Ark board of '${pending.amountSats}' sats", context = TAG) + true + }.onFailure { + Logger.error("Failed to resume Ark board", it, context = TAG) + } + } + + /** Sats already sent to bark but not yet boarded, shown as in-transfer to spending. */ + suspend fun pendingBoardSats(): ULong = cacheStore.data.first().pendingBarkBoard?.amountSats ?: 0uL + + /** Spending -> savings: offboard every VTXO to a fresh ldk-node on-chain address. */ + suspend fun offboardToSavings(): Result = withContext(bgDispatcher) { + runSuspendCatching { + val savingsAddress = lightningRepo.newAddress().getOrThrow() + barkRepo.offboardAll(savingsAddress).getOrThrow() + }.onFailure { + Logger.error("Failed to offboard Ark balance to savings", it, context = TAG) + } + } + + suspend fun estimateOffboardFeeSats(): Result = withContext(bgDispatcher) { + runSuspendCatching { + val savingsAddress = lightningRepo.newAddress().getOrThrow() + barkRepo.estimateOffboardAllFeeSats(savingsAddress).getOrThrow() + } + } +} diff --git a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt index a5c60d01b5..4b09c1c6e1 100644 --- a/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PreActivityMetadataRepo.kt @@ -10,8 +10,8 @@ import kotlinx.coroutines.withContext import to.bitkit.di.IoDispatcher import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp -import to.bitkit.services.CoreService import to.bitkit.models.WalletScope +import to.bitkit.services.CoreService import to.bitkit.utils.Logger import javax.inject.Inject import javax.inject.Singleton diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt index 3c4a947802..1de97efcee 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/hardware/SpendingHwSignScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.synonym.bitkitcore.IBtOrder import to.bitkit.R +import to.bitkit.models.safe import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Display import to.bitkit.ui.components.FeeInfo @@ -36,7 +37,6 @@ import to.bitkit.ui.screens.transfer.previewBtOrder import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.models.safe import to.bitkit.viewmodels.TransferViewModel @Composable diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 76795a937b..c1d8a5c16b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -316,7 +316,8 @@ internal object TrezorPreviewData { val sampleWatcherActivities = listOf( Activity.Onchain( - OnchainActivity.create(walletId = "wallet0", + OnchainActivity.create( + walletId = "wallet0", id = SAMPLE_TXID, txType = PaymentType.RECEIVED, txId = SAMPLE_TXID, @@ -328,7 +329,8 @@ internal object TrezorPreviewData { ), ), Activity.Onchain( - OnchainActivity.create(walletId = "wallet0", + OnchainActivity.create( + walletId = "wallet0", id = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", txType = PaymentType.SENT, txId = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", diff --git a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt index 4c39090858..813d451c2a 100644 --- a/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt +++ b/app/src/main/java/to/bitkit/usecases/DeriveBalanceStateUseCase.kt @@ -20,6 +20,7 @@ import to.bitkit.models.TransferType import to.bitkit.models.safe import to.bitkit.models.toBalance import to.bitkit.repositories.BarkRepo +import to.bitkit.repositories.BarkTransferRepo import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo @@ -28,11 +29,13 @@ import to.bitkit.utils.jsonLogOf import javax.inject.Inject import javax.inject.Singleton +@Suppress("LongParameterList") @Singleton class DeriveBalanceStateUseCase @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, private val barkRepo: BarkRepo, + private val barkTransferRepo: BarkTransferRepo, private val transferRepo: TransferRepo, private val settingsStore: SettingsStore, private val hwWalletRepo: HwWalletRepo, @@ -82,7 +85,9 @@ class DeriveBalanceStateUseCase @Inject constructor( toSavingsAmount.safe() - coopCloseSavingsSats.safe() }, balanceInTransferToSpending = if (isBarkBackend) { - barkState.pendingIncomingSats + // Sats already sent to bark's onchain wallet are in neither balance until the + // board completes, so surface them as in-transfer rather than losing them. + barkState.pendingIncomingSats.safe() + barkTransferRepo.pendingBoardSats().safe() } else { toSpendingAmount }, diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 932443f13f..30780b88b7 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -34,6 +34,7 @@ import to.bitkit.ext.runSuspendCatching import to.bitkit.models.Toast import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BarkRepo +import to.bitkit.repositories.BarkTransferRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState @@ -62,6 +63,7 @@ class WalletViewModel @Inject constructor( private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, private val barkRepo: BarkRepo, + private val barkTransferRepo: BarkTransferRepo, private val settingsStore: SettingsStore, private val backupRepo: BackupRepo, private val blocktankRepo: BlocktankRepo, @@ -335,6 +337,8 @@ class WalletViewModel @Inject constructor( // VTXOs expire, so background maintenance is not optional. BarkMaintenanceWorker.schedule(WorkManager.getInstance(context)) barkRepo.onForeground() + // A board interrupted by the app dying resumes here. + barkTransferRepo.resumePendingBoard() } } walletRepo.syncBalances() diff --git a/app/src/main/java/to/bitkit/workers/BarkMaintenanceWorker.kt b/app/src/main/java/to/bitkit/workers/BarkMaintenanceWorker.kt index c72de50bc7..ae9fad635f 100644 --- a/app/src/main/java/to/bitkit/workers/BarkMaintenanceWorker.kt +++ b/app/src/main/java/to/bitkit/workers/BarkMaintenanceWorker.kt @@ -12,6 +12,7 @@ import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject import to.bitkit.repositories.BarkRepo +import to.bitkit.repositories.BarkTransferRepo import to.bitkit.utils.Logger import kotlin.time.Duration.Companion.hours import kotlin.time.toJavaDuration @@ -29,6 +30,7 @@ class BarkMaintenanceWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted workerParams: WorkerParameters, private val barkRepo: BarkRepo, + private val barkTransferRepo: BarkTransferRepo, ) : CoroutineWorker(appContext, workerParams) { companion object { @@ -64,6 +66,7 @@ class BarkMaintenanceWorker @AssistedInject constructor( .mapCatching { barkRepo.runMaintenance().getOrThrow() barkRepo.claimPendingReceives().getOrThrow() + barkTransferRepo.resumePendingBoard().getOrThrow() barkRepo.sync().getOrThrow() } .fold( diff --git a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt index 0fbac4f234..3fe5434278 100644 --- a/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/TrezorExceptionExtTest.kt @@ -1,10 +1,10 @@ package to.bitkit.ext import com.synonym.bitkitcore.TrezorException +import org.junit.Test import to.bitkit.utils.AppError import kotlin.test.assertFalse import kotlin.test.assertTrue -import org.junit.Test class TrezorExceptionExtTest { @Test diff --git a/app/src/test/java/to/bitkit/repositories/BarkTransferRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BarkTransferRepoTest.kt new file mode 100644 index 0000000000..579b897e34 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/BarkTransferRepoTest.kt @@ -0,0 +1,178 @@ +package to.bitkit.repositories + +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.dto.PendingBarkBoard +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +class BarkTransferRepoTest : BaseUnitTest() { + + private val lightningRepo: LightningRepo = mock() + private val barkRepo: BarkRepo = mock() + private val cacheStore: CacheStore = mock() + private val clock: Clock = mock() + + private val cacheData = MutableStateFlow(AppCacheData()) + private val barkState = MutableStateFlow(BarkState()) + + private lateinit var sut: BarkTransferRepo + + @Before + fun setUp() { + whenever(cacheStore.data).thenReturn(cacheData) + whenever(barkRepo.barkState).thenReturn(barkState) + whenever(clock.now()).thenReturn(Clock.System.now()) + whenever { cacheStore.update(any()) }.thenAnswer { invocation -> + val transform = invocation.getArgument<(AppCacheData) -> AppCacheData>(0) + cacheData.value = transform(cacheData.value) + Unit + } + whenever { barkRepo.onchainDepositAddress() }.thenReturn(Result.success(BARK_ADDRESS)) + whenever { + lightningRepo.sendOnChain( + any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), + any(), anyOrNull(), any(), any(), + ) + }.thenReturn(Result.success(FUNDING_TXID)) + whenever { barkRepo.board(any()) }.thenReturn(Result.success(FUNDING_TXID)) + whenever { lightningRepo.newAddress() }.thenReturn(Result.success(SAVINGS_ADDRESS)) + whenever { barkRepo.offboardAll(any()) }.thenReturn(Result.success("round-1")) + + sut = BarkTransferRepo( + bgDispatcher = testDispatcher, + lightningRepo = lightningRepo, + barkRepo = barkRepo, + cacheStore = cacheStore, + clock = clock, + ) + } + + // region board + + @Test + fun `startBoard funds bark and records the pending board`() = test { + val result = sut.startBoard(50_000uL) + + assertEquals(FUNDING_TXID, result.getOrNull()) + val pending = assertNotNull(cacheData.value.pendingBarkBoard) + assertEquals(FUNDING_TXID, pending.fundingTxId) + assertEquals(50_000uL, pending.amountSats) + // The board itself must wait for confirmations. + verify(barkRepo, never()).board(any()) + } + + @Test + fun `startBoard refuses a second board while one is in flight`() = test { + cacheData.value = AppCacheData(pendingBarkBoard = newPendingBoard()) + + assertTrue(sut.startBoard(10_000uL).isFailure) + } + + @Test + fun `startBoard rejects an amount below the ark minimum`() = test { + barkState.value = BarkState(arkInfo = BarkArkInfo(600uL, 1u, 10_000uL, null)) + + assertTrue(sut.startBoard(5_000uL).isFailure) + assertNull(cacheData.value.pendingBarkBoard) + } + + @Test + fun `startBoard rejects an amount above the max vtxo size`() = test { + barkState.value = BarkState(arkInfo = BarkArkInfo(600uL, 1u, 1_000uL, 100_000uL)) + + assertTrue(sut.startBoard(200_000uL).isFailure) + assertNull(cacheData.value.pendingBarkBoard) + } + + // endregion + + // region resume + + @Test + fun `resumePendingBoard does nothing when there is no pending board`() = test { + val result = sut.resumePendingBoard() + + assertFalse(result.getOrThrow()) + verify(barkRepo, never()).board(any()) + } + + @Test + fun `resumePendingBoard waits while the funding tx is unconfirmed`() = test { + cacheData.value = AppCacheData(pendingBarkBoard = newPendingBoard(amountSats = 50_000uL)) + whenever { barkRepo.onchainSpendableSats() }.thenReturn(Result.success(0uL)) + + assertFalse(sut.resumePendingBoard().getOrThrow()) + verify(barkRepo, never()).board(any()) + // The intent must survive so a later run can finish it. + assertNotNull(cacheData.value.pendingBarkBoard) + } + + @Test + fun `resumePendingBoard boards and clears once funds have confirmed`() = test { + cacheData.value = AppCacheData(pendingBarkBoard = newPendingBoard(amountSats = 50_000uL)) + whenever { barkRepo.onchainSpendableSats() }.thenReturn(Result.success(50_000uL)) + + assertTrue(sut.resumePendingBoard().getOrThrow()) + verify(barkRepo).board(eq(50_000uL)) + assertNull(cacheData.value.pendingBarkBoard) + } + + @Test + fun `resumePendingBoard keeps the pending board when boarding fails`() = test { + cacheData.value = AppCacheData(pendingBarkBoard = newPendingBoard(amountSats = 50_000uL)) + whenever { barkRepo.onchainSpendableSats() }.thenReturn(Result.success(50_000uL)) + whenever { barkRepo.board(any()) }.thenReturn(Result.failure(RuntimeException("server down"))) + + assertTrue(sut.resumePendingBoard().isFailure) + assertNotNull(cacheData.value.pendingBarkBoard) + } + + @Test + fun `pendingBoardSats reports the in-flight amount`() = test { + assertEquals(0uL, sut.pendingBoardSats()) + + cacheData.value = AppCacheData(pendingBarkBoard = newPendingBoard(amountSats = 7_500uL)) + + assertEquals(7_500uL, sut.pendingBoardSats()) + } + + // endregion + + @Test + fun `offboardToSavings sends the ark balance to a fresh savings address`() = test { + val result = sut.offboardToSavings() + + assertEquals("round-1", result.getOrNull()) + verify(barkRepo).offboardAll(eq(SAVINGS_ADDRESS)) + } + + private fun newPendingBoard(amountSats: ULong = 50_000uL) = PendingBarkBoard( + fundingTxId = FUNDING_TXID, + amountSats = amountSats, + createdAtMillis = 0L, + ) + + private companion object { + const val BARK_ADDRESS = "bcrt1qbark" + const val SAVINGS_ADDRESS = "bcrt1qsavings" + const val FUNDING_TXID = "funding-txid" + } +} diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index c3e73db256..6abcf3213a 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -26,6 +26,7 @@ import to.bitkit.ext.of import to.bitkit.models.BalanceState import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BarkRepo +import to.bitkit.repositories.BarkTransferRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.ConnectivityRepo import to.bitkit.repositories.ConnectivityState @@ -50,6 +51,7 @@ class WalletViewModelTest : BaseUnitTest() { private val walletRepo = mock() private val lightningRepo = mock() private val barkRepo = mock() + private val barkTransferRepo = mock() private val settingsStore = mock() private val backupRepo = mock() private val blocktankRepo = mock() @@ -87,6 +89,7 @@ class WalletViewModelTest : BaseUnitTest() { walletRepo = walletRepo, lightningRepo = lightningRepo, barkRepo = barkRepo, + barkTransferRepo = barkTransferRepo, settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, @@ -332,6 +335,7 @@ class WalletViewModelTest : BaseUnitTest() { walletRepo = testWalletRepo, lightningRepo = testLightningRepo, barkRepo = barkRepo, + barkTransferRepo = barkTransferRepo, settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, @@ -399,6 +403,7 @@ class WalletViewModelTest : BaseUnitTest() { walletRepo = testWalletRepo, lightningRepo = testLightningRepo, barkRepo = barkRepo, + barkTransferRepo = barkTransferRepo, settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, @@ -455,6 +460,7 @@ class WalletViewModelTest : BaseUnitTest() { walletRepo = testWalletRepo, lightningRepo = testLightningRepo, barkRepo = barkRepo, + barkTransferRepo = barkTransferRepo, settingsStore = settingsStore, backupRepo = backupRepo, blocktankRepo = blocktankRepo, diff --git a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt index eda6245661..eadff023b8 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/BoostTransactionViewModelTest.kt @@ -57,7 +57,8 @@ class BoostTransactionViewModelTest : BaseUnitTest() { private val totalFee = 1000UL private val testValue = 50000UL - private val onchainActivity = OnchainActivity.create(walletId = "wallet0", + private val onchainActivity = OnchainActivity.create( + walletId = "wallet0", id = "test_id", txType = PaymentType.SENT, txId = mockTxId, diff --git a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt index 816069f2dd..8b731e09cc 100644 --- a/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt +++ b/app/src/test/java/to/bitkit/usecases/DeriveBalanceStateUseCaseTest.kt @@ -21,9 +21,10 @@ import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore import to.bitkit.data.entities.TransferEntity import to.bitkit.models.TransferType -import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.BarkRepo import to.bitkit.repositories.BarkState +import to.bitkit.repositories.BarkTransferRepo +import to.bitkit.repositories.HwWalletRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LightningState import to.bitkit.repositories.TransferRepo @@ -35,6 +36,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { private val lightningRepo: LightningRepo = mock() private val barkRepo: BarkRepo = mock() + private val barkTransferRepo: BarkTransferRepo = mock() private val transferRepo: TransferRepo = mock() private val settingsStore: SettingsStore = mock() private val hwWalletRepo: HwWalletRepo = mock() @@ -47,6 +49,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { whenever(settingsStore.data).thenReturn(flowOf(SettingsData())) whenever(lightningRepo.lightningState).thenReturn(MutableStateFlow(LightningState())) whenever(barkRepo.barkState).thenReturn(MutableStateFlow(BarkState())) + whenever { barkTransferRepo.pendingBoardSats() }.thenReturn(0uL) whenever(transferRepo.activeTransfers).thenReturn(flowOf(emptyList())) whenever(hwWalletRepo.wallets).thenReturn(MutableStateFlow(persistentListOf())) wheneverBlocking { lightningRepo.listSpendableOutputs() }.thenReturn(Result.success(emptyList())) @@ -63,6 +66,7 @@ class DeriveBalanceStateUseCaseTest : BaseUnitTest() { bgDispatcher = testDispatcher, lightningRepo = lightningRepo, barkRepo = barkRepo, + barkTransferRepo = barkTransferRepo, transferRepo = transferRepo, settingsStore = settingsStore, hwWalletRepo = hwWalletRepo, From aa2d38ddbec08b690664b3a57813681267d46db9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 3 Aug 2026 11:02:22 -0300 Subject: [PATCH 11/11] test: add ark journeys and changelog fragment Co-Authored-By: Claude Opus 5 (1M context) --- changelog.d/next/bark-poc.added.md | 1 + journeys/bark/bark-board.xml | 11 +++++++++++ journeys/bark/bark-enable.xml | 16 ++++++++++++++++ journeys/bark/bark-receive-lightning.xml | 11 +++++++++++ journeys/bark/bark-send-lightning.xml | 11 +++++++++++ journeys/bark/bark-toggle-gate.xml | 13 +++++++++++++ journeys/bark/ldk-regression.xml | 13 +++++++++++++ 7 files changed, 76 insertions(+) create mode 100644 changelog.d/next/bark-poc.added.md create mode 100644 journeys/bark/bark-board.xml create mode 100644 journeys/bark/bark-enable.xml create mode 100644 journeys/bark/bark-receive-lightning.xml create mode 100644 journeys/bark/bark-send-lightning.xml create mode 100644 journeys/bark/bark-toggle-gate.xml create mode 100644 journeys/bark/ldk-regression.xml diff --git a/changelog.d/next/bark-poc.added.md b/changelog.d/next/bark-poc.added.md new file mode 100644 index 0000000000..8375798c49 --- /dev/null +++ b/changelog.d/next/bark-poc.added.md @@ -0,0 +1 @@ +Added the ability to switch the spending balance from a local Lightning node to Ark (experimental, signet and mainnet only). diff --git a/journeys/bark/bark-board.xml b/journeys/bark/bark-board.xml new file mode 100644 index 0000000000..60fad76eb6 --- /dev/null +++ b/journeys/bark/bark-board.xml @@ -0,0 +1,11 @@ + + Precondition: onboarded signet wallet with Ark enabled and a funded savings balance. + + Verify that the wallet home screen shows a non-zero Savings balance + Tap the Spending balance + Tap the transfer from savings action + Enter an amount above the Ark minimum board amount + Confirm the transfer + Verify that the home screen shows the transferred amount as pending or in transfer + + diff --git a/journeys/bark/bark-enable.xml b/journeys/bark/bark-enable.xml new file mode 100644 index 0000000000..cc9d8b3530 --- /dev/null +++ b/journeys/bark/bark-enable.xml @@ -0,0 +1,16 @@ + + Precondition: onboarded signet wallet with zero spending balance and no open channels, Ark disabled. + + Verify that the wallet home screen shows a Spending balance of 0 + Open Settings + Tap the "Advanced" tab + Verify that a "Lightning Connections" row is visible + Verify that a "Lightning Node" row is visible + Tap the "Use Ark (experimental)" switch + Verify that a dialog titled "Experimental Feature" is shown and that it warns Ark funds are not backed up by the recovery phrase + Tap "I Understand, Switch" + Verify that the "Use Ark (experimental)" switch is on + Verify that the "Lightning Connections" row is not visible + Verify that the "Lightning Node" row is not visible + + diff --git a/journeys/bark/bark-receive-lightning.xml b/journeys/bark/bark-receive-lightning.xml new file mode 100644 index 0000000000..3315bced2e --- /dev/null +++ b/journeys/bark/bark-receive-lightning.xml @@ -0,0 +1,11 @@ + + Precondition: onboarded signet wallet with Ark enabled. Pay the invoice from https://signet.2nd.dev/ when prompted. + + Verify that the wallet home screen is shown + Tap the "Receive" button + Verify that a QR code and a Lightning invoice starting with "lntbs" are shown + Pay the displayed invoice from the signet faucet, then close the receive screen + Verify that the Spending balance on the home screen is greater than 0 + Verify that the activity list shows a received payment for the amount that was paid + + diff --git a/journeys/bark/bark-send-lightning.xml b/journeys/bark/bark-send-lightning.xml new file mode 100644 index 0000000000..539c0da606 --- /dev/null +++ b/journeys/bark/bark-send-lightning.xml @@ -0,0 +1,11 @@ + + Precondition: onboarded signet wallet with Ark enabled and a funded spending balance, plus a signet BOLT11 invoice to paste. + + Verify that the wallet home screen shows a non-zero Spending balance + Tap the "Send" button + Paste a signet BOLT11 invoice + Confirm the payment + Verify that a payment success screen is shown + Close the send flow and verify that the activity list shows a sent payment + + diff --git a/journeys/bark/bark-toggle-gate.xml b/journeys/bark/bark-toggle-gate.xml new file mode 100644 index 0000000000..310e67f829 --- /dev/null +++ b/journeys/bark/bark-toggle-gate.xml @@ -0,0 +1,13 @@ + + Precondition: onboarded signet wallet with a non-zero spending balance, Ark disabled. + + Verify that the wallet home screen shows a non-zero Spending balance + Open Settings + Tap the "Advanced" tab + Verify that a "Use Ark (experimental)" row with a switch is visible + Tap the "Use Ark (experimental)" switch + Verify that a dialog titled "Can't Switch Yet" is shown, that it says the spending balance must be moved to savings first, and that it offers a "Transfer To Savings" action + Tap "Transfer To Savings" + Verify that the app is on a transfer to savings screen + + diff --git a/journeys/bark/ldk-regression.xml b/journeys/bark/ldk-regression.xml new file mode 100644 index 0000000000..4d3f32b43a --- /dev/null +++ b/journeys/bark/ldk-regression.xml @@ -0,0 +1,13 @@ + + Precondition: onboarded dev (regtest) wallet. Guards that the Ark POC left the shipping ldk-node path untouched; the Ark toggle is hidden on regtest because no Ark server exists there. + + Verify that the wallet home screen shows Savings and Spending balances + Open Settings + Tap the "Advanced" tab + Verify that a "Lightning Connections" row is visible + Verify that a "Lightning Node" row is visible + Verify that no "Use Ark (experimental)" row is visible + Tap the "Lightning Connections" row + Verify that a Lightning connections screen is shown + +