
High-level Bitcoin wallet API offering HD key derivation, multiple address types including Taproot, watch-only support, UTXO selection strategies, transaction creation/signing/broadcast, and pluggable sync/storage.
A Kotlin Multiplatform library for managing Bitcoin wallets. Provides a high-level API for wallet operations including HD key derivation, address generation, UTXO management, transaction creation, and blockchain synchronization.
Built on top of ACINQ's bitcoin-kmp library.
| Platform | Architectures |
|---|---|
| JVM | x64, arm64 |
| Android | API 24+ (arm64-v8a, armeabi-v7a, x86, x86_64) |
| iOS | arm64, x64, simulator |
| Linux | x64, arm64 |
Add the dependency to your build.gradle.kts:
// For Kotlin Multiplatform projects
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.sourlabs.btc:library:0.5.0")
}
}
}
// For single-platform projects (JVM/Android)
dependencies {
implementation("io.sourlabs.btc:library-jvm:0.5.0") // JVM
implementation("io.sourlabs.btc:library-android:0.5.0") // Android
}Make sure you have Maven Central in your repositories:
repositories {
mavenCentral()
}import io.sourlabs.btc.wallet.api.BitcoinKit
import io.sourlabs.btc.wallet.core.WalletConfig
import io.sourlabs.btc.wallet.core.SyncConfig
import io.sourlabs.btc.wallet.keys.SeedManager
import io.sourlabs.btc.wallet.models.Network
import io.sourlabs.btc.wallet.models.Purpose
import kotlinx.coroutines.launch
// 1. Generate a new mnemonic (or use an existing one)
val mnemonic = SeedManager.generateMnemonicCode(SeedManager.MnemonicSize.Max)
// 2. Create wallet configuration
val config = WalletConfig.FromMnemonic(
mnemonic = mnemonic,
purpose = Purpose.BIP84, // Native SegWit (bc1q... addresses)
network = Network.MAINNET,
gapLimit = 20
)
// 3. Build the wallet. The builder picks a sensible default sync source for
// the network (Blockstream for mainnet/testnet/signet, Mempool.space for
// Testnet4). Override with .syncConfig(...) when you want something else.
val wallet = BitcoinKit.builder(config).build()
// 4. Start the wallet (in a coroutine scope)
coroutineScope.launch {
wallet.start()
// Get a receive address
val address = wallet.receiveAddress()
println("Send Bitcoin to: $address")
// Check balance
val balance = wallet.getBalance()
println("Balance: ${balance.spendable} satoshis")
}The library supports multiple ways to initialize a wallet:
val config = WalletConfig.FromMnemonic(
mnemonic = listOf("abandon", "abandon", ..., "about"), // 12, 15, 18, 21, or 24 words
passphrase = "", // Optional BIP39 passphrase
purpose = Purpose.BIP84, // Address type
network = Network.MAINNET,
account = 0, // BIP44 account index
gapLimit = 20, // Unused address buffer
confirmationsThreshold = 1 // Min confirmations for spendable UTXOs
)val config = WalletConfig.FromSeed(
seed = seedBytes, // At least 16 bytes
purpose = Purpose.BIP84,
network = Network.MAINNET
)The key must be at account depth — m/purpose'/coin'/account', BIP-32 depth 3
(e.g. xprv9z… exported by a hardware wallet for a specific account). Master
xprvs (depth 0) are rejected: deriving the account from a master could silently
produce the wrong wallet, so callers must do that derivation themselves.
val config = WalletConfig.FromExtendedPrivateKey(
accountExtendedPrivateKey = "xprv9z...", // at m/84'/0'/0' for this example
purpose = Purpose.BIP84,
network = Network.MAINNET,
account = 0,
)The key must be at account depth as well — typical hardware-wallet xpub exports
already satisfy this (Coldcard, Ledger, Trezor export at m/purpose'/coin'/account').
val config = WalletConfig.WatchOnly(
extendedPublicKey = "zpub6rFR7...", // account-level xpub/ypub/zpub
purpose = Purpose.BIP84,
network = Network.MAINNET,
account = 0,
)| Purpose | Script Type | Address Format | Description |
|---|---|---|---|
BIP44 |
P2PKH | 1... |
Legacy addresses |
BIP49 |
P2SH-P2WPKH | 3... |
Nested SegWit |
BIP84 |
P2WPKH | bc1q... |
Native SegWit v0 |
BIP86 |
P2TR | bc1p... |
Taproot (SegWit v1) |
| Network | Description |
|---|---|
Network.MAINNET |
Bitcoin mainnet |
Network.TESTNET |
Bitcoin testnet (Testnet3) |
Network.TESTNET4 |
Bitcoin Testnet4 (replaces Testnet3; served by Mempool.space, not Blockstream) |
Network.SIGNET |
Bitcoin signet |
Network.REGTEST |
Local regtest |
// Get a fresh receive address
val address = wallet.receiveAddress()
// Get all used addresses
val usedAddresses = wallet.usedAddresses()
// Validate an address
val isValid = wallet.validateAddress("bc1q...")
// Parse address details
val info = wallet.parseAddress("bc1q...")
// info?.scriptType, info?.scriptPubKeyval balance = wallet.getBalance()
println("Spendable: ${balance.spendable} sats") // Confirmed, meets threshold
println("Unconfirmed: ${balance.unconfirmed} sats") // Pending confirmations
println("Locked: ${balance.locked} sats") // Time-locked
println("Total: ${balance.total} sats")// Get transaction info before sending
val sendInfo = wallet.sendInfo(
toAddress = "bc1q...",
amount = 100_000, // Amount in satoshis
feeRate = 25 // Fee rate in sat/vB
)
println("Fee: ${sendInfo?.fee} sats")
// Create and broadcast transaction
val result = wallet.send(
toAddress = "bc1q...",
amount = 100_000,
feeRate = 25,
rbfEnabled = true, // Enable Replace-By-Fee
subtractFeeFromAmount = false // Deduct fee from amount?
)
result.onSuccess { txId ->
println("Transaction sent: $txId")
}.onFailure { error ->
println("Failed: ${error.message}")
}// Create a signed transaction without broadcasting
val createdTx = wallet.createTransaction(
toAddress = "bc1q...",
amount = 100_000,
feeRate = 25
)
println("TxId: ${createdTx.txId}")
println("Size: ${createdTx.vSize} vBytes")
println("Fee: ${createdTx.fee} sats (${createdTx.feeRate} sat/vB)")
println("Raw: ${createdTx.rawTx.toHex()}")
// Broadcast later
val result = wallet.broadcastTransaction(createdTx.rawTx.toHex())val sweepTx = wallet.createSweepTransaction(
toAddress = "bc1q...",
feeRate = 10
)
wallet.broadcastTransaction(sweepTx.rawTx.toHex())import io.sourlabs.btc.wallet.utxo.SelectionStrategy
wallet.send(
toAddress = "bc1q...",
amount = 100_000,
feeRate = 25,
strategy = SelectionStrategy.AUTOMATIC // Default: optimize for fees + privacy
// strategy = SelectionStrategy.OLDEST_FIRST // FIFO selection
// strategy = SelectionStrategy.LARGEST_FIRST // Minimize number of inputs
// strategy = SelectionStrategy.SMALLEST_FIRST // Consolidate small UTXOs
// strategy = SelectionStrategy.PRIVACY_OPTIMIZED // Avoid linking UTXOs
)// Get all transactions
val transactions = wallet.transactions()
// Filter by type
val incoming = wallet.transactions(type = TransactionType.INCOMING)
val outgoing = wallet.transactions(type = TransactionType.OUTGOING)
// Limit results
val recent = wallet.transactions(limit = 10)
// Get specific transaction
val tx = wallet.getTransaction("txid...")
tx?.let {
println("Amount: ${it.amount} sats")
println("Fee: ${it.fee} sats")
println("Status: ${it.status}") // PENDING, RELAYED, CONFIRMED, FAILED
println("Confirmations: ${it.confirmations(currentBlockHeight)}")
}val fees = wallet.getRecommendedFees()
fees?.let {
println("Fastest (next block): ${it.fastestFee} sat/vB")
println("Half hour: ${it.halfHourFee} sat/vB")
println("Hour: ${it.hourFee} sat/vB")
println("Economy: ${it.economyFee} sat/vB")
println("Minimum: ${it.minimumFee} sat/vB")
}// Calculate max amount you can send at a given fee rate
val maxAmount = wallet.maximumSpendableValue(feeRate = 25)
println("Maximum spendable: $maxAmount sats")The wallet emits reactive events through Kotlin Flows:
// Observe sync state
wallet.syncState.collect { state ->
when (state) {
is SyncState.NotSynced -> println("Not synced")
is SyncState.Syncing -> println("Syncing: ${(state.progress * 100).toInt()}%")
is SyncState.Synced -> println("Synced at ${state.lastSyncTime}")
is SyncState.Error -> println("Sync error: ${state.message}")
}
}
// Observe wallet events
wallet.events.collect { event ->
when (event) {
is WalletEvent.BalanceUpdated ->
println("New balance: ${event.balance.total}")
is WalletEvent.TransactionReceived ->
println("Received: ${event.transaction.amount} sats")
is WalletEvent.TransactionSent ->
println("Sent: ${event.transaction.txId}")
is WalletEvent.TransactionStatusChanged ->
println("Tx ${event.txId}: ${event.oldStatus} -> ${event.newStatus}")
is WalletEvent.NewBlock ->
println("New block: ${event.height}")
is WalletEvent.WalletError ->
println("Error: ${event.message}")
is WalletEvent.SyncStateChanged -> { /* handled by syncState flow */ }
}
}By default, the library uses in-memory storage. For persistence, implement the WalletStorage interface:
class MyDatabaseStorage : WalletStorage {
override val publicKeyStorage: PublicKeyStorage = MyPublicKeyStorage()
override val transactionStorage: TransactionStorage = MyTransactionStorage()
override val unspentOutputStorage: UnspentOutputStorage = MyUnspentOutputStorage()
override val blockInfoStorage: BlockInfoStorage = MyBlockInfoStorage()
override suspend fun clearAll() {
// Clear all storage
}
}
// Use custom storage
val wallet = BitcoinKit.builder(config)
.storage(MyDatabaseStorage())
.build()// Blockstream (mainnet / testnet / signet)
val syncConfig = SyncConfig.BlockStream.forNetwork(Network.MAINNET)
// Mempool.space (mainnet / testnet / testnet4 / signet)
val syncConfig = SyncConfig.MempoolSpace.forNetwork(Network.TESTNET4)
// Custom BlockStream instance
val syncConfig = SyncConfig.BlockStream(
baseUrl = "https://my-blockstream-instance.com/api",
pollingIntervalMs = 30_000
)
// Custom API endpoint (regtest, self-hosted Esplora, etc.)
val syncConfig = SyncConfig.CustomApi(
baseUrl = "https://my-api.com",
pollingIntervalMs = 60_000
)
// Smart default per network — what BitcoinKit.builder uses when no
// .syncConfig() is supplied.
val syncConfig = SyncConfig.defaultForNetwork(Network.TESTNET4)For higher rate limits, use the authenticated Enterprise endpoints. The library performs the
OAuth2 client_credentials exchange, attaches the Authorization: Bearer <token> header to
every request, and transparently re-fetches the token on 401 (tokens live for ~5 minutes and
no refresh token is issued).
val syncConfig = SyncConfig.BlockStream.enterprise(
network = Network.MAINNET,
clientId = clientId,
clientSecret = clientSecret
)Enterprise only serves mainnet and testnet:
SIGNET falls back to Blockstream's free public Signet endpoint (no auth).REGTEST and TESTNET4 throw IllegalArgumentException — Blockstream has
no endpoint for either. For Testnet4 use SyncConfig.MempoolSpace.forNetwork(Network.TESTNET4);
for regtest construct SyncConfig.BlockStream(baseUrl = ...) or
SyncConfig.CustomApi(...) explicitly.Never ship
clientSecretinside a distributed client binary. A secret bundled into an Android APK, iOS IPA, or desktop build is not secret — anyone who downloads the binary can extract it. For production apps with untrusted clients, run a small backend you control that holds the secret and proxies Explorer API requests (or mints short-lived tokens for your clients). For local development or trusted server-side use, load the credentials from environment variables, platform keystores, or a secrets manager — never commit them.
You can configure fallback sync providers that are automatically tried if the primary provider fails or times out:
val wallet = BitcoinKit.builder(config)
.syncConfig(SyncConfig.BlockStream.forNetwork(Network.MAINNET))
.addFallbackSyncConfig(SyncConfig.MempoolSpace.forNetwork(Network.MAINNET))
.addFallbackSyncConfig(SyncConfig.CustomApi("https://my-node.example.com/api"))
.build()When sync starts, the library tries each provider in order. If the primary (BlockStream in this example) fails, it automatically falls back to MempoolSpace, then to the custom API. Fallback attempts are reported as WalletEvent.WalletError events so you can observe them via the events flow.
The library minimizes calls to the Blockchain Explorer in a few ways:
GET /blocks call, instead of the three round-trips (/blocks/tip/height, /block-height/{h}, /block/{hash}) that a naive Esplora client would make.GET /address/{addr} call reads the current confirmed and mempool tx counts. The expensive per-address calls (/txs, /utxo) only fire when those counts have actually changed since the last sync.GET /address/{addr}/txs/chain and stops as soon as it sees the last-known txid — so subsequent syncs of an active address only fetch the new transactions, not the full history.getCurrentBlockHeight() returns the tip cached by the sync loop in local storage, falling back to the network only before the first sync completes.With the default gap limit of 20, a steady-state full sync of any wallet (fresh or active) costs ~41 calls (one block-tip call plus one probe per address). The full per-address fetch only fires when an address has actually changed.
Scan for existing wallet activity before creating a wallet:
val scanResult = BitcoinKit.scanWallet(
mnemonic = mnemonic,
passphrase = "",
network = Network.MAINNET
)
// scanResult contains detected address types and balances// Start wallet (initializes keys, starts sync). Suspends until the first
// sync attempt has finished — syncState is Synced or Error by the time
// start() returns. So you can immediately use getBalance() / transactions()
// / receiveAddress() without observing syncState yourself.
wallet.start()
// Manual refresh
wallet.refresh()
// Stop sync operations. Suspends until the sync coroutine has actually
// exited, so subsequent state mutations (clearData) don't race.
wallet.stop()
// Clear all wallet data
wallet.clearData()The library throws specific exceptions:
try {
wallet.send(...)
} catch (e: InvalidAddressException) {
// Invalid destination address
} catch (e: InvalidAmountException) {
// Amount is non-positive, below the destination's dust threshold,
// or feeRate is below 1 sat/vB
} catch (e: InsufficientFundsException) {
// Not enough balance
} catch (e: SigningException) {
// Watch-only wallet or signing failed
} catch (e: BroadcastException) {
// Network broadcast failed
} catch (e: ScanException) {
// Wallet restoration scan aborted after too many consecutive API failures
// (only thrown by BitcoinKit.scanWallet / MultiPurposeScanner)
} catch (e: WalletException) {
// General wallet error
}The library logs through Kermit with one tag per major component, so consumers can filter or suppress per-component:
| Tag | Source |
|---|---|
BitcoinKit |
BitcoinKit (start/stop/send/scan entry points) |
SyncManager |
SyncManager (sync lifecycle, polling, fallback decisions) |
BlockchainExplorerApi |
BlockchainExplorerApi (HTTP, retry, auth) |
MultiPurposeScanner |
MultiPurposeScanner (per-purpose chain walks during restoration) |
Raise the global minimum severity to quiet things down:
co.touchlab.kermit.Logger.setMinSeverity(co.touchlab.kermit.Severity.Warn)See Kermit's docs for per-tag filtering and custom log writers if you need finer control.
The main facade class. Key properties and methods:
| Property/Method | Description |
|---|---|
network |
Current network (MAINNET, TESTNET, etc.) |
purpose |
Address type (BIP44, BIP49, BIP84, BIP86) |
isWatchOnly |
Whether wallet can sign transactions |
syncState |
StateFlow of sync state |
events |
SharedFlow of wallet events |
start() |
Initialize and start syncing. Suspends until first sync finishes (Synced or Error). |
stop() |
Stop sync operations. Suspends until the sync coroutine has actually exited. |
refresh() |
Manually trigger sync |
receiveAddress() |
Get fresh receive address |
getBalance() |
Get current balance |
send() |
Create, sign, and broadcast transaction |
createTransaction() |
Create signed transaction without broadcast |
transactions() |
Get transaction history |
getRecommendedFees() |
Get fee estimates |
BitcoinKit.generateMnemonic(wordCount = 24) // Generate new mnemonic
BitcoinKit.validateMnemonic(mnemonic) // Validate mnemonic words
BitcoinKit.scanWallet(mnemonic, ...) // Scan for existing activityCurrent versions are tracked in gradle/libs.versions.toml.
| Library | Purpose |
|---|---|
| bitcoin-kmp | Core Bitcoin implementation |
| secp256k1-kmp | Elliptic curve cryptography |
| Ktor | HTTP networking |
| kotlinx-coroutines | Async/await and Flows |
| kotlinx-serialization | JSON serialization |
| Kermit | Multiplatform logging |
# Build all targets
./gradlew build
# Run tests
./gradlew allTests
# Platform-specific tests
./gradlew jvmTest
./gradlew iosSimulatorArm64Test
./gradlew linuxX64Test
# Publish to Maven Local
./gradlew publishToMavenLocalContributions are welcome — see CONTRIBUTING.md for setup, the PR workflow, and code conventions.
To report a vulnerability, please follow the process in SECURITY.md. Do not open a public issue for security-sensitive bugs.
This project follows the Contributor Covenant. By participating, you agree to abide by its terms.
Copyright 2026 Sour Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
A Kotlin Multiplatform library for managing Bitcoin wallets. Provides a high-level API for wallet operations including HD key derivation, address generation, UTXO management, transaction creation, and blockchain synchronization.
Built on top of ACINQ's bitcoin-kmp library.
| Platform | Architectures |
|---|---|
| JVM | x64, arm64 |
| Android | API 24+ (arm64-v8a, armeabi-v7a, x86, x86_64) |
| iOS | arm64, x64, simulator |
| Linux | x64, arm64 |
Add the dependency to your build.gradle.kts:
// For Kotlin Multiplatform projects
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.sourlabs.btc:library:0.5.0")
}
}
}
// For single-platform projects (JVM/Android)
dependencies {
implementation("io.sourlabs.btc:library-jvm:0.5.0") // JVM
implementation("io.sourlabs.btc:library-android:0.5.0") // Android
}Make sure you have Maven Central in your repositories:
repositories {
mavenCentral()
}import io.sourlabs.btc.wallet.api.BitcoinKit
import io.sourlabs.btc.wallet.core.WalletConfig
import io.sourlabs.btc.wallet.core.SyncConfig
import io.sourlabs.btc.wallet.keys.SeedManager
import io.sourlabs.btc.wallet.models.Network
import io.sourlabs.btc.wallet.models.Purpose
import kotlinx.coroutines.launch
// 1. Generate a new mnemonic (or use an existing one)
val mnemonic = SeedManager.generateMnemonicCode(SeedManager.MnemonicSize.Max)
// 2. Create wallet configuration
val config = WalletConfig.FromMnemonic(
mnemonic = mnemonic,
purpose = Purpose.BIP84, // Native SegWit (bc1q... addresses)
network = Network.MAINNET,
gapLimit = 20
)
// 3. Build the wallet. The builder picks a sensible default sync source for
// the network (Blockstream for mainnet/testnet/signet, Mempool.space for
// Testnet4). Override with .syncConfig(...) when you want something else.
val wallet = BitcoinKit.builder(config).build()
// 4. Start the wallet (in a coroutine scope)
coroutineScope.launch {
wallet.start()
// Get a receive address
val address = wallet.receiveAddress()
println("Send Bitcoin to: $address")
// Check balance
val balance = wallet.getBalance()
println("Balance: ${balance.spendable} satoshis")
}The library supports multiple ways to initialize a wallet:
val config = WalletConfig.FromMnemonic(
mnemonic = listOf("abandon", "abandon", ..., "about"), // 12, 15, 18, 21, or 24 words
passphrase = "", // Optional BIP39 passphrase
purpose = Purpose.BIP84, // Address type
network = Network.MAINNET,
account = 0, // BIP44 account index
gapLimit = 20, // Unused address buffer
confirmationsThreshold = 1 // Min confirmations for spendable UTXOs
)val config = WalletConfig.FromSeed(
seed = seedBytes, // At least 16 bytes
purpose = Purpose.BIP84,
network = Network.MAINNET
)The key must be at account depth — m/purpose'/coin'/account', BIP-32 depth 3
(e.g. xprv9z… exported by a hardware wallet for a specific account). Master
xprvs (depth 0) are rejected: deriving the account from a master could silently
produce the wrong wallet, so callers must do that derivation themselves.
val config = WalletConfig.FromExtendedPrivateKey(
accountExtendedPrivateKey = "xprv9z...", // at m/84'/0'/0' for this example
purpose = Purpose.BIP84,
network = Network.MAINNET,
account = 0,
)The key must be at account depth as well — typical hardware-wallet xpub exports
already satisfy this (Coldcard, Ledger, Trezor export at m/purpose'/coin'/account').
val config = WalletConfig.WatchOnly(
extendedPublicKey = "zpub6rFR7...", // account-level xpub/ypub/zpub
purpose = Purpose.BIP84,
network = Network.MAINNET,
account = 0,
)| Purpose | Script Type | Address Format | Description |
|---|---|---|---|
BIP44 |
P2PKH | 1... |
Legacy addresses |
BIP49 |
P2SH-P2WPKH | 3... |
Nested SegWit |
BIP84 |
P2WPKH | bc1q... |
Native SegWit v0 |
BIP86 |
P2TR | bc1p... |
Taproot (SegWit v1) |
| Network | Description |
|---|---|
Network.MAINNET |
Bitcoin mainnet |
Network.TESTNET |
Bitcoin testnet (Testnet3) |
Network.TESTNET4 |
Bitcoin Testnet4 (replaces Testnet3; served by Mempool.space, not Blockstream) |
Network.SIGNET |
Bitcoin signet |
Network.REGTEST |
Local regtest |
// Get a fresh receive address
val address = wallet.receiveAddress()
// Get all used addresses
val usedAddresses = wallet.usedAddresses()
// Validate an address
val isValid = wallet.validateAddress("bc1q...")
// Parse address details
val info = wallet.parseAddress("bc1q...")
// info?.scriptType, info?.scriptPubKeyval balance = wallet.getBalance()
println("Spendable: ${balance.spendable} sats") // Confirmed, meets threshold
println("Unconfirmed: ${balance.unconfirmed} sats") // Pending confirmations
println("Locked: ${balance.locked} sats") // Time-locked
println("Total: ${balance.total} sats")// Get transaction info before sending
val sendInfo = wallet.sendInfo(
toAddress = "bc1q...",
amount = 100_000, // Amount in satoshis
feeRate = 25 // Fee rate in sat/vB
)
println("Fee: ${sendInfo?.fee} sats")
// Create and broadcast transaction
val result = wallet.send(
toAddress = "bc1q...",
amount = 100_000,
feeRate = 25,
rbfEnabled = true, // Enable Replace-By-Fee
subtractFeeFromAmount = false // Deduct fee from amount?
)
result.onSuccess { txId ->
println("Transaction sent: $txId")
}.onFailure { error ->
println("Failed: ${error.message}")
}// Create a signed transaction without broadcasting
val createdTx = wallet.createTransaction(
toAddress = "bc1q...",
amount = 100_000,
feeRate = 25
)
println("TxId: ${createdTx.txId}")
println("Size: ${createdTx.vSize} vBytes")
println("Fee: ${createdTx.fee} sats (${createdTx.feeRate} sat/vB)")
println("Raw: ${createdTx.rawTx.toHex()}")
// Broadcast later
val result = wallet.broadcastTransaction(createdTx.rawTx.toHex())val sweepTx = wallet.createSweepTransaction(
toAddress = "bc1q...",
feeRate = 10
)
wallet.broadcastTransaction(sweepTx.rawTx.toHex())import io.sourlabs.btc.wallet.utxo.SelectionStrategy
wallet.send(
toAddress = "bc1q...",
amount = 100_000,
feeRate = 25,
strategy = SelectionStrategy.AUTOMATIC // Default: optimize for fees + privacy
// strategy = SelectionStrategy.OLDEST_FIRST // FIFO selection
// strategy = SelectionStrategy.LARGEST_FIRST // Minimize number of inputs
// strategy = SelectionStrategy.SMALLEST_FIRST // Consolidate small UTXOs
// strategy = SelectionStrategy.PRIVACY_OPTIMIZED // Avoid linking UTXOs
)// Get all transactions
val transactions = wallet.transactions()
// Filter by type
val incoming = wallet.transactions(type = TransactionType.INCOMING)
val outgoing = wallet.transactions(type = TransactionType.OUTGOING)
// Limit results
val recent = wallet.transactions(limit = 10)
// Get specific transaction
val tx = wallet.getTransaction("txid...")
tx?.let {
println("Amount: ${it.amount} sats")
println("Fee: ${it.fee} sats")
println("Status: ${it.status}") // PENDING, RELAYED, CONFIRMED, FAILED
println("Confirmations: ${it.confirmations(currentBlockHeight)}")
}val fees = wallet.getRecommendedFees()
fees?.let {
println("Fastest (next block): ${it.fastestFee} sat/vB")
println("Half hour: ${it.halfHourFee} sat/vB")
println("Hour: ${it.hourFee} sat/vB")
println("Economy: ${it.economyFee} sat/vB")
println("Minimum: ${it.minimumFee} sat/vB")
}// Calculate max amount you can send at a given fee rate
val maxAmount = wallet.maximumSpendableValue(feeRate = 25)
println("Maximum spendable: $maxAmount sats")The wallet emits reactive events through Kotlin Flows:
// Observe sync state
wallet.syncState.collect { state ->
when (state) {
is SyncState.NotSynced -> println("Not synced")
is SyncState.Syncing -> println("Syncing: ${(state.progress * 100).toInt()}%")
is SyncState.Synced -> println("Synced at ${state.lastSyncTime}")
is SyncState.Error -> println("Sync error: ${state.message}")
}
}
// Observe wallet events
wallet.events.collect { event ->
when (event) {
is WalletEvent.BalanceUpdated ->
println("New balance: ${event.balance.total}")
is WalletEvent.TransactionReceived ->
println("Received: ${event.transaction.amount} sats")
is WalletEvent.TransactionSent ->
println("Sent: ${event.transaction.txId}")
is WalletEvent.TransactionStatusChanged ->
println("Tx ${event.txId}: ${event.oldStatus} -> ${event.newStatus}")
is WalletEvent.NewBlock ->
println("New block: ${event.height}")
is WalletEvent.WalletError ->
println("Error: ${event.message}")
is WalletEvent.SyncStateChanged -> { /* handled by syncState flow */ }
}
}By default, the library uses in-memory storage. For persistence, implement the WalletStorage interface:
class MyDatabaseStorage : WalletStorage {
override val publicKeyStorage: PublicKeyStorage = MyPublicKeyStorage()
override val transactionStorage: TransactionStorage = MyTransactionStorage()
override val unspentOutputStorage: UnspentOutputStorage = MyUnspentOutputStorage()
override val blockInfoStorage: BlockInfoStorage = MyBlockInfoStorage()
override suspend fun clearAll() {
// Clear all storage
}
}
// Use custom storage
val wallet = BitcoinKit.builder(config)
.storage(MyDatabaseStorage())
.build()// Blockstream (mainnet / testnet / signet)
val syncConfig = SyncConfig.BlockStream.forNetwork(Network.MAINNET)
// Mempool.space (mainnet / testnet / testnet4 / signet)
val syncConfig = SyncConfig.MempoolSpace.forNetwork(Network.TESTNET4)
// Custom BlockStream instance
val syncConfig = SyncConfig.BlockStream(
baseUrl = "https://my-blockstream-instance.com/api",
pollingIntervalMs = 30_000
)
// Custom API endpoint (regtest, self-hosted Esplora, etc.)
val syncConfig = SyncConfig.CustomApi(
baseUrl = "https://my-api.com",
pollingIntervalMs = 60_000
)
// Smart default per network — what BitcoinKit.builder uses when no
// .syncConfig() is supplied.
val syncConfig = SyncConfig.defaultForNetwork(Network.TESTNET4)For higher rate limits, use the authenticated Enterprise endpoints. The library performs the
OAuth2 client_credentials exchange, attaches the Authorization: Bearer <token> header to
every request, and transparently re-fetches the token on 401 (tokens live for ~5 minutes and
no refresh token is issued).
val syncConfig = SyncConfig.BlockStream.enterprise(
network = Network.MAINNET,
clientId = clientId,
clientSecret = clientSecret
)Enterprise only serves mainnet and testnet:
SIGNET falls back to Blockstream's free public Signet endpoint (no auth).REGTEST and TESTNET4 throw IllegalArgumentException — Blockstream has
no endpoint for either. For Testnet4 use SyncConfig.MempoolSpace.forNetwork(Network.TESTNET4);
for regtest construct SyncConfig.BlockStream(baseUrl = ...) or
SyncConfig.CustomApi(...) explicitly.Never ship
clientSecretinside a distributed client binary. A secret bundled into an Android APK, iOS IPA, or desktop build is not secret — anyone who downloads the binary can extract it. For production apps with untrusted clients, run a small backend you control that holds the secret and proxies Explorer API requests (or mints short-lived tokens for your clients). For local development or trusted server-side use, load the credentials from environment variables, platform keystores, or a secrets manager — never commit them.
You can configure fallback sync providers that are automatically tried if the primary provider fails or times out:
val wallet = BitcoinKit.builder(config)
.syncConfig(SyncConfig.BlockStream.forNetwork(Network.MAINNET))
.addFallbackSyncConfig(SyncConfig.MempoolSpace.forNetwork(Network.MAINNET))
.addFallbackSyncConfig(SyncConfig.CustomApi("https://my-node.example.com/api"))
.build()When sync starts, the library tries each provider in order. If the primary (BlockStream in this example) fails, it automatically falls back to MempoolSpace, then to the custom API. Fallback attempts are reported as WalletEvent.WalletError events so you can observe them via the events flow.
The library minimizes calls to the Blockchain Explorer in a few ways:
GET /blocks call, instead of the three round-trips (/blocks/tip/height, /block-height/{h}, /block/{hash}) that a naive Esplora client would make.GET /address/{addr} call reads the current confirmed and mempool tx counts. The expensive per-address calls (/txs, /utxo) only fire when those counts have actually changed since the last sync.GET /address/{addr}/txs/chain and stops as soon as it sees the last-known txid — so subsequent syncs of an active address only fetch the new transactions, not the full history.getCurrentBlockHeight() returns the tip cached by the sync loop in local storage, falling back to the network only before the first sync completes.With the default gap limit of 20, a steady-state full sync of any wallet (fresh or active) costs ~41 calls (one block-tip call plus one probe per address). The full per-address fetch only fires when an address has actually changed.
Scan for existing wallet activity before creating a wallet:
val scanResult = BitcoinKit.scanWallet(
mnemonic = mnemonic,
passphrase = "",
network = Network.MAINNET
)
// scanResult contains detected address types and balances// Start wallet (initializes keys, starts sync). Suspends until the first
// sync attempt has finished — syncState is Synced or Error by the time
// start() returns. So you can immediately use getBalance() / transactions()
// / receiveAddress() without observing syncState yourself.
wallet.start()
// Manual refresh
wallet.refresh()
// Stop sync operations. Suspends until the sync coroutine has actually
// exited, so subsequent state mutations (clearData) don't race.
wallet.stop()
// Clear all wallet data
wallet.clearData()The library throws specific exceptions:
try {
wallet.send(...)
} catch (e: InvalidAddressException) {
// Invalid destination address
} catch (e: InvalidAmountException) {
// Amount is non-positive, below the destination's dust threshold,
// or feeRate is below 1 sat/vB
} catch (e: InsufficientFundsException) {
// Not enough balance
} catch (e: SigningException) {
// Watch-only wallet or signing failed
} catch (e: BroadcastException) {
// Network broadcast failed
} catch (e: ScanException) {
// Wallet restoration scan aborted after too many consecutive API failures
// (only thrown by BitcoinKit.scanWallet / MultiPurposeScanner)
} catch (e: WalletException) {
// General wallet error
}The library logs through Kermit with one tag per major component, so consumers can filter or suppress per-component:
| Tag | Source |
|---|---|
BitcoinKit |
BitcoinKit (start/stop/send/scan entry points) |
SyncManager |
SyncManager (sync lifecycle, polling, fallback decisions) |
BlockchainExplorerApi |
BlockchainExplorerApi (HTTP, retry, auth) |
MultiPurposeScanner |
MultiPurposeScanner (per-purpose chain walks during restoration) |
Raise the global minimum severity to quiet things down:
co.touchlab.kermit.Logger.setMinSeverity(co.touchlab.kermit.Severity.Warn)See Kermit's docs for per-tag filtering and custom log writers if you need finer control.
The main facade class. Key properties and methods:
| Property/Method | Description |
|---|---|
network |
Current network (MAINNET, TESTNET, etc.) |
purpose |
Address type (BIP44, BIP49, BIP84, BIP86) |
isWatchOnly |
Whether wallet can sign transactions |
syncState |
StateFlow of sync state |
events |
SharedFlow of wallet events |
start() |
Initialize and start syncing. Suspends until first sync finishes (Synced or Error). |
stop() |
Stop sync operations. Suspends until the sync coroutine has actually exited. |
refresh() |
Manually trigger sync |
receiveAddress() |
Get fresh receive address |
getBalance() |
Get current balance |
send() |
Create, sign, and broadcast transaction |
createTransaction() |
Create signed transaction without broadcast |
transactions() |
Get transaction history |
getRecommendedFees() |
Get fee estimates |
BitcoinKit.generateMnemonic(wordCount = 24) // Generate new mnemonic
BitcoinKit.validateMnemonic(mnemonic) // Validate mnemonic words
BitcoinKit.scanWallet(mnemonic, ...) // Scan for existing activityCurrent versions are tracked in gradle/libs.versions.toml.
| Library | Purpose |
|---|---|
| bitcoin-kmp | Core Bitcoin implementation |
| secp256k1-kmp | Elliptic curve cryptography |
| Ktor | HTTP networking |
| kotlinx-coroutines | Async/await and Flows |
| kotlinx-serialization | JSON serialization |
| Kermit | Multiplatform logging |
# Build all targets
./gradlew build
# Run tests
./gradlew allTests
# Platform-specific tests
./gradlew jvmTest
./gradlew iosSimulatorArm64Test
./gradlew linuxX64Test
# Publish to Maven Local
./gradlew publishToMavenLocalContributions are welcome — see CONTRIBUTING.md for setup, the PR workflow, and code conventions.
To report a vulnerability, please follow the process in SECURITY.md. Do not open a public issue for security-sensitive bugs.
This project follows the Contributor Covenant. By participating, you agree to abide by its terms.
Copyright 2026 Sour Labs
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.