Skip to content

Commit 41e5b65

Browse files
committed
[wip] wallet birthday
1 parent c1f052e commit 41e5b65

File tree

11 files changed

+377
-13
lines changed

11 files changed

+377
-13
lines changed

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,6 @@ name = "esplora_blocking"
7474

7575
[[example]]
7676
name = "bitcoind_rpc"
77+
78+
[[example]]
79+
name = "bitcoind_rpc_filter"

docs/adr/0004_birthday.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Introduce birthday information to the wallet
2+
3+
* Status: TBD
4+
* Authors: @luisschwab
5+
* Date: 2026-02-XX
6+
* Targeted modules: wallet
7+
* Associated tickets/PRs: [#368](https://github.com/bitcoindevkit/bdk_wallet/pull/368)
8+
9+
## Context and Problem Statement
10+
11+
[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.]
12+
13+
## Decision Drivers <!-- optional -->
14+
15+
* [driver 1, e.g., a force, facing concern, …]
16+
* [driver 2, e.g., a force, facing concern, …]
17+
*<!-- numbers of drivers can vary -->
18+
19+
## Considered Options <!-- numbers of options can vary -->
20+
21+
#### [Option 1]
22+
23+
[example | description | pointer to more information | …]
24+
25+
**Pros:**
26+
27+
* Good, because [argument …]
28+
29+
**Cons:**
30+
31+
* Bad, because [argument …]
32+
33+
#### [Option 2]
34+
...
35+
36+
#### [Option 3]
37+
...
38+
39+
## Decision Outcome
40+
41+
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)].
42+
43+
### Positive Consequences <!-- optional -->
44+
45+
* [e.g., improvement of quality attribute satisfaction, follow-up decisions required, …]
46+
*
47+
48+
### Negative Consequences <!-- optional -->
49+
50+
* [e.g., compromising quality attribute, follow-up decisions required, …]
51+
*
52+
53+
## Links <!-- optional -->
54+
55+
* [Link type] [Link to ADR] <!-- example: Refined by [ADR-0005](0005-example.md) -->
56+
*<!-- numbers of links can vary -->

examples/bitcoind_rpc_filter.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#![allow(unused)]
2+
3+
use bdk_chain::BlockId;
4+
use bdk_wallet::rusqlite::Connection;
5+
use bdk_wallet::{
6+
bitcoin::{Block, Network},
7+
KeychainKind, Wallet,
8+
};
9+
use bitcoin::{hashes::Hash, BlockHash};
10+
use clap::{self, Parser};
11+
use std::{
12+
path::PathBuf,
13+
sync::{mpsc::sync_channel, Arc},
14+
thread::spawn,
15+
time::Instant,
16+
};
17+
18+
fn main() -> anyhow::Result<()> {
19+
Ok(())
20+
}

