diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 891fe7f..e170fab 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -68,6 +68,7 @@ dependencies {
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose")
+ implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2")
implementation("androidx.constraintlayout:constraintlayout-compose:1.1.1")
implementation("androidx.navigation:navigation-compose:2.9.3")
@@ -86,6 +87,17 @@ dependencies {
// QR codes
implementation("com.google.zxing:core:3.5.3")
+ // CameraX (needed by QrCodeScanner)
+ implementation("androidx.camera:camera-camera2:1.3.4")
+ implementation("androidx.camera:camera-lifecycle:1.3.4")
+ implementation("androidx.camera:camera-view:1.3.4")
+
+ // ML Kit barcode scanning (needed by QrCodeScanner)
+ implementation("com.google.mlkit:barcode-scanning:17.3.0")
+
+ // Accompanist permissions (runtime permission handling in Compose)
+ implementation("com.google.accompanist:accompanist-permissions:0.36.0")
+
// Tests
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index db99496..7281a00 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
xmlns:android="http://schemas.android.com/apk/res/android">
+
+
()
+
+ val net = wallet.network()
+ val tempFiles = mutableListOf()
+
+ for (descString in candidates) {
+ val label = descString.substringBefore("(")
+ try {
+ val descriptor = Descriptor(descString, net)
+
+ val tempDir = System.getProperty("java.io.tmpdir")
+ val tempFile = File(
+ "$tempDir/temp-${UUID.randomUUID().toString().take(8)}.sqlite3",
+ )
+ tempFiles.add(tempFile)
+ val tempConnection = Persister.newSqlite(tempFile.absolutePath)
+
+ val tempBdkWallet = BdkWallet.createSingle(
+ descriptor = descriptor,
+ network = net,
+ persister = tempConnection,
+ )
+
+ DwLogger.log(INFO, "Sweep: scanning $label ...")
+ val fullScanRequest = tempBdkWallet.startFullScan().build()
+ val update = currentBlockchainClient?.fullScan(
+ fullScanRequest = fullScanRequest,
+ stopGap = 3u,
+ ) ?: throw IllegalStateException("Blockchain client not initialized")
+ tempBdkWallet.applyUpdate(update)
+
+ val bdkBalance = tempBdkWallet.balance()
+ val balance = bdkBalance.total.toSat()
+ DwLogger.log(
+ INFO,
+ "Sweep: $label balance: " +
+ "confirmed=${bdkBalance.confirmed.toSat()} " +
+ "pending=${bdkBalance.untrustedPending.toSat()} " +
+ "total=$balance",
+ )
+ Log.i(TAG, "Balance for $label: $balance")
+
+ if (balance > maxBalance) {
+ maxBalance = balance
+ bestWallet = tempBdkWallet
+ }
+ } catch (e: Exception) {
+ val msg = "$label failed: ${e.javaClass.simpleName} — ${e.message}"
+ Log.e(TAG, msg, e)
+ DwLogger.log(ERROR, "Sweep: $msg")
+ candidateErrors.add(msg)
+ }
+ }
+
+ try {
+ if (bestWallet == null || maxBalance == 0UL) {
+ val detail = if (candidateErrors.isNotEmpty()) {
+ candidateErrors.joinToString("; ")
+ } else {
+ "All script types returned 0 balance"
+ }
+ throw IllegalStateException(
+ "No sweepable funds found for this WIF ($detail)",
+ )
+ }
+
+ DwLogger.log(INFO, "Sweep: found $maxBalance sats, building tx")
+ val destinationAddress = getNewAddress().address
+ val destinationScriptPubKey = destinationAddress.scriptPubkey()
+
+ val txBuilder = TxBuilder()
+ .drainWallet()
+ .drainTo(destinationScriptPubKey)
+ .feeRate(feeRate)
+
+ val psbt = txBuilder.finish(bestWallet)
+ val signed = bestWallet.sign(psbt)
+ if (!signed) {
+ throw IllegalStateException(
+ "Failed to sign sweep transaction with the temporary wallet",
+ )
+ }
+
+ DwLogger.log(INFO, "Sweep: signed successfully")
+ return psbt
+ } finally {
+ for (f in tempFiles) {
+ listOf(f, File("${f.absolutePath}-wal"), File("${f.absolutePath}-shm"))
+ .forEach {
+ try {
+ it.delete()
+ } catch (_: Exception) {
+ }
+ }
+ }
+ }
}
private fun getAllTransactions(): List = wallet.transactions()
diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/domain/utils/WifParser.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/domain/utils/WifParser.kt
new file mode 100644
index 0000000..59d35a2
--- /dev/null
+++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/domain/utils/WifParser.kt
@@ -0,0 +1,81 @@
+/*
+ * 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 file.
+ */
+
+package org.bitcoindevkit.devkitwallet.domain.utils
+
+import android.util.Log
+import org.bitcoindevkit.devkitwallet.domain.DwLogger
+import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO
+import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.WARN
+
+private const val TAG = "WifParser"
+
+/**
+ * Utility for detecting and extracting WIF-encoded private keys from various input formats.
+ */
+object WifParser {
+ private val WIF_FIRST_CHARS = setOf('5', 'K', 'L', '9', 'c')
+
+ private val BASE58_CHARSET: Set =
+ "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz".toSet()
+
+ private val WIF_PARAM_KEYS = setOf("wif", "privkey", "private_key", "privatekey")
+
+ fun extract(value: String): String? {
+ val trimmed = value.trim()
+ val candidates = mutableListOf()
+
+ candidates.add(trimmed)
+
+ if (trimmed.lowercase().startsWith("wif:")) {
+ candidates.add(trimmed.drop(4).trim())
+ }
+
+ if (trimmed.lowercase().startsWith("bitcoin:")) {
+ try {
+ val queryPart = trimmed.substringAfter("?", "")
+ if (queryPart.isNotEmpty()) {
+ queryPart.split("&").forEach { param ->
+ val kv = param.split("=", limit = 2)
+ if (kv.size == 2 && kv[0].lowercase() in WIF_PARAM_KEYS) {
+ candidates.add(kv[1].trim())
+ }
+ }
+ }
+ } catch (e: Throwable) {
+ Log.i(TAG, "WIF Parsing error: ${e.message}", e)
+ }
+ }
+
+ val result = candidates.firstOrNull { isLikelyWif(it) }
+ if (result != null) {
+ DwLogger.log(
+ INFO,
+ "WifParser: extracted WIF (length=${result.length}) from input (length=${trimmed.length})"
+ )
+ } else if (candidates.size > 1) {
+ DwLogger.log(
+ WARN,
+ "WifParser: no valid WIF found in ${candidates.size} candidates from input (length=${trimmed.length})"
+ )
+ candidates.forEach { c ->
+ DwLogger.log(
+ WARN,
+ "WifParser: candidate length=${c.length}, first='${c.firstOrNull()}', likelyWif=${isLikelyWif(c)}"
+ )
+ }
+ }
+ return result
+ }
+
+ fun isLikelyWif(value: String): Boolean {
+ if (value.length != 51 && value.length != 52) return false
+
+ val first = value.firstOrNull() ?: return false
+ if (first !in WIF_FIRST_CHARS) return false
+
+ return value.all { it in BASE58_CHARSET }
+ }
+}
diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/navigation/WalletNavigation.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/navigation/WalletNavigation.kt
index c5b35ab..c4b5aa0 100644
--- a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/navigation/WalletNavigation.kt
+++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/navigation/WalletNavigation.kt
@@ -126,7 +126,14 @@ fun WalletNavigation(drawerState: DrawerState, activeWallet: Wallet, walletViewM
animationSpec = tween(ANIMATION_DURATION)
)
}
- ) { SendScreen(onAction = sendViewModel::onAction, navController = navController) }
+ ) {
+ SendScreen(
+ onAction = sendViewModel::onAction,
+ broadcastResult = sendViewModel.broadcastResult,
+ clearBroadcastResult = sendViewModel::clearBroadcastResult,
+ navController = navController,
+ )
+ }
composable(
enterTransition = {
diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt
index 220ea19..6c32b52 100644
--- a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt
+++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/intro/RecoverWalletScreen.kt
@@ -53,6 +53,7 @@ import org.bitcoindevkit.devkitwallet.domain.DwLogger
import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO
import org.bitcoindevkit.devkitwallet.domain.bip39WordList
import org.bitcoindevkit.devkitwallet.domain.createScriptAppropriateDescriptor
+import org.bitcoindevkit.devkitwallet.domain.utils.WifParser
import org.bitcoindevkit.devkitwallet.presentation.WalletCreateType
import org.bitcoindevkit.devkitwallet.presentation.theme.DevkitWalletColors
import org.bitcoindevkit.devkitwallet.presentation.theme.monoRegular
@@ -200,38 +201,47 @@ internal fun RecoverWalletScreen(onAction: (WalletCreateType) -> Unit, navContro
}
}
if (recoveryPhrase.isNotEmpty()) {
- Log.i("RecoverWalletScreen", "Recovering wallet with recovery phrase")
- val parsingResult = parseRecoveryPhrase(recoveryPhrase)
-
- if (parsingResult is RecoveryPhraseValidationResult.Invalid) {
+ if (WifParser.extract(recoveryPhrase) != null) {
scope.launch {
- snackbarHostState.showSnackbar(parsingResult.reason)
+ snackbarHostState.showSnackbar(
+ "This looks like a WIF private key, not a recovery phrase. " +
+ "To sweep a WIF, open an existing wallet and use Send > Scan QR."
+ )
+ }
+ } else {
+ Log.i("RecoverWalletScreen", "Recovering wallet with recovery phrase")
+ val parsingResult = parseRecoveryPhrase(recoveryPhrase)
+
+ if (parsingResult is RecoveryPhraseValidationResult.Invalid) {
+ scope.launch {
+ snackbarHostState.showSnackbar(parsingResult.reason)
+ }
+ } else if (parsingResult is RecoveryPhraseValidationResult.ProbablyValid) {
+ val mnemonic = Mnemonic.fromString(parsingResult.recoveryPhrase)
+ val bip32ExtendedRootKey = DescriptorSecretKey(selectedNetwork.value, mnemonic, null)
+ val descriptor = createScriptAppropriateDescriptor(
+ scriptType = selectedScriptType.value,
+ bip32ExtendedRootKey = bip32ExtendedRootKey,
+ network = selectedNetwork.value,
+ keychain = KeychainKind.EXTERNAL
+ )
+ val changeDescriptor = createScriptAppropriateDescriptor(
+ scriptType = selectedScriptType.value,
+ bip32ExtendedRootKey = bip32ExtendedRootKey,
+ network = selectedNetwork.value,
+ keychain = KeychainKind.INTERNAL
+ )
+ val recoverWalletConfig = RecoverWalletConfig(
+ name = walletName,
+ network = selectedNetwork.value,
+ scriptType = selectedScriptType.value,
+ descriptor = descriptor,
+ changeDescriptor = changeDescriptor,
+ recoveryPhrase = parsingResult.recoveryPhrase
+ )
+ DwLogger.log(INFO, "Recovering wallet with recovery phrase (name: $walletName)")
+ onAction(WalletCreateType.RECOVER(recoverWalletConfig))
}
- } else if (parsingResult is RecoveryPhraseValidationResult.ProbablyValid) {
- val mnemonic = Mnemonic.fromString(parsingResult.recoveryPhrase)
- val bip32ExtendedRootKey = DescriptorSecretKey(selectedNetwork.value, mnemonic, null)
- val descriptor = createScriptAppropriateDescriptor(
- scriptType = selectedScriptType.value,
- bip32ExtendedRootKey = bip32ExtendedRootKey,
- network = selectedNetwork.value,
- keychain = KeychainKind.EXTERNAL
- )
- val changeDescriptor = createScriptAppropriateDescriptor(
- scriptType = selectedScriptType.value,
- bip32ExtendedRootKey = bip32ExtendedRootKey,
- network = selectedNetwork.value,
- keychain = KeychainKind.INTERNAL
- )
- val recoverWalletConfig = RecoverWalletConfig(
- name = walletName,
- network = selectedNetwork.value,
- scriptType = selectedScriptType.value,
- descriptor = descriptor,
- changeDescriptor = changeDescriptor,
- recoveryPhrase = parsingResult.recoveryPhrase
- )
- DwLogger.log(INFO, "Recovering wallet with recovery phrase (name: $walletName)")
- onAction(WalletCreateType.RECOVER(recoverWalletConfig))
}
}
if (descriptorString.isNotEmpty() && changeDescriptorString.isNotEmpty()) {
diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/QrCodeScanner.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/QrCodeScanner.kt
new file mode 100644
index 0000000..2d4d279
--- /dev/null
+++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/QrCodeScanner.kt
@@ -0,0 +1,247 @@
+/*
+ * 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 file.
+ */
+
+@file:Suppress("UnsafeOptInUsageError")
+
+package org.bitcoindevkit.devkitwallet.presentation.ui.screens.wallet
+
+import android.Manifest
+import android.util.Log
+import android.view.ViewGroup
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import androidx.camera.core.Preview
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.google.accompanist.permissions.shouldShowRationale
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+import org.bitcoindevkit.devkitwallet.presentation.theme.DevkitWalletColors
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+private const val TAG = "QrCodeScanner"
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun QrScannerDialog(onScanned: (String) -> Unit, onDismiss: () -> Unit) {
+ val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(DevkitWalletColors.primary),
+ contentAlignment = Alignment.Center,
+ ) {
+ when {
+ cameraPermissionState.status.isGranted -> {
+ CameraPreview(
+ onScanned = { rawValue ->
+ onScanned(rawValue)
+ }
+ )
+ Button(
+ onClick = onDismiss,
+ colors = ButtonDefaults.buttonColors(DevkitWalletColors.accent2),
+ shape = RoundedCornerShape(16.dp),
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 48.dp),
+ ) {
+ Text(text = "Cancel")
+ }
+ }
+
+ cameraPermissionState.status.shouldShowRationale -> {
+ PermissionRationaleContent(
+ onRequest = { cameraPermissionState.launchPermissionRequest() },
+ onDismiss = onDismiss,
+ )
+ }
+
+ else -> {
+ DisposableEffect(Unit) {
+ cameraPermissionState.launchPermissionRequest()
+ onDispose {}
+ }
+ PermissionRationaleContent(
+ onRequest = { cameraPermissionState.launchPermissionRequest() },
+ onDismiss = onDismiss,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PermissionRationaleContent(onRequest: () -> Unit, onDismiss: () -> Unit) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(32.dp),
+ ) {
+ Text(
+ text = "Camera permission is required to scan QR codes.",
+ color = DevkitWalletColors.white,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = onRequest,
+ colors = ButtonDefaults.buttonColors(DevkitWalletColors.accent1),
+ ) {
+ Text("Grant permission")
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ onClick = onDismiss,
+ colors = ButtonDefaults.buttonColors(DevkitWalletColors.accent2),
+ ) {
+ Text("Cancel")
+ }
+ }
+}
+
+@Composable
+private fun CameraPreview(onScanned: (String) -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
+
+ val cameraExecutor: ExecutorService = remember { Executors.newSingleThreadExecutor() }
+
+ var scanned by remember { mutableStateOf(false) }
+
+ DisposableEffect(Unit) {
+ onDispose { cameraExecutor.shutdown() }
+ }
+
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ val previewView = PreviewView(ctx).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ )
+ }
+
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
+ cameraProviderFuture.addListener(
+ {
+ val cameraProvider = cameraProviderFuture.get()
+
+ val preview = Preview.Builder().build().also {
+ it.setSurfaceProvider(previewView.surfaceProvider)
+ }
+
+ val barcodeScanner = BarcodeScanning.getClient()
+
+ val imageAnalysis = ImageAnalysis
+ .Builder()
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+
+ imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy: ImageProxy ->
+ if (scanned) {
+ imageProxy.close()
+ return@setAnalyzer
+ }
+ processImageProxy(
+ barcodeScanner = barcodeScanner,
+ imageProxy = imageProxy,
+ onFound = { rawValue ->
+ if (!scanned) {
+ scanned = true
+ onScanned(rawValue)
+ }
+ },
+ )
+ }
+
+ try {
+ cameraProvider.unbindAll()
+ cameraProvider.bindToLifecycle(
+ lifecycleOwner,
+ CameraSelector.DEFAULT_BACK_CAMERA,
+ preview,
+ imageAnalysis,
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Camera binding failed", e)
+ }
+ },
+ ContextCompat.getMainExecutor(ctx),
+ )
+
+ previewView
+ },
+ )
+}
+
+private fun processImageProxy(
+ barcodeScanner: com.google.mlkit.vision.barcode.BarcodeScanner,
+ imageProxy: ImageProxy,
+ onFound: (String) -> Unit,
+) {
+ val mediaImage = imageProxy.image
+ if (mediaImage == null) {
+ imageProxy.close()
+ return
+ }
+
+ val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
+ barcodeScanner
+ .process(image)
+ .addOnSuccessListener { barcodes ->
+ for (barcode in barcodes) {
+ if (barcode.format == Barcode.FORMAT_QR_CODE) {
+ val rawValue = barcode.rawValue ?: continue
+ if (rawValue.isNotBlank()) {
+ onFound(rawValue)
+ break
+ }
+ }
+ }
+ }.addOnFailureListener { e ->
+ Log.w(TAG, "Barcode scan failed", e)
+ }.addOnCompleteListener {
+ imageProxy.close()
+ }
+}
diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt
index 9f1f9ae..e4d59f3 100644
--- a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt
+++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/ui/screens/wallet/SendScreen.kt
@@ -32,6 +32,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
@@ -40,12 +41,16 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -62,15 +67,19 @@ import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.navigation.NavController
import com.composables.icons.lucide.Lucide
+import com.composables.icons.lucide.ScanLine
import com.composables.icons.lucide.UserRoundMinus
import com.composables.icons.lucide.UserRoundPlus
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
+import org.bitcoindevkit.devkitwallet.domain.utils.WifParser
import org.bitcoindevkit.devkitwallet.presentation.navigation.HomeScreen
import org.bitcoindevkit.devkitwallet.presentation.theme.DevkitWalletColors
import org.bitcoindevkit.devkitwallet.presentation.theme.standardText
import org.bitcoindevkit.devkitwallet.presentation.ui.components.NeutralButton
import org.bitcoindevkit.devkitwallet.presentation.ui.components.SecondaryScreensAppBar
+import org.bitcoindevkit.devkitwallet.presentation.viewmodels.BroadcastResult
import org.bitcoindevkit.devkitwallet.presentation.viewmodels.Recipient
import org.bitcoindevkit.devkitwallet.presentation.viewmodels.SendScreenAction
import org.bitcoindevkit.devkitwallet.presentation.viewmodels.TransactionType
@@ -80,7 +89,12 @@ private const val TAG = "SendScreen"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-internal fun SendScreen(onAction: (SendScreenAction) -> Unit, navController: NavController) {
+internal fun SendScreen(
+ onAction: (SendScreenAction) -> Unit,
+ broadcastResult: StateFlow,
+ clearBroadcastResult: () -> Unit,
+ navController: NavController,
+) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
@@ -89,8 +103,47 @@ internal fun SendScreen(onAction: (SendScreenAction) -> Unit, navController: Nav
val (showDialog, setShowDialog) = rememberSaveable { mutableStateOf(false) }
val sendAll: MutableState = remember { mutableStateOf(false) }
+ var showQrScanner by remember { mutableStateOf(false) }
+
val bottomSheetScaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState()
+ val result by broadcastResult.collectAsState()
+ LaunchedEffect(result) {
+ val r = result ?: return@LaunchedEffect
+ when (r) {
+ is BroadcastResult.Success -> {
+ Toast
+ .makeText(
+ context,
+ "Sent! txid: ${r.txid.take(10)}…",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+
+ is BroadcastResult.Error -> {
+ Toast
+ .makeText(
+ context,
+ "Broadcast failed: ${r.message}",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ clearBroadcastResult()
+ }
+
+ if (showQrScanner) {
+ QrScannerDialog(
+ onScanned = { rawValue ->
+ if (recipientList.isNotEmpty()) {
+ recipientList[0] = recipientList[0].copy(address = rawValue)
+ }
+ showQrScanner = false
+ },
+ onDismiss = { showQrScanner = false },
+ )
+ }
+
BottomSheetScaffold(
topBar = {
SecondaryScreensAppBar(
@@ -123,7 +176,10 @@ internal fun SendScreen(onAction: (SendScreenAction) -> Unit, navController: Nav
height = Dimension.fillToConstraints
}
) {
- TransactionRecipientInput(recipientList = recipientList)
+ TransactionRecipientInput(
+ recipientList = recipientList,
+ onScanClick = { showQrScanner = true },
+ )
TransactionAmountInput(
recipientList = recipientList,
transactionType = if (sendAll.value) TransactionType.SEND_ALL else TransactionType.STANDARD
@@ -277,7 +333,7 @@ internal fun AdvancedOptions(sendAll: MutableState, recipientList: Muta
}
@Composable
-private fun TransactionRecipientInput(recipientList: MutableList) {
+private fun TransactionRecipientInput(recipientList: MutableList, onScanClick: () -> Unit) {
LazyColumn(
modifier = Modifier
.fillMaxWidth(0.9f)
@@ -286,6 +342,16 @@ private fun TransactionRecipientInput(recipientList: MutableList) {
itemsIndexed(recipientList) { index, _ ->
val recipientAddress: MutableState = rememberSaveable { mutableStateOf("") }
+ LaunchedEffect(recipientList[index].address) {
+ if (recipientList[index].address != recipientAddress.value) {
+ recipientAddress.value = recipientList[index].address
+ }
+ }
+
+ val isWif = WifParser.isLikelyWif(
+ WifParser.extract(recipientAddress.value) ?: recipientAddress.value
+ )
+
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
modifier = Modifier
@@ -298,7 +364,7 @@ private fun TransactionRecipientInput(recipientList: MutableList) {
},
label = {
Text(
- text = "Recipient address ${index + 1}",
+ text = if (isWif) "WIF key (sweep)" else "Recipient address ${index + 1}",
color = DevkitWalletColors.white,
)
},
@@ -310,6 +376,15 @@ private fun TransactionRecipientInput(recipientList: MutableList) {
unfocusedBorderColor = DevkitWalletColors.white
)
)
+ if (index == 0) {
+ IconButton(onClick = onScanClick) {
+ Icon(
+ imageVector = Lucide.ScanLine,
+ contentDescription = "Scan QR code",
+ tint = DevkitWalletColors.white,
+ )
+ }
+ }
}
}
}
@@ -347,6 +422,10 @@ private fun TransactionAmountInput(recipientList: MutableList, transa
itemsIndexed(recipientList) { index, _ ->
val amount: MutableState = rememberSaveable { mutableStateOf("") }
+ val firstAddressIsWif = remember(recipientList[0].address) {
+ WifParser.isLikelyWif(WifParser.extract(recipientList[0].address) ?: recipientList[0].address)
+ }
+
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
modifier = Modifier
@@ -387,7 +466,7 @@ private fun TransactionAmountInput(recipientList: MutableList, transa
enabled = (
when (transactionType) {
TransactionType.SEND_ALL -> false
- else -> true
+ else -> !firstAddressIsWif
}
)
)
@@ -453,7 +532,14 @@ private fun Dialog(
) {
if (showDialog) {
var confirmationText = "Confirm Transaction : \n"
- recipientList.forEach { confirmationText += "${it.address}, ${it.amount}\n" }
+ val rawWif = if (recipientList.isNotEmpty()) WifParser.extract(recipientList[0].address) else null
+
+ if (rawWif != null) {
+ confirmationText = "Sweep WIF : \n${rawWif.take(8)}...\n"
+ } else {
+ recipientList.forEach { confirmationText += "${it.address}, ${it.amount}\n" }
+ }
+
if (feeRate.value.isNotEmpty()) {
confirmationText += "Fee Rate : ${feeRate.value.toULong()}"
}
@@ -476,13 +562,30 @@ private fun Dialog(
TextButton(
onClick = {
if (checkRecipientList(recipientList = recipientList, feeRate = feeRate, context = context)) {
- val txDataBundle =
- TxDataBundle(
- recipients = recipientList,
+ val rawWif = if (recipientList.isNotEmpty()) {
+ WifParser.extract(
+ recipientList[0].address
+ )
+ } else {
+ null
+ }
+ if (rawWif != null) {
+ val txDataBundle = TxDataBundle(
+ recipients = emptyList(),
feeRate = feeRate.value.toULong(),
- transactionType = transactionType,
+ transactionType = TransactionType.SWEEP,
+ wif = rawWif
)
- onAction(SendScreenAction.Broadcast(txDataBundle))
+ onAction(SendScreenAction.Broadcast(txDataBundle))
+ } else {
+ val txDataBundle =
+ TxDataBundle(
+ recipients = recipientList,
+ feeRate = feeRate.value.toULong(),
+ transactionType = transactionType,
+ )
+ onAction(SendScreenAction.Broadcast(txDataBundle))
+ }
setShowDialog(false)
}
},
diff --git a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt
index 642f5d3..f70a408 100644
--- a/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt
+++ b/app/src/main/java/org/bitcoindevkit/devkitwallet/presentation/viewmodels/SendViewModel.kt
@@ -7,8 +7,18 @@ package org.bitcoindevkit.devkitwallet.presentation.viewmodels
import android.util.Log
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.bitcoindevkit.FeeRate
import org.bitcoindevkit.Psbt
+import org.bitcoindevkit.devkitwallet.domain.DwLogger
+import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.ERROR
+import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO
import org.bitcoindevkit.devkitwallet.domain.Wallet
private const val TAG = "SendViewModel"
@@ -21,6 +31,7 @@ data class TxDataBundle(
val recipients: List,
val feeRate: ULong,
val transactionType: TransactionType,
+ val wif: String? = null,
)
data class Recipient(var address: String, var amount: ULong)
@@ -28,9 +39,23 @@ data class Recipient(var address: String, var amount: ULong)
enum class TransactionType {
STANDARD,
SEND_ALL,
+ SWEEP,
+}
+
+sealed class BroadcastResult {
+ data class Success(val txid: String) : BroadcastResult()
+
+ data class Error(val message: String) : BroadcastResult()
}
internal class SendViewModel(private val wallet: Wallet) : ViewModel() {
+ private val _broadcastResult = MutableStateFlow(null)
+ val broadcastResult: StateFlow = _broadcastResult.asStateFlow()
+
+ fun clearBroadcastResult() {
+ _broadcastResult.value = null
+ }
+
fun onAction(action: SendScreenAction) {
when (action) {
is SendScreenAction.Broadcast -> broadcast(action.txDataBundle)
@@ -38,31 +63,45 @@ internal class SendViewModel(private val wallet: Wallet) : ViewModel() {
}
private fun broadcast(txInfo: TxDataBundle) {
- try {
- // Create, sign, and broadcast
- val psbt: Psbt =
- when (txInfo.transactionType) {
- TransactionType.STANDARD -> {
- wallet.createTransaction(
- recipientList = txInfo.recipients,
- feeRate = FeeRate.fromSatPerVb(txInfo.feeRate),
- )
- }
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val psbt: Psbt =
+ when (txInfo.transactionType) {
+ TransactionType.STANDARD -> {
+ val unsignedPsbt = wallet.createTransaction(
+ recipientList = txInfo.recipients,
+ feeRate = FeeRate.fromSatPerVb(txInfo.feeRate),
+ )
+ wallet.sign(unsignedPsbt)
+ unsignedPsbt
+ }
+
+ TransactionType.SWEEP -> {
+ val feeRate = FeeRate.fromSatPerVb(txInfo.feeRate)
+ val rawWif =
+ txInfo.wif ?: throw java.lang.IllegalStateException("WIF missing in sweep payload")
+ wallet.sweep(rawWif, feeRate)
+ }
- // TransactionType.SEND_ALL -> Wallet.createSendAllTransaction(recipientList[0].address, FeeRate.fromSatPerVb(feeRate), rbfEnabled, opReturnMsg)
- TransactionType.SEND_ALL -> {
- throw NotImplementedError("Send all not implemented")
+ // TransactionType.SEND_ALL -> Wallet.createSendAllTransaction(recipientList[0].address, FeeRate.fromSatPerVb(feeRate), rbfEnabled, opReturnMsg)
+ TransactionType.SEND_ALL -> {
+ throw NotImplementedError("Send all not implemented")
+ }
}
- }
- val isSigned = wallet.sign(psbt)
- if (isSigned) {
+
val txid: String = wallet.broadcast(psbt)
Log.i(TAG, "Transaction was broadcast! txid: $txid")
- } else {
- Log.i(TAG, "Transaction not signed.")
+ DwLogger.log(INFO, "Broadcast success: txid=$txid")
+ withContext(Dispatchers.Main) {
+ _broadcastResult.value = BroadcastResult.Success(txid)
+ }
+ } catch (e: Throwable) {
+ Log.e(TAG, "Broadcast error: ${e.message}", e)
+ DwLogger.log(ERROR, "Broadcast failed: ${e.message}")
+ withContext(Dispatchers.Main) {
+ _broadcastResult.value = BroadcastResult.Error(e.message ?: "Unknown error")
+ }
}
- } catch (e: Throwable) {
- Log.i(TAG, "Broadcast error: ${e.message}")
}
}
}
diff --git a/app/src/test/androidTest/java/org/bitcoindevkit/devkitwallet/WifSweepInstrumentedTest.kt b/app/src/test/androidTest/java/org/bitcoindevkit/devkitwallet/WifSweepInstrumentedTest.kt
new file mode 100644
index 0000000..13ada21
--- /dev/null
+++ b/app/src/test/androidTest/java/org/bitcoindevkit/devkitwallet/WifSweepInstrumentedTest.kt
@@ -0,0 +1,41 @@
+package org.bitcoindevkit.devkitwallet
+
+import org.bitcoindevkit.Descriptor
+import org.bitcoindevkit.Network
+import org.bitcoindevkit.devkitwallet.domain.utils.WifParser
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.junit.runner.RunWith
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+@RunWith(AndroidJUnit4::class)
+class WifSweepInstrumentedTest {
+
+ @Test
+ fun bdkDescriptorRejectsInvalidChecksumWif() {
+ val fakeWif = "c" + "1".repeat(51)
+ assertTrue("Fake WIF should pass the heuristic", WifParser.isLikelyWif(fakeWif))
+
+ try {
+ Descriptor("wpkh($fakeWif)", Network.TESTNET)
+ fail("BDK should have thrown on an invalid-checksum WIF")
+ } catch (e: Exception) {
+ // Expected — BDK correctly rejected the key
+ assertNotNull("Exception message should not be null", e.message)
+ }
+ }
+
+ @Test
+ fun bdkBuildsDescriptorsFromRealWif() {
+ val wif = "cS7h4T8nZ2wDBWk271QdvPeAnvGUoP1tXQKGMUYKkkFq1Tk6aKoP"
+ assertTrue(WifParser.isLikelyWif(wif))
+
+ val types = listOf("pkh($wif)", "wpkh($wif)", "sh(wpkh($wif))", "tr($wif)")
+ for (descStr in types) {
+ val descriptor = Descriptor(descStr, Network.TESTNET)
+ assertNotNull("Descriptor for $descStr should not be null", descriptor)
+ }
+ }
+}
diff --git a/app/src/test/java/org/bitcoindevkit/devkitwallet/WifParserTest.kt b/app/src/test/java/org/bitcoindevkit/devkitwallet/WifParserTest.kt
new file mode 100644
index 0000000..36adb3a
--- /dev/null
+++ b/app/src/test/java/org/bitcoindevkit/devkitwallet/WifParserTest.kt
@@ -0,0 +1,176 @@
+package org.bitcoindevkit.devkitwallet
+
+import org.bitcoindevkit.devkitwallet.domain.utils.WifParser
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WifParserTest {
+ // ─── isLikelyWif ────────────────────────────────────────────────────────
+
+ @Test
+ fun `isLikelyWif accepts valid-looking testnet compressed WIF`() {
+ // 'c' prefix + 51 more base-58 chars = 52 total
+ val wif = "c" + "1".repeat(51)
+ assertTrue(WifParser.isLikelyWif(wif))
+ }
+
+ @Test
+ fun `isLikelyWif accepts valid-looking mainnet compressed WIF starting with K`() {
+ val wif = "K" + "1".repeat(51)
+ assertTrue(WifParser.isLikelyWif(wif))
+ }
+
+ @Test
+ fun `isLikelyWif accepts valid-looking mainnet compressed WIF starting with L`() {
+ val wif = "L" + "1".repeat(51)
+ assertTrue(WifParser.isLikelyWif(wif))
+ }
+
+ @Test
+ fun `isLikelyWif accepts valid-looking mainnet uncompressed WIF starting with 5`() {
+ // '5' prefix + 50 more base-58 chars = 51 total
+ val wif = "5" + "1".repeat(50)
+ assertTrue(WifParser.isLikelyWif(wif))
+ }
+
+ @Test
+ fun `isLikelyWif accepts valid-looking testnet uncompressed WIF starting with 9`() {
+ val wif = "9" + "1".repeat(50)
+ assertTrue(WifParser.isLikelyWif(wif))
+ }
+
+ @Test
+ fun `isLikelyWif rejects string that is too short`() {
+ assertFalse(WifParser.isLikelyWif("c" + "1".repeat(10)))
+ }
+
+ @Test
+ fun `isLikelyWif rejects string that is too long`() {
+ assertFalse(WifParser.isLikelyWif("c" + "1".repeat(55)))
+ }
+
+ @Test
+ fun `isLikelyWif rejects string with wrong first character`() {
+ // 'A' is not a valid WIF first character
+ assertFalse(WifParser.isLikelyWif("A" + "1".repeat(51)))
+ }
+
+ @Test
+ fun `isLikelyWif rejects string with non-base58 characters`() {
+ // '0' is not in the base-58 alphabet
+ val wif = "c" + "0".repeat(51)
+ assertFalse(WifParser.isLikelyWif(wif))
+ }
+
+ @Test
+ fun `isLikelyWif rejects a regular Bitcoin address`() {
+ // A bech32 address is clearly not a WIF
+ assertFalse(WifParser.isLikelyWif("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"))
+ }
+
+ // ─── extract: raw input ──────────────────────────────────────────────────
+
+ @Test
+ fun `extract returns raw WIF when input is already a valid WIF`() {
+ val wif = "c" + "1".repeat(51)
+ assertEquals(wif, WifParser.extract(wif))
+ }
+
+ @Test
+ fun `extract handles leading and trailing whitespace`() {
+ val wif = "c" + "1".repeat(51)
+ assertEquals(wif, WifParser.extract(" $wif "))
+ }
+
+ // ─── extract: wif: prefix ────────────────────────────────────────────────
+
+ @Test
+ fun `extract strips wif colon prefix`() {
+ val wif = "c" + "1".repeat(51)
+ assertEquals(wif, WifParser.extract("wif:$wif"))
+ }
+
+ @Test
+ fun `extract strips WIF colon prefix case-insensitively`() {
+ val wif = "c" + "1".repeat(51)
+ assertEquals(wif, WifParser.extract("WIF:$wif"))
+ }
+
+ // ─── extract: bitcoin URI ────────────────────────────────────────────────
+
+ @Test
+ fun `extract reads wif query parameter from bitcoin URI`() {
+ val wif = "c" + "1".repeat(51)
+ assertEquals(wif, WifParser.extract("bitcoin:?wif=$wif"))
+ }
+
+ @Test
+ fun `extract reads privkey query parameter from bitcoin URI`() {
+ val wif = "c" + "1".repeat(51)
+ assertEquals(wif, WifParser.extract("bitcoin:?privkey=$wif"))
+ }
+
+ @Test
+ fun `extract reads private_key query parameter from bitcoin URI`() {
+ val wif = "c" + "1".repeat(51)
+ assertEquals(wif, WifParser.extract("bitcoin:?private_key=$wif"))
+ }
+
+ @Test
+ fun `extract reads privatekey query parameter from bitcoin URI`() {
+ val wif = "c" + "1".repeat(51)
+ assertEquals(wif, WifParser.extract("bitcoin:?privatekey=$wif"))
+ }
+
+ @Test
+ fun `extract works when WIF param is not the first query parameter`() {
+ val wif = "c" + "1".repeat(51)
+ assertEquals(wif, WifParser.extract("bitcoin:tb1qsomeaddress?amount=0.001&wif=$wif"))
+ }
+
+ // ─── extract: negative cases ─────────────────────────────────────────────
+
+ @Test
+ fun `extract returns null for a random non-WIF string`() {
+ assertNull(WifParser.extract("12cUi8cuUJRiFmGEu4jCAsonSS1dkVyaD7Aoo6URRiXpmaokikuyM778786"))
+ }
+
+ @Test
+ fun `extract returns null for a bech32 Bitcoin address`() {
+ assertNull(WifParser.extract("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"))
+ }
+
+ @Test
+ fun `extract returns null for an empty string`() {
+ assertNull(WifParser.extract(""))
+ }
+
+ @Test
+ fun `extract returns null for a bitcoin URI without a WIF param`() {
+ assertNull(WifParser.extract("bitcoin:tb1qsomeaddress?amount=0.001"))
+ }
+
+ @Test
+ fun `extract returns null for a bitcoin URI with unrecognised param key`() {
+ val wif = "c" + "1".repeat(51)
+ // "key" is not a recognised param name
+ assertNull(WifParser.extract("bitcoin:?key=$wif"))
+ }
+
+ // ─── extract: real WIF across all formats ───────────────────────────────
+
+ @Test
+ fun `extract handles real testnet WIF in all supported formats`() {
+ val wif = "cUkUX6eBYEiXULiJiDz5Cgvm5DQAZsMEw3mC6qd275kW6dk9hY8y"
+ assertTrue("Real WIF should pass isLikelyWif", WifParser.isLikelyWif(wif))
+ assertEquals(wif, WifParser.extract(wif))
+ assertEquals(wif, WifParser.extract("wif:$wif"))
+ assertEquals(wif, WifParser.extract("WIF:$wif"))
+ assertEquals(wif, WifParser.extract("bitcoin:?wif=$wif"))
+ assertEquals(wif, WifParser.extract("bitcoin:?privkey=$wif"))
+ assertEquals(wif, WifParser.extract(" $wif\n"))
+ }
+}
diff --git a/app/src/test/java/org/bitcoindevkit/devkitwallet/WifSweepTest.kt b/app/src/test/java/org/bitcoindevkit/devkitwallet/WifSweepTest.kt
new file mode 100644
index 0000000..ff9e30c
--- /dev/null
+++ b/app/src/test/java/org/bitcoindevkit/devkitwallet/WifSweepTest.kt
@@ -0,0 +1,45 @@
+package org.bitcoindevkit.devkitwallet
+
+import org.bitcoindevkit.devkitwallet.domain.utils.WifParser
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WifSweepTest {
+ @Test
+ fun wifParserRejectsShortPlaceholderKey() {
+ val placeholder = "cTjZ2BfARk264q9s3uH8Yk3fKqK3wT95JzFp8s1tXvR9k" // 45 chars
+ assertFalse(
+ "A 45-char string must NOT pass isLikelyWif (WIF must be 51 or 52 chars)",
+ WifParser.isLikelyWif(placeholder),
+ )
+ }
+
+ @Test
+ fun wifParserAcceptsCorrectLengthTestnetCompressedKey() {
+ val wif = "c" + "1".repeat(51)
+ assertTrue(
+ "A 52-char base58 string starting with 'c' should pass the heuristic",
+ WifParser.isLikelyWif(wif),
+ )
+ }
+
+ @Test
+ fun extractReturnsNullForBitcoinAddress() {
+ assertNull(
+ "A bech32 address must not be mistaken for a WIF",
+ WifParser.extract("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"),
+ )
+ }
+
+ @Test
+ fun extractReturnsCandidateFromBitcoinUri() {
+ val wif = "c" + "1".repeat(51)
+ val result = WifParser.extract("bitcoin:?wif=$wif")
+ assertTrue(
+ "WIF extracted from a bitcoin URI should pass isLikelyWif",
+ result != null && WifParser.isLikelyWif(result),
+ )
+ }
+}