Skip to content

Commit d77fd95

Browse files
committed
Merge #310: Return wallet events when applying updates
c6fb9b3 docs: add events ADR 0003 (Steve Myers) e1bcce2 docs(wallet): add example to appl_update_events (Steve Myers) fe88a73 feat(wallet): add WalletEvent and Wallet::apply_update_events (Steve Myers) 8b2efb3 docs: fix small typos (Steve Myers) Pull request description: ### Description Adds `WalletEvent` enum of user facing events that are generated when a sync update is applied to a wallet using the new `Wallet::apply_update_events` function. fixes #6 ### Notes to the reviewers I also modified wallet test_utils to add a `new_wallet_and_funding_update` function that returns a new wallet with an update that funds it. I used this new function in the original `new_funded_wallet` so there's no duplication. ### Changelog notice ### Checklists #### All Submissions: * [x] I've signed all my commits * [x] I followed the [contribution guidelines](https://github.com/bitcoindevkit/bdk/blob/master/CONTRIBUTING.md) * [x] I ran `just p` before pushing #### New Features: * [x] I've added tests for the new feature * [x] I've added docs for the new feature ACKs for top commit: thunderbiscuit: Re-ACK c6fb9b3. oleonardolima: ACK c6fb9b3 Tree-SHA512: 92007a1dfd748ede606a90e6b8e4d1f2609b78bd620be5dca784b76b9270ccdd309d1dcc6e59abddb542b111453a6619c6d6638ead82136684a05c85d5df37aa
2 parents 3f438e1 + c6fb9b3 commit d77fd95

File tree

6 files changed

+772
-67
lines changed

6 files changed

+772
-67
lines changed

docs/adr/0003_events.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Return user-facing events when applying updates after syncing
2+
3+
* Status: accepted
4+
* Authors: @notmandatory
5+
* Date: 2025-09-21
6+
* Targeted modules: wallet
7+
* Associated tickets/PRs: #6, #310
8+
9+
## Context and Problem Statement
10+
11+
When syncing a `Wallet` with new blockchain data using `Wallet::apply_update` it does not return any value on success,
12+
only a `CannotConnectError` if it fails.
13+
14+
Users have asked for a concise list of events that reflect if or how new blockchain data has changed the
15+
blockchain tip and the status of transactions relevant to the wallet's bitcoin balance. This information should also
16+
be useful for on-chain apps who want to notify users of wallet changes after syncing.
17+
18+
If the end user app ends for some reason before handling the wallet events, the same wallet events should be
19+
regenerated when the same blockchain sync data is re-downloaded and reapplied to the wallet.
20+
21+
## Decision Drivers
22+
23+
* Currently `Wallet::apply_update` does not return any value except a `CannotConnectError` if it fails.
24+
* Downstream users need updates on chain tip, new transactions and transaction status changes.
25+
* If the app doesn't process all the events before it ends the same events should be returned on a subsequent sync.
26+
* Current downstream users requesting this feature are: LDK node (@tnull) and Bitkit (@ovitrif).
27+
* This feature was requested in May 2024, over a year and a half ago.
28+
29+
## Considered Options
30+
31+
#### Option 1: Do nothing
32+
33+
Do not change anything since all the data the users require is available with the current API by comparing the
34+
wallet's canonical transaction list before and after applying a sync update.
35+
36+
**Pros:**
37+
38+
* No API changes are needed and user can customize the events to exactly what they need.
39+
40+
**Cons:**
41+
42+
* Users will need to duplicate the work to add this feature on every project.
43+
* It's easier for the core BDK team to add this feature once in a way that meets most users needs.
44+
45+
#### Option 2: Modify the `Wallet::apply_update` to return a list of `WalletEvent`
46+
47+
Adds `WalletEvent` enum of user facing events that are generated when a sync update is applied to a wallet using the
48+
existing `Wallet::apply_update` function. The `WalletEvent` enum includes an event for changes in blockchain tip and
49+
events for changes to the status of transactions that are relevant to the wallet, including:
50+
51+
1. newly seen in the mempool
52+
2. replaced in the mempool
53+
3. dropped from the mempool
54+
4. confirmed in a block
55+
5. confirmed in a new block due to a reorg
56+
6. unconfirmed due to a reorg
57+
58+
Chain tip change events are generated by comparing the wallet's chain tip before and after applying an update. Wallet
59+
transaction events are generated by comparing a snapshot of canonical transactions.
60+
61+
As long as updates to the wallet are not persisted until after all events are processed by the caller then if the app
62+
crashes for some reason and the wallet is re-sync'd a new update will re-return the same events.
63+
64+
The `WalletEvent` enum is non-exhaustive.
65+
66+
**Pros:**
67+
68+
* Events are always generated when a wallet update is applied.
69+
* The user doesn't need to add this functionality themselves.
70+
* New events can be added without a breaking change.
71+
72+
**Cons:**
73+
74+
* This can not be rolled out except as a breaking release since it changes the `Wallet::apply_update` function signature.
75+
* If an app doesn't care about these events they must still generate them.
76+
77+
#### Option 3: Same as option 2 but add a new function
78+
79+
This option is the same as option 2 but adds a new `Wallet::apply_update_events` function to update the wallet and
80+
return the list of `WalletEvent` enums.
81+
82+
**Pros:**
83+
84+
* Same reasons as above and does not require an API breaking release.
85+
* Keeps option for users to update the wallet with original `Wallet::apply_update` and not get events.
86+
87+
**Cons:**
88+
89+
* Could be confusing to users which function to use, the original or new one.
90+
* If in a future breaking release we decide to always return events we'll need to deprecate `Wallet::apply_update_events`.
91+
92+
## Decision Outcome
93+
94+
Chosen option: "Option 3", because it can be delivered to users in the next minor release. This option also lets us
95+
get user feedback and see how the events are used before forcing all users to generate them during an update.
96+
97+
### Positive Consequences
98+
99+
* The new wallet events can be used for more responsive on chain wallet UIs.
100+
101+
### Negative Consequences
102+
103+
* The down stream `bdk-ffi` and book of bdk projects will need to be updated for this new feature.
104+

