Skip to main content

bdk_labels/
extension.rs

1use crate::{Bip329, LabelTarget};
2use bdk_wallet::Wallet;
3
4use crate::Error;
5use crate::changeset::{LabelChangeset, MergeStrategy};
6use crate::persist::LabelPersister;
7use crate::{export, import};
8use bip329::{Label, LabelRef};
9use std::io::{BufRead, Write};
10
11/// A wrapper around a BDK `Wallet` and a `LabelChangeset` that provides BIP-329 functionality.
12///
13/// This struct enables direct manipulation of wallet labels while holding mutable references
14/// to both the underlying wallet state and the current label changeset.
15pub struct LabelledWallet<'a> {
16    /// A mutable reference to the underlying BDK wallet.
17    pub wallet: &'a mut Wallet,
18    /// A mutable reference to the current in-memory label state.
19    pub labels: &'a mut LabelChangeset,
20}
21
22impl Bip329 for LabelledWallet<'_> {
23    fn add_label(
24        &mut self,
25        target: impl Into<LabelTarget>,
26        label_text: impl Into<String>,
27    ) -> Result<Label, Error> {
28        let new_label = match target.into() {
29            LabelTarget::Txid(txid) => {
30                let known_txid = self.wallet.list_output().any(|o| o.outpoint.txid == txid);
31                if !known_txid {
32                    return Err(Error::Validation(format!(
33                        "txid {txid} not found in wallet"
34                    )));
35                }
36                Label::Transaction(bip329::TransactionRecord {
37                    ref_: txid,
38                    label: Some(label_text.into()),
39                    origin: None,
40                })
41            }
42            LabelTarget::Address(addr) => {
43                let checked_address = addr.assume_checked_ref();
44                if !self.wallet.is_mine(checked_address.script_pubkey()) {
45                    return Err(Error::Validation(format!(
46                        "address {:?} does not belong to this wallet",
47                        addr
48                    )));
49                }
50                Label::Address(bip329::AddressRecord {
51                    ref_: addr,
52                    label: Some(label_text.into()),
53                })
54            }
55            LabelTarget::PublicKey(pk) => Label::PublicKey(bip329::PublicKeyRecord {
56                ref_: pk,
57                label: Some(label_text.into()),
58            }),
59            LabelTarget::Input(outpoint) => {
60                let known_input = self.wallet.list_output().any(|o| o.outpoint == outpoint);
61                if !known_input {
62                    return Err(Error::Validation(format!(
63                        "outpoint {outpoint} not found in wallet"
64                    )));
65                }
66                Label::Input(bip329::InputRecord {
67                    ref_: outpoint,
68                    label: Some(label_text.into()),
69                })
70            }
71            LabelTarget::Output(outpoint) => {
72                let known_input = self.wallet.list_output().any(|o| o.outpoint == outpoint);
73                if !known_input {
74                    return Err(Error::Validation(format!(
75                        "outpoint {outpoint} not found in wallet"
76                    )));
77                }
78
79                let spendable = self
80                    .labels
81                    .get(&LabelRef::Output(outpoint))
82                    .and_then(|l| match l {
83                        Label::Output(rec) => Some(rec.spendable),
84                        _ => None,
85                    })
86                    .unwrap_or(true);
87
88                Label::Output(bip329::OutputRecord {
89                    ref_: outpoint,
90                    label: Some(label_text.into()),
91                    spendable,
92                })
93            }
94            LabelTarget::Xpub(xpub) => Label::ExtendedPublicKey(bip329::ExtendedPublicKeyRecord {
95                ref_: xpub,
96                label: Some(label_text.into()),
97            }),
98        };
99
100        self.labels.insert(new_label.clone());
101
102        Ok(new_label)
103    }
104
105    fn import_labels<R: BufRead>(
106        &mut self,
107        reader: R,
108        strategy: MergeStrategy,
109    ) -> Result<(), Error> {
110        let imported_labels = import(reader)?;
111        self.labels.merge(imported_labels, strategy);
112        Ok(())
113    }
114
115    fn export_labels<W: Write>(&self, writer: W) -> Result<(), Error> {
116        export(self.labels, writer)
117    }
118}
119
120impl LabelledWallet<'_> {
121    /// Flushes only the labels changed since the last successful persist to
122    /// the provided database persister.
123    pub fn persist<P: LabelPersister>(&mut self, persister: &mut P) -> Result<(), Error> {
124        if !self.labels.has_staged_changes() {
125            return Ok(());
126        }
127        let diff = self.labels.diff();
128        persister
129            .append_changeset(&diff)
130            .map_err(|e| Error::Custom(Box::new(e)))?;
131        self.labels.clear_staged();
132
133        Ok(())
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use crate::changeset::LabelChangeset;
140    use crate::{InputTarget, OutputTarget};
141    use bdk_wallet::test_utils::{get_funded_wallet, get_test_wpkh_and_change_desc};
142    use bdk_wallet::{KeychainKind, Wallet};
143    use bip329::{
144        AddressRecord, ExtendedPublicKeyRecord, InputRecord, Label, OutputRecord, PublicKeyRecord,
145        TransactionRecord,
146    };
147    use bitcoin::Address;
148    use bitcoin::Network;
149    use bitcoin::address::NetworkUnchecked;
150    use bitcoin::bip32::Xpub;
151    use bitcoin::{OutPoint, PublicKey, Txid};
152    use std::matches;
153    use std::str::FromStr;
154
155    use super::*;
156
157    /// An unfunded wallet, for cases that need `add_label` to reject a target —
158    /// this wallet has never seen any transaction or derived-and-funded address.
159    fn test_wallet() -> Wallet {
160        let external_desc =
161            "wpkh(0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798)";
162        let internal_desc =
163            "wpkh(03a0434d9e47f3c86235477c7b1ae6ae5d3442d49b1943c2b752a68e2a47e247c7)";
164
165        Wallet::create(external_desc, internal_desc)
166            .network(Network::Testnet)
167            .create_wallet_no_persist()
168            .expect("Failed to create wallet")
169    }
170
171    /// A wallet funded via a real, wallet-known transaction: `get_funded_wallet`
172    /// creates `tx0` (76_000 sats received) then `tx1`, which spends that output
173    /// and creates a 50_000 sat change output plus a 25_000 sat foreign payment.
174    /// This gives, from one call: a real `Txid` (returned), a real spent output
175    /// (a valid `Input` target), and a real unspent output (a valid `Output`
176    /// target) — everything `add_label`'s wallet-membership validation needs.
177    fn funded_test_wallet() -> (Wallet, Txid) {
178        let (desc, change_desc) = get_test_wpkh_and_change_desc();
179        get_funded_wallet(desc, change_desc)
180    }
181
182    /// Pulls the (unspent, spent) outpoints out of a wallet produced by
183    /// `funded_test_wallet`, without assuming which of `tx0`/`tx1`'s outputs
184    /// ends up in which position.
185    fn unspent_and_spent_outpoints(wallet: &Wallet) -> (OutPoint, OutPoint) {
186        let unspent_outpoint = wallet
187            .list_unspent()
188            .next()
189            .expect("funded wallet should have one unspent output")
190            .outpoint;
191
192        let spent_outpoint = wallet
193            .list_output()
194            .map(|o| o.outpoint)
195            .find(|op| *op != unspent_outpoint)
196            .expect("funded wallet should also have a spent output (tx1's input)");
197
198        (unspent_outpoint, spent_outpoint)
199    }
200
201    fn as_unchecked(address: Address) -> Address<NetworkUnchecked> {
202        address
203            .to_string()
204            .parse()
205            .expect("Wallet derived address should be converted to Network Unchecked")
206    }
207
208    #[test]
209    fn test_add_label_variant_mapping() {
210        let (mut wallet, funding_txid) = funded_test_wallet();
211
212        let (unspent_outpoint, spent_outpoint) = unspent_and_spent_outpoints(&wallet);
213        let owned_address = wallet.reveal_next_address(KeychainKind::External).address;
214
215        let dummy_pubkey = PublicKey::from_str(
216            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
217        )
218        .unwrap();
219
220        let dummy_xpub = Xpub::from_str("xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8").unwrap();
221
222        let mut changeset = LabelChangeset::new();
223
224        let mut labelled_wallet = LabelledWallet {
225            wallet: &mut wallet,
226            labels: &mut changeset,
227        };
228
229        let transaction_label = labelled_wallet
230            .add_label(funding_txid, "Payment for Machinery")
231            .expect("Failed to add transaction label");
232
233        let address_label = labelled_wallet
234            .add_label(as_unchecked(owned_address.clone()), "Employee address")
235            .expect("Failed to add address label");
236
237        let pubkey_label = labelled_wallet
238            .add_label(dummy_pubkey, "My wallet's public key")
239            .expect("Failed to add address label");
240
241        let input_label = labelled_wallet
242            .add_label(InputTarget(spent_outpoint), "My transaction's input")
243            .expect("Failed to add address label");
244
245        let output_label = labelled_wallet
246            .add_label(OutputTarget(unspent_outpoint), "My transaction's Output")
247            .expect("Failed to add address label");
248
249        let xpub_label = labelled_wallet
250            .add_label(dummy_xpub, "My wallet's extended public key")
251            .expect("Failed to add address label");
252
253        assert!(matches!(
254            transaction_label,
255            Label::Transaction(TransactionRecord {
256                ref_: _,
257                label: Some(_),
258                origin: _,
259            })
260        ));
261
262        assert!(matches!(
263            address_label,
264            Label::Address(AddressRecord {
265                ref_: _,
266                label: Some(_)
267            })
268        ));
269
270        assert!(matches!(
271            pubkey_label,
272            Label::PublicKey(PublicKeyRecord {
273                ref_: _,
274                label: Some(_),
275            })
276        ));
277
278        assert!(matches!(
279            input_label,
280            Label::Input(InputRecord {
281                ref_: _,
282                label: Some(_),
283            })
284        ));
285
286        assert!(matches!(
287            output_label,
288            Label::Output(OutputRecord {
289                ref_: _,
290                label: Some(_),
291                spendable: true,
292            })
293        ));
294
295        assert!(matches!(
296            xpub_label,
297            Label::ExtendedPublicKey(ExtendedPublicKeyRecord {
298                ref_: _,
299                label: Some(_),
300            })
301        ));
302    }
303
304    #[test]
305    fn test_add_label_rejects_address_not_owned_by_wallet() {
306        let mut wallet = test_wallet();
307        let mut changeset = LabelChangeset::new();
308        let mut labelled_wallet = LabelledWallet {
309            wallet: &mut wallet,
310            labels: &mut changeset,
311        };
312
313        let unowned_address = bitcoin::Address::from_str("mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt")
314            .expect("failed to parse address");
315
316        let result = labelled_wallet.add_label(unowned_address, "Not for this wallet");
317
318        assert!(matches!(result, Err(Error::Validation(_))));
319    }
320
321    #[test]
322    fn test_add_label_rejects_unknown_outpoint() {
323        let mut wallet = test_wallet();
324        let mut changeset = LabelChangeset::new();
325        let mut labelled_wallet = LabelledWallet {
326            wallet: &mut wallet,
327            labels: &mut changeset,
328        };
329
330        let dummy_txid =
331            Txid::from_str("0000000000000000000000000000000000000000000000000000000000000000")
332                .unwrap();
333        let unknown_outpoint = OutPoint::new(dummy_txid, 0);
334
335        let output_result =
336            labelled_wallet.add_label(OutputTarget(unknown_outpoint), "Not a real UTXO");
337        let input_result =
338            labelled_wallet.add_label(InputTarget(unknown_outpoint), "Not a real input");
339
340        assert!(matches!(output_result, Err(Error::Validation(_))));
341        assert!(matches!(input_result, Err(Error::Validation(_))));
342    }
343
344    #[test]
345    fn test_add_label_preserves_existing_spendable_state() {
346        let (mut wallet, _funding_txid) = funded_test_wallet();
347        let (unspent_outpoint, _spent_outpoint) = unspent_and_spent_outpoints(&wallet);
348
349        let mut changeset = LabelChangeset::new();
350
351        changeset.insert(Label::Output(bip329::OutputRecord {
352            ref_: unspent_outpoint,
353            label: Some("Dummy Label".to_string()),
354            spendable: false,
355        }));
356
357        let mut labelled_wallet = LabelledWallet {
358            wallet: &mut wallet,
359            labels: &mut changeset,
360        };
361
362        let relabelled_output = labelled_wallet
363            .add_label(OutputTarget(unspent_outpoint), "My transaction's Output")
364            .expect("Failed to add address label");
365
366        assert!(matches!(
367            relabelled_output,
368            Label::Output(OutputRecord {
369                spendable: false,
370                ..
371            })
372        ))
373    }
374
375    #[test]
376    fn test_add_label_defaults_new_outputs_to_spendadle_true() {
377        let (mut wallet, _funding_txid) = funded_test_wallet();
378        let (unspent_outpoint, _spent_outpoint) = unspent_and_spent_outpoints(&wallet);
379
380        let mut changeset = LabelChangeset::new();
381
382        let mut labelled_wallet = LabelledWallet {
383            wallet: &mut wallet,
384            labels: &mut changeset,
385        };
386
387        let new_output = labelled_wallet
388            .add_label(OutputTarget(unspent_outpoint), "New Output")
389            .expect("Failed to add address label");
390
391        assert!(matches!(
392            new_output,
393            Label::Output(OutputRecord {
394                spendable: true,
395                ..
396            })
397        ))
398    }
399
400    #[test]
401    fn test_mock_persister_captures_only_staged_labels() {
402        let (mut wallet, funding_txid) = funded_test_wallet();
403
404        let mut changeset = LabelChangeset::new();
405
406        let mut labelled_wallet = LabelledWallet {
407            wallet: &mut wallet,
408            labels: &mut changeset,
409        };
410
411        use std::convert::Infallible;
412        pub struct MockPersister {
413            pub received_changesets: Vec<LabelChangeset>,
414        }
415
416        impl LabelPersister for MockPersister {
417            type Error = Infallible;
418
419            fn read_labels(&self) -> Result<LabelChangeset, Self::Error> {
420                Ok(LabelChangeset::default())
421            }
422
423            fn append_changeset(&mut self, changeset: &LabelChangeset) -> Result<(), Self::Error> {
424                self.received_changesets.push(changeset.clone());
425                Ok(())
426            }
427        }
428
429        let mut mock_persister = MockPersister {
430            received_changesets: vec![],
431        };
432
433        assert_eq!(mock_persister.received_changesets.len(), 0);
434
435        let transaction_label = labelled_wallet
436            .add_label(funding_txid, "Payment for Machinery")
437            .expect("Failed to add transaction label");
438
439        labelled_wallet
440            .persist(&mut mock_persister)
441            .expect("first persist should succeed");
442
443        assert_eq!(mock_persister.received_changesets.len(), 1);
444        assert_eq!(mock_persister.received_changesets[0].len(), 1);
445
446        labelled_wallet
447            .persist(&mut mock_persister)
448            .expect("second, no-op persist should succeed");
449
450        assert_eq!(
451            mock_persister.received_changesets.len(),
452            1,
453            "persist() must not re-send unchanged labels"
454        );
455
456        assert!(matches!(
457            transaction_label,
458            Label::Transaction(TransactionRecord {
459                ref_: _,
460                label: Some(_),
461                origin: _,
462            })
463        ));
464
465        let persisted_changeset = &mock_persister.received_changesets[0];
466
467        assert_eq!(
468            persisted_changeset.get(&transaction_label.ref_()),
469            Some(&transaction_label)
470        );
471    }
472
473    #[test]
474    fn test_wallet_io_delegation_roundtrip() {
475        let mut source_wallet = test_wallet();
476        let owned_address = source_wallet
477            .reveal_next_address(KeychainKind::External)
478            .address;
479
480        let mut source_changeset = LabelChangeset::new();
481
482        let mut source_labelled_wallet = LabelledWallet {
483            wallet: &mut source_wallet,
484            labels: &mut source_changeset,
485        };
486
487        assert_eq!(source_labelled_wallet.labels.len(), 0);
488
489        let address_label = source_labelled_wallet
490            .add_label(as_unchecked(owned_address.clone()), "Employee address")
491            .expect("Failed to add address label");
492
493        assert_eq!(source_labelled_wallet.labels.len(), 1);
494
495        let mut buffer = Vec::new();
496
497        source_labelled_wallet
498            .export_labels(&mut buffer)
499            .expect("Failed to export labels");
500
501        assert!(
502            !buffer.is_empty(),
503            "The exported buffer should contain data"
504        );
505
506        let mut dest_wallet = test_wallet();
507        let mut dest_changeset = LabelChangeset::new();
508        let mut dest_labelled_wallet = LabelledWallet {
509            wallet: &mut dest_wallet,
510            labels: &mut dest_changeset,
511        };
512
513        assert_eq!(dest_labelled_wallet.labels.len(), 0);
514
515        let reader = std::io::Cursor::new(buffer);
516
517        dest_labelled_wallet
518            .import_labels(reader, MergeStrategy::Overwrite)
519            .expect("Failed to import labels");
520
521        assert_eq!(dest_labelled_wallet.labels.len(), 1);
522
523        assert_eq!(
524            dest_labelled_wallet.labels.get(&address_label.ref_()),
525            Some(&address_label)
526        );
527    }
528}