Skip to main content

breez_sdk_spark/sdk/
deposits.rs

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
36/// Confirmations a deposit needs before the operators treat it as mature. Mirrors
37/// operator policy the SDK cannot observe directly.
38fn maturity_confirmations(network: Network) -> u32 {
39    match network {
40        Network::Regtest => 1,
41        Network::Mainnet => 3,
42    }
43}
44
45// Retry parameters for looking up the transfer created by a static deposit
46// claim while it propagates across Spark operators.
47const 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        // Held for the whole attempt, so a sync pass or a second call on the same
68        // outpoint cannot run one alongside it. Keyed on the parsed txid rather
69        // than the caller's spelling of it, which `Txid::from_str` accepts in any
70        // case: an uppercase txid would otherwise take a key the sync loop never
71        // looks up, and slip past the guard.
72        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        // An unreadable depth counts as mature.
84        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        // Immature deposits take the early path, bounded by the same ceiling.
95        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                // Insert the payment before returning so callers that
109                // immediately list payments see the claim.
110                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    /// Quotes both ways of claiming a deposit, so the caller can offer a choice
141    /// between claiming ahead of maturity for a spread and waiting for the cheaper
142    /// claim at maturity.
143    ///
144    /// The early quote is requested from the provider on each call rather than read
145    /// from cache, so call this when a user is deciding, not on a timer.
146    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        // Withhold the early claim unless it credits sooner than maturity. The
166        // depth it becomes claimable at is reported, not filtered on.
167        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        // Nothing stored means no refund to outbid.
211        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        // `store_refund` only ever updates, so the row has to exist: without one the
231        // refund would be broadcast, persisted nowhere and never rebroadcast. A
232        // refund can be asked for before the background sync has inserted the row.
233        // Insert it only once the operators have signed, so a txid and vout they do
234        // not recognise leaves nothing behind.
235        if existing.is_none() {
236            // Mature: the operators only sign a refund once the deposit has enough
237            // confirmations.
238            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        // Store before broadcasting: a signed refund that is only in flight is
249        // lost if the process dies, and the rebroadcast on sync needs it.
250        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        // Record why the broadcast was refused before returning, so the deposit
264        // carries the reason rather than waiting for the next sync to retry.
265        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    /// Looks up the transfer produced by a static deposit claim, retrying
297    /// while the Spark operators have not yet indexed it. The SSP commits
298    /// the claim synchronously, but there is a brief window before the
299    /// transfer becomes queryable from the operators; transient query
300    /// errors are also retried. Returns the last error if every attempt
301    /// fails.
302    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    /// Fee in sats that a new refund has to outbid, or `None` when there is
365    /// nothing to outbid. `None` while the deposit output is unspent: a refund
366    /// that never reached the network conflicts with nothing, so re-creating it
367    /// at a lower fee has to stay possible.
368    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        // A refund that cannot be decoded is no basis for holding a new one back.
377        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            // A conflicting transaction is on the network. The floor is the stored
402            // refund's fee, which is the spender whenever this wallet made it. If
403            // something else did, an underpriced replacement is refused by the
404            // network rather than by this check.
405            Ok(Outspend::Spent { .. }) => Ok(stored),
406            // The outpoint cannot be read, so ask whether the stored refund itself
407            // reached the network. That is authoritative, unlike the recorded state,
408            // which a lost write can leave stale. Gating on a refund that never
409            // landed would raise the bar on every retry, since each attempt stores
410            // its own fee before broadcasting.
411            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    /// Confirmations on the deposit transaction, 0 while it is unconfirmed. Needs
428    /// two chain calls: a transaction reports the height it confirmed at, never its
429    /// depth.
430    pub(super) async fn deposit_confirmations(&self, txid: &str) -> Result<u32, SdkError> {
431        self.deposit_confirmations_at_tip(txid, None).await
432    }
433
434    /// Confirmations on the deposit transaction against an already-known chain tip,
435    /// which a caller resolving several deposits fetches once.
436    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        // An unconfirmed transaction can still carry a height.
446        if !status.confirmed {
447            return Ok(0);
448        }
449        // Confirmed without a height: at least one deep.
450        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    /// Quotes claiming `detailed_utxo` ahead of maturity. `None` when the provider
461    /// offers nothing for it, including when it cannot yet be quoted at all: this
462    /// is one of two options being priced for display, so it reports absence
463    /// rather than failing the call.
464    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        // What the provider offered, before filtering.
484        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    /// Quotes claiming `detailed_utxo` at maturity. The provider may decline to
501    /// quote a deposit that has not matured, so this falls back to an estimate
502    /// from current on-chain fees.
503    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    /// Whether the deposit can be claimed at maturity rather than early: true if the
540    /// operators say so, or the chain alone is deep enough. Either suffices because
541    /// the stored flag is only as fresh as the last deposit sync, which need never
542    /// have run, and trusting it alone pays a spread on a deposit that was already
543    /// claimable at maturity.
544    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    /// Claims a specific not-yet-mature deposit instantly, on demand.
579    /// The transfer settles asynchronously, so no payment is returned.
580    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            // Transient quote-fetch failure: leave unmarked so a retry works.
600            Err(e) => {
601                error!("Instant claim transient error: {e:?}");
602                return Err(e);
603            }
604        };
605
606        // Persist the resolved status. A manual claim can run before the background
607        // sync has inserted the deposit row, in which case update_deposit would be a
608        // no-op and the marker (which stops the sync from re-submitting or
609        // normal-claiming a still-in-flight deposit) would be lost, so insert the row
610        // first when missing. reconcile_deposits removes it once the claim settles.
611        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    /// Attempts an instant static deposit claim for `detailed_utxo`, ahead of
647    /// maturity, bounded by the same fee ceiling the claim at maturity uses.
648    /// `Ok(Submitted)` on a submitted claim, `Ok(Declined)` for a terminal outcome
649    /// (no plan offered, spread over the ceiling, or a rejected claim), and `Err`
650    /// for the transient cases that should be retried: a failed quote fetch (the
651    /// SSP may not have indexed the tx yet) and a claim the SSP rejected for
652    /// insufficient depth.
653    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        // An unresolved max fee is recorded as a zero ceiling: it admits nothing,
660        // and unlike none it stays retryable once one is configured.
661        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        // Price the spread against the on-chain UTXO value we already hold, not the
673        // SSP-reported deposit amount, so the fee gate does not depend on the quote.
674        match select_instant_claim_plan(
675            &quote_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                    // A depth rejection: the deposit is below the plan's
693                    // confirmations, or the operators disagree on how deep it is.
694                    Err(e) if is_pending_confirmation_error(&e.to_string()) => Err(e.into()),
695                    // The provider rejected the submission or could not be reached.
696                    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
732/// Result of an instant claim attempt.
733pub(super) enum InstantClaimOutcome {
734    /// The claim was submitted and carries the claim id.
735    Submitted(String),
736    /// The claim was declined: no plan offered, the spread was over the ceiling,
737    /// or the submission failed. `max_fee_sats` is the ceiling that declined it,
738    /// unset when no ceiling was involved.
739    Declined {
740        error: SdkError,
741        max_fee_sats: Option<u64>,
742    },
743}
744
745impl InstantClaimOutcome {
746    /// The status to persist on the deposit for this resolved outcome.
747    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
760/// Message fragments of the depth rejections that clear on their own. Neither the
761/// SSP nor the operators return an error code for these, so the message is all
762/// there is to go on. `enough confirmations` covers the SSP and the operators
763/// alike, which differ only by contraction; the second covers the SSP's separate
764/// "deep enough on some operators but not all" rejection.
765const PENDING_CONFIRMATION_MARKERS: [&str; 2] =
766    ["enough confirmations", "operators have not seen it"];
767
768/// Whether a rejected claim is the SSP or the operators waiting for the UTXO to
769/// reach the required depth, which clears within seconds. A phrasing outside
770/// [`PENDING_CONFIRMATION_MARKERS`] is terminal, so the deposit stops being
771/// retried and falls through to the claim at maturity.
772fn 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
779/// Prices one way of claiming a deposit, from the credit it would leave.
780fn 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
796/// Classification of an instant quote's shallowest plan against the fee ceiling.
797enum InstantClaimPlan {
798    /// The plan is within the ceiling and should be claimed.
799    Claimable(InstantStaticDepositPlan),
800    /// The quote carried no fulfillment plans at all.
801    NoPlan,
802    /// The SSP spread (`deposit - credit`) exceeds the ceiling, in sats and as the
803    /// on-chain rate it implies over the claim tx (both for the decline message).
804    FeeExceeded { quoted_sats: u64, quoted_rate: u64 },
805    /// The quote credits more than the deposit is worth, so there is no spread to
806    /// price. Carries the credit for the decline message.
807    CreditAboveDeposit { credit_sats: u64 },
808}
809
810/// Selects the shallowest of the fulfillment plans the SSP returned with the
811/// quote (the one crediting at the fewest confirmations) and checks the spread
812/// (`deposit - credit`) against `max_fee_sats`, the same resolved ceiling the
813/// claim at maturity is held to. An unset ceiling arrives here as zero, which
814/// admits nothing.
815fn 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    // The deposit is deep enough to claim at maturity, so an early claim buys
830    // nothing. The operator feed can still report it immature.
831    if u64::from(confirmations) >= u64::from(maturity_confirmations) {
832        return InstantClaimPlan::NoPlan;
833    }
834    // Skip plans that only credit once the deposit has matured anyway.
835    let plan_confirmations = plan.confirmations.unsigned_abs();
836    if plan_confirmations >= u64::from(maturity_confirmations) {
837        return InstantClaimPlan::NoPlan;
838    }
839    // Skip plans the deposit is not deep enough for yet: submitting one is
840    // rejected on depth.
841    if plan_confirmations > u64::from(confirmations) {
842        return InstantClaimPlan::NoPlan;
843    }
844    // Priced off the quote's credit, which is what the claim signs. A credit above
845    // the deposit's own value is not a fee at all, and must not read as a free
846    // claim: the claim rejects it at signing time, so reject it here where the
847    // reason is still attributable.
848    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/// The refund a replacement has to displace. Its size matters as well as its
863/// fee: the replacement has to beat its feerate, not just its total.
864#[derive(Clone, Copy)]
865struct PendingRefund {
866    fee_sats: u64,
867    vsize: u64,
868}
869
870/// Rejects a refund that cannot displace one already on the network. A
871/// replacement only relays if it outbids the refund it conflicts with, and
872/// overwriting the stored transaction with one that cannot relay would leave the
873/// deposit with no way out.
874fn 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
897/// Fee a refund pays, from the deposit output it spends. `None` if the refund
898/// pays out more than the deposit holds.
899fn 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
904/// Minimum fee a replacement must pay to displace a refund already on the
905/// network: more than that refund pays, plus the relay cost of its own size.
906fn replacement_min_fee_sats(pending: &PendingRefund, replacement_vsize: u64) -> u64 {
907    // Cover the pending fee plus the replacement's own relay bandwidth.
908    let bandwidth = pending
909        .fee_sats
910        .saturating_add(replacement_vsize.saturating_mul(MIN_RELAY_FEE_SAT_PER_VBYTE));
911    // And beat its feerate outright, which only bites when the replacement is the
912    // larger transaction, as it is when the destination widens to taproot. The
913    // comparison is made on feerates truncated to whole sat/kvB, so a fee that is
914    // higher as an exact rational can still land in the same bucket and be refused.
915    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/// Serialises claim attempts on the same deposit within this process.
928///
929/// A claim spends several seconds fetching a quote, transferring and signing
930/// before anything reaches storage, so a second attempt starting in that window
931/// finds no trace of the first. Holding the outpoint for the whole attempt closes
932/// that, which persisted state cannot: it is only written once the claim returns.
933#[derive(Clone, Default)]
934pub(crate) struct ClaimGuards {
935    in_flight: Arc<Mutex<HashSet<TxOutput>>>,
936}
937
938impl ClaimGuards {
939    /// `None` when an attempt on this outpoint is already running.
940    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
962/// Releases the outpoint when dropped, so a claim that fails or panics does not
963/// leave it locked out.
964pub(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    /// Builds a quote for a `deposit_sats` UTXO with one fulfillment plan per
1000    /// `(confirmations, credit_sats)` pair. The quote-level credit mirrors the
1001    /// first plan's.
1002    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        // 20_000 deposit crediting 18_810 costs 1_190, which over the 99 vbyte
1029        // claim tx rounds up to 13 sat/vbyte.
1030        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        // Not something the provider should return, but the arithmetic must not
1049        // wrap into an enormous fee if it ever does.
1050        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        // Spread 1_000, ceiling 2_000 -> claim.
1058        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        // A deposit that has already confirmed gets no 0-conf plan: claim at the
1069        // shallowest depth offered rather than waiting for maturity.
1070        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        // The SSP quotes its floor, not this deposit's depth, so a deposit well
1091        // past maturity is still offered a shallow plan. Claiming it would pay a
1092        // spread for a wait the deposit no longer has.
1093        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        // The same plan is worth claiming when maturity really is further out.
1099        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        // The operator feed can lag the chain, so a deposit past maturity depth
1108        // still reaches the selector. Paying a spread buys no time.
1109        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        // The SSP quotes a floor above this deposit's depth: submitting it is
1119        // rejected on depth, so wait for a confirmation and re-quote.
1120        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        // The same plan is claimable once the deposit reaches the floor.
1126        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    /// The substitution the claim-time binding rejects, seen from the fee gate: a
1142    /// credit above the deposit's value has no spread to price, and saturating it
1143    /// to zero would admit it against any ceiling.
1144    #[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        // An unset max fee reaches the gate as zero, which admits nothing. It is
1158        // recorded as a real ceiling, so configuring one later still retries.
1159        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        // The user statement signs the quote's credit, so that is what the ceiling
1169        // has to be checked against. Here the quote credits 90_000 while the plan
1170        // that gets selected (the shallowest) names 99_000: gating on the plan
1171        // would see a 1_000 spread and admit it, when the claim actually authorizes
1172        // a 10_000 one.
1173        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        // Spread 5_000 against a 1_000 ceiling -> skip. The reported rate is the
1187        // spread over the claim tx, so it is comparable with `MaxFee::Rate`.
1188        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        // Inclusive at the ceiling.
1210        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        // The SSP spread carries a term proportional to the deposit, so an absolute
1220        // ceiling admits small deposits and makes large ones wait: the inverse of a
1221        // bps cap. Spreads model `100 flat + 990 on-chain + 50 bps`.
1222        let ceiling = 2_000;
1223        // 20_000 deposit: spread 1_190 -> claimed.
1224        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        // 1_000_000 deposit: spread 6_090 -> declined, waits for maturity.
1230        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        // Verbatim SSP rejections: the UTXO is not deep enough yet, or is deep
1240        // enough but has not propagated to every operator.
1241        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        // The Spark operators phrase it differently again, with a contraction.
1250        assert!(is_pending_confirmation_error(
1251            "deposit tx doesn't have enough confirmations: confirmation height: 100 \
1252             current block height: 100"
1253        ));
1254        // A rejected statement is terminal and must stay that way.
1255        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        // The quote claims a 100_000 deposit, but we pass the real on-chain value
1263        // (50_000). Spread is priced off the passed value: 50_000 - 49_500 = 500,
1264        // within the 1_000 ceiling -> claim. Pricing off the quote's 100_000 would
1265        // give a 50_500 spread and decline, so a claim proves the passed value
1266        // drives the gate.
1267        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        // A refund cannot pay out more than the deposit holds.
1324        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        // Displacing a 111 sat refund with a 111 vbyte replacement costs 222,
1330        // not 112: the replacement also pays to relay its own bytes.
1331        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        // Covering the pending fee plus the replacement's own bandwidth is not
1346        // enough when the replacement is bigger: 3000 sats over 99 vB is 30.3
1347        // sat/vB, and 3111 over 111 vB would be 28.0, which the network refuses.
1348        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        // Same size, so paying the bandwidth is all it takes.
1360        assert_eq!(replacement_min_fee_sats(&pending, 99), 3_099);
1361
1362        // Truncation to whole sat/kvB: 1045 over 111 vB and 932 over 99 both come
1363        // to 9414, which is a tie and refused, so the floor has to be 1046.
1364        let tie = PendingRefund {
1365            fee_sats: 932,
1366            vsize: 99,
1367        };
1368        assert_eq!(replacement_min_fee_sats(&tie, 111), 1_046);
1369
1370        // A small pending fee never reaches the feerate rule.
1371        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        // Nothing on the network to outbid, so any fee is fine.
1392        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        // A single sat short of the floor is not enough, even though it pays
1400        // more than the refund it is trying to displace.
1401        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        // Paying exactly the floor is accepted.
1412        let exact = refund_paying_out(deposit - required);
1413        assert!(check_replacement_fee(&exact, deposit, Some(pending_refund)).is_ok());
1414    }
1415}