From 0f9bd99ba244d94da9268c620853bf2c7127a738 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Tue, 17 Jun 2025 09:53:02 -0300 Subject: [PATCH 1/6] feat(fuzzing): add new fuzz crate and initial test - creates a new `fuzz` crate, it's meant to run fuzz testing over bdk_wallet targets, with `cargo fuzz` (libFuzzer). - creates an initial `wallet_update` fuzz target for `bdk_wallet`. - creates an initial `fuzzed_data_provider` and `fuzz_utils` files with useful methods to consume the fuzzed data into `bdk_wallet` API-specific types. --- .gitignore | 6 + Cargo.toml | 1 + fuzz/Cargo.toml | 26 ++++ fuzz/README.md | 9 ++ fuzz/fuzz_targets/wallet_update.rs | 74 +++++++++ fuzz/src/fuzz_utils.rs | 238 +++++++++++++++++++++++++++++ fuzz/src/fuzzed_data_provider.rs | 74 +++++++++ fuzz/src/lib.rs | 2 + 8 files changed, 430 insertions(+) create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/fuzz_targets/wallet_update.rs create mode 100644 fuzz/src/fuzz_utils.rs create mode 100644 fuzz/src/fuzzed_data_provider.rs create mode 100644 fuzz/src/lib.rs diff --git a/.gitignore b/.gitignore index e2d4d770..7fbd9dff 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ Cargo.lock # Example persisted files. *.db *.sqlite* + +# fuzz testing related +fuzz/target +fuzz/corpus +fuzz/artifacts +fuzz/coverage diff --git a/Cargo.toml b/Cargo.toml index fbde1ace..2b878054 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "wallet", + "fuzz", "examples/example_wallet_electrum", "examples/example_wallet_esplora_blocking", "examples/example_wallet_esplora_async", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 00000000..f6dbdb15 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "bdk_wallet_fuzz" +homepage = "https://bitcoindevkit.org" +version = "0.0.1-alpha.0" +repository = "https://github.com/bitcoindevkit/bdk_wallet" +description = "A fuzz testing library for the Bitcoin Development Kit Wallet" +keywords = ["fuzz", "testing", "fuzzing", "bitcoin", "wallet"] +publish = false +readme = "README.md" +license = "MIT OR Apache-2.0" +authors = ["Bitcoin Dev Kit Developers"] +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +bdk_wallet = { path = "../wallet" } + +[[bin]] +name = "wallet" +path = "fuzz_targets/wallet_update.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 00000000..39ad998c --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,9 @@ +# Fuzzing + +## How does it work ? + +## How do I run the fuzz tests locally ? + +## How do I add a new fuzz test target ? + +## How do I reproduce a crashing fuzz test ? diff --git a/fuzz/fuzz_targets/wallet_update.rs b/fuzz/fuzz_targets/wallet_update.rs new file mode 100644 index 00000000..7ba65a1a --- /dev/null +++ b/fuzz/fuzz_targets/wallet_update.rs @@ -0,0 +1,74 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::collections::{BTreeMap, VecDeque}; + +use bdk_wallet::{ + bitcoin::{Network, Txid}, + chain::TxUpdate, + descriptor::DescriptorError, + KeychainKind, Update, Wallet, +}; +use bdk_wallet_fuzz::fuzz_utils::*; + +// descriptors +const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; +const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + +// network +const NETWORK: Network = Network::Testnet; + +fuzz_target!(|data: &[u8]| { + // creates initial wallet. + let wallet: Result = + Wallet::create(INTERNAL_DESCRIPTOR, EXTERNAL_DESCRIPTOR) + .network(NETWORK) + .create_wallet_no_persist(); + + // asserts that the wallet creation did not fail. + let mut wallet = match wallet { + Ok(wallet) => wallet, + Err(_) => return, + }; + + // fuzzed code goes here. + let mut new_data = data; + + // generated fuzzed keychain indices. + let internal_indices = consume_keychain_indices(&mut new_data, KeychainKind::Internal); + let external_indices = consume_keychain_indices(&mut new_data, KeychainKind::External); + + let mut last_active_indices: BTreeMap = BTreeMap::new(); + last_active_indices.extend(internal_indices); + last_active_indices.extend(external_indices); + + // generate fuzzed tx update. + let txs = consume_txs(data, &mut wallet); + + let unconfirmed_txids: VecDeque = txs.iter().map(|tx| tx.compute_txid()).collect(); + + let txouts = consume_txouts(data); + let anchors = consume_anchors(data, unconfirmed_txids.clone()); + let seen_ats = consume_seen_ats(data, unconfirmed_txids.clone()); + let evicted_ats = consume_evicted_ats(data, unconfirmed_txids.clone()); + + // build the tx update with fuzzed data + let mut tx_update = TxUpdate::default(); + tx_update.txs = txs; + tx_update.txouts = txouts; + tx_update.anchors = anchors; + tx_update.seen_ats = seen_ats; + tx_update.evicted_ats = evicted_ats; + + // generate fuzzed chain. + let chain = consume_checkpoint(data, &mut wallet); + + // apply fuzzed update. + let update = Update { + last_active_indices, + tx_update, + chain: Some(chain), + }; + + wallet.apply_update(update).unwrap(); +}); diff --git a/fuzz/src/fuzz_utils.rs b/fuzz/src/fuzz_utils.rs new file mode 100644 index 00000000..ea37ea36 --- /dev/null +++ b/fuzz/src/fuzz_utils.rs @@ -0,0 +1,238 @@ +use std::{ + cmp, + collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, + sync::Arc, +}; + +use bdk_wallet::{ + bitcoin::{ + self, absolute::LockTime, hashes::Hash, transaction::Version, Amount, BlockHash, OutPoint, + Transaction, TxIn, TxOut, Txid, + }, + chain::{BlockId, CheckPoint, ConfirmationBlockTime}, + KeychainKind, Wallet, +}; + +use crate::fuzzed_data_provider::{ + consume_bool, consume_bytes, consume_u32, consume_u64, consume_u8, +}; + +pub fn consume_block_hash(data: &mut &[u8]) -> BlockHash { + let bytes: [u8; 32] = consume_bytes(data, 32).try_into().unwrap_or([0; 32]); + + BlockHash::from_byte_array(bytes) +} + +pub fn consume_txid(data: &mut &[u8]) -> Txid { + let bytes: [u8; 32] = consume_bytes(data, 32).try_into().unwrap_or([0; 32]); + + Txid::from_byte_array(bytes) +} + +pub fn consume_keychain_indices( + data: &mut &[u8], + keychain: KeychainKind, +) -> BTreeMap { + let mut indices = BTreeMap::new(); + if consume_bool(data) { + let count = consume_u8(data) as u32; + let start = consume_u8(data) as u32; + indices.extend((start..count).map(|idx| (keychain, idx))) + } + indices +} + +// TODO: (@leonardo) improve this implementation to not rely on UniqueHash +pub fn consume_spk(data: &mut &[u8], wallet: &mut Wallet) -> bitcoin::ScriptBuf { + if data.is_empty() { + let bytes = consume_bytes(data, 32); + return bitcoin::ScriptBuf::from_bytes(bytes); + } + + let flags = data[0]; + *data = &data[1..]; + + match flags.trailing_zeros() { + 0 => wallet + .next_unused_address(KeychainKind::External) + .script_pubkey(), + 1 => wallet + .next_unused_address(KeychainKind::Internal) + .script_pubkey(), + _ => { + let bytes = consume_bytes(data, 32); + bitcoin::ScriptBuf::from_bytes(bytes) + } + } +} + +// TODO: (@leonardo) improve this implementation to not rely on UniqueHash +pub fn consume_txs(mut data: &[u8], wallet: &mut Wallet) -> Vec> { + // TODO: (@leonardo) should this be a usize ? + + let count = consume_u8(&mut data); + let mut txs = Vec::with_capacity(count as usize); + for _ in 0..count { + let version = consume_u32(&mut data); + // TODO: (@leonardo) should we use the Version::consensus_decode instead ? + let version = Version(version as i32); + + let locktime = consume_u32(&mut data); + let locktime = LockTime::from_consensus(locktime); + + let txin_count = consume_u8(&mut data); + let mut tx_inputs = Vec::with_capacity(txin_count as usize); + + for _ in 0..txin_count { + let prev_txid = consume_txid(&mut data); + let prev_vout = consume_u32(&mut data); + let prev_output = OutPoint::new(prev_txid, prev_vout); + let tx_input = TxIn { + previous_output: prev_output, + ..Default::default() + }; + tx_inputs.push(tx_input); + } + + let txout_count = consume_u8(&mut data); + let mut tx_outputs = Vec::with_capacity(txout_count as usize); + + for _ in 0..txout_count { + let spk = consume_spk(&mut data, wallet); + let sats = (consume_u8(&mut data) as u64) * 1_000; + let amount = Amount::from_sat(sats); + let tx_output = TxOut { + value: amount, + script_pubkey: spk, + }; + tx_outputs.push(tx_output); + } + + let tx = Transaction { + version, + lock_time: locktime, + input: tx_inputs, + output: tx_outputs, + }; + + txs.push(tx.into()); + } + txs +} + +pub fn consume_txouts(mut data: &[u8]) -> BTreeMap { + // TODO: (@leonardo) should this be a usize ? + let count = consume_u8(&mut data); + let mut txouts = BTreeMap::new(); + for _ in 0..count { + let prev_txid = consume_txid(&mut data); + let prev_vout = consume_u32(&mut data); + let prev_output = OutPoint::new(prev_txid, prev_vout); + + let sats = (consume_u8(&mut data) as u64) * 1_000; + let amount = Amount::from_sat(sats); + + // TODO: (@leonardo) should we use different spks ? + let txout = TxOut { + value: amount, + script_pubkey: Default::default(), + }; + + txouts.insert(prev_output, txout); + } + txouts +} + +pub fn consume_anchors( + mut data: &[u8], + mut unconfirmed_txids: VecDeque, +) -> BTreeSet<(ConfirmationBlockTime, Txid)> { + let mut anchors = BTreeSet::new(); + + let count = consume_u8(&mut data); + // FIXME: (@leonardo) should we use while limited by a flag instead ? (as per antoine's impls) + for _ in 0..count { + let block_height = consume_u32(&mut data); + let block_hash = consume_block_hash(&mut data); + + let block_id = BlockId { + height: block_height, + hash: block_hash, + }; + + let confirmation_time = consume_u64(&mut data); + + let anchor = ConfirmationBlockTime { + block_id, + confirmation_time, + }; + + if let Some(txid) = unconfirmed_txids.pop_front() { + anchors.insert((anchor, txid)); + } else { + break; + } + } + anchors +} + +pub fn consume_seen_ats( + mut data: &[u8], + mut unconfirmed_txids: VecDeque, +) -> HashSet<(Txid, u64)> { + let mut seen_ats = HashSet::new(); + + let count = consume_u8(&mut data); + // FIXME: (@leonardo) should we use while limited by a flag instead ? (as per antoine's impls) + for _ in 0..count { + let time = cmp::min(consume_u64(&mut data), i64::MAX as u64 - 1); + + if let Some(txid) = unconfirmed_txids.pop_front() { + seen_ats.insert((txid, time)); + } else { + let txid = consume_txid(&mut data); + seen_ats.insert((txid, time)); + } + } + seen_ats +} + +pub fn consume_evicted_ats( + mut data: &[u8], + mut unconfirmed_txids: VecDeque, +) -> HashSet<(Txid, u64)> { + let mut evicted_at = HashSet::new(); + + let count = consume_u8(&mut data); + // FIXME: (@leonardo) should we use while limited by a flag instead ? (as per antoine's impls) + for _ in 0..count { + let time = cmp::min(consume_u64(&mut data), i64::MAX as u64 - 1); + if let Some(txid) = unconfirmed_txids.pop_front() { + evicted_at.insert((txid, time)); + } else { + let txid = consume_txid(&mut data); + evicted_at.insert((txid, time)); + } + } + + evicted_at +} + +pub fn consume_checkpoint(mut data: &[u8], wallet: &mut Wallet) -> CheckPoint { + let mut tip = wallet.latest_checkpoint(); + + let _tip_hash = tip.hash(); + let tip_height = tip.height(); + + let count = consume_u8(&mut data); + // FIXME: (@leonardo) should we use while limited by a flag instead ? (as per antoine's impls) + for i in 1..count { + let height = tip_height + i as u32; + let hash = consume_block_hash(&mut data); + + let block_id = BlockId { height, hash }; + + tip = tip.push(block_id).unwrap(); + } + tip +} diff --git a/fuzz/src/fuzzed_data_provider.rs b/fuzz/src/fuzzed_data_provider.rs new file mode 100644 index 00000000..39acb89f --- /dev/null +++ b/fuzz/src/fuzzed_data_provider.rs @@ -0,0 +1,74 @@ +pub fn consume_bytes(data: &mut &[u8], num_bytes: usize) -> Vec { + let num_bytes = num_bytes.min(data.len()); + + let (bytes, remaining) = data.split_at(num_bytes); + *data = remaining; + + bytes.to_vec() +} + +pub fn consume_u64(data: &mut &[u8]) -> u64 { + // We need at least 8 bytes to read a u64 + if data.len() < 8 { + return 0; + } + + let (u64_bytes, rest) = data.split_at(8); + *data = rest; + + u64::from_le_bytes([ + u64_bytes[0], + u64_bytes[1], + u64_bytes[2], + u64_bytes[3], + u64_bytes[4], + u64_bytes[5], + u64_bytes[6], + u64_bytes[7], + ]) +} + +pub fn consume_u32(data: &mut &[u8]) -> u32 { + // We need at least 4 bytes to read a u32 + if data.len() < 4 { + return 0; + } + + let (u32_bytes, rest) = data.split_at(4); + *data = rest; + + u32::from_le_bytes([u32_bytes[0], u32_bytes[1], u32_bytes[2], u32_bytes[3]]) +} + +pub fn consume_u8(data: &mut &[u8]) -> u8 { + // We need at least 1 byte to read a u8 + if data.is_empty() { + return 0; + } + + let (u8_bytes, rest) = data.split_at(1); + *data = rest; + + u8::from_le_bytes([u8_bytes[0]]) +} + +pub fn consume_bool(data: &mut &[u8]) -> bool { + (1 & consume_u8(data)) != 0 +} + +pub fn consume_byte(data: &mut &[u8]) -> u8 { + let byte = data[0]; + *data = &data[1..]; + + byte +} + +#[allow(dead_code)] +fn scale_u32(byte: u8) -> u32 { + (byte as u32) * 0x01000000 +} + +#[allow(dead_code)] +fn scale_u64(byte: u8) -> u64 { + (byte as u64) * 0x0100000000000000 +} diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs new file mode 100644 index 00000000..d52377b3 --- /dev/null +++ b/fuzz/src/lib.rs @@ -0,0 +1,2 @@ +pub mod fuzz_utils; +pub mod fuzzed_data_provider; From 550925b40b5232cff6aa9da75aa91e1167f23491 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Wed, 23 Jul 2025 14:58:44 -0300 Subject: [PATCH 2/6] test(fuzzing): improve `bdk_wallet` fuzz target - renames the fuzz target to `bdk_wallet`. - add the `WalletAction` enum, in order to fuzz test different behaviors: wallet update, persistance/load, and tx creation. - use macros (e.g `try_consume_*`) in `fuzzed_data_provider` and `fuzz_utils` in order to properly handle an exhausted fuzzer byte stream, returning early. - update `Wallet::ApplyUpdate` target to use the newly added macros. --- fuzz/Cargo.toml | 4 +- fuzz/fuzz_targets/bdk_wallet.rs | 125 ++++++++++ fuzz/fuzz_targets/wallet_update.rs | 74 ------ fuzz/src/fuzz_utils.rs | 362 +++++++++++++---------------- fuzz/src/fuzzed_data_provider.rs | 68 +++++- 5 files changed, 348 insertions(+), 285 deletions(-) create mode 100644 fuzz/fuzz_targets/bdk_wallet.rs delete mode 100644 fuzz/fuzz_targets/wallet_update.rs diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index f6dbdb15..16879147 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -19,8 +19,8 @@ libfuzzer-sys = "0.4" bdk_wallet = { path = "../wallet" } [[bin]] -name = "wallet" -path = "fuzz_targets/wallet_update.rs" +name = "bdk_wallet" +path = "fuzz_targets/bdk_wallet.rs" test = false doc = false bench = false diff --git a/fuzz/fuzz_targets/bdk_wallet.rs b/fuzz/fuzz_targets/bdk_wallet.rs new file mode 100644 index 00000000..9e8c0c84 --- /dev/null +++ b/fuzz/fuzz_targets/bdk_wallet.rs @@ -0,0 +1,125 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::{ + cmp, + collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, +}; + +use bdk_wallet::{ + bitcoin::{Network, Txid}, + chain::{BlockId, ConfirmationBlockTime, TxUpdate}, + descriptor::DescriptorError, + KeychainKind, Update, Wallet, +}; + +use bdk_wallet::bitcoin::{ + absolute::LockTime, transaction::Version, Amount, OutPoint, Transaction, TxIn, TxOut, +}; + +use bdk_wallet_fuzz::{ + fuzz_utils::*, try_consume_anchors, try_consume_bool, try_consume_byte, try_consume_checkpoint, + try_consume_seen_or_evicted_ats, try_consume_txouts, try_consume_txs, try_consume_u32, + try_consume_u64, try_consume_u8, +}; + +// descriptors +const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; +const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + +// network +const NETWORK: Network = Network::Testnet; + +pub enum WalletAction { + ApplyUpdate, + CreateTx, + PersistAndLoad, +} + +impl WalletAction { + fn from_byte(byte: &u8) -> Option { + if *byte == 0x00 { + Some(WalletAction::ApplyUpdate) + } else if *byte == 0x01 { + Some(WalletAction::CreateTx) + } else if *byte == 0x02 { + Some(WalletAction::PersistAndLoad) + } else { + None + } + } +} + +fuzz_target!(|data: &[u8]| { + // creates initial wallet. + let wallet: Result = + Wallet::create(INTERNAL_DESCRIPTOR, EXTERNAL_DESCRIPTOR) + .network(NETWORK) + .create_wallet_no_persist(); + + // asserts that the wallet creation did not fail. + let mut wallet = match wallet { + Ok(wallet) => wallet, + Err(_) => return, + }; + + // fuzzed code goes here. + let mut new_data = data; + let mut data_iter = new_data.iter(); + while let Some(wallet_action) = WalletAction::from_byte(try_consume_byte!(data_iter)) { + match wallet_action { + WalletAction::ApplyUpdate => { + // generated fuzzed keychain indices. + let mut last_active_indices: BTreeMap = BTreeMap::new(); + for keychain in [KeychainKind::Internal, KeychainKind::External] { + if try_consume_bool!(data_iter) { + let count = try_consume_u8!(data_iter) as u32; + let start = try_consume_u8!(data_iter) as u32; + last_active_indices.extend((start..count).map(|idx| (keychain, idx))) + } + } + + // generate fuzzed tx update. + let txs: Vec> = + try_consume_txs!(&mut new_data, &mut wallet); + + let mut unconfirmed_txids: VecDeque = + txs.iter().map(|tx| tx.compute_txid()).collect(); + + let txouts = try_consume_txouts!(&mut new_data); + let anchors = try_consume_anchors!(&mut new_data, unconfirmed_txids); + let seen_ats = try_consume_seen_or_evicted_ats!(&mut new_data, unconfirmed_txids); + let evicted_ats = + try_consume_seen_or_evicted_ats!(&mut new_data, unconfirmed_txids); + + // build the tx update with fuzzed data + let mut tx_update = TxUpdate::default(); + tx_update.txs = txs; + tx_update.txouts = txouts; + tx_update.anchors = anchors; + tx_update.seen_ats = seen_ats; + tx_update.evicted_ats = evicted_ats; + + // generate fuzzed chain. + let chain = try_consume_checkpoint!(&mut new_data, wallet); + + // apply fuzzed update. + let update = Update { + last_active_indices, + tx_update, + chain: Some(chain), + }; + + wallet.apply_update(update).unwrap(); + } + WalletAction::CreateTx => { + // todo!() + continue; + } + WalletAction::PersistAndLoad => { + // todo!() + continue; + } + } + } +}); diff --git a/fuzz/fuzz_targets/wallet_update.rs b/fuzz/fuzz_targets/wallet_update.rs deleted file mode 100644 index 7ba65a1a..00000000 --- a/fuzz/fuzz_targets/wallet_update.rs +++ /dev/null @@ -1,74 +0,0 @@ -#![no_main] - -use libfuzzer_sys::fuzz_target; -use std::collections::{BTreeMap, VecDeque}; - -use bdk_wallet::{ - bitcoin::{Network, Txid}, - chain::TxUpdate, - descriptor::DescriptorError, - KeychainKind, Update, Wallet, -}; -use bdk_wallet_fuzz::fuzz_utils::*; - -// descriptors -const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; -const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; - -// network -const NETWORK: Network = Network::Testnet; - -fuzz_target!(|data: &[u8]| { - // creates initial wallet. - let wallet: Result = - Wallet::create(INTERNAL_DESCRIPTOR, EXTERNAL_DESCRIPTOR) - .network(NETWORK) - .create_wallet_no_persist(); - - // asserts that the wallet creation did not fail. - let mut wallet = match wallet { - Ok(wallet) => wallet, - Err(_) => return, - }; - - // fuzzed code goes here. - let mut new_data = data; - - // generated fuzzed keychain indices. - let internal_indices = consume_keychain_indices(&mut new_data, KeychainKind::Internal); - let external_indices = consume_keychain_indices(&mut new_data, KeychainKind::External); - - let mut last_active_indices: BTreeMap = BTreeMap::new(); - last_active_indices.extend(internal_indices); - last_active_indices.extend(external_indices); - - // generate fuzzed tx update. - let txs = consume_txs(data, &mut wallet); - - let unconfirmed_txids: VecDeque = txs.iter().map(|tx| tx.compute_txid()).collect(); - - let txouts = consume_txouts(data); - let anchors = consume_anchors(data, unconfirmed_txids.clone()); - let seen_ats = consume_seen_ats(data, unconfirmed_txids.clone()); - let evicted_ats = consume_evicted_ats(data, unconfirmed_txids.clone()); - - // build the tx update with fuzzed data - let mut tx_update = TxUpdate::default(); - tx_update.txs = txs; - tx_update.txouts = txouts; - tx_update.anchors = anchors; - tx_update.seen_ats = seen_ats; - tx_update.evicted_ats = evicted_ats; - - // generate fuzzed chain. - let chain = consume_checkpoint(data, &mut wallet); - - // apply fuzzed update. - let update = Update { - last_active_indices, - tx_update, - chain: Some(chain), - }; - - wallet.apply_update(update).unwrap(); -}); diff --git a/fuzz/src/fuzz_utils.rs b/fuzz/src/fuzz_utils.rs index ea37ea36..a5834306 100644 --- a/fuzz/src/fuzz_utils.rs +++ b/fuzz/src/fuzz_utils.rs @@ -1,26 +1,169 @@ -use std::{ - cmp, - collections::{BTreeMap, BTreeSet, HashSet, VecDeque}, - sync::Arc, -}; - use bdk_wallet::{ - bitcoin::{ - self, absolute::LockTime, hashes::Hash, transaction::Version, Amount, BlockHash, OutPoint, - Transaction, TxIn, TxOut, Txid, - }, - chain::{BlockId, CheckPoint, ConfirmationBlockTime}, + bitcoin::{self, hashes::Hash, BlockHash, Txid}, KeychainKind, Wallet, }; -use crate::fuzzed_data_provider::{ - consume_bool, consume_bytes, consume_u32, consume_u64, consume_u8, -}; +use crate::fuzzed_data_provider::consume_bytes; + +#[macro_export] +macro_rules! try_consume_txs { + ($data:expr, $wallet:expr) => {{ + let mut data_iter = $data.into_iter(); + + let txs_count = try_consume_u8!(data_iter) as usize; + let mut txs = Vec::with_capacity(txs_count); + + for _ in 0..txs_count { + let version = try_consume_u32!(data_iter); + let version = Version(version as i32); + + let lock_time = try_consume_u32!(data_iter); + let lock_time = LockTime::from_consensus(lock_time); + + let txin_count = try_consume_u8!(data_iter) as usize; + let mut input = Vec::with_capacity(txin_count); + + for _ in 0..txin_count { + let prev_txid = consume_txid($data); + let prev_vout = try_consume_u32!(data_iter); + let prev_output = OutPoint::new(prev_txid, prev_vout); + let tx_input = TxIn { + previous_output: prev_output, + ..Default::default() + }; + input.push(tx_input); + } + + let txout_count = try_consume_u8!(data_iter) as usize; + let mut output = Vec::with_capacity(txout_count); + + for _ in 0..txout_count { + let spk = consume_spk($data, $wallet); + let sats = (try_consume_u8!(data_iter) as u64) * 1_000; + let amount = Amount::from_sat(sats); + let tx_output = TxOut { + value: amount, + script_pubkey: spk, + }; + output.push(tx_output); + } + + let tx = Transaction { + version, + lock_time, + input, + output, + }; -pub fn consume_block_hash(data: &mut &[u8]) -> BlockHash { - let bytes: [u8; 32] = consume_bytes(data, 32).try_into().unwrap_or([0; 32]); + txs.push(tx.into()); + } + txs + }}; +} - BlockHash::from_byte_array(bytes) +#[macro_export] +macro_rules! try_consume_txouts { + ($data:expr) => {{ + let mut data_iter = $data.into_iter(); + let mut txouts = BTreeMap::new(); + + let txouts_count = try_consume_u8!(data_iter); + for _ in 0..txouts_count { + let prev_txid = consume_txid($data); + let prev_vout = try_consume_u32!(data_iter); + let prev_output = OutPoint::new(prev_txid, prev_vout); + + let sats = (try_consume_u8!(data_iter) as u64) * 1_000; + let amount = Amount::from_sat(sats); + + // TODO: (@leonardo) should it use fuzzed spks ? + let txout = TxOut { + value: amount, + script_pubkey: Default::default(), + }; + + txouts.insert(prev_output, txout); + } + txouts + }}; +} + +#[macro_export] +macro_rules! try_consume_anchors { + ($data:expr, $unconfirmed_txids:expr) => {{ + let mut data_iter = $data.into_iter(); + let mut anchors = BTreeSet::new(); + + let count = try_consume_u8!(data_iter); + for _ in 0..count { + let block_height = try_consume_u32!(data_iter); + let block_hash = consume_block_hash($data); + + let block_id = BlockId { + height: block_height, + hash: block_hash, + }; + + let confirmation_time = try_consume_u64!(data_iter); + + let anchor = ConfirmationBlockTime { + block_id, + confirmation_time, + }; + + if let Some(txid) = $unconfirmed_txids.pop_front() { + anchors.insert((anchor, txid)); + } else { + break; + } + } + anchors + }}; +} + +#[macro_export] +macro_rules! try_consume_seen_or_evicted_ats { + ($data:expr, $unconfirmed_txids:expr) => {{ + let mut data_iter = $data.into_iter(); + let mut seen_or_evicted_ats = HashSet::new(); + + let count = try_consume_u8!(data_iter); + for _ in 0..count { + let time = cmp::min(try_consume_u64!(data_iter), i64::MAX as u64 - 1); + + if let Some(txid) = $unconfirmed_txids.pop_front() { + seen_or_evicted_ats.insert((txid, time)); + } else { + let txid = consume_txid($data); + seen_or_evicted_ats.insert((txid, time)); + } + } + seen_or_evicted_ats + }}; +} + +#[macro_export] +macro_rules! try_consume_checkpoint { + ($data:expr, $wallet:expr) => {{ + let mut data_iter = $data.into_iter(); + + let mut tip = $wallet.latest_checkpoint(); + let _tip_hash = tip.hash(); + let tip_height = tip.height(); + + let count = try_consume_u8!(data_iter); + // FIXME: (@leonardo) should we use while limited by a flag instead ? (as per antoine's + // impls) + for i in 1..count { + let height = tip_height + i as u32; + let hash = consume_block_hash($data); + + let block_id = BlockId { height, hash }; + + tip = tip.push(block_id).unwrap(); + } + tip + }}; } pub fn consume_txid(data: &mut &[u8]) -> Txid { @@ -29,20 +172,6 @@ pub fn consume_txid(data: &mut &[u8]) -> Txid { Txid::from_byte_array(bytes) } -pub fn consume_keychain_indices( - data: &mut &[u8], - keychain: KeychainKind, -) -> BTreeMap { - let mut indices = BTreeMap::new(); - if consume_bool(data) { - let count = consume_u8(data) as u32; - let start = consume_u8(data) as u32; - indices.extend((start..count).map(|idx| (keychain, idx))) - } - indices -} - -// TODO: (@leonardo) improve this implementation to not rely on UniqueHash pub fn consume_spk(data: &mut &[u8], wallet: &mut Wallet) -> bitcoin::ScriptBuf { if data.is_empty() { let bytes = consume_bytes(data, 32); @@ -66,173 +195,8 @@ pub fn consume_spk(data: &mut &[u8], wallet: &mut Wallet) -> bitcoin::ScriptBuf } } -// TODO: (@leonardo) improve this implementation to not rely on UniqueHash -pub fn consume_txs(mut data: &[u8], wallet: &mut Wallet) -> Vec> { - // TODO: (@leonardo) should this be a usize ? - - let count = consume_u8(&mut data); - let mut txs = Vec::with_capacity(count as usize); - for _ in 0..count { - let version = consume_u32(&mut data); - // TODO: (@leonardo) should we use the Version::consensus_decode instead ? - let version = Version(version as i32); - - let locktime = consume_u32(&mut data); - let locktime = LockTime::from_consensus(locktime); - - let txin_count = consume_u8(&mut data); - let mut tx_inputs = Vec::with_capacity(txin_count as usize); - - for _ in 0..txin_count { - let prev_txid = consume_txid(&mut data); - let prev_vout = consume_u32(&mut data); - let prev_output = OutPoint::new(prev_txid, prev_vout); - let tx_input = TxIn { - previous_output: prev_output, - ..Default::default() - }; - tx_inputs.push(tx_input); - } - - let txout_count = consume_u8(&mut data); - let mut tx_outputs = Vec::with_capacity(txout_count as usize); - - for _ in 0..txout_count { - let spk = consume_spk(&mut data, wallet); - let sats = (consume_u8(&mut data) as u64) * 1_000; - let amount = Amount::from_sat(sats); - let tx_output = TxOut { - value: amount, - script_pubkey: spk, - }; - tx_outputs.push(tx_output); - } - - let tx = Transaction { - version, - lock_time: locktime, - input: tx_inputs, - output: tx_outputs, - }; - - txs.push(tx.into()); - } - txs -} - -pub fn consume_txouts(mut data: &[u8]) -> BTreeMap { - // TODO: (@leonardo) should this be a usize ? - let count = consume_u8(&mut data); - let mut txouts = BTreeMap::new(); - for _ in 0..count { - let prev_txid = consume_txid(&mut data); - let prev_vout = consume_u32(&mut data); - let prev_output = OutPoint::new(prev_txid, prev_vout); - - let sats = (consume_u8(&mut data) as u64) * 1_000; - let amount = Amount::from_sat(sats); - - // TODO: (@leonardo) should we use different spks ? - let txout = TxOut { - value: amount, - script_pubkey: Default::default(), - }; - - txouts.insert(prev_output, txout); - } - txouts -} - -pub fn consume_anchors( - mut data: &[u8], - mut unconfirmed_txids: VecDeque, -) -> BTreeSet<(ConfirmationBlockTime, Txid)> { - let mut anchors = BTreeSet::new(); - - let count = consume_u8(&mut data); - // FIXME: (@leonardo) should we use while limited by a flag instead ? (as per antoine's impls) - for _ in 0..count { - let block_height = consume_u32(&mut data); - let block_hash = consume_block_hash(&mut data); - - let block_id = BlockId { - height: block_height, - hash: block_hash, - }; - - let confirmation_time = consume_u64(&mut data); - - let anchor = ConfirmationBlockTime { - block_id, - confirmation_time, - }; - - if let Some(txid) = unconfirmed_txids.pop_front() { - anchors.insert((anchor, txid)); - } else { - break; - } - } - anchors -} - -pub fn consume_seen_ats( - mut data: &[u8], - mut unconfirmed_txids: VecDeque, -) -> HashSet<(Txid, u64)> { - let mut seen_ats = HashSet::new(); - - let count = consume_u8(&mut data); - // FIXME: (@leonardo) should we use while limited by a flag instead ? (as per antoine's impls) - for _ in 0..count { - let time = cmp::min(consume_u64(&mut data), i64::MAX as u64 - 1); - - if let Some(txid) = unconfirmed_txids.pop_front() { - seen_ats.insert((txid, time)); - } else { - let txid = consume_txid(&mut data); - seen_ats.insert((txid, time)); - } - } - seen_ats -} - -pub fn consume_evicted_ats( - mut data: &[u8], - mut unconfirmed_txids: VecDeque, -) -> HashSet<(Txid, u64)> { - let mut evicted_at = HashSet::new(); - - let count = consume_u8(&mut data); - // FIXME: (@leonardo) should we use while limited by a flag instead ? (as per antoine's impls) - for _ in 0..count { - let time = cmp::min(consume_u64(&mut data), i64::MAX as u64 - 1); - if let Some(txid) = unconfirmed_txids.pop_front() { - evicted_at.insert((txid, time)); - } else { - let txid = consume_txid(&mut data); - evicted_at.insert((txid, time)); - } - } - - evicted_at -} - -pub fn consume_checkpoint(mut data: &[u8], wallet: &mut Wallet) -> CheckPoint { - let mut tip = wallet.latest_checkpoint(); - - let _tip_hash = tip.hash(); - let tip_height = tip.height(); - - let count = consume_u8(&mut data); - // FIXME: (@leonardo) should we use while limited by a flag instead ? (as per antoine's impls) - for i in 1..count { - let height = tip_height + i as u32; - let hash = consume_block_hash(&mut data); - - let block_id = BlockId { height, hash }; +pub fn consume_block_hash(data: &mut &[u8]) -> BlockHash { + let bytes: [u8; 32] = consume_bytes(data, 32).try_into().unwrap_or([0; 32]); - tip = tip.push(block_id).unwrap(); - } - tip + BlockHash::from_byte_array(bytes) } diff --git a/fuzz/src/fuzzed_data_provider.rs b/fuzz/src/fuzzed_data_provider.rs index 39acb89f..2eb5e257 100644 --- a/fuzz/src/fuzzed_data_provider.rs +++ b/fuzz/src/fuzzed_data_provider.rs @@ -1,3 +1,61 @@ +#[macro_export] +macro_rules! try_consume_byte { + ($data_iter:expr) => { + match $data_iter.next() { + Some(byte) => byte, + None => return, + } + }; +} + +#[macro_export] +macro_rules! try_consume_bool { + ($data_iter:expr) => { + match $data_iter.next() { + Some(byte) => *byte != 0, + None => return, + } + }; +} + +#[macro_export] +macro_rules! try_consume_u8 { + ($data_iter:expr) => { + match $data_iter.next() { + Some(byte) => *byte, + None => return, + } + }; +} + +#[macro_export] +macro_rules! try_consume_u32 { + ($data_iter:expr) => {{ + let mut bytes = [0u8; 4]; + for i in 0..4 { + match $data_iter.next() { + Some(byte) => bytes[i] = *byte, + None => return, + } + } + u32::from_le_bytes(bytes) + }}; +} + +#[macro_export] +macro_rules! try_consume_u64 { + ($data_iter:expr) => {{ + let mut bytes = [0u8; 8]; + for i in 0..8 { + match $data_iter.next() { + Some(byte) => bytes[i] = *byte, + None => return, + } + } + u64::from_le_bytes(bytes) + }}; +} + pub fn consume_bytes(data: &mut &[u8], num_bytes: usize) -> Vec { let num_bytes = num_bytes.min(data.len()); @@ -62,13 +120,3 @@ pub fn consume_byte(data: &mut &[u8]) -> u8 { byte } - -#[allow(dead_code)] -fn scale_u32(byte: u8) -> u32 { - (byte as u32) * 0x01000000 -} - -#[allow(dead_code)] -fn scale_u64(byte: u8) -> u64 { - (byte as u64) * 0x0100000000000000 -} From 187321f0e34d23a2535a6ec7f1d92aa32cdd52a2 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Wed, 23 Jul 2025 15:10:20 -0300 Subject: [PATCH 3/6] test(fuzzing): add persist/load scenario to fuzz target - update `bdk_wallet_fuzz` to use `rusqlite` feature. - update the created wallet in `bdk_wallet` fuzz target to use an in-memory sqlite database connection, initializing wallet with persistance. - add the `PersistAndLoad` scenario to `bdk_wallet` fuzz target. --- fuzz/Cargo.toml | 2 +- fuzz/fuzz_targets/bdk_wallet.rs | 53 ++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 16879147..23e57758 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -16,7 +16,7 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" -bdk_wallet = { path = "../wallet" } +bdk_wallet = { path = "../wallet", features = ["rusqlite"] } [[bin]] name = "bdk_wallet" diff --git a/fuzz/fuzz_targets/bdk_wallet.rs b/fuzz/fuzz_targets/bdk_wallet.rs index 9e8c0c84..ef0702b4 100644 --- a/fuzz/fuzz_targets/bdk_wallet.rs +++ b/fuzz/fuzz_targets/bdk_wallet.rs @@ -7,9 +7,9 @@ use std::{ }; use bdk_wallet::{ - bitcoin::{Network, Txid}, + bitcoin::{hashes::Hash as _, BlockHash, Network, Txid}, chain::{BlockId, ConfirmationBlockTime, TxUpdate}, - descriptor::DescriptorError, + rusqlite::Connection, KeychainKind, Update, Wallet, }; @@ -52,10 +52,11 @@ impl WalletAction { fuzz_target!(|data: &[u8]| { // creates initial wallet. - let wallet: Result = - Wallet::create(INTERNAL_DESCRIPTOR, EXTERNAL_DESCRIPTOR) - .network(NETWORK) - .create_wallet_no_persist(); + let mut db_conn = Connection::open_in_memory() + .expect("Should start an in-memory database connection successfully!"); + let wallet = Wallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + .network(NETWORK) + .create_wallet(&mut db_conn); // asserts that the wallet creation did not fail. let mut wallet = match wallet { @@ -117,8 +118,44 @@ fuzz_target!(|data: &[u8]| { continue; } WalletAction::PersistAndLoad => { - // todo!() - continue; + let expected_balance = wallet.balance(); + let expected_internal_index = wallet.next_derivation_index(KeychainKind::Internal); + let expected_external_index = wallet.next_derivation_index(KeychainKind::External); + let expected_tip = wallet.latest_checkpoint(); + let expected_genesis_hash = + BlockHash::from_byte_array(NETWORK.chain_hash().to_bytes()); + + // generate fuzzed persist + if let Err(e) = wallet.persist(&mut db_conn) { + assert!( + matches!(e, bdk_wallet::rusqlite::Error::ToSqlConversionFailure(..)), + "It should always persist successfully!" + ); + return; + }; + + // generate fuzzed load + wallet = Wallet::load() + .descriptor(KeychainKind::External, Some(EXTERNAL_DESCRIPTOR)) + .descriptor(KeychainKind::Internal, Some(INTERNAL_DESCRIPTOR)) + .check_network(NETWORK) + .check_genesis_hash(expected_genesis_hash) + .load_wallet(&mut db_conn) + .expect("It should always load from persistence successfully!") + .expect("It should load the wallet successfully!"); + + // verify the persisted data is accurate + assert_eq!(wallet.network(), NETWORK); + assert_eq!(wallet.balance(), expected_balance); + assert_eq!( + wallet.next_derivation_index(KeychainKind::Internal), + expected_internal_index + ); + assert_eq!( + wallet.next_derivation_index(KeychainKind::External), + expected_external_index + ); + assert_eq!(wallet.latest_checkpoint(), expected_tip); } } } From 8ea8cb61c94c06cb64cd35df444b02f225f5d27d Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Wed, 23 Jul 2025 16:36:08 -0300 Subject: [PATCH 4/6] test(fuzzing): add tx creation scenario to fuzz target - add the `CreateTx` scenario to `bdk_wallet` fuzz target. - add two new macros: `try_consume_tx_builder` and `try_consume_sign_options`, in order to build the specific structures and types required for tx creation, signing and applying to wallet. --- fuzz/fuzz_targets/bdk_wallet.rs | 52 ++++++++- fuzz/src/fuzz_utils.rs | 187 +++++++++++++++++++++++++++++++ fuzz/src/fuzzed_data_provider.rs | 56 --------- 3 files changed, 233 insertions(+), 62 deletions(-) diff --git a/fuzz/fuzz_targets/bdk_wallet.rs b/fuzz/fuzz_targets/bdk_wallet.rs index ef0702b4..787a41ba 100644 --- a/fuzz/fuzz_targets/bdk_wallet.rs +++ b/fuzz/fuzz_targets/bdk_wallet.rs @@ -7,10 +7,11 @@ use std::{ }; use bdk_wallet::{ - bitcoin::{hashes::Hash as _, BlockHash, Network, Txid}, + bitcoin::{self, hashes::Hash as _, BlockHash, Network, Txid}, chain::{BlockId, ConfirmationBlockTime, TxUpdate}, rusqlite::Connection, - KeychainKind, Update, Wallet, + signer::TapLeavesOptions, + KeychainKind, SignOptions, TxOrdering, Update, Wallet, }; use bdk_wallet::bitcoin::{ @@ -19,8 +20,8 @@ use bdk_wallet::bitcoin::{ use bdk_wallet_fuzz::{ fuzz_utils::*, try_consume_anchors, try_consume_bool, try_consume_byte, try_consume_checkpoint, - try_consume_seen_or_evicted_ats, try_consume_txouts, try_consume_txs, try_consume_u32, - try_consume_u64, try_consume_u8, + try_consume_seen_or_evicted_ats, try_consume_sign_options, try_consume_tx_builder, + try_consume_txouts, try_consume_txs, try_consume_u32, try_consume_u64, try_consume_u8, }; // descriptors @@ -114,8 +115,47 @@ fuzz_target!(|data: &[u8]| { wallet.apply_update(update).unwrap(); } WalletAction::CreateTx => { - // todo!() - continue; + // generate fuzzed tx builder + let tx_builder = try_consume_tx_builder!(&mut new_data, &mut wallet); + + // generate fuzzed psbt + let mut psbt = match tx_builder.finish() { + Ok(psbt) => psbt, + Err(_) => continue, + }; + + // generate fuzzed sign options + // let sign_options = consume_sign_options(new_data); + let sign_options = try_consume_sign_options!(data_iter); + + // generate fuzzed signed psbt + let _is_signed = match wallet.sign(&mut psbt, sign_options.clone()) { + Ok(is_signed) => is_signed, + Err(_) => continue, + }; + + // generated fuzzed finalized psbt + // extract and apply fuzzed tx + match wallet.finalize_psbt(&mut psbt, sign_options) { + Ok(is_finalized) => match is_finalized { + true => match psbt.extract_tx() { + Ok(tx) => { + let mut update = Update::default(); + update.tx_update.txs.push(tx.into()); + wallet.apply_update(update).unwrap() + } + Err(e) => { + assert!(matches!( + e, + bitcoin::psbt::ExtractTxError::AbsurdFeeRate { .. } + )); + return; + } + }, + false => continue, + }, + Err(_) => continue, + } } WalletAction::PersistAndLoad => { let expected_balance = wallet.balance(); diff --git a/fuzz/src/fuzz_utils.rs b/fuzz/src/fuzz_utils.rs index a5834306..378ff4f8 100644 --- a/fuzz/src/fuzz_utils.rs +++ b/fuzz/src/fuzz_utils.rs @@ -166,6 +166,193 @@ macro_rules! try_consume_checkpoint { }}; } +#[macro_export] +macro_rules! try_consume_sign_options { + ($data_iter:expr) => {{ + let mut sign_options = SignOptions::default(); + + if try_consume_bool!($data_iter) { + sign_options.trust_witness_utxo = true; + } + + if try_consume_bool!($data_iter) { + let height = try_consume_u32!($data_iter); + sign_options.assume_height = Some(height); + } + + if try_consume_bool!($data_iter) { + sign_options.allow_all_sighashes = true; + } + + if try_consume_bool!($data_iter) { + sign_options.try_finalize = false; + } + + if try_consume_bool!($data_iter) { + // FIXME: how can we use the other include/exclude variants here ? + if try_consume_bool!($data_iter) { + sign_options.tap_leaves_options = TapLeavesOptions::All; + } else { + sign_options.tap_leaves_options = TapLeavesOptions::None; + } + } + + if try_consume_bool!($data_iter) { + sign_options.sign_with_tap_internal_key = false; + } + + if try_consume_bool!($data_iter) { + sign_options.allow_grinding = false; + } + + sign_options + }}; +} + +#[macro_export] +macro_rules! try_consume_tx_builder { + ($data:expr, $wallet:expr) => {{ + let mut data_iter = $data.into_iter(); + + let utxo = $wallet.list_unspent().next(); + + let recipients_count = *try_consume_byte!(data_iter) as usize; + let mut recipients = Vec::with_capacity(recipients_count); + for _ in 0..recipients_count { + let spk = consume_spk($data, $wallet); + let amount = *try_consume_byte!(data_iter) as u64 * 1_000; + let amount = bitcoin::Amount::from_sat(amount); + recipients.push((spk, amount)); + } + + let drain_to = consume_spk($data, $wallet); + + let mut tx_builder = match try_consume_bool!(data_iter) { + true => $wallet.build_tx(), + false => { + // FIXME: (@leonardo) get a randomized txid. + let txid = $wallet + .tx_graph() + .full_txs() + .next() + .map(|tx_node| tx_node.txid); + match txid { + Some(txid) => match $wallet.build_fee_bump(txid) { + Ok(builder) => builder, + Err(_) => continue, + }, + None => continue, + } + } + }; + + if try_consume_bool!(data_iter) { + let mut rate = *try_consume_byte!(data_iter) as u64; + if try_consume_bool!(data_iter) { + rate *= 1_000; + } + let rate = + bitcoin::FeeRate::from_sat_per_vb(rate).expect("It should be a valid fee rate."); + tx_builder.fee_rate(rate); + } + + if try_consume_bool!(data_iter) { + let mut fee = *try_consume_byte!(data_iter) as u64; + if try_consume_bool!(data_iter) { + fee *= 1_000; + } + let fee = bitcoin::Amount::from_sat(fee); + tx_builder.fee_absolute(fee); + } + + if try_consume_bool!(data_iter) { + if let Some(ref utxo) = utxo { + tx_builder + .add_utxo(utxo.outpoint) + .expect("It should be a known UTXO."); + } + } + + // FIXME: add the fuzzed option for `TxBuilder.add_foreign_utxo`. + + if try_consume_bool!(data_iter) { + tx_builder.manually_selected_only(); + } + + if try_consume_bool!(data_iter) { + if let Some(ref utxo) = utxo { + tx_builder.add_unspendable(utxo.outpoint); + } + } + + if try_consume_bool!(data_iter) { + let sighash = + bitcoin::psbt::PsbtSighashType::from_u32(*try_consume_byte!(data_iter) as u32); + tx_builder.sighash(sighash); + } + + if try_consume_bool!(data_iter) { + let ordering = if try_consume_bool!(data_iter) { + TxOrdering::Shuffle + } else { + TxOrdering::Untouched + }; + tx_builder.ordering(ordering); + } + + if try_consume_bool!(data_iter) { + let lock_time = try_consume_u32!(data_iter); + let lock_time = bitcoin::absolute::LockTime::from_consensus(lock_time); + tx_builder.nlocktime(lock_time); + } + + if try_consume_bool!(data_iter) { + let version = try_consume_u32!(data_iter); + tx_builder.version(version as i32); + } + + if try_consume_bool!(data_iter) { + tx_builder.do_not_spend_change(); + } + + if try_consume_bool!(data_iter) { + tx_builder.only_spend_change(); + } + + if try_consume_bool!(data_iter) { + tx_builder.only_witness_utxo(); + } + + if try_consume_bool!(data_iter) { + tx_builder.include_output_redeem_witness_script(); + } + + if try_consume_bool!(data_iter) { + tx_builder.add_global_xpubs(); + } + + if try_consume_bool!(data_iter) { + tx_builder.drain_wallet(); + } + + if try_consume_bool!(data_iter) { + tx_builder.allow_dust(true); + } + + if try_consume_bool!(data_iter) { + tx_builder.set_recipients(recipients); + } + + // FIXME: add the fuzzed option for `TxBuilder.add_data()` method. + + if try_consume_bool!(data_iter) { + tx_builder.drain_to(drain_to); + } + + tx_builder + }}; +} + pub fn consume_txid(data: &mut &[u8]) -> Txid { let bytes: [u8; 32] = consume_bytes(data, 32).try_into().unwrap_or([0; 32]); diff --git a/fuzz/src/fuzzed_data_provider.rs b/fuzz/src/fuzzed_data_provider.rs index 2eb5e257..a705ce0a 100644 --- a/fuzz/src/fuzzed_data_provider.rs +++ b/fuzz/src/fuzzed_data_provider.rs @@ -64,59 +64,3 @@ pub fn consume_bytes(data: &mut &[u8], num_bytes: usize) -> Vec { bytes.to_vec() } - -pub fn consume_u64(data: &mut &[u8]) -> u64 { - // We need at least 8 bytes to read a u64 - if data.len() < 8 { - return 0; - } - - let (u64_bytes, rest) = data.split_at(8); - *data = rest; - - u64::from_le_bytes([ - u64_bytes[0], - u64_bytes[1], - u64_bytes[2], - u64_bytes[3], - u64_bytes[4], - u64_bytes[5], - u64_bytes[6], - u64_bytes[7], - ]) -} - -pub fn consume_u32(data: &mut &[u8]) -> u32 { - // We need at least 4 bytes to read a u32 - if data.len() < 4 { - return 0; - } - - let (u32_bytes, rest) = data.split_at(4); - *data = rest; - - u32::from_le_bytes([u32_bytes[0], u32_bytes[1], u32_bytes[2], u32_bytes[3]]) -} - -pub fn consume_u8(data: &mut &[u8]) -> u8 { - // We need at least 1 byte to read a u8 - if data.is_empty() { - return 0; - } - - let (u8_bytes, rest) = data.split_at(1); - *data = rest; - - u8::from_le_bytes([u8_bytes[0]]) -} - -pub fn consume_bool(data: &mut &[u8]) -> bool { - (1 & consume_u8(data)) != 0 -} - -pub fn consume_byte(data: &mut &[u8]) -> u8 { - let byte = data[0]; - *data = &data[1..]; - - byte -} From c02f8437996fa84b78e18f5b01d3f1eada9047a9 Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Wed, 23 Jul 2025 20:22:02 -0300 Subject: [PATCH 5/6] chore(ci+justfile): add `bdk_wallet_fuzz` to excluded packages --- .github/workflows/cont_integration.yml | 4 ++-- justfile | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index ef34fa32..40941971 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -55,8 +55,8 @@ jobs: env: MATRIX_RUST_VERSION: ${{ matrix.rust.version }} run: | - cargo build --workspace --exclude 'example_*' ${{ matrix.features }} - cargo test --workspace --exclude 'example_*' ${{ matrix.features }} + cargo build --workspace --exclude 'example_*' --exclude 'bdk_wallet_fuzz' ${{ matrix.features }} + cargo test --workspace --exclude 'example_*' --exclude 'bdk_wallet_fuzz' ${{ matrix.features }} check-no-std: needs: prepare diff --git a/justfile b/justfile index e7501ea3..d0d26128 100644 --- a/justfile +++ b/justfile @@ -11,7 +11,7 @@ build: # Check code: formatting, compilation, linting, and commit signature check: cargo +nightly fmt --all -- --check - cargo check --workspace --exclude 'example_*' --all-features + cargo check --workspace --exclude 'example_*' --exclude 'bdk_wallet_fuzz' --all-features cargo clippy --all-features --all-targets -- -D warnings @[ "$(git log --pretty='format:%G?' -1 HEAD)" = "N" ] && \ echo "\n⚠️ Unsigned commit: BDK requires that commits be signed." || \ @@ -23,7 +23,7 @@ fmt: # Run all tests on the workspace with all features test: - cargo test --workspace --exclude 'example_*' --all-features + cargo test --workspace --exclude 'example_*' --exclude 'bdk_wallet_fuzz' --all-features # Run pre-push suite: format, check, and test pre-push: fmt check test \ No newline at end of file From 7f1fa74977f4b661eacfb10bcb127c4884a02e0c Mon Sep 17 00:00:00 2001 From: Leonardo Lima Date: Wed, 23 Jul 2025 20:46:18 -0300 Subject: [PATCH 6/6] ci(fuzz): add daily fuzz job - adds a new daily CI fuzz job, it runs every day at 5am UTC and uploads the artifacts on failures. - it currently only uses the `cargo fuzz`, as it's the only supported harness at the moment. --- .github/workflows/cron_daily_fuzz.yml | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/cron_daily_fuzz.yml diff --git a/.github/workflows/cron_daily_fuzz.yml b/.github/workflows/cron_daily_fuzz.yml new file mode 100644 index 00000000..131517f5 --- /dev/null +++ b/.github/workflows/cron_daily_fuzz.yml @@ -0,0 +1,71 @@ +on: + schedule: + - cron: "00 05 * * *" # At 05:00 (UTC) every day. + workflow_dispatch: # allows manual triggering + +permissions: {} + +name: Daily Fuzz + +jobs: + fuzz: + name: Cargo Fuzz + runs-on: ubuntu-latest + env: + # The version of `cargo-fuzz` to install and use. + CARGO_FUZZ_VERSION: 0.13.1 + + # The number of seconds to run the fuzz target. 1800 seconds = 30 minutes. + FUZZ_TIME: 1800 + + strategy: + fail-fast: false + matrix: + include: + - fuzz_target: bdk_wallet + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install the nightly Rust channel + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + override: true + profile: minimal + + - name: Check cache for cargo-fuzz + id: cache-cargo-fuzz + uses: actions/cache@v4 + with: + path: ${{ runner.tool_cache }}/cargo-fuzz + key: cargo-fuzz-bin-${{ env.CARGO_FUZZ_VERSION }} + + - name: Install cargo-fuzz + if: steps.cache-cargo-fuzz.outputs.cache-hit != 'true' + run: | + cargo install --root "${{ runner.tool_cache }}/cargo-fuzz" --version $CARGO_FUZZ_VERSION cargo-fuzz --locked + env: + CARGO_FUZZ_VERSION: ${{ env.CARGO_FUZZ_VERSION }} + + - name: Add cargo-fuzz to PATH + run: echo "${{ runner.tool_cache }}/cargo-fuzz/bin" >> $GITHUB_PATH + + - name: Build & Run Fuzz Target + run: | + cargo fuzz build ${{ matrix.fuzz_target }} + cargo fuzz run ${{ matrix.fuzz_target }} -- -max_total_time=$FUZZ_TIME + env: + FUZZ_TIME: ${{ env.FUZZ_TIME }} + + - name: Upload fuzzing artifacts on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: fuzzing-artifacts-${{ matrix.fuzz_target }}-${{ github.sha }} + path: fuzz/artifacts + +# TODO: add a verify-execution job similar to rust-bitcoin's one \ No newline at end of file