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 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
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 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 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 #[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 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 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 let funding_inputs = resolve_funding(chain, funding_inputs).await?;
264 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 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 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 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
453fn 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
470fn sat_per_kw_from_vbyte(sat_per_vbyte: u64) -> u64 {
473 sat_per_vbyte.saturating_mul(250)
474}
475
476fn funding_kind_params(kind: &CpfpFundingKind) -> Result<(u64, ScriptBuf), SdkError> {
479 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 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 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 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
604fn 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
615async 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 for query in scan.pending {
639 let result = execute_chain_query(chain, &query).await;
640 observed.push(Observation { query, result });
641 }
642 }
643}
644
645fn 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
663fn 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
722fn 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
770async 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 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 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 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
859fn 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
890async 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 let height = known?;
919 spendable_at = Some(spendable_at.unwrap_or(0).max(height.saturating_add(blocks)));
920 }
921 spendable_at
922}
923
924fn 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
937async 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 for query in check.pending {
957 let result = execute_chain_query(chain, &query).await;
958 observed.push(Observation { query, result });
959 }
960 }
961}
962
963async 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 for query in scan.pending {
984 let result = execute_chain_query(chain, &query).await;
985 observed.push(Observation { query, result });
986 }
987 }
988}
989
990async 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
1088fn 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
1100fn 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
1131async 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
1154async 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
1176fn 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}