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 async fn transactions(&self) -> Result<Vec<WalletTx>, PaymentError>;
42
43 async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError>;
45
46 async fn asset_utxos(&self, asset: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError>;
48
49 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 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 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 async fn sign_pset(&self, pset: &mut PartiallySignedTransaction) -> Result<(), PaymentError>;
85
86 async fn next_unused_address(&self) -> Result<Address, PaymentError>;
88
89 async fn next_unused_change_address(&self) -> Result<Address, PaymentError>;
91
92 async fn tip(&self) -> u32;
94
95 fn pubkey(&self) -> Result<String>;
97
98 fn fingerprint(&self) -> Result<String>;
100
101 fn sign_message(&self, msg: &str) -> Result<String>;
104
105 fn check_message(&self, message: &str, pubkey: &str, signature: &str) -> Result<bool>;
108
109 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 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 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 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 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 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 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 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 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 async fn tip(&self) -> u32 {
480 self.wallet.lock().await.tip().height()
481 }
482
483 fn pubkey(&self) -> Result<String> {
485 Ok(self.signer.xpub()?.public_key.to_string())
486 }
487
488 fn fingerprint(&self) -> Result<String> {
490 Ok(self.signer.fingerprint()?.to_hex())
491 }
492
493 async fn full_scan(&self) -> Result<(), PaymentError> {
495 debug!("LiquidOnchainWallet::full_scan: start");
496 let full_scan_started = Instant::now();
497
498 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 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 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 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 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 let message = "Hello, Liquid!";
611
612 let signature = wallet.sign_message(message).unwrap();
614
615 let pubkey = wallet.pubkey().unwrap();
617
618 let is_valid = wallet.check_message(message, &pubkey, &signature).unwrap();
620 assert!(is_valid, "Message signature should be valid");
621
622 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 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 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 Ok(())
654 }
655}