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    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
38/// Confirmations a deposit needs before the operators treat it as mature. Mirrors
39/// operator policy the SDK cannot observe directly.
40fn maturity_confirmations(network: Network) -> u32 {
41    match network {
42        Network::Regtest => 1,
43        Network::Mainnet | Network::Signet => 3,
44    }
45}
46
47// Retry parameters for looking up the transfer created by a static deposit
48// claim while it propagates across Spark operators.
49const 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        // Held for the whole attempt, so a sync pass or a second call on the same
70        // outpoint cannot run one alongside it. Keyed on the parsed txid rather
71        // than the caller's spelling of it, which `Txid::from_str` accepts in any
72        // case: an uppercase txid would otherwise take a key the sync loop never
73        // looks up, and slip past the guard.
74        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        // An unreadable depth counts as mature.
88        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        // A claim already has this deposit and an early one cannot be made twice.
101        // Decided before the ceiling is recorded, so this leaves the deposit as it
102        // was, the same as losing the claim guard does.
103        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        // Immature deposits take the early path, bounded by the same ceiling.
119        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    /// Quotes both ways of claiming a deposit, so the caller can offer a choice
129    /// between claiming ahead of maturity for a spread and waiting for the cheaper
130    /// claim at maturity.
131    ///
132    /// The early quote is requested from the provider on each call rather than read
133    /// from cache, so call this when a user is deciding, not on a timer.
134    ///
135    /// Fails with `DepositTooSmall` for a deposit worth too little to claim at the
136    /// current fees.
137    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        // Each claim runs under its own ceiling: the early one under the deposit's
160        // own max fee when it has one, the one at maturity under the larger of that
161        // and the configured one.
162        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        // Withhold the early claim unless it credits sooner than maturity. The
187        // depth it becomes claimable at is reported, not filtered on.
188        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        // Nothing stored means no refund to outbid.
232        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        // `store_refund` only ever updates, so the row has to exist: without one the
252        // refund would be broadcast, persisted nowhere and never rebroadcast. A
253        // refund can be asked for before the background sync has inserted the row.
254        // Insert it only once the operators have signed, so a txid and vout they do
255        // not recognise leaves nothing behind.
256        if existing.is_none() {
257            // Mature: the operators only sign a refund once the deposit has enough
258            // confirmations.
259            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        // Store before broadcasting: a signed refund that is only in flight is
270        // lost if the process dies, and the rebroadcast on sync needs it.
271        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        // Record why the broadcast was refused before returning, so the deposit
285        // carries the reason rather than waiting for the next sync to retry.
286        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    /// Claims a deposit that has matured. The claim settles synchronously, so the
318    /// payment is returned with the response.
319    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                // Insert the payment before returning so callers that
330                // immediately list payments see the claim.
331                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            // An earlier claim took it, so the deposit is settled rather than
346            // failed and records no claim error. The caller still gets the error:
347            // the claim they asked for did not happen here.
348            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    /// Records the ceiling standing for a deposit. It is written before the claim is
381    /// attempted, so it stands whatever that attempt does, and it governs every
382    /// later automatic attempt, the one at maturity included.
383    ///
384    /// `update_deposit` is a no-op on a deposit with no row, which a manual claim
385    /// can reach before the syncer has inserted one, so a ceiling to store inserts
386    /// the row first.
387    ///
388    /// Returns whether the deposit has a row once this is done.
389    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            // Every claim that names no ceiling clears, so this is the ordinary
404            // path and says nothing on its own.
405            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    /// Looks up the transfer produced by a static deposit claim, retrying
433    /// while the Spark operators have not yet indexed it. The SSP commits
434    /// the claim synchronously, but there is a brief window before the
435    /// transfer becomes queryable from the operators; transient query
436    /// errors are also retried. Returns the last error if every attempt
437    /// fails.
438    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    /// Fee in sats that a new refund has to outbid, or `None` when there is
501    /// nothing to outbid. `None` while the deposit output is unspent: a refund
502    /// that never reached the network conflicts with nothing, so re-creating it
503    /// at a lower fee has to stay possible.
504    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        // A refund that cannot be decoded is no basis for holding a new one back.
513        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            // A conflicting transaction is on the network. The floor is the stored
535            // refund's fee, which is the spender whenever this wallet made it. If
536            // something else did, an underpriced replacement is refused by the
537            // network rather than by this check.
538            Ok(Outspend::Spent { .. }) => Ok(stored),
539            // The outpoint cannot be read, so ask whether the stored refund itself
540            // reached the network. That is authoritative, unlike the recorded state,
541            // which a lost write can leave stale. Gating on a refund that never
542            // landed would raise the bar on every retry, since each attempt stores
543            // its own fee before broadcasting.
544            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    /// Confirmations on the deposit transaction, 0 while it is unconfirmed. Needs
561    /// two chain calls: a transaction reports the height it confirmed at, never its
562    /// depth.
563    pub(super) async fn deposit_confirmations(&self, txid: &str) -> Result<u32, SdkError> {
564        self.deposit_confirmations_at_tip(txid, None).await
565    }
566
567    /// Confirmations on the deposit transaction against an already-known chain tip,
568    /// which a caller resolving several deposits fetches once.
569    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        // An unconfirmed transaction can still carry a height.
579        if !status.confirmed {
580            return Ok(0);
581        }
582        // Confirmed without a height: at least one deep.
583        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    /// Quotes claiming `detailed_utxo` ahead of maturity. `None` when the provider
594    /// offers nothing for it, including when it cannot yet be quoted at all: this
595    /// is one of two options being priced for display, so it reports absence
596    /// rather than failing the call.
597    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        // What the provider offered, before filtering.
617        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    /// Quotes claiming `detailed_utxo` at maturity. The provider may decline to
634    /// quote a deposit that has not matured, so this falls back to an estimate
635    /// from current on-chain fees, unless it refused the deposit as too small.
636    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    /// Whether the deposit can be claimed at maturity rather than early: true if the
680    /// operators say so, or the chain alone is deep enough. Either suffices because
681    /// the stored flag is only as fresh as the last deposit sync, which need never
682    /// have run, and trusting it alone pays a spread on a deposit that was already
683    /// claimable at maturity.
684    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    /// The stored row for one deposit, or `None` when the syncer has not recorded
714    /// it yet.
715    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    /// Claims a specific not-yet-mature deposit instantly, on demand.
729    /// The transfer settles asynchronously, so no payment is returned.
730    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            // Transient quote-fetch failure: leave unmarked so a retry works.
745            Err(e) => {
746                error!("Instant claim transient error: {e:?}");
747                return Err(e);
748            }
749        };
750
751        // Persist the resolved status. A manual claim can run before the background
752        // sync has inserted the deposit row, in which case update_deposit would be a
753        // no-op and the marker (which stops the sync from re-submitting or
754        // normal-claiming a still-in-flight deposit) would be lost, so insert the row
755        // first when missing. reconcile_deposits removes it once the claim settles.
756        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    /// Attempts an instant static deposit claim for `detailed_utxo`, ahead of
789    /// maturity, bounded by the same fee ceiling the claim at maturity uses.
790    /// `Ok(Submitted)` on a submitted claim, `Ok(Declined)` for a terminal outcome
791    /// (no plan offered, spread over the ceiling, or a rejected claim), and `Err`
792    /// for the transient cases that should be retried: a failed quote fetch (the
793    /// SSP may not have indexed the tx yet) and a claim the SSP rejected for
794    /// insufficient depth.
795    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        // An unresolved max fee is recorded as a zero ceiling: it admits nothing,
802        // and unlike none it stays retryable once one is configured.
803        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            // Too small to claim early says nothing about claiming at maturity,
814            // which costs less.
815            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        // Price the spread against the on-chain UTXO value we already hold, not the
830        // SSP-reported deposit amount, so the fee gate does not depend on the quote.
831        match select_instant_claim_plan(
832            &quote_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                    // A depth rejection: the deposit is below the plan's
850                    // confirmations, or the operators disagree on how deep it is.
851                    Err(e) if is_pending_confirmation_error(&e.to_string()) => Err(e.into()),
852                    // The provider rejected the submission or could not be reached.
853                    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            // A quote crediting more than the deposit is the provider's, not a
871            // depth the deposit will reach.
872            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
900/// Result of an instant claim attempt.
901pub(super) enum InstantClaimOutcome {
902    /// The claim was submitted and carries the claim id.
903    Submitted(String),
904    /// The claim was declined: no plan offered, the spread was over the ceiling,
905    /// or the submission failed. `max_fee_sats` is the ceiling that declined it,
906    /// unset when no ceiling was involved.
907    Declined {
908        error: SdkError,
909        max_fee_sats: Option<u64>,
910        reason: ClaimDeferredReason,
911    },
912}
913
914impl InstantClaimOutcome {
915    /// The status to persist on the deposit for this resolved outcome.
916    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
929/// Whether the provider rejected a claim because the deposit is already claimed,
930/// which is a settled deposit reported as a failure. Neither the SSP nor the
931/// operators return an error code for it, so the message is all there is to go
932/// on.
933pub(super) fn is_already_claimed_error(message: &str) -> bool {
934    message.to_lowercase().contains("already been claimed")
935}
936
937/// Message fragments of the depth rejections that clear on their own. Neither the
938/// SSP nor the operators return an error code for these, so the message is all
939/// there is to go on. `enough confirmations` covers the SSP and the operators
940/// alike, which differ only by contraction; the second covers the SSP's separate
941/// "deep enough on some operators but not all" rejection.
942const PENDING_CONFIRMATION_MARKERS: [&str; 2] =
943    ["enough confirmations", "operators have not seen it"];
944
945/// Whether a rejected claim is the SSP or the operators waiting for the UTXO to
946/// reach the required depth, which clears within seconds. A phrasing outside
947/// [`PENDING_CONFIRMATION_MARKERS`] is terminal, so the deposit stops being
948/// retried and falls through to the claim at maturity.
949fn 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
956/// What an explicit claim runs under, and what it leaves standing on the deposit.
957pub(super) struct ClaimCeiling {
958    /// The ceiling to record on the deposit. `None` clears any standing one.
959    pub stored: Option<MaxFee>,
960    /// The ceiling this claim is held to.
961    pub effective: Option<MaxFee>,
962}
963
964/// Resolves an explicit claim's ceiling. A requested ceiling becomes the deposit's
965/// standing one, so later automatic attempts are held to what the caller chose here.
966/// Requesting none claims under the configured ceiling and clears any standing one.
967pub(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
977/// The ceiling an automatic claim at maturity runs under: whichever of the
978/// deposit's own and the configured one admits more.
979///
980/// A deposit's ceiling governs whether it is claimed early. Letting a lowered one
981/// also cap the claim at maturity would leave a deposit set below the on-chain
982/// claim cost unclaimable until someone intervened, and that claim costs only what
983/// the wallet would have paid anyway.
984///
985/// Takes both already resolved to sats: a fixed amount and a fee rate are not
986/// comparable until they are.
987pub(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
1000/// The response for a resolved early claim. Neither outcome carries a payment: a
1001/// submitted claim settles asynchronously, and a declined one moved nothing.
1002pub(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
1012/// The error for a failed quote to claim `detailed_utxo` at maturity.
1013pub(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
1020/// Whether a claim has already taken this deposit, still settling or credited.
1021/// The chain and the operators go on reporting the UTXO until the provider
1022/// spends it, so a deposit that is already spoken for still appears unclaimed.
1023pub(super) fn claim_already_made(status: Option<&InstantClaimStatus>) -> bool {
1024    matches!(
1025        status,
1026        Some(InstantClaimStatus::Submitted { .. } | InstantClaimStatus::Claimed)
1027    )
1028}
1029
1030/// Whether a deposit's standing ceiling has to be resolved for that deposit alone.
1031/// Only one that differs from the configured ceiling does: an equal one resolves to
1032/// the same sats as the ceiling already resolved once for the pass.
1033pub(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/// A max claim fee with its on-chain part priced at the current fee market. The
1041/// ceiling it comes to in sats still depends on the deposit, through its
1042/// proportional part.
1043#[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    /// The most claiming a deposit worth `deposit_sats` may cost. The on-chain part
1058    /// is priced over the claim tx size.
1059    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
1068/// Prices one way of claiming a deposit, from the credit it would leave.
1069fn 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
1085/// Classification of an instant quote's shallowest plan against the fee ceiling.
1086enum InstantClaimPlan {
1087    /// The plan is within the ceiling and should be claimed.
1088    Claimable(InstantStaticDepositPlan),
1089    /// The quote carried no fulfillment plans at all.
1090    NoPlan,
1091    /// The SSP spread (`deposit - credit`) exceeds the ceiling. Carries the spread
1092    /// in sats for the decline message.
1093    FeeExceeded { quoted_sats: u64 },
1094    /// The quote credits more than the deposit is worth, so there is no spread to
1095    /// price. Carries the credit for the decline message.
1096    CreditAboveDeposit { credit_sats: u64 },
1097}
1098
1099/// Selects the shallowest of the fulfillment plans the SSP returned with the
1100/// quote (the one crediting at the fewest confirmations) and checks the spread
1101/// (`deposit - credit`) against `max_fee_sats`, the same resolved ceiling the
1102/// claim at maturity is held to. An unset ceiling arrives here as zero, which
1103/// admits nothing.
1104fn 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    // The deposit is deep enough to claim at maturity, so an early claim buys
1119    // nothing. The operator feed can still report it immature.
1120    if u64::from(confirmations) >= u64::from(maturity_confirmations) {
1121        return InstantClaimPlan::NoPlan;
1122    }
1123    // Skip plans that only credit once the deposit has matured anyway.
1124    let plan_confirmations = plan.confirmations.unsigned_abs();
1125    if plan_confirmations >= u64::from(maturity_confirmations) {
1126        return InstantClaimPlan::NoPlan;
1127    }
1128    // Skip plans the deposit is not deep enough for yet: submitting one is
1129    // rejected on depth.
1130    if plan_confirmations > u64::from(confirmations) {
1131        return InstantClaimPlan::NoPlan;
1132    }
1133    // Priced off the quote's credit, which is what the claim signs. A credit above
1134    // the deposit's own value is not a fee at all, and must not read as a free
1135    // claim: the claim rejects it at signing time, so reject it here where the
1136    // reason is still attributable.
1137    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
1148/// Rejects a refund that cannot displace one already on the network. A
1149/// replacement only relays if it outbids the refund it conflicts with, and
1150/// overwriting the stored transaction with one that cannot relay would leave the
1151/// deposit with no way out.
1152fn 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/// Serialises claim attempts on the same deposit within this process.
1176///
1177/// A claim spends several seconds fetching a quote, transferring and signing
1178/// before anything reaches storage, so a second attempt starting in that window
1179/// finds no trace of the first. Holding the outpoint for the whole attempt closes
1180/// that, which persisted state cannot: it is only written once the claim returns.
1181#[derive(Clone, Default)]
1182pub(crate) struct ClaimGuards {
1183    in_flight: Arc<Mutex<HashSet<TxOutput>>>,
1184}
1185
1186impl ClaimGuards {
1187    /// `None` when an attempt on this outpoint is already running.
1188    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
1210/// Releases the outpoint when dropped, so a claim that fails or panics does not
1211/// leave it locked out.
1212pub(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    // ---- resolve_claim_ceiling / larger_ceiling ----
1246
1247    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        // Recorded so later automatic attempts are held to what the caller chose,
1259        // which is what lets a raised ceiling claim a deposit early unattended.
1260        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        // A bare claim means "claim under the wallet defaults", so it also retires
1268        // any ceiling standing on the deposit.
1269        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        // Not combined with it: a request below config must lower the claim, not
1282        // be raised back up to it.
1283        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        // Lowering it keeps the deposit from being claimed early. Capping the claim
1298        // at maturity too would leave a deposit set below the on-chain cost stuck
1299        // until someone intervened.
1300        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        // A rate and a fixed amount are only comparable once resolved, and the rate
1306        // here admits more despite the smaller number on its face.
1307        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        // Nothing to gain from swapping, and the deposit's own is the more specific.
1318        assert_eq!(larger_ceiling(&fixed(99), 99, &fixed(99), 99), fixed(99));
1319    }
1320
1321    // ---- instant_claim_response ----
1322
1323    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        // The transfer settles asynchronously, so the caller watches for it rather
1336        // than reading it off this response: only Settled carries a payment.
1337        assert!(matches!(response.outcome, ClaimDepositOutcome::Submitted));
1338    }
1339
1340    #[test]
1341    fn a_declined_early_claim_defers_rather_than_failing() {
1342        // The deposit falls through to the claim at maturity, so this is an
1343        // ordinary outcome and the caller is told what it would have cost.
1344        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        // Waiting for a confirmation does not address an unreachable provider, so
1373        // this must not collapse into NoEarlyClaimAvailable.
1374        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        // Nothing of its own, so the ceiling resolved once for the pass serves.
1392        assert!(!needs_own_ceiling_resolution(None, Some(&config)));
1393        // The same value resolves to the same sats, so re-resolving it would buy a
1394        // second fee lookup and nothing else.
1395        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        // Lower than the configured ceiling counts as differing too: the deposit is
1404        // being held back, which the pass ceiling would not do.
1405        assert!(needs_own_ceiling_resolution(
1406            Some(&fixed(10)),
1407            Some(&config)
1408        ));
1409        // Nothing configured at all, so the pass has no ceiling to fall back on.
1410        assert!(needs_own_ceiling_resolution(Some(&fixed(10)), None));
1411        assert!(!needs_own_ceiling_resolution(None, None));
1412        // Equal values of a different variant are still equal.
1413        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        // A different proportional part is a different ceiling.
1424        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    // ---- ResolvedMaxFee ----
1437
1438    #[test]
1439    fn a_proportional_part_scales_the_ceiling_with_the_deposit() {
1440        // 2 sat/vbyte over the claim tx, plus 750 ppm of the deposit.
1441        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        // Rounded down: 999 sats at 750 ppm is 0.749 sats.
1447        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    /// Builds a quote for a `deposit_sats` UTXO with one fulfillment plan per
1478    /// `(confirmations, credit_sats)` pair. The quote-level credit mirrors the
1479    /// first plan's.
1480    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        // 20_000 deposit crediting 18_810 costs 1_190.
1507        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        // Not something the provider should return, but the arithmetic must not
1524        // wrap into an enormous fee if it ever does.
1525        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        // Spread 1_000, ceiling 2_000 -> claim.
1532        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        // A deposit that has already confirmed gets no 0-conf plan: claim at the
1543        // shallowest depth offered rather than waiting for maturity.
1544        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        // The SSP quotes its floor, not this deposit's depth, so a deposit well
1565        // past maturity is still offered a shallow plan. Claiming it would pay a
1566        // spread for a wait the deposit no longer has.
1567        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        // The same plan is worth claiming when maturity really is further out.
1573        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        // The operator feed can lag the chain, so a deposit past maturity depth
1582        // still reaches the selector. Paying a spread buys no time.
1583        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        // The SSP quotes a floor above this deposit's depth: submitting it is
1593        // rejected on depth, so wait for a confirmation and re-quote.
1594        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        // The same plan is claimable once the deposit reaches the floor.
1600        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    /// The substitution the claim-time binding rejects, seen from the fee gate: a
1616    /// credit above the deposit's value has no spread to price, and saturating it
1617    /// to zero would admit it against any ceiling.
1618    #[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        // An unset max fee reaches the gate as zero, which admits nothing. It is
1632        // recorded as a real ceiling, so configuring one later still retries.
1633        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        // The user statement signs the quote's credit, so that is what the ceiling
1643        // has to be checked against. Here the quote credits 90_000 while the plan
1644        // that gets selected (the shallowest) names 99_000: gating on the plan
1645        // would see a 1_000 spread and admit it, when the claim actually authorizes
1646        // a 10_000 one.
1647        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        // Spread 5_000 against a 1_000 ceiling -> skip.
1661        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        // Inclusive at the ceiling.
1680        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        // The SSP spread carries a term proportional to the deposit, so an absolute
1690        // ceiling admits small deposits and makes large ones wait: the inverse of a
1691        // bps cap. Spreads model `100 flat + 990 on-chain + 50 bps`.
1692        let ceiling = 2_000;
1693        // 20_000 deposit: spread 1_190 -> claimed.
1694        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        // 1_000_000 deposit: spread 6_090 -> declined, waits for maturity.
1700        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        // Verbatim SSP rejections: the UTXO is not deep enough yet, or is deep
1710        // enough but has not propagated to every operator.
1711        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        // The Spark operators phrase it differently again, with a contraction.
1720        assert!(is_pending_confirmation_error(
1721            "deposit tx doesn't have enough confirmations: confirmation height: 100 \
1722             current block height: 100"
1723        ));
1724        // A rejected statement is terminal and must stay that way.
1725        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        // The quote claims a 100_000 deposit, but we pass the real on-chain value
1733        // (50_000). Spread is priced off the passed value: 50_000 - 49_500 = 500,
1734        // within the 1_000 ceiling -> claim. Pricing off the quote's 100_000 would
1735        // give a 50_500 spread and decline, so a claim proves the passed value
1736        // drives the gate.
1737        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        // Nothing on the network to outbid, so any fee is fine.
1795        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        // A single sat short of the floor is not enough, even though it pays
1803        // more than the refund it is trying to displace.
1804        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        // Paying exactly the floor is accepted.
1816        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        // What the SSP answers a claim at maturity with when an early claim has
1823        // already taken the UTXO.
1824        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        // A genuine failure still records why.
1830        assert!(!is_already_claimed_error(
1831            "deposit does not have enough confirmations"
1832        ));
1833        assert!(!is_already_claimed_error("insufficient funds"));
1834    }
1835}