From 492cc8b484bdaeb6a0f67898eadca2a8a8851ffd Mon Sep 17 00:00:00 2001 From: russeree Date: Wed, 21 Oct 2026 11:45:32 -0700 Subject: [PATCH 1/7] fix: preserve full anchor identity and confirmation time bdk_core anchors are ordered over block height, block hash, and confirmation time. Key anchor_tx by the full identity and verify the stored columns against the JSON payload on load. Distinct anchors are no longer collapsed, and a stale changeset can no longer overwrite a newer confirmation time. --- CHANGELOG.md | 6 ++++ migrations/postgres/07_anchor_identity.sql | 36 ++++++++++++++++++++ migrations/sqlite/07_anchor_identity.sql | 29 +++++++++++++++++ src/lib.rs | 18 ++++++++++ src/postgres.rs | 38 +++++++++++++++++----- src/sqlite.rs | 38 +++++++++++++++++----- src/test.rs | 34 +++++++++++++++++++ 7 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 migrations/postgres/07_anchor_identity.sql create mode 100644 migrations/sqlite/07_anchor_identity.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 395be0f..c735bd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ guarded by an always-on regression test in the suite (`src/test.rs` and ### Fixed +- 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, diff --git a/migrations/postgres/07_anchor_identity.sql b/migrations/postgres/07_anchor_identity.sql new file mode 100644 index 0000000..dd6f034 --- /dev/null +++ b/migrations/postgres/07_anchor_identity.sql @@ -0,0 +1,36 @@ +-- bdk_core anchors are ordered and compared over the full +-- (block height, block hash, confirmation time) identity. The old +-- (wallet_name, block_hash, txid) key collapsed distinct anchors that shared a +-- block and transaction, and an unconditional payload update let a stale +-- changeset regress confirmation_time. Store the full identity in the key so +-- every distinct anchor keeps its own row and no anchor can overwrite another. +ALTER TABLE "bdk_wallet"."anchor_tx" ADD COLUMN block_height INTEGER; +ALTER TABLE "bdk_wallet"."anchor_tx" ADD COLUMN confirmation_time BIGINT; +UPDATE "bdk_wallet"."anchor_tx" +SET block_height = (anchor -> 'block_id' ->> 'height')::integer, + confirmation_time = (anchor ->> 'confirmation_time')::bigint; +ALTER TABLE "bdk_wallet"."anchor_tx" ALTER COLUMN block_height SET NOT NULL; +ALTER TABLE "bdk_wallet"."anchor_tx" ALTER COLUMN confirmation_time SET NOT NULL; + +DO $$ +DECLARE + con record; +BEGIN + FOR con IN + SELECT c.conname + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE n.nspname = 'bdk_wallet' + AND t.relname = 'anchor_tx' + AND c.contype = 'p' + LOOP + EXECUTE format( + 'ALTER TABLE "bdk_wallet"."anchor_tx" DROP CONSTRAINT %I', + con.conname + ); + END LOOP; +END $$; + +ALTER TABLE "bdk_wallet"."anchor_tx" + ADD PRIMARY KEY (wallet_name, txid, block_height, block_hash, confirmation_time); diff --git a/migrations/sqlite/07_anchor_identity.sql b/migrations/sqlite/07_anchor_identity.sql new file mode 100644 index 0000000..d65129b --- /dev/null +++ b/migrations/sqlite/07_anchor_identity.sql @@ -0,0 +1,29 @@ +-- bdk_core anchors are ordered and compared over the full +-- (block height, block hash, confirmation time) identity. The old +-- (wallet_name, block_hash, txid) key collapsed distinct anchors that shared a +-- block and transaction, and an unconditional payload update let a stale +-- changeset regress confirmation_time. Store the full identity in the key so +-- every distinct anchor keeps its own row and no anchor can overwrite another. +DROP TABLE IF EXISTS anchor_tx_new; +CREATE TABLE anchor_tx_new ( + wallet_name TEXT NOT NULL, + txid TEXT NOT NULL, + block_height INTEGER NOT NULL, + block_hash TEXT NOT NULL, + confirmation_time INTEGER NOT NULL, + anchor BLOB NOT NULL, + PRIMARY KEY (wallet_name, txid, block_height, block_hash, confirmation_time) +); +INSERT INTO anchor_tx_new + (wallet_name, txid, block_height, block_hash, confirmation_time, anchor) +SELECT + wallet_name, + txid, + json_extract(json(anchor), '$.block_id.height'), + block_hash, + json_extract(json(anchor), '$.confirmation_time'), + anchor +FROM anchor_tx; +DROP TABLE anchor_tx; +ALTER TABLE anchor_tx_new RENAME TO anchor_tx; +CREATE INDEX IF NOT EXISTS idx_anchor_tx_txid ON anchor_tx (txid); diff --git a/src/lib.rs b/src/lib.rs index 0b04bda..77b426d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,24 @@ pub enum BdkSqlxError { /// block hash contained in the anchor payload computed: BlockHash, }, + /// stored anchor references a different height than the stored block_height + #[error("anchor block height {computed} does not match stored block_height {stored}")] + AnchorBlockHeightMismatch { + /// height stored in the block_height column + stored: u32, + /// height contained in the anchor payload + computed: u32, + }, + /// stored anchor references a different confirmation time than the stored column + #[error( + "anchor confirmation time {computed} does not match stored confirmation_time {stored}" + )] + AnchorConfirmationTimeMismatch { + /// confirmation time stored in the confirmation_time column + stored: u64, + /// confirmation time contained in the anchor payload + computed: u64, + }, /// a changeset maps the same block hash to more than one height, which the /// block table cannot represent (its key is the hash itself) #[error( diff --git a/src/postgres.rs b/src/postgres.rs index 950314e..22e8f40 100644 --- a/src/postgres.rs +++ b/src/postgres.rs @@ -494,7 +494,7 @@ pub(crate) async fn tx_graph_changeset_from_postgres( // Fetch anchors let rows = sqlx::query( - r#"SELECT anchor, txid, block_hash FROM "bdk_wallet"."anchor_tx" WHERE wallet_name = $1"#, + r#"SELECT anchor, txid, block_height, block_hash, confirmation_time FROM "bdk_wallet"."anchor_tx" WHERE wallet_name = $1"#, ) .bind(wallet_name) .fetch_all(&mut **db_tx) @@ -508,15 +508,31 @@ pub(crate) async fn tx_graph_changeset_from_postgres( let anchor: serde_json::Value = row.get("anchor"); let txid: String = row.get("txid"); let txid = Txid::from_str(&txid)?; + let block_height: i32 = row.get("block_height"); let block_hash: String = row.get("block_hash"); let block_hash = BlockHash::from_str(&block_hash)?; + let confirmation_time: i64 = row.get("confirmation_time"); let anchor: ConfirmationBlockTime = serde_json::from_value(anchor)?; - let computed = anchor.anchor_block().hash; - if computed != block_hash { + let computed_hash = anchor.block_id.hash; + if computed_hash != block_hash { return Err(BdkSqlxError::AnchorBlockHashMismatch { stored: block_hash, - computed, + computed: computed_hash, + }); + } + let stored_height: u32 = crate::checked_conv(block_height, "anchor.block_height")?; + if anchor.block_id.height != stored_height { + return Err(BdkSqlxError::AnchorBlockHeightMismatch { + stored: stored_height, + computed: anchor.block_id.height, + }); + } + let stored_time: u64 = crate::checked_conv(confirmation_time, "anchor.confirmation_time")?; + if anchor.confirmation_time != stored_time { + return Err(BdkSqlxError::AnchorConfirmationTimeMismatch { + stored: stored_time, + computed: anchor.confirmation_time, }); } changeset.anchors.insert((anchor, txid)); @@ -593,16 +609,22 @@ pub(crate) async fn tx_graph_changeset_persist_to_postgres( } for (anchor, txid) in &changeset.anchors { - let block_hash = anchor.anchor_block().hash; + let block_id = anchor.anchor_block(); + let block_hash = block_id.hash; + let block_height = crate::checked_conv::<_, i32>(block_id.height, "anchor.block_height")?; + let confirmation_time = + crate::checked_conv::<_, i64>(anchor.confirmation_time, "anchor.confirmation_time")?; let anchor = serde_json::to_value(anchor)?; sqlx::query( - r#"INSERT INTO "bdk_wallet"."anchor_tx" (wallet_name, block_hash, anchor, txid) VALUES ($1, $2, $3, $4) - ON CONFLICT (wallet_name, block_hash, txid) DO UPDATE SET anchor = $3"#, + r#"INSERT INTO "bdk_wallet"."anchor_tx" (wallet_name, txid, block_height, block_hash, confirmation_time, anchor) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (wallet_name, txid, block_height, block_hash, confirmation_time) DO UPDATE SET anchor = $6"#, ) .bind(wallet_name) + .bind(txid.to_string()) + .bind(block_height) .bind(block_hash.to_string()) + .bind(confirmation_time) .bind(anchor) - .bind(txid.to_string()) .execute(&mut **db_tx) .await .map_err(|e| BdkSqlxError::QueryError { diff --git a/src/sqlite.rs b/src/sqlite.rs index ab426ce..69b4f14 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -527,7 +527,7 @@ pub(crate) async fn tx_graph_changeset_from_sqlite( // Fetch anchors let rows = sqlx::query( - "SELECT json(anchor) as anchor, txid, block_hash FROM anchor_tx WHERE wallet_name = $1", + "SELECT json(anchor) as anchor, txid, block_height, block_hash, confirmation_time FROM anchor_tx WHERE wallet_name = $1", ) .bind(wallet_name) .fetch_all(&mut **db_tx) @@ -541,15 +541,31 @@ pub(crate) async fn tx_graph_changeset_from_sqlite( let anchor: serde_json::Value = row.get("anchor"); let txid: String = row.get("txid"); let txid = Txid::from_str(&txid)?; + let block_height: i32 = row.get("block_height"); let block_hash: String = row.get("block_hash"); let block_hash = BlockHash::from_str(&block_hash)?; + let confirmation_time: i64 = row.get("confirmation_time"); let anchor: ConfirmationBlockTime = serde_json::from_value(anchor)?; - let computed = anchor.anchor_block().hash; - if computed != block_hash { + let computed_hash = anchor.block_id.hash; + if computed_hash != block_hash { return Err(BdkSqlxError::AnchorBlockHashMismatch { stored: block_hash, - computed, + computed: computed_hash, + }); + } + let stored_height: u32 = crate::checked_conv(block_height, "anchor.block_height")?; + if anchor.block_id.height != stored_height { + return Err(BdkSqlxError::AnchorBlockHeightMismatch { + stored: stored_height, + computed: anchor.block_id.height, + }); + } + let stored_time: u64 = crate::checked_conv(confirmation_time, "anchor.confirmation_time")?; + if anchor.confirmation_time != stored_time { + return Err(BdkSqlxError::AnchorConfirmationTimeMismatch { + stored: stored_time, + computed: anchor.confirmation_time, }); } changeset.anchors.insert((anchor, txid)); @@ -629,16 +645,22 @@ pub(crate) async fn tx_graph_changeset_persist_to_sqlite( } for (anchor, txid) in &changeset.anchors { - let block_hash = anchor.anchor_block().hash; + let block_id = anchor.anchor_block(); + let block_hash = block_id.hash; + let block_height = crate::checked_conv::<_, i32>(block_id.height, "anchor.block_height")?; + let confirmation_time = + crate::checked_conv::<_, i64>(anchor.confirmation_time, "anchor.confirmation_time")?; let anchor = serde_json::to_value(anchor)?; sqlx::query( - "INSERT INTO anchor_tx (wallet_name, block_hash, anchor, txid) VALUES ($1, $2, jsonb($3), $4) - ON CONFLICT (wallet_name, block_hash, txid) DO UPDATE SET anchor = jsonb($3)", + "INSERT INTO anchor_tx (wallet_name, txid, block_height, block_hash, confirmation_time, anchor) VALUES ($1, $2, $3, $4, $5, jsonb($6)) + ON CONFLICT (wallet_name, txid, block_height, block_hash, confirmation_time) DO UPDATE SET anchor = jsonb($6)", ) .bind(wallet_name) + .bind(txid.to_string()) + .bind(block_height) .bind(block_hash.to_string()) + .bind(confirmation_time) .bind(anchor) - .bind(txid.to_string()) .execute(&mut **db_tx) .await .map_err(|e| BdkSqlxError::QueryError { diff --git a/src/test.rs b/src/test.rs index 6183b3c..b41796c 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1724,6 +1724,40 @@ async fn reanchor_after_reorg_roundtrip() -> anyhow::Result<()> { Ok(()) } +/// Anchors are distinct when their height or confirmation time differs, even +/// when they share a block hash and transaction. A stale anchor must add its own +/// row rather than overwrite a newer anchor's confirmation time. +#[tokio::test] +async fn anchor_identity_includes_height_and_confirmation_time() -> anyhow::Result<()> { + initialize(); + + let wallet_name = "anchor_identity_includes_height_and_confirmation_time".to_string(); + for mut store in create_test_stores(wallet_name).await? { + let txid = sample_tx(0, 1_000).compute_txid(); + let same_block_early = anchor_at(10, block_hash(1), 100); + let same_block_late = anchor_at(10, block_hash(1), 200); + let other_height = anchor_at(20, block_hash(1), 100); + + let mut cs = ChangeSet::default(); + cs.tx_graph.anchors.insert((same_block_late, txid)); + TestStore::persist(&mut store, &cs).await?; + + // A stale observation of the same block with an earlier confirmation + // time must not replace the newer anchor. + let mut stale = ChangeSet::default(); + stale.tx_graph.anchors.insert((same_block_early, txid)); + stale.tx_graph.anchors.insert((other_height, txid)); + TestStore::persist(&mut store, &stale).await?; + + let loaded = TestStore::initialize(&mut store).await?; + assert_eq!(loaded.tx_graph.anchors.len(), 3); + assert!(loaded.tx_graph.anchors.contains(&(same_block_early, txid))); + assert!(loaded.tx_graph.anchors.contains(&(same_block_late, txid))); + assert!(loaded.tx_graph.anchors.contains(&(other_height, txid))); + } + Ok(()) +} + /// Advancing the derivation index across multiple persist/load cycles. #[tokio::test] async fn derivation_index_advances_across_loads() -> anyhow::Result<()> { From 7134ca128153d1ab92779358598325aeb8992852 Mon Sep 17 00:00:00 2001 From: russeree Date: Wed, 21 Oct 2026 11:48:08 -0700 Subject: [PATCH 2/7] fix: return typed errors instead of panicking on decoded rows Replace Row::get (which unwraps decode failures) with try_get and map failures to BdkSqlxError::QueryError on both backends. Read SQLite integer columns as i64 and range-check them, so oversized height/vout/last_revealed/block_height values and wrong storage classes fail the load instead of panicking. --- CHANGELOG.md | 6 ++++ src/lib.rs | 14 ++++++-- src/postgres.rs | 56 +++++++++++++++++++----------- src/sqlite.rs | 62 ++++++++++++++++++++++----------- src/test.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c735bd6..58ad35f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ guarded by an always-on regression test in the suite (`src/test.rs` and ### Fixed +- 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 diff --git a/src/lib.rs b/src/lib.rs index 77b426d..38f4671 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -202,6 +202,16 @@ where }) } +/// Converts a database `i64` to the `u32` domain this schema can represent. +/// +/// The `u32` columns are declared as signed 32-bit `INTEGER`/`i32` so both +/// backends agree on their range; a value outside that range is rejected like +/// any other out-of-range database value rather than truncated. +pub(crate) fn checked_u32(value: i64, context: &'static str) -> Result { + let value: i32 = checked_conv(value, context)?; + checked_conv(value, context) +} + /// Process-global network configuration, shared by every store in the process /// regardless of backend. Persisting data for the wrong network wedges a /// wallet store, so the first store built fixes the network for the whole @@ -276,7 +286,7 @@ pub(crate) fn keychain_changeset_from_parts( changeset: &mut ChangeSet, keychainkind: &str, descriptor_str: &str, - last_revealed: Option, + last_revealed: Option, ) -> Result<(), BdkSqlxError> { let descriptor: Descriptor = descriptor_str.parse()?; let did = descriptor.descriptor_id(); @@ -293,7 +303,7 @@ pub(crate) fn keychain_changeset_from_parts( changeset .indexer .last_revealed - .insert(did, checked_conv(last_rev, "keychain.last_revealed")?); + .insert(did, checked_u32(last_rev, "keychain.last_revealed")?); } Ok(()) } diff --git a/src/postgres.rs b/src/postgres.rs index 22e8f40..3106010 100644 --- a/src/postgres.rs +++ b/src/postgres.rs @@ -18,7 +18,7 @@ use bdk_wallet::{ KeychainKind::{External, Internal}, }; use sqlx::{ - postgres::{PgPool, Postgres}, + postgres::{PgPool, PgRow, Postgres}, sqlx_macros::migrate, Pool, Row, Transaction, }; @@ -29,6 +29,22 @@ use super::{BdkSqlxError, FutureResult, PgStoreBuilder, Store}; type Result = core::result::Result; +/// Decode a column without panicking. +/// +/// `Row::get` unwraps decode failures, which turns a corrupt or foreign-schema +/// row into a process panic. Every read goes through this helper so NULL/type +/// mismatches become typed errors instead. +fn read_col<'r, T>(row: &'r PgRow, column: &str, table: &str) -> Result +where + T: sqlx::Decode<'r, Postgres> + sqlx::Type, +{ + row.try_get(column) + .map_err(|source| BdkSqlxError::QueryError { + table: table.to_string(), + source, + }) +} + impl AsyncWalletPersister for Store { type Error = BdkSqlxError; @@ -222,7 +238,7 @@ impl Store { source: e, })?; if let Some(row) = row { - let network: String = row.get("name"); + let network: String = read_col(&row, "name", "read network")?; changeset.network = Some(crate::parse_and_validate_network(&network)?); } @@ -241,14 +257,14 @@ impl Store { source: e, })?; for row in rows { - let keychainkind: String = row.get("keychainkind"); - let descriptor: String = row.get("descriptor"); - let last_revealed: Option = row.get("last_revealed"); + let keychainkind: String = read_col(&row, "keychainkind", "read keychain")?; + let descriptor: String = read_col(&row, "descriptor", "read keychain")?; + let last_revealed: Option = read_col(&row, "last_revealed", "read keychain")?; crate::keychain_changeset_from_parts( &mut changeset, &keychainkind, &descriptor, - last_revealed, + last_revealed.map(i64::from), )?; } @@ -438,10 +454,10 @@ pub(crate) async fn tx_graph_changeset_from_postgres( })?; for row in rows { - let txid: String = row.get("txid"); + let txid: String = read_col(&row, "txid", "select tx")?; let txid = Txid::from_str(&txid)?; - let whole_tx: Option> = row.get("whole_tx"); - let last_seen: Option = row.get("last_seen"); + let whole_tx: Option> = read_col(&row, "whole_tx", "select tx")?; + let last_seen: Option = read_col(&row, "last_seen", "select tx")?; if let Some(tx_bytes) = whole_tx { let tx: bitcoin::Transaction = consensus::deserialize(&tx_bytes)?; @@ -474,11 +490,11 @@ pub(crate) async fn tx_graph_changeset_from_postgres( })?; for row in rows { - let txid: String = row.get("txid"); + let txid: String = read_col(&row, "txid", "select txout")?; let txid = Txid::from_str(&txid)?; - let vout: i32 = row.get("vout"); - let value: i64 = row.get("value"); - let script: Vec = row.get("script"); + let vout: i32 = read_col(&row, "vout", "select txout")?; + let value: i64 = read_col(&row, "value", "select txout")?; + let script: Vec = read_col(&row, "script", "select txout")?; changeset.txouts.insert( OutPoint { @@ -505,13 +521,13 @@ pub(crate) async fn tx_graph_changeset_from_postgres( })?; for row in rows { - let anchor: serde_json::Value = row.get("anchor"); - let txid: String = row.get("txid"); + let anchor: serde_json::Value = read_col(&row, "anchor", "select anchor tx")?; + let txid: String = read_col(&row, "txid", "select anchor tx")?; let txid = Txid::from_str(&txid)?; - let block_height: i32 = row.get("block_height"); - let block_hash: String = row.get("block_hash"); + let block_height: i32 = read_col(&row, "block_height", "select anchor tx")?; + let block_hash: String = read_col(&row, "block_hash", "select anchor tx")?; let block_hash = BlockHash::from_str(&block_hash)?; - let confirmation_time: i64 = row.get("confirmation_time"); + let confirmation_time: i64 = read_col(&row, "confirmation_time", "select anchor tx")?; let anchor: ConfirmationBlockTime = serde_json::from_value(anchor)?; let computed_hash = anchor.block_id.hash; @@ -656,8 +672,8 @@ pub(crate) async fn local_chain_changeset_from_postgres( })?; for row in rows { - let hash: String = row.get("hash"); - let height: i32 = row.get("height"); + let hash: String = read_col(&row, "hash", "select block")?; + let height: i32 = read_col(&row, "height", "select block")?; let block_hash = BlockHash::from_str(&hash)?; changeset.blocks.insert( crate::checked_conv(height, "block.height")?, diff --git a/src/sqlite.rs b/src/sqlite.rs index 69b4f14..51189a1 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -21,6 +21,26 @@ use sqlx::sqlx_macros::migrate; use sqlx::{sqlite::Sqlite, Pool, Row, Transaction}; use tracing::trace; +/// Decode a column without panicking. +/// +/// `Row::get` unwraps decode failures, which turns a corrupt or foreign-schema +/// row into a process panic. Every read goes through this helper so storage +/// class mismatches and out-of-range integers become typed errors instead. +fn read_col<'r, T>( + row: &'r sqlx::sqlite::SqliteRow, + column: &str, + table: &str, +) -> Result +where + T: sqlx::Decode<'r, Sqlite> + sqlx::Type, +{ + row.try_get(column) + .map_err(|source| BdkSqlxError::QueryError { + table: table.to_string(), + source, + }) +} + impl AsyncWalletPersister for Store { type Error = BdkSqlxError; @@ -256,7 +276,7 @@ impl Store { source: e, })?; if let Some(row) = row { - let network: String = row.get("name"); + let network: String = read_col(&row, "name", "read network")?; changeset.network = Some(crate::parse_and_validate_network(&network)?); } @@ -275,9 +295,9 @@ impl Store { source: e, })?; for row in rows { - let keychainkind: String = row.get("keychainkind"); - let descriptor: String = row.get("descriptor"); - let last_revealed: Option = row.get("last_revealed"); + let keychainkind: String = read_col(&row, "keychainkind", "read keychain")?; + let descriptor: String = read_col(&row, "descriptor", "read keychain")?; + let last_revealed: Option = read_col(&row, "last_revealed", "read keychain")?; crate::keychain_changeset_from_parts( &mut changeset, &keychainkind, @@ -473,10 +493,10 @@ pub(crate) async fn tx_graph_changeset_from_sqlite( })?; for row in rows { - let txid: String = row.get("txid"); + let txid: String = read_col(&row, "txid", "select tx")?; let txid = Txid::from_str(&txid)?; - let whole_tx: Option> = row.get("whole_tx"); - let last_seen: Option = row.get("last_seen"); + let whole_tx: Option> = read_col(&row, "whole_tx", "select tx")?; + let last_seen: Option = read_col(&row, "last_seen", "select tx")?; if let Some(tx_bytes) = whole_tx { let tx: bitcoin::Transaction = consensus::deserialize(&tx_bytes)?; @@ -507,16 +527,16 @@ pub(crate) async fn tx_graph_changeset_from_sqlite( })?; for row in rows { - let txid: String = row.get("txid"); + let txid: String = read_col(&row, "txid", "select txout")?; let txid = Txid::from_str(&txid)?; - let vout: i32 = row.get("vout"); - let value: i64 = row.get("value"); - let script: Vec = row.get("script"); + let vout: i64 = read_col(&row, "vout", "select txout")?; + let value: i64 = read_col(&row, "value", "select txout")?; + let script: Vec = read_col(&row, "script", "select txout")?; changeset.txouts.insert( OutPoint { txid, - vout: crate::checked_conv(vout, "txout.vout")?, + vout: crate::checked_u32(vout, "txout.vout")?, }, TxOut { value: Amount::from_sat(crate::checked_conv(value, "txout.value")?), @@ -538,13 +558,13 @@ pub(crate) async fn tx_graph_changeset_from_sqlite( })?; for row in rows { - let anchor: serde_json::Value = row.get("anchor"); - let txid: String = row.get("txid"); + let anchor: serde_json::Value = read_col(&row, "anchor", "select anchor tx")?; + let txid: String = read_col(&row, "txid", "select anchor tx")?; let txid = Txid::from_str(&txid)?; - let block_height: i32 = row.get("block_height"); - let block_hash: String = row.get("block_hash"); + let block_height: i64 = read_col(&row, "block_height", "select anchor tx")?; + let block_hash: String = read_col(&row, "block_hash", "select anchor tx")?; let block_hash = BlockHash::from_str(&block_hash)?; - let confirmation_time: i64 = row.get("confirmation_time"); + let confirmation_time: i64 = read_col(&row, "confirmation_time", "select anchor tx")?; let anchor: ConfirmationBlockTime = serde_json::from_value(anchor)?; let computed_hash = anchor.block_id.hash; @@ -554,7 +574,7 @@ pub(crate) async fn tx_graph_changeset_from_sqlite( computed: computed_hash, }); } - let stored_height: u32 = crate::checked_conv(block_height, "anchor.block_height")?; + let stored_height = crate::checked_u32(block_height, "anchor.block_height")?; if anchor.block_id.height != stored_height { return Err(BdkSqlxError::AnchorBlockHeightMismatch { stored: stored_height, @@ -691,11 +711,11 @@ pub(crate) async fn local_chain_changeset_from_sqlite( })?; for row in rows { - let hash: String = row.get("hash"); - let height: i32 = row.get("height"); + let hash: String = read_col(&row, "hash", "select block")?; + let height: i64 = read_col(&row, "height", "select block")?; let block_hash = BlockHash::from_str(&hash)?; changeset.blocks.insert( - crate::checked_conv(height, "block.height")?, + crate::checked_u32(height, "block.height")?, Some(block_hash), ); } diff --git a/src/test.rs b/src/test.rs index b41796c..cf521bf 100644 --- a/src/test.rs +++ b/src/test.rs @@ -454,6 +454,98 @@ async fn out_of_range_values_error_on_load() -> anyhow::Result<()> { Ok(()) } +/// SQLite INTEGER values outside the signed 32-bit range used by this schema, +/// and wrong storage classes, must produce typed errors rather than panicking in +/// sqlx's `Row::get`. +#[tokio::test] +async fn sqlite_oversized_and_mistyped_values_error_on_load() -> anyhow::Result<()> { + initialize(); + + let (external_desc, internal_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let wallet_name = wallet_name_from_descriptor( + external_desc, + Some(internal_desc), + NETWORK, + &Secp256k1::new(), + )?; + let mut store = Store::::new_with_url(None, wallet_name.clone(), NETWORK, true).await?; + let mut wallet = Wallet::create(external_desc, internal_desc) + .network(NETWORK) + .create_wallet_async(&mut store) + .await?; + let txid = insert_fake_tx( + &mut wallet, + Amount::from_sat(20_000), + Amount::from_sat(10_000), + Amount::from_sat(1_000), + ); + let mut extra = ChangeSet::default(); + extra.tx_graph.txouts.insert( + OutPoint { txid, vout: 0 }, + TxOut { + value: Amount::from_sat(1), + script_pubkey: Default::default(), + }, + ); + assert!(wallet.persist_async(&mut store).await?); + store.write(&extra).await?; + + // height above i32::MAX + sqlx::query("UPDATE block SET height=2147483648 WHERE wallet_name=$1 AND height=2000") + .bind(&wallet_name) + .execute(&store.pool) + .await?; + assert_matches!(store.read().await, Err(BdkSqlxError::IntOutOfRange { .. })); + sqlx::query("UPDATE block SET height=2000 WHERE wallet_name=$1 AND height=2147483648") + .bind(&wallet_name) + .execute(&store.pool) + .await?; + + // vout above i32::MAX + sqlx::query("UPDATE txout SET vout=2147483648 WHERE wallet_name=$1 AND txid=$2 AND vout=0") + .bind(&wallet_name) + .bind(txid.to_string()) + .execute(&store.pool) + .await?; + assert_matches!(store.read().await, Err(BdkSqlxError::IntOutOfRange { .. })); + sqlx::query("UPDATE txout SET vout=0 WHERE wallet_name=$1 AND txid=$2 AND vout=2147483648") + .bind(&wallet_name) + .bind(txid.to_string()) + .execute(&store.pool) + .await?; + + // last_revealed above i32::MAX + sqlx::query( + "UPDATE keychain SET last_revealed=2147483648 WHERE wallet_name=$1 AND keychainkind='External'", + ) + .bind(&wallet_name) + .execute(&store.pool) + .await?; + assert_matches!(store.read().await, Err(BdkSqlxError::IntOutOfRange { .. })); + sqlx::query( + "UPDATE keychain SET last_revealed=0 WHERE wallet_name=$1 AND keychainkind='External'", + ) + .bind(&wallet_name) + .execute(&store.pool) + .await?; + + // A BLOB where the schema promises TEXT must be reported, not panic. + sqlx::query("UPDATE tx SET txid=x'00ff' WHERE wallet_name=$1 AND txid=$2") + .bind(&wallet_name) + .bind(txid.to_string()) + .execute(&store.pool) + .await?; + assert_matches!(store.read().await, Err(BdkSqlxError::QueryError { .. })); + sqlx::query("UPDATE tx SET txid=$2 WHERE wallet_name=$1 AND typeof(txid)='blob'") + .bind(&wallet_name) + .bind(txid.to_string()) + .execute(&store.pool) + .await?; + + store.read().await?; + Ok(()) +} + /// Data stored for a different network than the store was configured with (or an /// unparseable network string) must fail the load instead of being silently accepted. #[tokio::test] From bb5b223c5763821b385cdc2cc903977e03a5d27b Mon Sep 17 00:00:00 2001 From: russeree Date: Wed, 21 Oct 2026 11:49:56 -0700 Subject: [PATCH 3/7] fix: reject unsupported descriptors before computing their id DescriptorExt::descriptor_id() unwraps at_derivation_index(0), which panics for multipath keys and hardened derivation. Validate those shapes first and derive index 0 fallibly, returning BdkSqlxError::UnsupportedDescriptor so a corrupt or caller-supplied changeset cannot panic the process. --- CHANGELOG.md | 7 +++++++ src/lib.rs | 42 ++++++++++++++++++++++++++++++++++++++++-- src/postgres.rs | 6 ++---- src/sqlite.rs | 6 ++---- src/test.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ad35f..5f090a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ 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`, diff --git a/src/lib.rs b/src/lib.rs index 38f4671..b9257a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,9 +15,12 @@ use std::str::FromStr; use std::sync::OnceLock; use bdk_wallet::bitcoin; +use bdk_wallet::bitcoin::hashes::{sha256, Hash}; use bdk_wallet::bitcoin::{BlockHash, Network, Txid}; use bdk_wallet::chain::miniscript; -use bdk_wallet::chain::DescriptorExt; +use bdk_wallet::chain::miniscript::descriptor::Wildcard; +use bdk_wallet::chain::miniscript::ForEachKey; +use bdk_wallet::chain::DescriptorId; use bdk_wallet::descriptor::{Descriptor, DescriptorPublicKey}; use bdk_wallet::ChangeSet; /// Re-export of the [`sqlx`] crate this library is built on. @@ -86,6 +89,9 @@ pub enum BdkSqlxError { /// second, conflicting height second_height: u32, }, + /// a stored or incoming descriptor uses a feature the store cannot derive + #[error("unsupported descriptor: multipath or hardened derivation")] + UnsupportedDescriptor, /// miniscript error #[error("miniscript error: {0}")] Miniscript(#[from] miniscript::Error), @@ -276,6 +282,38 @@ pub(crate) fn parse_and_validate_network(stored: &str) -> Result, +) -> Result { + let unsupported = descriptor.is_multipath() + || descriptor.for_any_key(|key| match key { + DescriptorPublicKey::XPub(xpub) => { + xpub.wildcard == Wildcard::Hardened + || xpub + .derivation_path + .into_iter() + .any(|step| step.is_hardened()) + } + _ => false, + }); + if unsupported { + return Err(BdkSqlxError::UnsupportedDescriptor); + } + + let spk = descriptor + .at_derivation_index(0) + .map_err(|_| BdkSqlxError::UnsupportedDescriptor)? + .script_pubkey(); + Ok(DescriptorId(sha256::Hash::hash(spk.as_bytes()))) +} + /// Applies one stored keychain row to `changeset`. /// /// Shared by both backends so the parse and validation rules cannot drift @@ -289,7 +327,7 @@ pub(crate) fn keychain_changeset_from_parts( last_revealed: Option, ) -> Result<(), BdkSqlxError> { let descriptor: Descriptor = descriptor_str.parse()?; - let did = descriptor.descriptor_id(); + let did = descriptor_id_checked(&descriptor)?; match keychainkind { "External" => changeset.descriptor = Some(descriptor), "Internal" => changeset.change_descriptor = Some(descriptor), diff --git a/src/postgres.rs b/src/postgres.rs index 3106010..5770cbd 100644 --- a/src/postgres.rs +++ b/src/postgres.rs @@ -5,9 +5,7 @@ // Standard library imports use std::{str::FromStr, sync::Arc}; // Third party crates -use bdk_chain::{ - local_chain, tx_graph, Anchor, ConfirmationBlockTime, DescriptorExt, DescriptorId, Merge, -}; +use bdk_chain::{local_chain, tx_graph, Anchor, ConfirmationBlockTime, DescriptorId, Merge}; use bdk_wallet::{ bitcoin::{ self, consensus, hashes::Hash, Amount, BlockHash, Network, OutPoint, ScriptBuf, TxOut, Txid, @@ -334,7 +332,7 @@ async fn insert_descriptor( trace!("insert descriptor"); let descriptor_str = descriptor.to_string(); - let descriptor_id = descriptor.descriptor_id().to_byte_array(); + let descriptor_id = crate::descriptor_id_checked(descriptor)?.to_byte_array(); let keychain = match keychain { External => "External", Internal => "Internal", diff --git a/src/sqlite.rs b/src/sqlite.rs index 51189a1..3d979e2 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -6,9 +6,7 @@ use std::str::FromStr; use std::sync::Arc; use super::{BdkSqlxError, FutureResult, Store}; -use bdk_chain::{ - local_chain, tx_graph, Anchor, ConfirmationBlockTime, DescriptorExt, DescriptorId, Merge, -}; +use bdk_chain::{local_chain, tx_graph, Anchor, ConfirmationBlockTime, DescriptorId, Merge}; use bdk_wallet::bitcoin::{ self, consensus, hashes::Hash, Amount, BlockHash, Network, OutPoint, ScriptBuf, TxOut, Txid, }; @@ -372,7 +370,7 @@ async fn insert_descriptor( trace!("insert descriptor"); let descriptor_str = descriptor.to_string(); - let descriptor_id = descriptor.descriptor_id().to_byte_array(); + let descriptor_id = crate::descriptor_id_checked(descriptor)?.to_byte_array(); let keychain = match keychain { External => "External", Internal => "Internal", diff --git a/src/test.rs b/src/test.rs index cf521bf..436e310 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1434,6 +1434,54 @@ async fn two_wallets_load() -> anyhow::Result<()> { // Pure unit tests (no database) // --------------------------------------------------------------------------- +#[test] +fn unsupported_descriptors_error_without_panicking() { + // `DescriptorExt::descriptor_id()` unwraps `at_derivation_index(0)`, which + // panics for these valid-but-unsupported descriptor shapes. + let tpub = "tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK"; + for descriptor in [ + format!("wpkh({tpub}/<0;1>/*)"), + format!("wpkh({tpub}/0/*')"), + format!("wpkh({tpub}/0'/1/*)"), + ] { + let mut changeset = ChangeSet::default(); + assert_matches!( + crate::keychain_changeset_from_parts(&mut changeset, "External", &descriptor, None), + Err(BdkSqlxError::UnsupportedDescriptor), + "unsupported descriptor must not panic: {descriptor}" + ); + } +} + +/// A stored multipath/hardened descriptor must fail the load with a typed error +/// instead of panicking while computing its descriptor id. +#[tokio::test] +async fn unsupported_stored_descriptor_errors_on_load() -> anyhow::Result<()> { + initialize(); + + let wallet_name = "unsupported_stored_descriptor_errors_on_load".to_string(); + let (external_desc, internal_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut store = Store::::new_with_url(None, wallet_name.clone(), NETWORK, true).await?; + let mut wallet = Wallet::create(external_desc, internal_desc) + .network(NETWORK) + .create_wallet_async(&mut store) + .await?; + let _ = wallet.reveal_next_address(bdk_wallet::KeychainKind::External); + assert!(wallet.persist_async(&mut store).await?); + + let tpub = "tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK"; + sqlx::query( + "UPDATE keychain SET descriptor=$2 WHERE wallet_name=$1 AND keychainkind='External'", + ) + .bind(&wallet_name) + .bind(format!("wpkh({tpub}/<0;1>/*)")) + .execute(&store.pool) + .await?; + + assert_matches!(store.read().await, Err(BdkSqlxError::UnsupportedDescriptor)); + Ok(()) +} + #[test] fn checked_conv_accepts_in_range_values() { assert_eq!(crate::checked_conv::(42, "t").unwrap(), 42u32); From 42b5621ecf711fa178d1ee0500474b48907c35e0 Mon Sep 17 00:00:00 2001 From: russeree Date: Wed, 21 Oct 2026 12:01:42 -0700 Subject: [PATCH 4/7] test: add fuzz targets for changesets, corrupt rows, anchors, and descriptors Add a cargo-fuzz crate with four targets: changeset persist/load idempotence, corrupt SQLite row decoding, full anchor identity, and descriptor-id validation. Add a Fuzz workflow that builds and lints the fuzz crate and runs each target for 90 seconds on PRs (300 seconds on the weekly schedule). --- .github/workflows/fuzz.yml | 74 ++++++ fuzz/.gitignore | 5 + fuzz/Cargo.toml | 46 ++++ fuzz/README.md | 17 ++ fuzz/corpus/anchor_identity/seed | Bin 0 -> 85 bytes fuzz/corpus/changeset_roundtrip/seed | Bin 0 -> 69 bytes fuzz/corpus/corrupt_rows/seed | Bin 0 -> 101 bytes fuzz/corpus/descriptor_validation/seed | 1 + fuzz/fuzz_targets/anchor_identity.rs | 58 ++++ fuzz/fuzz_targets/changeset_roundtrip.rs | 46 ++++ fuzz/fuzz_targets/corrupt_rows.rs | 167 ++++++++++++ fuzz/fuzz_targets/descriptor_validation.rs | 52 ++++ fuzz/rust-toolchain.toml | 2 + fuzz/src/lib.rs | 294 +++++++++++++++++++++ 14 files changed, 762 insertions(+) create mode 100644 .github/workflows/fuzz.yml create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/corpus/anchor_identity/seed create mode 100644 fuzz/corpus/changeset_roundtrip/seed create mode 100644 fuzz/corpus/corrupt_rows/seed create mode 100644 fuzz/corpus/descriptor_validation/seed create mode 100644 fuzz/fuzz_targets/anchor_identity.rs create mode 100644 fuzz/fuzz_targets/changeset_roundtrip.rs create mode 100644 fuzz/fuzz_targets/corrupt_rows.rs create mode 100644 fuzz/fuzz_targets/descriptor_validation.rs create mode 100644 fuzz/rust-toolchain.toml create mode 100644 fuzz/src/lib.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..2e2d134 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,74 @@ +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 + - 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 }} + - name: Run fuzz target + working-directory: fuzz + run: | + cargo +nightly fuzz run ${{ matrix.target }} -- \ + -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 diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..bfc2bdc --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,5 @@ +target +artifacts +coverage +corpus/*/* +!corpus/*/seed diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..73455fe --- /dev/null +++ b/fuzz/Cargo.toml @@ -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 = ["."] diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..de5acd5 --- /dev/null +++ b/fuzz/README.md @@ -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`). diff --git a/fuzz/corpus/anchor_identity/seed b/fuzz/corpus/anchor_identity/seed new file mode 100644 index 0000000000000000000000000000000000000000..fc1c5088ba9d0104a211ef0f8962c624bdd885a0 GIT binary patch literal 85 zcmZQ6PEAc=U}R!uVP#|I;N;@w;pO8O5EK#?5fu}ckd%^^k(HBIP*hS@QB_md(A3h_ o(bdy8Ff=kYF*P%{u(Yzav9+^zaCCBZadmU|@bvQb@%8fu0M&U7{r~^~ literal 0 HcmV?d00001 diff --git a/fuzz/corpus/changeset_roundtrip/seed b/fuzz/corpus/changeset_roundtrip/seed new file mode 100644 index 0000000000000000000000000000000000000000..8a41dfc7f01452e790b54c349e49a8161d8d06f7 GIT binary patch literal 69 zcmZQ6PEAc=U}R!uVP#|I;N;@w;pO8O5EK#?5fu}ckd%^^k(HBIP*hS@QB_md(A3h_ Y(bdy8Ff=kYF*P%{u(Yzav9+@Y02pxzh5!Hn literal 0 HcmV?d00001 diff --git a/fuzz/corpus/corrupt_rows/seed b/fuzz/corpus/corrupt_rows/seed new file mode 100644 index 0000000000000000000000000000000000000000..c880dfad4f13a09bcc19ecf3a1f46faec8042104 GIT binary patch literal 101 zcmZQ6PEAc=U}R!uVP#|I;N;@w;pO8O5EK#?5fu}ckd%^^k(HBIP*hS@QB_md(A3h_ z(bdy8Ff=kYF*P%{u(Yzav9+^zaCCBZadmU|@bvQb@%8f$2n-4i2@MO6h>VJkiH(Z~ E0OF7nb^rhX literal 0 HcmV?d00001 diff --git a/fuzz/corpus/descriptor_validation/seed b/fuzz/corpus/descriptor_validation/seed new file mode 100644 index 0000000..bdce249 --- /dev/null +++ b/fuzz/corpus/descriptor_validation/seed @@ -0,0 +1 @@ +seedwpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/<0;1>/*) \ No newline at end of file diff --git a/fuzz/fuzz_targets/anchor_identity.rs b/fuzz/fuzz_targets/anchor_identity.rs new file mode 100644 index 0000000..2eb5729 --- /dev/null +++ b/fuzz/fuzz_targets/anchor_identity.rs @@ -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()); + }); +}); diff --git a/fuzz/fuzz_targets/changeset_roundtrip.rs b/fuzz/fuzz_targets/changeset_roundtrip.rs new file mode 100644 index 0000000..ef5edf5 --- /dev/null +++ b/fuzz/fuzz_targets/changeset_roundtrip.rs @@ -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:?}"), + } + }); +}); diff --git a/fuzz/fuzz_targets/corrupt_rows.rs b/fuzz/fuzz_targets/corrupt_rows.rs new file mode 100644 index 0000000..31d1690 --- /dev/null +++ b/fuzz/fuzz_targets/corrupt_rows.rs @@ -0,0 +1,167 @@ +#![no_main] + +use bdk_sqlx::sqlx; +use bdk_sqlx::sqlx::{Pool, Sqlite}; +use bdk_sqlx_fuzz::{baseline_changeset, load, new_store_with_pool, persist, Reader}; +use bdk_wallet::bitcoin::hashes::Hash; +use libfuzzer_sys::fuzz_target; + +async fn apply_mutation(pool: &Pool, wallet_name: &str, reader: &mut Reader<'_>) { + match reader.u8() % 16 { + 0 => { + let _ = sqlx::query("UPDATE tx SET txid=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.bytes(32).to_vec()) + .execute(pool) + .await; + } + 1 => { + let _ = sqlx::query("UPDATE tx SET whole_tx=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.bytes(128).to_vec()) + .execute(pool) + .await; + } + 2 => { + let _ = sqlx::query("UPDATE tx SET last_seen=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.text(24)) + .execute(pool) + .await; + } + 3 => { + let _ = sqlx::query("UPDATE txout SET txid=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.bytes(32).to_vec()) + .execute(pool) + .await; + } + 4 => { + let _ = sqlx::query("UPDATE txout SET vout=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.i64()) + .execute(pool) + .await; + } + 5 => { + let _ = sqlx::query("UPDATE txout SET value=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.i64()) + .execute(pool) + .await; + } + 6 => { + let _ = sqlx::query("UPDATE txout SET script=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.bytes(128).to_vec()) + .execute(pool) + .await; + } + 7 => { + let anchor = if reader.bool() { + serde_json::json!({ + "block_id": { + "height": reader.u32(), + "hash": bdk_wallet::bitcoin::BlockHash::from_byte_array( + reader.array32(), + ) + .to_string(), + }, + "confirmation_time": reader.u64(), + }) + .to_string() + } else { + reader.text(48) + }; + let _ = sqlx::query("UPDATE anchor_tx SET anchor=jsonb($2) WHERE wallet_name=$1") + .bind(wallet_name) + .bind(anchor) + .execute(pool) + .await; + } + 8 => { + let _ = sqlx::query("UPDATE anchor_tx SET block_height=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.i64()) + .execute(pool) + .await; + } + 9 => { + let _ = sqlx::query("UPDATE anchor_tx SET confirmation_time=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.i64()) + .execute(pool) + .await; + } + 10 => { + let _ = sqlx::query("UPDATE anchor_tx SET txid=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.bytes(32).to_vec()) + .execute(pool) + .await; + } + 11 => { + let _ = sqlx::query("UPDATE block SET height=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.i64()) + .execute(pool) + .await; + } + 12 => { + let _ = sqlx::query("UPDATE block SET hash=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.bytes(32).to_vec()) + .execute(pool) + .await; + } + 13 => { + let _ = sqlx::query("UPDATE keychain SET last_revealed=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.i64()) + .execute(pool) + .await; + } + 14 => { + let _ = sqlx::query("UPDATE keychain SET descriptor=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.text(256)) + .execute(pool) + .await; + } + _ => { + let _ = sqlx::query("UPDATE network SET name=$2 WHERE wallet_name=$1") + .bind(wallet_name) + .bind(reader.text(24)) + .execute(pool) + .await; + } + } +} + +fuzz_target!(|data: &[u8]| { + let mut reader = Reader::new(data); + let wallet_name = reader.wallet_name(); + let baseline = baseline_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, pool) = new_store_with_pool(wallet_name.clone()).await; + persist(&mut store, &baseline) + .await + .expect("baseline changeset must persist"); + + for _ in 0..(1 + reader.u8() % 8) { + apply_mutation(&pool, &wallet_name, &mut reader).await; + } + + // The only invariant is that corrupt rows never panic the load. If the + // corrupted state happens to be valid, exercise a write round trip too. + if let Ok(loaded) = load(&mut store).await { + let _ = persist(&mut store, &loaded).await; + let _ = load(&mut store).await; + } + }); +}); diff --git a/fuzz/fuzz_targets/descriptor_validation.rs b/fuzz/fuzz_targets/descriptor_validation.rs new file mode 100644 index 0000000..be4dd72 --- /dev/null +++ b/fuzz/fuzz_targets/descriptor_validation.rs @@ -0,0 +1,52 @@ +#![no_main] + +use bdk_sqlx::sqlx; +use bdk_sqlx::BdkSqlxError; +use bdk_sqlx_fuzz::{ + descriptor_changeset, load, new_store_with_pool, persist, unsupported_descriptors, Reader, +}; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let mut reader = Reader::new(data); + let wallet_name = reader.wallet_name(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime"); + runtime.block_on(async move { + let (mut store, pool) = new_store_with_pool(wallet_name.clone()).await; + persist(&mut store, &descriptor_changeset()) + .await + .expect("descriptor changeset must persist"); + + for descriptor in unsupported_descriptors() { + sqlx::query( + "UPDATE keychain SET descriptor=$2 WHERE wallet_name=$1 AND keychainkind='External'", + ) + .bind(&wallet_name) + .bind(descriptor) + .execute(&pool) + .await + .expect("update descriptor"); + + match load(&mut store).await { + Err(BdkSqlxError::UnsupportedDescriptor) => {} + other => panic!("expected UnsupportedDescriptor, got {other:?}"), + } + } + + // Arbitrary bytes must also fail cleanly (or parse as a valid descriptor). + sqlx::query( + "UPDATE keychain SET descriptor=$2 WHERE wallet_name=$1 AND keychainkind='External'", + ) + .bind(&wallet_name) + .bind(reader.text(256)) + .execute(&pool) + .await + .expect("update descriptor"); + + let _ = load(&mut store).await; + }); +}); diff --git a/fuzz/rust-toolchain.toml b/fuzz/rust-toolchain.toml new file mode 100644 index 0000000..5d56faf --- /dev/null +++ b/fuzz/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs new file mode 100644 index 0000000..d077c25 --- /dev/null +++ b/fuzz/src/lib.rs @@ -0,0 +1,294 @@ +//! Shared generators and helpers for the `bdk-sqlx` fuzz targets. +//! +//! The targets only exercise the SQLite backend. They deliberately treat all +//! bytes as untrusted, and only assert invariants that must hold for every +//! input (no panic, successful reads are idempotent, valid anchors round-trip). + +#![allow(clippy::needless_range_loop)] + +use std::str::FromStr; +use std::sync::Arc; + +use bdk_sqlx::sqlx::sqlite::SqlitePoolOptions; +use bdk_sqlx::sqlx::{Pool, Sqlite}; +use bdk_sqlx::{BdkSqlxError, Store}; +use bdk_wallet::bitcoin::absolute::LockTime; +use bdk_wallet::bitcoin::hashes::Hash; +use bdk_wallet::bitcoin::transaction; +use bdk_wallet::bitcoin::{ + Amount, BlockHash, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, +}; +use bdk_wallet::chain::{BlockId, ConfirmationBlockTime, DescriptorExt}; +use bdk_wallet::miniscript::{Descriptor, DescriptorPublicKey}; +use bdk_wallet::{AsyncWalletPersister, ChangeSet}; + +pub const FIXED_EXTERNAL: &str = "wpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/0/*)"; +pub const FIXED_INTERNAL: &str = "wpkh(tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK/1/*)"; + +const TPUB: &str = "tpubD6NzVbkrYhZ4XHndKkuB8FifXm8r5FQHwrN6oZuWCz13qb93rtgKvD4PQsqC4HP4yhV3tA2fqr2RbY5mNXfM7RxXUoeABoDtsFUq2zJq6YK"; + +/// A deterministic byte reader. Missing bytes read as zero. +pub struct Reader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + pub fn u8(&mut self) -> u8 { + let byte = self.data.get(self.pos).copied().unwrap_or(0); + if self.pos < self.data.len() { + self.pos += 1; + } + byte + } + + pub fn u16(&mut self) -> u16 { + u16::from_le_bytes(self.array()) + } + + pub fn u32(&mut self) -> u32 { + u32::from_le_bytes(self.array()) + } + + pub fn u64(&mut self) -> u64 { + u64::from_le_bytes(self.array()) + } + + pub fn i64(&mut self) -> i64 { + self.u64() as i64 + } + + pub fn bool(&mut self) -> bool { + self.u8() & 1 == 1 + } + + fn array(&mut self) -> [u8; N] { + let mut out = [0u8; N]; + for byte in &mut out { + *byte = self.u8(); + } + out + } + + pub fn array32(&mut self) -> [u8; 32] { + self.array() + } + + /// Consume at most `max` bytes, bounded by the remaining input. + pub fn bytes(&mut self, max: usize) -> &'a [u8] { + let data = self.data; + let pos = self.pos.min(data.len()); + let remaining = data.len() - pos; + let len = (self.u8() as usize).min(max).min(remaining); + let out = &data[pos..pos + len]; + self.pos = pos + len; + out + } + + pub fn text(&mut self, max: usize) -> String { + String::from_utf8_lossy(self.bytes(max)).into_owned() + } + + pub fn wallet_name(&mut self) -> String { + let name = self.text(48); + if name.is_empty() { + "fuzz_wallet".to_string() + } else { + format!("fuzz_{name}") + } + } +} + +pub fn fixed_descriptors() -> ( + Descriptor, + Descriptor, +) { + ( + Descriptor::from_str(FIXED_EXTERNAL).expect("fixed external descriptor"), + Descriptor::from_str(FIXED_INTERNAL).expect("fixed internal descriptor"), + ) +} + +/// A changeset containing only the fixed descriptors and network. +pub fn descriptor_changeset() -> ChangeSet { + let (external, internal) = fixed_descriptors(); + ChangeSet { + network: Some(Network::Regtest), + descriptor: Some(external), + change_descriptor: Some(internal), + ..Default::default() + } +} + +/// Descriptors that parse successfully but cannot derive index 0. +pub fn unsupported_descriptors() -> [String; 3] { + [ + format!("wpkh({TPUB}/<0;1>/*)"), + format!("wpkh({TPUB}/0/*')"), + format!("wpkh({TPUB}/0'/1/*)"), + ] +} + +pub async fn new_store(wallet_name: String) -> Store { + Store::::new_with_url(None, wallet_name, Network::Regtest, true) + .await + .expect("in-memory sqlite store must build") +} + +/// Builds a store over a caller-owned pool so fuzz targets can inject corrupt +/// rows directly. +pub async fn new_store_with_pool(wallet_name: String) -> (Store, Pool) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .min_connections(1) + .idle_timeout(None) + .max_lifetime(None) + .connect(":memory:") + .await + .expect("in-memory sqlite pool"); + let store = Store::::new(pool.clone(), wallet_name, Network::Regtest, true) + .await + .expect("in-memory sqlite store must build"); + (store, pool) +} + +pub async fn persist(store: &mut Store, changeset: &ChangeSet) -> Result<(), BdkSqlxError> { + AsyncWalletPersister::persist(store, changeset).await +} + +pub async fn load(store: &mut Store) -> Result { + AsyncWalletPersister::initialize(store).await +} + +pub fn random_tx(reader: &mut Reader<'_>) -> Transaction { + Transaction { + version: transaction::Version::TWO, + lock_time: LockTime::from_consensus(reader.u32()), + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_byte_array(reader.array32()), + vout: reader.u32(), + }, + ..Default::default() + }], + output: vec![TxOut { + value: Amount::from_sat(reader.u64() >> 1), + script_pubkey: ScriptBuf::from(reader.bytes(64).to_vec()), + }], + } +} + +pub fn random_anchor(reader: &mut Reader<'_>) -> ConfirmationBlockTime { + ConfirmationBlockTime { + block_id: BlockId { + height: reader.u32() % 1_000_000, + hash: BlockHash::from_byte_array(reader.array32()), + }, + confirmation_time: reader.u64() >> 1, + } +} + +/// A changeset that exercises every persisted table, including an anchor whose +/// transaction row exists. Used as the starting point for corruption fuzzing. +pub fn baseline_changeset(reader: &mut Reader<'_>) -> ChangeSet { + let (external, internal) = fixed_descriptors(); + let tx = random_tx(reader); + let txid = tx.compute_txid(); + let height = 100 + reader.u32() % 1_000; + let hash = BlockHash::from_byte_array(reader.array32()); + let anchor = ConfirmationBlockTime { + block_id: BlockId { height, hash }, + confirmation_time: reader.u64() >> 1, + }; + + let mut changeset = ChangeSet { + network: Some(Network::Regtest), + descriptor: Some(external.clone()), + change_descriptor: Some(internal.clone()), + ..Default::default() + }; + changeset.local_chain.blocks.insert(height, Some(hash)); + changeset.tx_graph.txs.insert(Arc::new(tx)); + changeset.tx_graph.txouts.insert( + OutPoint { txid, vout: 0 }, + TxOut { + value: Amount::from_sat(reader.u64() >> 1), + script_pubkey: ScriptBuf::from(reader.bytes(32).to_vec()), + }, + ); + changeset.tx_graph.last_seen.insert(txid, reader.u64() >> 1); + changeset.tx_graph.anchors.insert((anchor, txid)); + changeset + .indexer + .last_revealed + .insert(external.descriptor_id(), reader.u32() & 0x7fff_ffff); + changeset + .indexer + .last_revealed + .insert(internal.descriptor_id(), reader.u32() & 0x7fff_ffff); + changeset +} + +/// Arbitrary, mostly-valid changeset. Values are masked into the schema's +/// representable range; structural invalidity (for example one hash at two +/// heights) is left to the fuzzer. +pub fn random_changeset(reader: &mut Reader<'_>) -> ChangeSet { + let (external, internal) = fixed_descriptors(); + let mut changeset = ChangeSet { + network: Some(Network::Regtest), + descriptor: Some(external.clone()), + change_descriptor: Some(internal.clone()), + ..Default::default() + }; + + for _ in 0..(reader.u8() % 4) { + let height = reader.u32() % 1_000_000; + let hash = BlockHash::from_byte_array(reader.array32()); + let value = reader.bool().then_some(hash); + changeset.local_chain.blocks.insert(height, value); + } + + for _ in 0..(reader.u8() % 3) { + changeset.tx_graph.txs.insert(Arc::new(random_tx(reader))); + } + + for _ in 0..(reader.u8() % 4) { + let outpoint = OutPoint { + txid: Txid::from_byte_array(reader.array32()), + vout: reader.u32() % 16, + }; + changeset.tx_graph.txouts.insert( + outpoint, + TxOut { + value: Amount::from_sat(reader.u64() >> 1), + script_pubkey: ScriptBuf::from(reader.bytes(64).to_vec()), + }, + ); + } + + for _ in 0..(reader.u8() % 4) { + let txid = Txid::from_byte_array(reader.array32()); + changeset.tx_graph.last_seen.insert(txid, reader.u64() >> 1); + } + + for _ in 0..(reader.u8() % 4) { + let anchor = random_anchor(reader); + let txid = Txid::from_byte_array(reader.array32()); + changeset.tx_graph.anchors.insert((anchor, txid)); + } + + changeset + .indexer + .last_revealed + .insert(external.descriptor_id(), reader.u32() & 0x7fff_ffff); + changeset + .indexer + .last_revealed + .insert(internal.descriptor_id(), reader.u32() & 0x7fff_ffff); + + changeset +} From 80678594dc2390eb9e592c94ec3f5965ea24a9f7 Mon Sep 17 00:00:00 2001 From: russeree Date: Wed, 21 Oct 2026 12:02:37 -0700 Subject: [PATCH 5/7] ci: install rustfmt and clippy for fuzz workflow --- .github/workflows/fuzz.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 2e2d134..d4c2d1e 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -43,6 +43,7 @@ jobs: - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # nightly with: toolchain: nightly + components: rustfmt, clippy - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: "fuzz -> fuzz/target" From cce0fd383d5d5e5ee235f7a4d8328d1b09c3b626 Mon Sep 17 00:00:00 2001 From: russeree Date: Wed, 21 Oct 2026 12:04:30 -0700 Subject: [PATCH 6/7] ci: build fuzz targets for the gnu host and install rust-src --- .github/workflows/fuzz.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index d4c2d1e..7d04477 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -43,7 +43,7 @@ jobs: - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # nightly with: toolchain: nightly - components: rustfmt, clippy + components: rustfmt, clippy, rust-src - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: workspaces: "fuzz -> fuzz/target" @@ -56,11 +56,11 @@ jobs: 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 }} + 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 }} -- \ + cargo +nightly fuzz run ${{ matrix.target }} --target x86_64-unknown-linux-gnu -- \ -max_total_time=${FUZZ_SECONDS} \ -max_len=4096 \ -rss_limit_mb=4096 \ From 9e0be41426f41f218b175ea888ab0c3a4863b5ee Mon Sep 17 00:00:00 2001 From: russeree Date: Wed, 21 Oct 2026 13:19:07 -0700 Subject: [PATCH 7/7] ci: fix MSRV and audit failures in the example dependencies Remove the unused sqlx chrono/uuid features, which pulled uuid 1.28 and required Rust 1.89 despite the crate's 1.88 MSRV. Drop the example's bdk_electrum dependency, which transitively pinned vulnerable webpki-roots 0.25; the example now demonstrates persistence and wallet reloading without a network sync. --- Cargo.toml | 4 +-- examples/bdk_sqlx_postgres.rs | 54 +++-------------------------------- 2 files changed, 5 insertions(+), 53 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d44fd6d..49b0078 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/examples/bdk_sqlx_postgres.rs b/examples/bdk_sqlx_postgres.rs index 0961e0b..7ec8ea1 100644 --- a/examples/bdk_sqlx_postgres.rs +++ b/examples/bdk_sqlx_postgres.rs @@ -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; @@ -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( |_| { @@ -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, @@ -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) @@ -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 @@ -119,31 +101,3 @@ async fn main() -> anyhow::Result<()> { Ok(()) } - -fn electrum(wallet: &mut PersistedWallet>) -> 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::::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(()) -}