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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Fuzz

on:
pull_request:
types: [ opened, synchronize, reopened ]
branches:
- '**'
push:
branches: [ "master" ]
schedule:
- cron: '41 5 * * *'
workflow_dispatch:

# Third-party actions are pinned to full commit SHAs (tag comments alongside).
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# Longer runs on the nightly schedule; short smoke runs on PRs/pushes.
FUZZ_SECONDS: ${{ github.event_name == 'schedule' && '300' || '90' }}

jobs:
fuzz:
name: Fuzz (${{ matrix.target }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
target:
- changeset_roundtrip
- corrupt_rows
- anchor_identity
- descriptor_validation
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # nightly
with:
toolchain: nightly
components: rustfmt, clippy, rust-src
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: "fuzz -> fuzz/target"
- uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2
with:
tool: cargo-fuzz
- name: Check fuzz crate formatting
run: cargo +nightly fmt --manifest-path fuzz/Cargo.toml -- --check
- name: Lint fuzz crate
run: cargo +nightly clippy --manifest-path fuzz/Cargo.toml --all-targets -- -D warnings
- name: Build fuzz target
working-directory: fuzz
run: cargo +nightly fuzz build ${{ matrix.target }} --target x86_64-unknown-linux-gnu
- name: Run fuzz target
working-directory: fuzz
run: |
cargo +nightly fuzz run ${{ matrix.target }} --target x86_64-unknown-linux-gnu -- \
-max_total_time=${FUZZ_SECONDS} \
-max_len=4096 \
-rss_limit_mb=4096 \
-use_value_profile=1 \
-print_final_stats=1
- name: Upload failing corpus
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: fuzz-artifacts-${{ matrix.target }}
path: fuzz/artifacts
if-no-files-found: ignore
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ guarded by an always-on regression test in the suite (`src/test.rs` and

### Fixed

- Descriptor IDs are computed through a checked helper that rejects multipath
keys, hardened wildcards, and hardened derivation steps before deriving index
0. Stored or caller-supplied descriptors with those shapes now return
`BdkSqlxError::UnsupportedDescriptor` instead of panicking inside
`DescriptorExt::descriptor_id()`. Regression tests:
`unsupported_descriptors_error_without_panicking` and
`unsupported_stored_descriptor_errors_on_load`.
- Stored rows are decoded with `try_get` instead of `Row::get`, and SQLite
integer columns are read as `i64` before range checking. Corrupt or
foreign-schema values (including values above `i32::MAX` in `height`, `vout`,
`last_revealed`, or `block_height`, and wrong SQLite storage classes) now
return `BdkSqlxError` instead of panicking during a wallet load. Regression
test: `sqlite_oversized_and_mistyped_values_error_on_load`.
- Anchors now persist their full bdk_core identity: block height, block hash,
and confirmation time are part of the primary key, and the stored columns are
cross-checked against the JSON payload on load. Anchors that share a block and
transaction but differ in height or confirmation time no longer collapse, and
a stale changeset can no longer overwrite a newer anchor's confirmation time.
Regression test: `anchor_identity_includes_height_and_confirmation_time`.
- Anchors are now stored independently of local-chain blocks and transaction
rows. `anchor_tx` no longer has foreign keys to `block` or `tx`, so a valid
BDK changeset may persist an anchor before its block/transaction is known,
Expand Down
4 changes: 1 addition & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,14 @@ license = "MIT"
bdk_wallet = { version = "1.2.0" }
serde = { version = "1.0.208", features = ["derive"] }
serde_json = "1.0.125"
sqlx = { version = "0.8.1", default-features = false, features = ["runtime-tokio", "tls-rustls-ring","derive", "postgres", "sqlite", "json", "chrono", "uuid", "sqlx-macros", "migrate"] }
sqlx = { version = "0.8.1", default-features = false, features = ["runtime-tokio", "tls-rustls-ring","derive", "postgres", "sqlite", "json", "sqlx-macros", "migrate"] }
thiserror = "1"
tracing = "0.1.40"

[dev-dependencies]
assert_matches = "1.5.0"
anyhow = "1.0.89"
bdk_electrum = { version = "0.20.1"}
bdk_wallet = { version = "1.2.0", features = ["test-utils"] }
rustls = "0.23.14"
tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] }
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde_json", "json"] }

Expand Down
54 changes: 4 additions & 50 deletions examples/bdk_sqlx_postgres.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
#![allow(unused)]
use std::collections::HashSet;
use std::io::Write;

use bdk_electrum::{electrum_client, BdkElectrumClient};
use bdk_sqlx::sqlx::Postgres;
use bdk_sqlx::{PgStoreBuilder, Store};
use bdk_sqlx::PgStoreBuilder;
use bdk_wallet::bitcoin::secp256k1::Secp256k1;
use bdk_wallet::bitcoin::Network;
use bdk_wallet::{KeychainKind, PersistedWallet, Wallet};
use rustls::crypto::ring::default_provider;
use bdk_wallet::{KeychainKind, Wallet};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
Expand All @@ -25,16 +19,8 @@ const CHANGE_DESC: &str =
"wsh(andor(multi(2,[a0d3c79c/48'/1'/79'/2']tpubDEsGdqFaKUVnVNZZw8AixJ8C3yD8o6nN7hsdLfbtVRDTk3PNrQ2pcWNWNbxhdcNSgQP25pUpgRQ7qiVtN3YvSzACKizrvzSwH9SQ2Bjbbwt/1/*,[ea2484f9/48'/1'/79'/2']tpubDFjkswBXoRHKkvmHsxv4xdDqbjg1peX9zJytLeSLbXuwVgYhXgbABzC2r5MAWxqWoaUr7hWGW5TPjA9sNvxa3mX6DrNBdynDsEvwDoXGFpm/1/*,[93f245d7/48'/1'/79'/2']tpubDEVnR72gRgTsqaPFMacV6fCfaSEe56gcDomuGhk9MFeUdEi18riJCokgsZr2x1KKGRM59TJ4AQ6FuNun3khh95ceoH2ytN13nVD7yDLP5LJ/1/*),or_i(and_v(v:pkh([61cdf766/48'/1'/79'/2']tpubDEXETCw2WurhazfW5gW1z4njP6yLXDQmCGfjWGP5k3BuTQ5iZqovMr1zz1zWPhDMRn11hXGpZHodus1LysXnwREsD1ig96M24JhQCpPPpf6/1/*),after(1753228800)),thresh(2,pk([39bf48a9/48'/1'/0'/2']tpubDEr9rVFQbT1keErwxb6GuGy3RM6TEACSkFxBgziUvrDprYuM1Wm7wi6jb1gcaLrSgk6MSkGx84dS2kQQwJKxGRJ59rAvmuKTU7E3saHJLf5/1/*),s:pk([9467fdb3/48'/1'/0'/2']tpubDFEjX5BY88AbWpshPwGscwgKLtcCjeVodMbmhS6D6cbz1eGNUs3546ephbVmbHpxEhbCDrezGmFBArLxBKzPEfBcBdzQuncPm8ww2xa6UUQ/1/*),s:pk([01adf45e/48'/1'/0'/2']tpubDFPYZPeShApyWndvDUtpLSjDHGYK4tTT4BkMyTukGqbP9AXQeQhiWsbwEzyZhxgud9ZPew1FPsoLbWjfnE3veSXLeU4ViofrhVAHNXtjQWE/1/*),snl:after(1739836800))),and_v(v:thresh(2,pkh([39bf48a9/48'/1'/0'/2']tpubDEr9rVFQbT1keErwxb6GuGy3RM6TEACSkFxBgziUvrDprYuM1Wm7wi6jb1gcaLrSgk6MSkGx84dS2kQQwJKxGRJ59rAvmuKTU7E3saHJLf5/3/*),a:pkh([9467fdb3/48'/1'/0'/2']tpubDFEjX5BY88AbWpshPwGscwgKLtcCjeVodMbmhS6D6cbz1eGNUs3546ephbVmbHpxEhbCDrezGmFBArLxBKzPEfBcBdzQuncPm8ww2xa6UUQ/3/*),a:pkh([01adf45e/48'/1'/0'/2']tpubDFPYZPeShApyWndvDUtpLSjDHGYK4tTT4BkMyTukGqbP9AXQeQhiWsbwEzyZhxgud9ZPew1FPsoLbWjfnE3veSXLeU4ViofrhVAHNXtjQWE/3/*)),after(1757116800))))";

const NETWORK: Network = Network::Signet;
const ELECTRUM_URL: &str = "ssl://mempool.space:60602";
const STOP_GAP: usize = 50;
const BATCH_SIZE: usize = 5;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
default_provider()
.install_default()
.expect("Failed to install rustls default crypto provider");

tracing_subscriber::registry()
.with(EnvFilter::new(std::env::var("RUST_LOG").unwrap_or_else(
|_| {
Expand All @@ -48,7 +34,7 @@ async fn main() -> anyhow::Result<()> {

let url = std::env::var("DATABASE_URL").expect("must set DATABASE_URL");

// Load wallet 1 + sync with electrum
// Load wallet 1
let secp = Secp256k1::new();
let wallet_name = bdk_wallet::wallet_name_from_descriptor(
DESCRIPTOR,
Expand All @@ -63,7 +49,7 @@ async fn main() -> anyhow::Result<()> {
.build_with_url(&url)
.await?;

let mut wallet = match Wallet::load().load_wallet_async(&mut store).await? {
let wallet = match Wallet::load().load_wallet_async(&mut store).await? {
Some(wallet) => wallet,
None => {
let wallet = Wallet::create(DESCRIPTOR, CHANGE_DESCRIPTOR)
Expand All @@ -78,10 +64,6 @@ async fn main() -> anyhow::Result<()> {
}
};

print!("Syncing...");
electrum(&mut wallet)?;
let _ = wallet.persist_async(&mut store).await?;

println!("Balance {}", wallet.balance().total().display_dynamic());

// Load wallet 2
Expand Down Expand Up @@ -119,31 +101,3 @@ async fn main() -> anyhow::Result<()> {

Ok(())
}

fn electrum(wallet: &mut PersistedWallet<Store<Postgres>>) -> anyhow::Result<()> {
let client = BdkElectrumClient::new(electrum_client::Client::new(ELECTRUM_URL)?);

// Populate the electrum client's transaction cache so it doesn't re-download transaction we
// already have.
client.populate_tx_cache(wallet.tx_graph().full_txs().map(|tx_node| tx_node.tx));

let request = wallet.start_full_scan().inspect({
let mut stdout = std::io::stdout();
let mut once = HashSet::<KeychainKind>::new();
move |k, spk_i, _| {
if once.insert(k) {
print!("\nScanning keychain [{:?}]", k);
}
print!(" {:<3}", spk_i);
stdout.flush().expect("must flush");
}
});

let update = client.full_scan(request, STOP_GAP, BATCH_SIZE, true)?;

println!();

wallet.apply_update(update)?;

Ok(())
}
5 changes: 5 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target
artifacts
coverage
corpus/*/*
!corpus/*/seed
46 changes: 46 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
[package]
name = "bdk-sqlx-fuzz"
version = "0.0.0"
publish = false
edition = "2021"

[package.metadata]
cargo-fuzz = true

[dependencies]
bdk_sqlx = { path = ".." }
bdk_wallet = { version = "1.2.0" }
libfuzzer-sys = "0.4"
serde_json = "1"
tokio = { version = "1", features = ["rt"] }

[[bin]]
name = "changeset_roundtrip"
path = "fuzz_targets/changeset_roundtrip.rs"
test = false
doc = false

[[bin]]
name = "corrupt_rows"
path = "fuzz_targets/corrupt_rows.rs"
test = false
doc = false

[[bin]]
name = "anchor_identity"
path = "fuzz_targets/anchor_identity.rs"
test = false
doc = false

[[bin]]
name = "descriptor_validation"
path = "fuzz_targets/descriptor_validation.rs"
test = false
doc = false

[profile.release]
debug = 1
overflow-checks = true

[workspace]
members = ["."]
17 changes: 17 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Fuzz targets

These targets exercise the SQLite backend with arbitrary change sets and
corrupt database rows. They complement the deterministic regression tests in
`src/test.rs`.

```sh
cd fuzz
cargo +nightly fuzz run changeset_roundtrip
cargo +nightly fuzz run corrupt_rows
cargo +nightly fuzz run anchor_identity
cargo +nightly fuzz run descriptor_validation
```

The `fuzz` crate is intentionally outside the main package. CI runs a short
smoke pass of every target on pull requests and a longer pass on the weekly
schedule (see `.github/workflows/fuzz.yml`).
Binary file added fuzz/corpus/anchor_identity/seed
Binary file not shown.
Binary file added fuzz/corpus/changeset_roundtrip/seed
Binary file not shown.
Binary file added fuzz/corpus/corrupt_rows/seed
Binary file not shown.
1 change: 1 addition & 0 deletions fuzz/corpus/descriptor_validation/seed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
seedwpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/<0;1>/*)
58 changes: 58 additions & 0 deletions fuzz/fuzz_targets/anchor_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#![no_main]

use bdk_sqlx_fuzz::{load, new_store, persist, Reader};
use bdk_wallet::bitcoin::hashes::Hash;
use bdk_wallet::bitcoin::{BlockHash, Txid};
use bdk_wallet::chain::{BlockId, ConfirmationBlockTime};
use bdk_wallet::ChangeSet;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
let mut reader = Reader::new(data);
let wallet_name = reader.wallet_name();
let hash = BlockHash::from_byte_array(reader.array32());
let txid = Txid::from_byte_array(reader.array32());
let height = reader.u32() % 1_000_000;
let height_other = (height + 1) % 1_000_000;
let early = reader.u64() >> 1;
let late = early ^ 1;

let anchor = |height, confirmation_time| ConfirmationBlockTime {
block_id: BlockId { height, hash },
confirmation_time,
};
let same_block_early = anchor(height, early);
let same_block_late = anchor(height, late);
let other_height = anchor(height_other, early);

let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
runtime.block_on(async move {
let mut store = new_store(wallet_name).await;

let mut first = ChangeSet::default();
first.tx_graph.anchors.insert((same_block_late, txid));
persist(&mut store, &first)
.await
.expect("anchor without block or tx must persist");

// A stale observation of the same block, plus the same hash at another
// height, must each get their own row rather than replace the newer one.
let mut stale = ChangeSet::default();
stale.tx_graph.anchors.insert((same_block_early, txid));
stale.tx_graph.anchors.insert((other_height, txid));
persist(&mut store, &stale)
.await
.expect("distinct anchors must persist independently");

let loaded = load(&mut store).await.expect("load anchors");
assert_eq!(loaded.tx_graph.anchors.len(), 3);
assert!(loaded.tx_graph.anchors.contains(&(same_block_late, txid)));
assert!(loaded.tx_graph.anchors.contains(&(same_block_early, txid)));
assert!(loaded.tx_graph.anchors.contains(&(other_height, txid)));
assert!(loaded.tx_graph.txs.is_empty());
assert!(loaded.local_chain.blocks.is_empty());
});
});
46 changes: 46 additions & 0 deletions fuzz/fuzz_targets/changeset_roundtrip.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#![no_main]

use bdk_sqlx::BdkSqlxError;
use bdk_sqlx_fuzz::{load, new_store, persist, random_changeset, Reader};
use bdk_wallet::chain::Merge;
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
let mut reader = Reader::new(data);
let wallet_name = reader.wallet_name();
let changeset = random_changeset(&mut reader);

let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
runtime.block_on(async move {
let mut store = new_store(wallet_name).await;

match persist(&mut store, &changeset).await {
Ok(()) => {
let first = load(&mut store)
.await
.expect("loading after a successful persist must succeed");
persist(&mut store, &first)
.await
.expect("re-persisting a loaded snapshot must succeed");
let second = load(&mut store)
.await
.expect("loading after re-persisting must succeed");
assert_eq!(first, second, "persist/load must be idempotent");
}
// These are the only structurally invalid changesets the generator
// can produce: one hash at multiple heights, or a value outside the
// schema's representable range.
Err(BdkSqlxError::IntOutOfRange { .. })
| Err(BdkSqlxError::DuplicateBlockHash { .. }) => {
let loaded = load(&mut store)
.await
.expect("loading after a rejected changeset must succeed");
assert!(loaded.is_empty(), "a rejected changeset must roll back");
}
Err(error) => panic!("unexpected persist error: {error:?}"),
}
});
});
Loading
Loading