Skip to main content

breez_sdk_spark/sdk/
unilateral_exit.rs

1use std::str::FromStr;
2use std::sync::Arc;
3
4use bitcoin::{
5    Address, Amount, CompressedPublicKey, OutPoint, ScriptBuf, Transaction, TxOut, Txid,
6    XOnlyPublicKey,
7    address::NetworkUnchecked,
8    consensus::encode::{deserialize_hex, serialize_hex},
9    secp256k1::PublicKey,
10};
11
12use spark_wallet::{
13    AddressUtxo, ChainQuery, ChainResult, CpfpInput, ExitTxKind, ExitTxStatus, Observation,
14    PreparedUnilateralExit, SpendInfo, TreeNodeId, UnilateralExitBuild, build_unilateral_exit,
15    is_ephemeral_anchor_output, next_chain_queries,
16};
17
18use tracing::{debug, trace, warn};
19
20use crate::{
21    chain::{BitcoinChainService, Outspend},
22    error::SdkError,
23    models::{
24        ConfirmationStatus, CpfpFundingKind, CpfpInput as ModelCpfpInput, ExitLeafSelection,
25        PerBranchFunding, PrepareUnilateralExitRequest, PrepareUnilateralExitResponse,
26        UnilateralExitLeaf, UnilateralExitRequest, UnilateralExitResponse,
27        UnilateralExitTransaction, UnilateralExitTxKind,
28    },
29    signer::CpfpSigner,
30};
31
32use super::BreezSdk;
33
34#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
35#[allow(clippy::needless_pass_by_value)]
36impl BreezSdk {
37    /// Quotes a unilateral exit without any funding UTXOs: selects which leaves
38    /// would exit, computes the exact fee for the given funding kind, and reports
39    /// how much to fund.
40    pub async fn prepare_unilateral_exit(
41        &self,
42        request: PrepareUnilateralExitRequest,
43    ) -> Result<PrepareUnilateralExitResponse, SdkError> {
44        debug!(
45            fee_rate_sat_per_vbyte = request.fee_rate_sat_per_vbyte,
46            funding_kind = ?request.funding_kind,
47            selection = ?request.selection,
48            "prepare_unilateral_exit: quoting"
49        );
50        let btc_network: bitcoin::Network = self.config.network.into();
51
52        let destination = request
53            .destination
54            .parse::<Address<NetworkUnchecked>>()
55            .map_err(|e| SdkError::InvalidInput(format!("Invalid destination address: {e}")))?
56            .require_network(btc_network)
57            .map_err(|e| SdkError::InvalidInput(format!("Address network mismatch: {e}")))?;
58        let dest_script_len = destination.script_pubkey().len();
59
60        // Leaf auto-resolution lives in the wallet.
61        let selection = match request.selection {
62            ExitLeafSelection::Auto => spark_wallet::ExitLeafSelection::Auto,
63            ExitLeafSelection::Specific { leaf_ids } => {
64                if leaf_ids.is_empty() {
65                    return Err(SdkError::InvalidInput("No leaves to exit".to_string()));
66                }
67                let ids = leaf_ids
68                    .iter()
69                    .map(|s| {
70                        TreeNodeId::from_str(s).map_err(|e| {
71                            SdkError::InvalidInput(format!("Invalid leaf id {s}: {e}"))
72                        })
73                    })
74                    .collect::<Result<Vec<_>, _>>()?;
75                spark_wallet::ExitLeafSelection::Specific(ids)
76            }
77        };
78
79        let (input_weight, output_script) = funding_kind_params(&request.funding_kind)?;
80        let quote = self
81            .spark_wallet
82            .quote_unilateral_exit(
83                sat_per_kw_from_vbyte(request.fee_rate_sat_per_vbyte),
84                selection,
85                input_weight,
86                output_script.len(),
87                output_script.minimal_non_dust().to_sat(),
88                dest_script_len,
89            )
90            .await?;
91        // No selected leaves is not an error: return an empty quote.
92        let recoverable_value_sat = quote
93            .selected_leaves
94            .iter()
95            .map(|l| l.value)
96            .fold(0u64, u64::saturating_add);
97        let leaves = quote
98            .selected_leaves
99            .iter()
100            .map(|l| UnilateralExitLeaf {
101                leaf_id: l.id.to_string(),
102                value: l.value,
103            })
104            .collect();
105        let per_branch_funding: Vec<PerBranchFunding> = quote
106            .per_branch_funding
107            .into_iter()
108            .map(|(id, funding_sat)| PerBranchFunding {
109                leaf_id: id.to_string(),
110                funding_sat,
111            })
112            .collect();
113
114        debug!(
115            selected_leaves = quote.selected_leaves.len(),
116            recoverable_value_sat,
117            total_fee_sat = quote.total_fee_sat,
118            fanout_fee_sat = quote.fanout_fee_sat,
119            single_utxo_funding_sat = quote.single_utxo_funding_sat,
120            branches = per_branch_funding.len(),
121            "prepare_unilateral_exit: quote ready"
122        );
123
124        Ok(PrepareUnilateralExitResponse {
125            leaves,
126            recoverable_value_sat,
127            total_fee_sat: quote.total_fee_sat,
128            fanout_fee_sat: quote.fanout_fee_sat,
129            single_utxo_funding_sat: quote.single_utxo_funding_sat,
130            per_branch_funding,
131            fee_rate_sat_per_vbyte: request.fee_rate_sat_per_vbyte,
132            destination: request.destination,
133        })
134    }
135
136    /// Builds and signs a complete unilateral exit from a `prepare_unilateral_exit`
137    /// quote and the actual funding UTXOs, returning the full transaction set in
138    /// topological broadcast order without broadcasting. Broadcast it over time,
139    /// respecting each transaction's `depends_on` and `csv_timelock_blocks`.
140    ///
141    /// It resolves on-chain state first (see [`resolve_exit_observations`]): an
142    /// already-confirmed fan-out or CPFP node is not rebuilt, and a leaf refund
143    /// already on-chain (recognized by the leaf's refund address, so any refund
144    /// variant counts) is swept directly. Re-running after partial progress
145    /// therefore resumes rather than restarts.
146    #[allow(clippy::too_many_lines)]
147    pub async fn unilateral_exit(
148        &self,
149        request: UnilateralExitRequest,
150        signer: Arc<dyn CpfpSigner>,
151    ) -> Result<UnilateralExitResponse, SdkError> {
152        let UnilateralExitRequest {
153            prepared,
154            funding_inputs,
155        } = request;
156        debug!(
157            leaves = prepared.leaves.len(),
158            funding_inputs = funding_inputs.len(),
159            fee_rate_sat_per_vbyte = prepared.fee_rate_sat_per_vbyte,
160            "unilateral_exit: building"
161        );
162        let btc_network: bitcoin::Network = self.config.network.into();
163        let chain = self.chain_service.as_ref();
164
165        let destination = prepared
166            .destination
167            .parse::<Address<NetworkUnchecked>>()
168            .map_err(|e| SdkError::InvalidInput(format!("Invalid destination address: {e}")))?
169            .require_network(btc_network)
170            .map_err(|e| SdkError::InvalidInput(format!("Address network mismatch: {e}")))?;
171        let dest_script_len = destination.script_pubkey().len();
172
173        // The build never re-selects: the quote's leaves are an explicit set.
174        let leaf_ids = prepared
175            .leaves
176            .iter()
177            .map(|l| {
178                TreeNodeId::from_str(&l.leaf_id).map_err(|e| {
179                    SdkError::InvalidInput(format!("Invalid leaf id {}: {e}", l.leaf_id))
180                })
181            })
182            .collect::<Result<Vec<_>, _>>()?;
183        if leaf_ids.is_empty() {
184            // An empty quote builds to an empty result rather than erroring.
185            debug!("unilateral_exit: quote has no leaves, returning empty result");
186            return Ok(empty_exit_response());
187        }
188
189        let funding_inputs = funding_inputs
190            .into_iter()
191            .map(|i| i.into_funding_input(btc_network))
192            .collect::<Result<Vec<_>, SdkError>>()?;
193        if funding_inputs.is_empty() {
194            return Err(SdkError::InvalidInput(
195                "At least one funding input is required".to_string(),
196            ));
197        }
198
199        let fee_rate_sat_per_kw = sat_per_kw_from_vbyte(prepared.fee_rate_sat_per_vbyte);
200        let prepared_exit = self
201            .spark_wallet
202            .prepare_unilateral_exit_plan(
203                fee_rate_sat_per_kw,
204                spark_wallet::ExitLeafSelection::Specific(leaf_ids),
205                funding_inputs,
206                dest_script_len,
207            )
208            .await?;
209        if prepared_exit.plan.selected_leaves.is_empty() {
210            debug!("unilateral_exit: plan selected no leaves, returning empty result");
211            return Ok(empty_exit_response());
212        }
213        trace!(
214            selected_leaves = prepared_exit.plan.selected_leaves.len(),
215            tree_nodes = prepared_exit.plan.tree_nodes.len(),
216            has_fan_out = prepared_exit.plan.fan_out_psbt.is_some(),
217            "unilateral_exit: plan prepared"
218        );
219
220        let leaves: Vec<UnilateralExitLeaf> = prepared_exit
221            .plan
222            .selected_leaves
223            .iter()
224            .map(|l| UnilateralExitLeaf {
225                leaf_id: l.id.to_string(),
226                value: l.value,
227            })
228            .collect();
229
230        let observed = resolve_exit_observations(chain, &prepared_exit).await?;
231        let build = build_unilateral_exit(&prepared_exit, &observed, fee_rate_sat_per_kw)?;
232        let recoverable_value_sat = build.recoverable_value_sat;
233        let build_fee_sat = build.total_fee_sat;
234        // Captured before the loop below consumes `build.branches`.
235        let sweep_status = sweep_confirmation_status(&build);
236        debug!(
237            has_fan_out = build.fan_out.is_some(),
238            branches = build.branches.len(),
239            refund_outputs = build.refund_outputs.len(),
240            cpfp_change_inputs = build.cpfp_change_inputs.len(),
241            recoverable_value_sat,
242            build_fee_sat,
243            "unilateral_exit: build assembled, signing"
244        );
245
246        let mut transactions: Vec<UnilateralExitTransaction> = Vec::new();
247
248        if let Some(fan_out) = build.fan_out {
249            trace!(
250                txid = %fan_out.txid,
251                status = ?fan_out.status,
252                needs_signing = fan_out.to_sign.is_some(),
253                "unilateral_exit: fan-out"
254            );
255            let tx_hex = match fan_out.to_sign {
256                Some(psbt) => sign_psbt_via(psbt, signer.as_ref()).await?,
257                None => serialize_hex(&fan_out.base_tx),
258            };
259            transactions.push(UnilateralExitTransaction {
260                kind: UnilateralExitTxKind::FanOut,
261                node_id: None,
262                txid: fan_out.txid.to_string(),
263                tx_hex,
264                cpfp_tx_hex: None,
265                csv_timelock_blocks: fan_out.csv_timelock_blocks,
266                depends_on: fan_out.depends_on.iter().map(ToString::to_string).collect(),
267                status: confirmation_status(fan_out.status),
268            });
269        }
270
271        for branch in build.branches {
272            trace!(leaf_id = %branch.leaf_id, txs = branch.txs.len(), "unilateral_exit: branch");
273            for tx in branch.txs {
274                let kind = match tx.kind {
275                    ExitTxKind::Node => UnilateralExitTxKind::Node,
276                    ExitTxKind::Refund => UnilateralExitTxKind::Refund,
277                    // The fan-out is emitted above, never inside a branch.
278                    ExitTxKind::FanOut => continue,
279                };
280                trace!(
281                    ?kind,
282                    node_id = ?tx.node_id.as_ref().map(ToString::to_string),
283                    txid = %tx.txid,
284                    status = ?tx.status,
285                    needs_cpfp_child = tx.to_sign.is_some(),
286                    csv_timelock_blocks = ?tx.csv_timelock_blocks,
287                    depends_on = tx.depends_on.len(),
288                    "unilateral_exit: exit tx"
289                );
290                let cpfp_tx_hex = match tx.to_sign {
291                    Some(child) => Some(sign_psbt_via(child, signer.as_ref()).await?),
292                    None => None,
293                };
294                transactions.push(UnilateralExitTransaction {
295                    kind,
296                    node_id: tx.node_id.map(|id| id.to_string()),
297                    txid: tx.txid.to_string(),
298                    tx_hex: serialize_hex(&tx.base_tx),
299                    cpfp_tx_hex,
300                    csv_timelock_blocks: tx.csv_timelock_blocks,
301                    depends_on: tx.depends_on.iter().map(ToString::to_string).collect(),
302                    status: confirmation_status(tx.status),
303                });
304            }
305        }
306
307        // A sweep over zero inputs would error: return without one when no refund
308        // is on-chain yet. A later run sweeps any refund that surfaces.
309        if build.refund_outputs.is_empty() {
310            debug!("unilateral_exit: no refund outputs to sweep, omitting the sweep");
311            return Ok(UnilateralExitResponse {
312                recoverable_value_sat,
313                total_fee_sat: build_fee_sat,
314                leaves,
315                transactions,
316            });
317        }
318
319        let refund_txids: Vec<String> = build
320            .refund_outputs
321            .iter()
322            .map(|r| r.outpoint.txid.to_string())
323            .collect();
324        let sweep_psbt = self
325            .spark_wallet
326            .create_refund_sweep_transaction(
327                build.refund_outputs,
328                build.cpfp_change_inputs,
329                destination,
330                fee_rate_sat_per_kw,
331            )
332            .await?;
333        let actual_sweep_fee = sweep_fee(&sweep_psbt);
334        let total_fee_sat = build_fee_sat.saturating_add(actual_sweep_fee);
335        let sweep_txid = sweep_psbt.unsigned_tx.compute_txid();
336        trace!(
337            txid = %sweep_txid,
338            status = ?sweep_status,
339            refund_inputs = refund_txids.len(),
340            "unilateral_exit: sweep"
341        );
342        let sweep_tx_hex = finalize_sweep(sweep_psbt, signer.as_ref()).await?;
343        transactions.push(UnilateralExitTransaction {
344            kind: UnilateralExitTxKind::Sweep,
345            node_id: None,
346            txid: sweep_txid.to_string(),
347            tx_hex: sweep_tx_hex,
348            cpfp_tx_hex: None,
349            csv_timelock_blocks: None,
350            depends_on: refund_txids,
351            status: sweep_status,
352        });
353
354        debug!(
355            transactions = transactions.len(),
356            recoverable_value_sat, total_fee_sat, "unilateral_exit: complete"
357        );
358        Ok(UnilateralExitResponse {
359            recoverable_value_sat,
360            total_fee_sat,
361            leaves,
362            transactions,
363        })
364    }
365}
366
367/// The sweep's fee: total input value minus output value.
368fn sweep_fee(sweep_psbt: &bitcoin::Psbt) -> u64 {
369    let in_value: u64 = sweep_psbt
370        .inputs
371        .iter()
372        .filter_map(|i| i.witness_utxo.as_ref())
373        .map(|o| o.value.to_sat())
374        .fold(0u64, u64::saturating_add);
375    let out_value: u64 = sweep_psbt
376        .unsigned_tx
377        .output
378        .iter()
379        .map(|o| o.value.to_sat())
380        .fold(0u64, u64::saturating_add);
381    in_value.saturating_sub(out_value)
382}
383
384/// Converts a sat/vByte fee rate (the public API unit) to sat/kW (the exit
385/// engine's unit): one vByte is 4 weight units, so 1 sat/vByte is 250 sat/kW.
386fn sat_per_kw_from_vbyte(sat_per_vbyte: u64) -> u64 {
387    sat_per_vbyte.saturating_mul(250)
388}
389
390/// The signed input weight and a representative output scriptPubKey for a
391/// funding kind.
392fn funding_kind_params(kind: &CpfpFundingKind) -> Result<(u64, ScriptBuf), SdkError> {
393    // Only the scriptPubKey length matters here (it fixes output weight and
394    // dust), so any valid program of the right size works.
395    let witness_script = |version, program: &[u8]| -> Result<ScriptBuf, SdkError> {
396        let program = bitcoin::WitnessProgram::new(version, program).map_err(|e| {
397            SdkError::Generic(format!("invalid representative witness program: {e}"))
398        })?;
399        Ok(ScriptBuf::new_witness_program(&program))
400    };
401    let (weight, script) = match kind {
402        CpfpFundingKind::P2wpkh => (
403            spark_wallet::p2wpkh_input_weight().to_wu(),
404            witness_script(bitcoin::WitnessVersion::V0, &[0u8; 20])?,
405        ),
406        CpfpFundingKind::P2tr => (
407            spark_wallet::p2tr_key_path_input_weight().to_wu(),
408            witness_script(bitcoin::WitnessVersion::V1, &[0u8; 32])?,
409        ),
410        CpfpFundingKind::Custom {
411            script_pubkey_hex,
412            signed_input_weight,
413        } => {
414            let script = ScriptBuf::from_hex(script_pubkey_hex).map_err(|e| {
415                SdkError::InvalidInput(format!("Invalid funding script_pubkey_hex: {e}"))
416            })?;
417            // Only native SegWit funding is supported: the exit threads txids from
418            // unsigned txs, stable only when the input's scriptSig stays empty.
419            // Reject here so the quote fails before funding is gathered.
420            if !script.is_witness_program() {
421                return Err(SdkError::InvalidInput(
422                    "Custom funding must pay to a native SegWit (witness-program) script"
423                        .to_string(),
424                ));
425            }
426            (*signed_input_weight, script)
427        }
428    };
429    Ok((weight, script))
430}
431
432impl ModelCpfpInput {
433    /// Converts into the spark-wallet funding type. Takes `network` to derive
434    /// the P2WPKH/P2TR script from a pubkey.
435    fn into_funding_input(self, network: bitcoin::Network) -> Result<CpfpInput, SdkError> {
436        let parse_txid = |s: &str| {
437            Txid::from_str(s)
438                .map_err(|e| SdkError::InvalidInput(format!("Invalid funding txid: {e}")))
439        };
440        match self {
441            ModelCpfpInput::P2wpkh {
442                txid,
443                vout,
444                value,
445                pubkey,
446            } => {
447                let pk = PublicKey::from_str(&pubkey)
448                    .map_err(|e| SdkError::InvalidInput(format!("Invalid funding pubkey: {e}")))?;
449                let script_pubkey =
450                    Address::p2wpkh(&CompressedPublicKey(pk), network).script_pubkey();
451                Ok(CpfpInput {
452                    outpoint: OutPoint {
453                        txid: parse_txid(&txid)?,
454                        vout,
455                    },
456                    witness_utxo: TxOut {
457                        value: Amount::from_sat(value),
458                        script_pubkey,
459                    },
460                    signed_input_weight: spark_wallet::p2wpkh_input_weight().to_wu(),
461                })
462            }
463            ModelCpfpInput::P2tr {
464                txid,
465                vout,
466                value,
467                pubkey,
468            } => {
469                let xonly = parse_xonly(&pubkey)?;
470                let secp = bitcoin::secp256k1::Secp256k1::verification_only();
471                let script_pubkey = Address::p2tr(&secp, xonly, None, network).script_pubkey();
472                Ok(CpfpInput {
473                    outpoint: OutPoint {
474                        txid: parse_txid(&txid)?,
475                        vout,
476                    },
477                    witness_utxo: TxOut {
478                        value: Amount::from_sat(value),
479                        script_pubkey,
480                    },
481                    signed_input_weight: spark_wallet::p2tr_key_path_input_weight().to_wu(),
482                })
483            }
484            ModelCpfpInput::Custom {
485                txid,
486                vout,
487                value,
488                script_pubkey_hex,
489                signed_input_weight,
490            } => {
491                let script_pubkey = ScriptBuf::from_hex(&script_pubkey_hex).map_err(|e| {
492                    SdkError::InvalidInput(format!("Invalid funding scriptPubKey hex: {e}"))
493                })?;
494                // The exit signs/weighs funding inputs as SegWit and spends them in
495                // a v3/TRUC anchor package; a legacy input breaks both. Reject it.
496                if !script_pubkey.is_witness_program() {
497                    return Err(SdkError::InvalidInput(
498                        "Custom funding input must pay to a SegWit (witness-program) script"
499                            .to_string(),
500                    ));
501                }
502                Ok(CpfpInput {
503                    outpoint: OutPoint {
504                        txid: parse_txid(&txid)?,
505                        vout,
506                    },
507                    witness_utxo: TxOut {
508                        value: Amount::from_sat(value),
509                        script_pubkey,
510                    },
511                    signed_input_weight,
512                })
513            }
514        }
515    }
516}
517
518/// Parses an x-only pubkey from hex, accepting both x-only (32-byte) and
519/// compressed (33-byte) encodings.
520fn parse_xonly(pubkey: &str) -> Result<XOnlyPublicKey, SdkError> {
521    if let Ok(xonly) = XOnlyPublicKey::from_str(pubkey) {
522        return Ok(xonly);
523    }
524    let pk = PublicKey::from_str(pubkey)
525        .map_err(|e| SdkError::InvalidInput(format!("Invalid funding pubkey: {e}")))?;
526    Ok(pk.x_only_public_key().0)
527}
528
529/// Drives the wallet's pure resolver to completion: it reports which chain
530/// lookups the exit needs, core performs them, and the results are fed back until
531/// nothing more is needed. Core never interprets the exit tree itself.
532async fn resolve_exit_observations(
533    chain: &dyn BitcoinChainService,
534    prepared: &PreparedUnilateralExit,
535) -> Result<Vec<Observation>, SdkError> {
536    let mut observed: Vec<Observation> = Vec::new();
537    let mut round = 0u32;
538    loop {
539        let queries = next_chain_queries(prepared, &observed)?;
540        if queries.is_empty() {
541            break;
542        }
543        round = round.saturating_add(1);
544        trace!(
545            round,
546            queries = queries.len(),
547            "resolve_exit_observations: round"
548        );
549        // Each query is answered exactly once (a failed lookup records
550        // `Unavailable`), so the loop always progresses and terminates.
551        for query in queries {
552            let result = execute_chain_query(chain, &query).await;
553            observed.push(Observation { query, result });
554        }
555    }
556    debug!(
557        rounds = round,
558        observations = observed.len(),
559        "resolve_exit_observations: on-chain state resolved"
560    );
561    Ok(observed)
562}
563
564/// Performs one [`ChainQuery`], translating this crate's chain types into the
565/// wallet's `bitcoin`-only [`ChainResult`]. A failed lookup becomes
566/// [`ChainResult::Unavailable`] so the wallet flags the affected tx as unverified
567/// rather than treating it as confirmed or absent.
568async fn execute_chain_query(chain: &dyn BitcoinChainService, query: &ChainQuery) -> ChainResult {
569    match query {
570        ChainQuery::Outspend(outpoint) => {
571            match chain
572                .get_outspend(outpoint.txid.to_string(), outpoint.vout)
573                .await
574            {
575                Ok(Outspend::Spent { txid, status, .. }) => match Txid::from_str(&txid) {
576                    Ok(spender_txid) => {
577                        trace!(%outpoint, spender = %spender_txid, confirmed = status.confirmed, "chain: outpoint spent");
578                        ChainResult::Spend(Some(SpendInfo {
579                            spender_txid,
580                            confirmed: status.confirmed,
581                        }))
582                    }
583                    Err(e) => {
584                        warn!("outspend of {outpoint} has an unparsable spender txid {txid}: {e}");
585                        ChainResult::Unavailable
586                    }
587                },
588                Ok(Outspend::Unspent) => {
589                    trace!(%outpoint, "chain: outpoint unspent");
590                    ChainResult::Spend(None)
591                }
592                Err(e) => {
593                    warn!("get_outspend for {outpoint} failed: {e}");
594                    ChainResult::Unavailable
595                }
596            }
597        }
598        ChainQuery::Transaction(txid) => match chain.get_transaction_hex(txid.to_string()).await {
599            Ok(hex) => match deserialize_hex::<Transaction>(&hex) {
600                Ok(tx) => {
601                    trace!(%txid, outputs = tx.output.len(), "chain: transaction fetched");
602                    ChainResult::Transaction(tx)
603                }
604                Err(e) => {
605                    warn!("failed to decode transaction {txid}: {e}");
606                    ChainResult::Unavailable
607                }
608            },
609            Err(e) => {
610                warn!("get_transaction_hex for {txid} failed: {e}");
611                ChainResult::Unavailable
612            }
613        },
614        ChainQuery::RefundAddress { leaf_id, address } => {
615            match chain.get_address_txos(address.to_string()).await {
616                Ok(txos) => {
617                    let txos: Vec<AddressUtxo> = txos
618                        .into_iter()
619                        .filter_map(|u| match Txid::from_str(&u.txid) {
620                            Ok(txid) => Some(AddressUtxo {
621                                txid,
622                                vout: u.vout,
623                                value: u.value,
624                                confirmed: u.status.confirmed,
625                            }),
626                            Err(e) => {
627                                warn!("skipping refund txo {} for leaf {leaf_id}: {e}", u.txid);
628                                None
629                            }
630                        })
631                        .collect();
632                    trace!(
633                        %leaf_id,
634                        txos = txos.len(),
635                        confirmed = txos.iter().filter(|u| u.confirmed).count(),
636                        "chain: refund address scanned"
637                    );
638                    ChainResult::AddressUtxos(txos)
639                }
640                Err(e) => {
641                    warn!("get_address_txos for leaf {leaf_id} failed: {e}");
642                    ChainResult::Unavailable
643                }
644            }
645        }
646    }
647}
648
649fn confirmation_status(status: ExitTxStatus) -> ConfirmationStatus {
650    match status {
651        ExitTxStatus::Confirmed => ConfirmationStatus::Confirmed,
652        ExitTxStatus::Unconfirmed => ConfirmationStatus::Unconfirmed,
653        ExitTxStatus::Unverified => ConfirmationStatus::Unverified,
654    }
655}
656
657/// The sweep's status, derived from the refunds it spends. A verified refund is
658/// spent-and-dropped once its sweep confirms (the exit then returns with no
659/// sweep), so a freshly-returned sweep over verified refunds is never yet
660/// on-chain: `Unconfirmed`. An unverified refund (its chain lookup failed) could
661/// already be on-chain and swept without us knowing, so the sweep is `Unverified`.
662fn sweep_confirmation_status(build: &UnilateralExitBuild) -> ConfirmationStatus {
663    let any_refund_unverified = build
664        .branches
665        .iter()
666        .flat_map(|b| b.txs.iter())
667        .any(|t| t.kind == ExitTxKind::Refund && t.status == ExitTxStatus::Unverified);
668    if any_refund_unverified {
669        ConfirmationStatus::Unverified
670    } else {
671        ConfirmationStatus::Unconfirmed
672    }
673}
674
675fn empty_exit_response() -> UnilateralExitResponse {
676    UnilateralExitResponse {
677        recoverable_value_sat: 0,
678        total_fee_sat: 0,
679        leaves: Vec::new(),
680        transactions: Vec::new(),
681    }
682}
683
684/// Signs a PSBT via the external `CpfpSigner`, returning the tx as hex.
685/// Ephemeral anchor inputs are finalized here (`OP_TRUE`, no signature).
686async fn sign_psbt_via(
687    mut psbt: bitcoin::Psbt,
688    signer: &dyn CpfpSigner,
689) -> Result<String, SdkError> {
690    for input in &mut psbt.inputs {
691        if let Some(txo) = &input.witness_utxo
692            && is_ephemeral_anchor_output(txo)
693        {
694            input.final_script_witness = Some(bitcoin::Witness::new());
695        }
696    }
697    let out_bytes = signer
698        .sign_psbt(psbt.serialize())
699        .await
700        .map_err(|e| SdkError::Signer(format!("CPFP signer error: {e}")))?;
701    let out_psbt = bitcoin::Psbt::deserialize(&out_bytes)
702        .map_err(|e| SdkError::Generic(format!("Failed to deserialize signed PSBT: {e}")))?;
703    ensure_all_inputs_finalized(&out_psbt)?;
704    Ok(serialize_hex(&out_psbt.extract_tx_unchecked_fee_rate()))
705}
706
707/// Finalizes the sweep. Refund inputs are already signed by spark-wallet, so the
708/// external signer is only invoked when CPFP-change inputs still need it.
709async fn finalize_sweep(psbt: bitcoin::Psbt, signer: &dyn CpfpSigner) -> Result<String, SdkError> {
710    let needs_signer = psbt
711        .inputs
712        .iter()
713        .any(|input| input.final_script_witness.is_none());
714    let psbt = if needs_signer {
715        let out_bytes = signer
716            .sign_psbt(psbt.serialize())
717            .await
718            .map_err(|e| SdkError::Signer(format!("Sweep signer error: {e}")))?;
719        bitcoin::Psbt::deserialize(&out_bytes).map_err(|e| {
720            SdkError::Generic(format!("Failed to deserialize signed sweep PSBT: {e}"))
721        })?
722    } else {
723        psbt
724    };
725    ensure_all_inputs_finalized(&psbt)?;
726    Ok(serialize_hex(&psbt.extract_tx_unchecked_fee_rate()))
727}
728
729/// Rejects a PSBT with any input the signer left unfinalized (neither a witness
730/// nor a scriptSig), so a missing signature fails here instead of at broadcast.
731fn ensure_all_inputs_finalized(psbt: &bitcoin::Psbt) -> Result<(), SdkError> {
732    if let Some(index) = psbt
733        .inputs
734        .iter()
735        .position(|input| input.final_script_witness.is_none() && input.final_script_sig.is_none())
736    {
737        return Err(SdkError::Signer(format!(
738            "PSBT input {index} was not signed"
739        )));
740    }
741    Ok(())
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use crate::error::SignerError;
748    use bitcoin::hashes::Hash;
749    use spark_wallet::{ExitBranch, ExitTx};
750
751    #[cfg(feature = "browser-tests")]
752    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
753
754    fn refund_tx(status: ExitTxStatus) -> ExitTx {
755        ExitTx {
756            kind: ExitTxKind::Refund,
757            node_id: None,
758            txid: Txid::from_byte_array([3; 32]),
759            base_tx: Transaction {
760                version: bitcoin::transaction::Version::TWO,
761                lock_time: bitcoin::absolute::LockTime::ZERO,
762                input: vec![],
763                output: vec![],
764            },
765            to_sign: None,
766            csv_timelock_blocks: None,
767            depends_on: vec![],
768            status,
769        }
770    }
771
772    fn build_with_refund(status: ExitTxStatus) -> UnilateralExitBuild {
773        UnilateralExitBuild {
774            fan_out: None,
775            branches: vec![ExitBranch {
776                leaf_id: TreeNodeId::from_str("leaf").unwrap(),
777                txs: vec![refund_tx(status)],
778            }],
779            refund_outputs: vec![],
780            cpfp_change_inputs: vec![],
781            recoverable_value_sat: 0,
782            total_fee_sat: 0,
783        }
784    }
785
786    #[test]
787    fn sweep_status_is_unconfirmed_when_refunds_are_verified() {
788        assert_eq!(
789            sweep_confirmation_status(&build_with_refund(ExitTxStatus::Unconfirmed)),
790            ConfirmationStatus::Unconfirmed
791        );
792    }
793
794    #[test]
795    fn sweep_status_is_unverified_when_a_refund_is_unverified() {
796        assert_eq!(
797            sweep_confirmation_status(&build_with_refund(ExitTxStatus::Unverified)),
798            ConfirmationStatus::Unverified
799        );
800    }
801
802    fn unsigned_two_input_psbt() -> bitcoin::Psbt {
803        let tx = Transaction {
804            version: bitcoin::transaction::Version::TWO,
805            lock_time: bitcoin::absolute::LockTime::ZERO,
806            input: vec![
807                bitcoin::TxIn {
808                    previous_output: OutPoint {
809                        txid: Txid::from_byte_array([1; 32]),
810                        vout: 0,
811                    },
812                    ..Default::default()
813                },
814                bitcoin::TxIn {
815                    previous_output: OutPoint {
816                        txid: Txid::from_byte_array([2; 32]),
817                        vout: 0,
818                    },
819                    ..Default::default()
820                },
821            ],
822            output: vec![TxOut {
823                value: Amount::from_sat(1_000),
824                script_pubkey: ScriptBuf::new(),
825            }],
826        };
827        let mut psbt = bitcoin::Psbt::from_unsigned_tx(tx).unwrap();
828        for input in &mut psbt.inputs {
829            input.witness_utxo = Some(TxOut {
830                value: Amount::from_sat(2_000),
831                script_pubkey: ScriptBuf::new(),
832            });
833        }
834        psbt
835    }
836
837    fn finalize_input(input: &mut bitcoin::psbt::Input) {
838        let mut witness = bitcoin::Witness::new();
839        witness.push([0x01u8]);
840        input.final_script_witness = Some(witness);
841    }
842
843    /// A `CpfpSigner` that finalizes only the first `finalize` inputs.
844    struct PartialSigner {
845        finalize: usize,
846    }
847
848    #[macros::async_trait]
849    impl CpfpSigner for PartialSigner {
850        async fn sign_psbt(&self, psbt_bytes: Vec<u8>) -> Result<Vec<u8>, SignerError> {
851            let mut psbt = bitcoin::Psbt::deserialize(&psbt_bytes).unwrap();
852            for input in psbt.inputs.iter_mut().take(self.finalize) {
853                finalize_input(input);
854            }
855            Ok(psbt.serialize())
856        }
857    }
858
859    #[test]
860    fn ensure_all_inputs_finalized_rejects_unsigned() {
861        assert!(ensure_all_inputs_finalized(&unsigned_two_input_psbt()).is_err());
862    }
863
864    #[test]
865    fn ensure_all_inputs_finalized_accepts_finalized() {
866        let mut psbt = unsigned_two_input_psbt();
867        psbt.inputs.iter_mut().for_each(finalize_input);
868        assert!(ensure_all_inputs_finalized(&psbt).is_ok());
869    }
870
871    #[macros::async_test_all]
872    async fn sign_psbt_via_errors_when_an_input_is_left_unsigned() {
873        let result = sign_psbt_via(unsigned_two_input_psbt(), &PartialSigner { finalize: 1 }).await;
874        assert!(matches!(result, Err(SdkError::Signer(_))));
875    }
876
877    #[macros::async_test_all]
878    async fn sign_psbt_via_succeeds_when_every_input_is_signed() {
879        let result = sign_psbt_via(unsigned_two_input_psbt(), &PartialSigner { finalize: 2 }).await;
880        assert!(result.is_ok());
881    }
882}