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