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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,6 @@ name = "esplora_blocking"

[[example]]
name = "bitcoind_rpc"

[[example]]
name = "bitcoind_rpc_filter"
56 changes: 56 additions & 0 deletions docs/adr/0004_birthday.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Introduce birthday information to the wallet

* Status: TBD
* Authors: @luisschwab
* Date: 2026-02-XX
* Targeted modules: wallet
* Associated tickets/PRs: [#368](https://github.com/bitcoindevkit/bdk_wallet/pull/368)

## Context and Problem Statement

[Describe the context and problem statement, e.g., in free form using two to three sentences. You may want to articulate the problem in form of a question.]

## Decision Drivers <!-- optional -->

* [driver 1, e.g., a force, facing concern, …]
* [driver 2, e.g., a force, facing concern, …]
* … <!-- numbers of drivers can vary -->

## Considered Options <!-- numbers of options can vary -->

#### [Option 1]

[example | description | pointer to more information | …]

**Pros:**

* Good, because [argument …]

**Cons:**

* Bad, because [argument …]

#### [Option 2]
...

#### [Option 3]
...

## Decision Outcome

Chosen option: "[option 1]", because [justification. e.g., only option, which meets k.o. criterion decision driver | which resolves force force | … | comes out best (see below)].

### Positive Consequences <!-- optional -->

* [e.g., improvement of quality attribute satisfaction, follow-up decisions required, …]
* …

### Negative Consequences <!-- optional -->

* [e.g., compromising quality attribute, follow-up decisions required, …]
* …

## Links <!-- optional -->

* [Link type] [Link to ADR] <!-- example: Refined by [ADR-0005](0005-example.md) -->
* … <!-- numbers of links can vary -->
20 changes: 20 additions & 0 deletions examples/bitcoind_rpc_filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#![allow(unused)]

use bdk_chain::BlockId;
use bdk_wallet::rusqlite::Connection;
use bdk_wallet::{
bitcoin::{Block, Network},
KeychainKind, Wallet,
};
use bitcoin::{hashes::Hash, BlockHash};
use clap::{self, Parser};
use std::{
path::PathBuf,
sync::{mpsc::sync_channel, Arc},
thread::spawn,
time::Instant,
};

fn main() -> anyhow::Result<()> {
Ok(())
}
40 changes: 40 additions & 0 deletions src/persist_test_utils.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
//! Utilities for testing custom persistence backends for `bdk_wallet`

use bitcoin::{hashes::Hash, BlockHash};
use chain::BlockId;

use crate::{
bitcoin::{
absolute, key::Secp256k1, transaction, Address, Amount, Network, OutPoint, ScriptBuf,
Expand Down Expand Up @@ -168,6 +171,7 @@ where
descriptor: Some(descriptor.clone()),
change_descriptor: Some(change_descriptor.clone()),
network: Some(Network::Testnet),
birthday: None,
local_chain: local_chain_changeset,
tx_graph: tx_graph_changeset,
indexer: keychain_txout_changeset,
Expand Down Expand Up @@ -227,6 +231,7 @@ where
descriptor: None,
change_descriptor: None,
network: None,
birthday: None,
local_chain: local_chain_changeset,
tx_graph: tx_graph_changeset,
indexer: keychain_txout_changeset,
Expand Down Expand Up @@ -351,6 +356,41 @@ where
assert_eq!(changeset_read.network, Some(Network::Bitcoin));
}

/// Test whether the `birthday` is persisted correctly.
pub fn persist_birthday<Store, CreateStore>(filename: &str, create_store: CreateStore)
where
CreateStore: Fn(&Path) -> anyhow::Result<Store>,
Store: WalletPersister,
Store::Error: Debug,
{
// Create store
let temp_dir = tempfile::tempdir().expect("must create tempdir");
let file_path = temp_dir.path().join(filename);
let mut store = create_store(&file_path).expect("store should get created");

// Initialize store
let changeset = WalletPersister::initialize(&mut store)
.expect("should initialize and load empty changeset");
assert_eq!(changeset, ChangeSet::default());

let birthday = BlockId {
height: 42,
hash: BlockHash::all_zeros(),
};
let changeset = ChangeSet {
birthday: Some(birthday),
..Default::default()
};

WalletPersister::persist(&mut store, &changeset).expect("should persist birthday");

// Load the birthday
let changeset_read =
WalletPersister::initialize(&mut store).expect("should read persisted changeset");

assert_eq!(changeset_read.birthday, Some(birthday));
}

/// tests if descriptors are being persisted correctly
///
/// [`ChangeSet`]: <https://docs.rs/bdk_wallet/latest/bdk_wallet/struct.ChangeSet.html>
Expand Down
64 changes: 59 additions & 5 deletions src/wallet/changeset.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use bdk_chain::{
indexed_tx_graph, keychain_txout, local_chain, tx_graph, ConfirmationBlockTime, Merge,
indexed_tx_graph, keychain_txout, local_chain, tx_graph, BlockId, ConfirmationBlockTime, Merge,
};
use miniscript::{Descriptor, DescriptorPublicKey};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -32,7 +32,7 @@ type IndexedTxGraphChangeSet =
/// instance.
/// * A change set is composed of a number of individual "sub-change sets" that adhere to the same
/// rules as above. This is for increased modularity and portability. For example the core modules
/// each have their own change set (`tx_graph`, `local_chain`, etc).
/// each have their own change set ([`tx_graph`], [`local_chain`], etc).
///
/// ## Members and required fields
///
Expand Down Expand Up @@ -110,6 +110,8 @@ pub struct ChangeSet {
pub change_descriptor: Option<Descriptor<DescriptorPublicKey>>,
/// Stores the network type of the transaction data.
pub network: Option<bitcoin::Network>,
/// Set the wallet's birthday.
pub birthday: Option<BlockId>,
/// Changes to the [`LocalChain`](local_chain::LocalChain).
pub local_chain: local_chain::ChangeSet,
/// Changes to [`TxGraph`](tx_graph::TxGraph).
Expand Down Expand Up @@ -146,6 +148,14 @@ impl Merge for ChangeSet {
self.network = other.network;
}

// Merging birthdays should yield the earliest birthday.
if other.birthday.is_some() {
self.birthday = match (self.birthday, other.birthday) {
(Some(a), Some(b)) => Some(if a.height <= b.height { a } else { b }),
(None, some) | (some, None) => some,
};
}

// merge locked outpoints
self.locked_outpoints.merge(other.locked_outpoints);

Expand All @@ -158,6 +168,7 @@ impl Merge for ChangeSet {
self.descriptor.is_none()
&& self.change_descriptor.is_none()
&& self.network.is_none()
&& self.birthday.is_none()
&& self.local_chain.is_empty()
&& self.tx_graph.is_empty()
&& self.indexer.is_empty()
Expand Down Expand Up @@ -199,12 +210,26 @@ impl ChangeSet {
)
}

/// Get the `v2` sqlite [`ChangeSet`] schema.
///
/// Adds two columns to the wallet table to perist the wallet's birthday from a [`BlockId`]:
/// * `birth_height`: the height of the birthday block, as a `u32`
/// * `birth_hash`: the hash of the birthday block, as a [`BlockHash`]
pub fn schema_v2() -> alloc::string::String {
format!(
"ALTER TABLE {} ADD COLUMN birth_height INTEGER; \
ALTER TABLE {} ADD COLUMN birth_hash TEXT;",
Self::WALLET_TABLE_NAME,
Self::WALLET_TABLE_NAME,
)
}

/// Initialize sqlite tables for wallet tables.
pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> {
crate::rusqlite_impl::migrate_schema(
db_tx,
Self::WALLET_SCHEMA_NAME,
&[&Self::schema_v0(), &Self::schema_v1()],
&[&Self::schema_v0(), &Self::schema_v1(), &Self::schema_v2()],
)?;

bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?;
Expand All @@ -223,7 +248,7 @@ impl ChangeSet {
let mut changeset = Self::default();

let mut wallet_statement = db_tx.prepare(&format!(
"SELECT descriptor, change_descriptor, network FROM {}",
"SELECT descriptor, change_descriptor, network, birth_height, birth_hash FROM {}",
Self::WALLET_TABLE_NAME,
))?;
let row = wallet_statement
Expand All @@ -234,13 +259,22 @@ impl ChangeSet {
"change_descriptor",
)?,
row.get::<_, Option<Impl<bitcoin::Network>>>("network")?,
row.get::<_, Option<u32>>("birth_height")?,
row.get::<_, Option<Impl<bitcoin::BlockHash>>>("birth_hash")?,
))
})
.optional()?;
if let Some((desc, change_desc, network)) = row {
if let Some((desc, change_desc, network, birth_height, birth_hash)) = row {
changeset.descriptor = desc.map(Impl::into_inner);
changeset.change_descriptor = change_desc.map(Impl::into_inner);
changeset.network = network.map(Impl::into_inner);
changeset.birthday = match (birth_height, birth_hash) {
(Some(height), Some(hash)) => Some(BlockId {
height,
hash: hash.into_inner(),
}),
_ => None,
}
}

// Select locked outpoints.
Expand Down Expand Up @@ -309,6 +343,26 @@ impl ChangeSet {
})?;
}

