1use std::{
2 collections::HashSet,
3 str::FromStr,
4 sync::{Arc, Mutex},
5 time::Duration,
6};
7
8use bitcoin::{
9 Transaction,
10 consensus::{encode::deserialize_hex, serialize},
11 hex::DisplayHex,
12};
13use platform_utils::tokio;
14use spark_wallet::{
15 InstantStaticDepositPlan, InstantStaticDepositQuoteResult, ListTransfersRequest,
16 MIN_RELAY_FEE_SAT_PER_VBYTE, TransferId, WalletTransfer,
17};
18use tracing::{debug, error, info, trace, warn};
19
20use crate::{
21 ClaimDepositQuote, ClaimDepositRequest, ClaimDepositResponse, DepositInfo, Fee,
22 FetchClaimDepositQuoteRequest, FetchClaimDepositQuoteResponse, InstantClaimStatus,
23 ListUnclaimedDepositsRequest, ListUnclaimedDepositsResponse, MaxFee, Network,
24 RefundDepositRequest, RefundDepositResponse, RefundState,
25 chain::Outspend,
26 error::SdkError,
27 models::Payment,
28 persist::UpdateDepositPayload,
29 sdk::RuntimeEvent,
30 utils::deposit_chain_syncer::TxOutput,
31 utils::utxo_fetcher::{CachedUtxoFetcher, DetailedUtxo},
32};
33
34use super::{BreezSdk, CLAIM_TX_SIZE_VBYTES};
35
36fn maturity_confirmations(network: Network) -> u32 {
39 match network {
40 Network::Regtest => 1,
41 Network::Mainnet => 3,
42 }
43}
44
45const CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS: u32 = 3;
48const CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS: u64 = 500;
49
50#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
51#[allow(clippy::needless_pass_by_value)]
52impl BreezSdk {
53 pub async fn claim_deposit(
54 &self,
55 request: ClaimDepositRequest,
56 ) -> Result<ClaimDepositResponse, SdkError> {
57 self.maybe_ensure_spark_private_mode_initialized().await?;
58 let detailed_utxo =
59 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
60 .fetch_detailed_utxo(&request.txid, request.vout)
61 .await?;
62
63 let max_fee = request
64 .max_fee
65 .or(self.config.max_deposit_claim_fee.clone());
66
67 let txid = detailed_utxo.txid.to_string();
73 let Some(_claim_guard) = self.claim_guards.try_acquire(TxOutput {
74 txid: txid.clone(),
75 vout: detailed_utxo.vout,
76 }) else {
77 return Err(SdkError::DepositClaimInProgress {
78 tx: txid,
79 vout: detailed_utxo.vout,
80 });
81 };
82
83 let confirmations = self
85 .deposit_confirmations(&request.txid)
86 .await
87 .unwrap_or_else(|e| {
88 warn!(
89 "Could not read the chain depth for {}:{}: {e}",
90 request.txid, request.vout
91 );
92 u32::MAX
93 });
94 if !self
96 .is_deposit_mature_at(&detailed_utxo, confirmations)
97 .await?
98 {
99 return self
100 .instant_claim_deposit(&detailed_utxo, max_fee, confirmations)
101 .await;
102 }
103
104 match self.claim_utxo(&detailed_utxo, max_fee).await {
105 Ok(transfer_id) => {
106 let transfer = self.lookup_claim_transfer_with_retry(transfer_id).await?;
107 let payment: Payment = transfer.try_into()?;
108 let should_emit_event = self.storage.apply_payment_update(payment.clone()).await?;
111 self.storage
112 .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
113 .await?;
114 self.event_emitter
115 .emit_runtime_event(RuntimeEvent::DepositClaimed {
116 payment: Box::new(payment.clone()),
117 should_emit_event,
118 })
119 .await;
120 Ok(ClaimDepositResponse {
121 payment: Some(payment),
122 })
123 }
124 Err(e) => {
125 error!("Failed to claim deposit: {e:?}");
126 self.storage
127 .update_deposit(
128 detailed_utxo.txid.to_string(),
129 detailed_utxo.vout,
130 UpdateDepositPayload::ClaimError {
131 error: e.clone().into(),
132 },
133 )
134 .await?;
135 Err(e)
136 }
137 }
138 }
139
140 pub async fn fetch_claim_deposit_quote(
147 &self,
148 request: FetchClaimDepositQuoteRequest,
149 ) -> Result<FetchClaimDepositQuoteResponse, SdkError> {
150 let detailed_utxo =
151 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
152 .fetch_detailed_utxo(&request.txid, request.vout)
153 .await?;
154
155 let (confirmations, instant, mature) = tokio::join!(
156 self.deposit_confirmations(&request.txid),
157 self.fetch_instant_claim_quote(&detailed_utxo),
158 self.fetch_mature_claim_quote(&detailed_utxo),
159 );
160 let confirmations = confirmations?;
161 let mature = mature?;
162 let is_mature = self
163 .is_deposit_mature_at(&detailed_utxo, confirmations)
164 .await?;
165 let instant = instant.filter(|quote| {
168 let earlier = quote.confirmations_required < mature.confirmations_required;
169 if is_mature || !earlier {
170 info!(
171 "Withholding the early claim for {}:{}: {}",
172 request.txid,
173 request.vout,
174 if is_mature {
175 "the deposit has already matured".to_string()
176 } else {
177 format!(
178 "it credits at {} confirmations, no sooner than maturity at {}",
179 quote.confirmations_required, mature.confirmations_required
180 )
181 }
182 );
183 }
184 !is_mature && earlier
185 });
186
187 Ok(FetchClaimDepositQuoteResponse {
188 amount_sats: detailed_utxo.value,
189 confirmations,
190 instant,
191 mature,
192 })
193 }
194
195 pub async fn refund_deposit(
196 &self,
197 request: RefundDepositRequest,
198 ) -> Result<RefundDepositResponse, SdkError> {
199 let detailed_utxo =
200 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
201 .fetch_detailed_utxo(&request.txid, request.vout)
202 .await?;
203
204 let existing = self
205 .storage
206 .list_deposits()
207 .await?
208 .into_iter()
209 .find(|d| d.txid == detailed_utxo.txid.to_string() && d.vout == detailed_utxo.vout);
210 let fee_to_outbid = match &existing {
212 Some(deposit) => self.refund_fee_to_outbid(&detailed_utxo, deposit).await?,
213 None => None,
214 };
215
216 let tx = self
217 .spark_wallet
218 .refund_static_deposit(
219 detailed_utxo.clone().tx,
220 Some(detailed_utxo.vout),
221 &request.destination_address,
222 request.fee.into(),
223 )
224 .await?;
225 let tx_hex = serialize(&tx).as_hex().to_string();
226 let tx_id = tx.compute_txid().as_raw_hash().to_string();
227
228 check_replacement_fee(&tx, detailed_utxo.value, fee_to_outbid)?;
229
230 if existing.is_none() {
236 self.storage
239 .add_deposit(
240 detailed_utxo.txid.to_string(),
241 detailed_utxo.vout,
242 detailed_utxo.value,
243 true,
244 )
245 .await?;
246 }
247
248 self.store_refund(
251 &detailed_utxo,
252 &tx_hex,
253 &tx_id,
254 RefundState::BroadcastPending { last_error: None },
255 )
256 .await?;
257
258 let broadcast_error = self
259 .chain_service
260 .broadcast_transaction(tx_hex.clone())
261 .await
262 .err();
263 let state = match &broadcast_error {
266 None => RefundState::Broadcast,
267 Some(e) => RefundState::BroadcastPending {
268 last_error: Some(e.to_string()),
269 },
270 };
271
272 if let Err(e) = self
273 .store_refund(&detailed_utxo, &tx_hex, &tx_id, state)
274 .await
275 {
276 error!("Failed to record refund state: {e:?}");
277 }
278
279 if let Some(e) = broadcast_error {
280 return Err(e.into());
281 }
282 Ok(RefundDepositResponse { tx_id, tx_hex })
283 }
284
285 #[allow(unused_variables)]
286 pub async fn list_unclaimed_deposits(
287 &self,
288 request: ListUnclaimedDepositsRequest,
289 ) -> Result<ListUnclaimedDepositsResponse, SdkError> {
290 let deposits = self.storage.list_deposits().await?;
291 Ok(ListUnclaimedDepositsResponse { deposits })
292 }
293}
294
295impl BreezSdk {
296 async fn lookup_claim_transfer_with_retry(
303 &self,
304 transfer_id: String,
305 ) -> Result<WalletTransfer, SdkError> {
306 let parsed_id = TransferId::from_str(&transfer_id).map_err(SdkError::Generic)?;
307 let mut last_error: Option<SdkError> = None;
308
309 for attempt in 0..CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS {
310 if attempt > 0 {
311 let delay_ms = CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS
312 .saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
313 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
314 trace!(
315 "Retrying claim transfer lookup (attempt {}/{}) for transfer {transfer_id}",
316 attempt.saturating_add(1),
317 CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS
318 );
319 }
320
321 match self
322 .spark_wallet
323 .list_transfers(ListTransfersRequest {
324 transfer_ids: vec![parsed_id.clone()],
325 paging: None,
326 })
327 .await
328 {
329 Ok(mut resp) => {
330 if let Some(transfer) = resp.items.pop() {
331 return Ok(transfer);
332 }
333 last_error = None;
334 }
335 Err(e) => last_error = Some(e.into()),
336 }
337 }
338
339 Err(last_error
340 .unwrap_or_else(|| SdkError::Generic("transfer not found after claim".to_string())))
341 }
342
343 async fn store_refund(
344 &self,
345 detailed_utxo: &DetailedUtxo,
346 tx_hex: &str,
347 tx_id: &str,
348 state: RefundState,
349 ) -> Result<(), SdkError> {
350 self.storage
351 .update_deposit(
352 detailed_utxo.txid.to_string(),
353 detailed_utxo.vout,
354 UpdateDepositPayload::Refund {
355 refund_tx: tx_hex.to_string(),
356 refund_txid: tx_id.to_string(),
357 state,
358 },
359 )
360 .await?;
361 Ok(())
362 }
363
364 async fn refund_fee_to_outbid(
369 &self,
370 detailed_utxo: &DetailedUtxo,
371 deposit: &DepositInfo,
372 ) -> Result<Option<PendingRefund>, SdkError> {
373 let Some(refund_tx) = deposit.refund_tx.as_ref() else {
374 return Ok(None);
375 };
376 let Ok(tx) = deserialize_hex::<Transaction>(refund_tx) else {
378 warn!(
379 "Stored refund of deposit {}:{} does not decode, not requiring a fee bump",
380 detailed_utxo.txid, detailed_utxo.vout
381 );
382 return Ok(None);
383 };
384 let stored = refund_fee_sats(&tx, detailed_utxo.value).map(|fee_sats| PendingRefund {
385 fee_sats,
386 vsize: tx.vsize().try_into().unwrap_or(u64::MAX),
387 });
388
389 let outspend = self
390 .chain_service
391 .get_outspend(detailed_utxo.txid.to_string(), detailed_utxo.vout)
392 .await;
393 match outspend {
394 Ok(Outspend::Unspent) => Ok(None),
395 Ok(Outspend::Spent { txid, status, .. }) if status.confirmed => {
396 Err(SdkError::InvalidInput(format!(
397 "Deposit {}:{} was already spent by {txid}",
398 detailed_utxo.txid, detailed_utxo.vout
399 )))
400 }
401 Ok(Outspend::Spent { .. }) => Ok(stored),
406 Err(_) => {
412 let Some(refund_txid) = deposit.refund_tx_id.clone() else {
413 return Ok(None);
414 };
415 match self.chain_service.get_transaction_status(refund_txid).await {
416 Ok(status) if status.confirmed => Err(SdkError::InvalidInput(format!(
417 "Deposit {}:{} was already refunded",
418 detailed_utxo.txid, detailed_utxo.vout
419 ))),
420 Ok(_) => Ok(stored),
421 Err(_) => Ok(None),
422 }
423 }
424 }
425 }
426
427 pub(super) async fn deposit_confirmations(&self, txid: &str) -> Result<u32, SdkError> {
431 self.deposit_confirmations_at_tip(txid, None).await
432 }
433
434 pub(super) async fn deposit_confirmations_at_tip(
437 &self,
438 txid: &str,
439 tip_height: Option<u32>,
440 ) -> Result<u32, SdkError> {
441 let status = self
442 .chain_service
443 .get_transaction_status(txid.to_string())
444 .await?;
445 if !status.confirmed {
447 return Ok(0);
448 }
449 let Some(block_height) = status.block_height else {
451 return Ok(1);
452 };
453 let tip_height = match tip_height {
454 Some(tip) => tip,
455 None => self.chain_service.tip_height().await?,
456 };
457 Ok(tip_height.saturating_sub(block_height).saturating_add(1))
458 }
459
460 async fn fetch_instant_claim_quote(
465 &self,
466 detailed_utxo: &DetailedUtxo,
467 ) -> Option<ClaimDepositQuote> {
468 let quote_result = self
469 .spark_wallet
470 .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
471 .await
472 .inspect_err(|e| {
473 info!(
474 "No instant quote for {}:{}: {e}",
475 detailed_utxo.txid, detailed_utxo.vout
476 );
477 })
478 .ok()?;
479 let plan = quote_result
480 .fulfillment_plans
481 .iter()
482 .min_by_key(|p| p.confirmations)?;
483 info!(
485 "Early claim quoted for {}:{} ({} sats): credits {} at {} confirmations",
486 detailed_utxo.txid,
487 detailed_utxo.vout,
488 detailed_utxo.value,
489 quote_result.quote.credit_amount.original_value,
490 plan.confirmations
491 );
492 Some(claim_deposit_quote(
493 u32::try_from(plan.confirmations.unsigned_abs()).unwrap_or(u32::MAX),
494 detailed_utxo.value,
495 quote_result.quote.credit_amount.original_value,
496 false,
497 ))
498 }
499
500 async fn fetch_mature_claim_quote(
504 &self,
505 detailed_utxo: &DetailedUtxo,
506 ) -> Result<ClaimDepositQuote, SdkError> {
507 match self
508 .spark_wallet
509 .fetch_static_deposit_claim_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
510 .await
511 {
512 Ok(quote) => Ok(claim_deposit_quote(
513 maturity_confirmations(self.config.network),
514 detailed_utxo.value,
515 quote.credit_amount_sats,
516 false,
517 )),
518 Err(e) => {
519 info!(
520 "No mature quote for {}:{}, estimating: {e}",
521 detailed_utxo.txid, detailed_utxo.vout
522 );
523 let fee_sats = self
524 .chain_service
525 .recommended_fees()
526 .await?
527 .fastest_fee
528 .saturating_mul(CLAIM_TX_SIZE_VBYTES);
529 Ok(claim_deposit_quote(
530 maturity_confirmations(self.config.network),
531 detailed_utxo.value,
532 detailed_utxo.value.saturating_sub(fee_sats),
533 true,
534 ))
535 }
536 }
537 }
538
539 async fn is_deposit_mature_at(
545 &self,
546 detailed_utxo: &DetailedUtxo,
547 confirmations: u32,
548 ) -> Result<bool, SdkError> {
549 let stored_mature = self
550 .storage
551 .list_deposits()
552 .await?
553 .into_iter()
554 .find(|d| d.txid == detailed_utxo.txid.to_string() && d.vout == detailed_utxo.vout)
555 .is_some_and(|d| d.is_mature);
556 let required = maturity_confirmations(self.config.network);
557 let is_mature = stored_mature || confirmations >= required;
558 info!(
559 "Deposit {}:{} is {} (operators: {}, chain: {}/{} confirmations)",
560 detailed_utxo.txid,
561 detailed_utxo.vout,
562 if is_mature {
563 "mature"
564 } else {
565 "not yet mature"
566 },
567 if stored_mature {
568 "mature"
569 } else {
570 "not mature"
571 },
572 confirmations,
573 required
574 );
575 Ok(is_mature)
576 }
577
578 async fn instant_claim_deposit(
581 &self,
582 detailed_utxo: &DetailedUtxo,
583 max_fee: Option<MaxFee>,
584 confirmations: u32,
585 ) -> Result<ClaimDepositResponse, SdkError> {
586 let row_exists = self
587 .storage
588 .list_deposits()
589 .await?
590 .iter()
591 .any(|d| d.txid == detailed_utxo.txid.to_string() && d.vout == detailed_utxo.vout);
592
593 let resolved_max_fee = self.resolve_max_claim_fee(max_fee).await?;
594 let outcome = match self
595 .instant_claim_utxo(detailed_utxo, resolved_max_fee, confirmations)
596 .await
597 {
598 Ok(outcome) => outcome,
599 Err(e) => {
601 error!("Instant claim transient error: {e:?}");
602 return Err(e);
603 }
604 };
605
606 if !row_exists {
612 self.storage
613 .add_deposit(
614 detailed_utxo.txid.to_string(),
615 detailed_utxo.vout,
616 detailed_utxo.value,
617 false,
618 )
619 .await?;
620 }
621 self.storage
622 .update_deposit(
623 detailed_utxo.txid.to_string(),
624 detailed_utxo.vout,
625 UpdateDepositPayload::InstantClaim {
626 status: outcome.status(confirmations),
627 },
628 )
629 .await?;
630
631 match outcome {
632 InstantClaimOutcome::Submitted(claim_id) => {
633 info!(
634 "Instant claimed utxo {}:{} with claim_id: {claim_id}",
635 detailed_utxo.txid, detailed_utxo.vout
636 );
637 Ok(ClaimDepositResponse { payment: None })
638 }
639 InstantClaimOutcome::Declined { error, .. } => {
640 error!("Instant claim declined: {error:?}");
641 Err(error)
642 }
643 }
644 }
645
646 pub(super) async fn instant_claim_utxo(
654 &self,
655 detailed_utxo: &DetailedUtxo,
656 resolved_max_fee: Option<(Fee, u64)>,
657 confirmations: u32,
658 ) -> Result<InstantClaimOutcome, SdkError> {
659 let max_fee_sats = resolved_max_fee.as_ref().map_or(0, |(_, sats)| *sats);
662
663 let quote_result = self
664 .spark_wallet
665 .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
666 .await?;
667 info!(
668 "Instant quote for {}:{} ({} sats, ceiling {} sats)",
669 detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value, max_fee_sats
670 );
671 debug!("Instant quote: {quote_result:?}");
672 match select_instant_claim_plan(
675 "e_result,
676 detailed_utxo.value,
677 max_fee_sats,
678 confirmations,
679 maturity_confirmations(self.config.network),
680 ) {
681 InstantClaimPlan::Claimable(plan) => {
682 match self
683 .spark_wallet
684 .claim_instant_static_deposit(
685 detailed_utxo.tx.clone(),
686 quote_result.quote,
687 plan,
688 )
689 .await
690 {
691 Ok(claim_id) => Ok(InstantClaimOutcome::Submitted(claim_id)),
692 Err(e) if is_pending_confirmation_error(&e.to_string()) => Err(e.into()),
695 Err(e) => Ok(InstantClaimOutcome::Declined {
697 error: e.into(),
698 max_fee_sats: None,
699 }),
700 }
701 }
702 InstantClaimPlan::NoPlan => Ok(InstantClaimOutcome::Declined {
703 error: SdkError::Generic("No instant claim plan available".to_string()),
704 max_fee_sats: None,
705 }),
706 InstantClaimPlan::CreditAboveDeposit { credit_sats } => {
707 Ok(InstantClaimOutcome::Declined {
708 error: SdkError::Generic(format!(
709 "Instant quote credits {credit_sats} sats for {}:{}, which is worth {} sats",
710 detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value
711 )),
712 max_fee_sats: None,
713 })
714 }
715 InstantClaimPlan::FeeExceeded {
716 quoted_sats,
717 quoted_rate,
718 } => Ok(InstantClaimOutcome::Declined {
719 error: SdkError::MaxDepositClaimFeeExceeded {
720 tx: detailed_utxo.txid.to_string(),
721 vout: detailed_utxo.vout,
722 max_fee: resolved_max_fee.map(|(fee, _)| fee),
723 required_fee_sats: quoted_sats,
724 required_fee_rate_sat_per_vbyte: quoted_rate,
725 },
726 max_fee_sats: Some(max_fee_sats),
727 }),
728 }
729 }
730}
731
732pub(super) enum InstantClaimOutcome {
734 Submitted(String),
736 Declined {
740 error: SdkError,
741 max_fee_sats: Option<u64>,
742 },
743}
744
745impl InstantClaimOutcome {
746 pub(super) fn status(&self, confirmations: u32) -> InstantClaimStatus {
748 match self {
749 InstantClaimOutcome::Submitted(claim_id) => InstantClaimStatus::Submitted {
750 claim_id: claim_id.clone(),
751 },
752 InstantClaimOutcome::Declined { max_fee_sats, .. } => InstantClaimStatus::Declined {
753 max_fee_sats: *max_fee_sats,
754 confirmations,
755 },
756 }
757 }
758}
759
760const PENDING_CONFIRMATION_MARKERS: [&str; 2] =
766 ["enough confirmations", "operators have not seen it"];
767
768fn is_pending_confirmation_error(message: &str) -> bool {
773 let message = message.to_lowercase();
774 PENDING_CONFIRMATION_MARKERS
775 .iter()
776 .any(|marker| message.contains(marker))
777}
778
779fn claim_deposit_quote(
781 confirmations_required: u32,
782 deposit_sats: u64,
783 credit_amount_sats: u64,
784 is_estimate: bool,
785) -> ClaimDepositQuote {
786 let fee_sats = deposit_sats.saturating_sub(credit_amount_sats);
787 ClaimDepositQuote {
788 confirmations_required,
789 credit_amount_sats,
790 fee_sats,
791 fee_rate_sat_per_vbyte: fee_sats.div_ceil(CLAIM_TX_SIZE_VBYTES),
792 is_estimate,
793 }
794}
795
796enum InstantClaimPlan {
798 Claimable(InstantStaticDepositPlan),
800 NoPlan,
802 FeeExceeded { quoted_sats: u64, quoted_rate: u64 },
805 CreditAboveDeposit { credit_sats: u64 },
808}
809
810fn select_instant_claim_plan(
816 quote_result: &InstantStaticDepositQuoteResult,
817 deposit_sats: u64,
818 max_fee_sats: u64,
819 confirmations: u32,
820 maturity_confirmations: u32,
821) -> InstantClaimPlan {
822 let Some(plan) = quote_result
823 .fulfillment_plans
824 .iter()
825 .min_by_key(|p| p.confirmations)
826 else {
827 return InstantClaimPlan::NoPlan;
828 };
829 if u64::from(confirmations) >= u64::from(maturity_confirmations) {
832 return InstantClaimPlan::NoPlan;
833 }
834 let plan_confirmations = plan.confirmations.unsigned_abs();
836 if plan_confirmations >= u64::from(maturity_confirmations) {
837 return InstantClaimPlan::NoPlan;
838 }
839 if plan_confirmations > u64::from(confirmations) {
842 return InstantClaimPlan::NoPlan;
843 }
844 let credit_sats = quote_result.quote.credit_amount.original_value;
849 let Some(quoted_sats) = deposit_sats.checked_sub(credit_sats) else {
850 return InstantClaimPlan::CreditAboveDeposit { credit_sats };
851 };
852 if quoted_sats <= max_fee_sats {
853 InstantClaimPlan::Claimable(plan.clone())
854 } else {
855 InstantClaimPlan::FeeExceeded {
856 quoted_sats,
857 quoted_rate: quoted_sats.div_ceil(CLAIM_TX_SIZE_VBYTES),
858 }
859 }
860}
861
862#[derive(Clone, Copy)]
865struct PendingRefund {
866 fee_sats: u64,
867 vsize: u64,
868}
869
870fn check_replacement_fee(
875 tx: &Transaction,
876 deposit_value_sats: u64,
877 pending: Option<PendingRefund>,
878) -> Result<(), SdkError> {
879 let Some(pending) = pending else {
880 return Ok(());
881 };
882 let pending_fee_sats = pending.fee_sats;
883 let required_fee_sats =
884 replacement_min_fee_sats(&pending, tx.vsize().try_into().unwrap_or(u64::MAX));
885 let fee_sats = refund_fee_sats(tx, deposit_value_sats).ok_or_else(|| {
886 SdkError::Generic("refund pays out more than the deposit holds".to_string())
887 })?;
888 if fee_sats < required_fee_sats {
889 return Err(SdkError::RefundReplacementFeeTooLow {
890 pending_fee_sats,
891 required_fee_sats,
892 });
893 }
894 Ok(())
895}
896
897fn refund_fee_sats(refund_tx: &Transaction, deposit_value_sats: u64) -> Option<u64> {
900 let out_sats: u64 = refund_tx.output.iter().map(|o| o.value.to_sat()).sum();
901 deposit_value_sats.checked_sub(out_sats)
902}
903
904fn replacement_min_fee_sats(pending: &PendingRefund, replacement_vsize: u64) -> u64 {
907 let bandwidth = pending
909 .fee_sats
910 .saturating_add(replacement_vsize.saturating_mul(MIN_RELAY_FEE_SAT_PER_VBYTE));
911 let pending_per_kvb = pending
916 .fee_sats
917 .saturating_mul(1000)
918 .checked_div(pending.vsize)
919 .unwrap_or(u64::MAX);
920 let feerate = pending_per_kvb
921 .saturating_add(1)
922 .saturating_mul(replacement_vsize)
923 .div_ceil(1000);
924 bandwidth.max(feerate)
925}
926
927#[derive(Clone, Default)]
934pub(crate) struct ClaimGuards {
935 in_flight: Arc<Mutex<HashSet<TxOutput>>>,
936}
937
938impl ClaimGuards {
939 pub(crate) fn try_acquire(&self, outpoint: TxOutput) -> Option<ClaimGuard> {
941 let mut in_flight = self
942 .in_flight
943 .lock()
944 .unwrap_or_else(std::sync::PoisonError::into_inner);
945 if !in_flight.insert(outpoint.clone()) {
946 return None;
947 }
948 Some(ClaimGuard {
949 guards: self.clone(),
950 outpoint,
951 })
952 }
953
954 fn release(&self, outpoint: &TxOutput) {
955 self.in_flight
956 .lock()
957 .unwrap_or_else(std::sync::PoisonError::into_inner)
958 .remove(outpoint);
959 }
960}
961
962pub(crate) struct ClaimGuard {
965 guards: ClaimGuards,
966 outpoint: TxOutput,
967}
968
969impl Drop for ClaimGuard {
970 fn drop(&mut self) {
971 self.guards.release(&self.outpoint);
972 }
973}
974
975#[cfg(test)]
976mod tests {
977 use bitcoin::{
978 Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, absolute::LockTime,
979 transaction::Version,
980 };
981 use spark_wallet::{
982 CurrencyAmount, InstantStaticDepositPlan, InstantStaticDepositQuote,
983 InstantStaticDepositQuoteResult,
984 };
985
986 use super::{
987 ClaimGuards, InstantClaimPlan, PendingRefund, SdkError, TxOutput, check_replacement_fee,
988 claim_deposit_quote, is_pending_confirmation_error, refund_fee_sats,
989 replacement_min_fee_sats, select_instant_claim_plan,
990 };
991
992 fn sats(value: u64) -> CurrencyAmount {
993 CurrencyAmount {
994 original_value: value,
995 ..Default::default()
996 }
997 }
998
999 fn quote_result(deposit_sats: u64, plans: &[(i64, u64)]) -> InstantStaticDepositQuoteResult {
1003 InstantStaticDepositQuoteResult {
1004 quote: InstantStaticDepositQuote {
1005 id: "quote-id".to_string(),
1006 transaction_id: "tx".to_string(),
1007 output_index: 0,
1008 deposit_amount: sats(deposit_sats),
1009 credit_amount: sats(plans.first().map_or(0, |(_, credit)| *credit)),
1010 quote_signature: "00".to_string(),
1011 },
1012 fulfillment_plans: plans
1013 .iter()
1014 .enumerate()
1015 .map(
1016 |(i, (confirmations, credit_sats))| InstantStaticDepositPlan {
1017 id: format!("plan-{i}"),
1018 amount: sats(*credit_sats),
1019 confirmations: *confirmations,
1020 },
1021 )
1022 .collect(),
1023 }
1024 }
1025
1026 #[test]
1027 fn quotes_the_fee_as_the_credit_shortfall() {
1028 let quote = claim_deposit_quote(1, 20_000, 18_810, false);
1031 assert_eq!(quote.confirmations_required, 1);
1032 assert_eq!(quote.credit_amount_sats, 18_810);
1033 assert_eq!(quote.fee_sats, 1_190);
1034 assert_eq!(quote.fee_rate_sat_per_vbyte, 13);
1035 assert!(!quote.is_estimate);
1036 }
1037
1038 #[test]
1039 fn quotes_a_free_claim_at_a_zero_rate() {
1040 let quote = claim_deposit_quote(3, 20_000, 20_000, true);
1041 assert_eq!(quote.fee_sats, 0);
1042 assert_eq!(quote.fee_rate_sat_per_vbyte, 0);
1043 assert!(quote.is_estimate);
1044 }
1045
1046 #[test]
1047 fn quotes_zero_rather_than_underflowing_on_a_credit_above_the_deposit() {
1048 let quote = claim_deposit_quote(0, 20_000, 25_000, false);
1051 assert_eq!(quote.fee_sats, 0);
1052 assert_eq!(quote.fee_rate_sat_per_vbyte, 0);
1053 }
1054
1055 #[test]
1056 fn selects_zero_conf_plan_within_ceiling() {
1057 let q = quote_result(100_000, &[(0, 99_000), (1, 99_500)]);
1059 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 0, 3)
1060 else {
1061 panic!("expected a claimable 0-conf plan");
1062 };
1063 assert_eq!(plan.confirmations, 0);
1064 }
1065
1066 #[test]
1067 fn selects_shallowest_plan_when_no_zero_conf_plan() {
1068 let q = quote_result(100_000, &[(1, 99_000), (2, 99_500)]);
1071 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 1, 3)
1072 else {
1073 panic!("expected the 1-conf plan to be claimable");
1074 };
1075 assert_eq!(plan.confirmations, 1);
1076 }
1077
1078 #[test]
1079 fn selects_shallowest_plan_regardless_of_order() {
1080 let q = quote_result(100_000, &[(3, 99_900), (1, 99_500), (0, 99_000)]);
1081 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 0, 3)
1082 else {
1083 panic!("expected a claimable plan");
1084 };
1085 assert_eq!(plan.confirmations, 0);
1086 }
1087
1088 #[test]
1089 fn skips_a_plan_that_credits_no_sooner_than_maturity() {
1090 let q = quote_result(100_000, &[(1, 99_000)]);
1094 assert!(matches!(
1095 select_instant_claim_plan(&q, 100_000, 10_000, 1, 1),
1096 InstantClaimPlan::NoPlan
1097 ));
1098 assert!(matches!(
1100 select_instant_claim_plan(&q, 100_000, 10_000, 1, 3),
1101 InstantClaimPlan::Claimable(_)
1102 ));
1103 }
1104
1105 #[test]
1106 fn skips_when_the_deposit_is_already_deep_enough_to_mature() {
1107 let q = quote_result(100_000, &[(1, 99_000)]);
1110 assert!(matches!(
1111 select_instant_claim_plan(&q, 100_000, 10_000, 5, 3),
1112 InstantClaimPlan::NoPlan
1113 ));
1114 }
1115
1116 #[test]
1117 fn skips_a_plan_the_deposit_is_not_deep_enough_for() {
1118 let q = quote_result(100_000, &[(2, 99_000)]);
1121 assert!(matches!(
1122 select_instant_claim_plan(&q, 100_000, 10_000, 1, 3),
1123 InstantClaimPlan::NoPlan
1124 ));
1125 assert!(matches!(
1127 select_instant_claim_plan(&q, 100_000, 10_000, 2, 3),
1128 InstantClaimPlan::Claimable(_)
1129 ));
1130 }
1131
1132 #[test]
1133 fn skips_when_no_plans_offered() {
1134 let q = quote_result(100_000, &[]);
1135 assert!(matches!(
1136 select_instant_claim_plan(&q, 100_000, 100_000, 0, 3),
1137 InstantClaimPlan::NoPlan
1138 ));
1139 }
1140
1141 #[test]
1145 fn declines_a_credit_above_the_deposit_value() {
1146 let q = quote_result(100_000, &[(0, 125_000)]);
1147 assert!(matches!(
1148 select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1149 InstantClaimPlan::CreditAboveDeposit {
1150 credit_sats: 125_000
1151 }
1152 ));
1153 }
1154
1155 #[test]
1156 fn declines_when_no_ceiling_is_set() {
1157 let q = quote_result(100_000, &[(0, 99_000)]);
1160 assert!(matches!(
1161 select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1162 InstantClaimPlan::FeeExceeded { .. }
1163 ));
1164 }
1165
1166 #[test]
1167 fn gates_on_the_credit_the_claim_signs() {
1168 let q = quote_result(100_000, &[(3, 90_000), (0, 99_000)]);
1174 assert_eq!(q.quote.credit_amount.original_value, 90_000);
1175 assert!(matches!(
1176 select_instant_claim_plan(&q, 100_000, 2_000, 0, 3),
1177 InstantClaimPlan::FeeExceeded {
1178 quoted_sats: 10_000,
1179 ..
1180 }
1181 ));
1182 }
1183
1184 #[test]
1185 fn skips_when_spread_over_ceiling() {
1186 let q = quote_result(100_000, &[(0, 95_000)]);
1189 assert!(matches!(
1190 select_instant_claim_plan(&q, 100_000, 1_000, 0, 3),
1191 InstantClaimPlan::FeeExceeded {
1192 quoted_sats: 5_000,
1193 quoted_rate: 51
1194 }
1195 ));
1196 }
1197
1198 #[test]
1199 fn rejects_any_spread_at_a_zero_ceiling() {
1200 let q = quote_result(100_000, &[(0, 99_000)]);
1201 assert!(matches!(
1202 select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1203 InstantClaimPlan::FeeExceeded { .. }
1204 ));
1205 }
1206
1207 #[test]
1208 fn accepts_spread_equal_to_ceiling() {
1209 let q = quote_result(100_000, &[(0, 99_000)]);
1211 assert!(matches!(
1212 select_instant_claim_plan(&q, 100_000, 1_000, 0, 3),
1213 InstantClaimPlan::Claimable(_)
1214 ));
1215 }
1216
1217 #[test]
1218 fn one_ceiling_admits_small_declines_large() {
1219 let ceiling = 2_000;
1223 let small = quote_result(20_000, &[(0, 18_810)]);
1225 assert!(matches!(
1226 select_instant_claim_plan(&small, 20_000, ceiling, 0, 3),
1227 InstantClaimPlan::Claimable(_)
1228 ));
1229 let large = quote_result(1_000_000, &[(0, 993_910)]);
1231 assert!(matches!(
1232 select_instant_claim_plan(&large, 1_000_000, ceiling, 0, 3),
1233 InstantClaimPlan::FeeExceeded { .. }
1234 ));
1235 }
1236
1237 #[test]
1238 fn treats_ssp_depth_rejections_as_retryable() {
1239 assert!(is_pending_confirmation_error(
1242 "graphql error: UTXO does not have enough confirmations. Required: 1, got: 0"
1243 ));
1244 assert!(is_pending_confirmation_error(
1245 "graphql error: UTXO needs 1 confirmations on every Spark operator before it \
1246 can be claimed. Some operators have not seen it that deep yet. Retry in a \
1247 few seconds."
1248 ));
1249 assert!(is_pending_confirmation_error(
1251 "deposit tx doesn't have enough confirmations: confirmation height: 100 \
1252 current block height: 100"
1253 ));
1254 assert!(!is_pending_confirmation_error(
1256 "graphql error: Something went wrong."
1257 ));
1258 }
1259
1260 #[test]
1261 fn prices_spread_off_passed_deposit_not_quote() {
1262 let q = quote_result(100_000, &[(0, 49_500)]);
1268 assert!(matches!(
1269 select_instant_claim_plan(&q, 50_000, 1_000, 0, 3),
1270 InstantClaimPlan::Claimable(_)
1271 ));
1272 }
1273
1274 fn outpoint(vout: u32) -> TxOutput {
1275 TxOutput {
1276 txid: "tx".to_string(),
1277 vout,
1278 }
1279 }
1280
1281 fn refund_paying_out(out_sats: u64) -> Transaction {
1282 Transaction {
1283 version: Version::non_standard(3),
1284 lock_time: LockTime::ZERO,
1285 input: vec![TxIn {
1286 previous_output: OutPoint::null(),
1287 ..Default::default()
1288 }],
1289 output: vec![TxOut {
1290 value: Amount::from_sat(out_sats),
1291 script_pubkey: ScriptBuf::new(),
1292 }],
1293 }
1294 }
1295
1296 #[test]
1297 fn a_second_attempt_on_the_same_outpoint_is_refused() {
1298 let guards = ClaimGuards::default();
1299 let first = guards.try_acquire(outpoint(0));
1300 assert!(first.is_some());
1301 assert!(guards.try_acquire(outpoint(0)).is_none());
1302 drop(first);
1303 assert!(guards.try_acquire(outpoint(0)).is_some());
1304 }
1305
1306 #[test]
1307 fn different_outpoints_do_not_block_each_other() {
1308 let guards = ClaimGuards::default();
1309 let _first = guards.try_acquire(outpoint(0));
1310 assert!(guards.try_acquire(outpoint(1)).is_some());
1311 }
1312
1313 #[test]
1314 fn fee_is_what_the_refund_leaves_behind() {
1315 assert_eq!(
1316 refund_fee_sats(&refund_paying_out(99_889), 100_000),
1317 Some(111)
1318 );
1319 assert_eq!(
1320 refund_fee_sats(&refund_paying_out(100_000), 100_000),
1321 Some(0)
1322 );
1323 assert_eq!(refund_fee_sats(&refund_paying_out(100_001), 100_000), None);
1325 }
1326
1327 #[test]
1328 fn replacement_must_cover_the_pending_fee_and_its_own_relay() {
1329 let pending = PendingRefund {
1332 fee_sats: 111,
1333 vsize: 111,
1334 };
1335 assert_eq!(replacement_min_fee_sats(&pending, 111), 222);
1336 let free = PendingRefund {
1337 fee_sats: 0,
1338 vsize: 111,
1339 };
1340 assert_eq!(replacement_min_fee_sats(&free, 111), 111);
1341 }
1342
1343 #[test]
1344 fn a_larger_replacement_must_beat_the_pending_feerate_too() {
1345 let pending = PendingRefund {
1349 fee_sats: 3_000,
1350 vsize: 99,
1351 };
1352 let required = replacement_min_fee_sats(&pending, 111);
1353 assert_eq!(required, 3_364);
1354 assert!(
1355 required * pending.vsize > pending.fee_sats * 111,
1356 "a replacement has to beat the pending feerate outright"
1357 );
1358
1359 assert_eq!(replacement_min_fee_sats(&pending, 99), 3_099);
1361
1362 let tie = PendingRefund {
1365 fee_sats: 932,
1366 vsize: 99,
1367 };
1368 assert_eq!(replacement_min_fee_sats(&tie, 111), 1_046);
1369
1370 let small = PendingRefund {
1372 fee_sats: 300,
1373 vsize: 99,
1374 };
1375 assert_eq!(replacement_min_fee_sats(&small, 111), 411);
1376 }
1377
1378 #[test]
1379 fn replacement_is_rejected_until_it_outbids_the_pending_refund() {
1380 let deposit = 100_000u64;
1381 let pending = 500u64;
1382 let vsize = refund_paying_out(0).vsize() as u64;
1383 let required = replacement_min_fee_sats(
1384 &PendingRefund {
1385 fee_sats: pending,
1386 vsize,
1387 },
1388 vsize,
1389 );
1390
1391 assert!(check_replacement_fee(&refund_paying_out(deposit - 1), deposit, None).is_ok());
1393
1394 let pending_refund = PendingRefund {
1395 fee_sats: pending,
1396 vsize,
1397 };
1398
1399 let short = refund_paying_out(deposit - required + 1);
1402 assert!(refund_fee_sats(&short, deposit).unwrap() > pending);
1403 assert!(matches!(
1404 check_replacement_fee(&short, deposit, Some(pending_refund)),
1405 Err(SdkError::RefundReplacementFeeTooLow {
1406 pending_fee_sats,
1407 required_fee_sats,
1408 }) if pending_fee_sats == pending && required_fee_sats == required
1409 ));
1410
1411 let exact = refund_paying_out(deposit - required);
1413 assert!(check_replacement_fee(&exact, deposit, Some(pending_refund)).is_ok());
1414 }
1415}