Skip to content
Open
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
12 changes: 12 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
Expand Down
120 changes: 118 additions & 2 deletions app/src/main/java/org/bitcoindevkit/devkitwallet/domain/Wallet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ import org.bitcoindevkit.devkitwallet.data.RecoverWalletConfig
import org.bitcoindevkit.devkitwallet.data.SingleWallet
import org.bitcoindevkit.devkitwallet.data.Timestamp
import org.bitcoindevkit.devkitwallet.data.TxDetails
import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.ERROR
import org.bitcoindevkit.devkitwallet.domain.DwLogger.LogLevel.INFO
import org.bitcoindevkit.devkitwallet.domain.utils.intoDomain
import org.bitcoindevkit.devkitwallet.domain.utils.intoProto
import org.bitcoindevkit.devkitwallet.presentation.viewmodels.Recipient
import java.io.File
import java.util.UUID
import org.bitcoindevkit.Wallet as BdkWallet

Expand Down Expand Up @@ -108,10 +111,123 @@ class Wallet private constructor(
}

fun broadcast(signedPsbt: Psbt): String {
currentBlockchainClient?.broadcast(signedPsbt.extractTx()) ?: throw IllegalStateException(
val tx = signedPsbt.extractTx()
currentBlockchainClient?.broadcast(tx) ?: throw IllegalStateException(
"Blockchain client not initialized"
)
return signedPsbt.extractTx().computeTxid().toString()
return tx.computeTxid().toString()
}

fun sweep(wif: String, feeRate: FeeRate): Psbt {
val shortWif = wif.take(8) + "..."
DwLogger.log(INFO, "Sweep started for WIF $shortWif (length=${wif.length}) on ${wallet.network()}")
DwLogger.log(INFO, "Esplora endpoint: ${getClientEndpoint()}")

val candidates = listOf(
"pkh($wif)",
"wpkh($wif)",
"tr($wif)",
"sh(wpkh($wif))",
)

var bestWallet: BdkWallet? = null
var maxBalance = 0UL
val candidateErrors = mutableListOf<String>()

val net = wallet.network()
val tempFiles = mutableListOf<File>()

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<CanonicalTx> = wallet.transactions()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Char> =
"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<String>()

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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<RbfScreen>(
enterTransition = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down
Loading