wallet/src/test_utils.rs

Lines changed: 62 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use alloc::string::ToString;
44
use alloc::sync::Arc;
55
use core::str::FromStr;
66

7-
use bdk_chain::{BlockId, ConfirmationBlockTime, TxUpdate};
7+
use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate};
88
use bitcoin::{
99
absolute, hashes::Hash, transaction, Address, Amount, BlockHash, FeeRate, Network, OutPoint,
1010
Transaction, TxIn, TxOut, Txid,
@@ -22,13 +22,42 @@ pub fn get_funded_wallet(descriptor: &str, change_descriptor: &str) -> (Wallet,
2222
}
2323

2424
fn new_funded_wallet(descriptor: &str, change_descriptor: Option<&str>) -> (Wallet, Txid) {
25+
let (mut wallet, txid, update) = new_wallet_and_funding_update(descriptor, change_descriptor);
26+
wallet.apply_update(update).unwrap();
27+
(wallet, txid)
28+
}
29+
30+
/// Return a fake wallet that appears to be funded for testing.
31+
///
32+
/// The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000
33+
/// to a foreign address and one returning 50_000 back to the wallet. The remaining 1000
34+
/// sats are the transaction fee.
35+
pub fn get_funded_wallet_single(descriptor: &str) -> (Wallet, Txid) {
36+
new_funded_wallet(descriptor, None)
37+
}
38+
39+
/// Get funded segwit wallet
40+
pub fn get_funded_wallet_wpkh() -> (Wallet, Txid) {
41+
let (desc, change_desc) = get_test_wpkh_and_change_desc();
42+
get_funded_wallet(desc, change_desc)
43+
}
44+
45+
/// Get unfunded wallet and wallet update that funds it
46+
///
47+
/// The funding update contains a tx with a 76_000 sats input and two outputs, one spending
48+
/// 25_000 to a foreign address and one returning 50_000 back to the wallet as
49+
/// change. The remaining 1000 sats are the transaction fee.
50+
pub fn new_wallet_and_funding_update(
51+
descriptor: &str,
52+
change_descriptor: Option<&str>,
53+
) -> (Wallet, Txid, Update) {
2554
let params = if let Some(change_desc) = change_descriptor {
2655
Wallet::create(descriptor.to_string(), change_desc.to_string())
2756
} else {
2857
Wallet::create_single(descriptor.to_string())
2958
};
3059

31-
let mut wallet = params
60+
let wallet = params
3261
.network(Network::Regtest)
3362
.create_wallet_no_persist()
3463
.expect("descriptors must be valid");
@@ -39,6 +68,8 @@ fn new_funded_wallet(descriptor: &str, change_descriptor: Option<&str>) -> (Wall
3968
.require_network(Network::Regtest)
4069
.unwrap();
4170

71+
let mut update = Update::default();
72+
4273
let tx0 = Transaction {
4374
output: vec![TxOut {
4475
value: Amount::from_sat(76_000),
@@ -67,71 +98,37 @@ fn new_funded_wallet(descriptor: &str, change_descriptor: Option<&str>) -> (Wall
6798
],
6899
..new_tx(0)
69100
};
101+
let txid1 = tx1.compute_txid();
70102

71-
insert_checkpoint(
72-
&mut wallet,
73-
BlockId {
74-
height: 42,
75-
hash: BlockHash::all_zeros(),
76-
},
77-
);
78-
insert_checkpoint(
79-
&mut wallet,
80-
BlockId {
81-
height: 1_000,
82-
hash: BlockHash::all_zeros(),
83-
},
84-
);
85-
insert_checkpoint(
86-
&mut wallet,
87-
BlockId {
88-
height: 2_000,
89-
hash: BlockHash::all_zeros(),
90-
},
91-
);
92-
93-
insert_tx(&mut wallet, tx0.clone());
94-
insert_anchor(
95-
&mut wallet,
96-
tx0.compute_txid(),
97-
ConfirmationBlockTime {
98-
block_id: BlockId {
99-
height: 1_000,
100-
hash: BlockHash::all_zeros(),
101-
},
102-
confirmation_time: 100,
103-
},
104-
);
105-
106-
insert_tx(&mut wallet, tx1.clone());
107-
insert_anchor(
108-
&mut wallet,
109-
tx1.compute_txid(),
110-
ConfirmationBlockTime {
111-
block_id: BlockId {
112-
height: 2_000,
113-
hash: BlockHash::all_zeros(),
114-
},
115-
confirmation_time: 200,
116-
},
117-
);
118-
119-
(wallet, tx1.compute_txid())
120-
}
121-
122-
/// Return a fake wallet that appears to be funded for testing.
123-
///
124-
/// The funded wallet contains a tx with a 76_000 sats input and two outputs, one spending 25_000
125-
/// to a foreign address and one returning 50_000 back to the wallet. The remaining 1000
126-
/// sats are the transaction fee.
127-
pub fn get_funded_wallet_single(descriptor: &str) -> (Wallet, Txid) {
128-
new_funded_wallet(descriptor, None)
129-
}
103+
let b0 = BlockId {
104+
height: 0,
105+
hash: BlockHash::from_slice(wallet.network().chain_hash().as_bytes()).unwrap(),
106+
};
107+
let b1 = BlockId {
108+
height: 42,
109+
hash: BlockHash::all_zeros(),
110+
};
111+
let b2 = BlockId {
112+
height: 1000,
113+
hash: BlockHash::all_zeros(),
114+
};
115+
let a2 = ConfirmationBlockTime {
116+
block_id: b2,
117+
confirmation_time: 100,
118+
};
119+
let b3 = BlockId {
120+
height: 2000,
121+
hash: BlockHash::all_zeros(),
122+
};
123+
let a3 = ConfirmationBlockTime {
124+
block_id: b3,
125+
confirmation_time: 200,
126+
};
127+
update.chain = CheckPoint::from_block_ids([b0, b1, b2, b3]).ok();
128+
update.tx_update.anchors = [(a2, tx0.compute_txid()), (a3, tx1.compute_txid())].into();
129+
update.tx_update.txs = [Arc::new(tx0), Arc::new(tx1)].into();
130130

131-
/// Get funded segwit wallet
132-
pub fn get_funded_wallet_wpkh() -> (Wallet, Txid) {
133-
let (desc, change_desc) = get_test_wpkh_and_change_desc();
134-
get_funded_wallet(desc, change_desc)
131+
(wallet, txid1, update)
135132
}
136133

137134
/// `pkh` single key descriptor

wallet/src/wallet/changeset.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ type IndexedTxGraphChangeSet =
1111
///
1212
/// ## Definition
1313
///
14-
/// The change set is responsible for transmiting data between the persistent storage layer and the
14+
/// The change set is responsible for transmitting data between the persistent storage layer and the
1515
/// core library components. Specifically, it serves two primary functions:
1616
///
1717
/// 1) Recording incremental changes to the in-memory representation that need to be persisted to
@@ -46,7 +46,7 @@ type IndexedTxGraphChangeSet =
4646
/// at any point thereafter.
4747
///
4848
/// Other fields of the change set are not required to be non-empty, that is they may be empty even
49-
/// in the aggregate. However in practice they should contain the data needed to recover a wallet
49+
/// in the aggregate. However, in practice they should contain the data needed to recover a wallet
5050
/// state between sessions. These include:
5151
/// * [`tx_graph`](Self::tx_graph)
5252
/// * [`indexer`](Self::indexer)

0 commit comments

Comments
 (0)