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