breez_sdk_liquid/wallet/
mod.rs

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