diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..7d04477 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -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 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 0000000..fc1c508 Binary files /dev/null and b/fuzz/corpus/anchor_identity/seed differ diff --git a/fuzz/corpus/changeset_roundtrip/seed b/fuzz/corpus/changeset_roundtrip/seed new file mode 100644 index 0000000..8a41dfc Binary files /dev/null and b/fuzz/corpus/changeset_roundtrip/seed differ diff --git a/fuzz/corpus/corrupt_rows/seed b/fuzz/corpus/corrupt_rows/seed new file mode 100644 index 0000000..c880dfa Binary files /dev/null and b/fuzz/corpus/corrupt_rows/seed differ 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 +}