src/persist_test_utils.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
//! Utilities for testing custom persistence backends for `bdk_wallet`
22
3+
use bitcoin::{hashes::Hash, BlockHash};
4+
use chain::BlockId;
5+
36
use crate::{
47
bitcoin::{
58
absolute, key::Secp256k1, transaction, Address, Amount, Network, OutPoint, ScriptBuf,
@@ -168,6 +171,7 @@ where
168171
descriptor: Some(descriptor.clone()),
169172
change_descriptor: Some(change_descriptor.clone()),
170173
network: Some(Network::Testnet),
174+
birthday: None,
171175
local_chain: local_chain_changeset,
172176
tx_graph: tx_graph_changeset,
173177
indexer: keychain_txout_changeset,
@@ -227,6 +231,7 @@ where
227231
descriptor: None,
228232
change_descriptor: None,
229233
network: None,
234+
birthday: None,
230235
local_chain: local_chain_changeset,
231236
tx_graph: tx_graph_changeset,
232237
indexer: keychain_txout_changeset,
@@ -351,6 +356,41 @@ where
351356
assert_eq!(changeset_read.network, Some(Network::Bitcoin));
352357
}
353358

359+
/// Test whether the `birthday` is persisted correctly.
360+
pub fn persist_birthday<Store, CreateStore>(filename: &str, create_store: CreateStore)
361+
where
362+
CreateStore: Fn(&Path) -> anyhow::Result<Store>,
363+
Store: WalletPersister,
364+
Store::Error: Debug,
365+
{
366+
// Create store
367+
let temp_dir = tempfile::tempdir().expect("must create tempdir");
368+
let file_path = temp_dir.path().join(filename);
369+
let mut store = create_store(&file_path).expect("store should get created");
370+
371+
// Initialize store
372+
let changeset = WalletPersister::initialize(&mut store)
373+
.expect("should initialize and load empty changeset");
374+
assert_eq!(changeset, ChangeSet::default());
375+
376+
let birthday = BlockId {
377+
height: 42,
378+
hash: BlockHash::all_zeros(),
379+
};
380+
let changeset = ChangeSet {
381+
birthday: Some(birthday),
382+
..Default::default()
383+
};
384+
385+
WalletPersister::persist(&mut store, &changeset).expect("should persist birthday");
386+
387+
// Load the birthday
388+
let changeset_read =
389+
WalletPersister::initialize(&mut store).expect("should read persisted changeset");
390+
391+
assert_eq!(changeset_read.birthday, Some(birthday));
392+
}
393+
354394
/// tests if descriptors are being persisted correctly
355395
///
356396
/// [`ChangeSet`]: <https://docs.rs/bdk_wallet/latest/bdk_wallet/struct.ChangeSet.html>

src/wallet/changeset.rs

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use bdk_chain::{
2-
indexed_tx_graph, keychain_txout, local_chain, tx_graph, ConfirmationBlockTime, Merge,
2+
indexed_tx_graph, keychain_txout, local_chain, tx_graph, BlockId, ConfirmationBlockTime, Merge,
33
};
44
use miniscript::{Descriptor, DescriptorPublicKey};
55
use serde::{Deserialize, Serialize};
@@ -32,7 +32,7 @@ type IndexedTxGraphChangeSet =
3232
/// instance.
3333
/// * A change set is composed of a number of individual "sub-change sets" that adhere to the same
3434
/// rules as above. This is for increased modularity and portability. For example the core modules
35-
/// each have their own change set (`tx_graph`, `local_chain`, etc).
35+
/// each have their own change set ([`tx_graph`], [`local_chain`], etc).
3636
///
3737
/// ## Members and required fields
3838
///
@@ -110,6 +110,8 @@ pub struct ChangeSet {
110110
pub change_descriptor: Option<Descriptor<DescriptorPublicKey>>,
111111
/// Stores the network type of the transaction data.
112112
pub network: Option<bitcoin::Network>,
113+
/// Set the wallet's birthday.
114+
pub birthday: Option<BlockId>,
113115
/// Changes to the [`LocalChain`](local_chain::LocalChain).
114116
pub local_chain: local_chain::ChangeSet,
115117
/// Changes to [`TxGraph`](tx_graph::TxGraph).
@@ -146,6 +148,14 @@ impl Merge for ChangeSet {
146148
self.network = other.network;
147149
}
148150

151+
// Merging birthdays should yield the earliest birthday.
152+
if other.birthday.is_some() {
153+
self.birthday = match (self.birthday, other.birthday) {
154+
(Some(a), Some(b)) => Some(if a.height <= b.height { a } else { b }),
155+
(None, some) | (some, None) => some,
156+
};
157+
}
158+
149159
// merge locked outpoints
150160
self.locked_outpoints.merge(other.locked_outpoints);
151161

@@ -158,6 +168,7 @@ impl Merge for ChangeSet {
158168
self.descriptor.is_none()
159169
&& self.change_descriptor.is_none()
160170
&& self.network.is_none()
171+
&& self.birthday.is_none()
161172
&& self.local_chain.is_empty()
162173
&& self.tx_graph.is_empty()
163174
&& self.indexer.is_empty()
@@ -199,12 +210,26 @@ impl ChangeSet {
199210
)
200211
}
201212

213+
/// Get the `v2` sqlite [`ChangeSet`] schema.
214+
///
215+
/// Adds two columns to the wallet table to perist the wallet's birthday from a [`BlockId`]:
216+
/// * `birth_height`: the height of the birthday block, as a `u32`
217+
/// * `birth_hash`: the hash of the birthday block, as a [`BlockHash`]
218+
pub fn schema_v2() -> alloc::string::String {
219+
format!(
220+
"ALTER TABLE {} ADD COLUMN birth_height INTEGER; \
221+
ALTER TABLE {} ADD COLUMN birth_hash TEXT;",
222+
Self::WALLET_TABLE_NAME,
223+
Self::WALLET_TABLE_NAME,
224+
)
225+
}
226+
202227
/// Initialize sqlite tables for wallet tables.
203228
pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> {
204229
crate::rusqlite_impl::migrate_schema(
205230
db_tx,
206231
Self::WALLET_SCHEMA_NAME,
207-
&[&Self::schema_v0(), &Self::schema_v1()],
232+
&[&Self::schema_v0(), &Self::schema_v1(), &Self::schema_v2()],
208233
)?;
209234

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

225250
let mut wallet_statement = db_tx.prepare(&format!(
226-
"SELECT descriptor, change_descriptor, network FROM {}",
251+
"SELECT descriptor, change_descriptor, network, birth_height, birth_hash FROM {}",
227252
Self::WALLET_TABLE_NAME,
228253
))?;
229254
let row = wallet_statement
@@ -234,13 +259,22 @@ impl ChangeSet {
234259
"change_descriptor",
235260
)?,
236261
row.get::<_, Option<Impl<bitcoin::Network>>>("network")?,
262+
row.get::<_, Option<u32>>("birth_height")?,
263+
row.get::<_, Option<Impl<bitcoin::BlockHash>>>("birth_hash")?,
237264
))
238265
})
239266
.optional()?;
240-
if let Some((desc, change_desc, network)) = row {
267+
if let Some((desc, change_desc, network, birth_height, birth_hash)) = row {
241268
changeset.descriptor = desc.map(Impl::into_inner);
242269
changeset.change_descriptor = change_desc.map(Impl::into_inner);
243270
changeset.network = network.map(Impl::into_inner);
271+
changeset.birthday = match (birth_height, birth_hash) {
272+
(Some(height), Some(hash)) => Some(BlockId {
273+
height,
274+
hash: hash.into_inner(),
275+
}),
276+
_ => None,
277+
}
244278
}
245279

246280
// Select locked outpoints.
@@ -309,6 +343,26 @@ impl ChangeSet {
309343
})?;
310344
}
311345

