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