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 ClaimDeferredReason, ClaimDepositOutcome, ClaimDepositQuote, ClaimDepositRequest,
22 ClaimDepositResponse, DepositInfo, Fee, FetchClaimDepositQuoteRequest,
23 FetchClaimDepositQuoteResponse, InstantClaimStatus, ListUnclaimedDepositsRequest,
24 ListUnclaimedDepositsResponse, MaxFee, Network, RefundDepositRequest, RefundDepositResponse,
25 RefundState,
26 chain::Outspend,
27 error::SdkError,
28 models::Payment,
29 persist::UpdateDepositPayload,
30 sdk::RuntimeEvent,
31 utils::deposit_chain_syncer::TxOutput,
32 utils::utxo_fetcher::{CachedUtxoFetcher, DetailedUtxo},
33};
34
35use super::{BreezSdk, CLAIM_TX_SIZE_VBYTES};
36
37fn maturity_confirmations(network: Network) -> u32 {
40 match network {
41 Network::Regtest => 1,
42 Network::Mainnet | Network::Signet => 3,
43 }
44}
45
46const CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS: u32 = 3;
49const CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS: u64 = 500;
50
51#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
52#[allow(clippy::needless_pass_by_value)]
53impl BreezSdk {
54 pub async fn claim_deposit(
55 &self,
56 request: ClaimDepositRequest,
57 ) -> Result<ClaimDepositResponse, SdkError> {
58 self.maybe_ensure_spark_private_mode_initialized().await?;
59 let detailed_utxo =
60 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
61 .fetch_detailed_utxo(&request.txid, request.vout)
62 .await?;
63
64 let ceiling =
65 resolve_claim_ceiling(request.max_fee, 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 max_fee = ceiling.effective;
84
85 let confirmations = self
87 .deposit_confirmations(&request.txid)
88 .await
89 .unwrap_or_else(|e| {
90 warn!(
91 "Could not read the chain depth for {}:{}: {e}",
92 request.txid, request.vout
93 );
94 u32::MAX
95 });
96 let stored = self.stored_deposit(&txid, detailed_utxo.vout).await?;
97 let is_mature = self.is_deposit_mature_at(&detailed_utxo, confirmations, stored.as_ref());
98 if !is_mature
102 && claim_already_made(
103 stored
104 .as_ref()
105 .and_then(|d| d.instant_claim_status.as_ref()),
106 )
107 {
108 return Err(SdkError::DepositClaimInProgress {
109 tx: txid,
110 vout: detailed_utxo.vout,
111 });
112 }
113 let row_exists = self
114 .store_max_claim_fee(&detailed_utxo, ceiling.stored, is_mature, stored.is_some())
115 .await?;
116 if !is_mature {
118 return self
119 .instant_claim_deposit(&detailed_utxo, max_fee, confirmations, row_exists)
120 .await;
121 }
122
123 self.claim_mature_deposit(&detailed_utxo, max_fee).await
124 }
125
126 pub async fn fetch_claim_deposit_quote(
133 &self,
134 request: FetchClaimDepositQuoteRequest,
135 ) -> Result<FetchClaimDepositQuoteResponse, SdkError> {
136 let detailed_utxo =
137 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
138 .fetch_detailed_utxo(&request.txid, request.vout)
139 .await?;
140
141 let (confirmations, instant, mature) = tokio::join!(
142 self.deposit_confirmations(&request.txid),
143 self.fetch_instant_claim_quote(&detailed_utxo),
144 self.fetch_mature_claim_quote(&detailed_utxo),
145 );
146 let confirmations = confirmations?;
147 let mature = mature?;
148 let stored = self
149 .stored_deposit(&detailed_utxo.txid.to_string(), detailed_utxo.vout)
150 .await?;
151 let is_mature = self.is_deposit_mature_at(&detailed_utxo, confirmations, stored.as_ref());
152 let instant = instant.filter(|quote| {
155 let earlier = quote.confirmations_required < mature.confirmations_required;
156 if is_mature || !earlier {
157 info!(
158 "Withholding the early claim for {}:{}: {}",
159 request.txid,
160 request.vout,
161 if is_mature {
162 "the deposit has already matured".to_string()
163 } else {
164 format!(
165 "it credits at {} confirmations, no sooner than maturity at {}",
166 quote.confirmations_required, mature.confirmations_required
167 )
168 }
169 );
170 }
171 !is_mature && earlier
172 });
173
174 Ok(FetchClaimDepositQuoteResponse {
175 amount_sats: detailed_utxo.value,
176 confirmations,
177 instant,
178 mature,
179 })
180 }
181
182 pub async fn refund_deposit(
183 &self,
184 request: RefundDepositRequest,
185 ) -> Result<RefundDepositResponse, SdkError> {
186 let detailed_utxo =
187 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
188 .fetch_detailed_utxo(&request.txid, request.vout)
189 .await?;
190
191 let existing = self
192 .storage
193 .list_deposits()
194 .await?
195 .into_iter()
196 .find(|d| d.txid == detailed_utxo.txid.to_string() && d.vout == detailed_utxo.vout);
197 let fee_to_outbid = match &existing {
199 Some(deposit) => self.refund_fee_to_outbid(&detailed_utxo, deposit).await?,
200 None => None,
201 };
202
203 let tx = self
204 .spark_wallet
205 .refund_static_deposit(
206 detailed_utxo.clone().tx,
207 Some(detailed_utxo.vout),
208 &request.destination_address,
209 request.fee.into(),
210 )
211 .await?;
212 let tx_hex = serialize(&tx).as_hex().to_string();
213 let tx_id = tx.compute_txid().as_raw_hash().to_string();
214
215 check_replacement_fee(&tx, detailed_utxo.value, fee_to_outbid)?;
216
217 if existing.is_none() {
223 self.storage
226 .add_deposit(
227 detailed_utxo.txid.to_string(),
228 detailed_utxo.vout,
229 detailed_utxo.value,
230 true,
231 )
232 .await?;
233 }
234
235 self.store_refund(
238 &detailed_utxo,
239 &tx_hex,
240 &tx_id,
241 RefundState::BroadcastPending { last_error: None },
242 )
243 .await?;
244
245 let broadcast_error = self
246 .chain_service
247 .broadcast_transaction(tx_hex.clone())
248 .await
249 .err();
250 let state = match &broadcast_error {
253 None => RefundState::Broadcast,
254 Some(e) => RefundState::BroadcastPending {
255 last_error: Some(e.to_string()),
256 },
257 };
258
259 if let Err(e) = self
260 .store_refund(&detailed_utxo, &tx_hex, &tx_id, state)
261 .await
262 {
263 error!("Failed to record refund state: {e:?}");
264 }
265
266 if let Some(e) = broadcast_error {
267 return Err(e.into());
268 }
269 Ok(RefundDepositResponse { tx_id, tx_hex })
270 }
271
272 #[allow(unused_variables)]
273 pub async fn list_unclaimed_deposits(
274 &self,
275 request: ListUnclaimedDepositsRequest,
276 ) -> Result<ListUnclaimedDepositsResponse, SdkError> {
277 let deposits = self.storage.list_deposits().await?;
278 Ok(ListUnclaimedDepositsResponse { deposits })
279 }
280}
281
282impl BreezSdk {
283 async fn claim_mature_deposit(
286 &self,
287 detailed_utxo: &DetailedUtxo,
288 max_fee: Option<MaxFee>,
289 ) -> Result<ClaimDepositResponse, SdkError> {
290 match self.claim_utxo(detailed_utxo, max_fee).await {
291 Ok(transfer_id) => {
292 let transfer = self.lookup_claim_transfer_with_retry(transfer_id).await?;
293 let payment: Payment = transfer.try_into()?;
294 let should_emit_event = self.storage.apply_payment_update(payment.clone()).await?;
297 self.storage
298 .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
299 .await?;
300 self.event_emitter
301 .emit_runtime_event(RuntimeEvent::DepositClaimed {
302 payment: Box::new(payment.clone()),
303 should_emit_event,
304 })
305 .await;
306 Ok(ClaimDepositResponse {
307 outcome: ClaimDepositOutcome::Settled { payment },
308 })
309 }
310 Err(e) if is_already_claimed_error(&e.to_string()) => {
314 info!(
315 "Deposit {}:{} was already claimed, marking it",
316 detailed_utxo.txid, detailed_utxo.vout
317 );
318 self.storage
319 .update_deposit(
320 detailed_utxo.txid.to_string(),
321 detailed_utxo.vout,
322 UpdateDepositPayload::InstantClaim {
323 status: InstantClaimStatus::Claimed,
324 },
325 )
326 .await?;
327 Err(e)
328 }
329 Err(e) => {
330 error!("Failed to claim deposit: {e:?}");
331 self.storage
332 .update_deposit(
333 detailed_utxo.txid.to_string(),
334 detailed_utxo.vout,
335 UpdateDepositPayload::ClaimError {
336 error: e.clone().into(),
337 },
338 )
339 .await?;
340 Err(e)
341 }
342 }
343 }
344
345 async fn store_max_claim_fee(
355 &self,
356 detailed_utxo: &DetailedUtxo,
357 max_fee: Option<MaxFee>,
358 is_mature: bool,
359 row_exists: bool,
360 ) -> Result<bool, SdkError> {
361 let txid = detailed_utxo.txid.to_string();
362 if let Some(max_fee) = &max_fee {
363 info!(
364 "Recording max claim fee {max_fee:?} on deposit {txid}:{}",
365 detailed_utxo.vout
366 );
367 } else {
368 debug!(
371 "Clearing any max claim fee on deposit {txid}:{}, \
372 which returns it to the configured one",
373 detailed_utxo.vout
374 );
375 }
376 let inserted = max_fee.is_some() && !row_exists;
377 if inserted {
378 self.storage
379 .add_deposit(
380 txid.clone(),
381 detailed_utxo.vout,
382 detailed_utxo.value,
383 is_mature,
384 )
385 .await?;
386 }
387 self.storage
388 .update_deposit(
389 txid,
390 detailed_utxo.vout,
391 UpdateDepositPayload::MaxClaimFee { max_fee },
392 )
393 .await?;
394 Ok(row_exists || inserted)
395 }
396
397 async fn lookup_claim_transfer_with_retry(
404 &self,
405 transfer_id: String,
406 ) -> Result<WalletTransfer, SdkError> {
407 let parsed_id = TransferId::from_str(&transfer_id).map_err(SdkError::Generic)?;
408 let mut last_error: Option<SdkError> = None;
409
410 for attempt in 0..CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS {
411 if attempt > 0 {
412 let delay_ms = CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS
413 .saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
414 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
415 trace!(
416 "Retrying claim transfer lookup (attempt {}/{}) for transfer {transfer_id}",
417 attempt.saturating_add(1),
418 CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS
419 );
420 }
421
422 match self
423 .spark_wallet
424 .list_transfers(ListTransfersRequest {
425 transfer_ids: vec![parsed_id.clone()],
426 paging: None,
427 })
428 .await
429 {
430 Ok(mut resp) => {
431 if let Some(transfer) = resp.items.pop() {
432 return Ok(transfer);
433 }
434 last_error = None;
435 }
436 Err(e) => last_error = Some(e.into()),
437 }
438 }
439
440 Err(last_error
441 .unwrap_or_else(|| SdkError::Generic("transfer not found after claim".to_string())))
442 }
443
444 async fn store_refund(
445 &self,
446 detailed_utxo: &DetailedUtxo,
447 tx_hex: &str,
448 tx_id: &str,
449 state: RefundState,
450 ) -> Result<(), SdkError> {
451 self.storage
452 .update_deposit(
453 detailed_utxo.txid.to_string(),
454 detailed_utxo.vout,
455 UpdateDepositPayload::Refund {
456 refund_tx: tx_hex.to_string(),
457 refund_txid: tx_id.to_string(),
458 state,
459 },
460 )
461 .await?;
462 Ok(())
463 }
464
465 async fn refund_fee_to_outbid(
470 &self,
471 detailed_utxo: &DetailedUtxo,
472 deposit: &DepositInfo,
473 ) -> Result<Option<PendingRefund>, SdkError> {
474 let Some(refund_tx) = deposit.refund_tx.as_ref() else {
475 return Ok(None);
476 };
477 let Ok(tx) = deserialize_hex::<Transaction>(refund_tx) else {
479 warn!(
480 "Stored refund of deposit {}:{} does not decode, not requiring a fee bump",
481 detailed_utxo.txid, detailed_utxo.vout
482 );
483 return Ok(None);
484 };
485 let stored = refund_fee_sats(&tx, detailed_utxo.value).map(|fee_sats| PendingRefund {
486 fee_sats,
487 vsize: tx.vsize().try_into().unwrap_or(u64::MAX),
488 });
489
490 let outspend = self
491 .chain_service
492 .get_outspend(detailed_utxo.txid.to_string(), detailed_utxo.vout)
493 .await;
494 match outspend {
495 Ok(Outspend::Unspent) => Ok(None),
496 Ok(Outspend::Spent { txid, status, .. }) if status.confirmed => {
497 Err(SdkError::InvalidInput(format!(
498 "Deposit {}:{} was already spent by {txid}",
499 detailed_utxo.txid, detailed_utxo.vout
500 )))
501 }
502 Ok(Outspend::Spent { .. }) => Ok(stored),
507 Err(_) => {
513 let Some(refund_txid) = deposit.refund_tx_id.clone() else {
514 return Ok(None);
515 };
516 match self.chain_service.get_transaction_status(refund_txid).await {
517 Ok(status) if status.confirmed => Err(SdkError::InvalidInput(format!(
518 "Deposit {}:{} was already refunded",
519 detailed_utxo.txid, detailed_utxo.vout
520 ))),
521 Ok(_) => Ok(stored),
522 Err(_) => Ok(None),
523 }
524 }
525 }
526 }
527
528 pub(super) async fn deposit_confirmations(&self, txid: &str) -> Result<u32, SdkError> {
532 self.deposit_confirmations_at_tip(txid, None).await
533 }
534
535 pub(super) async fn deposit_confirmations_at_tip(
538 &self,
539 txid: &str,
540 tip_height: Option<u32>,
541 ) -> Result<u32, SdkError> {
542 let status = self
543 .chain_service
544 .get_transaction_status(txid.to_string())
545 .await?;
546 if !status.confirmed {
548 return Ok(0);
549 }
550 let Some(block_height) = status.block_height else {
552 return Ok(1);
553 };
554 let tip_height = match tip_height {
555 Some(tip) => tip,
556 None => self.chain_service.tip_height().await?,
557 };
558 Ok(tip_height.saturating_sub(block_height).saturating_add(1))
559 }
560
561 async fn fetch_instant_claim_quote(
566 &self,
567 detailed_utxo: &DetailedUtxo,
568 ) -> Option<ClaimDepositQuote> {
569 let quote_result = self
570 .spark_wallet
571 .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
572 .await
573 .inspect_err(|e| {
574 info!(
575 "No instant quote for {}:{}: {e}",
576 detailed_utxo.txid, detailed_utxo.vout
577 );
578 })
579 .ok()?;
580 let plan = quote_result
581 .fulfillment_plans
582 .iter()
583 .min_by_key(|p| p.confirmations)?;
584 info!(
586 "Early claim quoted for {}:{} ({} sats): credits {} at {} confirmations",
587 detailed_utxo.txid,
588 detailed_utxo.vout,
589 detailed_utxo.value,
590 quote_result.quote.credit_amount.original_value,
591 plan.confirmations
592 );
593 Some(claim_deposit_quote(
594 u32::try_from(plan.confirmations.unsigned_abs()).unwrap_or(u32::MAX),
595 detailed_utxo.value,
596 quote_result.quote.credit_amount.original_value,
597 false,
598 ))
599 }
600
601 async fn fetch_mature_claim_quote(
605 &self,
606 detailed_utxo: &DetailedUtxo,
607 ) -> Result<ClaimDepositQuote, SdkError> {
608 match self
609 .spark_wallet
610 .fetch_static_deposit_claim_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
611 .await
612 {
613 Ok(quote) => Ok(claim_deposit_quote(
614 maturity_confirmations(self.config.network),
615 detailed_utxo.value,
616 quote.credit_amount_sats,
617 false,
618 )),
619 Err(e) => {
620 info!(
621 "No mature quote for {}:{}, estimating: {e}",
622 detailed_utxo.txid, detailed_utxo.vout
623 );
624 let fee_sats = self
625 .chain_service
626 .recommended_fees()
627 .await?
628 .fastest_fee
629 .saturating_mul(CLAIM_TX_SIZE_VBYTES);
630 Ok(claim_deposit_quote(
631 maturity_confirmations(self.config.network),
632 detailed_utxo.value,
633 detailed_utxo.value.saturating_sub(fee_sats),
634 true,
635 ))
636 }
637 }
638 }
639
640 fn is_deposit_mature_at(
646 &self,
647 detailed_utxo: &DetailedUtxo,
648 confirmations: u32,
649 stored: Option<&DepositInfo>,
650 ) -> bool {
651 let stored_mature = stored.is_some_and(|d| d.is_mature);
652 let required = maturity_confirmations(self.config.network);
653 let is_mature = stored_mature || confirmations >= required;
654 info!(
655 "Deposit {}:{} is {} (operators: {}, chain: {}/{} confirmations)",
656 detailed_utxo.txid,
657 detailed_utxo.vout,
658 if is_mature {
659 "mature"
660 } else {
661 "not yet mature"
662 },
663 if stored_mature {
664 "mature"
665 } else {
666 "not mature"
667 },
668 confirmations,
669 required
670 );
671 is_mature
672 }
673
674 pub(super) async fn stored_deposit(
677 &self,
678 txid: &str,
679 vout: u32,
680 ) -> Result<Option<DepositInfo>, SdkError> {
681 Ok(self
682 .storage
683 .list_deposits()
684 .await?
685 .into_iter()
686 .find(|d| d.txid == txid && d.vout == vout))
687 }
688
689 async fn instant_claim_deposit(
692 &self,
693 detailed_utxo: &DetailedUtxo,
694 max_fee: Option<MaxFee>,
695 confirmations: u32,
696 row_exists: bool,
697 ) -> Result<ClaimDepositResponse, SdkError> {
698 let resolved_max_fee = self.resolve_max_claim_fee(max_fee).await?;
699 let outcome = match self
700 .instant_claim_utxo(detailed_utxo, resolved_max_fee, confirmations)
701 .await
702 {
703 Ok(outcome) => outcome,
704 Err(e) => {
706 error!("Instant claim transient error: {e:?}");
707 return Err(e);
708 }
709 };
710
711 if !row_exists {
717 self.storage
718 .add_deposit(
719 detailed_utxo.txid.to_string(),
720 detailed_utxo.vout,
721 detailed_utxo.value,
722 false,
723 )
724 .await?;
725 }
726 self.storage
727 .update_deposit(
728 detailed_utxo.txid.to_string(),
729 detailed_utxo.vout,
730 UpdateDepositPayload::InstantClaim {
731 status: outcome.status(confirmations),
732 },
733 )
734 .await?;
735
736 match &outcome {
737 InstantClaimOutcome::Submitted(claim_id) => info!(
738 "Instant claimed utxo {}:{} with claim_id: {claim_id}",
739 detailed_utxo.txid, detailed_utxo.vout
740 ),
741 InstantClaimOutcome::Declined { error, .. } => {
742 info!("Instant claim declined: {error}");
743 }
744 }
745 Ok(instant_claim_response(&outcome))
746 }
747
748 pub(super) async fn instant_claim_utxo(
756 &self,
757 detailed_utxo: &DetailedUtxo,
758 resolved_max_fee: Option<(Fee, u64)>,
759 confirmations: u32,
760 ) -> Result<InstantClaimOutcome, SdkError> {
761 let max_fee_sats = resolved_max_fee.as_ref().map_or(0, |(_, sats)| *sats);
764
765 let quote_result = self
766 .spark_wallet
767 .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
768 .await?;
769 info!(
770 "Instant quote for {}:{} ({} sats, ceiling {} sats)",
771 detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value, max_fee_sats
772 );
773 debug!("Instant quote: {quote_result:?}");
774 match select_instant_claim_plan(
777 "e_result,
778 detailed_utxo.value,
779 max_fee_sats,
780 confirmations,
781 maturity_confirmations(self.config.network),
782 ) {
783 InstantClaimPlan::Claimable(plan) => {
784 match self
785 .spark_wallet
786 .claim_instant_static_deposit(
787 detailed_utxo.tx.clone(),
788 quote_result.quote,
789 plan,
790 )
791 .await
792 {
793 Ok(claim_id) => Ok(InstantClaimOutcome::Submitted(claim_id)),
794 Err(e) if is_pending_confirmation_error(&e.to_string()) => Err(e.into()),
797 Err(e) => {
799 let error: SdkError = e.into();
800 Ok(InstantClaimOutcome::Declined {
801 reason: ClaimDeferredReason::ProviderDeclined {
802 message: error.to_string(),
803 },
804 error,
805 max_fee_sats: None,
806 })
807 }
808 }
809 }
810 InstantClaimPlan::NoPlan => Ok(InstantClaimOutcome::Declined {
811 error: SdkError::Generic("No instant claim plan available".to_string()),
812 max_fee_sats: None,
813 reason: ClaimDeferredReason::NoEarlyClaimAvailable,
814 }),
815 InstantClaimPlan::CreditAboveDeposit { credit_sats } => {
818 let message = format!(
819 "Instant quote credits {credit_sats} sats for {}:{}, which is worth {} sats",
820 detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value
821 );
822 Ok(InstantClaimOutcome::Declined {
823 error: SdkError::Generic(message.clone()),
824 max_fee_sats: None,
825 reason: ClaimDeferredReason::ProviderDeclined { message },
826 })
827 }
828 InstantClaimPlan::FeeExceeded {
829 quoted_sats,
830 quoted_rate,
831 } => Ok(InstantClaimOutcome::Declined {
832 error: SdkError::MaxDepositClaimFeeExceeded {
833 tx: detailed_utxo.txid.to_string(),
834 vout: detailed_utxo.vout,
835 max_fee: resolved_max_fee.map(|(fee, _)| fee),
836 required_fee_sats: quoted_sats,
837 required_fee_rate_sat_per_vbyte: quoted_rate,
838 },
839 max_fee_sats: Some(max_fee_sats),
840 reason: ClaimDeferredReason::MaxFeeExceeded {
841 required_fee_sats: quoted_sats,
842 max_fee_sats,
843 },
844 }),
845 }
846 }
847}
848
849pub(super) enum InstantClaimOutcome {
851 Submitted(String),
853 Declined {
857 error: SdkError,
858 max_fee_sats: Option<u64>,
859 reason: ClaimDeferredReason,
860 },
861}
862
863impl InstantClaimOutcome {
864 pub(super) fn status(&self, confirmations: u32) -> InstantClaimStatus {
866 match self {
867 InstantClaimOutcome::Submitted(claim_id) => InstantClaimStatus::Submitted {
868 claim_id: claim_id.clone(),
869 },
870 InstantClaimOutcome::Declined { max_fee_sats, .. } => InstantClaimStatus::Declined {
871 max_fee_sats: *max_fee_sats,
872 confirmations,
873 },
874 }
875 }
876}
877
878pub(super) fn is_already_claimed_error(message: &str) -> bool {
883 message.to_lowercase().contains("already been claimed")
884}
885
886const PENDING_CONFIRMATION_MARKERS: [&str; 2] =
892 ["enough confirmations", "operators have not seen it"];
893
894fn is_pending_confirmation_error(message: &str) -> bool {
899 let message = message.to_lowercase();
900 PENDING_CONFIRMATION_MARKERS
901 .iter()
902 .any(|marker| message.contains(marker))
903}
904
905pub(super) struct ClaimCeiling {
907 pub stored: Option<MaxFee>,
909 pub effective: Option<MaxFee>,
911}
912
913pub(super) fn resolve_claim_ceiling(
917 requested: Option<MaxFee>,
918 config_default: Option<MaxFee>,
919) -> ClaimCeiling {
920 ClaimCeiling {
921 stored: requested.clone(),
922 effective: requested.or(config_default),
923 }
924}
925
926pub(super) fn larger_ceiling(
937 stored: &MaxFee,
938 stored_sats: u64,
939 config_default: &MaxFee,
940 config_sats: u64,
941) -> MaxFee {
942 if stored_sats >= config_sats {
943 stored.clone()
944 } else {
945 config_default.clone()
946 }
947}
948
949pub(super) fn instant_claim_response(outcome: &InstantClaimOutcome) -> ClaimDepositResponse {
952 let outcome = match outcome {
953 InstantClaimOutcome::Submitted(_) => ClaimDepositOutcome::Submitted,
954 InstantClaimOutcome::Declined { reason, .. } => ClaimDepositOutcome::Deferred {
955 reason: reason.clone(),
956 },
957 };
958 ClaimDepositResponse { outcome }
959}
960
961pub(super) fn claim_already_made(status: Option<&InstantClaimStatus>) -> bool {
965 matches!(
966 status,
967 Some(InstantClaimStatus::Submitted { .. } | InstantClaimStatus::Claimed)
968 )
969}
970
971pub(super) fn needs_own_ceiling_resolution(
975 stored: Option<&MaxFee>,
976 config_default: Option<&MaxFee>,
977) -> bool {
978 stored.is_some() && stored != config_default
979}
980
981fn claim_deposit_quote(
983 confirmations_required: u32,
984 deposit_sats: u64,
985 credit_amount_sats: u64,
986 is_estimate: bool,
987) -> ClaimDepositQuote {
988 let fee_sats = deposit_sats.saturating_sub(credit_amount_sats);
989 ClaimDepositQuote {
990 confirmations_required,
991 credit_amount_sats,
992 fee_sats,
993 fee_rate_sat_per_vbyte: fee_sats.div_ceil(CLAIM_TX_SIZE_VBYTES),
994 is_estimate,
995 }
996}
997
998enum InstantClaimPlan {
1000 Claimable(InstantStaticDepositPlan),
1002 NoPlan,
1004 FeeExceeded { quoted_sats: u64, quoted_rate: u64 },
1007 CreditAboveDeposit { credit_sats: u64 },
1010}
1011
1012fn select_instant_claim_plan(
1018 quote_result: &InstantStaticDepositQuoteResult,
1019 deposit_sats: u64,
1020 max_fee_sats: u64,
1021 confirmations: u32,
1022 maturity_confirmations: u32,
1023) -> InstantClaimPlan {
1024 let Some(plan) = quote_result
1025 .fulfillment_plans
1026 .iter()
1027 .min_by_key(|p| p.confirmations)
1028 else {
1029 return InstantClaimPlan::NoPlan;
1030 };
1031 if u64::from(confirmations) >= u64::from(maturity_confirmations) {
1034 return InstantClaimPlan::NoPlan;
1035 }
1036 let plan_confirmations = plan.confirmations.unsigned_abs();
1038 if plan_confirmations >= u64::from(maturity_confirmations) {
1039 return InstantClaimPlan::NoPlan;
1040 }
1041 if plan_confirmations > u64::from(confirmations) {
1044 return InstantClaimPlan::NoPlan;
1045 }
1046 let credit_sats = quote_result.quote.credit_amount.original_value;
1051 let Some(quoted_sats) = deposit_sats.checked_sub(credit_sats) else {
1052 return InstantClaimPlan::CreditAboveDeposit { credit_sats };
1053 };
1054 if quoted_sats <= max_fee_sats {
1055 InstantClaimPlan::Claimable(plan.clone())
1056 } else {
1057 InstantClaimPlan::FeeExceeded {
1058 quoted_sats,
1059 quoted_rate: quoted_sats.div_ceil(CLAIM_TX_SIZE_VBYTES),
1060 }
1061 }
1062}
1063
1064#[derive(Clone, Copy)]
1067struct PendingRefund {
1068 fee_sats: u64,
1069 vsize: u64,
1070}
1071
1072fn check_replacement_fee(
1077 tx: &Transaction,
1078 deposit_value_sats: u64,
1079 pending: Option<PendingRefund>,
1080) -> Result<(), SdkError> {
1081 let Some(pending) = pending else {
1082 return Ok(());
1083 };
1084 let pending_fee_sats = pending.fee_sats;
1085 let required_fee_sats =
1086 replacement_min_fee_sats(&pending, tx.vsize().try_into().unwrap_or(u64::MAX));
1087 let fee_sats = refund_fee_sats(tx, deposit_value_sats).ok_or_else(|| {
1088 SdkError::Generic("refund pays out more than the deposit holds".to_string())
1089 })?;
1090 if fee_sats < required_fee_sats {
1091 return Err(SdkError::RefundReplacementFeeTooLow {
1092 pending_fee_sats,
1093 required_fee_sats,
1094 });
1095 }
1096 Ok(())
1097}
1098
1099fn refund_fee_sats(refund_tx: &Transaction, deposit_value_sats: u64) -> Option<u64> {
1102 let out_sats: u64 = refund_tx.output.iter().map(|o| o.value.to_sat()).sum();
1103 deposit_value_sats.checked_sub(out_sats)
1104}
1105
1106fn replacement_min_fee_sats(pending: &PendingRefund, replacement_vsize: u64) -> u64 {
1109 let bandwidth = pending
1111 .fee_sats
1112 .saturating_add(replacement_vsize.saturating_mul(MIN_RELAY_FEE_SAT_PER_VBYTE));
1113 let pending_per_kvb = pending
1118 .fee_sats
1119 .saturating_mul(1000)
1120 .checked_div(pending.vsize)
1121 .unwrap_or(u64::MAX);
1122 let feerate = pending_per_kvb
1123 .saturating_add(1)
1124 .saturating_mul(replacement_vsize)
1125 .div_ceil(1000);
1126 bandwidth.max(feerate)
1127}
1128
1129#[derive(Clone, Default)]
1136pub(crate) struct ClaimGuards {
1137 in_flight: Arc<Mutex<HashSet<TxOutput>>>,
1138}
1139
1140impl ClaimGuards {
1141 pub(crate) fn try_acquire(&self, outpoint: TxOutput) -> Option<ClaimGuard> {
1143 let mut in_flight = self
1144 .in_flight
1145 .lock()
1146 .unwrap_or_else(std::sync::PoisonError::into_inner);
1147 if !in_flight.insert(outpoint.clone()) {
1148 return None;
1149 }
1150 Some(ClaimGuard {
1151 guards: self.clone(),
1152 outpoint,
1153 })
1154 }
1155
1156 fn release(&self, outpoint: &TxOutput) {
1157 self.in_flight
1158 .lock()
1159 .unwrap_or_else(std::sync::PoisonError::into_inner)
1160 .remove(outpoint);
1161 }
1162}
1163
1164pub(crate) struct ClaimGuard {
1167 guards: ClaimGuards,
1168 outpoint: TxOutput,
1169}
1170
1171impl Drop for ClaimGuard {
1172 fn drop(&mut self) {
1173 self.guards.release(&self.outpoint);
1174 }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use bitcoin::{
1180 Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, absolute::LockTime,
1181 transaction::Version,
1182 };
1183 use spark_wallet::{
1184 CurrencyAmount, InstantStaticDepositPlan, InstantStaticDepositQuote,
1185 InstantStaticDepositQuoteResult,
1186 };
1187
1188 use super::{
1189 ClaimDeferredReason, ClaimDepositOutcome, ClaimGuards, InstantClaimOutcome,
1190 InstantClaimPlan, MaxFee, PendingRefund, SdkError, TxOutput, check_replacement_fee,
1191 claim_deposit_quote, instant_claim_response, is_already_claimed_error,
1192 is_pending_confirmation_error, larger_ceiling, needs_own_ceiling_resolution,
1193 refund_fee_sats, replacement_min_fee_sats, resolve_claim_ceiling,
1194 select_instant_claim_plan,
1195 };
1196
1197 fn fixed(amount: u64) -> MaxFee {
1200 MaxFee::Fixed { amount }
1201 }
1202
1203 #[test]
1204 fn a_requested_ceiling_is_used_and_recorded() {
1205 let ceiling = resolve_claim_ceiling(Some(fixed(50_000)), Some(fixed(99)));
1206 assert_eq!(ceiling.effective, Some(fixed(50_000)));
1207 assert_eq!(ceiling.stored, Some(fixed(50_000)));
1210 }
1211
1212 #[test]
1213 fn requesting_no_ceiling_claims_under_config_and_clears_the_stored_one() {
1214 let ceiling = resolve_claim_ceiling(None, Some(fixed(99)));
1215 assert_eq!(ceiling.effective, Some(fixed(99)));
1216 assert_eq!(ceiling.stored, None);
1219 }
1220
1221 #[test]
1222 fn no_ceiling_anywhere_leaves_nothing_to_claim_within() {
1223 let ceiling = resolve_claim_ceiling(None, None);
1224 assert_eq!(ceiling.effective, None);
1225 assert_eq!(ceiling.stored, None);
1226 }
1227
1228 #[test]
1229 fn a_requested_ceiling_replaces_the_configured_one() {
1230 let ceiling = resolve_claim_ceiling(Some(fixed(10)), Some(fixed(99)));
1233 assert_eq!(ceiling.effective, Some(fixed(10)));
1234 }
1235
1236 #[test]
1237 fn a_raised_ceiling_governs_the_claim_at_maturity() {
1238 assert_eq!(
1239 larger_ceiling(&fixed(50_000), 50_000, &fixed(99), 99),
1240 fixed(50_000)
1241 );
1242 }
1243
1244 #[test]
1245 fn a_ceiling_lowered_to_hold_a_deposit_back_does_not_cap_its_mature_claim() {
1246 assert_eq!(larger_ceiling(&fixed(10), 10, &fixed(99), 99), fixed(99));
1250 }
1251
1252 #[test]
1253 fn ceilings_are_compared_as_sats_not_by_shape() {
1254 let rate = MaxFee::Rate { sat_per_vbyte: 5 };
1257 assert_eq!(larger_ceiling(&rate, 495, &fixed(99), 99), rate);
1258 assert_eq!(larger_ceiling(&fixed(99), 99, &rate, 495), rate);
1259 }
1260
1261 #[test]
1262 fn an_equal_ceiling_keeps_the_deposit_own() {
1263 assert_eq!(larger_ceiling(&fixed(99), 99, &fixed(99), 99), fixed(99));
1265 }
1266
1267 fn declined(reason: ClaimDeferredReason) -> InstantClaimOutcome {
1270 InstantClaimOutcome::Declined {
1271 error: SdkError::Generic("declined".to_string()),
1272 max_fee_sats: None,
1273 reason,
1274 }
1275 }
1276
1277 #[test]
1278 fn a_submitted_early_claim_returns_no_payment_yet() {
1279 let response =
1280 instant_claim_response(&InstantClaimOutcome::Submitted("claim-1".to_string()));
1281 assert!(matches!(response.outcome, ClaimDepositOutcome::Submitted));
1284 }
1285
1286 #[test]
1287 fn a_declined_early_claim_defers_rather_than_failing() {
1288 let response = instant_claim_response(&declined(ClaimDeferredReason::MaxFeeExceeded {
1291 required_fee_sats: 5_000,
1292 max_fee_sats: 1_000,
1293 }));
1294 let ClaimDepositOutcome::Deferred { reason } = response.outcome else {
1295 panic!("a declined early claim must defer, not fail")
1296 };
1297 assert_eq!(
1298 reason,
1299 ClaimDeferredReason::MaxFeeExceeded {
1300 required_fee_sats: 5_000,
1301 max_fee_sats: 1_000,
1302 }
1303 );
1304 }
1305
1306 #[test]
1307 fn a_deposit_too_shallow_for_any_plan_defers_without_a_fee() {
1308 let response =
1309 instant_claim_response(&declined(ClaimDeferredReason::NoEarlyClaimAvailable));
1310 let ClaimDepositOutcome::Deferred { reason } = response.outcome else {
1311 panic!("too shallow for a plan must defer")
1312 };
1313 assert_eq!(reason, ClaimDeferredReason::NoEarlyClaimAvailable);
1314 }
1315
1316 #[test]
1317 fn a_provider_decline_is_not_reported_as_a_depth_to_wait_out() {
1318 let response = instant_claim_response(&declined(ClaimDeferredReason::ProviderDeclined {
1321 message: "transport error".to_string(),
1322 }));
1323 let ClaimDepositOutcome::Deferred { reason } = response.outcome else {
1324 panic!("a provider decline must defer")
1325 };
1326 assert_eq!(
1327 reason,
1328 ClaimDeferredReason::ProviderDeclined {
1329 message: "transport error".to_string()
1330 }
1331 );
1332 }
1333
1334 #[test]
1335 fn only_a_ceiling_differing_from_config_is_resolved_on_its_own() {
1336 let config = fixed(99);
1337 assert!(!needs_own_ceiling_resolution(None, Some(&config)));
1339 assert!(!needs_own_ceiling_resolution(
1342 Some(&fixed(99)),
1343 Some(&config)
1344 ));
1345 assert!(needs_own_ceiling_resolution(
1346 Some(&fixed(50_000)),
1347 Some(&config)
1348 ));
1349 assert!(needs_own_ceiling_resolution(
1352 Some(&fixed(10)),
1353 Some(&config)
1354 ));
1355 assert!(needs_own_ceiling_resolution(Some(&fixed(10)), None));
1357 assert!(!needs_own_ceiling_resolution(None, None));
1358 assert!(!needs_own_ceiling_resolution(
1360 Some(&MaxFee::Rate { sat_per_vbyte: 4 }),
1361 Some(&MaxFee::Rate { sat_per_vbyte: 4 })
1362 ));
1363 }
1364
1365 fn sats(value: u64) -> CurrencyAmount {
1366 CurrencyAmount {
1367 original_value: value,
1368 ..Default::default()
1369 }
1370 }
1371
1372 fn quote_result(deposit_sats: u64, plans: &[(i64, u64)]) -> InstantStaticDepositQuoteResult {
1376 InstantStaticDepositQuoteResult {
1377 quote: InstantStaticDepositQuote {
1378 id: "quote-id".to_string(),
1379 transaction_id: "tx".to_string(),
1380 output_index: 0,
1381 deposit_amount: sats(deposit_sats),
1382 credit_amount: sats(plans.first().map_or(0, |(_, credit)| *credit)),
1383 quote_signature: "00".to_string(),
1384 },
1385 fulfillment_plans: plans
1386 .iter()
1387 .enumerate()
1388 .map(
1389 |(i, (confirmations, credit_sats))| InstantStaticDepositPlan {
1390 id: format!("plan-{i}"),
1391 amount: sats(*credit_sats),
1392 confirmations: *confirmations,
1393 },
1394 )
1395 .collect(),
1396 }
1397 }
1398
1399 #[test]
1400 fn quotes_the_fee_as_the_credit_shortfall() {
1401 let quote = claim_deposit_quote(1, 20_000, 18_810, false);
1404 assert_eq!(quote.confirmations_required, 1);
1405 assert_eq!(quote.credit_amount_sats, 18_810);
1406 assert_eq!(quote.fee_sats, 1_190);
1407 assert_eq!(quote.fee_rate_sat_per_vbyte, 13);
1408 assert!(!quote.is_estimate);
1409 }
1410
1411 #[test]
1412 fn quotes_a_free_claim_at_a_zero_rate() {
1413 let quote = claim_deposit_quote(3, 20_000, 20_000, true);
1414 assert_eq!(quote.fee_sats, 0);
1415 assert_eq!(quote.fee_rate_sat_per_vbyte, 0);
1416 assert!(quote.is_estimate);
1417 }
1418
1419 #[test]
1420 fn quotes_zero_rather_than_underflowing_on_a_credit_above_the_deposit() {
1421 let quote = claim_deposit_quote(0, 20_000, 25_000, false);
1424 assert_eq!(quote.fee_sats, 0);
1425 assert_eq!(quote.fee_rate_sat_per_vbyte, 0);
1426 }
1427
1428 #[test]
1429 fn selects_zero_conf_plan_within_ceiling() {
1430 let q = quote_result(100_000, &[(0, 99_000), (1, 99_500)]);
1432 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 0, 3)
1433 else {
1434 panic!("expected a claimable 0-conf plan");
1435 };
1436 assert_eq!(plan.confirmations, 0);
1437 }
1438
1439 #[test]
1440 fn selects_shallowest_plan_when_no_zero_conf_plan() {
1441 let q = quote_result(100_000, &[(1, 99_000), (2, 99_500)]);
1444 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 1, 3)
1445 else {
1446 panic!("expected the 1-conf plan to be claimable");
1447 };
1448 assert_eq!(plan.confirmations, 1);
1449 }
1450
1451 #[test]
1452 fn selects_shallowest_plan_regardless_of_order() {
1453 let q = quote_result(100_000, &[(3, 99_900), (1, 99_500), (0, 99_000)]);
1454 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 0, 3)
1455 else {
1456 panic!("expected a claimable plan");
1457 };
1458 assert_eq!(plan.confirmations, 0);
1459 }
1460
1461 #[test]
1462 fn skips_a_plan_that_credits_no_sooner_than_maturity() {
1463 let q = quote_result(100_000, &[(1, 99_000)]);
1467 assert!(matches!(
1468 select_instant_claim_plan(&q, 100_000, 10_000, 1, 1),
1469 InstantClaimPlan::NoPlan
1470 ));
1471 assert!(matches!(
1473 select_instant_claim_plan(&q, 100_000, 10_000, 1, 3),
1474 InstantClaimPlan::Claimable(_)
1475 ));
1476 }
1477
1478 #[test]
1479 fn skips_when_the_deposit_is_already_deep_enough_to_mature() {
1480 let q = quote_result(100_000, &[(1, 99_000)]);
1483 assert!(matches!(
1484 select_instant_claim_plan(&q, 100_000, 10_000, 5, 3),
1485 InstantClaimPlan::NoPlan
1486 ));
1487 }
1488
1489 #[test]
1490 fn skips_a_plan_the_deposit_is_not_deep_enough_for() {
1491 let q = quote_result(100_000, &[(2, 99_000)]);
1494 assert!(matches!(
1495 select_instant_claim_plan(&q, 100_000, 10_000, 1, 3),
1496 InstantClaimPlan::NoPlan
1497 ));
1498 assert!(matches!(
1500 select_instant_claim_plan(&q, 100_000, 10_000, 2, 3),
1501 InstantClaimPlan::Claimable(_)
1502 ));
1503 }
1504
1505 #[test]
1506 fn skips_when_no_plans_offered() {
1507 let q = quote_result(100_000, &[]);
1508 assert!(matches!(
1509 select_instant_claim_plan(&q, 100_000, 100_000, 0, 3),
1510 InstantClaimPlan::NoPlan
1511 ));
1512 }
1513
1514 #[test]
1518 fn declines_a_credit_above_the_deposit_value() {
1519 let q = quote_result(100_000, &[(0, 125_000)]);
1520 assert!(matches!(
1521 select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1522 InstantClaimPlan::CreditAboveDeposit {
1523 credit_sats: 125_000
1524 }
1525 ));
1526 }
1527
1528 #[test]
1529 fn declines_when_no_ceiling_is_set() {
1530 let q = quote_result(100_000, &[(0, 99_000)]);
1533 assert!(matches!(
1534 select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1535 InstantClaimPlan::FeeExceeded { .. }
1536 ));
1537 }
1538
1539 #[test]
1540 fn gates_on_the_credit_the_claim_signs() {
1541 let q = quote_result(100_000, &[(3, 90_000), (0, 99_000)]);
1547 assert_eq!(q.quote.credit_amount.original_value, 90_000);
1548 assert!(matches!(
1549 select_instant_claim_plan(&q, 100_000, 2_000, 0, 3),
1550 InstantClaimPlan::FeeExceeded {
1551 quoted_sats: 10_000,
1552 ..
1553 }
1554 ));
1555 }
1556
1557 #[test]
1558 fn skips_when_spread_over_ceiling() {
1559 let q = quote_result(100_000, &[(0, 95_000)]);
1562 assert!(matches!(
1563 select_instant_claim_plan(&q, 100_000, 1_000, 0, 3),
1564 InstantClaimPlan::FeeExceeded {
1565 quoted_sats: 5_000,
1566 quoted_rate: 51
1567 }
1568 ));
1569 }
1570
1571 #[test]
1572 fn rejects_any_spread_at_a_zero_ceiling() {
1573 let q = quote_result(100_000, &[(0, 99_000)]);
1574 assert!(matches!(
1575 select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1576 InstantClaimPlan::FeeExceeded { .. }
1577 ));
1578 }
1579
1580 #[test]
1581 fn accepts_spread_equal_to_ceiling() {
1582 let q = quote_result(100_000, &[(0, 99_000)]);
1584 assert!(matches!(
1585 select_instant_claim_plan(&q, 100_000, 1_000, 0, 3),
1586 InstantClaimPlan::Claimable(_)
1587 ));
1588 }
1589
1590 #[test]
1591 fn one_ceiling_admits_small_declines_large() {
1592 let ceiling = 2_000;
1596 let small = quote_result(20_000, &[(0, 18_810)]);
1598 assert!(matches!(
1599 select_instant_claim_plan(&small, 20_000, ceiling, 0, 3),
1600 InstantClaimPlan::Claimable(_)
1601 ));
1602 let large = quote_result(1_000_000, &[(0, 993_910)]);
1604 assert!(matches!(
1605 select_instant_claim_plan(&large, 1_000_000, ceiling, 0, 3),
1606 InstantClaimPlan::FeeExceeded { .. }
1607 ));
1608 }
1609
1610 #[test]
1611 fn treats_ssp_depth_rejections_as_retryable() {
1612 assert!(is_pending_confirmation_error(
1615 "graphql error: UTXO does not have enough confirmations. Required: 1, got: 0"
1616 ));
1617 assert!(is_pending_confirmation_error(
1618 "graphql error: UTXO needs 1 confirmations on every Spark operator before it \
1619 can be claimed. Some operators have not seen it that deep yet. Retry in a \
1620 few seconds."
1621 ));
1622 assert!(is_pending_confirmation_error(
1624 "deposit tx doesn't have enough confirmations: confirmation height: 100 \
1625 current block height: 100"
1626 ));
1627 assert!(!is_pending_confirmation_error(
1629 "graphql error: Something went wrong."
1630 ));
1631 }
1632
1633 #[test]
1634 fn prices_spread_off_passed_deposit_not_quote() {
1635 let q = quote_result(100_000, &[(0, 49_500)]);
1641 assert!(matches!(
1642 select_instant_claim_plan(&q, 50_000, 1_000, 0, 3),
1643 InstantClaimPlan::Claimable(_)
1644 ));
1645 }
1646
1647 fn outpoint(vout: u32) -> TxOutput {
1648 TxOutput {
1649 txid: "tx".to_string(),
1650 vout,
1651 }
1652 }
1653
1654 fn refund_paying_out(out_sats: u64) -> Transaction {
1655 Transaction {
1656 version: Version::non_standard(3),
1657 lock_time: LockTime::ZERO,
1658 input: vec![TxIn {
1659 previous_output: OutPoint::null(),
1660 ..Default::default()
1661 }],
1662 output: vec![TxOut {
1663 value: Amount::from_sat(out_sats),
1664 script_pubkey: ScriptBuf::new(),
1665 }],
1666 }
1667 }
1668
1669 #[test]
1670 fn a_second_attempt_on_the_same_outpoint_is_refused() {
1671 let guards = ClaimGuards::default();
1672 let first = guards.try_acquire(outpoint(0));
1673 assert!(first.is_some());
1674 assert!(guards.try_acquire(outpoint(0)).is_none());
1675 drop(first);
1676 assert!(guards.try_acquire(outpoint(0)).is_some());
1677 }
1678
1679 #[test]
1680 fn different_outpoints_do_not_block_each_other() {
1681 let guards = ClaimGuards::default();
1682 let _first = guards.try_acquire(outpoint(0));
1683 assert!(guards.try_acquire(outpoint(1)).is_some());
1684 }
1685
1686 #[test]
1687 fn fee_is_what_the_refund_leaves_behind() {
1688 assert_eq!(
1689 refund_fee_sats(&refund_paying_out(99_889), 100_000),
1690 Some(111)
1691 );
1692 assert_eq!(
1693 refund_fee_sats(&refund_paying_out(100_000), 100_000),
1694 Some(0)
1695 );
1696 assert_eq!(refund_fee_sats(&refund_paying_out(100_001), 100_000), None);
1698 }
1699
1700 #[test]
1701 fn replacement_must_cover_the_pending_fee_and_its_own_relay() {
1702 let pending = PendingRefund {
1705 fee_sats: 111,
1706 vsize: 111,
1707 };
1708 assert_eq!(replacement_min_fee_sats(&pending, 111), 222);
1709 let free = PendingRefund {
1710 fee_sats: 0,
1711 vsize: 111,
1712 };
1713 assert_eq!(replacement_min_fee_sats(&free, 111), 111);
1714 }
1715
1716 #[test]
1717 fn a_larger_replacement_must_beat_the_pending_feerate_too() {
1718 let pending = PendingRefund {
1722 fee_sats: 3_000,
1723 vsize: 99,
1724 };
1725 let required = replacement_min_fee_sats(&pending, 111);
1726 assert_eq!(required, 3_364);
1727 assert!(
1728 required * pending.vsize > pending.fee_sats * 111,
1729 "a replacement has to beat the pending feerate outright"
1730 );
1731
1732 assert_eq!(replacement_min_fee_sats(&pending, 99), 3_099);
1734
1735 let tie = PendingRefund {
1738 fee_sats: 932,
1739 vsize: 99,
1740 };
1741 assert_eq!(replacement_min_fee_sats(&tie, 111), 1_046);
1742
1743 let small = PendingRefund {
1745 fee_sats: 300,
1746 vsize: 99,
1747 };
1748 assert_eq!(replacement_min_fee_sats(&small, 111), 411);
1749 }
1750
1751 #[test]
1752 fn replacement_is_rejected_until_it_outbids_the_pending_refund() {
1753 let deposit = 100_000u64;
1754 let pending = 500u64;
1755 let vsize = refund_paying_out(0).vsize() as u64;
1756 let required = replacement_min_fee_sats(
1757 &PendingRefund {
1758 fee_sats: pending,
1759 vsize,
1760 },
1761 vsize,
1762 );
1763
1764 assert!(check_replacement_fee(&refund_paying_out(deposit - 1), deposit, None).is_ok());
1766
1767 let pending_refund = PendingRefund {
1768 fee_sats: pending,
1769 vsize,
1770 };
1771
1772 let short = refund_paying_out(deposit - required + 1);
1775 assert!(refund_fee_sats(&short, deposit).unwrap() > pending);
1776 assert!(matches!(
1777 check_replacement_fee(&short, deposit, Some(pending_refund)),
1778 Err(SdkError::RefundReplacementFeeTooLow {
1779 pending_fee_sats,
1780 required_fee_sats,
1781 }) if pending_fee_sats == pending && required_fee_sats == required
1782 ));
1783
1784 let exact = refund_paying_out(deposit - required);
1786 assert!(check_replacement_fee(&exact, deposit, Some(pending_refund)).is_ok());
1787 }
1788
1789 #[test]
1790 fn an_already_claimed_deposit_is_not_a_claim_failure() {
1791 assert!(is_already_claimed_error(
1794 "SparkSdkError: Service error: service provider error: graphql error: \
1795 Static deposit has already been claimed, transaction_id: abc, output_index: 0, \
1796 transfer_id: 01a0"
1797 ));
1798 assert!(!is_already_claimed_error(
1800 "deposit does not have enough confirmations"
1801 ));
1802 assert!(!is_already_claimed_error("insufficient funds"));
1803 }
1804}