1pub(crate) mod network_fee;
2pub mod persister;
3pub(crate) mod utxo_select;
4
5use std::collections::HashMap;
6use std::io::Write;
7use std::str::FromStr;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11use anyhow::{anyhow, bail, Result};
12use boltz_client::ElementsAddress;
13use log::{debug, error, info, warn};
14use lwk_common::Signer as LwkSigner;
15use lwk_common::{singlesig_desc, Singlesig};
16use lwk_wollet::asyncr::{EsploraClient, EsploraClientBuilder};
17use lwk_wollet::elements::hex::ToHex;
18use lwk_wollet::elements::pset::PartiallySignedTransaction;
19use lwk_wollet::elements::{Address, AssetId, OutPoint, Transaction, TxOut, Txid};
20use lwk_wollet::secp256k1::Message;
21use lwk_wollet::{Network, WalletTx, WalletTxOut, Wollet, WolletDescriptor};
22use persister::SqliteWalletCachePersister;
23use sdk_common::bitcoin::hashes::{sha256, Hash};
24use sdk_common::bitcoin::secp256k1::PublicKey;
25use sdk_common::lightning::util::message_signing::verify;
26use tokio::sync::Mutex;
27use utxo_select::{InOut, WalletUtxoSelectRequest};
28use web_time::Instant;
29
30use crate::model::{BlockchainExplorer, Signer, BREEZ_LIQUID_ESPLORA_URL};
31use crate::persist::Persister;
32use crate::signer::SdkLwkSigner;
33use crate::{ensure_sdk, error::PaymentError, model::Config};
34
35use crate::wallet::persister::WalletCachePersister;
36#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
37use lwk_wollet::blocking::BlockchainBackend;
38
39static LN_MESSAGE_PREFIX: &[u8] = b"Lightning Signed Message:";
40
41#[sdk_macros::async_trait]
42pub trait OnchainWallet: Send + Sync {
43 async fn transactions(&self) -> Result<Vec<WalletTx>, PaymentError>;
45
46 async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError>;
48
49 async fn asset_utxos(&self, asset: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError>;
51
52 async fn build_tx(
54 &self,
55 fee_rate_sats_per_kvb: Option<f32>,
56 recipient_address: &str,
57 asset_id: &str,
58 amount_sat: u64,
59 ) -> Result<Transaction, PaymentError>;
60
61 async fn build_drain_tx(
69 &self,
70 fee_rate_sats_per_kvb: Option<f32>,
71 recipient_address: &str,
72 enforce_amount_sat: Option<u64>,
73 ) -> Result<Transaction, PaymentError>;
74
75 async fn build_tx_or_drain_tx(
79 &self,
80 fee_rate_sats_per_kvb: Option<f32>,
81 recipient_address: &str,
82 asset_id: &str,
83 amount_sat: u64,
84 ) -> Result<Transaction, PaymentError>;
85
86 async fn sign_pset(&self, pset: &mut PartiallySignedTransaction) -> Result<(), PaymentError>;
88
89 async fn next_unused_address(&self) -> Result<Address, PaymentError>;
91
92 async fn next_unused_change_address(&self) -> Result<Address, PaymentError>;
94
95 async fn tip(&self) -> u32;
97
98 fn pubkey(&self) -> Result<String>;
100
101 fn fingerprint(&self) -> Result<String>;
103
104 fn sign_message(&self, msg: &str) -> Result<String>;
107
108 fn check_message(&self, message: &str, pubkey: &str, signature: &str) -> Result<bool>;
111
112 async fn full_scan(&self) -> Result<(), PaymentError>;
114
115 async fn apply_broadcast_tx(&self, tx: &Transaction);
118
119 async fn repair_cache(&self) -> Result<bool, PaymentError>;
125}
126
127pub(crate) async fn handle_stale_cache_broadcast_error(
132 onchain_wallet: &dyn OnchainWallet,
133 err: anyhow::Error,
134) -> PaymentError {
135 if !crate::error::is_txn_inputs_missing_or_spent_error(&err) {
136 return err.into();
137 }
138 warn!("Broadcast rejected for spending inputs the node does not have, repairing the cache");
139 if let Err(e) = onchain_wallet.repair_cache().await {
140 warn!("Could not repair the wallet cache: {e}");
141 }
142 PaymentError::Generic {
143 err: format!("Wallet state was out of date, please retry shortly: {err}"),
144 }
145}
146
147pub enum WalletClient {
148 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
149 Electrum(Box<lwk_wollet::ElectrumClient>),
150 Esplora(Box<EsploraClient>),
151}
152
153impl WalletClient {
154 pub(crate) fn from_config(config: &Config) -> Result<Self> {
155 match &config.liquid_explorer {
156 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
157 BlockchainExplorer::Electrum { url } => {
158 let client = Box::new(config.electrum_client(url)?);
159 Ok(Self::Electrum(client))
160 }
161 BlockchainExplorer::Esplora {
162 url,
163 use_waterfalls,
164 } => {
165 let waterfalls = *use_waterfalls;
166 let mut builder = EsploraClientBuilder::new(url, config.network.into());
167 if url == BREEZ_LIQUID_ESPLORA_URL {
168 match &config.breez_api_key {
169 Some(api_key) => {
170 builder = builder
171 .header("authorization".to_string(), format!("Bearer {api_key}"));
172 }
173 None => {
174 let err = "Cannot start Breez Esplora client: Breez API key is not set";
175 error!("{err}");
176 bail!(err)
177 }
178 };
179 }
180 let client = builder
181 .timeout(config.onchain_sync_request_timeout_sec as u8)
182 .waterfalls(waterfalls)
183 .build()?;
184 Ok(Self::Esplora(Box::new(client)))
185 }
186 }
187 }
188
189 pub(crate) async fn full_scan_to_index(
190 &mut self,
191 wallet: &mut Wollet,
192 index: u32,
193 ) -> Result<(), lwk_wollet::Error> {
194 let maybe_update = match self {
195 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
196 WalletClient::Electrum(electrum_client) => {
197 electrum_client.full_scan_to_index(&wallet.state(), index)?
198 }
199 WalletClient::Esplora(esplora_client) => {
200 esplora_client.full_scan_to_index(wallet, index).await?
201 }
202 };
203
204 if let Some(update) = maybe_update {
205 debug!(
206 "WalletClient::full_scan_to_index: applying update {}",
207 update.version
208 );
209 wallet.apply_update(update)?;
210 }
211
212 Ok(())
213 }
214}
215
216pub struct LiquidOnchainWallet {
217 config: Config,
218 persister: std::sync::Arc<Persister>,
219 wallet: Arc<Mutex<Wollet>>,
220 client: Mutex<Option<WalletClient>>,
221 pub(crate) signer: SdkLwkSigner,
222 wallet_cache_persister: Arc<dyn WalletCachePersister>,
223 needs_cache_check: AtomicBool,
225 needs_cache_clear: AtomicBool,
227 recovery_scan_in_progress: AtomicBool,
230 cache_wiped: AtomicBool,
233}
234
235struct FlagGuard<'a>(&'a AtomicBool);
237
238impl Drop for FlagGuard<'_> {
239 fn drop(&mut self) {
240 self.0.store(false, Ordering::Relaxed);
241 }
242}
243
244pub(crate) fn find_spent_utxos(txs: &[WalletTx], utxos: &[WalletTxOut]) -> Vec<OutPoint> {
250 let spent: std::collections::HashSet<OutPoint> = txs
251 .iter()
252 .flat_map(|wtx| wtx.tx.input.iter().map(|i| i.previous_output))
253 .collect();
254 utxos
255 .iter()
256 .map(|u| u.outpoint)
257 .filter(|o| spent.contains(o))
258 .collect()
259}
260
261impl LiquidOnchainWallet {
262 pub(crate) async fn new(
264 config: Config,
265 persister: std::sync::Arc<Persister>,
266 user_signer: Arc<Box<dyn Signer>>,
267 ) -> Result<Self> {
268 let signer = SdkLwkSigner::new(user_signer.clone())?;
269
270 let wallet_cache_persister: Arc<dyn WalletCachePersister> = Arc::new(
271 SqliteWalletCachePersister::new(std::sync::Arc::clone(&persister))?,
272 );
273
274 let wollet = Self::create_wallet(&config, &signer, wallet_cache_persister.clone()).await?;
275
276 Ok(Self {
277 config,
278 persister,
279 wallet: Arc::new(Mutex::new(wollet)),
280 client: Mutex::new(None),
281 signer,
282 wallet_cache_persister,
283 needs_cache_check: AtomicBool::new(true),
286 needs_cache_clear: AtomicBool::new(false),
287 cache_wiped: AtomicBool::new(false),
288 recovery_scan_in_progress: AtomicBool::new(false),
289 })
290 }
291
292 fn schedule_cache_wipe(&self) -> bool {
300 if self.cache_wiped.load(Ordering::Relaxed) {
301 error!(
302 "Wallet cache is still inconsistent after a full rescan this session, not wiping \
303 again. Coin selection may keep offering spent utxos until the next restart."
304 );
305 return false;
306 }
307 warn!("Flagging the wallet cache to be wiped and rebuilt on the next scan");
308 self.needs_cache_clear.store(true, Ordering::Relaxed);
309 true
310 }
311
312 async fn check_and_repair_cache(&self) -> Result<(), PaymentError> {
313 if !self.needs_cache_check.swap(false, Ordering::Relaxed) {
314 return Ok(());
315 }
316
317 let spent = {
318 let wallet = self.wallet.lock().await;
319 let txs = wallet.transactions()?;
320 let utxos = wallet.utxos()?;
321 find_spent_utxos(&txs, &utxos)
322 };
323
324 if spent.is_empty() {
325 if self.cache_wiped.load(Ordering::Relaxed) {
326 info!("Wallet cache verified clean after the rescan");
327 } else {
328 debug!("Wallet cache check: no utxo is spent by a known tx");
329 }
330 return Ok(());
331 }
332
333 error!(
334 "Wallet cache is inconsistent: {} utxo(s) are already spent by known txs. {spent:?}",
335 spent.len()
336 );
337
338 self.repair_cache().await?;
340 Ok(())
341 }
342
343 async fn create_wallet(
344 config: &Config,
345 signer: &SdkLwkSigner,
346 wallet_cache_persister: Arc<dyn WalletCachePersister>,
347 ) -> Result<Wollet> {
348 let network: Network = config.network.into();
349 let descriptor = get_descriptor(signer)?;
350 let build_wollet = |persister: persister::LwkPersister| {
351 lwk_wollet::WolletBuilder::new(network, descriptor.clone())
352 .with_updates_store(persister)
353 .build()
354 };
355 let wollet_res = build_wollet(wallet_cache_persister.get_lwk_persister()?);
356 match wollet_res {
357 Ok(wollet) => Ok(wollet),
358 res @ Err(
359 lwk_wollet::Error::UpdateHeightTooOld { .. }
360 | lwk_wollet::Error::UpdateOnDifferentStatus { .. }
361 | lwk_wollet::Error::StoreError(_),
362 ) => {
363 warn!("Update error initialising wollet, wiping cache and retrying: {res:?}");
364 wallet_cache_persister.clear_cache().await?;
365 Ok(build_wollet(wallet_cache_persister.get_lwk_persister()?)?)
366 }
367 Err(e) => Err(e.into()),
368 }
369 }
370
371 async fn get_txout(&self, wallet: &Wollet, outpoint: &OutPoint) -> Result<TxOut> {
372 let wallet_tx = wallet
373 .transaction(&outpoint.txid)?
374 .ok_or(anyhow!("Transaction not found"))?;
375 let tx_out = wallet_tx
376 .tx
377 .output
378 .get(outpoint.vout as usize)
379 .ok_or(anyhow!("Output not found"))?;
380 Ok(tx_out.clone())
381 }
382
383 fn select_wallet_utxos(
384 &self,
385 wallet: &Wollet,
386 policy_asset: AssetId,
387 selection_asset: AssetId,
388 recipient_outputs: Vec<InOut>,
389 fee_rate_sats_per_kvb: Option<f32>,
390 ) -> Result<Vec<OutPoint>, PaymentError> {
391 let mut wallet_utxos = wallet.utxos()?;
392 debug!(
393 "Wallet utxos: {:?}",
394 wallet_utxos
395 .iter()
396 .map(|tx_out| format!(
397 "{}:{}, value: {}",
398 tx_out.outpoint.txid, tx_out.outpoint.vout, tx_out.unblinded.value
399 ))
400 .collect::<Vec<_>>()
401 );
402 let fee_rate = fee_rate_sats_per_kvb.map(|rate| rate as f64 / 1000.0);
403 let selected_in_outs = utxo_select::utxo_select(WalletUtxoSelectRequest {
404 policy_asset,
405 selection_asset,
406 wallet_utxos: wallet_utxos.iter().map(Into::into).collect(),
407 recipient_outputs,
408 fee_rate,
409 })?;
410 let selected_utxos = Self::resolve_selected_utxos(&mut wallet_utxos, &selected_in_outs)?;
411 debug!(
412 "Selected wallet outputs: {:?}",
413 selected_utxos
414 .iter()
415 .map(|outpoint| format!("{}:{}", outpoint.txid, outpoint.vout))
416 .collect::<Vec<_>>()
417 );
418 Ok(selected_utxos)
419 }
420
421 fn select_asset_and_fee_utxos(
429 &self,
430 wallet: &Wollet,
431 asset: AssetId,
432 amount_sat: u64,
433 fee_rate_sats_per_kvb: Option<f32>,
434 ) -> Result<Vec<OutPoint>, PaymentError> {
435 let policy_asset = wallet.policy_asset();
436 ensure_sdk!(
437 asset != policy_asset,
438 PaymentError::generic("select_asset_and_fee_utxos called for the policy asset")
439 );
440
441 let mut wallet_utxos = wallet.utxos()?;
442
443 let asset_values = wallet_utxos
445 .iter()
446 .filter(|tx_out| tx_out.unblinded.asset == asset)
447 .map(|tx_out| tx_out.unblinded.value)
448 .collect::<Vec<_>>();
449 let selected_asset_values = utxo_select::utxo_select_best(amount_sat, &asset_values)
450 .ok_or_else(|| PaymentError::generic("Failed to select asset utxos"))?;
451 let asset_input_count = selected_asset_values.len();
452
453 let fee_rate = fee_rate_sats_per_kvb.map(|rate| rate as f64 / 1000.0);
456 let policy_values = wallet_utxos
457 .iter()
458 .filter(|tx_out| tx_out.unblinded.asset == policy_asset)
459 .map(|tx_out| tx_out.unblinded.value)
460 .collect::<Vec<_>>();
461 let selected_fee_values = utxo_select::utxo_select_dynamic(
462 0,
463 &policy_values,
464 |lbtc_input_count, change_count| {
465 network_fee::TxFee {
466 native_inputs: asset_input_count + lbtc_input_count,
467 nested_inputs: 0,
468 outputs: 2 + change_count,
470 }
471 .fee(fee_rate)
472 },
473 )
474 .ok_or_else(|| PaymentError::generic("Failed to select L-BTC utxos for fee"))?;
475
476 let selected = selected_asset_values
478 .into_iter()
479 .map(|value| InOut {
480 asset_id: asset,
481 value,
482 })
483 .chain(selected_fee_values.into_iter().map(|value| InOut {
484 asset_id: policy_asset,
485 value,
486 }))
487 .collect::<Vec<_>>();
488 Self::resolve_selected_utxos(&mut wallet_utxos, &selected)
489 }
490
491 fn resolve_selected_utxos(
495 wallet_utxos: &mut Vec<WalletTxOut>,
496 selected: &[InOut],
497 ) -> Result<Vec<OutPoint>, PaymentError> {
498 let selected_utxos = selected
499 .iter()
500 .filter_map(|in_out| {
501 wallet_utxos
502 .iter()
503 .position(|tx_out| {
504 tx_out.unblinded.asset == in_out.asset_id
505 && tx_out.unblinded.value == in_out.value
506 })
507 .map(|index| wallet_utxos.remove(index).outpoint)
508 })
509 .collect::<Vec<_>>();
510 ensure_sdk!(
511 selected_utxos.len() == selected.len(),
512 PaymentError::generic("Failed to resolve selected wallet utxos to outpoints")
513 );
514 Ok(selected_utxos)
515 }
516}
517
518pub fn get_descriptor(signer: &SdkLwkSigner) -> Result<WolletDescriptor, PaymentError> {
519 let descriptor_str = singlesig_desc(
520 signer,
521 Singlesig::Wpkh,
522 lwk_common::DescriptorBlindingKey::Slip77,
523 )
524 .map_err(|e| anyhow!("Invalid descriptor: {e}"))?;
525 Ok(descriptor_str.parse()?)
526}
527
528#[sdk_macros::async_trait]
529impl OnchainWallet for LiquidOnchainWallet {
530 async fn transactions(&self) -> Result<Vec<WalletTx>, PaymentError> {
532 let wallet = self.wallet.lock().await;
533 wallet.transactions().map_err(|e| PaymentError::Generic {
534 err: format!("Failed to fetch wallet transactions: {e:?}"),
535 })
536 }
537
538 async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError> {
540 let tx_map: HashMap<Txid, WalletTx> = self
541 .transactions()
542 .await?
543 .iter()
544 .map(|tx| (tx.txid, tx.clone()))
545 .collect();
546 Ok(tx_map)
547 }
548
549 async fn asset_utxos(&self, asset: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError> {
550 Ok(self
551 .wallet
552 .lock()
553 .await
554 .utxos()?
555 .into_iter()
556 .filter(|utxo| &utxo.unblinded.asset == asset)
557 .collect())
558 }
559
560 async fn build_tx(
562 &self,
563 fee_rate_sats_per_kvb: Option<f32>,
564 recipient_address: &str,
565 asset_id: &str,
566 amount_sat: u64,
567 ) -> Result<Transaction, PaymentError> {
568 ensure_sdk!(
569 !self.recovery_scan_in_progress.load(Ordering::Relaxed),
570 PaymentError::Generic {
571 err: "Wallet state is being repaired, please retry shortly".to_string()
572 }
573 );
574 let lwk_wollet = self.wallet.lock().await;
575 let address =
576 ElementsAddress::from_str(recipient_address).map_err(|e| PaymentError::Generic {
577 err: format!(
578 "Recipient address {recipient_address} is not a valid ElementsAddress: {e:?}"
579 ),
580 })?;
581 let mut tx_builder = lwk_wollet::TxBuilder::new(self.config.network.into())
582 .fee_rate(fee_rate_sats_per_kvb)
583 .enable_ct_discount();
584 if asset_id.eq(&self.config.lbtc_asset_id()) {
585 let policy_asset = lwk_wollet.policy_asset();
588 match self.select_wallet_utxos(
591 &lwk_wollet,
592 policy_asset,
593 policy_asset,
594 vec![InOut {
595 asset_id: policy_asset,
596 value: amount_sat,
597 }],
598 fee_rate_sats_per_kvb,
599 ) {
600 Ok(wallet_utxos) => {
601 tx_builder = tx_builder.set_wallet_utxos(wallet_utxos);
602 }
603 Err(e) => warn!("Failed to select wallet utxos: {e:?}"),
604 }
605 tx_builder = tx_builder.add_lbtc_recipient(&address, amount_sat)?;
607 } else {
608 let asset = AssetId::from_str(asset_id)?;
610 match self.select_asset_and_fee_utxos(
614 &lwk_wollet,
615 asset,
616 amount_sat,
617 fee_rate_sats_per_kvb,
618 ) {
619 Ok(wallet_utxos) => {
620 tx_builder = tx_builder.set_wallet_utxos(wallet_utxos);
621 }
622 Err(e) => warn!("Failed to select asset and fee wallet utxos: {e:?}"),
623 }
624 tx_builder = tx_builder.add_recipient(&address, amount_sat, asset)?;
625 }
626 let mut pset = tx_builder.finish(&lwk_wollet)?;
627 self.signer
628 .sign(&mut pset)
629 .map_err(|e| PaymentError::Generic {
630 err: format!("Failed to sign transaction: {e:?}"),
631 })?;
632 Ok(lwk_wollet.finalize(&mut pset)?)
633 }
634
635 async fn build_drain_tx(
636 &self,
637 fee_rate_sats_per_kvb: Option<f32>,
638 recipient_address: &str,
639 enforce_amount_sat: Option<u64>,
640 ) -> Result<Transaction, PaymentError> {
641 ensure_sdk!(
642 !self.recovery_scan_in_progress.load(Ordering::Relaxed),
643 PaymentError::Generic {
644 err: "Wallet state is being repaired, please retry shortly".to_string()
645 }
646 );
647 let lwk_wollet = self.wallet.lock().await;
648
649 let address =
650 ElementsAddress::from_str(recipient_address).map_err(|e| PaymentError::Generic {
651 err: format!(
652 "Recipient address {recipient_address} is not a valid ElementsAddress: {e:?}"
653 ),
654 })?;
655 let mut pset = lwk_wollet
656 .tx_builder()
657 .drain_lbtc_wallet()
658 .drain_lbtc_to(address)
659 .fee_rate(fee_rate_sats_per_kvb)
660 .enable_ct_discount()
661 .finish()?;
662
663 if let Some(enforce_amount_sat) = enforce_amount_sat {
664 let pset_details = lwk_wollet.get_details(&pset)?;
665 let pset_balance_sat = pset_details
666 .balance
667 .balances
668 .get(&lwk_wollet.policy_asset())
669 .unwrap_or(&0);
670 let pset_fees = pset_details.balance.fees_in(&lwk_wollet.policy_asset());
671
672 ensure_sdk!(
673 (*pset_balance_sat * -1) as u64 - pset_fees == enforce_amount_sat,
674 PaymentError::Generic {
675 err: format!("Drain tx amount {pset_balance_sat} sat doesn't match enforce_amount_sat {enforce_amount_sat} sat")
676 }
677 );
678 }
679
680 self.signer
681 .sign(&mut pset)
682 .map_err(|e| PaymentError::Generic {
683 err: format!("Failed to sign transaction: {e:?}"),
684 })?;
685 Ok(lwk_wollet.finalize(&mut pset)?)
686 }
687
688 async fn build_tx_or_drain_tx(
689 &self,
690 fee_rate_sats_per_kvb: Option<f32>,
691 recipient_address: &str,
692 asset_id: &str,
693 amount_sat: u64,
694 ) -> Result<Transaction, PaymentError> {
695 match self
696 .build_tx(
697 fee_rate_sats_per_kvb,
698 recipient_address,
699 asset_id,
700 amount_sat,
701 )
702 .await
703 {
704 Ok(tx) => Ok(tx),
705 Err(PaymentError::InsufficientFunds) if asset_id.eq(&self.config.lbtc_asset_id()) => {
706 warn!("Cannot build tx due to insufficient funds, attempting to build drain tx");
707 self.build_drain_tx(fee_rate_sats_per_kvb, recipient_address, Some(amount_sat))
708 .await
709 }
710 Err(e) => Err(e),
711 }
712 }
713
714 async fn sign_pset(&self, pset: &mut PartiallySignedTransaction) -> Result<(), PaymentError> {
715 let lwk_wollet = self.wallet.lock().await;
716
717 for input in pset.inputs_mut().iter_mut() {
719 let tx_out_res = self
720 .get_txout(
721 &lwk_wollet,
722 &OutPoint {
723 txid: input.previous_txid,
724 vout: input.previous_output_index,
725 },
726 )
727 .await;
728 if let Ok(mut tx_out) = tx_out_res {
729 input.in_utxo_rangeproof = tx_out.witness.rangeproof.take();
730 input.witness_utxo = Some(tx_out);
731 }
732 }
733
734 lwk_wollet.add_details(pset)?;
735
736 self.signer.sign(pset).map_err(|e| PaymentError::Generic {
737 err: format!("Failed to sign transaction: {e:?}"),
738 })?;
739
740 for input in pset.inputs_mut() {
742 if let Some((public_key, input_sign)) = input.partial_sigs.iter().next() {
743 input.final_script_witness = Some(vec![input_sign.clone(), public_key.to_bytes()]);
744 }
745 }
746
747 Ok(())
748 }
749
750 async fn next_unused_address(&self) -> Result<Address, PaymentError> {
752 let tip = self.tip().await;
753 let address = match self.persister.next_expired_reserved_address(tip)? {
754 Some(reserved_address) => {
755 debug!(
756 "Got reserved address {} that expired on block height {}",
757 reserved_address.address, reserved_address.expiry_block_height
758 );
759 ElementsAddress::from_str(&reserved_address.address)
760 .map_err(|e| PaymentError::Generic { err: e.to_string() })?
761 }
762 None => {
763 let next_index = self.persister.next_derivation_index()?;
764 let address_result = self.wallet.lock().await.address(next_index)?;
765 let address = address_result.address().clone();
766 let index = address_result.index();
767 debug!("Got unused address {address} with derivation index {index}");
768 if next_index.is_none() {
769 self.persister.set_last_derivation_index(index)?;
770 }
771 address
772 }
773 };
774
775 Ok(address)
776 }
777
778 async fn next_unused_change_address(&self) -> Result<Address, PaymentError> {
780 let address = self.wallet.lock().await.change(None)?.address().clone();
781
782 Ok(address)
783 }
784
785 async fn tip(&self) -> u32 {
787 self.wallet.lock().await.tip().height()
788 }
789
790 fn pubkey(&self) -> Result<String> {
792 Ok(self.signer.xpub()?.public_key.to_string())
793 }
794
795 fn fingerprint(&self) -> Result<String> {
797 Ok(self.signer.fingerprint()?.to_hex())
798 }
799
800 async fn full_scan(&self) -> Result<(), PaymentError> {
802 {
804 debug!("LiquidOnchainWallet::full_scan: start");
805 let full_scan_started = Instant::now();
806
807 let mut client = self.client.lock().await;
809 if client.is_none() {
810 *client = Some(WalletClient::from_config(&self.config)?);
811 }
812 let client = client.as_mut().ok_or_else(|| PaymentError::Generic {
813 err: "Wallet client not initialized".to_string(),
814 })?;
815
816 let last_derivation_index = self
818 .persister
819 .get_last_derivation_index()?
820 .unwrap_or_default();
821 let index_with_buffer = last_derivation_index + 5;
822 let mut wallet = self.wallet.lock().await;
823
824 let clearing = self.needs_cache_clear.load(Ordering::Relaxed);
827 let _recovery_guard = clearing.then(|| {
830 warn!("Wiping the wallet cache; this scan will rebuild it from scratch");
831 self.recovery_scan_in_progress
832 .store(true, Ordering::Relaxed);
833 FlagGuard(&self.recovery_scan_in_progress)
834 });
835 if clearing {
836 self.wallet_cache_persister.clear_cache().await?;
837 *wallet = Self::create_wallet(
838 &self.config,
839 &self.signer,
840 self.wallet_cache_persister.clone(),
841 )
842 .await?;
843 self.needs_cache_clear.store(false, Ordering::Relaxed);
846 self.cache_wiped.store(true, Ordering::Relaxed);
847 self.needs_cache_check.store(true, Ordering::Relaxed);
850 }
851
852 if self
855 .persister
856 .get_last_scanned_derivation_index()?
857 .is_some_and(|index| index != last_derivation_index)
858 {
859 debug!("LiquidOnchainWallet::full_scan: reunblinding all transactions");
860 wallet.reunblind()?;
861 }
862
863 let res: Result<(), PaymentError> = match client
864 .full_scan_to_index(&mut wallet, index_with_buffer)
865 .await
866 {
867 Ok(()) => Ok(()),
868 Err(e)
869 if matches!(
870 e,
871 lwk_wollet::Error::UpdateHeightTooOld { .. }
872 | lwk_wollet::Error::UpdateOnDifferentStatus { .. }
873 | lwk_wollet::Error::StoreError(_)
874 ) =>
875 {
876 warn!("Full scan failed due to {e}, reloading wallet and retrying");
877 let mut new_wallet = Self::create_wallet(
878 &self.config,
879 &self.signer,
880 self.wallet_cache_persister.clone(),
881 )
882 .await?;
883 let rescan_res = client
884 .full_scan_to_index(&mut new_wallet, index_with_buffer)
885 .await;
886 *wallet = new_wallet;
890 rescan_res?;
891 Ok(())
892 }
893 Err(e) => Err(e.into()),
894 };
895
896 self.persister
897 .set_last_scanned_derivation_index(last_derivation_index)?;
898
899 let duration_ms = Instant::now().duration_since(full_scan_started).as_millis();
900 info!("lwk wallet full_scan duration: ({duration_ms} ms)");
901 debug!("LiquidOnchainWallet::full_scan: end");
902 res?;
903 }
904
905 if let Err(e) = self.check_and_repair_cache().await {
908 error!("Wallet cache check failed, continuing: {e}");
909 }
910 Ok(())
911 }
912
913 async fn apply_broadcast_tx(&self, tx: &Transaction) {
914 let mut wallet = self.wallet.lock().await;
917 match wallet.apply_transaction(tx.clone()) {
918 Ok(_) => debug!("Applied broadcast tx {} to the wallet state", tx.txid()),
919 Err(e) => warn!(
920 "Could not apply broadcast tx {} to the wallet state: {e}",
921 tx.txid()
922 ),
923 }
924 }
925
926 async fn repair_cache(&self) -> Result<bool, PaymentError> {
927 use std::collections::HashSet;
928
929 let mut wallet = self.wallet.lock().await;
930 let txs = wallet.transactions()?;
931 let stale: HashSet<OutPoint> = find_spent_utxos(&txs, &wallet.utxos()?)
932 .into_iter()
933 .collect();
934 if stale.is_empty() {
935 debug!("Wallet cache repair found nothing to do; the unspent set is unchanged");
936 return Ok(true);
937 }
938
939 let mut to_apply: Vec<&WalletTx> = vec![];
943 let mut queued: HashSet<Txid> = HashSet::new();
944 let mut frontier = stale.clone();
945 while !frontier.is_empty() {
946 let spenders: Vec<&WalletTx> = txs
947 .iter()
948 .filter(|wtx| !queued.contains(&wtx.txid))
949 .filter(|wtx| {
950 wtx.tx
951 .input
952 .iter()
953 .any(|i| frontier.contains(&i.previous_output))
954 })
955 .collect();
956 if spenders.is_empty() {
957 break;
958 }
959 frontier = spenders
961 .iter()
962 .flat_map(|wtx| wtx.outputs.iter().flatten().map(|o| o.outpoint))
963 .collect();
964 for wtx in spenders {
965 queued.insert(wtx.txid);
966 to_apply.push(wtx);
967 }
968 }
969
970 if to_apply.is_empty() {
971 warn!(
972 "Wallet cache has {} stale utxo(s) with no known spender",
973 stale.len()
974 );
975 self.schedule_cache_wipe();
976 return Ok(false);
977 }
978
979 to_apply.sort_by_key(|wtx| wtx.height.unwrap_or(u32::MAX));
981
982 warn!(
983 "Repairing wallet cache locally: re-applying {} tx(s) that spend {} stale utxo(s)",
984 to_apply.len(),
985 stale.len()
986 );
987 for wtx in to_apply {
988 let txid = wtx.txid;
989 if let Err(e) = wallet.apply_transaction(wtx.tx.clone()) {
991 warn!("Could not re-apply tx {txid} while repairing the wallet cache: {e}");
992 }
993 }
994
995 let remaining = find_spent_utxos(&wallet.transactions()?, &wallet.utxos()?);
996 if remaining.is_empty() {
997 info!("Wallet cache repaired locally, no rescan needed");
998 return Ok(true);
999 }
1000 warn!(
1001 "Local wallet cache repair left {} utxo(s) unresolved",
1002 remaining.len()
1003 );
1004 self.schedule_cache_wipe();
1005 Ok(false)
1006 }
1007
1008 fn sign_message(&self, message: &str) -> Result<String> {
1009 let mut engine = sha256::HashEngine::default();
1011 engine.write_all(LN_MESSAGE_PREFIX)?;
1012 engine.write_all(message.as_bytes())?;
1013 let hashed_msg = sha256::Hash::from_engine(engine);
1014 let double_hashed_msg = Message::from_digest(sha256::Hash::hash(&hashed_msg).into_inner());
1015 let recoverable_sig = self.signer.sign_ecdsa_recoverable(&double_hashed_msg)?;
1017 Ok(zbase32::encode_full_bytes(recoverable_sig.as_slice()))
1018 }
1019
1020 fn check_message(&self, message: &str, pubkey: &str, signature: &str) -> Result<bool> {
1021 let pk = PublicKey::from_str(pubkey)?;
1022 Ok(verify(message.as_bytes(), signature, &pk))
1023 }
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use super::*;
1029 use crate::model::Config;
1030 use crate::signer::SdkSigner;
1031 use crate::test_utils::persist::create_persister;
1032 use crate::wallet::LiquidOnchainWallet;
1033 use anyhow::Result;
1034 use lwk_common::SignedBalance;
1035 use lwk_wollet::elements::confidential::{AssetBlindingFactor, ValueBlindingFactor};
1036 use lwk_wollet::elements::{AssetId, TxIn, TxOutSecrets, Txid};
1037 use lwk_wollet::Chain;
1038 use std::collections::BTreeMap;
1039
1040 fn test_asset() -> AssetId {
1041 AssetId::from_str("6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d")
1042 .unwrap()
1043 }
1044
1045 fn wallet_tx(spends: &[OutPoint]) -> WalletTx {
1047 let tx = Transaction {
1048 version: 2,
1049 lock_time: lwk_wollet::elements::LockTime::ZERO,
1050 input: spends
1051 .iter()
1052 .map(|o| TxIn {
1053 previous_output: *o,
1054 ..Default::default()
1055 })
1056 .collect(),
1057 output: vec![],
1058 };
1059 WalletTx {
1060 txid: tx.txid(),
1061 tx,
1062 height: Some(1),
1063 balance: SignedBalance::from(BTreeMap::new()),
1064 fee: 0,
1065 type_: "outgoing".to_string(),
1066 timestamp: None,
1067 inputs: vec![],
1068 outputs: vec![],
1069 }
1070 }
1071
1072 fn wallet_utxo(outpoint: OutPoint) -> WalletTxOut {
1073 let mut bytes = vec![0x00, 0x14];
1075 bytes.extend_from_slice(&[7u8; 20]);
1076 let script = lwk_wollet::elements::Script::from(bytes);
1077 let address =
1078 Address::from_script(&script, None, &lwk_wollet::elements::AddressParams::LIQUID)
1079 .expect("p2wpkh script should render to an address");
1080 WalletTxOut {
1081 outpoint,
1082 script_pubkey: script,
1083 height: Some(1),
1084 unblinded: TxOutSecrets::new(
1085 test_asset(),
1086 AssetBlindingFactor::zero(),
1087 1000,
1088 ValueBlindingFactor::zero(),
1089 ),
1090 wildcard_index: 0,
1091 ext_int: Chain::External,
1092 is_spent: false,
1093 address,
1094 }
1095 }
1096
1097 #[sdk_macros::async_test_all]
1100 async fn test_apply_transaction_drops_spent_inputs() -> Result<()> {
1101 use lwk_wollet::clients::LastUnused;
1102 use lwk_wollet::elements::bitcoin::bip32::ChildNumber;
1103 use lwk_wollet::elements::{BlockExtData, BlockHash, BlockHeader, TxMerkleNode};
1104 use lwk_wollet::hashes::Hash as _;
1105 use lwk_wollet::{DownloadTxResult, Update};
1106
1107 let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1108 let signer: Arc<Box<dyn Signer>> =
1109 Arc::new(Box::new(SdkSigner::new(mnemonic, "", false).unwrap()));
1110 create_persister!(storage);
1111 let wallet =
1112 LiquidOnchainWallet::new(Config::regtest_esplora(), storage, signer.clone()).await?;
1113 let mut w = wallet.wallet.lock().await;
1114
1115 let script: lwk_wollet::elements::Script = {
1118 let desc = w.wollet_descriptor();
1119 desc.definite_descriptor(Chain::External, 0)?
1120 .script_pubkey()
1121 };
1122 let blinding_pubkey = None;
1123
1124 let secrets = TxOutSecrets::new(
1126 test_asset(),
1127 AssetBlindingFactor::from_slice(&[3u8; 32])?,
1128 1000,
1129 ValueBlindingFactor::from_slice(&[4u8; 32])?,
1130 );
1131
1132 let funding = Transaction {
1133 version: 2,
1134 lock_time: lwk_wollet::elements::LockTime::ZERO,
1135 input: vec![],
1136 output: vec![lwk_wollet::elements::TxOut {
1137 script_pubkey: script.clone(),
1138 ..Default::default()
1139 }],
1140 };
1141 let funding_txid = funding.txid();
1142 let outpoint = OutPoint::new(funding_txid, 0);
1143
1144 let wollet_status = w.status();
1145 w.apply_update(Update {
1146 version: 4,
1147 wollet_status,
1148 new_txs: DownloadTxResult {
1149 txs: vec![(funding_txid, funding)],
1150 unblinds: vec![(outpoint, secrets)],
1151 },
1152 txid_height_new: vec![(funding_txid, Some(1))],
1153 txid_height_delete: vec![],
1154 timestamps: vec![(1, 1)],
1155 scripts_with_blinding_pubkey: vec![(
1156 Chain::External,
1157 ChildNumber::from_normal_idx(0)?,
1158 script,
1159 blinding_pubkey,
1160 )],
1161 tip: BlockHeader {
1162 version: 0,
1163 prev_blockhash: BlockHash::all_zeros(),
1164 merkle_root: TxMerkleNode::all_zeros(),
1165 time: 0,
1166 height: 0,
1167 ext: BlockExtData::default(),
1168 },
1169 unspent: vec![],
1170 last_unused: LastUnused {
1171 internal: 0,
1172 external: 1,
1173 },
1174 })?;
1175
1176 assert!(
1177 w.utxos()?.iter().any(|u| u.outpoint == outpoint),
1178 "the funding output should be unspent"
1179 );
1180
1181 let spend = Transaction {
1182 version: 2,
1183 lock_time: lwk_wollet::elements::LockTime::ZERO,
1184 input: vec![TxIn {
1185 previous_output: outpoint,
1186 ..Default::default()
1187 }],
1188 output: vec![],
1189 };
1190 w.apply_transaction(spend)?;
1191
1192 assert!(
1193 !w.utxos()?.iter().any(|u| u.outpoint == outpoint),
1194 "apply_transaction must drop the input it spends - repair_cache depends on it"
1195 );
1196
1197 Ok(())
1198 }
1199
1200 const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1201
1202 fn descriptor_script(w: &Wollet) -> Result<lwk_wollet::elements::Script> {
1203 Ok(w.wollet_descriptor()
1204 .definite_descriptor(Chain::External, 0)?
1205 .script_pubkey())
1206 }
1207
1208 fn default_tip() -> lwk_wollet::elements::BlockHeader {
1209 use lwk_wollet::elements::{BlockExtData, BlockHash, TxMerkleNode};
1210 use lwk_wollet::hashes::Hash as _;
1211 lwk_wollet::elements::BlockHeader {
1212 version: 0,
1213 prev_blockhash: BlockHash::all_zeros(),
1214 merkle_root: TxMerkleNode::all_zeros(),
1215 time: 0,
1216 height: 0,
1217 ext: BlockExtData::default(),
1218 }
1219 }
1220
1221 fn tx_paying(script: &lwk_wollet::elements::Script) -> Transaction {
1222 Transaction {
1223 version: 2,
1224 lock_time: lwk_wollet::elements::LockTime::ZERO,
1225 input: vec![],
1226 output: vec![lwk_wollet::elements::TxOut {
1227 script_pubkey: script.clone(),
1228 ..Default::default()
1229 }],
1230 }
1231 }
1232
1233 fn confidential_tx_paying(
1239 script: &lwk_wollet::elements::Script,
1240 policy_asset: AssetId,
1241 sats: u64,
1242 seed: u8,
1243 ) -> (Transaction, TxOutSecrets) {
1244 use lwk_wollet::elements::confidential::{Asset, Nonce, Value};
1245 use lwk_wollet::elements::secp256k1_zkp;
1246
1247 let secp = secp256k1_zkp::Secp256k1::new();
1248 let asset_bf = AssetBlindingFactor::from_slice(&[seed | 0x01; 32]).unwrap();
1249 let value_bf = ValueBlindingFactor::from_slice(&[seed | 0x40; 32]).unwrap();
1250 let asset_gen = secp256k1_zkp::Generator::new_blinded(
1251 &secp,
1252 policy_asset.into_tag(),
1253 asset_bf.into_inner(),
1254 );
1255 let value_commit =
1256 secp256k1_zkp::PedersenCommitment::new(&secp, sats, value_bf.into_inner(), asset_gen);
1257
1258 let tx = Transaction {
1259 version: 2,
1260 lock_time: lwk_wollet::elements::LockTime::ZERO,
1261 input: vec![],
1262 output: vec![lwk_wollet::elements::TxOut {
1263 asset: Asset::Confidential(asset_gen),
1264 value: Value::Confidential(value_commit),
1265 nonce: Nonce::Null,
1266 script_pubkey: script.clone(),
1267 witness: Default::default(),
1268 }],
1269 };
1270 let secrets = TxOutSecrets {
1271 asset: policy_asset,
1272 value: sats,
1273 asset_bf,
1274 value_bf,
1275 };
1276 (tx, secrets)
1277 }
1278
1279 fn fund(
1281 w: &mut Wollet,
1282 script: &lwk_wollet::elements::Script,
1283 sats: u64,
1284 seed: u8,
1285 ) -> Result<OutPoint> {
1286 use lwk_wollet::clients::LastUnused;
1287 use lwk_wollet::elements::bitcoin::bip32::ChildNumber;
1288 use lwk_wollet::{DownloadTxResult, Update};
1289
1290 let (tx, secrets) = confidential_tx_paying(script, w.policy_asset(), sats, seed);
1291 let txid = tx.txid();
1292 let outpoint = OutPoint::new(txid, 0);
1293 let wollet_status = w.status();
1294 w.apply_update(Update {
1295 version: 4,
1296 wollet_status,
1297 new_txs: DownloadTxResult {
1298 txs: vec![(txid, tx)],
1299 unblinds: vec![(outpoint, secrets)],
1300 },
1301 txid_height_new: vec![(txid, Some(1))],
1302 txid_height_delete: vec![],
1303 timestamps: vec![(1, 1)],
1304 scripts_with_blinding_pubkey: vec![(
1305 Chain::External,
1306 ChildNumber::from_normal_idx(0)?,
1307 script.clone(),
1308 None,
1309 )],
1310 tip: default_tip(),
1311 unspent: vec![],
1312 last_unused: LastUnused {
1313 internal: 0,
1314 external: 1,
1315 },
1316 })?;
1317 Ok(outpoint)
1318 }
1319
1320 fn tx_spending(outpoint: OutPoint) -> Transaction {
1321 Transaction {
1322 version: 2,
1323 lock_time: lwk_wollet::elements::LockTime::ZERO,
1324 input: vec![TxIn {
1325 previous_output: outpoint,
1326 ..Default::default()
1327 }],
1328 output: vec![],
1329 }
1330 }
1331
1332 fn has_utxo(w: &Wollet, outpoint: OutPoint) -> Result<bool> {
1333 Ok(w.utxos()?.iter().any(|u| u.outpoint == outpoint))
1334 }
1335
1336 fn apply(
1338 w: &mut Wollet,
1339 tx: &Transaction,
1340 heights: &[(Txid, Option<u32>)],
1341 script: &lwk_wollet::elements::Script,
1342 ) -> Result<()> {
1343 use lwk_wollet::clients::LastUnused;
1344 use lwk_wollet::elements::bitcoin::bip32::ChildNumber;
1345 use lwk_wollet::{DownloadTxResult, Update};
1346
1347 let txid = tx.txid();
1348 let unblinds = tx
1349 .output
1350 .iter()
1351 .enumerate()
1352 .filter(|(_, o)| &o.script_pubkey == script)
1353 .map(|(vout, _)| {
1354 (
1355 OutPoint::new(txid, vout as u32),
1356 TxOutSecrets::new(
1357 test_asset(),
1358 AssetBlindingFactor::from_slice(&[3u8; 32]).unwrap(),
1359 1000,
1360 ValueBlindingFactor::from_slice(&[4u8; 32]).unwrap(),
1361 ),
1362 )
1363 })
1364 .collect();
1365
1366 let wollet_status = w.status();
1367 w.apply_update(Update {
1368 version: 4,
1369 wollet_status,
1370 new_txs: DownloadTxResult {
1371 txs: vec![(txid, tx.clone())],
1372 unblinds,
1373 },
1374 txid_height_new: heights.to_vec(),
1375 txid_height_delete: vec![],
1376 timestamps: vec![(1, 1)],
1377 scripts_with_blinding_pubkey: vec![(
1378 Chain::External,
1379 ChildNumber::from_normal_idx(0)?,
1380 script.clone(),
1381 None,
1382 )],
1383 tip: default_tip(),
1384 unspent: vec![],
1385 last_unused: LastUnused {
1386 internal: 0,
1387 external: 1,
1388 },
1389 })?;
1390 Ok(())
1391 }
1392
1393 fn apply_heights_only(w: &mut Wollet, heights: &[(Txid, Option<u32>)]) -> Result<()> {
1395 use lwk_wollet::clients::LastUnused;
1396 use lwk_wollet::{DownloadTxResult, Update};
1397 let wollet_status = w.status();
1398 w.apply_update(Update {
1399 version: 4,
1400 wollet_status,
1401 new_txs: DownloadTxResult {
1402 txs: vec![],
1403 unblinds: vec![],
1404 },
1405 txid_height_new: heights.to_vec(),
1406 txid_height_delete: vec![],
1407 timestamps: vec![(1, 1)],
1408 scripts_with_blinding_pubkey: vec![],
1409 tip: default_tip(),
1410 unspent: vec![],
1411 last_unused: LastUnused {
1412 internal: 0,
1413 external: 1,
1414 },
1415 })?;
1416 Ok(())
1417 }
1418
1419 #[sdk_macros::async_test_all]
1423 async fn test_repair_cache_resolves_a_chain_of_spends() -> Result<()> {
1424 let signer: Arc<Box<dyn Signer>> =
1425 Arc::new(Box::new(SdkSigner::new(TEST_MNEMONIC, "", false).unwrap()));
1426 create_persister!(storage);
1427 let wallet =
1428 LiquidOnchainWallet::new(Config::regtest_esplora(), storage, signer.clone()).await?;
1429
1430 let (a0, b0) = {
1431 let mut w = wallet.wallet.lock().await;
1432 let script = descriptor_script(&w)?;
1433
1434 let a = tx_paying(&script);
1436 let a0 = OutPoint::new(a.txid(), 0);
1437 apply(&mut w, &a, &[(a.txid(), None)], &script)?;
1438
1439 let mut b = tx_paying(&script);
1440 b.input = vec![TxIn {
1441 previous_output: a0,
1442 ..Default::default()
1443 }];
1444 let b0 = OutPoint::new(b.txid(), 0);
1445 apply(&mut w, &b, &[(b.txid(), None)], &script)?;
1446
1447 let c = tx_spending(b0);
1448 apply(&mut w, &c, &[(c.txid(), None)], &script)?;
1449
1450 assert!(!has_utxo(&w, a0)?, "A:0 is spent by B");
1451 assert!(!has_utxo(&w, b0)?, "B:0 is spent by C");
1452
1453 apply_heights_only(&mut w, &[(a.txid(), Some(1))])?;
1455 assert!(has_utxo(&w, a0)?, "A:0 resurrected");
1456 (a0, b0)
1457 };
1458
1459 assert!(
1461 wallet.repair_cache().await?,
1462 "the repair should resolve the chain without needing a wipe"
1463 );
1464
1465 let w = wallet.wallet.lock().await;
1466 assert!(!has_utxo(&w, a0)?, "A:0 must not survive the repair");
1467 assert!(
1468 !has_utxo(&w, b0)?,
1469 "B:0 must not be left behind by the repair"
1470 );
1471 assert!(find_spent_utxos(&w.transactions()?, &w.utxos()?).is_empty());
1472 Ok(())
1473 }
1474
1475 #[sdk_macros::async_test_all]
1480 async fn test_repair_cache_resolves_a_long_chain_in_one_pass() -> Result<()> {
1481 const CHAIN: usize = 25; let signer: Arc<Box<dyn Signer>> =
1484 Arc::new(Box::new(SdkSigner::new(TEST_MNEMONIC, "", false).unwrap()));
1485 create_persister!(storage);
1486 let wallet =
1487 LiquidOnchainWallet::new(Config::regtest_esplora(), storage, signer.clone()).await?;
1488
1489 let first_outpoint = {
1490 let mut w = wallet.wallet.lock().await;
1491 let script = descriptor_script(&w)?;
1492
1493 let tx0 = tx_paying(&script);
1495 let first = OutPoint::new(tx0.txid(), 0);
1496 apply(&mut w, &tx0, &[(tx0.txid(), None)], &script)?;
1497
1498 let mut prev = first;
1499 for _ in 0..CHAIN {
1500 let mut next = tx_paying(&script);
1501 next.input = vec![TxIn {
1502 previous_output: prev,
1503 ..Default::default()
1504 }];
1505 apply(&mut w, &next, &[(next.txid(), None)], &script)?;
1506 prev = OutPoint::new(next.txid(), 0);
1507 }
1508
1509 assert!(!has_utxo(&w, first)?, "the head of the chain is spent");
1511 assert!(has_utxo(&w, prev)?, "the tip of the chain is unspent");
1512
1513 apply_heights_only(&mut w, &[(tx0.txid(), Some(1))])?;
1515 assert!(has_utxo(&w, first)?, "head resurrected");
1516 first
1517 };
1518
1519 assert!(
1520 wallet.repair_cache().await?,
1521 "a {CHAIN}-hop chain must resolve without falling back to a wipe"
1522 );
1523
1524 let w = wallet.wallet.lock().await;
1525 assert!(!has_utxo(&w, first_outpoint)?);
1526 assert!(find_spent_utxos(&w.transactions()?, &w.utxos()?).is_empty());
1527 Ok(())
1528 }
1529
1530 #[sdk_macros::async_test_all]
1534 async fn test_build_drain_tx_spends_every_utxo() -> Result<()> {
1535 let signer: Arc<Box<dyn Signer>> =
1536 Arc::new(Box::new(SdkSigner::new(TEST_MNEMONIC, "", false).unwrap()));
1537 create_persister!(storage);
1538 let wallet =
1539 LiquidOnchainWallet::new(Config::regtest_esplora(), storage, signer.clone()).await?;
1540
1541 let (script, recipient) = {
1542 let w = wallet.wallet.lock().await;
1543 (
1544 descriptor_script(&w)?,
1545 w.address(Some(1))?.address().clone(),
1546 )
1547 };
1548
1549 let funded = {
1551 let mut w = wallet.wallet.lock().await;
1552 let outpoints = [
1553 fund(&mut w, &script, 100_000, 1)?,
1554 fund(&mut w, &script, 50_000, 2)?,
1555 fund(&mut w, &script, 25_000, 3)?,
1556 ];
1557 assert_eq!(w.utxos()?.len(), 3, "wallet should hold the 3 funded utxos");
1558 outpoints
1559 };
1560
1561 let tx = wallet
1562 .build_drain_tx(None, &recipient.to_string(), None)
1563 .await?;
1564
1565 assert_eq!(
1566 tx.input.len(),
1567 funded.len(),
1568 "a drain must spend every utxo, not a selected subset"
1569 );
1570 for outpoint in funded {
1571 assert!(
1572 tx.input.iter().any(|i| i.previous_output == outpoint),
1573 "drain tx is missing utxo {outpoint}"
1574 );
1575 }
1576 assert_eq!(tx.output.len(), 2, "expected the drain output and the fee");
1578 let fee = tx.output.iter().find(|o| o.is_fee()).expect("a fee output");
1579 assert!(
1580 fee.value.explicit().is_some_and(|sats| sats > 0),
1581 "the fee must be explicit and non-zero, so the tx actually balanced"
1582 );
1583
1584 let ordinary = wallet
1587 .build_tx(
1588 None,
1589 &recipient.to_string(),
1590 &wallet.config.lbtc_asset_id(),
1591 1_000,
1592 )
1593 .await?;
1594 assert!(
1595 ordinary.input.len() < funded.len(),
1596 "a 1000 sat send should select a subset, got {} of {} inputs",
1597 ordinary.input.len(),
1598 funded.len()
1599 );
1600 Ok(())
1601 }
1602
1603 #[sdk_macros::async_test_all]
1607 async fn test_build_drain_tx_enforces_the_exact_amount() -> Result<()> {
1608 let signer: Arc<Box<dyn Signer>> =
1609 Arc::new(Box::new(SdkSigner::new(TEST_MNEMONIC, "", false).unwrap()));
1610 create_persister!(storage);
1611 let wallet =
1612 LiquidOnchainWallet::new(Config::regtest_esplora(), storage, signer.clone()).await?;
1613
1614 let (script, recipient) = {
1615 let w = wallet.wallet.lock().await;
1616 (
1617 descriptor_script(&w)?,
1618 w.address(Some(1))?.address().clone(),
1619 )
1620 };
1621 let total = 175_000;
1622 {
1623 let mut w = wallet.wallet.lock().await;
1624 fund(&mut w, &script, 100_000, 1)?;
1625 fund(&mut w, &script, 50_000, 2)?;
1626 fund(&mut w, &script, 25_000, 3)?;
1627 }
1628 let recipient = recipient.to_string();
1629
1630 let fee = wallet
1633 .build_drain_tx(None, &recipient, None)
1634 .await?
1635 .output
1636 .iter()
1637 .find(|o| o.is_fee())
1638 .and_then(|o| o.value.explicit())
1639 .expect("an explicit fee output");
1640 let drained = total - fee;
1641
1642 wallet
1643 .build_drain_tx(None, &recipient, Some(drained))
1644 .await
1645 .unwrap_or_else(|e| {
1646 panic!("enforcing the actual drained amount {drained} failed: {e}")
1647 });
1648
1649 for wrong in [drained - 1, drained + 1] {
1651 let err = match wallet.build_drain_tx(None, &recipient, Some(wrong)).await {
1652 Ok(tx) => panic!(
1654 "enforcing {wrong} must fail, but it built {} draining {drained}",
1655 tx.txid()
1656 ),
1657 Err(e) => e,
1658 };
1659 assert!(
1660 err.to_string().contains("doesn't match enforce_amount_sat"),
1661 "expected an enforce mismatch for {wrong}, got: {err}"
1662 );
1663 }
1664 Ok(())
1665 }
1666
1667 #[sdk_macros::async_test_all]
1670 async fn test_cache_wipe_is_bounded_to_once_per_session() -> Result<()> {
1671 let signer: Arc<Box<dyn Signer>> =
1672 Arc::new(Box::new(SdkSigner::new(TEST_MNEMONIC, "", false).unwrap()));
1673 create_persister!(storage);
1674 let wallet =
1675 LiquidOnchainWallet::new(Config::regtest_esplora(), storage, signer.clone()).await?;
1676
1677 assert!(wallet.schedule_cache_wipe(), "the first wipe is allowed");
1678 assert!(wallet.needs_cache_clear.load(Ordering::Relaxed));
1679
1680 wallet.needs_cache_clear.store(false, Ordering::Relaxed);
1682 wallet.cache_wiped.store(true, Ordering::Relaxed);
1683
1684 assert!(
1685 !wallet.schedule_cache_wipe(),
1686 "a second wipe in the same session must be refused"
1687 );
1688 assert!(
1689 !wallet.needs_cache_clear.load(Ordering::Relaxed),
1690 "a refused wipe must not leave the scan flagged, or every scan would rescan cold"
1691 );
1692 Ok(())
1693 }
1694
1695 #[sdk_macros::async_test_all]
1700 async fn test_lwk_resurrects_outputs_of_reannounced_funding_tx() -> Result<()> {
1701 let signer: Arc<Box<dyn Signer>> =
1702 Arc::new(Box::new(SdkSigner::new(TEST_MNEMONIC, "", false).unwrap()));
1703 create_persister!(storage);
1704 let wallet =
1705 LiquidOnchainWallet::new(Config::regtest_esplora(), storage, signer.clone()).await?;
1706 let mut w = wallet.wallet.lock().await;
1707 let script = descriptor_script(&w)?;
1708
1709 let funding = tx_paying(&script);
1711 let a0 = OutPoint::new(funding.txid(), 0);
1712 apply(&mut w, &funding, &[(funding.txid(), None)], &script)?;
1713 assert!(
1714 has_utxo(&w, a0)?,
1715 "A:0 should be spendable while unconfirmed"
1716 );
1717
1718 let spend = tx_spending(a0);
1720 apply(&mut w, &spend, &[(spend.txid(), None)], &script)?;
1721 assert!(!has_utxo(&w, a0)?, "A:0 should be spent by B");
1722
1723 apply_heights_only(&mut w, &[(funding.txid(), Some(1))])?;
1725
1726 let resurrected = has_utxo(&w, a0)?;
1727 let spender_known = w.transactions()?.iter().any(|t| t.txid == spend.txid());
1728
1729 assert!(
1732 resurrected && spender_known,
1733 "lwk no longer resurrects outputs of a re-announced funding tx \
1734 (resurrected={resurrected}, spender_known={spender_known}). If this failed after an \
1735 lwk bump the upstream bug is likely fixed, so reassess whether repair_cache and \
1736 check_and_repair_cache are still needed."
1737 );
1738 assert_eq!(
1739 find_spent_utxos(&w.transactions()?, &w.utxos()?),
1740 vec![a0],
1741 "and our invariant should catch it"
1742 );
1743 Ok(())
1744 }
1745
1746 #[sdk_macros::test_all]
1749 fn test_find_spent_utxos() {
1750 let a = OutPoint::new(Txid::from_str(&"a".repeat(64)).unwrap(), 0);
1751 let b = OutPoint::new(Txid::from_str(&"b".repeat(64)).unwrap(), 1);
1752
1753 let unrelated = wallet_tx(&[OutPoint::new(Txid::from_str(&"c".repeat(64)).unwrap(), 0)]);
1755 assert!(find_spent_utxos(
1756 std::slice::from_ref(&unrelated),
1757 &[wallet_utxo(a), wallet_utxo(b)]
1758 )
1759 .is_empty());
1760
1761 let spender = wallet_tx(&[a]);
1763 assert_eq!(
1764 find_spent_utxos(
1765 &[unrelated.clone(), spender.clone()],
1766 &[wallet_utxo(a), wallet_utxo(b)]
1767 ),
1768 vec![a],
1769 "a utxo spent by a known tx must be reported"
1770 );
1771
1772 assert!(find_spent_utxos(&[spender], &[]).is_empty());
1774 }
1775
1776 #[cfg(feature = "browser-tests")]
1777 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
1778
1779 #[sdk_macros::async_test_all]
1780 async fn test_sign_and_check_message() -> Result<()> {
1781 let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1782 let sdk_signer: Box<dyn Signer> = Box::new(SdkSigner::new(mnemonic, "", false).unwrap());
1783 let sdk_signer = Arc::new(sdk_signer);
1784
1785 let config = Config::regtest_esplora();
1786
1787 create_persister!(storage);
1788
1789 let wallet: Arc<dyn OnchainWallet> = Arc::new(
1790 LiquidOnchainWallet::new(config, storage, sdk_signer.clone())
1791 .await
1792 .unwrap(),
1793 );
1794
1795 let message = "Hello, Liquid!";
1797
1798 let signature = wallet.sign_message(message).unwrap();
1800
1801 let pubkey = wallet.pubkey().unwrap();
1803
1804 let is_valid = wallet.check_message(message, &pubkey, &signature).unwrap();
1806 assert!(is_valid, "Message signature should be valid");
1807
1808 let incorrect_message = "Wrong message";
1810 let is_invalid = wallet
1811 .check_message(incorrect_message, &pubkey, &signature)
1812 .unwrap();
1813 assert!(
1814 !is_invalid,
1815 "Message signature should be invalid for incorrect message"
1816 );
1817
1818 let incorrect_pubkey = "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc";
1820 let is_invalid = wallet
1821 .check_message(message, incorrect_pubkey, &signature)
1822 .unwrap();
1823 assert!(
1824 !is_invalid,
1825 "Message signature should be invalid for incorrect public key"
1826 );
1827
1828 let incorrect_signature = zbase32::encode_full_bytes(&[0; 65]);
1830 let is_invalid = wallet
1831 .check_message(message, &pubkey, &incorrect_signature)
1832 .unwrap();
1833 assert!(
1834 !is_invalid,
1835 "Message signature should be invalid for incorrect signature"
1836 );
1837
1838 Ok(())
1840 }
1841}