diff --git a/Cargo.toml b/Cargo.toml index e181ee1..34cafbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ version = "0.0.1" edition = "2021" [dependencies] -bdk_wallet = { version = "1.2.0", features = ["test-utils"] } 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"] } @@ -12,14 +11,25 @@ thiserror = "1" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde_json", "json"] } -sqlx-postgres-tester = "0.1.1" + +[dependencies.bdk_wallet] +version = "2.0.0" +git = "https://github.com/bitcoindevkit/bdk_wallet.git" +tag = "wallet-2.0.0" +features = ["test-utils"] [dev-dependencies] assert_matches = "1.5.0" anyhow = "1.0.89" -bdk_electrum = { version = "0.20.1"} rustls = "0.23.14" +[dev-dependencies.bdk_electrum] +version = "0.23.0" + [[example]] name = "bdk_sqlx_postgres" path = "examples/bdk_sqlx_postgres.rs" + +[[example]] +name = "regtest_bdk_sqlx_postgres" +path = "examples/regtest_bdk_sqlx_postgres.rs" diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..19046ec --- /dev/null +++ b/Justfile @@ -0,0 +1,122 @@ +# Default task: Show available commands +default: + just --list + +# Set environment variables for all commands +export DATABASE_URL := "postgres://postgres:password@localhost:5432/mydatabase" +export ELECTRUM_URL := "localhost:50001" +export NODE_URL := "http://localhost:18443" +export NODE_USERNAME := "custom_user" +export NODE_PASSWORD := "custom_pass" +export RUST_BACKTRACE := "1" + +# Setup network +setup-network: + # Check if the network exists first + docker network inspect bitcoin-network >/dev/null 2>&1 || docker network create bitcoin-network + # Verify network was created + docker network inspect bitcoin-network >/dev/null 2>&1 && echo "Bitcoin network is ready" || (echo "Failed to create bitcoin-network" && exit 1) + +# PostgreSQL commands +start-postgres: + docker run -d --name postgres \ + -p 5432:5432 \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=password \ + -e POSTGRES_DB=mydatabase \ + postgres:15 + +test-postgres: + PGPASSWORD=password psql -h localhost -p 5432 -U postgres -d mydatabase -c "SELECT 1" + +# Bitcoin Core commands +build-bitcoin: + docker build -t bitcoin-nix \ + --build-arg NODE_USERNAME=custom_user \ + --build-arg NODE_PASSWORD=custom_pass \ + -f bitcoin.Dockerfile . + +start-bitcoin: setup-network + docker run -d --name bitcoin-node \ + --network bitcoin-network \ + -p 18443:18443 \ + bitcoin-nix + # Add a delay or check to ensure bitcoin is ready + echo "Waiting for Bitcoin node to start..." + for i in {1..30}; do \ + if just test-bitcoin >/dev/null 2>&1; then \ + echo "Bitcoin node is ready!"; \ + break; \ + fi; \ + echo "Waiting... ($i/30)"; \ + sleep 1; \ + if [ $i -eq 30 ]; then \ + echo "Timed out waiting for Bitcoin node to start"; \ + exit 1; \ + fi; \ + done + +test-bitcoin: + curl --silent --user custom_user:custom_pass \ + --data-binary '{"jsonrpc": "1.0", "id":"bitcointest", "method": "getblockchaininfo", "params": []}' \ + -H 'content-type: text/plain;' http://localhost:18443 + +generate-blocks: + curl --silent --user custom_user:custom_pass \ + --data-binary '{"jsonrpc": "1.0", "id":"bitcointest", "method": "generatetoaddress", "params": [101, "bcrt1prdfvfk2ddxe8y88qxhwkxn9cy0d2w6k98gj9smwz6tqjcnl4tdwsrf03aw"]}' \ + -H 'content-type: text/plain;' http://localhost:18443 + +# Electrs commands +build-electrs: + docker build -t electrs-nix \ + -f electrs.Dockerfile . + +start-electrs: setup-network + docker run -d --name electrs \ + --network bitcoin-network \ + -p 50001:50001 \ + electrs-nix + echo "Electrs started" + +check-electrs: + docker logs electrs + + + +# Kill any services that might be using the ports +kill-services: + # Kill any process using PostgreSQL port + lsof -ti:5432 | xargs kill -9 || true + # Kill any process using Bitcoin RPC port + lsof -ti:18443 | xargs kill -9 || true + # Kill any process using Electrs port + lsof -ti:50001 | xargs kill -9 || true + +# Stop and remove Docker containers +stop-containers: + docker stop postgres bitcoin-node electrs || true + docker rm postgres bitcoin-node electrs || true + +# Clean everything +clean-all: + just kill-services + just stop-containers + docker network rm bitcoin-network || true + # Make sure all orphaned networks are also removed + docker network prune -f + echo "Environment cleaned successfully" + +# Run the regtest example with all required services +run-example-regtest: + # Clean up any existing containers first + just stop-containers + # Start all required services + just start-postgres + just build-bitcoin + just start-bitcoin + just build-electrs + just start-electrs + # Generate initial blocks + just generate-blocks + # Run the regtest example + cargo run -r --example regtest_bdk_sqlx_postgres diff --git a/bitcoin.Dockerfile b/bitcoin.Dockerfile new file mode 100644 index 0000000..43c1052 --- /dev/null +++ b/bitcoin.Dockerfile @@ -0,0 +1,34 @@ +# Use the official Nix base image +FROM nixos/nix:latest + +# Define build arguments with defaults +ARG NODE_USERNAME +ARG NODE_PASSWORD + +# Install bitcoind and basic tools via Nix +RUN nix-channel --add https://nixos.org/channels/nixpkgs-unstable nixpkgs && \ + nix-channel --update && \ + nix-env -iA nixpkgs.bitcoind nixpkgs.coreutils + +# Create data directory +RUN mkdir -p /data/bitcoin + +# Expose Bitcoin RPC port and P2P port +EXPOSE 18443 +EXPOSE 18444 + +# Set the entrypoint using shell form to enable variable interpolation +ENTRYPOINT exec /root/.nix-profile/bin/bitcoind \ + "-datadir=/data/bitcoin" \ + "-printtoconsole" \ + "-regtest=1" \ + "-server=1" \ + "-txindex=1" \ + "-rpcbind=0.0.0.0:18443" \ + "-rpcallowip=0.0.0.0/0" \ + "-rpcuser=custom_user" \ + "-rpcpassword=custom_pass" \ + "-rpcworkqueue=64" \ + "-rpcthreads=8" \ + "-fallbackfee=0.0002" \ + "-debug=1" \ No newline at end of file diff --git a/electrs.Dockerfile b/electrs.Dockerfile new file mode 100644 index 0000000..64a2fa6 --- /dev/null +++ b/electrs.Dockerfile @@ -0,0 +1,20 @@ +# Use the official Nix base image +FROM nixos/nix:latest + +# Add the unstable channel +RUN nix-channel --add https://nixos.org/channels/nixpkgs-unstable nixpkgs && \ + nix-channel --update + +# Install electrs from Nixpkgs +RUN nix-env -iA nixpkgs.electrs + +# Create data directory for electrs +RUN mkdir -p /data/electrs/ + +# Expose electrs RPC port (default: 50001) +EXPOSE 50001 + +COPY electrs.toml /etc/electrs/config.toml + +# Command to run electrs with direct auth parameters +CMD ["/root/.nix-profile/bin/electrs", "--conf=/etc/electrs/config.toml"] \ No newline at end of file diff --git a/electrs.toml b/electrs.toml new file mode 100644 index 0000000..697da11 --- /dev/null +++ b/electrs.toml @@ -0,0 +1,19 @@ +auth = "custom_user:custom_pass" +# The listening RPC address of bitcoind +daemon_rpc_addr = "bitcoin-node:18443" # Use the Docker service name +daemon_p2p_addr = "bitcoin-node:18444" +# The listening P2P address of bitcoind +# Note: Your Bitcoin node has P2P listening disabled (-listen=0) +# so this connection will fail, but electrs needs P2P + +# Network type +network = "regtest" + +# Database directory +db_dir = "/data/electrs" + +# Electrum server address +electrum_rpc_addr = "0.0.0.0:50001" # Allow external connections + +# Log level +log_filters = "INFO" \ No newline at end of file diff --git a/examples/bdk_sqlx_postgres.rs b/examples/bdk_sqlx_postgres.rs index 0961e0b..439a8a4 100644 --- a/examples/bdk_sqlx_postgres.rs +++ b/examples/bdk_sqlx_postgres.rs @@ -1,14 +1,17 @@ #![allow(unused)] -use std::collections::HashSet; -use std::io::Write; - +use anyhow::Context; use bdk_electrum::{electrum_client, BdkElectrumClient}; use bdk_sqlx::sqlx::Postgres; use bdk_sqlx::{PgStoreBuilder, Store}; use bdk_wallet::bitcoin::secp256k1::Secp256k1; -use bdk_wallet::bitcoin::Network; -use bdk_wallet::{KeychainKind, PersistedWallet, Wallet}; +use bdk_wallet::bitcoin::{constants, Network}; +use bdk_wallet::chain::keychain_txout::{KeychainTxOutIndex, DEFAULT_LOOKAHEAD}; +use bdk_wallet::chain::local_chain::LocalChain; +use bdk_wallet::chain::{keychain_txout, IndexedTxGraph}; +use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Update, Wallet}; use rustls::crypto::ring::default_provider; +use std::collections::HashSet; +use std::io::Write; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::EnvFilter; @@ -122,28 +125,10 @@ async fn main() -> anyhow::Result<()> { 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)?; - + let request = wallet.start_full_scan().build(); + let res = client + .full_scan::<_>(request, STOP_GAP, BATCH_SIZE, true) + .context("scanning the blockchain")?; + wallet.apply_update(res)?; Ok(()) } diff --git a/examples/regtest_bdk_sqlx_postgres.rs b/examples/regtest_bdk_sqlx_postgres.rs new file mode 100644 index 0000000..a5ad30c --- /dev/null +++ b/examples/regtest_bdk_sqlx_postgres.rs @@ -0,0 +1,699 @@ +#![allow(unused)] +use anyhow::{bail, Context}; +use bdk_electrum::electrum_client::ElectrumApi; +use bdk_electrum::{electrum_client, BdkElectrumClient}; +use bdk_sqlx::sqlx::Postgres; +use bdk_sqlx::{PgStoreBuilder, Store}; +use bdk_wallet::bitcoin::consensus::Encodable; +use bdk_wallet::bitcoin::secp256k1::Secp256k1; +use bdk_wallet::bitcoin::{constants, Address, Amount, FeeRate, Network}; +use bdk_wallet::chain::keychain_txout::{KeychainTxOutIndex, DEFAULT_LOOKAHEAD}; +use bdk_wallet::chain::local_chain::LocalChain; +use bdk_wallet::chain::{keychain_txout, ChainPosition, IndexedTxGraph}; +use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, SignOptions, Update, Wallet}; +use rustls::crypto::ring::default_provider; +use std::collections::HashSet; +use std::io::Write; +use std::time::Instant; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::EnvFilter; + +// Create and persist a BDK wallet to postgres. + +// wallet 1 +pub const DESCRIPTOR: &str = "tr(tprv8ZgxMBicQKsPdDArR4xSAECuVxeX1jwwSXR4ApKbkYgZiziDc4LdBy2WvJeGDfUSE4UT4hHhbgEwbdq8ajjUHiKDegkwrNU6V55CxcxonVN/0/*)"; +pub const CHANGE_DESCRIPTOR: &str = "tr(tprv8ZgxMBicQKsPdDArR4xSAECuVxeX1jwwSXR4ApKbkYgZiziDc4LdBy2WvJeGDfUSE4UT4hHhbgEwbdq8ajjUHiKDegkwrNU6V55CxcxonVN/1/*)"; +pub const _FIRST_TR_ADDRESS: &str = + "bcrt1prdfvfk2ddxe8y88qxhwkxn9cy0d2w6k98gj9smwz6tqjcnl4tdwsrf03aw"; + +// wallet 2 +const VAULT_DESC: &str = +"wsh(andor(multi(2,[a0d3c79c/48'/1'/79'/2']tpubDEsGdqFaKUVnVNZZw8AixJ8C3yD8o6nN7hsdLfbtVRDTk3PNrQ2pcWNWNbxhdcNSgQP25pUpgRQ7qiVtN3YvSzACKizrvzSwH9SQ2Bjbbwt/0/*,[ea2484f9/48'/1'/79'/2']tpubDFjkswBXoRHKkvmHsxv4xdDqbjg1peX9zJytLeSLbXuwVgYhXgbABzC2r5MAWxqWoaUr7hWGW5TPjA9sNvxa3mX6DrNBdynDsEvwDoXGFpm/0/*,[93f245d7/48'/1'/79'/2']tpubDEVnR72gRgTsqaPFMacV6fCfaSEe56gcDomuGhk9MFeUdEi18riJCokgsZr2x1KKGRM59TJ4AQ6FuNun3khh95ceoH2ytN13nVD7yDLP5LJ/0/*),or_i(and_v(v:pkh([61cdf766/48'/1'/79'/2']tpubDEXETCw2WurhazfW5gW1z4njP6yLXDQmCGfjWGP5k3BuTQ5iZqovMr1zz1zWPhDMRn11hXGpZHodus1LysXnwREsD1ig96M24JhQCpPPpf6/0/*),after(1753228800)),thresh(2,pk([39bf48a9/48'/1'/0'/2']tpubDEr9rVFQbT1keErwxb6GuGy3RM6TEACSkFxBgziUvrDprYuM1Wm7wi6jb1gcaLrSgk6MSkGx84dS2kQQwJKxGRJ59rAvmuKTU7E3saHJLf5/0/*),s:pk([9467fdb3/48'/1'/0'/2']tpubDFEjX5BY88AbWpshPwGscwgKLtcCjeVodMbmhS6D6cbz1eGNUs3546ephbVmbHpxEhbCDrezGmFBArLxBKzPEfBcBdzQuncPm8ww2xa6UUQ/0/*),s:pk([01adf45e/48'/1'/0'/2']tpubDFPYZPeShApyWndvDUtpLSjDHGYK4tTT4BkMyTukGqbP9AXQeQhiWsbwEzyZhxgud9ZPew1FPsoLbWjfnE3veSXLeU4ViofrhVAHNXtjQWE/0/*),snl:after(1739836800))),and_v(v:thresh(2,pkh([39bf48a9/48'/1'/0'/2']tpubDEr9rVFQbT1keErwxb6GuGy3RM6TEACSkFxBgziUvrDprYuM1Wm7wi6jb1gcaLrSgk6MSkGx84dS2kQQwJKxGRJ59rAvmuKTU7E3saHJLf5/2/*),a:pkh([9467fdb3/48'/1'/0'/2']tpubDFEjX5BY88AbWpshPwGscwgKLtcCjeVodMbmhS6D6cbz1eGNUs3546ephbVmbHpxEhbCDrezGmFBArLxBKzPEfBcBdzQuncPm8ww2xa6UUQ/2/*),a:pkh([01adf45e/48'/1'/0'/2']tpubDFPYZPeShApyWndvDUtpLSjDHGYK4tTT4BkMyTukGqbP9AXQeQhiWsbwEzyZhxgud9ZPew1FPsoLbWjfnE3veSXLeU4ViofrhVAHNXtjQWE/2/*)),after(1757116800))))"; +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::Regtest; +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( + |_| { + "sqlx=warn,\ + bdk_sqlx=debug,trace" + .into() + }, + ))) + .with(tracing_subscriber::fmt::layer()) + .try_init()?; + + let url = std::env::var("DATABASE_URL").expect("must set DATABASE_URL"); + let electrum_url = + std::env::var("ELECTRUM_URL").unwrap_or_else(|_| "localhost:50001".to_string()); + + // Load wallet 1 + sync with electrum + let secp = Secp256k1::new(); + let wallet_name = bdk_wallet::wallet_name_from_descriptor( + DESCRIPTOR, + Some(CHANGE_DESCRIPTOR), + NETWORK, + &secp, + )?; + + let mut store = PgStoreBuilder::new(wallet_name.clone()) + .network(NETWORK) + .migrate(true) + .build_with_url(&url) + .await?; + + let mut wallet = match Wallet::load().load_wallet_async(&mut store).await? { + Some(wallet) => wallet, + None => { + let mut wallet = Wallet::create(DESCRIPTOR, CHANGE_DESCRIPTOR) + .network(Network::Regtest) + .create_wallet_async(&mut store) + .await?; + println!( + "Descriptor: {}", + wallet.public_descriptor(KeychainKind::External) + ); + wallet + } + }; + + print!("Syncing..."); + electrum(&mut wallet, &electrum_url)?; + let _ = wallet.persist_async(&mut store).await?; + + println!("Balance {}", wallet.balance().total().display_dynamic()); + + // Transaction demonstration + println!("\n=== Transaction Demonstration ==="); + + // List existing transactions + list_transactions(&wallet); + + // Create and sign a transaction with RBF if we have funds + let balance = wallet.balance(); + if balance.total() > Amount::from_sat(10_000) { + match create_rbf_transaction(&mut wallet, &electrum_url).await { + Ok(txid) => { + println!("\nCreated transaction: {}", txid); + // Persist wallet state after creating transaction + wallet.persist_async(&mut store).await?; + + // Sync again to see the new transaction + println!("\nSyncing to see new transaction..."); + electrum(&mut wallet, &electrum_url)?; + wallet.persist_async(&mut store).await?; + + // List transactions again to show the new one + list_transactions(&wallet); + + // Verify persistence by loading a new wallet instance + println!("\n=== Verifying Persistence ==="); + verify_transaction_persisted(&url, &wallet_name, txid).await?; + + // List UTXOs and demonstrate coin control + println!("\n=== UTXO Management ==="); + let utxos_before = wallet.list_unspent().count(); + println!("Total UTXOs before second transaction: {}", utxos_before); + + if let Some(selected_utxo) = list_and_select_utxos(&wallet) { + // Create a valid transaction using coin control + println!("\n=== Coin Control Transaction ==="); + match create_coincontrol_transaction(&mut wallet, selected_utxo, &electrum_url) + .await + { + Ok(txid2) => { + println!("\nSuccessfully created second transaction: {}", txid2); + wallet.persist_async(&mut store).await?; + + // Sync and show the result + println!("\nSyncing after second transaction..."); + electrum(&mut wallet, &electrum_url)?; + wallet.persist_async(&mut store).await?; + + let utxos_after = wallet.list_unspent().count(); + println!("\nTotal UTXOs after second transaction: {}", utxos_after); + println!( + "UTXO count changed from {} to {}", + utxos_before, utxos_after + ); + + // Now demonstrate double-spend attempt + println!("\n=== Double-Spend Attempt ==="); + println!( + "Attempting to spend the same UTXO again (this should fail)..." + ); + + match create_double_spend_transaction( + &mut wallet, + selected_utxo, + &electrum_url, + ) + .await + { + Ok(_) => { + println!("Double-spend succeeded!"); + } + Err(e) => { + println!("āœ“ Double-spend correctly failed: {}", e); + } + } + } + Err(e) => { + bail!("Failed to create second transaction: {}", e); + } + } + } + } + Err(e) => bail!("Failed to create transaction: {}", e), + } + } else { + println!("\nInsufficient balance to create a transaction. Need at least 10,000 sats."); + bail!("Current balance: {} sats", balance.total().to_sat()); + } + + // Load wallet 2 + let wallet_name = + bdk_wallet::wallet_name_from_descriptor(VAULT_DESC, Some(CHANGE_DESC), NETWORK, &secp)?; + + let mut store = PgStoreBuilder::new(wallet_name.clone()) + .network(NETWORK) + .migrate(true) + .build_with_url(&url) + .await?; + + let mut wallet = match Wallet::load().load_wallet_async(&mut store).await? { + Some(wallet) => wallet, + None => { + let wallet = Wallet::create(VAULT_DESC, CHANGE_DESC) + .network(Network::Regtest) + .create_wallet_async(&mut store) + .await?; + println!( + "Descriptor: {}", + wallet.public_descriptor(KeychainKind::External) + ); + wallet + } + }; + + let addr = wallet.reveal_next_address(KeychainKind::External); + wallet.persist_async(&mut store).await?; + + println!( + "2nd wallet address ({:?} {}) {}", + addr.keychain, addr.index, addr.address, + ); + + // Run SPK cache performance test + test_spk_cache_performance(&url).await?; + + Ok(()) +} + +fn electrum( + wallet: &mut PersistedWallet>, + electrum_url: &str, +) -> anyhow::Result<()> { + let client = BdkElectrumClient::new(electrum_client::Client::new(electrum_url)?); + let request = wallet.start_full_scan().build(); + let res = client.full_scan::<_>(request, STOP_GAP, BATCH_SIZE, true)?; + wallet.apply_update(res)?; + Ok(()) +} + +fn list_transactions(wallet: &PersistedWallet>) { + println!("\n--- Wallet Transactions ---"); + + let transactions = wallet.transactions(); + let mut tx_count = 0; + let mut displayed_count = 0; + + for tx in transactions { + tx_count += 1; + + // Skip the first 100 transactions (mining transactions) + if tx_count <= 100 { + continue; + } + + displayed_count += 1; + let txid = tx.tx_node.txid; + let tx_ref = tx.tx_node.tx.as_ref(); + + // Get sent and received amounts + let (sent, received) = wallet.sent_and_received(tx_ref); + let fee = wallet.calculate_fee(tx_ref); + + // Check confirmation status + match tx.chain_position { + ChainPosition::Confirmed { ref anchor, .. } => { + println!("\nTransaction: {}", txid); + println!(" Status: Confirmed at height {}", anchor.block_id.height); + println!(" Sent: {} sats", sent.to_sat()); + println!(" Received: {} sats", received.to_sat()); + if let Ok(fee_amount) = &fee { + println!(" Fee: {} sats", fee_amount.to_sat()); + } + } + ChainPosition::Unconfirmed { last_seen, .. } => { + println!("\nTransaction: {}", txid); + println!(" Status: Unconfirmed"); + println!(" Sent: {} sats", sent.to_sat()); + println!(" Received: {} sats", received.to_sat()); + if let Ok(fee_amount) = &fee { + println!(" Fee: {} sats", fee_amount.to_sat()); + } + } + } + } + + if displayed_count == 0 { + if tx_count > 100 { + println!("No non-mining transactions found."); + } else { + println!( + "No transactions found beyond the initial {} mining transactions.", + tx_count + ); + } + } else { + println!( + "\nTotal non-mining transactions: {} (skipped first 100 mining transactions)", + displayed_count + ); + } +} + +async fn create_rbf_transaction( + wallet: &mut PersistedWallet>, + electrum_url: &str, +) -> anyhow::Result { + println!("\n--- Creating RBF Transaction ---"); + + // Get a fresh address to send to (for demo purposes, sending to ourselves) + let recipient_address = wallet.reveal_next_address(KeychainKind::External); + println!("Recipient address: {}", recipient_address.address); + + // Build the transaction + let amount_to_send = Amount::from_sat(5_000); // Send 5000 sats + let fee_rate = FeeRate::from_sat_per_vb(2).expect("valid fee rate"); // 2 sat/vbyte + + let mut tx_builder = wallet.build_tx(); + tx_builder + .include_output_redeem_witness_script() + .add_recipient(recipient_address.address.script_pubkey(), amount_to_send) + .fee_rate(fee_rate); + //.enable_rbf(); // Replace-By-Fee enabled by default + + println!("Building transaction with RBF enabled..."); + println!(" Amount: {} sats", amount_to_send.to_sat()); + println!(" Fee rate: {} sat/vbyte", fee_rate.to_sat_per_vb_ceil()); + + // Finish building and get the PSBT + let mut psbt = tx_builder.finish()?; + + // Sign the transaction + println!("\nSigning transaction..."); + let finalized = wallet.sign(&mut psbt, SignOptions::default())?; + + let tx = psbt.clone().extract_tx()?; + let txid = tx.clone().compute_txid(); + + if !finalized { + bail!("Failed to finalize transaction"); + } + + // Extract the signed transaction + // let tx = psbt.extract_tx()?; + // let txid = tx.compute_txid(); + + println!("Transaction signed successfully!"); + println!("Transaction ID: {}", txid); + + // Broadcast the transaction + println!("\nBroadcasting transaction..."); + let client = electrum_client::Client::new(electrum_url)?; + let mut tx_raw = Vec::new(); + tx.consensus_encode(&mut tx_raw).expect("can encode tx"); + match client.transaction_broadcast_raw(&tx_raw) { + Ok(_) => { + println!("Transaction broadcast successfully!"); + Ok(txid) + } + Err(e) => { + println!("Failed to broadcast: {}", e); + bail!("Broadcast failed: {}", e) + } + } +} + +async fn verify_transaction_persisted( + url: &str, + wallet_name: &str, + txid: bdk_wallet::bitcoin::Txid, +) -> anyhow::Result<()> { + println!("Loading a new wallet instance from persistence..."); + + // Create a new store connection + let mut store = PgStoreBuilder::new(wallet_name.to_string()) + .network(NETWORK) + .migrate(false) // Don't migrate, just connect + .build_with_url(url) + .await?; + + // Load the wallet from persistence + let wallet = match Wallet::load().load_wallet_async(&mut store).await? { + Some(wallet) => wallet, + None => { + bail!("Failed to load wallet from persistence"); + } + }; + + println!("Wallet loaded successfully from persistence!"); + + // Check if the transaction exists in the loaded wallet + if let Some(tx_details) = wallet.get_tx(txid) { + println!("\nāœ“ Transaction found in persisted wallet!"); + println!(" Transaction ID: {}", txid); + println!(" Chain position: {:?}", tx_details.chain_position); + + let (sent, received) = wallet.sent_and_received(&tx_details.tx_node.tx); + println!(" Sent: {} sats", sent.to_sat()); + println!(" Received: {} sats", received.to_sat()); + + if let Ok(fee) = wallet.calculate_fee(&tx_details.tx_node.tx) { + println!(" Fee: {} sats", fee.to_sat()); + } + } else { + println!("\nāœ— Transaction NOT found in persisted wallet!"); + bail!("Transaction not found after persistence"); + } + + Ok(()) +} + +fn list_and_select_utxos( + wallet: &PersistedWallet>, +) -> Option { + println!("Listing available UTXOs..."); + + let mut utxos: Vec<_> = wallet.list_unspent().collect(); + + if utxos.is_empty() { + println!("No UTXOs available"); + return None; + } + + // Sort UTXOs by confirmation height (oldest first) + utxos.sort_by(|a, b| { + match (&a.chain_position, &b.chain_position) { + ( + ChainPosition::Confirmed { + anchor: a_anchor, .. + }, + ChainPosition::Confirmed { + anchor: b_anchor, .. + }, + ) => a_anchor.block_id.height.cmp(&b_anchor.block_id.height), + (ChainPosition::Confirmed { .. }, ChainPosition::Unconfirmed { .. }) => { + std::cmp::Ordering::Less // Confirmed comes before unconfirmed + } + (ChainPosition::Unconfirmed { .. }, ChainPosition::Confirmed { .. }) => { + std::cmp::Ordering::Greater // Unconfirmed comes after confirmed + } + (ChainPosition::Unconfirmed { .. }, ChainPosition::Unconfirmed { .. }) => { + std::cmp::Ordering::Equal // Both unconfirmed, treat as equal + } + } + }); + + println!( + "\nFound {} UTXOs (sorted by age, oldest first):", + utxos.len() + ); + + for (index, utxo) in utxos.iter().enumerate() { + println!("\nUTXO #{}:", index + 1); + println!(" Outpoint: {}", utxo.outpoint); + println!(" Value: {} sats", utxo.txout.value.to_sat()); + println!(" Keychain: {:?}", utxo.keychain); + println!(" Derivation index: {}", utxo.derivation_index); + println!(" Confirmed: {}", utxo.chain_position.is_confirmed()); + + if let ChainPosition::Confirmed { anchor, .. } = &utxo.chain_position { + println!(" Block height: {}", anchor.block_id.height); + } + } + + // Select the oldest UTXO (first after sorting) + let selected = utxos.first().map(|utxo| utxo.outpoint); + + if let Some(outpoint) = selected { + println!("\n→ Selected oldest UTXO: {}", outpoint); + } + + selected +} + +async fn create_coincontrol_transaction( + wallet: &mut PersistedWallet>, + utxo_to_spend: bdk_wallet::bitcoin::OutPoint, + electrum_url: &str, +) -> anyhow::Result { + println!("Creating transaction with coin control..."); + println!("Attempting to spend UTXO: {}", utxo_to_spend); + + // Get a fresh address + let recipient_address = wallet.reveal_next_address(KeychainKind::External); + println!("Recipient address: {}", recipient_address.address); + + // Build transaction using only the selected UTXO + let amount_to_send = Amount::from_sat(1_000); // Send 1000 sats + let fee_rate = FeeRate::from_sat_per_vb(2).expect("valid fee rate"); + + let mut tx_builder = wallet.build_tx(); + tx_builder + .add_utxo(utxo_to_spend)? // Add specific UTXO + .manually_selected_only() // ONLY use manually selected UTXOs + .add_recipient(recipient_address.address.script_pubkey(), amount_to_send) + .fee_rate(fee_rate); + + println!("Building transaction with coin control..."); + println!(" Selected UTXO: {}", utxo_to_spend); + println!(" Amount: {} sats", amount_to_send.to_sat()); + println!(" Fee rate: {} sat/vbyte", fee_rate.to_sat_per_vb_ceil()); + + // Try to finish building + let mut psbt = tx_builder.finish()?; + + // Sign the transaction + println!("\nSigning transaction..."); + let finalized = wallet.sign(&mut psbt, SignOptions::default())?; + + if !finalized { + bail!("Failed to finalize transaction"); + } + + let tx = psbt.extract_tx().expect("valid tx"); + let txid = tx.compute_txid(); + + println!("Transaction signed successfully!"); + println!("Transaction ID: {}", txid); + + // Try to broadcast (this should fail if spending already spent coins) + println!("\nBroadcasting transaction..."); + let client = electrum_client::Client::new(electrum_url)?; + let mut tx_raw = Vec::new(); + tx.consensus_encode(&mut tx_raw).expect("can encode tx"); + + match client.transaction_broadcast_raw(&tx_raw) { + Ok(_) => { + println!("Transaction broadcast successfully!"); + Ok(txid) + } + Err(e) => { + println!("Broadcast failed (expected for double-spend): {}", e); + bail!("Broadcast failed: {}", e) + } + } +} + +async fn create_double_spend_transaction( + wallet: &mut PersistedWallet>, + utxo_to_spend: bdk_wallet::bitcoin::OutPoint, + electrum_url: &str, +) -> anyhow::Result { + println!("Attempting to create a double-spend transaction..."); + println!( + "Trying to spend UTXO: {} (which was already spent)", + utxo_to_spend + ); + + // Get a fresh address + let recipient_address = wallet.reveal_next_address(KeychainKind::External); + + // Try to build transaction using the already-spent UTXO + let amount_to_send = Amount::from_sat(500); // Different amount + let fee_rate = FeeRate::from_sat_per_vb(5).expect("valid fee rate"); // Higher fee + + let mut tx_builder = wallet.build_tx(); + tx_builder + .add_utxo(utxo_to_spend)? // Try to add the already-spent UTXO + .manually_selected_only() + .add_recipient(recipient_address.address.script_pubkey(), amount_to_send) + .fee_rate(fee_rate); + + // This should fail because the UTXO is already spent + match tx_builder.finish() { + Ok(mut psbt) => { + // If we somehow got here, try to sign and broadcast + println!("WARNING: Transaction building succeeded when it should have failed!"); + + let finalized = wallet.sign(&mut psbt, SignOptions::default())?; + if !finalized { + bail!("Failed to finalize double-spend transaction"); + } + + let tx = psbt.extract_tx().expect("valid tx"); + let txid = tx.compute_txid(); + + // Try to broadcast + let client = electrum_client::Client::new(electrum_url)?; + let mut tx_raw = Vec::new(); + tx.consensus_encode(&mut tx_raw).expect("can encode tx"); + + match client.transaction_broadcast_raw(&tx_raw) { + Ok(_) => Ok(txid), + Err(e) => bail!("Broadcast failed: {}", e), + } + } + Err(e) => { + // This is the expected path + bail!("Transaction building failed (expected): {}", e) + } + } +} + +async fn test_spk_cache_performance(url: &str) -> anyhow::Result<()> { + println!("\n\n=== SPK Cache Performance Test ==="); + println!("Testing performance with 3000 derived addresses...\n"); + + let secp = Secp256k1::new(); + let wallet_name = "spk_cache_test_wallet"; + + // Create a fresh store for this test + let mut store = PgStoreBuilder::new(wallet_name.to_string()) + .network(NETWORK) + .migrate(true) + .build_with_url(url) + .await?; + + // Step 1: Create wallet and derive 3000 addresses + println!("1. Creating wallet and deriving 3000 addresses for each keychain..."); + let start = Instant::now(); + + let mut wallet = Wallet::create(DESCRIPTOR, CHANGE_DESCRIPTOR) + .network(NETWORK) + .use_spk_cache(true) + .create_wallet_async(&mut store) + .await?; + + // Derive 3000 addresses for external keychain + let external_addresses: Vec<_> = wallet + .reveal_addresses_to(KeychainKind::External, 2999) + .collect(); + println!( + " Generated {} external addresses", + external_addresses.len() + ); + + // Derive 3000 addresses for internal keychain + let internal_addresses: Vec<_> = wallet + .reveal_addresses_to(KeychainKind::Internal, 2999) + .collect(); + println!( + " Generated {} internal addresses", + internal_addresses.len() + ); + + let derivation_time = start.elapsed(); + println!(" Address derivation took: {:?}", derivation_time); + + // Step 2: Persist the wallet with all derived addresses + println!("\n2. Persisting wallet to database..."); + let persist_start = Instant::now(); + wallet.persist_async(&mut store).await?; + let persist_time = persist_start.elapsed(); + println!(" Persistence took: {:?}", persist_time); + + // Step 3: Drop the wallet and reload from database + println!("\n3. Loading wallet from database (this regenerates all SPKs)..."); + drop(wallet); + drop(store); + + // Create a new store instance + let mut store = PgStoreBuilder::new(wallet_name.to_string()) + .network(NETWORK) + .migrate(false) // Don't migrate, just connect + .build_with_url(url) + .await?; + + // Measure loading time + let load_start = Instant::now(); + let loaded_wallet = match Wallet::load() + .use_spk_cache(true) + .load_wallet_async(&mut store) + .await? + { + Some(wallet) => wallet, + None => { + bail!("Failed to load wallet from persistence"); + } + }; + let load_time = load_start.elapsed(); + println!(" Loading took: {:?}", load_time); + + // Verify the addresses were loaded correctly + let last_revealed_external = loaded_wallet + .derivation_index(KeychainKind::External) + .expect("external keychain should exist"); + let last_revealed_internal = loaded_wallet + .derivation_index(KeychainKind::Internal) + .expect("internal keychain should exist"); + + println!("\n4. Verification:"); + println!( + " Last revealed external index: {}", + last_revealed_external + ); + println!( + " Last revealed internal index: {}", + last_revealed_internal + ); + + // Summary + println!("\n=== Performance Summary ==="); + println!("Address derivation time: {:?}", derivation_time); + println!("Database persistence time: {:?}", persist_time); + println!( + "Wallet loading time (with SPK regeneration): {:?}", + load_time + ); + println!( + "Total addresses regenerated on load: {}", + (last_revealed_external + 1) + (last_revealed_internal + 1) + ); + + Ok(()) +} diff --git a/migrations/postgres/01_bdk_wallet.sql b/migrations/postgres/01_bdk_wallet.sql index 91242c3..2cf0488 100644 --- a/migrations/postgres/01_bdk_wallet.sql +++ b/migrations/postgres/01_bdk_wallet.sql @@ -22,7 +22,19 @@ CREATE TABLE IF NOT EXISTS bdk_wallet.keychain ( descriptor TEXT NOT NULL, descriptor_id BYTEA NOT NULL, last_revealed INTEGER DEFAULT 0, - PRIMARY KEY (wallet_name, keychainkind) + PRIMARY KEY (wallet_name, keychainkind), + UNIQUE (wallet_name, descriptor_id) +); + +-- Script pubkey cache for keychains +-- Stores precomputed script pubkeys for each keychain index +CREATE TABLE IF NOT EXISTS bdk_wallet.keychain_spk ( + wallet_name TEXT NOT NULL, + descriptor_id BYTEA NOT NULL, + idx INTEGER NOT NULL, + script BYTEA NOT NULL, + PRIMARY KEY (wallet_name, descriptor_id, idx), + FOREIGN KEY (wallet_name, descriptor_id) REFERENCES bdk_wallet.keychain(wallet_name, descriptor_id) ); -- Hash is block hash hex string, @@ -43,6 +55,8 @@ CREATE TABLE IF NOT EXISTS bdk_wallet.tx ( txid TEXT NOT NULL, whole_tx BYTEA, last_seen BIGINT, + last_evicted BIGINT, + first_seen BIGINT, PRIMARY KEY (wallet_name, txid) ); diff --git a/src/postgres.rs b/src/postgres.rs index d4acec8..53c616b 100644 --- a/src/postgres.rs +++ b/src/postgres.rs @@ -4,6 +4,7 @@ // Standard library imports use std::{ + collections::BTreeMap, str::FromStr, sync::{Arc, OnceLock}, }; @@ -236,7 +237,8 @@ impl Store { descriptor TEXT NOT NULL, descriptor_id BYTEA NOT NULL, last_revealed INTEGER DEFAULT 0, - PRIMARY KEY (wallet_name, keychainkind) + PRIMARY KEY (wallet_name, keychainkind), + UNIQUE (wallet_name, descriptor_id) )"#, r#"CREATE TABLE IF NOT EXISTS "bdk_wallet"."block" ( wallet_name TEXT NOT NULL, @@ -250,6 +252,8 @@ impl Store { txid TEXT NOT NULL, whole_tx BYTEA, last_seen BIGINT, + last_evicted BIGINT, + first_seen BIGINT, PRIMARY KEY (wallet_name, txid) )"#, r#"CREATE TABLE IF NOT EXISTS "bdk_wallet"."txout" ( @@ -270,6 +274,14 @@ impl Store { FOREIGN KEY (wallet_name, txid) REFERENCES "bdk_wallet"."tx"(wallet_name, txid) )"#, r#"CREATE INDEX IF NOT EXISTS idx_anchor_tx_txid ON "bdk_wallet"."anchor_tx" (txid)"#, + r#"CREATE TABLE IF NOT EXISTS "bdk_wallet"."keychain_spk" ( + wallet_name TEXT NOT NULL, + descriptor_id BYTEA NOT NULL, + idx INTEGER NOT NULL, + script BYTEA NOT NULL, + PRIMARY KEY (wallet_name, descriptor_id, idx), + FOREIGN KEY (wallet_name, descriptor_id) REFERENCES "bdk_wallet"."keychain"(wallet_name, descriptor_id) + )"#, ]; // Execute each query separately @@ -283,14 +295,103 @@ impl Store { })?; } - // At the end of migration, insert the current version - // After all tables are created but before tx.commit() + // Check current schema version and apply migrations if needed + let current_version: Option = sqlx::query_scalar( + r#"SELECT version FROM "bdk_wallet"."version" ORDER BY version DESC LIMIT 1"#, + ) + .fetch_optional(&mut *tx) + .await + .map_err(|e| BdkSqlxError::QueryError { + table: "select version".to_string(), + source: e, + })?; + + match current_version { + Some(1) => { + // Migrate from v1 to v2: Add last_evicted and first_seen columns + sqlx::query( + r#"ALTER TABLE "bdk_wallet"."tx" + ADD COLUMN IF NOT EXISTS last_evicted BIGINT, + ADD COLUMN IF NOT EXISTS first_seen BIGINT"#, + ) + .execute(&mut *tx) + .await + .map_err(|e| BdkSqlxError::QueryError { + table: "alter tx table".to_string(), + source: e, + })?; + + // Also add unique constraint and keychain_spk table for v2 + sqlx::query( + r#"ALTER TABLE "bdk_wallet"."keychain" + ADD CONSTRAINT keychain_wallet_descriptor_unique + UNIQUE (wallet_name, descriptor_id)"#, + ) + .execute(&mut *tx) + .await + .map_err(|e| BdkSqlxError::QueryError { + table: "add unique constraint to keychain".to_string(), + source: e, + })?; + + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS "bdk_wallet"."keychain_spk" ( + wallet_name TEXT NOT NULL, + descriptor_id BYTEA NOT NULL, + idx INTEGER NOT NULL, + script BYTEA NOT NULL, + PRIMARY KEY (wallet_name, descriptor_id, idx), + FOREIGN KEY (wallet_name, descriptor_id) REFERENCES "bdk_wallet"."keychain"(wallet_name, descriptor_id) + )"#, + ) + .execute(&mut *tx) + .await + .map_err(|e| BdkSqlxError::QueryError { + table: "create keychain_spk table".to_string(), + source: e, + })?; + } + Some(2) => { + // Migrate from v2 to v3: Add unique constraint and keychain_spk table + sqlx::query( + r#"ALTER TABLE "bdk_wallet"."keychain" + ADD CONSTRAINT IF NOT EXISTS keychain_wallet_descriptor_unique + UNIQUE (wallet_name, descriptor_id)"#, + ) + .execute(&mut *tx) + .await + .map_err(|e| BdkSqlxError::QueryError { + table: "add unique constraint to keychain".to_string(), + source: e, + })?; + + sqlx::query( + r#"CREATE TABLE IF NOT EXISTS "bdk_wallet"."keychain_spk" ( + wallet_name TEXT NOT NULL, + descriptor_id BYTEA NOT NULL, + idx INTEGER NOT NULL, + script BYTEA NOT NULL, + PRIMARY KEY (wallet_name, descriptor_id, idx), + FOREIGN KEY (wallet_name, descriptor_id) REFERENCES "bdk_wallet"."keychain"(wallet_name, descriptor_id) + )"#, + ) + .execute(&mut *tx) + .await + .map_err(|e| BdkSqlxError::QueryError { + table: "create keychain_spk table".to_string(), + source: e, + })?; + } + _ => {} // Fresh install or already at v3 + } + + // Insert or update to current version sqlx::query( r#"INSERT INTO "bdk_wallet"."version" (version) VALUES ($1) ON CONFLICT (version) DO NOTHING"#, ) - .bind(1) // Current schema version + .bind(3) // Current schema version is now 3 .execute(&mut *tx) .await .map_err(|e| BdkSqlxError::QueryError { @@ -365,6 +466,8 @@ impl Store { if let Some(last_rev) = external_last_revealed { changeset.indexer.last_revealed.insert(did, last_rev as u32); } + // Load SPK cache for external descriptor + load_keychain_spks(db_tx, wallet_name, did, changeset).await?; } if let Some(desc_str) = internal_desc_str { @@ -374,6 +477,8 @@ impl Store { if let Some(last_rev) = internal_last_revealed { changeset.indexer.last_revealed.insert(did, last_rev as u32); } + // Load SPK cache for internal descriptor + load_keychain_spks(db_tx, wallet_name, did, changeset).await?; } changeset.tx_graph = tx_graph_changeset_from_postgres(db_tx, wallet_name).await?; @@ -410,6 +515,14 @@ impl Store { } } + // Persist SPK cache + let spk_cache = &changeset.indexer.spk_cache; + if !spk_cache.is_empty() { + for (desc_id, spks) in spk_cache { + persist_keychain_spks(&mut tx, wallet_name, *desc_id, spks).await?; + } + } + local_chain_changeset_persist_to_postgres(&mut tx, wallet_name, &changeset.local_chain) .await?; tx_graph_changeset_persist_to_postgres(&mut tx, wallet_name, &changeset.tx_graph).await?; @@ -501,6 +614,99 @@ async fn update_last_revealed( Ok(()) } +/// Load keychain script pubkeys from the database +#[tracing::instrument(skip(db_tx, changeset))] +async fn load_keychain_spks( + db_tx: &mut Transaction<'_, Postgres>, + wallet_name: &str, + descriptor_id: DescriptorId, + changeset: &mut ChangeSet, +) -> Result<()> { + let start_time = std::time::Instant::now(); + trace!("load keychain spks - starting"); + println!( + "load_keychain_spks: Starting for wallet '{}', descriptor_id: {:?}", + wallet_name, descriptor_id + ); + + let query_start = std::time::Instant::now(); + let rows = sqlx::query( + r#"SELECT idx, script FROM "bdk_wallet"."keychain_spk" + WHERE wallet_name = $1 AND descriptor_id = $2 + ORDER BY idx"#, + ) + .bind(wallet_name) + .bind(descriptor_id.to_byte_array()) + .fetch_all(&mut **db_tx) + .await + .map_err(|e| BdkSqlxError::QueryError { + table: "select keychain_spk".to_string(), + source: e, + })?; + let query_duration = query_start.elapsed(); + println!( + "load_keychain_spks: Query completed in {:?}, fetched {} rows", + query_duration, + rows.len() + ); + + if !rows.is_empty() { + let processing_start = std::time::Instant::now(); + let mut spks = BTreeMap::new(); + for row in rows { + let idx: i32 = row.get("idx"); + let script: Vec = row.get("script"); + spks.insert(idx as u32, ScriptBuf::from_bytes(script)); + } + changeset.indexer.spk_cache.insert(descriptor_id, spks); + let processing_duration = processing_start.elapsed(); + println!( + "load_keychain_spks: Processing rows completed in {:?}", + processing_duration + ); + } + + let total_duration = start_time.elapsed(); + println!( + "load_keychain_spks: Total function execution time: {:?}", + total_duration + ); + trace!("load keychain spks - completed in {:?}", total_duration); + + Ok(()) +} + +/// Persist keychain script pubkeys to the database +#[tracing::instrument(skip(db_tx, spks))] +async fn persist_keychain_spks( + db_tx: &mut Transaction<'_, Postgres>, + wallet_name: &str, + descriptor_id: DescriptorId, + spks: &BTreeMap, +) -> Result<()> { + trace!("persist keychain spks"); + + for (idx, spk) in spks { + sqlx::query( + r#"INSERT INTO "bdk_wallet"."keychain_spk" (wallet_name, descriptor_id, idx, script) + VALUES ($1, $2, $3, $4) + ON CONFLICT (wallet_name, descriptor_id, idx) DO UPDATE SET script = $4"#, + ) + .bind(wallet_name) + .bind(descriptor_id.to_byte_array()) + .bind(*idx as i32) + .bind(spk.as_bytes()) + .execute(&mut **db_tx) + .await + .map_err(|e| BdkSqlxError::QueryError { + table: "insert keychain_spk".to_string(), + source: e, + })?; + } + + Ok(()) +} + /// Select transactions, txouts, and anchors. #[tracing::instrument(skip(db_tx))] pub async fn tx_graph_changeset_from_postgres( @@ -512,7 +718,7 @@ pub async fn tx_graph_changeset_from_postgres( // Fetch transactions let rows = sqlx::query( - r#"SELECT txid, whole_tx, last_seen FROM "bdk_wallet"."tx" WHERE wallet_name = $1"#, + r#"SELECT txid, whole_tx, last_seen, last_evicted, first_seen FROM "bdk_wallet"."tx" WHERE wallet_name = $1"#, ) .bind(wallet_name) .fetch_all(&mut **db_tx) @@ -527,6 +733,8 @@ pub async fn tx_graph_changeset_from_postgres( let txid = Txid::from_str(&txid)?; let whole_tx: Option> = row.get("whole_tx"); let last_seen: Option = row.get("last_seen"); + let _last_evicted: Option = row.get("last_evicted"); + let _first_seen: Option = row.get("first_seen"); if let Some(tx_bytes) = whole_tx { if let Ok(tx) = bitcoin::Transaction::consensus_decode(&mut tx_bytes.as_slice()) { @@ -536,6 +744,8 @@ pub async fn tx_graph_changeset_from_postgres( if let Some(last_seen) = last_seen { changeset.last_seen.insert(txid, last_seen as u64); } + // Note: last_evicted and first_seen are fetched but not yet used in ChangeSet + // These fields are stored for future use when bdk_chain supports them } // Fetch txouts @@ -602,6 +812,8 @@ pub async fn tx_graph_changeset_persist_to_postgres( ) -> Result<()> { trace!("tx graph changeset from postgres"); for tx in &changeset.txs { + // Note: last_evicted and first_seen columns are reserved for future use + // when bdk_chain adds support for these fields in ChangeSet sqlx::query( r#"INSERT INTO "bdk_wallet"."tx" (wallet_name, txid, whole_tx) VALUES ($1, $2, $3) ON CONFLICT (wallet_name, txid) DO UPDATE SET whole_tx = $3"#, @@ -747,7 +959,7 @@ pub async fn local_chain_changeset_persist_to_postgres( /// Collects information on all the wallets in the database and dumps it to stdout. #[tracing::instrument] -pub async fn easy_backup(db: Pool) -> Result<()> { +pub async fn _easy_backup(db: Pool) -> Result<()> { trace!("Starting easy backup"); let statement = r#"SELECT * FROM "bdk_wallet"."keychain""#; diff --git a/src/sqlite.rs b/src/sqlite.rs index 590054b..e59bfd2 100644 --- a/src/sqlite.rs +++ b/src/sqlite.rs @@ -471,7 +471,7 @@ pub async fn local_chain_changeset_persist_to_sqlite( /// Collects information on all the wallets in the database and dumps it to stdout. #[tracing::instrument] -pub async fn easy_backup(db: Pool) -> Result<(), BdkSqlxError> { +pub async fn _easy_backup(db: Pool) -> Result<(), BdkSqlxError> { info!("Starting easy backup"); let statement = "SELECT * FROM keychain";