if let Some(birthday) = self.birthday {
let mut birth_height_statement = db_tx.prepare_cached(&format!(
"INSERT INTO {}(id, birth_height) VALUES(:id, :birth_height) ON CONFLICT(id) DO UPDATE SET birth_height=:birth_height",
Self::WALLET_TABLE_NAME,
))?;
birth_height_statement.execute(named_params! {
":id": 0,
":birth_height": birthday.height,
})?;

let mut birth_hash_statement = db_tx.prepare_cached(&format!(
"INSERT INTO {}(id, birth_hash) VALUES(:id, :birth_hash) ON CONFLICT(id) DO UPDATE SET birth_hash=:birth_hash",
Self::WALLET_TABLE_NAME,
))?;
birth_hash_statement.execute(named_params! {
":id": 0,
":birth_hash": Impl(birthday.hash),
})?;
}

// Insert or delete locked outpoints.
let mut insert_stmt = db_tx.prepare_cached(&format!(
"INSERT OR IGNORE INTO {}(txid, vout) VALUES(:txid, :vout)",
Expand Down
20 changes: 20 additions & 0 deletions src/wallet/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ use alloc::{
string::{String, ToString},
};
use bitcoin::{absolute, psbt, Amount, BlockHash, Network, OutPoint, Sequence, Txid};

use chain::BlockId;
use core::fmt;

/// The error type when loading a [`Wallet`] from a [`ChangeSet`].
Expand Down Expand Up @@ -64,6 +66,13 @@ pub enum LoadMismatch {
/// The expected network.
expected: Network,
},
/// Birthday does not match.
Birthday {
/// The birthday that is loaded.
loaded: Option<BlockId>,
/// The expected birthday.
expected: Option<BlockId>,
},
/// Genesis hash does not match.
Genesis {
/// The genesis hash that is loaded.
Expand All @@ -88,6 +97,17 @@ impl fmt::Display for LoadMismatch {
LoadMismatch::Network { loaded, expected } => {
write!(f, "Network mismatch: loaded {loaded}, expected {expected}")
}
LoadMismatch::Birthday { loaded, expected } => {
let loaded = match loaded {
Some(loaded) => format!("{}:{}", loaded.height, loaded.hash),
None => "None".to_string(),
};
let expected = match expected {
Some(expected) => format!("{}:{}", expected.height, expected.hash),
None => "None".to_string(),
};
write!(f, "Birthday mismatch: loaded {loaded}, expected {expected}")
}
LoadMismatch::Genesis { loaded, expected } => {
write!(
f,
Expand Down
Loading