Skip to main content

breez_sdk_liquid/wallet/
mod.rs

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    /// List all transactions in the wallet
44    async fn transactions(&self) -> Result<Vec<WalletTx>, PaymentError>;
45
46    /// List all transactions in the wallet mapped by tx id
47    async fn transactions_by_tx_id(&self) -> Result<HashMap<Txid, WalletTx>, PaymentError>;
48
49    /// List all utxos in the wallet for a given asset
50    async fn asset_utxos(&self, asset: &AssetId) -> Result<Vec<WalletTxOut>, PaymentError>;
51
52    /// Build a transaction to send funds to a recipient
53    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    /// Builds a drain tx.
62    ///
63    /// ### Arguments
64    /// - `fee_rate_sats_per_kvb`: custom drain tx feerate
65    /// - `recipient_address`: drain tx recipient
66    /// - `enforce_amount_sat`: if set, the drain tx will only be built if the amount transferred is
67    ///   this amount, otherwise it will fail with a validation error
68    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    /// Build a transaction to send funds to a recipient. If building a transaction
76    /// results in an InsufficientFunds error, attempt to build a drain transaction
77    /// validating that the `amount_sat` matches the drain output.
78    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    /// Sign a partially signed transaction
87    async fn sign_pset(&self, pset: &mut PartiallySignedTransaction) -> Result<(), PaymentError>;
88
89    /// Get the next unused address in the wallet
90    async fn next_unused_address(&self) -> Result<Address, PaymentError>;
91
92    /// Get the next unused change address in the wallet
93    async fn next_unused_change_address(&self) -> Result<Address, PaymentError>;
94
95    /// Get the current tip of the blockchain the wallet is aware of
96    async fn tip(&self) -> u32;
97
98    /// Get the public key of the wallet
99    fn pubkey(&self) -> Result<String>;
100
101    /// Get the fingerprint of the wallet
102    fn fingerprint(&self) -> Result<String>;
103
104    /// Sign given message with the wallet private key. Returns a zbase
105    /// encoded signature.
106    fn sign_message(&self, msg: &str) -> Result<String>;
107
108    /// Check whether given message was signed by the given
109    /// pubkey and the signature (zbase encoded) is valid.
110    fn check_message(&self, message: &str, pubkey: &str, signature: &str) -> Result<bool>;
111
112    /// Perform a full scan of the wallet
113    async fn full_scan(&self) -> Result<(), PaymentError>;
114
115    /// Records a just-broadcast tx so the coins it spends stop being selectable before the next
116    /// scan sees them; without this a second payment re-selects them and is rejected.
117    async fn apply_broadcast_tx(&self, tx: &Transaction);
118
119    /// Repairs the cached unspent set locally, with no network access, by re-applying the txs
120    /// the wallet already holds that spend outputs still listed as unspent.
121    ///
122    /// Returns whether the cache is consistent afterwards. If not, the wallet flags itself so the
123    /// next scan wipes and rescans.
124    async fn repair_cache(&self) -> Result<bool, PaymentError>;
125}
126
127/// Maps a stale-cache broadcast rejection to an actionable error, repairing the cache on the way.
128///
129/// The repair is local and instant, so a retry by the caller succeeds. If it cannot resolve the
130/// drift it schedules a wipe for the next scan, which runs in the background.
131pub(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    /// Whether the next scan should verify the cached unspent set.
224    needs_cache_check: AtomicBool,
225    /// Whether the next scan must wipe the cache first, because a local repair could not fix it.
226    needs_cache_clear: AtomicBool,
227    /// Whether a wipe-and-rescan is running. It holds the wallet lock for a cold rescan, so tx
228    /// building checks this first to fail fast rather than block for minutes.
229    recovery_scan_in_progress: AtomicBool,
230    /// Whether a wipe already ran this session. A cold rescan costs minutes, and if one did not
231    /// produce a clean unspent set a second will not either, so this bounds it to one attempt.
232    cache_wiped: AtomicBool,
233}
234
235/// Clears an [`AtomicBool`] on drop, so the flag is released even on an early return.
236struct FlagGuard<'a>(&'a AtomicBool);
237
238impl Drop for FlagGuard<'_> {
239    fn drop(&mut self) {
240        self.0.store(false, Ordering::Relaxed);
241    }
242}
243
244/// Outpoints still listed as unspent despite being spent by a tx the wallet already knows about.
245///
246/// lwk enforced this on every `utxos()` call before the unspent set became materialised state
247/// (`RawCache::spent()`). A violation is permanent without a wipe: selection is deterministic, so
248/// it keeps picking the same spent coin and every broadcast is rejected.
249pub(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    /// Creates a new LiquidOnchainWallet that caches data on the provided `working_dir`.
263    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            // Check on startup, so a cache corrupted in a previous session is repaired before a
284            // payment discovers it.
285            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    /// Verifies the cached unspent set against the tx set and repairs any drift. Cheap and
293    /// local; only a repair costs anything. Runs after a scan, never during one.
294    /// Schedules a wipe-and-rescan for the next scan, unless one already ran this session.
295    ///
296    /// A cold rescan costs minutes and rebuilds the unspent set from an empty cache, so if it did
297    /// not produce a clean one, repeating it will not either. Bounding it keeps a wallet whose
298    /// drift the rescan cannot resolve from wiping on every scan for the rest of the session.
299    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        // The local repair usually suffices; if not it schedules a wipe for the next scan.
339        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    /// Selects wallet utxos for a non-L-BTC asset send: enough utxos of `asset`
422    /// to cover `amount_sat`, plus a bounded set of L-BTC utxos to cover the fee.
423    ///
424    /// Without an explicit selection, lwk falls into its "always add all L-BTC
425    /// inputs" path, which can exceed the 256-input surjection-proof limit (and
426    /// fail with `TooManyInputs`) for wallets with many small L-BTC utxos, even
427    /// though an asset send only needs a couple of inputs to pay the fee.
428    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        // Select asset utxos to cover the amount being sent.
444        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        // Select a bounded set of L-BTC utxos to cover the fee. The fee depends on
454        // the total input count, so seed the estimate with the asset inputs above.
455        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                    // asset recipient + asset change + L-BTC change
469                    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        // Resolve the selected asset and fee values to their wallet outpoints.
477        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    /// Resolves selected `(asset, value)` pairs to their wallet outpoints,
492    /// removing each match as it is found so that duplicate values resolve to
493    /// distinct utxos. Errors if any selected value has no matching utxo.
494    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    /// List all transactions in the wallet
531    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    /// List all transactions in the wallet mapped by tx id
539    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    /// Build a transaction to send funds to a recipient
561    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            // If the asset is L-BTC, try to select wallet utxos for the recipient amount.
586            // If it fails to select utxos, the LWK wallet will select the utxos for us.
587            let policy_asset = lwk_wollet.policy_asset();
588            // TODO: LWK only supports selecting utxos for the policy asset, in the future
589            // we should be able to select utxos for any asset.
590            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            // Add the L-BTC recipient
606            tx_builder = tx_builder.add_lbtc_recipient(&address, amount_sat)?;
607        } else {
608            // Add the asset recipient
609            let asset = AssetId::from_str(asset_id)?;
610            // Explicitly select the asset utxos plus a bounded set of L-BTC utxos
611            // for the fee. If selection fails, fall back to letting lwk select the
612            // utxos (which adds all L-BTC inputs).
613            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        // Get the tx_out for each input and add the rangeproof/witness utxo
718        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        // Set the final script witness for each input adding the signature and any missing public key
741        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    /// Get the next unused address in the wallet
751    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    /// Get the next unused change address in the wallet
779    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    /// Get the current tip of the blockchain the wallet is aware of
786    async fn tip(&self) -> u32 {
787        self.wallet.lock().await.tip().height()
788    }
789
790    /// Get the public key of the wallet
791    fn pubkey(&self) -> Result<String> {
792        Ok(self.signer.xpub()?.public_key.to_string())
793    }
794
795    /// Get the fingerprint of the wallet
796    fn fingerprint(&self) -> Result<String> {
797        Ok(self.signer.fingerprint()?.to_hex())
798    }
799
800    /// Perform a full scan of the wallet
801    async fn full_scan(&self) -> Result<(), PaymentError> {
802        // Scoped so the wallet and client locks drop before the repair re-acquires them.
803        {
804            debug!("LiquidOnchainWallet::full_scan: start");
805            let full_scan_started = Instant::now();
806
807            // create electrum client if doesn't already exist
808            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            // Use the cached derivation index with a buffer of 5 to perform the scan
817            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            // Wipe at the *start* of a scan so this same scan repopulates before the sync runs
825            // `update_wallet_info`; wiping after one would persist an empty balance.
826            let clearing = self.needs_cache_clear.load(Ordering::Relaxed);
827            // Guard before anything fallible: an early return here must still release the flag,
828            // or tx building fails fast forever.
829            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                // Only now is the wipe complete. Clearing earlier would strand a wiped store
844                // beside the old in-memory wallet if `create_wallet` failed.
845                self.needs_cache_clear.store(false, Ordering::Relaxed);
846                self.cache_wiped.store(true, Ordering::Relaxed);
847                // Re-arm the check so this scan verifies its own result. Without it a wipe that
848                // failed to produce a clean set would look identical to one that succeeded.
849                self.needs_cache_check.store(true, Ordering::Relaxed);
850            }
851
852            // Reunblind the wallet txs if there has been a change in the derivation index since the
853            // last full scan
854            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                    // Adopt the reloaded wallet even if the rescan failed: `create_wallet` may
887                    // have wiped the cache, and a stale in-memory wallet would then persist a
888                    // delta that cannot reconstruct it.
889                    *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        // Best-effort: a failed check must not abort the scan, or `sync_inner` returns before
906        // syncing payments.
907        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        // Same mutex `full_scan` holds across scan + apply_update, which is what stops this
915        // racing an update into UpdateOnDifferentStatus.
916        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        // Re-applying a spender drops its stale inputs but re-adds its own outputs, so if one of
940        // those was itself already spent the drift just moves a hop down the chain. Walk the whole
941        // chain first and re-apply it in one go, otherwise a chain of N spends needs N passes.
942        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            // The next hop is whatever those spenders paid back to us.
960            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        // Oldest first, so a child removes what its parent re-adds rather than the reverse.
980        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            // Note this records the tx as unconfirmed; the next scan restores its real height.
990            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        // Prefix and double hash message
1010        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        // Get message signature and encode to zbase32
1016        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    /// A transaction spending `spends`, wrapped as a wallet tx.
1046    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        // p2wpkh so it renders to an address; neither is read by the invariant.
1074        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    /// `repair_cache` rests on this: applying a tx must remove the inputs it spends from
1098    /// lwk's unspent set. Against a real `Wollet`, not a mock.
1099    #[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        // Derive a real script from the wallet descriptor so it resolves back to an index.
1116        // `None` for the blinding pubkey: lwk derives it from the wallet descriptor.
1117        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        // Non-zero blinding factors, or the output counts as explicit and `utxos()` drops it.
1125        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    /// A tx paying `sats` of the policy asset to `script`, plus the secrets it unblinds with.
1234    ///
1235    /// Unlike [`tx_paying`], the commitments are real and consistent with the secrets. The cache
1236    /// tests can leave them `Default::default()` because `extend_unblinded` does no crypto, but
1237    /// anything that *builds* a tx blinds and proves against them, so they have to be genuine.
1238    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    /// Credits the wallet a spendable confirmed utxo of `sats`, returning its outpoint.
1280    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    /// Applies `tx` with the given heights, unblinding any output paying `script`.
1337    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    /// A height-only delta, as a scan produces when a known tx confirms.
1394    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    /// Re-applying a spender re-adds its own outputs, so if one of those was already spent the
1420    /// drift moves one hop down the chain. Raised in review: the repair must iterate, not give up
1421    /// after a single pass and schedule a wipe.
1422    #[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            // A funds us; B spends A:0 and pays us change; C spends that change.
1435            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            // A confirms, resurrecting A:0 (the lwk bug).
1454            apply_heights_only(&mut w, &[(a.txid(), Some(1))])?;
1455            assert!(has_utxo(&w, a0)?, "A:0 resurrected");
1456            (a0, b0)
1457        };
1458
1459        // One pass would drop A:0 but re-add B:0, which C spends. Only iterating resolves both.
1460        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    /// A long chain of change spends. Each re-applied spender re-adds its own change, which the
1476    /// next spender consumes, so a hop-by-hop repair needs one pass per hop and stalls on chains
1477    /// longer than its pass limit. Seen in the field as 16 identical passes over 34 stale utxos
1478    /// before falling back to a wipe. The repair must walk the whole chain in one go.
1479    #[sdk_macros::async_test_all]
1480    async fn test_repair_cache_resolves_a_long_chain_in_one_pass() -> Result<()> {
1481        const CHAIN: usize = 25; // longer than any sane pass limit
1482
1483        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            // tx0 pays us; each following tx spends the previous change and pays us again.
1494            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            // Only the tip of the chain should be unspent.
1510            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            // Confirm tx0, resurrecting its already-spent output at the head of the chain.
1514            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    /// Drain hands the whole job to lwk's `drain_lbtc_wallet()` and never consults
1531    /// `select_wallet_utxos`, so it must keep spending every utxo regardless of what our own
1532    /// coin selection would have picked.
1533    #[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        // A spread that our own selector would never take whole: a 21 sat send would pick one.
1550        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        // The drain output reuses the change slot, so there is no third output to change into.
1577        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        // Contrast: an ordinary send of the same funds goes through `select_wallet_utxos` and
1585        // takes a subset. Without this the drain assertion would pass on any built tx.
1586        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    /// `enforce_amount_sat` is the branch `build_tx_or_drain_tx` falls back to when an ordinary
1604    /// send cannot be built, and it only passes when the drained amount matches exactly. Getting
1605    /// the arithmetic wrong turns an unbuildable tx into a bare "not enough funds".
1606    #[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        // Learn the fee from an unconstrained drain, so the expected amount is derived rather
1631        // than hardcoded to a fee model that may change.
1632        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        // Off by one in either direction must be rejected, not silently drained.
1650        for wrong in [drained - 1, drained + 1] {
1651            let err = match wallet.build_drain_tx(None, &recipient, Some(wrong)).await {
1652                // Report the txid rather than the tx, which Debug-prints every rangeproof.
1653                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    /// A wipe costs a cold rescan (minutes on a large wallet, 221s when measured against the
1668    /// affected one), so drift a rescan cannot resolve must not re-trigger it on every scan.
1669    #[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        // Stand in for `full_scan` performing the wipe.
1681        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    /// `Cache::update_unspent` re-adds the outputs of every tx in `txid_height_new`
1696    /// filtered only by txid. It never checks whether a tx already in the cache
1697    /// spends them. So a funding tx confirming *after* the tx spending its output was recorded
1698    /// resurrects that output. Which is what happens whenever unconfirmed change is spent.
1699    #[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        // 1. Funding tx A arrives unconfirmed, paying us.
1710        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        // 2. We spend that unconfirmed output. B is recorded, A:0 correctly leaves the set.
1719        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        // 3. A confirms. Only A is in this delta, so B is never consulted.
1724        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        // Asserts the bug is still PRESENT, so this fails if lwk ever fixes it. That is the
1730        // point: it is the signal to revisit the recovery code, not a regression in this crate.
1731        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    /// The invariant that drives both detection and repair: a utxo the wallet still lists as
1747    /// unspent, but which a transaction it already knows about spends, is stale.
1748    #[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        // Healthy: nothing the wallet holds spends either utxo.
1754        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        // Corrupt: `a` is still listed as unspent although a known tx spends it.
1762        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        // An empty utxo set cannot be corrupt, however many spends are known.
1773        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        // Test message
1796        let message = "Hello, Liquid!";
1797
1798        // Sign the message
1799        let signature = wallet.sign_message(message).unwrap();
1800
1801        // Get the public key
1802        let pubkey = wallet.pubkey().unwrap();
1803
1804        // Check the message
1805        let is_valid = wallet.check_message(message, &pubkey, &signature).unwrap();
1806        assert!(is_valid, "Message signature should be valid");
1807
1808        // Check with an incorrect message
1809        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        // Check with an incorrect public key
1819        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        // Check with an incorrect signature
1829        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        // The temporary directory will be automatically deleted when temp_dir goes out of scope
1839        Ok(())
1840    }
1841}