346+
if let Some(birthday) = self.birthday {
347+
let mut birth_height_statement = db_tx.prepare_cached(&format!(
348+
"INSERT INTO {}(id, birth_height) VALUES(:id, :birth_height) ON CONFLICT(id) DO UPDATE SET birth_height=:birth_height",
349+
Self::WALLET_TABLE_NAME,
350+
))?;
351+
birth_height_statement.execute(named_params! {
352+
":id": 0,
353+
":birth_height": birthday.height,
354+
})?;
355+
356+
let mut birth_hash_statement = db_tx.prepare_cached(&format!(
357+
"INSERT INTO {}(id, birth_hash) VALUES(:id, :birth_hash) ON CONFLICT(id) DO UPDATE SET birth_hash=:birth_hash",
358+
Self::WALLET_TABLE_NAME,
359+
))?;
360+
birth_hash_statement.execute(named_params! {
361+
":id": 0,
362+
":birth_hash": Impl(birthday.hash),
363+
})?;
364+
}
365+
312366
// Insert or delete locked outpoints.
313367
let mut insert_stmt = db_tx.prepare_cached(&format!(
314368
"INSERT OR IGNORE INTO {}(txid, vout) VALUES(:txid, :vout)",

src/wallet/error.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ use alloc::{
2020
string::{String, ToString},
2121
};
2222
use bitcoin::{absolute, psbt, Amount, BlockHash, Network, OutPoint, Sequence, Txid};
23+
24+
use chain::BlockId;
2325
use core::fmt;
2426

2527
/// The error type when loading a [`Wallet`] from a [`ChangeSet`].
@@ -64,6 +66,13 @@ pub enum LoadMismatch {
6466
/// The expected network.
6567
expected: Network,
6668
},
69+
/// Birthday does not match.
70+
Birthday {
71+
/// The birthday that is loaded.
72+
loaded: Option<BlockId>,
73+
/// The expected birthday.
74+
expected: Option<BlockId>,
75+
},
6776
/// Genesis hash does not match.
6877
Genesis {
6978
/// The genesis hash that is loaded.
@@ -88,6 +97,17 @@ impl fmt::Display for LoadMismatch {
8897
LoadMismatch::Network { loaded, expected } => {
8998
write!(f, "Network mismatch: loaded {loaded}, expected {expected}")
9099
}
100+
LoadMismatch::Birthday { loaded, expected } => {
101+
let loaded = match loaded {
102+
Some(loaded) => format!("{}:{}", loaded.height, loaded.hash),
103+
None => "None".to_string(),
104+
};
105+
let expected = match expected {
106+
Some(expected) => format!("{}:{}", expected.height, expected.hash),
107+
None => "None".to_string(),
108+
};
109+
write!(f, "Birthday mismatch: loaded {loaded}, expected {expected}")
110+
}
91111
LoadMismatch::Genesis { loaded, expected } => {
92112
write!(
93113
f,

0 commit comments

Comments
 (0)