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 async fn transactions(&self) -> Result<Vec<WalletTx>, PaymentError>;
41
42 async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError>;
44
45 async fn asset_utxos(&self, asset: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError>;
47
48 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 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 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 async fn sign_pset(&self, pset: &mut PartiallySignedTransaction) -> Result<(), PaymentError>;
84
85 async fn next_unused_address(&self) -> Result<Address, PaymentError>;
87
88 async fn next_unused_change_address(&self) -> Result<Address, PaymentError>;
90
91 async fn tip(&self) -> u32;
93
94 fn pubkey(&self) -> Result<String>;
96
97 fn fingerprint(&self) -> Result<String>;
99
100 fn sign_message(&self, msg: &str) -> Result<String>;
103
104 fn check_message(&self, message: &str, pubkey: &str, signature: &str) -> Result<bool>;
107
108 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 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 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 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 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 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 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 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 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 async fn tip(&self) -> u32 {
484 self.wallet.lock().await.tip().height()
485 }
486
487 fn pubkey(&self) -> Result<String> {
489 Ok(self.signer.xpub()?.public_key.to_string())
490 }
491
492 fn fingerprint(&self) -> Result<String> {
494 Ok(self.signer.fingerprint()?.to_hex())
495 }
496
497 async fn full_scan(&self) -> Result<(), PaymentError> {
499 debug!("LiquidOnchainWallet::full_scan: start");
500 let full_scan_started = Instant::now();
501
502 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 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 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 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 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 let message = "Hello, Liquid!";
615
616 let signature = wallet.sign_message(message).unwrap();
618
619 let pubkey = wallet.pubkey().unwrap();
621
622 let is_valid = wallet.check_message(message, &pubkey, &signature).unwrap();
624 assert!(is_valid, "Message signature should be valid");
625
626 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 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 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 Ok(())
658 }
659}