Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 0 additions & 19 deletions jniLibs/README.md

This file was deleted.

Binary file removed jniLibs/arm64-v8a/libjnidispatch.so
Binary file not shown.
Binary file removed jniLibs/x86_64/libjnidispatch.so
Binary file not shown.
4 changes: 2 additions & 2 deletions kotlin
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
4 changes: 2 additions & 2 deletions kotlin.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions src/org/bitcoindevkit/devkitwallet/domain/Checkpoints.kt
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand All @@ -45,7 +51,14 @@ internal class WalletViewModel(private val wallet: Wallet) : ViewModel() {
val defaultPeer: NodePeer? = Kyoto.defaultPeer(wallet.network)

val state: StateFlow<WalletScreenState>
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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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)
}
}
Loading
Loading