diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 0557c36..5d01d92 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -28,7 +28,7 @@ jobs: java-version: 17 - name: "Set up Android SDK" - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@v4 - name: "Set up Android NDK" uses: nttld/setup-ndk@v1 diff --git a/jniLibs/README.md b/jniLibs/README.md deleted file mode 100644 index 9b83a31..0000000 --- a/jniLibs/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# README - -This directory vendors JNA's Android native library, `libjnidispatch.so`, for the ABIs the app ships. **Do not delete these files** — the app builds without them, but crashes at runtime on the first call into bdk. - -## Where the files come from - -They are copied verbatim out of the JNA aar — same bytes, no patching: - -```sh -curl -sLO https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.14.0/jna-5.14.0.aar -unzip -j jna-5.14.0.aar 'jni/arm64-v8a/libjnidispatch.so' -d jniLibs/arm64-v8a -unzip -j jna-5.14.0.aar 'jni/x86_64/libjnidispatch.so' -d jniLibs/x86_64 -``` - -Keep the version in sync with whatever JNA version `bdk-android` depends on. Check it with: - -```sh -grep -A3 'net.java.dev.jna' ~/Library/Caches/JetBrains/Kotlin/.m2.cache/org/bitcoindevkit/bdk-android//bdk-android-.pom -``` diff --git a/jniLibs/arm64-v8a/libjnidispatch.so b/jniLibs/arm64-v8a/libjnidispatch.so deleted file mode 100644 index cd7e671..0000000 Binary files a/jniLibs/arm64-v8a/libjnidispatch.so and /dev/null differ diff --git a/jniLibs/x86_64/libjnidispatch.so b/jniLibs/x86_64/libjnidispatch.so deleted file mode 100644 index e594b88..0000000 Binary files a/jniLibs/x86_64/libjnidispatch.so and /dev/null differ diff --git a/kotlin b/kotlin index 914d331..d8ae3c0 100755 --- a/kotlin +++ b/kotlin @@ -17,9 +17,9 @@ set -e -u # The version of the Kotlin Toolchain (and CLI) distribution to provision and use -kotlin_cli_version=0.12.2 +kotlin_cli_version=0.13.0-dev-4413 # Establish chain of trust from here by specifying exact checksum of Kotlin Toolchain (and CLI) distribution to be run -kotlin_cli_sha256=9fec42de4f378a27240b78c1b66ffef0c2aca66a4a6390ca67fe51aacb75677e +kotlin_cli_sha256=0e610451461a89237aeb66670786220ca56008212bfbfe72fc1635dc85c9bb03 KOTLIN_CLI_DOWNLOAD_ROOT="${KOTLIN_CLI_DOWNLOAD_ROOT:-https://packages.jetbrains.team/maven/p/amper/amper}" diff --git a/kotlin.bat b/kotlin.bat index 3349f2c..c4d54d6 100755 --- a/kotlin.bat +++ b/kotlin.bat @@ -17,9 +17,9 @@ setlocal @rem The version of the Kotlin Toolchain distribution to provision and use -set kotlin_cli_version=0.12.2 +set kotlin_cli_version=0.13.0-dev-4413 @rem Establish chain of trust from here by specifying the exact checksum of the Kotlin Toolchain distribution to be run -set kotlin_cli_sha256=9fec42de4f378a27240b78c1b66ffef0c2aca66a4a6390ca67fe51aacb75677e +set kotlin_cli_sha256=0e610451461a89237aeb66670786220ca56008212bfbfe72fc1635dc85c9bb03 if not defined KOTLIN_CLI_DOWNLOAD_ROOT set KOTLIN_CLI_DOWNLOAD_ROOT=https://packages.jetbrains.team/maven/p/amper/amper if not defined KOTLIN_CLI_BOOTSTRAP_CACHE_DIR set KOTLIN_CLI_BOOTSTRAP_CACHE_DIR=%LOCALAPPDATA%\JetBrains\Kotlin\cli diff --git a/src/org/bitcoindevkit/devkitwallet/domain/Checkpoints.kt b/src/org/bitcoindevkit/devkitwallet/domain/Checkpoints.kt new file mode 100644 index 0000000..5a06309 --- /dev/null +++ b/src/org/bitcoindevkit/devkitwallet/domain/Checkpoints.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2021-2026 thunderbiscuit and contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the ./LICENSE.txt file. + */ + +package org.bitcoindevkit.devkitwallet.domain + +import org.bitcoindevkit.BlockHash +import org.bitcoindevkit.BlockId +import org.bitcoindevkit.Network +import org.bitcoindevkit.RecoveryPoint + +/** + * A block hardcoded in the app, used as the starting point of a recovery scan for a wallet that has no history of its + * own yet. + * + * Recovering from the genesis block makes the Kyoto node walk every block filter the network ever produced, which is a + * lot of bandwidth for a wallet created today that cannot possibly hold coins older than its own keys. Each network + * therefore ships a checkpoint reasonably far along its chain, and a wallet created after that block still finds all of + * its history starting from there. + * + * @property height Height of the checkpoint block. + * @property hash Hash of the checkpoint block, as a hex string. + */ +data class BundledCheckpoint(val height: UInt, val hash: String) { + /** Turns the checkpoint into the [RecoveryPoint] the Kyoto node's recovery scan starts from. */ + fun toRecoveryPoint(): RecoveryPoint = RecoveryPoint.Other(BlockId(height, BlockHash.fromString(hash))) +} + +/** + * The checkpoint the app ships for this network, or null on a network where the genesis block is the only sensible + * starting point. + * + * Regtest chains are created locally and start at their own genesis block, so they ship no checkpoint. + */ +val Network.bundledCheckpoint: BundledCheckpoint? + get() = + when (this) { + Network.SIGNET -> + BundledCheckpoint( + height = 320_000u, + hash = "0000000740ae66b284da84387dcfa14d7b1385b0bad482005ba4e770ea6c4b95", + ) + Network.TESTNET -> + BundledCheckpoint( + height = 5_128_000u, + hash = "000000000001d3a7821a20f7c2a07143705ad249b1514ecb4ec7f3add4f1c54b", + ) + Network.TESTNET4 -> + BundledCheckpoint( + height = 150_000u, + hash = "0000000000d9877342754dea8ec1eb24631517d38e3443c370465ee53a8b7434", + ) + Network.REGTEST -> null + Network.BITCOIN -> throw IllegalArgumentException("Bitcoin mainnet network is not supported") + } diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt index d35f677..f74b43a 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/ui/screens/settings/CbfNodeScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider @@ -30,6 +31,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -43,18 +45,25 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import org.bitcoindevkit.Network import org.bitcoindevkit.devkitwallet.data.NodePeer +import org.bitcoindevkit.devkitwallet.domain.bundledCheckpoint import org.bitcoindevkit.devkitwallet.presentation.theme.inter +import org.bitcoindevkit.devkitwallet.presentation.ui.components.RadioButtonWithLabel import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.CbfNodeStatus +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.ScanChoice import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenAction import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenState +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.displayString +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.recoveryScanChoices /** * Settings screen for managing the Kyoto Compact Block Filters (CBF) node. * * Shows node status, latest known block height, a configurable peer list, and Start/Stop controls that dispatch to - * [WalletViewModel]. + * [WalletViewModel]. A wallet that has never scanned the chain gets a [ScanTypeDialog] asking where to start; once it + * has scanned once, starting the node just resumes from the wallet's own checkpoint. */ @Composable internal fun CbfNodeScreen( @@ -64,6 +73,18 @@ internal fun CbfNodeScreen( ) { val colorScheme = MaterialTheme.colorScheme val isRunning = state.kyotoNodeStatus == CbfNodeStatus.Running + var showScanTypeDialog by rememberSaveable { mutableStateOf(false) } + + if (showScanTypeDialog) { + ScanTypeDialog( + network = state.network, + onDismiss = { showScanTypeDialog = false }, + onConfirm = { scanChoice -> + showScanTypeDialog = false + onAction(WalletScreenAction.ActivateCbfNode(scanChoice)) + }, + ) + } Scaffold( topBar = { @@ -135,7 +156,13 @@ internal fun CbfNodeScreen( Spacer(modifier = Modifier.height(32.dp)) Button( - onClick = { onAction(WalletScreenAction.ActivateCbfNode) }, + onClick = { + if (state.initialRecoveryDone) { + onAction(WalletScreenAction.ActivateCbfNode(ScanChoice.Sync)) + } else { + showScanTypeDialog = true + } + }, enabled = !isRunning, colors = ButtonDefaults.buttonColors( @@ -176,6 +203,83 @@ internal fun CbfNodeScreen( } } +/** + * Dialog shown the first time the user starts the node on a wallet that has never scanned the chain, asking whether + * these keys are new. + * + * Fresh keys can have no history before the checkpoint the app ships for [network], so the scan starts there and skips + * every filter before it. Keys restored from an older recovery phrase may hold coins further back and have to walk the + * chain from its genesis block. + * + * @param network The network the wallet runs on; supplies the checkpoint quoted in the explanation. + * @param onDismiss Called when the dialog is dismissed without starting the node. + * @param onConfirm Called with the selected [ScanChoice] when the user confirms. + */ +@Composable +private fun ScanTypeDialog(network: Network, onDismiss: () -> Unit, onConfirm: (ScanChoice) -> Unit) { + val colorScheme = MaterialTheme.colorScheme + var selectedChoice by rememberSaveable { mutableStateOf(ScanChoice.RecoverFromCheckpoint) } + val checkpoint = network.bundledCheckpoint + + val explanation = + if (checkpoint != null) { + "This wallet has never scanned the chain, so it needs a starting point. A wallet created in this app " + + "has no history before block ${checkpoint.height} and starts scanning there; keys restored from an " + + "older recovery phrase may hold coins further back and have to scan the whole chain." + } else { + "This wallet has never scanned the chain, so it needs a starting point. This network ships no " + + "checkpoint, so either option scans from the genesis block." + } + + AlertDialog( + containerColor = colorScheme.surface, + onDismissRequest = onDismiss, + title = { + Text( + text = "Start the node", + color = colorScheme.onSurface, + fontFamily = inter, + ) + }, + text = { + Column { + Text( + text = explanation, + color = colorScheme.onSurface.copy(alpha = 0.7f), + fontFamily = inter, + fontSize = 14.sp, + ) + Spacer(modifier = Modifier.height(12.dp)) + recoveryScanChoices.forEach { choice -> + RadioButtonWithLabel( + label = choice.displayString(), + isSelected = choice == selectedChoice, + onSelect = { selectedChoice = choice }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selectedChoice) }) { + Text( + text = "Start Node", + color = colorScheme.primary, + fontFamily = inter, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = "Cancel", + color = colorScheme.onSurface.copy(alpha = 0.5f), + fontFamily = inter, + ) + } + }, + ) +} + /** * Sub-section of [CbfNodeScreen] that lists the default and custom peers and provides input fields for adding new ones. */ diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt index ce7a50b..2d64671 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/WalletViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.bitcoindevkit.Network import org.bitcoindevkit.RecoveryPoint import org.bitcoindevkit.ScanType import org.bitcoindevkit.devkitwallet.data.Kyoto @@ -26,12 +27,17 @@ import org.bitcoindevkit.devkitwallet.domain.CurrencyUnit import org.bitcoindevkit.devkitwallet.domain.DwLogger import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO import org.bitcoindevkit.devkitwallet.domain.Wallet +import org.bitcoindevkit.devkitwallet.domain.bundledCheckpoint import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.CbfNodeStatus +import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.ScanChoice import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenAction import org.bitcoindevkit.devkitwallet.presentation.viewmodels.mvi.WalletScreenState private const val TAG = "WalletViewModel" +/** Conservative estimate of the number of scripts a recovered wallet may have revealed. */ +private val USED_SCRIPT_INDEX: UInt = 1000u + /** * [ViewModel] backing the wallet home screen and the blockchain-client settings screen. * @@ -45,7 +51,14 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { val defaultPeer: NodePeer? = Kyoto.defaultPeer(wallet.network) val state: StateFlow - field = MutableStateFlow(WalletScreenState(network = wallet.network, defaultPeer = defaultPeer)) + field = + MutableStateFlow( + WalletScreenState( + network = wallet.network, + defaultPeer = defaultPeer, + initialRecoveryDone = wallet.initialRecoveryDone, + ) + ) private val kyotoCoroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO) private var kyoto: Kyoto? = null @@ -58,7 +71,7 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { when (action) { WalletScreenAction.SwitchUnit -> switchUnit() WalletScreenAction.UpdateBalance -> updateBalance() - WalletScreenAction.ActivateCbfNode -> activateKyoto() + is WalletScreenAction.ActivateCbfNode -> activateKyoto(action.scanChoice) WalletScreenAction.StopKyotoNode -> stopKyotoNode() is WalletScreenAction.AddCustomPeer -> addCustomPeer(action.ip, action.port) is WalletScreenAction.RemoveCustomPeer -> removeCustomPeer(action.peer) @@ -107,18 +120,15 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { } /** - * Starts the Kyoto CBF node, begins collecting chain updates, and applies each [Update] to the underlying wallet. - * Also hooks up logging flows to Logcat. + * Starts the Kyoto CBF node using the [scanChoice] selected by the user, begins collecting chain updates, and + * applies each [Update] to the underlying wallet. Also hooks up logging flows to Logcat. */ - private fun activateKyoto() { + private fun activateKyoto(scanChoice: ScanChoice) { // An empty list is fine: Kyoto discovers peers on its own if none are provided val peers = state.value.customPeers.ifEmpty { listOfNotNull(defaultPeer) } val dataDir = wallet.internalAppFilesPath - val scanType = - if (wallet.initialRecoveryDone) ScanType.Sync - else ScanType.Recovery(usedScriptIndex = 1000u, checkpoint = RecoveryPoint.GenesisBlock) - this.kyoto = Kyoto.create(wallet.wallet, dataDir, wallet.network, peers, scanType) + this.kyoto = Kyoto.create(wallet.wallet, dataDir, wallet.network, peers, scanChoice.toScanType(wallet.network)) val updatesFlow = kyoto!!.start() state.update { it.copy(kyotoNodeStatus = CbfNodeStatus.Running) } kyotoCoroutineScope.launch { @@ -129,6 +139,7 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { wallet.applyUpdate(it) if (!wallet.initialRecoveryDone) { wallet.markInitialRecoveryDone() + state.update { currentState -> currentState.copy(initialRecoveryDone = true) } } updateBalance() updateBestBlock() @@ -159,3 +170,21 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() { state.update { it.copy(bestBlockHeight = bestBlockHeight) } } } + +/** + * Maps the scan strategy chosen in the UI to the BDK [ScanType] the Kyoto node is built with. + * + * A network that ships no checkpoint of its own recovers from the genesis block. + */ +private fun ScanChoice.toScanType(network: Network): ScanType { + return when (this) { + ScanChoice.Sync -> ScanType.Sync + ScanChoice.RecoverFromCheckpoint -> + ScanType.Recovery( + usedScriptIndex = USED_SCRIPT_INDEX, + checkpoint = network.bundledCheckpoint?.toRecoveryPoint() ?: RecoveryPoint.GenesisBlock, + ) + ScanChoice.RecoverFromGenesis -> + ScanType.Recovery(usedScriptIndex = USED_SCRIPT_INDEX, checkpoint = RecoveryPoint.GenesisBlock) + } +} diff --git a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt index faedd02..da07d59 100644 --- a/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt +++ b/src/org/bitcoindevkit/devkitwallet/presentation/viewmodels/mvi/MviWalletScreen.kt @@ -19,6 +19,7 @@ import org.bitcoindevkit.devkitwallet.domain.CurrencyUnit * @property kyotoNodeStatus Whether the Kyoto CBF node is running or stopped. * @property defaultPeer Hard-coded default peer for the current network, if any. * @property customPeers User-added peers for the Kyoto node. + * @property initialRecoveryDone Whether this wallet has ever completed a scan of the chain. */ data class WalletScreenState( val balance: ULong = 0u, @@ -28,6 +29,7 @@ data class WalletScreenState( val kyotoNodeStatus: CbfNodeStatus = CbfNodeStatus.Stopped, val defaultPeer: NodePeer? = null, val customPeers: List = emptyList(), + val initialRecoveryDone: Boolean = false, ) /** One-way actions that the wallet home screen can dispatch to its [WalletViewModel]. */ @@ -38,8 +40,8 @@ sealed interface WalletScreenAction { /** Toggle between BTC and satoshi display. */ data object SwitchUnit : WalletScreenAction - /** Start the Kyoto CBF node and begin listening for chain updates. */ - data object ActivateCbfNode : WalletScreenAction + /** Start the Kyoto CBF node with the chosen scan strategy and begin listening for chain updates. */ + data class ActivateCbfNode(val scanChoice: ScanChoice) : WalletScreenAction /** Shut down the Kyoto CBF node gracefully. */ data object StopKyotoNode : WalletScreenAction @@ -56,3 +58,32 @@ enum class CbfNodeStatus { Running, Stopped, } + +/** + * How far back the Kyoto node should scan the chain when it starts. + * + * [Sync] resumes from the wallet's own last stored checkpoint. It is what a wallet that has already scanned the chain + * once always uses, and it needs no input from the user. + * + * A wallet that has never scanned has no checkpoint of its own and has to be told where to start, which comes down to + * whether its keys are new: [RecoverFromCheckpoint] starts from the checkpoint the app ships for the network, which is + * everything a wallet created in this app can have history for, while [RecoverFromGenesis] walks every filter the + * network ever produced, which is what keys restored from an older recovery phrase may need. + */ +enum class ScanChoice { + Sync, + RecoverFromCheckpoint, + RecoverFromGenesis, +} + +/** The starting points offered to a wallet that has never scanned the chain. */ +val recoveryScanChoices: List = listOf(ScanChoice.RecoverFromCheckpoint, ScanChoice.RecoverFromGenesis) + +/** Human-readable label for the scan strategy. */ +fun ScanChoice.displayString(): String { + return when (this) { + ScanChoice.Sync -> "Sync from last known block" + ScanChoice.RecoverFromCheckpoint -> "Fresh wallet (no past history)" + ScanChoice.RecoverFromGenesis -> "Existing wallet (recover from genesis block)" + } +}