breez_sdk_liquid/wallet/
mod.rs

1pub mod persister;
2
3use std::collections::HashMap;
4use std::io::Write;
5use std::str::FromStr;
6
7use anyhow::{anyhow, bail, Result};
8use boltz_client::ElementsAddress;
9use log::{debug, error, info, warn};
10use lwk_common::Signer as LwkSigner;
11use lwk_common::{singlesig_desc, Singlesig};
12use lwk_wollet::asyncr::{EsploraClient, EsploraClientBuilder};
13use lwk_wollet::elements::hex::ToHex;
14use lwk_wollet::elements::pset::PartiallySignedTransaction;
15use lwk_wollet::elements::{Address, AssetId, OutPoint, Transaction, TxOut, Txid};
16use lwk_wollet::secp256k1::Message;
17use lwk_wollet::{ElementsNetwork, WalletTx, WalletTxOut, Wollet, WolletDescriptor};
18use maybe_sync::{MaybeSend, MaybeSync};
19use persister::SqliteWalletCachePersister;
20use sdk_common::bitcoin::hashes::{sha256, Hash};
21use sdk_common::bitcoin::secp256k1::PublicKey;
22use sdk_common::lightning::util::message_signing::verify;
23use tokio::sync::Mutex;
24use web_time::Instant;
25
26use crate::model::{BlockchainExplorer, Signer, BREEZ_LIQUID_ESPLORA_URL};
27use crate::persist::Persister;
28use crate::signer::SdkLwkSigner;
29use crate::{ensure_sdk, error::PaymentError, model::Config};
30use sdk_common::utils::Arc;
31
32use crate::wallet::persister::WalletCachePersister;
33#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
34use lwk_wollet::blocking::BlockchainBackend;
35
36static LN_MESSAGE_PREFIX: &[u8] = b"Lightning Signed Message:";
37
38#[sdk_macros::async_trait]
39pub trait OnchainWallet: MaybeSend + MaybeSync {
40    /// List all transactions in the wallet
41    async fn transactions(&self) -> Result<Vec<WalletTx>, PaymentError>;
42
43    /// List all transactions in the wallet mapped by tx id
44    async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError>;
45
46    /// List all utxos in the wallet for a given asset
47    async fn asset_utxos(&self, asset: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError>;
48
49    /// Build a transaction to send funds to a recipient
50    async fn build_tx(
51        &self,
52        fee_rate_sats_per_kvb: Option<f32>,
53        recipient_address: &str,
54        asset_id: &str,
55        amount_sat: u64,
56    ) -> Result<Transaction, PaymentError>;
57
58    /// Builds a drain tx.
59    ///
60    /// ### Arguments
61    /// - `fee_rate_sats_per_kvb`: custom drain tx feerate
62    /// - `recipient_address`: drain tx recipient
63    /// - `enforce_amount_sat`: if set, the drain tx will only be built if the amount transferred is
64    ///   this amount, otherwise it will fail with a validation error
65    async fn build_drain_tx(
66        &self,
67        fee_rate_sats_per_kvb: Option<f32>,
68        recipient_address: &str,
69        enforce_amount_sat: Option<u64>,
70    ) -> Result<Transaction, PaymentError>;
71
72    /// Build a transaction to send funds to a recipient. If building a transaction
73    /// results in an InsufficientFunds error, attempt to build a drain transaction
74    /// validating that the `amount_sat` matches the drain output.
75    async fn build_tx_or_drain_tx(
76        &self,
77        fee_rate_sats_per_kvb: Option<f32>,
78        recipient_address: &str,
79        asset_id: &str,
80        amount_sat: u64,
81    ) -> Result<Transaction, PaymentError>;
82
83    /// Sign a partially signed transaction
84    async fn sign_pset(&self, pset: &mut PartiallySignedTransaction) -> Result<(), PaymentError>;
85
86    /// Get the next unused address in the wallet
87    async fn next_unused_address(&self) -> Result<Address, PaymentError>;
88
89    /// Get the next unused change address in the wallet
90    async fn next_unused_change_address(&self) -> Result<Address, PaymentError>;
91
92    /// Get the current tip of the blockchain the wallet is aware of
93    async fn tip(&self) -> u32;
94
95    /// Get the public key of the wallet
96    fn pubkey(&self) -> Result<String>;
97
98    /// Get the fingerprint of the wallet
99    fn fingerprint(&self) -> Result<String>;
100
101    /// Sign given message with the wallet private key. Returns a zbase
102    /// encoded signature.
103    fn sign_message(&self, msg: &str) -> Result<String>;
104
105    /// Check whether given message was signed by the given
106    /// pubkey and the signature (zbase encoded) is valid.
107    fn check_message(&self, message: &str, pubkey: &str, signature: &str) -> Result<bool>;
108
109    /// Perform a full scan of the wallet
110    async fn full_scan(&self) -> Result<(), PaymentError>;
111}
112
113pub enum WalletClient {
114    #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
115    Electrum(Box<lwk_wollet::ElectrumClient>),
116    Esplora(Box<EsploraClient>),
117}
118
119impl WalletClient {
120    pub(crate) fn from_config(config: &Config) -> Result<Self> {
121        match &config.liquid_explorer {
122            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
123            BlockchainExplorer::Electrum { url } => {
124                let client = Box::new(config.electrum_client(url)?);
125                Ok(Self::Electrum(client))
126            }
127            BlockchainExplorer::Esplora {
128                url,
129                use_waterfalls,
130            } => {
131                let waterfalls = *use_waterfalls;
132                let mut builder = EsploraClientBuilder::new(url, config.network.into());
133                if url == BREEZ_LIQUID_ESPLORA_URL {
134                    match &config.breez_api_key {
135                        Some(api_key) => {
136                            builder = builder
137                                .header("authorization".to_string(), format!("Bearer {api_key}"));
138                        }
139                        None => {
140                            let err = "Cannot start Breez Esplora client: Breez API key is not set";
141                            error!("{err}");
142                            bail!(err)
143                        }
144                    };
145                }
146                let client = Box::new(builder.timeout(3).waterfalls(waterfalls).build());
147                Ok(Self::Esplora(client))
148            }
149        }
150    }
151
152    pub(crate) async fn full_scan_to_index(
153        &mut self,
154        wallet: &mut Wollet,
155        index: u32,
156    ) -> Result<(), lwk_wollet::Error> {
157        let maybe_update = match self {
158            #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
159            WalletClient::Electrum(electrum_client) => {
160                electrum_client.full_scan_to_index(&wallet.state(), index)?
161            }
162            WalletClient::Esplora(esplora_client) => {
163                esplora_client.full_scan_to_index(wallet, index).await?
164            }
165        };
166
167        if let Some(update) = maybe_update {
168            debug!(
169                "WalletClient::full_scan_to_index: applying update {}",
170                update.version
171            );
172            wallet.apply_update(update)?;
173        }
174
175        Ok(())
176    }
177}
178
179pub struct LiquidOnchainWallet {
180    config: Config,
181    persister: std::sync::Arc<Persister>,
182    wallet: Arc<Mutex<Wollet>>,
183    client: Mutex<Option<WalletClient>>,
184    pub(crate) signer: SdkLwkSigner,
185    wallet_cache_persister: Arc<dyn WalletCachePersister>,
186}
187
188impl LiquidOnchainWallet {
189    /// Creates a new LiquidOnchainWallet that caches data on the provided `working_dir`.
190    pub(crate) async fn new(
191        config: Config,
192        persister: std::sync::Arc<Persister>,
193        user_signer: Arc<Box<dyn Signer>>,
194    ) -> Result<Self> {
195        let signer = SdkLwkSigner::new(user_signer.clone())?;
196
197        let wallet_cache_persister: Arc<dyn WalletCachePersister> =
198            Arc::new(SqliteWalletCachePersister::new(
199                std::sync::Arc::clone(&persister),
200                get_descriptor(&signer)?,
201            )?);
202
203        let wollet = Self::create_wallet(&config, &signer, wallet_cache_persister.clone()).await?;
204
205        Ok(Self {
206            config,
207            persister,
208            wallet: Arc::new(Mutex::new(wollet)),
209            client: Mutex::new(None),
210            signer,
211            wallet_cache_persister,
212        })
213    }
214
215    async fn create_wallet(
216        config: &Config,
217        signer: &SdkLwkSigner,
218        wallet_cache_persister: Arc<dyn WalletCachePersister>,
219    ) -> Result<Wollet> {
220        let elements_network: ElementsNetwork = config.network.into();
221        let descriptor = get_descriptor(signer)?;
222        let wollet_res = Wollet::new(
223            elements_network,
224            wallet_cache_persister.get_lwk_persister()?,
225            descriptor.clone(),
226        );
227        match wollet_res {
228            Ok(wollet) => Ok(wollet),
229            res @ Err(
230                lwk_wollet::Error::PersistError(_)
231                | lwk_wollet::Error::UpdateHeightTooOld { .. }
232                | lwk_wollet::Error::UpdateOnDifferentStatus { .. },
233            ) => {
234                warn!("Update error initialising wollet, wiping cache and retrying: {res:?}");
235                wallet_cache_persister.clear_cache().await?;
236                Ok(Wollet::new(
237                    elements_network,
238                    wallet_cache_persister.get_lwk_persister()?,
239                    descriptor.clone(),
240                )?)
241            }
242            Err(e) => Err(e.into()),
243        }
244    }
245
246    async fn get_txout(&self, wallet: &Wollet, outpoint: &OutPoint) -> Result<TxOut> {
247        let wallet_tx = wallet
248            .transaction(&outpoint.txid)?
249            .ok_or(anyhow!("Transaction not found"))?;
250        let tx_out = wallet_tx
251            .tx
252            .output
253            .get(outpoint.vout as usize)
254            .ok_or(anyhow!("Output not found"))?;
255        Ok(tx_out.clone())
256    }
257}
258
259pub fn get_descriptor(signer: &SdkLwkSigner) -> Result<WolletDescriptor, PaymentError> {
260    let descriptor_str = singlesig_desc(
261        signer,
262        Singlesig::Wpkh,
263        lwk_common::DescriptorBlindingKey::Slip77,
264    )
265    .map_err(|e| anyhow!("Invalid descriptor: {e}"))?;
266    Ok(descriptor_str.parse()?)
267}
268
269#[sdk_macros::async_trait]
270impl OnchainWallet for LiquidOnchainWallet {
271    /// List all transactions in the wallet
272    async fn transactions(&self) -> Result<Vec<WalletTx>, PaymentError> {
273        let wallet = self.wallet.lock().await;
274        wallet.transactions().map_err(|e| PaymentError::Generic {
275            err: format!("Failed to fetch wallet transactions: {e:?}"),
276        })
277    }
278
279    /// List all transactions in the wallet mapped by tx id
280    async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError> {
281        let tx_map: HashMap<Txid, WalletTx> = self
282            .transactions()
283            .await?
284            .iter()
285            .map(|tx| (tx.txid, tx.clone()))
286            .collect();
287        Ok(tx_map)
288    }
289
290    async fn asset_utxos(&self, asset: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError> {
291        Ok(self
292            .wallet
293            .lock()
294            .await
295            .utxos()?
296            .into_iter()
297            .filter(|utxo| &utxo.unblinded.asset == asset)
298            .collect())
299    }
300
301    /// Build a transaction to send funds to a recipient
302    async fn build_tx(
303        &self,
304        fee_rate_sats_per_kvb: Option<f32>,
305        recipient_address: &str,
306        asset_id: &str,
307        amount_sat: u64,
308    ) -> Result<Transaction, PaymentError> {
309        let lwk_wollet = self.wallet.lock().await;
310        let address =
311            ElementsAddress::from_str(recipient_address).map_err(|e| PaymentError::Generic {
312                err: format!(
313                    "Recipient address {recipient_address} is not a valid ElementsAddress: {e:?}"
314                ),
315            })?;
316        let mut tx_builder = lwk_wollet::TxBuilder::new(self.config.network.into())
317            .fee_rate(fee_rate_sats_per_kvb)
318            .enable_ct_discount();
319        if asset_id.eq(&self.config.lbtc_asset_id()) {
320            tx_builder = tx_builder.add_lbtc_recipient(&address, amount_sat)?;
321        } else {
322            let asset = AssetId::from_str(asset_id)?;
323            tx_builder = tx_builder.add_recipient(&address, amount_sat, asset)?;
324        }
325        let mut pset = tx_builder.finish(&lwk_wollet)?;
326        self.signer
327            .sign(&mut pset)
328            .map_err(|e| PaymentError::Generic {
329                err: format!("Failed to sign transaction: {e:?}"),
330            })?;
331        Ok(lwk_wollet.finalize(&mut pset)?)
332    }
333
334    async fn build_drain_tx(
335        &self,
336        fee_rate_sats_per_kvb: Option<f32>,
337        recipient_address: &str,
338        enforce_amount_sat: Option<u64>,
339    ) -> Result<Transaction, PaymentError> {
340        let lwk_wollet = self.wallet.lock().await;
341
342        let address =
343            ElementsAddress::from_str(recipient_address).map_err(|e| PaymentError::Generic {
344                err: format!(
345                    "Recipient address {recipient_address} is not a valid ElementsAddress: {e:?}"
346                ),
347            })?;
348        let mut pset = lwk_wollet
349            .tx_builder()
350            .drain_lbtc_wallet()
351            .drain_lbtc_to(address)
352            .fee_rate(fee_rate_sats_per_kvb)
353            .enable_ct_discount()
354            .finish()?;
355
356        if let Some(enforce_amount_sat) = enforce_amount_sat {
357            let pset_details = lwk_wollet.get_details(&pset)?;
358            let pset_balance_sat = pset_details
359                .balance
360                .balances
361                .get(&lwk_wollet.policy_asset())
362                .unwrap_or(&0);
363            let pset_fees = pset_details.balance.fee;
364
365            ensure_sdk!(
366                (*pset_balance_sat * -1) as u64 - pset_fees == enforce_amount_sat,
367                PaymentError::Generic {
368                    err: format!("Drain tx amount {pset_balance_sat} sat doesn't match enforce_amount_sat {enforce_amount_sat} sat")
369                }
370            );
371        }
372
373        self.signer
374            .sign(&mut pset)
375            .map_err(|e| PaymentError::Generic {
376                err: format!("Failed to sign transaction: {e:?}"),
377            })?;
378        Ok(lwk_wollet.finalize(&mut pset)?)
379    }
380
381    async fn build_tx_or_drain_tx(
382        &self,
383        fee_rate_sats_per_kvb: Option<f32>,
384        recipient_address: &str,
385        asset_id: &str,
386        amount_sat: u64,
387    ) -> Result<Transaction, PaymentError> {
388        match self
389            .build_tx(
390                fee_rate_sats_per_kvb,
391                recipient_address,
392                asset_id,
393                amount_sat,
394            )
395            .await
396        {
397            Ok(tx) => Ok(tx),
398            Err(PaymentError::InsufficientFunds) if asset_id.eq(&self.config.lbtc_asset_id()) => {
399                warn!("Cannot build tx due to insufficient funds, attempting to build drain tx");
400                self.build_drain_tx(fee_rate_sats_per_kvb, recipient_address, Some(amount_sat))
401                    .await
402            }
403            Err(e) => Err(e),
404        }
405    }
406
407    async fn sign_pset(&self, pset: &mut PartiallySignedTransaction) -> Result<(), PaymentError> {
408        let lwk_wollet = self.wallet.lock().await;
409
410        // Get the tx_out for each input and add the rangeproof/witness utxo
411        for input in pset.inputs_mut().iter_mut() {
412            let tx_out_res = self
413                .get_txout(
414                    &lwk_wollet,
415                    &OutPoint {
416                        txid: input.previous_txid,
417                        vout: input.previous_output_index,
418                    },
419                )
420                .await;
421            if let Ok(mut tx_out) = tx_out_res {
422                input.in_utxo_rangeproof = tx_out.witness.rangeproof.take();
423                input.witness_utxo = Some(tx_out);
424            }
425        }
426
427        lwk_wollet.add_details(pset)?;
428
429        self.signer.sign(pset).map_err(|e| PaymentError::Generic {
430            err: format!("Failed to sign transaction: {e:?}"),
431        })?;
432
433        // Set the final script witness for each input adding the signature and any missing public key
434        for input in pset.inputs_mut() {
435            if let Some((public_key, input_sign)) = input.partial_sigs.iter().next() {
436                input.final_script_witness = Some(vec![input_sign.clone(), public_key.to_bytes()]);
437            }
438        }
439
440        Ok(())
441    }
442
443    /// Get the next unused address in the wallet
444    async fn next_unused_address(&self) -> Result<Address, PaymentError> {
445        let tip = self.tip().await;
446        let address = match self.persister.next_expired_reserved_address(tip)? {
447            Some(reserved_address) => {
448                debug!(
449                    "Got reserved address {} that expired on block height {}",
450                    reserved_address.address, reserved_address.expiry_block_height
451                );
452                ElementsAddress::from_str(&reserved_address.address)
453                    .map_err(|e| PaymentError::Generic { err: e.to_string() })?
454            }
455            None => {
456                let next_index = self.persister.next_derivation_index()?;
457                let address_result = self.wallet.lock().await.address(next_index)?;
458                let address = address_result.address().clone();
459                let index = address_result.index();
460                debug!("Got unused address {address} with derivation index {index}");
461                if next_index.is_none() {
462                    self.persister.set_last_derivation_index(index)?;
463                }
464                address
465            }
466        };
467
468        Ok(address)
469    }
470
471    /// Get the next unused change address in the wallet
472    async fn next_unused_change_address(&self) -> Result<Address, PaymentError> {
473        let address = self.wallet.lock().await.change(None)?.address().clone();
474
475        Ok(address)
476    }
477
478    /// Get the current tip of the blockchain the wallet is aware of
479    async fn tip(&self) -> u32 {
480        self.wallet.lock().await.tip().height()
481    }
482
483    /// Get the public key of the wallet
484    fn pubkey(&self) -> Result<String> {
485        Ok(self.signer.xpub()?.public_key.to_string())
486    }
487
488    /// Get the fingerprint of the wallet
489    fn fingerprint(&self) -> Result<String> {
490        Ok(self.signer.fingerprint()?.to_hex())
491    }
492
493    /// Perform a full scan of the wallet
494    async fn full_scan(&self) -> Result<(), PaymentError> {
495        debug!("LiquidOnchainWallet::full_scan: start");
496        let full_scan_started = Instant::now();
497
498        // create electrum client if doesn't already exist
499        let mut client = self.client.lock().await;
500        if client.is_none() {
501            *client = Some(WalletClient::from_config(&self.config)?);
502        }
503        let client = client.as_mut().ok_or_else(|| PaymentError::Generic {
504            err: "Wallet client not initialized".to_string(),
505        })?;
506
507        // Use the cached derivation index with a buffer of 5 to perform the scan
508        let last_derivation_index = self
509            .persister
510            .get_last_derivation_index()?
511            .unwrap_or_default();
512        let index_with_buffer = last_derivation_index + 5;
513        let mut wallet = self.wallet.lock().await;
514
515        // Reunblind the wallet txs if there has been a change in the derivation index since the
516        // last full scan
517        if self
518            .persister
519            .get_last_scanned_derivation_index()?
520            .is_some_and(|index| index != last_derivation_index)
521        {
522            debug!("LiquidOnchainWallet::full_scan: reunblinding all transactions");
523            wallet.reunblind()?;
524        }
525
526        let res = match client
527            .full_scan_to_index(&mut wallet, index_with_buffer)
528            .await
529        {
530            Ok(()) => Ok(()),
531            Err(e)
532                if matches!(
533                    e,
534                    lwk_wollet::Error::UpdateHeightTooOld { .. }
535                        | lwk_wollet::Error::PersistError(_)
536                ) =>
537            {
538                warn!("Full scan failed due to {e}, reloading wallet and retrying");
539                let mut new_wallet = Self::create_wallet(
540                    &self.config,
541                    &self.signer,
542                    self.wallet_cache_persister.clone(),
543                )
544                .await?;
545                client
546                    .full_scan_to_index(&mut new_wallet, index_with_buffer)
547                    .await?;
548                *wallet = new_wallet;
549                Ok(())
550            }
551            Err(e) => Err(e.into()),
552        };
553
554        self.persister
555            .set_last_scanned_derivation_index(last_derivation_index)?;
556
557        let duration_ms = Instant::now().duration_since(full_scan_started).as_millis();
558        info!("lwk wallet full_scan duration: ({duration_ms} ms)");
559        debug!("LiquidOnchainWallet::full_scan: end");
560        res
561    }
562
563    fn sign_message(&self, message: &str) -> Result<String> {
564        // Prefix and double hash message
565        let mut engine = sha256::HashEngine::default();
566        engine.write_all(LN_MESSAGE_PREFIX)?;
567        engine.write_all(message.as_bytes())?;
568        let hashed_msg = sha256::Hash::from_engine(engine);
569        let double_hashed_msg = Message::from_digest(sha256::Hash::hash(&hashed_msg).into_inner());
570        // Get message signature and encode to zbase32
571        let recoverable_sig = self.signer.sign_ecdsa_recoverable(&double_hashed_msg)?;
572        Ok(zbase32::encode_full_bytes(recoverable_sig.as_slice()))
573    }
574
575    fn check_message(&self, message: &str, pubkey: &str, signature: &str) -> Result<bool> {
576        let pk = PublicKey::from_str(pubkey)?;
577        Ok(verify(message.as_bytes(), signature, &pk))
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::model::Config;
585    use crate::signer::SdkSigner;
586    use crate::test_utils::persist::create_persister;
587    use crate::wallet::LiquidOnchainWallet;
588    use anyhow::Result;
589
590    #[cfg(feature = "browser-tests")]
591    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
592
593    #[sdk_macros::async_test_all]
594    async fn test_sign_and_check_message() -> Result<()> {
595        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
596        let sdk_signer: Box<dyn Signer> = Box::new(SdkSigner::new(mnemonic, "", false).unwrap());
597        let sdk_signer = Arc::new(sdk_signer);
598
599        let config = Config::testnet_esplora(None);
600
601        create_persister!(storage);
602
603        let wallet: Arc<dyn OnchainWallet> = Arc::new(
604            LiquidOnchainWallet::new(config, storage, sdk_signer.clone())
605                .await
606                .unwrap(),
607        );
608
609        // Test message
610        let message = "Hello, Liquid!";
611
612        // Sign the message
613        let signature = wallet.sign_message(message).unwrap();
614
615        // Get the public key
616        let pubkey = wallet.pubkey().unwrap();
617
618        // Check the message
619        let is_valid = wallet.check_message(message, &pubkey, &signature).unwrap();
620        assert!(is_valid, "Message signature should be valid");
621
622        // Check with an incorrect message
623        let incorrect_message = "Wrong message";
624        let is_invalid = wallet
625            .check_message(incorrect_message, &pubkey, &signature)
626            .unwrap();
627        assert!(
628            !is_invalid,
629            "Message signature should be invalid for incorrect message"
630        );
631
632        // Check with an incorrect public key
633        let incorrect_pubkey = "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc";
634        let is_invalid = wallet
635            .check_message(message, incorrect_pubkey, &signature)
636            .unwrap();
637        assert!(
638            !is_invalid,
639            "Message signature should be invalid for incorrect public key"
640        );
641
642        // Check with an incorrect signature
643        let incorrect_signature = zbase32::encode_full_bytes(&[0; 65]);
644        let is_invalid = wallet
645            .check_message(message, &pubkey, &incorrect_signature)
646            .unwrap();
647        assert!(
648            !is_invalid,
649            "Message signature should be invalid for incorrect signature"
650        );
651
652        // The temporary directory will be automatically deleted when temp_dir goes out of scope
653        Ok(())
654    }
655}