Skip to main content

breez_sdk_spark/sdk/
unilateral_exit.rs

1use std::collections::{HashMap, HashSet};
2use std::str::FromStr;
3use std::sync::Arc;
4
5use bitcoin::{
6    Address, Amount, CompressedPublicKey, OutPoint, ScriptBuf, Transaction, TxOut, Txid,
7    XOnlyPublicKey,
8    address::NetworkUnchecked,
9    consensus::encode::{deserialize_hex, serialize_hex},
10    secp256k1::PublicKey,
11};
12
13use spark_wallet::{
14    AddressUtxo, ChainQuery, ChainResult, ConfirmedExitNode as WalletConfirmedExitNode, CpfpInput,
15    ExitChainState as WalletExitChainState, ExitCheck, ExitCheckInput,
16    ExitNodeConfirmation as WalletExitNodeConfirmation, ExitRefund as WalletExitRefund,
17    ExitRefundState as WalletExitRefundState, ExitTxKind, ExitTxStatus, Observation, SpendInfo,
18    TreeNode, TreeNodeId, UnilateralExitBuild, build_unilateral_exit, check_exit_chain,
19    is_ephemeral_anchor_output, leaf_refund_addresses, scan_exit_chain, scan_funding,
20};
21
22use tracing::{debug, trace, warn};
23
24use crate::{
25    chain::{BitcoinChainService, Outspend},
26    error::SdkError,
27    models::{
28        CheckUnilateralExitRequest, CheckUnilateralExitResponse, ConfirmedExitNode,
29        CpfpFundingKind, CpfpInput as ModelCpfpInput, ExitChainState as ModelExitChainState,
30        ExitLeafSelection, ExitNodeConfirmation, ExitRefund, ExitRefundState,
31        ExitTransactionStatus, PerBranchFunding, PrepareUnilateralExitRequest,
32        PrepareUnilateralExitResponse, UnilateralExitLeaf, UnilateralExitRedoReason,
33        UnilateralExitRequest, UnilateralExitResponse, UnilateralExitTransaction,
34        UnilateralExitTxKind, UnilateralExitVerdict,
35    },
36    signer::CpfpSigner,
37};
38
39use super::BreezSdk;
40
41#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
42#[allow(clippy::needless_pass_by_value)]
43impl BreezSdk {
44    /// Quotes a unilateral exit without any funding UTXOs: selects which leaves
45    /// would exit, computes the exact fee for the given funding kind, and reports
46    /// how much to fund.
47    pub async fn prepare_unilateral_exit(
48        &self,
49        request: PrepareUnilateralExitRequest,
50    ) -> Result<PrepareUnilateralExitResponse, SdkError> {
51        debug!(
52            fee_rate_sat_per_vbyte = request.fee_rate_sat_per_vbyte,
53            funding_kind = ?request.funding_kind,
54            selection = ?request.selection,
55            "prepare_unilateral_exit: quoting"
56        );
57        let btc_network: bitcoin::Network = self.config.network.into();
58
59        let destination = request
60            .destination
61            .parse::<Address<NetworkUnchecked>>()
62            .map_err(|e| SdkError::InvalidInput(format!("Invalid destination address: {e}")))?
63            .require_network(btc_network)
64            .map_err(|e| SdkError::InvalidInput(format!("Address network mismatch: {e}")))?;
65        let dest_script_len = destination.script_pubkey().len();
66
67        let selection = wallet_selection(request.selection)?;
68
69        let (input_weight, output_script) = funding_kind_params(&request.funding_kind)?;
70        let mut context = self.spark_wallet.load_exit_context(selection).await?;
71
72        // Ask the chain what these leaves have already done before pricing them,
73        // so a leaf part-way out is quoted on the work it has left rather than on
74        // a fresh exit, and is not dropped as unprofitable over work already
75        // paid for. Every lookup the tree needs happens here.
76        let refund_addresses = leaf_refund_addresses(
77            &context.tree_nodes,
78            &context.leaf_ids,
79            self.config.network.into(),
80        );
81        let exit_chain_state = resolve_exit_chain_state(
82            self.chain_service.as_ref(),
83            &context.tree_nodes,
84            &context.leaf_ids,
85            &refund_addresses,
86        )
87        .await?;
88        // A leaf whose exit already finished is not exited again, whether named or
89        // not; `exit_chain_state` still reports it as swept or stopped.
90        context.drop_finished_leaves(&exit_chain_state);
91
92        let quote = self.spark_wallet.quote_unilateral_exit(
93            &context,
94            sat_per_kw_from_vbyte(request.fee_rate_sat_per_vbyte),
95            input_weight,
96            output_script.len(),
97            output_script.minimal_non_dust().to_sat(),
98            dest_script_len,
99            &exit_chain_state,
100        )?;
101        // No selected leaves is not an error: return an empty quote.
102        let recoverable_value_sat = quote
103            .selected_leaves
104            .iter()
105            .map(|l| l.value)
106            .fold(0u64, u64::saturating_add);
107        let leaves = quote
108            .selected_leaves
109            .iter()
110            .map(|l| UnilateralExitLeaf {
111                leaf_id: l.id.to_string(),
112                value: l.value,
113            })
114            .collect();
115        let per_branch_funding: Vec<PerBranchFunding> = quote
116            .per_branch_funding
117            .into_iter()
118            .map(|(id, funding_sat)| PerBranchFunding {
119                leaf_id: id.to_string(),
120                funding_sat,
121            })
122            .collect();
123
124        debug!(
125            selected_leaves = quote.selected_leaves.len(),
126            recoverable_value_sat,
127            total_fee_sat = quote.total_fee_sat,
128            cpfp_fee_sat = quote.cpfp_fee_sat,
129            fanout_fee_sat = quote.fanout_fee_sat,
130            sweep_fee_sat = quote.sweep_fee_sat,
131            single_utxo_funding_sat = quote.single_utxo_funding_sat,
132            branches = per_branch_funding.len(),
133            "prepare_unilateral_exit: quote ready"
134        );
135
136        Ok(PrepareUnilateralExitResponse {
137            leaves,
138            recoverable_value_sat,
139            total_fee_sat: quote.total_fee_sat,
140            cpfp_fee_sat: quote.cpfp_fee_sat,
141            fanout_fee_sat: quote.fanout_fee_sat,
142            sweep_fee_sat: quote.sweep_fee_sat,
143            single_utxo_funding_sat: quote.single_utxo_funding_sat,
144            per_branch_funding,
145            fee_rate_sat_per_vbyte: request.fee_rate_sat_per_vbyte,
146            destination: request.destination,
147            exit_chain_state: exit_chain_state_model(exit_chain_state),
148        })
149    }
150
151    /// Reads an exit you kept back against the chain: which of its transactions
152    /// are now in a block, and whether it can still be finished as it stands.
153    ///
154    /// Needs neither the wallet's leaves nor a signer, so an exit can be followed
155    /// from the response alone. Store the response in place of the one you passed
156    /// in, and broadcast what its statuses leave to send.
157    pub async fn check_unilateral_exit(
158        &self,
159        request: CheckUnilateralExitRequest,
160    ) -> Result<CheckUnilateralExitResponse, SdkError> {
161        let mut exit = request.exit;
162        debug!(
163            transactions = exit.transactions.len(),
164            "check_unilateral_exit: reading back"
165        );
166
167        let inputs = exit
168            .transactions
169            .iter()
170            .map(exit_check_input)
171            .collect::<Result<Vec<_>, SdkError>>()?;
172        let check = resolve_exit_check(self.chain_service.as_ref(), &inputs).await?;
173
174        for tx in &mut exit.transactions {
175            let txid = Txid::from_str(&tx.txid)
176                .map_err(|e| SdkError::InvalidInput(format!("Invalid txid {}: {e}", tx.txid)))?;
177            tx.status = status_after_check(tx.status, &check, &txid);
178        }
179
180        resolve_statuses(self.chain_service.as_ref(), &mut exit.transactions).await?;
181
182        let all_confirmed = exit
183            .transactions
184            .iter()
185            .all(|tx| matches!(tx.status, ExitTransactionStatus::Confirmed { .. }));
186        let verdict = if check.diverged {
187            UnilateralExitVerdict::Redo {
188                reason: UnilateralExitRedoReason::OnChainStateDiverged,
189            }
190        } else if all_confirmed && !exit.transactions.is_empty() {
191            UnilateralExitVerdict::Done
192        } else {
193            UnilateralExitVerdict::Valid
194        };
195        debug!(?verdict, "check_unilateral_exit: read back");
196
197        Ok(CheckUnilateralExitResponse { exit, verdict })
198    }
199
200    /// Builds and signs a complete unilateral exit from a `prepare_unilateral_exit`
201    /// quote and the actual funding UTXOs, returning the full transaction set in
202    /// topological broadcast order without broadcasting. Broadcast it over time,
203    /// respecting each transaction's `depends_on` and `csv_timelock_blocks`.
204    ///
205    /// It reads on-chain state first: an already-confirmed fan-out or CPFP node
206    /// is not rebuilt, and a leaf refund already on-chain (recognized by the
207    /// leaf's refund address, so any refund variant counts) is swept directly.
208    /// Re-running after partial progress therefore resumes rather than restarts.
209    #[allow(clippy::too_many_lines)]
210    pub async fn unilateral_exit(
211        &self,
212        request: UnilateralExitRequest,
213        signer: Arc<dyn CpfpSigner>,
214    ) -> Result<UnilateralExitResponse, SdkError> {
215        let UnilateralExitRequest {
216            prepared,
217            funding_inputs,
218        } = request;
219        let supplied_funding = funding_inputs.clone();
220        debug!(
221            leaves = prepared.leaves.len(),
222            funding_inputs = funding_inputs.len(),
223            fee_rate_sat_per_vbyte = prepared.fee_rate_sat_per_vbyte,
224            "unilateral_exit: building"
225        );
226        let btc_network: bitcoin::Network = self.config.network.into();
227        let chain = self.chain_service.as_ref();
228
229        let destination = prepared
230            .destination
231            .parse::<Address<NetworkUnchecked>>()
232            .map_err(|e| SdkError::InvalidInput(format!("Invalid destination address: {e}")))?
233            .require_network(btc_network)
234            .map_err(|e| SdkError::InvalidInput(format!("Address network mismatch: {e}")))?;
235        let dest_script_len = destination.script_pubkey().len();
236
237        // The build never re-selects: the quote's leaves are an explicit set.
238        let leaf_ids = prepared
239            .leaves
240            .iter()
241            .map(|l| {
242                TreeNodeId::from_str(&l.leaf_id).map_err(|e| {
243                    SdkError::InvalidInput(format!("Invalid leaf id {}: {e}", l.leaf_id))
244                })
245            })
246            .collect::<Result<Vec<_>, _>>()?;
247        if leaf_ids.is_empty() {
248            // An empty quote builds to an empty result rather than erroring.
249            debug!("unilateral_exit: quote has no leaves, returning empty result");
250            return Ok(empty_exit_response());
251        }
252
253        let funding_inputs = funding_inputs
254            .into_iter()
255            .map(|i| i.into_funding_input(btc_network))
256            .collect::<Result<Vec<_>, SdkError>>()?;
257        if funding_inputs.is_empty() {
258            return Err(SdkError::InvalidInput(
259                "At least one funding input is required".to_string(),
260            ));
261        }
262
263        // A prior run spends the funding it was given, so what the caller hands
264        // back may name outputs that are gone. Follow them to what they became
265        // before planning, so the plan is made over what can actually be spent.
266        let funding_inputs = resolve_funding(chain, funding_inputs).await?;
267        // Followed to nothing: a previous run spent all of it. That is a
268        // shortfall, not a malformed request.
269        if funding_inputs.is_empty() {
270            return Err(SdkError::InsufficientCpfpFunds {
271                required_sat: prepared.single_utxo_funding_sat,
272            });
273        }
274        let fee_rate_sat_per_kw = sat_per_kw_from_vbyte(prepared.fee_rate_sat_per_vbyte);
275        let chain_state = exit_chain_state_from_model(&prepared.exit_chain_state)?;
276        let context = self
277            .spark_wallet
278            .load_exit_context(spark_wallet::ExitLeafSelection::Specific(leaf_ids))
279            .await?;
280        let prepared_exit = self.spark_wallet.prepare_unilateral_exit_plan(
281            &context,
282            fee_rate_sat_per_kw,
283            funding_inputs,
284            dest_script_len,
285            &chain_state,
286        )?;
287        if prepared_exit.selected_leaves.is_empty() {
288            debug!("unilateral_exit: plan selected no leaves, returning empty result");
289            return Ok(empty_exit_response());
290        }
291        trace!(
292            selected_leaves = prepared_exit.selected_leaves.len(),
293            tree_nodes = prepared_exit.tree_nodes.len(),
294            has_fan_out = prepared_exit.fan_out_psbt.is_some(),
295            "unilateral_exit: plan prepared"
296        );
297
298        let leaves: Vec<UnilateralExitLeaf> = prepared_exit
299            .selected_leaves
300            .iter()
301            .map(|l| UnilateralExitLeaf {
302                leaf_id: l.id.to_string(),
303                value: l.value,
304            })
305            .collect();
306
307        let build = build_unilateral_exit(&prepared_exit, &chain_state, fee_rate_sat_per_kw)?;
308        let recoverable_value_sat = build.recoverable_value_sat;
309        let cpfp_fee_sat = build.cpfp_fee_sat;
310        let fanout_fee_sat = build.fanout_fee_sat;
311        let build_fee_sat = cpfp_fee_sat.saturating_add(fanout_fee_sat);
312        // Captured before the loop below consumes `build.branches`.
313        let sweep_status = sweep_initial_status(&build);
314        debug!(
315            has_fan_out = build.fan_out.is_some(),
316            branches = build.branches.len(),
317            refund_outputs = build.refund_outputs.len(),
318            cpfp_change_inputs = build.cpfp_change_inputs.len(),
319            recoverable_value_sat,
320            cpfp_fee_sat,
321            fanout_fee_sat,
322            "unilateral_exit: build assembled, signing"
323        );
324
325        let mut transactions: Vec<UnilateralExitTransaction> = Vec::new();
326
327        if let Some(fan_out) = build.fan_out {
328            trace!(
329                txid = %fan_out.txid,
330                status = ?fan_out.status,
331                needs_signing = fan_out.to_sign.is_some(),
332                "unilateral_exit: fan-out"
333            );
334            let tx_hex = match fan_out.to_sign {
335                Some(psbt) => sign_psbt_via(psbt, signer.as_ref()).await?,
336                None => serialize_hex(&fan_out.base_tx),
337            };
338            transactions.push(UnilateralExitTransaction {
339                kind: UnilateralExitTxKind::FanOut,
340                node_id: None,
341                txid: fan_out.txid.to_string(),
342                tx_hex,
343                cpfp_tx_hex: None,
344                csv_timelock_blocks: fan_out.csv_timelock_blocks,
345                depends_on: fan_out.depends_on.iter().map(ToString::to_string).collect(),
346                status: initial_status(fan_out.status),
347            });
348        }
349
350        for branch in build.branches {
351            trace!(leaf_id = %branch.leaf_id, txs = branch.txs.len(), "unilateral_exit: branch");
352            for tx in branch.txs {
353                let kind = match tx.kind {
354                    ExitTxKind::Node => UnilateralExitTxKind::Node,
355                    ExitTxKind::Refund => UnilateralExitTxKind::Refund,
356                    // The fan-out is emitted above, never inside a branch.
357                    ExitTxKind::FanOut => continue,
358                };
359                trace!(
360                    ?kind,
361                    node_id = ?tx.node_id.as_ref().map(ToString::to_string),
362                    txid = %tx.txid,
363                    status = ?tx.status,
364                    needs_cpfp_child = tx.to_sign.is_some(),
365                    csv_timelock_blocks = ?tx.csv_timelock_blocks,
366                    depends_on = tx.depends_on.len(),
367                    "unilateral_exit: exit tx"
368                );
369                let cpfp_tx_hex = match tx.to_sign {
370                    Some(child) => Some(sign_psbt_via(child, signer.as_ref()).await?),
371                    None => None,
372                };
373                transactions.push(UnilateralExitTransaction {
374                    kind,
375                    node_id: tx.node_id.map(|id| id.to_string()),
376                    txid: tx.txid.to_string(),
377                    tx_hex: serialize_hex(&tx.base_tx),
378                    cpfp_tx_hex,
379                    csv_timelock_blocks: tx.csv_timelock_blocks,
380                    depends_on: tx.depends_on.iter().map(ToString::to_string).collect(),
381                    status: initial_status(tx.status),
382                });
383            }
384        }
385
386        // A sweep over zero inputs would error: return without one when no refund
387        // is on-chain yet. A later run sweeps any refund that surfaces.
388        if build.refund_outputs.is_empty() {
389            resolve_statuses(chain, &mut transactions).await?;
390            debug!("unilateral_exit: no refund outputs to sweep, omitting the sweep");
391            return Ok(UnilateralExitResponse {
392                recoverable_value_sat,
393                total_fee_sat: build_fee_sat,
394                cpfp_fee_sat,
395                fanout_fee_sat,
396                sweep_fee_sat: 0,
397                leaves,
398                transactions,
399                funding_inputs: supplied_funding,
400            });
401        }
402
403        let refund_txids: Vec<String> = build
404            .refund_outputs
405            .iter()
406            .map(|r| r.outpoint.txid.to_string())
407            .collect();
408        let sweep_psbt = self
409            .spark_wallet
410            .create_refund_sweep_transaction(
411                build.refund_outputs,
412                build.cpfp_change_inputs,
413                destination,
414                fee_rate_sat_per_kw,
415            )
416            .await?;
417        let sweep_fee_sat = sweep_fee(&sweep_psbt);
418        let total_fee_sat = build_fee_sat.saturating_add(sweep_fee_sat);
419        let sweep_txid = sweep_psbt.unsigned_tx.compute_txid();
420        trace!(
421            txid = %sweep_txid,
422            status = ?sweep_status,
423            refund_inputs = refund_txids.len(),
424            "unilateral_exit: sweep"
425        );
426        let sweep_tx_hex = finalize_sweep(sweep_psbt, signer.as_ref()).await?;
427        transactions.push(UnilateralExitTransaction {
428            kind: UnilateralExitTxKind::Sweep,
429            node_id: None,
430            txid: sweep_txid.to_string(),
431            tx_hex: sweep_tx_hex,
432            cpfp_tx_hex: None,
433            csv_timelock_blocks: None,
434            depends_on: refund_txids,
435            status: sweep_status,
436        });
437
438        resolve_statuses(chain, &mut transactions).await?;
439        debug!(
440            transactions = transactions.len(),
441            recoverable_value_sat, total_fee_sat, sweep_fee_sat, "unilateral_exit: complete"
442        );
443        Ok(UnilateralExitResponse {
444            recoverable_value_sat,
445            total_fee_sat,
446            cpfp_fee_sat,
447            fanout_fee_sat,
448            sweep_fee_sat,
449            leaves,
450            transactions,
451            funding_inputs: supplied_funding,
452        })
453    }
454}
455
456/// The sweep's fee: total input value minus output value.
457fn sweep_fee(sweep_psbt: &bitcoin::Psbt) -> u64 {
458    let in_value: u64 = sweep_psbt
459        .inputs
460        .iter()
461        .filter_map(|i| i.witness_utxo.as_ref())
462        .map(|o| o.value.to_sat())
463        .fold(0u64, u64::saturating_add);
464    let out_value: u64 = sweep_psbt
465        .unsigned_tx
466        .output
467        .iter()
468        .map(|o| o.value.to_sat())
469        .fold(0u64, u64::saturating_add);
470    in_value.saturating_sub(out_value)
471}
472
473/// Converts a sat/vByte fee rate (the public API unit) to sat/kW (the exit
474/// engine's unit): one vByte is 4 weight units, so 1 sat/vByte is 250 sat/kW.
475fn sat_per_kw_from_vbyte(sat_per_vbyte: u64) -> u64 {
476    sat_per_vbyte.saturating_mul(250)
477}
478
479/// The signed input weight and a representative output scriptPubKey for a
480/// funding kind.
481fn funding_kind_params(kind: &CpfpFundingKind) -> Result<(u64, ScriptBuf), SdkError> {
482    // Only the scriptPubKey length matters here (it fixes output weight and
483    // dust), so any valid program of the right size works.
484    let witness_script = |version, program: &[u8]| -> Result<ScriptBuf, SdkError> {
485        let program = bitcoin::WitnessProgram::new(version, program).map_err(|e| {
486            SdkError::Generic(format!("invalid representative witness program: {e}"))
487        })?;
488        Ok(ScriptBuf::new_witness_program(&program))
489    };
490    let (weight, script) = match kind {
491        CpfpFundingKind::P2wpkh => (
492            spark_wallet::p2wpkh_input_weight().to_wu(),
493            witness_script(bitcoin::WitnessVersion::V0, &[0u8; 20])?,
494        ),
495        CpfpFundingKind::P2tr => (
496            spark_wallet::p2tr_key_path_input_weight().to_wu(),
497            witness_script(bitcoin::WitnessVersion::V1, &[0u8; 32])?,
498        ),
499        CpfpFundingKind::Custom {
500            script_pubkey_hex,
501            signed_input_weight,
502        } => {
503            let script = ScriptBuf::from_hex(script_pubkey_hex).map_err(|e| {
504                SdkError::InvalidInput(format!("Invalid funding script_pubkey_hex: {e}"))
505            })?;
506            // Only native SegWit funding is supported: the exit threads txids from
507            // unsigned txs, stable only when the input's scriptSig stays empty.
508            // Reject here so the quote fails before funding is gathered.
509            if !script.is_witness_program() {
510                return Err(SdkError::InvalidInput(
511                    "Custom funding must pay to a native SegWit (witness-program) script"
512                        .to_string(),
513                ));
514            }
515            (*signed_input_weight, script)
516        }
517    };
518    Ok((weight, script))
519}
520
521impl ModelCpfpInput {
522    /// Converts into the spark-wallet funding type. Takes `network` to derive
523    /// the P2WPKH/P2TR script from a pubkey.
524    fn into_funding_input(self, network: bitcoin::Network) -> Result<CpfpInput, SdkError> {
525        let parse_txid = |s: &str| {
526            Txid::from_str(s)
527                .map_err(|e| SdkError::InvalidInput(format!("Invalid funding txid: {e}")))
528        };
529        match self {
530            ModelCpfpInput::P2wpkh {
531                txid,
532                vout,
533                value,
534                pubkey,
535            } => {
536                let pk = PublicKey::from_str(&pubkey)
537                    .map_err(|e| SdkError::InvalidInput(format!("Invalid funding pubkey: {e}")))?;
538                let script_pubkey =
539                    Address::p2wpkh(&CompressedPublicKey(pk), network).script_pubkey();
540                Ok(CpfpInput {
541                    outpoint: OutPoint {
542                        txid: parse_txid(&txid)?,
543                        vout,
544                    },
545                    witness_utxo: TxOut {
546                        value: Amount::from_sat(value),
547                        script_pubkey,
548                    },
549                    signed_input_weight: spark_wallet::p2wpkh_input_weight().to_wu(),
550                })
551            }
552            ModelCpfpInput::P2tr {
553                txid,
554                vout,
555                value,
556                pubkey,
557            } => {
558                let xonly = parse_xonly(&pubkey)?;
559                let secp = bitcoin::secp256k1::Secp256k1::verification_only();
560                let script_pubkey = Address::p2tr(&secp, xonly, None, network).script_pubkey();
561                Ok(CpfpInput {
562                    outpoint: OutPoint {
563                        txid: parse_txid(&txid)?,
564                        vout,
565                    },
566                    witness_utxo: TxOut {
567                        value: Amount::from_sat(value),
568                        script_pubkey,
569                    },
570                    signed_input_weight: spark_wallet::p2tr_key_path_input_weight().to_wu(),
571                })
572            }
573            ModelCpfpInput::Custom {
574                txid,
575                vout,
576                value,
577                script_pubkey_hex,
578                signed_input_weight,
579            } => {
580                let script_pubkey = ScriptBuf::from_hex(&script_pubkey_hex).map_err(|e| {
581                    SdkError::InvalidInput(format!("Invalid funding scriptPubKey hex: {e}"))
582                })?;
583                // The exit signs/weighs funding inputs as SegWit and spends them in
584                // a v3/TRUC anchor package; a legacy input breaks both. Reject it.
585                if !script_pubkey.is_witness_program() {
586                    return Err(SdkError::InvalidInput(
587                        "Custom funding input must pay to a SegWit (witness-program) script"
588                            .to_string(),
589                    ));
590                }
591                Ok(CpfpInput {
592                    outpoint: OutPoint {
593                        txid: parse_txid(&txid)?,
594                        vout,
595                    },
596                    witness_utxo: TxOut {
597                        value: Amount::from_sat(value),
598                        script_pubkey,
599                    },
600                    signed_input_weight,
601                })
602            }
603        }
604    }
605}
606
607/// Parses an x-only pubkey from hex, accepting both x-only (32-byte) and
608/// compressed (33-byte) encodings.
609fn parse_xonly(pubkey: &str) -> Result<XOnlyPublicKey, SdkError> {
610    if let Ok(xonly) = XOnlyPublicKey::from_str(pubkey) {
611        return Ok(xonly);
612    }
613    let pk = PublicKey::from_str(pubkey)
614        .map_err(|e| SdkError::InvalidInput(format!("Invalid funding pubkey: {e}")))?;
615    Ok(pk.x_only_public_key().0)
616}
617
618/// Reads what the chain has already done to `leaf_ids`, before any funding is
619/// considered. Same loop as [`resolve_exit_check`], over the scan that needs no
620/// plan.
621async fn resolve_exit_chain_state(
622    chain: &dyn BitcoinChainService,
623    tree_nodes: &HashMap<TreeNodeId, TreeNode>,
624    leaf_ids: &[TreeNodeId],
625    refund_addresses: &HashMap<TreeNodeId, Address>,
626) -> Result<WalletExitChainState, SdkError> {
627    let mut observed: Vec<Observation> = Vec::new();
628    loop {
629        let scan = scan_exit_chain(tree_nodes, leaf_ids, refund_addresses, &observed);
630        if scan.pending.is_empty() {
631            debug!(
632                nodes = scan.state.nodes.len(),
633                refunds = scan.state.refunds.len(),
634                observations = observed.len(),
635                "resolve_exit_chain_state: what the chain has already done"
636            );
637            return Ok(scan.state);
638        }
639        // Each query is answered exactly once (a failed lookup records
640        // `Unavailable`), so the loop always progresses and terminates.
641        for query in scan.pending {
642            let result = execute_chain_query(chain, &query).await;
643            observed.push(Observation { query, result });
644        }
645    }
646}
647
648/// Leaf auto-resolution lives in the wallet, so a selection only has to be
649/// restated in its terms.
650fn wallet_selection(
651    selection: ExitLeafSelection,
652) -> Result<spark_wallet::ExitLeafSelection, SdkError> {
653    match selection {
654        ExitLeafSelection::Auto => Ok(spark_wallet::ExitLeafSelection::Auto),
655        ExitLeafSelection::Specific { leaf_ids } => {
656            if leaf_ids.is_empty() {
657                return Err(SdkError::InvalidInput("No leaves to exit".to_string()));
658            }
659            Ok(spark_wallet::ExitLeafSelection::Specific(node_ids(
660                &leaf_ids,
661            )?))
662        }
663    }
664}
665
666/// Reads back the state a caller carried over from `prepare_unilateral_exit`.
667fn exit_chain_state_from_model(
668    state: &ModelExitChainState,
669) -> Result<WalletExitChainState, SdkError> {
670    Ok(WalletExitChainState {
671        nodes: state
672            .confirmed_nodes
673            .iter()
674            .map(|node| {
675                Ok(WalletConfirmedExitNode {
676                    block_height: node.block_height,
677                    node_id: node_id(&node.node_id)?,
678                    confirmed_by: match node.confirmed_by {
679                        ExitNodeConfirmation::Cpfp => WalletExitNodeConfirmation::Cpfp,
680                        ExitNodeConfirmation::Direct => WalletExitNodeConfirmation::Direct,
681                    },
682                })
683            })
684            .collect::<Result<_, SdkError>>()?,
685        refunds: state
686            .refunds
687            .iter()
688            .map(|refund| {
689                let restored = match &refund.state {
690                    ExitRefundState::OnChain {
691                        tx_hex,
692                        vout,
693                        value_sat,
694                        block_height,
695                    } => WalletExitRefundState::OnChain {
696                        block_height: *block_height,
697                        tx: deserialize_hex(tx_hex).map_err(|e| {
698                            SdkError::InvalidInput(format!("Invalid refund transaction: {e}"))
699                        })?,
700                        vout: *vout,
701                        value: *value_sat,
702                    },
703                    ExitRefundState::Swept => WalletExitRefundState::Swept,
704                };
705                Ok(WalletExitRefund {
706                    leaf_id: node_id(&refund.leaf_id)?,
707                    state: restored,
708                })
709            })
710            .collect::<Result<_, SdkError>>()?,
711        stopped_leaves: node_ids(&state.stopped_leaf_ids)?,
712        unverified_nodes: node_ids(&state.unverified_node_ids)?,
713        unverifiable_confirmed_nodes: node_ids(&state.unverifiable_confirmed_node_ids)?,
714    })
715}
716
717fn node_id(id: &str) -> Result<TreeNodeId, SdkError> {
718    TreeNodeId::from_str(id).map_err(|e| SdkError::InvalidInput(format!("Invalid id {id}: {e}")))
719}
720
721fn node_ids(ids: &[String]) -> Result<Vec<TreeNodeId>, SdkError> {
722    ids.iter().map(|id| node_id(id)).collect()
723}
724
725/// Restates the wallet's on-chain reading in the API's own types: ids as strings
726/// and transactions as hex, so a caller can read what an exit has already done
727/// rather than hand an opaque token back.
728fn exit_chain_state_model(state: WalletExitChainState) -> ModelExitChainState {
729    ModelExitChainState {
730        confirmed_nodes: state
731            .nodes
732            .into_iter()
733            .map(|node| ConfirmedExitNode {
734                node_id: node.node_id.to_string(),
735                confirmed_by: match node.confirmed_by {
736                    WalletExitNodeConfirmation::Cpfp => ExitNodeConfirmation::Cpfp,
737                    WalletExitNodeConfirmation::Direct => ExitNodeConfirmation::Direct,
738                },
739                block_height: node.block_height,
740            })
741            .collect(),
742        refunds: state
743            .refunds
744            .into_iter()
745            .map(|refund| ExitRefund {
746                leaf_id: refund.leaf_id.to_string(),
747                state: match refund.state {
748                    WalletExitRefundState::OnChain {
749                        tx,
750                        vout,
751                        value,
752                        block_height,
753                    } => ExitRefundState::OnChain {
754                        tx_hex: serialize_hex(&tx),
755                        vout,
756                        value_sat: value,
757                        block_height,
758                    },
759                    WalletExitRefundState::Swept => ExitRefundState::Swept,
760                },
761            })
762            .collect(),
763        stopped_leaf_ids: ids(state.stopped_leaves),
764        unverified_node_ids: ids(state.unverified_nodes),
765        unverifiable_confirmed_node_ids: ids(state.unverifiable_confirmed_nodes),
766    }
767}
768
769fn ids(ids: Vec<TreeNodeId>) -> Vec<String> {
770    ids.into_iter().map(|id| id.to_string()).collect()
771}
772
773/// Fills in the status of every transaction that is neither on-chain nor
774/// unreadable: whether it can go out now, or what it is waiting on. Derived from
775/// the whole list, so it is resolved once the list is complete rather than as
776/// each entry is built.
777///
778/// A relative timelock counts from the height of the input being spent, so
779/// maturity needs that height and the chain tip. Both are read here rather than
780/// fetched again per transaction. A height that cannot be read leaves the
781/// transaction waiting rather than ready, the direction that never sends a
782/// transaction the mempool would reject.
783async fn resolve_statuses(
784    chain: &dyn BitcoinChainService,
785    transactions: &mut [UnilateralExitTransaction],
786) -> Result<(), SdkError> {
787    let confirmed: HashSet<&str> = transactions
788        .iter()
789        .filter(|tx| matches!(tx.status, ExitTransactionStatus::Confirmed { .. }))
790        .map(|tx| tx.txid.as_str())
791        .collect();
792    let met: Vec<bool> = transactions
793        .iter()
794        .map(|tx| {
795            tx.depends_on
796                .iter()
797                .all(|dep| confirmed.contains(dep.as_str()))
798        })
799        .collect();
800
801    // Seeded from the list, which already carries the height of everything the
802    // exit walk confirmed, so the chain is only asked about inputs from outside
803    // it: the ancestor a resumed exit starts from.
804    let mut heights: HashMap<Txid, Option<u32>> = transactions
805        .iter()
806        .filter_map(|tx| match tx.status {
807            ExitTransactionStatus::Confirmed {
808                block_height: Some(height),
809            } => Some((Txid::from_str(&tx.txid).ok()?, Some(height))),
810            _ => None,
811        })
812        .collect();
813    let mut tip: Option<u32> = None;
814
815    let mut resolved = Vec::with_capacity(transactions.len());
816    for (tx, met) in transactions.iter().zip(&met) {
817        // On-chain, or its own on-chain state unknown: neither leaves anything
818        // to wait for.
819        if matches!(
820            tx.status,
821            ExitTransactionStatus::Confirmed { .. } | ExitTransactionStatus::Unverified
822        ) {
823            resolved.push(tx.status);
824            continue;
825        }
826        if !met {
827            resolved.push(ExitTransactionStatus::WaitingForDependencies);
828            continue;
829        }
830        if tx.csv_timelock_blocks.is_none() {
831            resolved.push(ExitTransactionStatus::Ready);
832            continue;
833        }
834        let decoded = deserialize_hex::<Transaction>(&tx.tx_hex)
835            .map_err(|e| SdkError::InvalidInput(format!("Invalid transaction: {e}")))?;
836        let Some(spendable_at) = spendable_at_height(chain, &decoded, &mut heights).await else {
837            resolved.push(ExitTransactionStatus::WaitingForTimelock {
838                spendable_at_height: None,
839            });
840            continue;
841        };
842        if tip.is_none() {
843            tip = chain.tip_height().await.ok();
844        }
845        // A transaction is accepted while it would be valid in the next block.
846        let matured = tip.is_some_and(|tip| tip.saturating_add(1) >= spendable_at);
847        resolved.push(if matured {
848            ExitTransactionStatus::Ready
849        } else {
850            ExitTransactionStatus::WaitingForTimelock {
851                spendable_at_height: Some(spendable_at),
852            }
853        });
854    }
855
856    for (tx, status) in transactions.iter_mut().zip(resolved) {
857        tx.status = status;
858    }
859    Ok(())
860}
861
862/// The status of one kept transaction after a chain read.
863///
864/// - The chain has it in a block: `Confirmed`, at the height the read reported,
865///   or at the height already held when the read settled it along the spend
866///   chain instead of asking about it.
867/// - The chain was asked and reported it is not in a block: reset to a
868///   placeholder `resolve_statuses` replaces, discarding a confirmation a reorg
869///   has undone.
870/// - Anything else: left as it is. `resolve_statuses` recomputes every status
871///   except `Confirmed` and `Unverified`, and neither of those may be dropped on
872///   a read that did not contradict it.
873fn status_after_check(
874    current: ExitTransactionStatus,
875    check: &ExitCheck,
876    txid: &Txid,
877) -> ExitTransactionStatus {
878    let held_height = match current {
879        ExitTransactionStatus::Confirmed { block_height } => block_height,
880        _ => None,
881    };
882    match (check.confirmed.get(txid), current) {
883        (Some(block_height), _) => ExitTransactionStatus::Confirmed {
884            block_height: block_height.or(held_height),
885        },
886        (None, ExitTransactionStatus::Confirmed { .. }) if check.not_confirmed.contains(txid) => {
887            ExitTransactionStatus::WaitingForDependencies
888        }
889        (None, _) => current,
890    }
891}
892
893/// The first block height that can include `tx`, given the relative timelocks on
894/// its inputs. `None` when an input's confirmation height cannot be established,
895/// which leaves maturity unknown.
896async fn spendable_at_height(
897    chain: &dyn BitcoinChainService,
898    tx: &Transaction,
899    heights: &mut HashMap<Txid, Option<u32>>,
900) -> Option<u32> {
901    let mut spendable_at = None;
902    for input in &tx.input {
903        let blocks = match input.sequence.to_relative_lock_time() {
904            Some(bitcoin::relative::LockTime::Blocks(blocks)) => u32::from(blocks.value()),
905            _ => continue,
906        };
907        if blocks == 0 {
908            continue;
909        }
910        let funded_by = input.previous_output.txid;
911        if heights.get(&funded_by).is_none() {
912            let height = match chain.get_transaction_status(funded_by.to_string()).await {
913                Ok(status) if status.confirmed => status.block_height,
914                _ => None,
915            };
916            heights.insert(funded_by, height);
917        }
918        let known = heights.get(&funded_by).copied().flatten();
919        // One unreadable input is enough to leave the whole transaction unknown:
920        // it may be the one that matures last.
921        let height = known?;
922        spendable_at = Some(spendable_at.unwrap_or(0).max(height.saturating_add(blocks)));
923    }
924    spendable_at
925}
926
927/// Decodes one kept transaction into what the check reads.
928fn exit_check_input(tx: &UnilateralExitTransaction) -> Result<ExitCheckInput, SdkError> {
929    let decode = |hex: &str| {
930        deserialize_hex::<Transaction>(hex)
931            .map_err(|e| SdkError::InvalidInput(format!("Invalid transaction: {e}")))
932    };
933    Ok(ExitCheckInput {
934        tx: decode(&tx.tx_hex)?,
935        cpfp: tx.cpfp_tx_hex.as_deref().map(decode).transpose()?,
936        confirmed: matches!(tx.status, ExitTransactionStatus::Confirmed { .. }),
937    })
938}
939
940/// Drives [`check_exit_chain`] to completion.
941async fn resolve_exit_check(
942    chain: &dyn BitcoinChainService,
943    inputs: &[ExitCheckInput],
944) -> Result<ExitCheck, SdkError> {
945    let mut observed: Vec<Observation> = Vec::new();
946    loop {
947        let check = check_exit_chain(inputs, &observed);
948        if check.pending.is_empty() {
949            debug!(
950                confirmed = check.confirmed.len(),
951                diverged = check.diverged,
952                observations = observed.len(),
953                "resolve_exit_check: read back"
954            );
955            return Ok(check);
956        }
957        // Each query is answered exactly once (a failed lookup records
958        // `Unavailable`), so the loop always progresses and terminates.
959        for query in check.pending {
960            let result = execute_chain_query(chain, &query).await;
961            observed.push(Observation { query, result });
962        }
963    }
964}
965
966/// Follows the supplied funding to what it is worth now, driving
967/// [`scan_funding`] to completion. The only chain reading a build does: the tree
968/// was read while preparing.
969async fn resolve_funding(
970    chain: &dyn BitcoinChainService,
971    supplied: Vec<CpfpInput>,
972) -> Result<Vec<CpfpInput>, SdkError> {
973    let mut observed: Vec<Observation> = Vec::new();
974    loop {
975        let scan = scan_funding(&supplied, &observed);
976        if scan.pending.is_empty() {
977            debug!(
978                supplied = supplied.len(),
979                resolved = scan.inputs.len(),
980                "resolve_funding: what the supplied funding is worth now"
981            );
982            return Ok(scan.inputs);
983        }
984        // Each query is answered exactly once (a failed lookup records
985        // `Unavailable`), so the loop always progresses and terminates.
986        for query in scan.pending {
987            let result = execute_chain_query(chain, &query).await;
988            observed.push(Observation { query, result });
989        }
990    }
991}
992
993/// Performs one [`ChainQuery`], translating this crate's chain types into the
994/// wallet's `bitcoin`-only [`ChainResult`]. A failed lookup becomes
995/// [`ChainResult::Unavailable`] so the wallet flags the affected tx as unverified
996/// rather than treating it as confirmed or absent.
997async fn execute_chain_query(chain: &dyn BitcoinChainService, query: &ChainQuery) -> ChainResult {
998    match query {
999        ChainQuery::TxConfirmed(txid) => match chain.get_transaction_status(txid.to_string()).await
1000        {
1001            Ok(status) => ChainResult::Confirmed {
1002                confirmed: status.confirmed,
1003                block_height: status.block_height,
1004            },
1005            Err(e) => {
1006                warn!(%txid, error = %e, "chain lookup failed: transaction status");
1007                ChainResult::Unavailable
1008            }
1009        },
1010        ChainQuery::Outspend(outpoint) => {
1011            match chain
1012                .get_outspend(outpoint.txid.to_string(), outpoint.vout)
1013                .await
1014            {
1015                Ok(Outspend::Spent { txid, status, .. }) => match Txid::from_str(&txid) {
1016                    Ok(spender_txid) => {
1017                        trace!(%outpoint, spender = %spender_txid, confirmed = status.confirmed, "chain: outpoint spent");
1018                        ChainResult::Spend(Some(SpendInfo {
1019                            spender_txid,
1020                            confirmed: status.confirmed,
1021                            block_height: status.block_height,
1022                        }))
1023                    }
1024                    Err(e) => {
1025                        warn!("outspend of {outpoint} has an unparsable spender txid {txid}: {e}");
1026                        ChainResult::Unavailable
1027                    }
1028                },
1029                Ok(Outspend::Unspent) => {
1030                    trace!(%outpoint, "chain: outpoint unspent");
1031                    ChainResult::Spend(None)
1032                }
1033                Err(e) => {
1034                    warn!("get_outspend for {outpoint} failed: {e}");
1035                    ChainResult::Unavailable
1036                }
1037            }
1038        }
1039        ChainQuery::Transaction(txid) => match chain.get_transaction_hex(txid.to_string()).await {
1040            Ok(hex) => match deserialize_hex::<Transaction>(&hex) {
1041                Ok(tx) => {
1042                    trace!(%txid, outputs = tx.output.len(), "chain: transaction fetched");
1043                    ChainResult::Transaction(tx)
1044                }
1045                Err(e) => {
1046                    warn!("failed to decode transaction {txid}: {e}");
1047                    ChainResult::Unavailable
1048                }
1049            },
1050            Err(e) => {
1051                warn!("get_transaction_hex for {txid} failed: {e}");
1052                ChainResult::Unavailable
1053            }
1054        },
1055        ChainQuery::RefundAddress { leaf_id, address } => {
1056            match chain.get_address_txos(address.to_string()).await {
1057                Ok(txos) => {
1058                    let txos: Vec<AddressUtxo> = txos
1059                        .into_iter()
1060                        .filter_map(|u| match Txid::from_str(&u.txid) {
1061                            Ok(txid) => Some(AddressUtxo {
1062                                txid,
1063                                vout: u.vout,
1064                                value: u.value,
1065                                confirmed: u.status.confirmed,
1066                                block_height: u.status.block_height,
1067                            }),
1068                            Err(e) => {
1069                                warn!("skipping refund txo {} for leaf {leaf_id}: {e}", u.txid);
1070                                None
1071                            }
1072                        })
1073                        .collect();
1074                    trace!(
1075                        %leaf_id,
1076                        txos = txos.len(),
1077                        confirmed = txos.iter().filter(|u| u.confirmed).count(),
1078                        "chain: refund address scanned"
1079                    );
1080                    ChainResult::AddressUtxos(txos)
1081                }
1082                Err(e) => {
1083                    warn!("get_address_txos for leaf {leaf_id} failed: {e}");
1084                    ChainResult::Unavailable
1085                }
1086            }
1087        }
1088    }
1089}
1090
1091/// The status a built transaction starts at. What an unconfirmed one is waiting
1092/// for takes the whole list to work out, so [`resolve_statuses`] replaces this.
1093fn initial_status(status: ExitTxStatus) -> ExitTransactionStatus {
1094    match status {
1095        ExitTxStatus::Confirmed { block_height } => {
1096            ExitTransactionStatus::Confirmed { block_height }
1097        }
1098        ExitTxStatus::Unconfirmed => ExitTransactionStatus::WaitingForDependencies,
1099        ExitTxStatus::Unverified => ExitTransactionStatus::Unverified,
1100    }
1101}
1102
1103/// The sweep's starting status, derived from the refunds it spends. A verified
1104/// refund is spent-and-dropped once its sweep confirms (the exit then returns
1105/// with no sweep), so a freshly-returned sweep over verified refunds is never
1106/// yet on-chain. An unverified refund (its chain lookup failed) could already be
1107/// on-chain and swept without us knowing, so the sweep is `Unverified`.
1108fn sweep_initial_status(build: &UnilateralExitBuild) -> ExitTransactionStatus {
1109    let any_refund_unverified = build
1110        .branches
1111        .iter()
1112        .flat_map(|b| b.txs.iter())
1113        .any(|t| t.kind == ExitTxKind::Refund && t.status == ExitTxStatus::Unverified);
1114    if any_refund_unverified {
1115        ExitTransactionStatus::Unverified
1116    } else {
1117        ExitTransactionStatus::WaitingForDependencies
1118    }
1119}
1120
1121fn empty_exit_response() -> UnilateralExitResponse {
1122    UnilateralExitResponse {
1123        recoverable_value_sat: 0,
1124        total_fee_sat: 0,
1125        cpfp_fee_sat: 0,
1126        fanout_fee_sat: 0,
1127        sweep_fee_sat: 0,
1128        leaves: Vec::new(),
1129        transactions: Vec::new(),
1130        funding_inputs: Vec::new(),
1131    }
1132}
1133
1134/// Signs a PSBT via the external `CpfpSigner`, returning the tx as hex.
1135/// Ephemeral anchor inputs are finalized here (`OP_TRUE`, no signature).
1136async fn sign_psbt_via(
1137    mut psbt: bitcoin::Psbt,
1138    signer: &dyn CpfpSigner,
1139) -> Result<String, SdkError> {
1140    for input in &mut psbt.inputs {
1141        if let Some(txo) = &input.witness_utxo
1142            && is_ephemeral_anchor_output(txo)
1143        {
1144            input.final_script_witness = Some(bitcoin::Witness::new());
1145        }
1146    }
1147    let out_bytes = signer
1148        .sign_psbt(psbt.serialize())
1149        .await
1150        .map_err(|e| SdkError::Signer(format!("CPFP signer error: {e}")))?;
1151    let out_psbt = bitcoin::Psbt::deserialize(&out_bytes)
1152        .map_err(|e| SdkError::Generic(format!("Failed to deserialize signed PSBT: {e}")))?;
1153    ensure_all_inputs_finalized(&out_psbt)?;
1154    Ok(serialize_hex(&out_psbt.extract_tx_unchecked_fee_rate()))
1155}
1156
1157/// Finalizes the sweep. Refund inputs are already signed by spark-wallet, so the
1158/// external signer is only invoked when CPFP-change inputs still need it.
1159async fn finalize_sweep(psbt: bitcoin::Psbt, signer: &dyn CpfpSigner) -> Result<String, SdkError> {
1160    let needs_signer = psbt
1161        .inputs
1162        .iter()
1163        .any(|input| input.final_script_witness.is_none());
1164    let psbt = if needs_signer {
1165        let out_bytes = signer
1166            .sign_psbt(psbt.serialize())
1167            .await
1168            .map_err(|e| SdkError::Signer(format!("Sweep signer error: {e}")))?;
1169        bitcoin::Psbt::deserialize(&out_bytes).map_err(|e| {
1170            SdkError::Generic(format!("Failed to deserialize signed sweep PSBT: {e}"))
1171        })?
1172    } else {
1173        psbt
1174    };
1175    ensure_all_inputs_finalized(&psbt)?;
1176    Ok(serialize_hex(&psbt.extract_tx_unchecked_fee_rate()))
1177}
1178
1179/// Rejects a PSBT with any input the signer left unfinalized (neither a witness
1180/// nor a scriptSig), so a missing signature fails here instead of at broadcast.
1181fn ensure_all_inputs_finalized(psbt: &bitcoin::Psbt) -> Result<(), SdkError> {
1182    if let Some(index) = psbt
1183        .inputs
1184        .iter()
1185        .position(|input| input.final_script_witness.is_none() && input.final_script_sig.is_none())
1186    {
1187        return Err(SdkError::Signer(format!(
1188            "PSBT input {index} was not signed"
1189        )));
1190    }
1191    Ok(())
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197    use crate::error::SignerError;
1198    use bitcoin::hashes::Hash;
1199    use spark_wallet::{ExitBranch, ExitTx};
1200
1201    #[cfg(feature = "browser-tests")]
1202    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
1203
1204    fn refund_tx(status: ExitTxStatus) -> ExitTx {
1205        ExitTx {
1206            kind: ExitTxKind::Refund,
1207            node_id: None,
1208            txid: Txid::from_byte_array([3; 32]),
1209            base_tx: Transaction {
1210                version: bitcoin::transaction::Version::TWO,
1211                lock_time: bitcoin::absolute::LockTime::ZERO,
1212                input: vec![],
1213                output: vec![],
1214            },
1215            to_sign: None,
1216            csv_timelock_blocks: None,
1217            depends_on: vec![],
1218            status,
1219        }
1220    }
1221
1222    fn build_with_refund(status: ExitTxStatus) -> UnilateralExitBuild {
1223        UnilateralExitBuild {
1224            fan_out: None,
1225            branches: vec![ExitBranch {
1226                leaf_id: TreeNodeId::from_str("leaf").unwrap(),
1227                txs: vec![refund_tx(status)],
1228            }],
1229            refund_outputs: vec![],
1230            cpfp_change_inputs: vec![],
1231            recoverable_value_sat: 0,
1232            cpfp_fee_sat: 0,
1233            fanout_fee_sat: 0,
1234        }
1235    }
1236
1237    #[test]
1238    fn a_sweep_over_verified_refunds_starts_out_waiting_on_them() {
1239        assert_eq!(
1240            sweep_initial_status(&build_with_refund(ExitTxStatus::Unconfirmed)),
1241            ExitTransactionStatus::WaitingForDependencies
1242        );
1243    }
1244
1245    #[test]
1246    fn a_sweep_over_an_unverified_refund_is_unverified() {
1247        assert_eq!(
1248            sweep_initial_status(&build_with_refund(ExitTxStatus::Unverified)),
1249            ExitTransactionStatus::Unverified
1250        );
1251    }
1252
1253    fn unsigned_two_input_psbt() -> bitcoin::Psbt {
1254        let tx = Transaction {
1255            version: bitcoin::transaction::Version::TWO,
1256            lock_time: bitcoin::absolute::LockTime::ZERO,
1257            input: vec![
1258                bitcoin::TxIn {
1259                    previous_output: OutPoint {
1260                        txid: Txid::from_byte_array([1; 32]),
1261                        vout: 0,
1262                    },
1263                    ..Default::default()
1264                },
1265                bitcoin::TxIn {
1266                    previous_output: OutPoint {
1267                        txid: Txid::from_byte_array([2; 32]),
1268                        vout: 0,
1269                    },
1270                    ..Default::default()
1271                },
1272            ],
1273            output: vec![TxOut {
1274                value: Amount::from_sat(1_000),
1275                script_pubkey: ScriptBuf::new(),
1276            }],
1277        };
1278        let mut psbt = bitcoin::Psbt::from_unsigned_tx(tx).unwrap();
1279        for input in &mut psbt.inputs {
1280            input.witness_utxo = Some(TxOut {
1281                value: Amount::from_sat(2_000),
1282                script_pubkey: ScriptBuf::new(),
1283            });
1284        }
1285        psbt
1286    }
1287
1288    fn finalize_input(input: &mut bitcoin::psbt::Input) {
1289        let mut witness = bitcoin::Witness::new();
1290        witness.push([0x01u8]);
1291        input.final_script_witness = Some(witness);
1292    }
1293
1294    /// A `CpfpSigner` that finalizes only the first `finalize` inputs.
1295    struct PartialSigner {
1296        finalize: usize,
1297    }
1298
1299    #[macros::async_trait]
1300    impl CpfpSigner for PartialSigner {
1301        async fn sign_psbt(&self, psbt_bytes: Vec<u8>) -> Result<Vec<u8>, SignerError> {
1302            let mut psbt = bitcoin::Psbt::deserialize(&psbt_bytes).unwrap();
1303            for input in psbt.inputs.iter_mut().take(self.finalize) {
1304                finalize_input(input);
1305            }
1306            Ok(psbt.serialize())
1307        }
1308    }
1309
1310    #[test]
1311    fn ensure_all_inputs_finalized_rejects_unsigned() {
1312        assert!(ensure_all_inputs_finalized(&unsigned_two_input_psbt()).is_err());
1313    }
1314
1315    #[test]
1316    fn ensure_all_inputs_finalized_accepts_finalized() {
1317        let mut psbt = unsigned_two_input_psbt();
1318        psbt.inputs.iter_mut().for_each(finalize_input);
1319        assert!(ensure_all_inputs_finalized(&psbt).is_ok());
1320    }
1321
1322    #[macros::async_test_all]
1323    async fn sign_psbt_via_errors_when_an_input_is_left_unsigned() {
1324        let result = sign_psbt_via(unsigned_two_input_psbt(), &PartialSigner { finalize: 1 }).await;
1325        assert!(matches!(result, Err(SdkError::Signer(_))));
1326    }
1327
1328    #[macros::async_test_all]
1329    async fn sign_psbt_via_succeeds_when_every_input_is_signed() {
1330        let result = sign_psbt_via(unsigned_two_input_psbt(), &PartialSigner { finalize: 2 }).await;
1331        assert!(result.is_ok());
1332    }
1333
1334    /// A chain that knows one transaction's height and one tip, and refuses
1335    /// everything else, so a test says exactly what the resolver was told.
1336    struct StubChain {
1337        tip: Option<u32>,
1338        heights: HashMap<String, u32>,
1339    }
1340
1341    #[macros::async_trait]
1342    impl BitcoinChainService for StubChain {
1343        async fn get_address_utxos(
1344            &self,
1345            _address: String,
1346        ) -> Result<Vec<crate::chain::Utxo>, crate::chain::ChainServiceError> {
1347            unreachable!()
1348        }
1349        async fn get_address_txos(
1350            &self,
1351            _address: String,
1352        ) -> Result<Vec<crate::chain::Utxo>, crate::chain::ChainServiceError> {
1353            unreachable!()
1354        }
1355        async fn get_transaction_status(
1356            &self,
1357            txid: String,
1358        ) -> Result<crate::chain::TxStatus, crate::chain::ChainServiceError> {
1359            match self.heights.get(&txid) {
1360                Some(height) => Ok(crate::chain::TxStatus {
1361                    confirmed: true,
1362                    block_height: Some(*height),
1363                    block_time: None,
1364                }),
1365                None => Err(crate::chain::ChainServiceError::Generic("unknown".into())),
1366            }
1367        }
1368        async fn tip_height(&self) -> Result<u32, crate::chain::ChainServiceError> {
1369            self.tip
1370                .ok_or_else(|| crate::chain::ChainServiceError::Generic("no tip".into()))
1371        }
1372        async fn get_transaction_hex(
1373            &self,
1374            _txid: String,
1375        ) -> Result<String, crate::chain::ChainServiceError> {
1376            unreachable!()
1377        }
1378        async fn get_outspend(
1379            &self,
1380            _txid: String,
1381            _vout: u32,
1382        ) -> Result<Outspend, crate::chain::ChainServiceError> {
1383            unreachable!()
1384        }
1385        async fn broadcast_transaction(
1386            &self,
1387            _tx: String,
1388        ) -> Result<(), crate::chain::ChainServiceError> {
1389            unreachable!()
1390        }
1391        async fn recommended_fees(
1392            &self,
1393        ) -> Result<crate::chain::RecommendedFees, crate::chain::ChainServiceError> {
1394            unreachable!()
1395        }
1396    }
1397
1398    /// A transaction spending `parent` under a relative timelock of `csv` blocks.
1399    fn timelocked_tx(parent: Txid, csv: u32) -> Transaction {
1400        Transaction {
1401            version: bitcoin::transaction::Version::TWO,
1402            lock_time: bitcoin::absolute::LockTime::ZERO,
1403            input: vec![bitcoin::TxIn {
1404                previous_output: OutPoint {
1405                    txid: parent,
1406                    vout: 0,
1407                },
1408                sequence: bitcoin::Sequence::from_height(u16::try_from(csv).unwrap()),
1409                ..Default::default()
1410            }],
1411            output: vec![TxOut {
1412                value: Amount::from_sat(1_000),
1413                script_pubkey: ScriptBuf::new(),
1414            }],
1415        }
1416    }
1417
1418    fn model_tx(
1419        tx: &Transaction,
1420        csv: Option<u32>,
1421        depends_on: Vec<String>,
1422        status: ExitTransactionStatus,
1423    ) -> UnilateralExitTransaction {
1424        UnilateralExitTransaction {
1425            kind: UnilateralExitTxKind::Node,
1426            node_id: None,
1427            txid: tx.compute_txid().to_string(),
1428            tx_hex: serialize_hex(tx),
1429            cpfp_tx_hex: None,
1430            csv_timelock_blocks: csv,
1431            depends_on,
1432            status,
1433        }
1434    }
1435
1436    fn check_of(
1437        confirmed: &[(Txid, Option<u32>)],
1438        not_confirmed: &[Txid],
1439    ) -> spark_wallet::ExitCheck {
1440        spark_wallet::ExitCheck {
1441            confirmed: confirmed.iter().copied().collect(),
1442            diverged: false,
1443            not_confirmed: not_confirmed.iter().copied().collect(),
1444            pending: Vec::new(),
1445        }
1446    }
1447
1448    /// The check settles an ancestor along the spend chain without asking, so it
1449    /// reports no height for it. Overwriting with that loses the height the
1450    /// caller is holding, which is what a child's timelock counts from.
1451    #[macros::async_test_all]
1452    async fn a_settled_ancestor_keeps_the_height_the_caller_already_had() {
1453        let txid = Txid::from_byte_array([4; 32]);
1454        let held = ExitTransactionStatus::Confirmed {
1455            block_height: Some(880_000),
1456        };
1457
1458        assert_eq!(
1459            status_after_check(held, &check_of(&[(txid, None)], &[]), &txid),
1460            held,
1461            "a height-less confirmation does not erase the one held"
1462        );
1463        assert_eq!(
1464            status_after_check(held, &check_of(&[(txid, Some(880_004))], &[]), &txid),
1465            ExitTransactionStatus::Confirmed {
1466                block_height: Some(880_004)
1467            },
1468            "a height the chain did report wins"
1469        );
1470    }
1471
1472    /// A transaction the chain reports as not in a block loses its stored
1473    /// `Confirmed`, so a reorg cannot leave the exit reporting `Done` over a
1474    /// transaction that is no longer there. A failed lookup leaves it alone.
1475    #[macros::async_test_all]
1476    async fn a_confirmation_the_chain_contradicts_is_dropped() {
1477        let txid = Txid::from_byte_array([5; 32]);
1478        let held = ExitTransactionStatus::Confirmed {
1479            block_height: Some(880_000),
1480        };
1481
1482        assert_eq!(
1483            status_after_check(held, &check_of(&[], &[txid]), &txid),
1484            ExitTransactionStatus::WaitingForDependencies,
1485            "re-derived, so resolve_statuses works out where it really stands"
1486        );
1487        assert_eq!(
1488            status_after_check(held, &check_of(&[], &[]), &txid),
1489            held,
1490            "no answer either way leaves the caller's record alone"
1491        );
1492    }
1493
1494    /// `Unverified` says the chain could not be read while the exit was built, so
1495    /// the SDK cannot tell whether an earlier fee-bumping child already spent the
1496    /// funding this transaction would use. A check reads only the kept exit, never
1497    /// the funding, so it can promote the status on finding the transaction in a
1498    /// block, but must not clear it on failing to.
1499    #[macros::async_test_all]
1500    async fn a_check_promotes_an_unverified_transaction_but_never_clears_it() {
1501        let txid = Txid::from_byte_array([6; 32]);
1502
1503        assert_eq!(
1504            status_after_check(
1505                ExitTransactionStatus::Unverified,
1506                &check_of(&[], &[]),
1507                &txid
1508            ),
1509            ExitTransactionStatus::Unverified,
1510            "not found is not evidence that broadcasting it is safe"
1511        );
1512        assert_eq!(
1513            status_after_check(
1514                ExitTransactionStatus::Unverified,
1515                &check_of(&[(txid, Some(880_000))], &[]),
1516                &txid,
1517            ),
1518            ExitTransactionStatus::Confirmed {
1519                block_height: Some(880_000)
1520            },
1521            "found in a block settles it outright"
1522        );
1523    }
1524
1525    /// `resolve_statuses` recomputes everything except `Confirmed` and
1526    /// `Unverified`, so a check that learned nothing about a transaction can leave
1527    /// its status alone and still have it come out up to date.
1528    #[macros::async_test_all]
1529    async fn a_check_that_learned_nothing_leaves_a_recomputable_status_alone() {
1530        let txid = Txid::from_byte_array([7; 32]);
1531        for status in [
1532            ExitTransactionStatus::Ready,
1533            ExitTransactionStatus::WaitingForDependencies,
1534            ExitTransactionStatus::WaitingForTimelock {
1535                spendable_at_height: Some(880_010),
1536            },
1537        ] {
1538            assert_eq!(
1539                status_after_check(status, &check_of(&[], &[]), &txid),
1540                status
1541            );
1542        }
1543    }
1544
1545    /// A parent at height 100 with a 6 block timelock is spendable from block
1546    /// 106, so a tip of 105 is enough: the child would be valid in block 106.
1547    #[macros::async_test_all]
1548    async fn a_matured_timelock_is_ready_one_block_early() {
1549        let parent = model_tx(
1550            &timelocked_tx(Txid::from_byte_array([9; 32]), 0),
1551            None,
1552            vec![],
1553            ExitTransactionStatus::Confirmed {
1554                block_height: Some(100),
1555            },
1556        );
1557        let parent_txid = Txid::from_str(&parent.txid).unwrap();
1558        let child = model_tx(
1559            &timelocked_tx(parent_txid, 6),
1560            Some(6),
1561            vec![parent.txid.clone()],
1562            ExitTransactionStatus::WaitingForDependencies,
1563        );
1564        let chain = StubChain {
1565            tip: Some(105),
1566            heights: HashMap::new(),
1567        };
1568
1569        let mut txs = vec![parent, child];
1570        resolve_statuses(&chain, &mut txs).await.unwrap();
1571
1572        assert_eq!(txs[1].status, ExitTransactionStatus::Ready);
1573    }
1574
1575    #[macros::async_test_all]
1576    async fn an_unmatured_timelock_reports_the_block_it_waits_for() {
1577        let parent = model_tx(
1578            &timelocked_tx(Txid::from_byte_array([9; 32]), 0),
1579            None,
1580            vec![],
1581            ExitTransactionStatus::Confirmed {
1582                block_height: Some(100),
1583            },
1584        );
1585        let parent_txid = Txid::from_str(&parent.txid).unwrap();
1586        let child = model_tx(
1587            &timelocked_tx(parent_txid, 6),
1588            Some(6),
1589            vec![parent.txid.clone()],
1590            ExitTransactionStatus::WaitingForDependencies,
1591        );
1592        let chain = StubChain {
1593            tip: Some(104),
1594            heights: HashMap::new(),
1595        };
1596
1597        let mut txs = vec![parent, child];
1598        resolve_statuses(&chain, &mut txs).await.unwrap();
1599
1600        assert_eq!(
1601            txs[1].status,
1602            ExitTransactionStatus::WaitingForTimelock {
1603                spendable_at_height: Some(106)
1604            }
1605        );
1606    }
1607
1608    /// An unconfirmed parent holds the child at its dependencies: the timelock
1609    /// has no height to count from yet.
1610    #[macros::async_test_all]
1611    async fn an_unconfirmed_dependency_outranks_the_timelock() {
1612        let parent = model_tx(
1613            &timelocked_tx(Txid::from_byte_array([9; 32]), 0),
1614            None,
1615            vec![],
1616            ExitTransactionStatus::WaitingForDependencies,
1617        );
1618        let parent_txid = Txid::from_str(&parent.txid).unwrap();
1619        let child = model_tx(
1620            &timelocked_tx(parent_txid, 6),
1621            Some(6),
1622            vec![parent.txid.clone()],
1623            ExitTransactionStatus::WaitingForDependencies,
1624        );
1625        let chain = StubChain {
1626            tip: Some(999),
1627            heights: HashMap::new(),
1628        };
1629
1630        let mut txs = vec![parent, child];
1631        resolve_statuses(&chain, &mut txs).await.unwrap();
1632
1633        assert_eq!(txs[1].status, ExitTransactionStatus::WaitingForDependencies);
1634    }
1635
1636    /// The exit a resume builds starts below the node it resumes from, so the
1637    /// height its first timelock counts from is not in the list. It comes from
1638    /// the chain instead.
1639    #[macros::async_test_all]
1640    async fn a_height_outside_the_list_is_read_from_the_chain() {
1641        let ancestor = Txid::from_byte_array([7; 32]);
1642        let tx = timelocked_tx(ancestor, 10);
1643        let chain = StubChain {
1644            tip: Some(1_000),
1645            heights: HashMap::from([(ancestor.to_string(), 500)]),
1646        };
1647
1648        let mut txs = vec![model_tx(
1649            &tx,
1650            Some(10),
1651            vec![],
1652            ExitTransactionStatus::WaitingForDependencies,
1653        )];
1654        resolve_statuses(&chain, &mut txs).await.unwrap();
1655
1656        assert_eq!(txs[0].status, ExitTransactionStatus::Ready);
1657    }
1658
1659    /// An unreadable chain leaves the transaction waiting rather than ready:
1660    /// the direction that never sends what the mempool would reject.
1661    #[macros::async_test_all]
1662    async fn an_unreadable_height_leaves_the_timelock_unknown() {
1663        let tx = timelocked_tx(Txid::from_byte_array([7; 32]), 10);
1664        let chain = StubChain {
1665            tip: Some(1_000),
1666            heights: HashMap::new(),
1667        };
1668
1669        let mut txs = vec![model_tx(
1670            &tx,
1671            Some(10),
1672            vec![],
1673            ExitTransactionStatus::WaitingForDependencies,
1674        )];
1675        resolve_statuses(&chain, &mut txs).await.unwrap();
1676
1677        assert_eq!(
1678            txs[0].status,
1679            ExitTransactionStatus::WaitingForTimelock {
1680                spendable_at_height: None
1681            }
1682        );
1683    }
1684}