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 SparkWalletError, 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::{FeeMarket, Outspend},
27 error::SdkError,
28 models::Payment,
29 persist::UpdateDepositPayload,
30 sdk::RuntimeEvent,
31 utils::deposit_chain_syncer::TxOutput,
32 utils::replacement::{Replaced, fee_paid_sats},
33 utils::utxo_fetcher::{CachedUtxoFetcher, DetailedUtxo},
34};
35
36use super::{BreezSdk, CLAIM_TX_SIZE_VBYTES};
37
38fn maturity_confirmations(network: Network) -> u32 {
41 match network {
42 Network::Regtest => 1,
43 Network::Mainnet | Network::Signet => 3,
44 }
45}
46
47const CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS: u32 = 3;
50const CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS: u64 = 500;
51
52#[macros::instance_span]
53#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
54#[allow(clippy::needless_pass_by_value)]
55impl BreezSdk {
56 pub async fn claim_deposit(
57 &self,
58 request: ClaimDepositRequest,
59 ) -> Result<ClaimDepositResponse, SdkError> {
60 self.maybe_ensure_spark_private_mode_initialized().await?;
61 let detailed_utxo =
62 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
63 .fetch_detailed_utxo(&request.txid, request.vout)
64 .await?;
65
66 let ceiling =
67 resolve_claim_ceiling(request.max_fee, self.config.max_deposit_claim_fee.clone());
68
69 let txid = detailed_utxo.txid.to_string();
75 let Some(_claim_guard) = self.claim_guards.try_acquire(TxOutput {
76 txid: txid.clone(),
77 vout: detailed_utxo.vout,
78 }) else {
79 return Err(SdkError::deposit_claim_in_progress(
80 txid,
81 detailed_utxo.vout,
82 ));
83 };
84
85 let max_fee = ceiling.effective;
86
87 let confirmations = self
89 .deposit_confirmations(&request.txid)
90 .await
91 .unwrap_or_else(|e| {
92 warn!(
93 "Could not read the chain depth for {}:{}: {e}",
94 request.txid, request.vout
95 );
96 u32::MAX
97 });
98 let stored = self.stored_deposit(&txid, detailed_utxo.vout).await?;
99 let is_mature = self.is_deposit_mature_at(&detailed_utxo, confirmations, stored.as_ref());
100 if !is_mature
104 && claim_already_made(
105 stored
106 .as_ref()
107 .and_then(|d| d.instant_claim_status.as_ref()),
108 )
109 {
110 return Err(SdkError::deposit_claim_in_progress(
111 txid,
112 detailed_utxo.vout,
113 ));
114 }
115 let row_exists = self
116 .store_max_claim_fee(&detailed_utxo, ceiling.stored, is_mature, stored.is_some())
117 .await?;
118 if !is_mature {
120 return self
121 .instant_claim_deposit(&detailed_utxo, max_fee, confirmations, row_exists)
122 .await;
123 }
124
125 self.claim_mature_deposit(&detailed_utxo, max_fee).await
126 }
127
128 pub async fn fetch_claim_deposit_quote(
138 &self,
139 request: FetchClaimDepositQuoteRequest,
140 ) -> Result<FetchClaimDepositQuoteResponse, SdkError> {
141 let detailed_utxo =
142 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
143 .fetch_detailed_utxo(&request.txid, request.vout)
144 .await?;
145
146 let fee_market = FeeMarket::new(self.chain_service.as_ref());
147 let (confirmations, instant, mature) = tokio::join!(
148 self.deposit_confirmations(&request.txid),
149 self.fetch_instant_claim_quote(&detailed_utxo),
150 self.fetch_mature_claim_quote(&detailed_utxo, &fee_market),
151 );
152 let confirmations = confirmations?;
153 let mature = mature?;
154 let stored = self
155 .stored_deposit(&detailed_utxo.txid.to_string(), detailed_utxo.vout)
156 .await?;
157 let is_mature = self.is_deposit_mature_at(&detailed_utxo, confirmations, stored.as_ref());
158
159 let deposit_sats = detailed_utxo.value;
163 let stored_max_fee = stored.as_ref().and_then(|d| d.max_claim_fee.as_ref());
164 let instant_max_fee = stored_max_fee
165 .or(self.config.max_deposit_claim_fee.as_ref())
166 .cloned();
167 let mature_max_fee = self
168 .mature_claim_ceiling(stored_max_fee, deposit_sats, &fee_market)
169 .await;
170 let instant_max_fee_sats = self
171 .resolve_max_claim_fee(instant_max_fee, &fee_market)
172 .await?
173 .map(|max_fee| max_fee.ceiling_sats(deposit_sats));
174 let mature = ClaimDepositQuote {
175 max_fee_sats: self
176 .resolve_max_claim_fee(mature_max_fee, &fee_market)
177 .await?
178 .map(|max_fee| max_fee.ceiling_sats(deposit_sats)),
179 ..mature
180 };
181 let instant = instant.map(|quote| ClaimDepositQuote {
182 max_fee_sats: instant_max_fee_sats,
183 ..quote
184 });
185
186 let instant = instant.filter(|quote| {
189 let earlier = quote.confirmations_required < mature.confirmations_required;
190 if is_mature || !earlier {
191 info!(
192 "Withholding the early claim for {}:{}: {}",
193 request.txid,
194 request.vout,
195 if is_mature {
196 "the deposit has already matured".to_string()
197 } else {
198 format!(
199 "it credits at {} confirmations, no sooner than maturity at {}",
200 quote.confirmations_required, mature.confirmations_required
201 )
202 }
203 );
204 }
205 !is_mature && earlier
206 });
207
208 Ok(FetchClaimDepositQuoteResponse {
209 amount_sats: detailed_utxo.value,
210 confirmations,
211 instant,
212 mature,
213 })
214 }
215
216 pub async fn refund_deposit(
217 &self,
218 request: RefundDepositRequest,
219 ) -> Result<RefundDepositResponse, SdkError> {
220 let detailed_utxo =
221 CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
222 .fetch_detailed_utxo(&request.txid, request.vout)
223 .await?;
224
225 let existing = self
226 .storage
227 .list_deposits()
228 .await?
229 .into_iter()
230 .find(|d| d.txid == detailed_utxo.txid.to_string() && d.vout == detailed_utxo.vout);
231 let fee_to_outbid = match &existing {
233 Some(deposit) => self.refund_fee_to_outbid(&detailed_utxo, deposit).await?,
234 None => None,
235 };
236
237 let tx = self
238 .spark_wallet
239 .refund_static_deposit(
240 detailed_utxo.clone().tx,
241 Some(detailed_utxo.vout),
242 &request.destination_address,
243 request.fee.into(),
244 )
245 .await?;
246 let tx_hex = serialize(&tx).as_hex().to_string();
247 let tx_id = tx.compute_txid().as_raw_hash().to_string();
248
249 check_replacement_fee(&tx, detailed_utxo.value, fee_to_outbid)?;
250
251 if existing.is_none() {
257 self.storage
260 .add_deposit(
261 detailed_utxo.txid.to_string(),
262 detailed_utxo.vout,
263 detailed_utxo.value,
264 true,
265 )
266 .await?;
267 }
268
269 self.store_refund(
272 &detailed_utxo,
273 &tx_hex,
274 &tx_id,
275 RefundState::BroadcastPending { last_error: None },
276 )
277 .await?;
278
279 let broadcast_error = self
280 .chain_service
281 .broadcast_transaction(tx_hex.clone())
282 .await
283 .err();
284 let state = match &broadcast_error {
287 None => RefundState::Broadcast,
288 Some(e) => RefundState::BroadcastPending {
289 last_error: Some(e.to_string()),
290 },
291 };
292
293 if let Err(e) = self
294 .store_refund(&detailed_utxo, &tx_hex, &tx_id, state)
295 .await
296 {
297 error!("Failed to record refund state: {e:?}");
298 }
299
300 if let Some(e) = broadcast_error {
301 return Err(e.into());
302 }
303 Ok(RefundDepositResponse { tx_id, tx_hex })
304 }
305
306 #[allow(unused_variables)]
307 pub async fn list_unclaimed_deposits(
308 &self,
309 request: ListUnclaimedDepositsRequest,
310 ) -> Result<ListUnclaimedDepositsResponse, SdkError> {
311 let deposits = self.storage.list_deposits().await?;
312 Ok(ListUnclaimedDepositsResponse { deposits })
313 }
314}
315
316impl BreezSdk {
317 async fn claim_mature_deposit(
320 &self,
321 detailed_utxo: &DetailedUtxo,
322 max_fee: Option<MaxFee>,
323 ) -> Result<ClaimDepositResponse, SdkError> {
324 let fee_market = FeeMarket::new(self.chain_service.as_ref());
325 match self.claim_utxo(detailed_utxo, max_fee, &fee_market).await {
326 Ok(transfer_id) => {
327 let transfer = self.lookup_claim_transfer_with_retry(transfer_id).await?;
328 let payment: Payment = transfer.try_into()?;
329 let should_emit_event = self.storage.apply_payment_update(payment.clone()).await?;
332 self.storage
333 .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
334 .await?;
335 self.event_emitter
336 .emit_runtime_event(RuntimeEvent::DepositClaimed {
337 payment: Box::new(payment.clone()),
338 should_emit_event,
339 })
340 .await;
341 Ok(ClaimDepositResponse {
342 outcome: ClaimDepositOutcome::Settled { payment },
343 })
344 }
345 Err(e) if is_already_claimed_error(&e.to_string()) => {
349 info!(
350 "Deposit {}:{} was already claimed, marking it",
351 detailed_utxo.txid, detailed_utxo.vout
352 );
353 self.storage
354 .update_deposit(
355 detailed_utxo.txid.to_string(),
356 detailed_utxo.vout,
357 UpdateDepositPayload::InstantClaim {
358 status: InstantClaimStatus::Claimed,
359 },
360 )
361 .await?;
362 Err(e)
363 }
364 Err(e) => {
365 error!("Failed to claim deposit: {e:?}");
366 self.storage
367 .update_deposit(
368 detailed_utxo.txid.to_string(),
369 detailed_utxo.vout,
370 UpdateDepositPayload::ClaimError {
371 error: e.clone().into(),
372 },
373 )
374 .await?;
375 Err(e)
376 }
377 }
378 }
379
380 async fn store_max_claim_fee(
390 &self,
391 detailed_utxo: &DetailedUtxo,
392 max_fee: Option<MaxFee>,
393 is_mature: bool,
394 row_exists: bool,
395 ) -> Result<bool, SdkError> {
396 let txid = detailed_utxo.txid.to_string();
397 if let Some(max_fee) = &max_fee {
398 info!(
399 "Recording max claim fee {max_fee:?} on deposit {txid}:{}",
400 detailed_utxo.vout
401 );
402 } else {
403 debug!(
406 "Clearing any max claim fee on deposit {txid}:{}, \
407 which returns it to the configured one",
408 detailed_utxo.vout
409 );
410 }
411 let inserted = max_fee.is_some() && !row_exists;
412 if inserted {
413 self.storage
414 .add_deposit(
415 txid.clone(),
416 detailed_utxo.vout,
417 detailed_utxo.value,
418 is_mature,
419 )
420 .await?;
421 }
422 self.storage
423 .update_deposit(
424 txid,
425 detailed_utxo.vout,
426 UpdateDepositPayload::MaxClaimFee { max_fee },
427 )
428 .await?;
429 Ok(row_exists || inserted)
430 }
431
432 async fn lookup_claim_transfer_with_retry(
439 &self,
440 transfer_id: String,
441 ) -> Result<WalletTransfer, SdkError> {
442 let parsed_id = TransferId::from_str(&transfer_id).map_err(SdkError::Generic)?;
443 let mut last_error: Option<SdkError> = None;
444
445 for attempt in 0..CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS {
446 if attempt > 0 {
447 let delay_ms = CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS
448 .saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
449 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
450 trace!(
451 "Retrying claim transfer lookup (attempt {}/{}) for transfer {transfer_id}",
452 attempt.saturating_add(1),
453 CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS
454 );
455 }
456
457 match self
458 .spark_wallet
459 .list_transfers(ListTransfersRequest {
460 transfer_ids: vec![parsed_id.clone()],
461 paging: None,
462 })
463 .await
464 {
465 Ok(mut resp) => {
466 if let Some(transfer) = resp.items.pop() {
467 return Ok(transfer);
468 }
469 last_error = None;
470 }
471 Err(e) => last_error = Some(e.into()),
472 }
473 }
474
475 Err(last_error
476 .unwrap_or_else(|| SdkError::Generic("transfer not found after claim".to_string())))
477 }
478
479 async fn store_refund(
480 &self,
481 detailed_utxo: &DetailedUtxo,
482 tx_hex: &str,
483 tx_id: &str,
484 state: RefundState,
485 ) -> Result<(), SdkError> {
486 self.storage
487 .update_deposit(
488 detailed_utxo.txid.to_string(),
489 detailed_utxo.vout,
490 UpdateDepositPayload::Refund {
491 refund_tx: tx_hex.to_string(),
492 refund_txid: tx_id.to_string(),
493 state,
494 },
495 )
496 .await?;
497 Ok(())
498 }
499
500 async fn refund_fee_to_outbid(
505 &self,
506 detailed_utxo: &DetailedUtxo,
507 deposit: &DepositInfo,
508 ) -> Result<Option<Replaced>, SdkError> {
509 let Some(refund_tx) = deposit.refund_tx.as_ref() else {
510 return Ok(None);
511 };
512 let Ok(tx) = deserialize_hex::<Transaction>(refund_tx) else {
514 warn!(
515 "Stored refund of deposit {}:{} does not decode, not requiring a fee bump",
516 detailed_utxo.txid, detailed_utxo.vout
517 );
518 return Ok(None);
519 };
520 let stored = Replaced::spending(&tx, detailed_utxo.value);
521
522 let outspend = self
523 .chain_service
524 .get_outspend(detailed_utxo.txid.to_string(), detailed_utxo.vout)
525 .await;
526 match outspend {
527 Ok(Outspend::Unspent) => Ok(None),
528 Ok(Outspend::Spent { txid, status, .. }) if status.confirmed => {
529 Err(SdkError::InvalidInput(format!(
530 "Deposit {}:{} was already spent by {txid}",
531 detailed_utxo.txid, detailed_utxo.vout
532 )))
533 }
534 Ok(Outspend::Spent { .. }) => Ok(stored),
539 Err(_) => {
545 let Some(refund_txid) = deposit.refund_tx_id.clone() else {
546 return Ok(None);
547 };
548 match self.chain_service.get_transaction_status(refund_txid).await {
549 Ok(status) if status.confirmed => Err(SdkError::InvalidInput(format!(
550 "Deposit {}:{} was already refunded",
551 detailed_utxo.txid, detailed_utxo.vout
552 ))),
553 Ok(_) => Ok(stored),
554 Err(_) => Ok(None),
555 }
556 }
557 }
558 }
559
560 pub(super) async fn deposit_confirmations(&self, txid: &str) -> Result<u32, SdkError> {
564 self.deposit_confirmations_at_tip(txid, None).await
565 }
566
567 pub(super) async fn deposit_confirmations_at_tip(
570 &self,
571 txid: &str,
572 tip_height: Option<u32>,
573 ) -> Result<u32, SdkError> {
574 let status = self
575 .chain_service
576 .get_transaction_status(txid.to_string())
577 .await?;
578 if !status.confirmed {
580 return Ok(0);
581 }
582 let Some(block_height) = status.block_height else {
584 return Ok(1);
585 };
586 let tip_height = match tip_height {
587 Some(tip) => tip,
588 None => self.chain_service.tip_height().await?,
589 };
590 Ok(tip_height.saturating_sub(block_height).saturating_add(1))
591 }
592
593 async fn fetch_instant_claim_quote(
598 &self,
599 detailed_utxo: &DetailedUtxo,
600 ) -> Option<ClaimDepositQuote> {
601 let quote_result = self
602 .spark_wallet
603 .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
604 .await
605 .inspect_err(|e| {
606 info!(
607 "No instant quote for {}:{}: {e}",
608 detailed_utxo.txid, detailed_utxo.vout
609 );
610 })
611 .ok()?;
612 let plan = quote_result
613 .fulfillment_plans
614 .iter()
615 .min_by_key(|p| p.confirmations)?;
616 info!(
618 "Early claim quoted for {}:{} ({} sats): credits {} at {} confirmations",
619 detailed_utxo.txid,
620 detailed_utxo.vout,
621 detailed_utxo.value,
622 quote_result.quote.credit_amount.original_value,
623 plan.confirmations
624 );
625 Some(claim_deposit_quote(
626 u32::try_from(plan.confirmations.unsigned_abs()).unwrap_or(u32::MAX),
627 detailed_utxo.value,
628 quote_result.quote.credit_amount.original_value,
629 false,
630 ))
631 }
632
633 async fn fetch_mature_claim_quote(
637 &self,
638 detailed_utxo: &DetailedUtxo,
639 fee_market: &FeeMarket<'_>,
640 ) -> Result<ClaimDepositQuote, SdkError> {
641 match self
642 .spark_wallet
643 .fetch_static_deposit_claim_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
644 .await
645 {
646 Ok(quote) => Ok(claim_deposit_quote(
647 maturity_confirmations(self.config.network),
648 detailed_utxo.value,
649 quote.credit_amount_sats,
650 false,
651 )),
652 Err(e) if e.is_deposit_below_dust_limit() => {
653 info!(
654 "Deposit {}:{} is too small to claim: {e}",
655 detailed_utxo.txid, detailed_utxo.vout
656 );
657 Err(mature_quote_error(e, detailed_utxo))
658 }
659 Err(e) => {
660 info!(
661 "No mature quote for {}:{}, estimating: {e}",
662 detailed_utxo.txid, detailed_utxo.vout
663 );
664 let fee_sats = fee_market
665 .recommended_fees()
666 .await?
667 .fastest_fee
668 .saturating_mul(CLAIM_TX_SIZE_VBYTES);
669 Ok(claim_deposit_quote(
670 maturity_confirmations(self.config.network),
671 detailed_utxo.value,
672 detailed_utxo.value.saturating_sub(fee_sats),
673 true,
674 ))
675 }
676 }
677 }
678
679 fn is_deposit_mature_at(
685 &self,
686 detailed_utxo: &DetailedUtxo,
687 confirmations: u32,
688 stored: Option<&DepositInfo>,
689 ) -> bool {
690 let stored_mature = stored.is_some_and(|d| d.is_mature);
691 let required = maturity_confirmations(self.config.network);
692 let is_mature = stored_mature || confirmations >= required;
693 info!(
694 "Deposit {}:{} is {} (operators: {}, chain: {}/{} confirmations)",
695 detailed_utxo.txid,
696 detailed_utxo.vout,
697 if is_mature {
698 "mature"
699 } else {
700 "not yet mature"
701 },
702 if stored_mature {
703 "mature"
704 } else {
705 "not mature"
706 },
707 confirmations,
708 required
709 );
710 is_mature
711 }
712
713 pub(super) async fn stored_deposit(
716 &self,
717 txid: &str,
718 vout: u32,
719 ) -> Result<Option<DepositInfo>, SdkError> {
720 Ok(self
721 .storage
722 .list_deposits()
723 .await?
724 .into_iter()
725 .find(|d| d.txid == txid && d.vout == vout))
726 }
727
728 async fn instant_claim_deposit(
731 &self,
732 detailed_utxo: &DetailedUtxo,
733 max_fee: Option<MaxFee>,
734 confirmations: u32,
735 row_exists: bool,
736 ) -> Result<ClaimDepositResponse, SdkError> {
737 let fee_market = FeeMarket::new(self.chain_service.as_ref());
738 let resolved_max_fee = self.resolve_max_claim_fee(max_fee, &fee_market).await?;
739 let outcome = match self
740 .instant_claim_utxo(detailed_utxo, resolved_max_fee, confirmations)
741 .await
742 {
743 Ok(outcome) => outcome,
744 Err(e) => {
746 error!("Instant claim transient error: {e:?}");
747 return Err(e);
748 }
749 };
750
751 if !row_exists {
757 self.storage
758 .add_deposit(
759 detailed_utxo.txid.to_string(),
760 detailed_utxo.vout,
761 detailed_utxo.value,
762 false,
763 )
764 .await?;
765 }
766 self.storage
767 .update_deposit(
768 detailed_utxo.txid.to_string(),
769 detailed_utxo.vout,
770 UpdateDepositPayload::InstantClaim {
771 status: outcome.status(confirmations),
772 },
773 )
774 .await?;
775
776 match &outcome {
777 InstantClaimOutcome::Submitted(claim_id) => info!(
778 "Instant claimed utxo {}:{} with claim_id: {claim_id}",
779 detailed_utxo.txid, detailed_utxo.vout
780 ),
781 InstantClaimOutcome::Declined { error, .. } => {
782 info!("Instant claim declined: {error}");
783 }
784 }
785 Ok(instant_claim_response(&outcome))
786 }
787
788 pub(super) async fn instant_claim_utxo(
796 &self,
797 detailed_utxo: &DetailedUtxo,
798 resolved_max_fee: Option<ResolvedMaxFee>,
799 confirmations: u32,
800 ) -> Result<InstantClaimOutcome, SdkError> {
801 let max_fee_sats = resolved_max_fee
804 .as_ref()
805 .map_or(0, |max_fee| max_fee.ceiling_sats(detailed_utxo.value));
806
807 let quote_result = match self
808 .spark_wallet
809 .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
810 .await
811 {
812 Ok(quote_result) => quote_result,
813 Err(e) if e.is_deposit_below_dust_limit() => {
816 return Ok(InstantClaimOutcome::Declined {
817 error: e.into(),
818 max_fee_sats: None,
819 reason: ClaimDeferredReason::NoEarlyClaimAvailable,
820 });
821 }
822 Err(e) => return Err(e.into()),
823 };
824 info!(
825 "Instant quote for {}:{} ({} sats, ceiling {} sats)",
826 detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value, max_fee_sats
827 );
828 debug!("Instant quote: {quote_result:?}");
829 match select_instant_claim_plan(
832 "e_result,
833 detailed_utxo.value,
834 max_fee_sats,
835 confirmations,
836 maturity_confirmations(self.config.network),
837 ) {
838 InstantClaimPlan::Claimable(plan) => {
839 match self
840 .spark_wallet
841 .claim_instant_static_deposit(
842 detailed_utxo.tx.clone(),
843 quote_result.quote,
844 plan,
845 )
846 .await
847 {
848 Ok(claim_id) => Ok(InstantClaimOutcome::Submitted(claim_id)),
849 Err(e) if is_pending_confirmation_error(&e.to_string()) => Err(e.into()),
852 Err(e) => {
854 let error: SdkError = e.into();
855 Ok(InstantClaimOutcome::Declined {
856 reason: ClaimDeferredReason::ProviderDeclined {
857 message: error.to_string(),
858 },
859 error,
860 max_fee_sats: None,
861 })
862 }
863 }
864 }
865 InstantClaimPlan::NoPlan => Ok(InstantClaimOutcome::Declined {
866 error: SdkError::Generic("No instant claim plan available".to_string()),
867 max_fee_sats: None,
868 reason: ClaimDeferredReason::NoEarlyClaimAvailable,
869 }),
870 InstantClaimPlan::CreditAboveDeposit { credit_sats } => {
873 let message = format!(
874 "Instant quote credits {credit_sats} sats for {}:{}, which is worth {} sats",
875 detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value
876 );
877 Ok(InstantClaimOutcome::Declined {
878 error: SdkError::Generic(message.clone()),
879 max_fee_sats: None,
880 reason: ClaimDeferredReason::ProviderDeclined { message },
881 })
882 }
883 InstantClaimPlan::FeeExceeded { quoted_sats } => Ok(InstantClaimOutcome::Declined {
884 error: SdkError::max_deposit_claim_fee_exceeded(
885 detailed_utxo.txid.to_string(),
886 detailed_utxo.vout,
887 resolved_max_fee.as_ref().map(|_| max_fee_sats),
888 quoted_sats,
889 ),
890 max_fee_sats: Some(max_fee_sats),
891 reason: ClaimDeferredReason::MaxFeeExceeded {
892 required_fee_sats: quoted_sats,
893 max_fee_sats,
894 },
895 }),
896 }
897 }
898}
899
900pub(super) enum InstantClaimOutcome {
902 Submitted(String),
904 Declined {
908 error: SdkError,
909 max_fee_sats: Option<u64>,
910 reason: ClaimDeferredReason,
911 },
912}
913
914impl InstantClaimOutcome {
915 pub(super) fn status(&self, confirmations: u32) -> InstantClaimStatus {
917 match self {
918 InstantClaimOutcome::Submitted(claim_id) => InstantClaimStatus::Submitted {
919 claim_id: claim_id.clone(),
920 },
921 InstantClaimOutcome::Declined { max_fee_sats, .. } => InstantClaimStatus::Declined {
922 max_fee_sats: *max_fee_sats,
923 confirmations,
924 },
925 }
926 }
927}
928
929pub(super) fn is_already_claimed_error(message: &str) -> bool {
934 message.to_lowercase().contains("already been claimed")
935}
936
937const PENDING_CONFIRMATION_MARKERS: [&str; 2] =
943 ["enough confirmations", "operators have not seen it"];
944
945fn is_pending_confirmation_error(message: &str) -> bool {
950 let message = message.to_lowercase();
951 PENDING_CONFIRMATION_MARKERS
952 .iter()
953 .any(|marker| message.contains(marker))
954}
955
956pub(super) struct ClaimCeiling {
958 pub stored: Option<MaxFee>,
960 pub effective: Option<MaxFee>,
962}
963
964pub(super) fn resolve_claim_ceiling(
968 requested: Option<MaxFee>,
969 config_default: Option<MaxFee>,
970) -> ClaimCeiling {
971 ClaimCeiling {
972 stored: requested.clone(),
973 effective: requested.or(config_default),
974 }
975}
976
977pub(super) fn larger_ceiling(
988 stored: &MaxFee,
989 stored_sats: u64,
990 config_default: &MaxFee,
991 config_sats: u64,
992) -> MaxFee {
993 if stored_sats >= config_sats {
994 stored.clone()
995 } else {
996 config_default.clone()
997 }
998}
999
1000pub(super) fn instant_claim_response(outcome: &InstantClaimOutcome) -> ClaimDepositResponse {
1003 let outcome = match outcome {
1004 InstantClaimOutcome::Submitted(_) => ClaimDepositOutcome::Submitted,
1005 InstantClaimOutcome::Declined { reason, .. } => ClaimDepositOutcome::Deferred {
1006 reason: reason.clone(),
1007 },
1008 };
1009 ClaimDepositResponse { outcome }
1010}
1011
1012pub(super) fn mature_quote_error(e: SparkWalletError, detailed_utxo: &DetailedUtxo) -> SdkError {
1014 if e.is_deposit_below_dust_limit() {
1015 return SdkError::deposit_too_small(detailed_utxo.txid.to_string(), detailed_utxo.vout);
1016 }
1017 e.into()
1018}
1019
1020pub(super) fn claim_already_made(status: Option<&InstantClaimStatus>) -> bool {
1024 matches!(
1025 status,
1026 Some(InstantClaimStatus::Submitted { .. } | InstantClaimStatus::Claimed)
1027 )
1028}
1029
1030pub(super) fn needs_own_ceiling_resolution(
1034 stored: Option<&MaxFee>,
1035 config_default: Option<&MaxFee>,
1036) -> bool {
1037 stored.is_some() && stored != config_default
1038}
1039
1040#[derive(Debug, Clone)]
1044pub(super) struct ResolvedMaxFee {
1045 onchain: Fee,
1046 proportional_ppm: u32,
1047}
1048
1049impl ResolvedMaxFee {
1050 pub(super) fn new(onchain: Fee, proportional_ppm: u32) -> Self {
1051 Self {
1052 onchain,
1053 proportional_ppm,
1054 }
1055 }
1056
1057 pub(super) fn ceiling_sats(&self, deposit_sats: u64) -> u64 {
1060 let proportional =
1061 u128::from(deposit_sats).saturating_mul(u128::from(self.proportional_ppm)) / 1_000_000;
1062 self.onchain
1063 .to_sats(CLAIM_TX_SIZE_VBYTES)
1064 .saturating_add(u64::try_from(proportional).unwrap_or(u64::MAX))
1065 }
1066}
1067
1068fn claim_deposit_quote(
1070 confirmations_required: u32,
1071 deposit_sats: u64,
1072 credit_amount_sats: u64,
1073 is_estimate: bool,
1074) -> ClaimDepositQuote {
1075 let fee_sats = deposit_sats.saturating_sub(credit_amount_sats);
1076 ClaimDepositQuote {
1077 confirmations_required,
1078 credit_amount_sats,
1079 fee_sats,
1080 max_fee_sats: None,
1081 is_estimate,
1082 }
1083}
1084
1085enum InstantClaimPlan {
1087 Claimable(InstantStaticDepositPlan),
1089 NoPlan,
1091 FeeExceeded { quoted_sats: u64 },
1094 CreditAboveDeposit { credit_sats: u64 },
1097}
1098
1099fn select_instant_claim_plan(
1105 quote_result: &InstantStaticDepositQuoteResult,
1106 deposit_sats: u64,
1107 max_fee_sats: u64,
1108 confirmations: u32,
1109 maturity_confirmations: u32,
1110) -> InstantClaimPlan {
1111 let Some(plan) = quote_result
1112 .fulfillment_plans
1113 .iter()
1114 .min_by_key(|p| p.confirmations)
1115 else {
1116 return InstantClaimPlan::NoPlan;
1117 };
1118 if u64::from(confirmations) >= u64::from(maturity_confirmations) {
1121 return InstantClaimPlan::NoPlan;
1122 }
1123 let plan_confirmations = plan.confirmations.unsigned_abs();
1125 if plan_confirmations >= u64::from(maturity_confirmations) {
1126 return InstantClaimPlan::NoPlan;
1127 }
1128 if plan_confirmations > u64::from(confirmations) {
1131 return InstantClaimPlan::NoPlan;
1132 }
1133 let credit_sats = quote_result.quote.credit_amount.original_value;
1138 let Some(quoted_sats) = deposit_sats.checked_sub(credit_sats) else {
1139 return InstantClaimPlan::CreditAboveDeposit { credit_sats };
1140 };
1141 if quoted_sats <= max_fee_sats {
1142 InstantClaimPlan::Claimable(plan.clone())
1143 } else {
1144 InstantClaimPlan::FeeExceeded { quoted_sats }
1145 }
1146}
1147
1148fn check_replacement_fee(
1153 tx: &Transaction,
1154 deposit_value_sats: u64,
1155 pending: Option<Replaced>,
1156) -> Result<(), SdkError> {
1157 let Some(pending) = pending else {
1158 return Ok(());
1159 };
1160 let pending_fee_sats = pending.fee_sats;
1161 let required_fee_sats =
1162 pending.min_replacement_fee_sats(tx.vsize().try_into().unwrap_or(u64::MAX));
1163 let fee_sats = fee_paid_sats(tx, deposit_value_sats).ok_or_else(|| {
1164 SdkError::Generic("refund pays out more than the deposit holds".to_string())
1165 })?;
1166 if fee_sats < required_fee_sats {
1167 return Err(SdkError::refund_replacement_fee_too_low(
1168 pending_fee_sats,
1169 required_fee_sats,
1170 ));
1171 }
1172 Ok(())
1173}
1174
1175#[derive(Clone, Default)]
1182pub(crate) struct ClaimGuards {
1183 in_flight: Arc<Mutex<HashSet<TxOutput>>>,
1184}
1185
1186impl ClaimGuards {
1187 pub(crate) fn try_acquire(&self, outpoint: TxOutput) -> Option<ClaimGuard> {
1189 let mut in_flight = self
1190 .in_flight
1191 .lock()
1192 .unwrap_or_else(std::sync::PoisonError::into_inner);
1193 if !in_flight.insert(outpoint.clone()) {
1194 return None;
1195 }
1196 Some(ClaimGuard {
1197 guards: self.clone(),
1198 outpoint,
1199 })
1200 }
1201
1202 fn release(&self, outpoint: &TxOutput) {
1203 self.in_flight
1204 .lock()
1205 .unwrap_or_else(std::sync::PoisonError::into_inner)
1206 .remove(outpoint);
1207 }
1208}
1209
1210pub(crate) struct ClaimGuard {
1213 guards: ClaimGuards,
1214 outpoint: TxOutput,
1215}
1216
1217impl Drop for ClaimGuard {
1218 fn drop(&mut self) {
1219 self.guards.release(&self.outpoint);
1220 }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use bitcoin::{
1226 Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, absolute::LockTime,
1227 transaction::Version,
1228 };
1229 use spark_wallet::{
1230 CurrencyAmount, InstantStaticDepositPlan, InstantStaticDepositQuote,
1231 InstantStaticDepositQuoteResult,
1232 };
1233
1234 use crate::MaxOnchainFee;
1235
1236 use super::{
1237 CLAIM_TX_SIZE_VBYTES, ClaimDeferredReason, ClaimDepositOutcome, ClaimGuards, Fee,
1238 InstantClaimOutcome, InstantClaimPlan, MaxFee, Replaced, ResolvedMaxFee, SdkError,
1239 TxOutput, check_replacement_fee, claim_deposit_quote, fee_paid_sats,
1240 instant_claim_response, is_already_claimed_error, is_pending_confirmation_error,
1241 larger_ceiling, needs_own_ceiling_resolution, resolve_claim_ceiling,
1242 select_instant_claim_plan,
1243 };
1244
1245 fn fixed(amount: u64) -> MaxFee {
1248 MaxFee {
1249 onchain: MaxOnchainFee::Fixed { amount },
1250 proportional_ppm: None,
1251 }
1252 }
1253
1254 #[test]
1255 fn a_requested_ceiling_is_used_and_recorded() {
1256 let ceiling = resolve_claim_ceiling(Some(fixed(50_000)), Some(fixed(99)));
1257 assert_eq!(ceiling.effective, Some(fixed(50_000)));
1258 assert_eq!(ceiling.stored, Some(fixed(50_000)));
1261 }
1262
1263 #[test]
1264 fn requesting_no_ceiling_claims_under_config_and_clears_the_stored_one() {
1265 let ceiling = resolve_claim_ceiling(None, Some(fixed(99)));
1266 assert_eq!(ceiling.effective, Some(fixed(99)));
1267 assert_eq!(ceiling.stored, None);
1270 }
1271
1272 #[test]
1273 fn no_ceiling_anywhere_leaves_nothing_to_claim_within() {
1274 let ceiling = resolve_claim_ceiling(None, None);
1275 assert_eq!(ceiling.effective, None);
1276 assert_eq!(ceiling.stored, None);
1277 }
1278
1279 #[test]
1280 fn a_requested_ceiling_replaces_the_configured_one() {
1281 let ceiling = resolve_claim_ceiling(Some(fixed(10)), Some(fixed(99)));
1284 assert_eq!(ceiling.effective, Some(fixed(10)));
1285 }
1286
1287 #[test]
1288 fn a_raised_ceiling_governs_the_claim_at_maturity() {
1289 assert_eq!(
1290 larger_ceiling(&fixed(50_000), 50_000, &fixed(99), 99),
1291 fixed(50_000)
1292 );
1293 }
1294
1295 #[test]
1296 fn a_ceiling_lowered_to_hold_a_deposit_back_does_not_cap_its_mature_claim() {
1297 assert_eq!(larger_ceiling(&fixed(10), 10, &fixed(99), 99), fixed(99));
1301 }
1302
1303 #[test]
1304 fn ceilings_are_compared_as_sats_not_by_shape() {
1305 let rate = MaxFee {
1308 onchain: MaxOnchainFee::Rate { sat_per_vbyte: 5 },
1309 proportional_ppm: None,
1310 };
1311 assert_eq!(larger_ceiling(&rate, 495, &fixed(99), 99), rate);
1312 assert_eq!(larger_ceiling(&fixed(99), 99, &rate, 495), rate);
1313 }
1314
1315 #[test]
1316 fn an_equal_ceiling_keeps_the_deposit_own() {
1317 assert_eq!(larger_ceiling(&fixed(99), 99, &fixed(99), 99), fixed(99));
1319 }
1320
1321 fn declined(reason: ClaimDeferredReason) -> InstantClaimOutcome {
1324 InstantClaimOutcome::Declined {
1325 error: SdkError::Generic("declined".to_string()),
1326 max_fee_sats: None,
1327 reason,
1328 }
1329 }
1330
1331 #[test]
1332 fn a_submitted_early_claim_returns_no_payment_yet() {
1333 let response =
1334 instant_claim_response(&InstantClaimOutcome::Submitted("claim-1".to_string()));
1335 assert!(matches!(response.outcome, ClaimDepositOutcome::Submitted));
1338 }
1339
1340 #[test]
1341 fn a_declined_early_claim_defers_rather_than_failing() {
1342 let response = instant_claim_response(&declined(ClaimDeferredReason::MaxFeeExceeded {
1345 required_fee_sats: 5_000,
1346 max_fee_sats: 1_000,
1347 }));
1348 let ClaimDepositOutcome::Deferred { reason } = response.outcome else {
1349 panic!("a declined early claim must defer, not fail")
1350 };
1351 assert_eq!(
1352 reason,
1353 ClaimDeferredReason::MaxFeeExceeded {
1354 required_fee_sats: 5_000,
1355 max_fee_sats: 1_000,
1356 }
1357 );
1358 }
1359
1360 #[test]
1361 fn a_deposit_too_shallow_for_any_plan_defers_without_a_fee() {
1362 let response =
1363 instant_claim_response(&declined(ClaimDeferredReason::NoEarlyClaimAvailable));
1364 let ClaimDepositOutcome::Deferred { reason } = response.outcome else {
1365 panic!("too shallow for a plan must defer")
1366 };
1367 assert_eq!(reason, ClaimDeferredReason::NoEarlyClaimAvailable);
1368 }
1369
1370 #[test]
1371 fn a_provider_decline_is_not_reported_as_a_depth_to_wait_out() {
1372 let response = instant_claim_response(&declined(ClaimDeferredReason::ProviderDeclined {
1375 message: "transport error".to_string(),
1376 }));
1377 let ClaimDepositOutcome::Deferred { reason } = response.outcome else {
1378 panic!("a provider decline must defer")
1379 };
1380 assert_eq!(
1381 reason,
1382 ClaimDeferredReason::ProviderDeclined {
1383 message: "transport error".to_string()
1384 }
1385 );
1386 }
1387
1388 #[test]
1389 fn only_a_ceiling_differing_from_config_is_resolved_on_its_own() {
1390 let config = fixed(99);
1391 assert!(!needs_own_ceiling_resolution(None, Some(&config)));
1393 assert!(!needs_own_ceiling_resolution(
1396 Some(&fixed(99)),
1397 Some(&config)
1398 ));
1399 assert!(needs_own_ceiling_resolution(
1400 Some(&fixed(50_000)),
1401 Some(&config)
1402 ));
1403 assert!(needs_own_ceiling_resolution(
1406 Some(&fixed(10)),
1407 Some(&config)
1408 ));
1409 assert!(needs_own_ceiling_resolution(Some(&fixed(10)), None));
1411 assert!(!needs_own_ceiling_resolution(None, None));
1412 assert!(!needs_own_ceiling_resolution(
1414 Some(&MaxFee {
1415 onchain: MaxOnchainFee::Rate { sat_per_vbyte: 4 },
1416 proportional_ppm: None,
1417 }),
1418 Some(&MaxFee {
1419 onchain: MaxOnchainFee::Rate { sat_per_vbyte: 4 },
1420 proportional_ppm: None,
1421 })
1422 ));
1423 assert!(needs_own_ceiling_resolution(
1425 Some(&MaxFee {
1426 onchain: MaxOnchainFee::Rate { sat_per_vbyte: 4 },
1427 proportional_ppm: Some(750),
1428 }),
1429 Some(&MaxFee {
1430 onchain: MaxOnchainFee::Rate { sat_per_vbyte: 4 },
1431 proportional_ppm: None,
1432 })
1433 ));
1434 }
1435
1436 #[test]
1439 fn a_proportional_part_scales_the_ceiling_with_the_deposit() {
1440 let max_fee = ResolvedMaxFee::new(Fee::Rate { sat_per_vbyte: 2 }, 750);
1442 let onchain = 2 * CLAIM_TX_SIZE_VBYTES;
1443 assert_eq!(max_fee.ceiling_sats(0), onchain);
1444 assert_eq!(max_fee.ceiling_sats(1_000_000), onchain + 750);
1445 assert_eq!(max_fee.ceiling_sats(100_000_000), onchain + 75_000);
1446 assert_eq!(max_fee.ceiling_sats(999), onchain);
1448 }
1449
1450 #[test]
1451 fn a_ceiling_without_a_proportional_part_ignores_the_deposit() {
1452 let max_fee = ResolvedMaxFee::new(Fee::Fixed { amount: 500 }, 0);
1453 assert_eq!(max_fee.ceiling_sats(1_000), 500);
1454 assert_eq!(max_fee.ceiling_sats(100_000_000), 500);
1455 }
1456
1457 #[test]
1458 fn a_fixed_amount_takes_a_proportional_part_too() {
1459 let max_fee = ResolvedMaxFee::new(Fee::Fixed { amount: 500 }, 1_000);
1460 assert_eq!(max_fee.ceiling_sats(0), 500);
1461 assert_eq!(max_fee.ceiling_sats(1_000_000), 1_500);
1462 }
1463
1464 #[test]
1465 fn a_huge_proportional_part_saturates() {
1466 let max_fee = ResolvedMaxFee::new(Fee::Fixed { amount: 1 }, u32::MAX);
1467 assert_eq!(max_fee.ceiling_sats(u64::MAX), u64::MAX);
1468 }
1469
1470 fn sats(value: u64) -> CurrencyAmount {
1471 CurrencyAmount {
1472 original_value: value,
1473 ..Default::default()
1474 }
1475 }
1476
1477 fn quote_result(deposit_sats: u64, plans: &[(i64, u64)]) -> InstantStaticDepositQuoteResult {
1481 InstantStaticDepositQuoteResult {
1482 quote: InstantStaticDepositQuote {
1483 id: "quote-id".to_string(),
1484 transaction_id: "tx".to_string(),
1485 output_index: 0,
1486 deposit_amount: sats(deposit_sats),
1487 credit_amount: sats(plans.first().map_or(0, |(_, credit)| *credit)),
1488 quote_signature: "00".to_string(),
1489 },
1490 fulfillment_plans: plans
1491 .iter()
1492 .enumerate()
1493 .map(
1494 |(i, (confirmations, credit_sats))| InstantStaticDepositPlan {
1495 id: format!("plan-{i}"),
1496 amount: sats(*credit_sats),
1497 confirmations: *confirmations,
1498 },
1499 )
1500 .collect(),
1501 }
1502 }
1503
1504 #[test]
1505 fn quotes_the_fee_as_the_credit_shortfall() {
1506 let quote = claim_deposit_quote(1, 20_000, 18_810, false);
1508 assert_eq!(quote.confirmations_required, 1);
1509 assert_eq!(quote.credit_amount_sats, 18_810);
1510 assert_eq!(quote.fee_sats, 1_190);
1511 assert!(!quote.is_estimate);
1512 }
1513
1514 #[test]
1515 fn quotes_a_free_claim_at_a_zero_fee() {
1516 let quote = claim_deposit_quote(3, 20_000, 20_000, true);
1517 assert_eq!(quote.fee_sats, 0);
1518 assert!(quote.is_estimate);
1519 }
1520
1521 #[test]
1522 fn quotes_zero_rather_than_underflowing_on_a_credit_above_the_deposit() {
1523 let quote = claim_deposit_quote(0, 20_000, 25_000, false);
1526 assert_eq!(quote.fee_sats, 0);
1527 }
1528
1529 #[test]
1530 fn selects_zero_conf_plan_within_ceiling() {
1531 let q = quote_result(100_000, &[(0, 99_000), (1, 99_500)]);
1533 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 0, 3)
1534 else {
1535 panic!("expected a claimable 0-conf plan");
1536 };
1537 assert_eq!(plan.confirmations, 0);
1538 }
1539
1540 #[test]
1541 fn selects_shallowest_plan_when_no_zero_conf_plan() {
1542 let q = quote_result(100_000, &[(1, 99_000), (2, 99_500)]);
1545 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 1, 3)
1546 else {
1547 panic!("expected the 1-conf plan to be claimable");
1548 };
1549 assert_eq!(plan.confirmations, 1);
1550 }
1551
1552 #[test]
1553 fn selects_shallowest_plan_regardless_of_order() {
1554 let q = quote_result(100_000, &[(3, 99_900), (1, 99_500), (0, 99_000)]);
1555 let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 0, 3)
1556 else {
1557 panic!("expected a claimable plan");
1558 };
1559 assert_eq!(plan.confirmations, 0);
1560 }
1561
1562 #[test]
1563 fn skips_a_plan_that_credits_no_sooner_than_maturity() {
1564 let q = quote_result(100_000, &[(1, 99_000)]);
1568 assert!(matches!(
1569 select_instant_claim_plan(&q, 100_000, 10_000, 1, 1),
1570 InstantClaimPlan::NoPlan
1571 ));
1572 assert!(matches!(
1574 select_instant_claim_plan(&q, 100_000, 10_000, 1, 3),
1575 InstantClaimPlan::Claimable(_)
1576 ));
1577 }
1578
1579 #[test]
1580 fn skips_when_the_deposit_is_already_deep_enough_to_mature() {
1581 let q = quote_result(100_000, &[(1, 99_000)]);
1584 assert!(matches!(
1585 select_instant_claim_plan(&q, 100_000, 10_000, 5, 3),
1586 InstantClaimPlan::NoPlan
1587 ));
1588 }
1589
1590 #[test]
1591 fn skips_a_plan_the_deposit_is_not_deep_enough_for() {
1592 let q = quote_result(100_000, &[(2, 99_000)]);
1595 assert!(matches!(
1596 select_instant_claim_plan(&q, 100_000, 10_000, 1, 3),
1597 InstantClaimPlan::NoPlan
1598 ));
1599 assert!(matches!(
1601 select_instant_claim_plan(&q, 100_000, 10_000, 2, 3),
1602 InstantClaimPlan::Claimable(_)
1603 ));
1604 }
1605
1606 #[test]
1607 fn skips_when_no_plans_offered() {
1608 let q = quote_result(100_000, &[]);
1609 assert!(matches!(
1610 select_instant_claim_plan(&q, 100_000, 100_000, 0, 3),
1611 InstantClaimPlan::NoPlan
1612 ));
1613 }
1614
1615 #[test]
1619 fn declines_a_credit_above_the_deposit_value() {
1620 let q = quote_result(100_000, &[(0, 125_000)]);
1621 assert!(matches!(
1622 select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1623 InstantClaimPlan::CreditAboveDeposit {
1624 credit_sats: 125_000
1625 }
1626 ));
1627 }
1628
1629 #[test]
1630 fn declines_when_no_ceiling_is_set() {
1631 let q = quote_result(100_000, &[(0, 99_000)]);
1634 assert!(matches!(
1635 select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1636 InstantClaimPlan::FeeExceeded { .. }
1637 ));
1638 }
1639
1640 #[test]
1641 fn gates_on_the_credit_the_claim_signs() {
1642 let q = quote_result(100_000, &[(3, 90_000), (0, 99_000)]);
1648 assert_eq!(q.quote.credit_amount.original_value, 90_000);
1649 assert!(matches!(
1650 select_instant_claim_plan(&q, 100_000, 2_000, 0, 3),
1651 InstantClaimPlan::FeeExceeded {
1652 quoted_sats: 10_000,
1653 ..
1654 }
1655 ));
1656 }
1657
1658 #[test]
1659 fn skips_when_spread_over_ceiling() {
1660 let q = quote_result(100_000, &[(0, 95_000)]);
1662 assert!(matches!(
1663 select_instant_claim_plan(&q, 100_000, 1_000, 0, 3),
1664 InstantClaimPlan::FeeExceeded { quoted_sats: 5_000 }
1665 ));
1666 }
1667
1668 #[test]
1669 fn rejects_any_spread_at_a_zero_ceiling() {
1670 let q = quote_result(100_000, &[(0, 99_000)]);
1671 assert!(matches!(
1672 select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1673 InstantClaimPlan::FeeExceeded { .. }
1674 ));
1675 }
1676
1677 #[test]
1678 fn accepts_spread_equal_to_ceiling() {
1679 let q = quote_result(100_000, &[(0, 99_000)]);
1681 assert!(matches!(
1682 select_instant_claim_plan(&q, 100_000, 1_000, 0, 3),
1683 InstantClaimPlan::Claimable(_)
1684 ));
1685 }
1686
1687 #[test]
1688 fn one_ceiling_admits_small_declines_large() {
1689 let ceiling = 2_000;
1693 let small = quote_result(20_000, &[(0, 18_810)]);
1695 assert!(matches!(
1696 select_instant_claim_plan(&small, 20_000, ceiling, 0, 3),
1697 InstantClaimPlan::Claimable(_)
1698 ));
1699 let large = quote_result(1_000_000, &[(0, 993_910)]);
1701 assert!(matches!(
1702 select_instant_claim_plan(&large, 1_000_000, ceiling, 0, 3),
1703 InstantClaimPlan::FeeExceeded { .. }
1704 ));
1705 }
1706
1707 #[test]
1708 fn treats_ssp_depth_rejections_as_retryable() {
1709 assert!(is_pending_confirmation_error(
1712 "graphql error: UTXO does not have enough confirmations. Required: 1, got: 0"
1713 ));
1714 assert!(is_pending_confirmation_error(
1715 "graphql error: UTXO needs 1 confirmations on every Spark operator before it \
1716 can be claimed. Some operators have not seen it that deep yet. Retry in a \
1717 few seconds."
1718 ));
1719 assert!(is_pending_confirmation_error(
1721 "deposit tx doesn't have enough confirmations: confirmation height: 100 \
1722 current block height: 100"
1723 ));
1724 assert!(!is_pending_confirmation_error(
1726 "graphql error: Something went wrong."
1727 ));
1728 }
1729
1730 #[test]
1731 fn prices_spread_off_passed_deposit_not_quote() {
1732 let q = quote_result(100_000, &[(0, 49_500)]);
1738 assert!(matches!(
1739 select_instant_claim_plan(&q, 50_000, 1_000, 0, 3),
1740 InstantClaimPlan::Claimable(_)
1741 ));
1742 }
1743
1744 fn outpoint(vout: u32) -> TxOutput {
1745 TxOutput {
1746 txid: "tx".to_string(),
1747 vout,
1748 }
1749 }
1750
1751 fn refund_paying_out(out_sats: u64) -> Transaction {
1752 Transaction {
1753 version: Version::non_standard(3),
1754 lock_time: LockTime::ZERO,
1755 input: vec![TxIn {
1756 previous_output: OutPoint::null(),
1757 ..Default::default()
1758 }],
1759 output: vec![TxOut {
1760 value: Amount::from_sat(out_sats),
1761 script_pubkey: ScriptBuf::new(),
1762 }],
1763 }
1764 }
1765
1766 #[test]
1767 fn a_second_attempt_on_the_same_outpoint_is_refused() {
1768 let guards = ClaimGuards::default();
1769 let first = guards.try_acquire(outpoint(0));
1770 assert!(first.is_some());
1771 assert!(guards.try_acquire(outpoint(0)).is_none());
1772 drop(first);
1773 assert!(guards.try_acquire(outpoint(0)).is_some());
1774 }
1775
1776 #[test]
1777 fn different_outpoints_do_not_block_each_other() {
1778 let guards = ClaimGuards::default();
1779 let _first = guards.try_acquire(outpoint(0));
1780 assert!(guards.try_acquire(outpoint(1)).is_some());
1781 }
1782
1783 #[test]
1784 fn replacement_is_rejected_until_it_outbids_the_pending_refund() {
1785 let deposit = 100_000u64;
1786 let pending = 500u64;
1787 let vsize = refund_paying_out(0).vsize() as u64;
1788 let required = Replaced {
1789 fee_sats: pending,
1790 vsize,
1791 }
1792 .min_replacement_fee_sats(vsize);
1793
1794 assert!(check_replacement_fee(&refund_paying_out(deposit - 1), deposit, None).is_ok());
1796
1797 let pending_refund = Replaced {
1798 fee_sats: pending,
1799 vsize,
1800 };
1801
1802 let short = refund_paying_out(deposit - required + 1);
1805 assert!(fee_paid_sats(&short, deposit).unwrap() > pending);
1806 assert!(matches!(
1807 check_replacement_fee(&short, deposit, Some(pending_refund)),
1808 Err(SdkError::RefundReplacementFeeTooLow {
1809 pending_fee_sats,
1810 required_fee_sats,
1811 ..
1812 }) if pending_fee_sats == pending && required_fee_sats == required
1813 ));
1814
1815 let exact = refund_paying_out(deposit - required);
1817 assert!(check_replacement_fee(&exact, deposit, Some(pending_refund)).is_ok());
1818 }
1819
1820 #[test]
1821 fn an_already_claimed_deposit_is_not_a_claim_failure() {
1822 assert!(is_already_claimed_error(
1825 "SparkSdkError: Service error: service provider error: graphql error: \
1826 Static deposit has already been claimed, transaction_id: abc, output_index: 0, \
1827 transfer_id: 01a0"
1828 ));
1829 assert!(!is_already_claimed_error(
1831 "deposit does not have enough confirmations"
1832 ));
1833 assert!(!is_already_claimed_error("insufficient funds"));
1834 }
1835}