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    ClaimDeferredReason, ClaimDepositOutcome, ClaimDepositQuote, ClaimDepositRequest,
22    ClaimDepositResponse, DepositInfo, Fee, FetchClaimDepositQuoteRequest,
23    FetchClaimDepositQuoteResponse, InstantClaimStatus, ListUnclaimedDepositsRequest,
24    ListUnclaimedDepositsResponse, MaxFee, Network, RefundDepositRequest, RefundDepositResponse,
25    RefundState,
26    chain::Outspend,
27    error::SdkError,
28    models::Payment,
29    persist::UpdateDepositPayload,
30    sdk::RuntimeEvent,
31    utils::deposit_chain_syncer::TxOutput,
32    utils::utxo_fetcher::{CachedUtxoFetcher, DetailedUtxo},
33};
34
35use super::{BreezSdk, CLAIM_TX_SIZE_VBYTES};
36
37/// Confirmations a deposit needs before the operators treat it as mature. Mirrors
38/// operator policy the SDK cannot observe directly.
39fn maturity_confirmations(network: Network) -> u32 {
40    match network {
41        Network::Regtest => 1,
42        Network::Mainnet | Network::Signet => 3,
43    }
44}
45
46// Retry parameters for looking up the transfer created by a static deposit
47// claim while it propagates across Spark operators.
48const CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS: u32 = 3;
49const CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS: u64 = 500;
50
51#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
52#[allow(clippy::needless_pass_by_value)]
53impl BreezSdk {
54    pub async fn claim_deposit(
55        &self,
56        request: ClaimDepositRequest,
57    ) -> Result<ClaimDepositResponse, SdkError> {
58        self.maybe_ensure_spark_private_mode_initialized().await?;
59        let detailed_utxo =
60            CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
61                .fetch_detailed_utxo(&request.txid, request.vout)
62                .await?;
63
64        let ceiling =
65            resolve_claim_ceiling(request.max_fee, self.config.max_deposit_claim_fee.clone());
66
67        // 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        let max_fee = ceiling.effective;
84
85        // An unreadable depth counts as mature.
86        let confirmations = self
87            .deposit_confirmations(&request.txid)
88            .await
89            .unwrap_or_else(|e| {
90                warn!(
91                    "Could not read the chain depth for {}:{}: {e}",
92                    request.txid, request.vout
93                );
94                u32::MAX
95            });
96        let stored = self.stored_deposit(&txid, detailed_utxo.vout).await?;
97        let is_mature = self.is_deposit_mature_at(&detailed_utxo, confirmations, stored.as_ref());
98        // A claim already has this deposit and an early one cannot be made twice.
99        // Decided before the ceiling is recorded, so this leaves the deposit as it
100        // was, the same as losing the claim guard does.
101        if !is_mature
102            && claim_already_made(
103                stored
104                    .as_ref()
105                    .and_then(|d| d.instant_claim_status.as_ref()),
106            )
107        {
108            return Err(SdkError::DepositClaimInProgress {
109                tx: txid,
110                vout: detailed_utxo.vout,
111            });
112        }
113        let row_exists = self
114            .store_max_claim_fee(&detailed_utxo, ceiling.stored, is_mature, stored.is_some())
115            .await?;
116        // Immature deposits take the early path, bounded by the same ceiling.
117        if !is_mature {
118            return self
119                .instant_claim_deposit(&detailed_utxo, max_fee, confirmations, row_exists)
120                .await;
121        }
122
123        self.claim_mature_deposit(&detailed_utxo, max_fee).await
124    }
125
126    /// Quotes both ways of claiming a deposit, so the caller can offer a choice
127    /// between claiming ahead of maturity for a spread and waiting for the cheaper
128    /// claim at maturity.
129    ///
130    /// The early quote is requested from the provider on each call rather than read
131    /// from cache, so call this when a user is deciding, not on a timer.
132    pub async fn fetch_claim_deposit_quote(
133        &self,
134        request: FetchClaimDepositQuoteRequest,
135    ) -> Result<FetchClaimDepositQuoteResponse, SdkError> {
136        let detailed_utxo =
137            CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
138                .fetch_detailed_utxo(&request.txid, request.vout)
139                .await?;
140
141        let (confirmations, instant, mature) = tokio::join!(
142            self.deposit_confirmations(&request.txid),
143            self.fetch_instant_claim_quote(&detailed_utxo),
144            self.fetch_mature_claim_quote(&detailed_utxo),
145        );
146        let confirmations = confirmations?;
147        let mature = mature?;
148        let stored = self
149            .stored_deposit(&detailed_utxo.txid.to_string(), detailed_utxo.vout)
150            .await?;
151        let is_mature = self.is_deposit_mature_at(&detailed_utxo, confirmations, stored.as_ref());
152        // Withhold the early claim unless it credits sooner than maturity. The
153        // depth it becomes claimable at is reported, not filtered on.
154        let instant = instant.filter(|quote| {
155            let earlier = quote.confirmations_required < mature.confirmations_required;
156            if is_mature || !earlier {
157                info!(
158                    "Withholding the early claim for {}:{}: {}",
159                    request.txid,
160                    request.vout,
161                    if is_mature {
162                        "the deposit has already matured".to_string()
163                    } else {
164                        format!(
165                            "it credits at {} confirmations, no sooner than maturity at {}",
166                            quote.confirmations_required, mature.confirmations_required
167                        )
168                    }
169                );
170            }
171            !is_mature && earlier
172        });
173
174        Ok(FetchClaimDepositQuoteResponse {
175            amount_sats: detailed_utxo.value,
176            confirmations,
177            instant,
178            mature,
179        })
180    }
181
182    pub async fn refund_deposit(
183        &self,
184        request: RefundDepositRequest,
185    ) -> Result<RefundDepositResponse, SdkError> {
186        let detailed_utxo =
187            CachedUtxoFetcher::new(self.chain_service.clone(), self.storage.clone())
188                .fetch_detailed_utxo(&request.txid, request.vout)
189                .await?;
190
191        let existing = self
192            .storage
193            .list_deposits()
194            .await?
195            .into_iter()
196            .find(|d| d.txid == detailed_utxo.txid.to_string() && d.vout == detailed_utxo.vout);
197        // Nothing stored means no refund to outbid.
198        let fee_to_outbid = match &existing {
199            Some(deposit) => self.refund_fee_to_outbid(&detailed_utxo, deposit).await?,
200            None => None,
201        };
202
203        let tx = self
204            .spark_wallet
205            .refund_static_deposit(
206                detailed_utxo.clone().tx,
207                Some(detailed_utxo.vout),
208                &request.destination_address,
209                request.fee.into(),
210            )
211            .await?;
212        let tx_hex = serialize(&tx).as_hex().to_string();
213        let tx_id = tx.compute_txid().as_raw_hash().to_string();
214
215        check_replacement_fee(&tx, detailed_utxo.value, fee_to_outbid)?;
216
217        // `store_refund` only ever updates, so the row has to exist: without one the
218        // refund would be broadcast, persisted nowhere and never rebroadcast. A
219        // refund can be asked for before the background sync has inserted the row.
220        // Insert it only once the operators have signed, so a txid and vout they do
221        // not recognise leaves nothing behind.
222        if existing.is_none() {
223            // Mature: the operators only sign a refund once the deposit has enough
224            // confirmations.
225            self.storage
226                .add_deposit(
227                    detailed_utxo.txid.to_string(),
228                    detailed_utxo.vout,
229                    detailed_utxo.value,
230                    true,
231                )
232                .await?;
233        }
234
235        // Store before broadcasting: a signed refund that is only in flight is
236        // lost if the process dies, and the rebroadcast on sync needs it.
237        self.store_refund(
238            &detailed_utxo,
239            &tx_hex,
240            &tx_id,
241            RefundState::BroadcastPending { last_error: None },
242        )
243        .await?;
244
245        let broadcast_error = self
246            .chain_service
247            .broadcast_transaction(tx_hex.clone())
248            .await
249            .err();
250        // Record why the broadcast was refused before returning, so the deposit
251        // carries the reason rather than waiting for the next sync to retry.
252        let state = match &broadcast_error {
253            None => RefundState::Broadcast,
254            Some(e) => RefundState::BroadcastPending {
255                last_error: Some(e.to_string()),
256            },
257        };
258
259        if let Err(e) = self
260            .store_refund(&detailed_utxo, &tx_hex, &tx_id, state)
261            .await
262        {
263            error!("Failed to record refund state: {e:?}");
264        }
265
266        if let Some(e) = broadcast_error {
267            return Err(e.into());
268        }
269        Ok(RefundDepositResponse { tx_id, tx_hex })
270    }
271
272    #[allow(unused_variables)]
273    pub async fn list_unclaimed_deposits(
274        &self,
275        request: ListUnclaimedDepositsRequest,
276    ) -> Result<ListUnclaimedDepositsResponse, SdkError> {
277        let deposits = self.storage.list_deposits().await?;
278        Ok(ListUnclaimedDepositsResponse { deposits })
279    }
280}
281
282impl BreezSdk {
283    /// Claims a deposit that has matured. The claim settles synchronously, so the
284    /// payment is returned with the response.
285    async fn claim_mature_deposit(
286        &self,
287        detailed_utxo: &DetailedUtxo,
288        max_fee: Option<MaxFee>,
289    ) -> Result<ClaimDepositResponse, SdkError> {
290        match self.claim_utxo(detailed_utxo, max_fee).await {
291            Ok(transfer_id) => {
292                let transfer = self.lookup_claim_transfer_with_retry(transfer_id).await?;
293                let payment: Payment = transfer.try_into()?;
294                // Insert the payment before returning so callers that
295                // immediately list payments see the claim.
296                let should_emit_event = self.storage.apply_payment_update(payment.clone()).await?;
297                self.storage
298                    .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
299                    .await?;
300                self.event_emitter
301                    .emit_runtime_event(RuntimeEvent::DepositClaimed {
302                        payment: Box::new(payment.clone()),
303                        should_emit_event,
304                    })
305                    .await;
306                Ok(ClaimDepositResponse {
307                    outcome: ClaimDepositOutcome::Settled { payment },
308                })
309            }
310            // An earlier claim took it, so the deposit is settled rather than
311            // failed and records no claim error. The caller still gets the error:
312            // the claim they asked for did not happen here.
313            Err(e) if is_already_claimed_error(&e.to_string()) => {
314                info!(
315                    "Deposit {}:{} was already claimed, marking it",
316                    detailed_utxo.txid, detailed_utxo.vout
317                );
318                self.storage
319                    .update_deposit(
320                        detailed_utxo.txid.to_string(),
321                        detailed_utxo.vout,
322                        UpdateDepositPayload::InstantClaim {
323                            status: InstantClaimStatus::Claimed,
324                        },
325                    )
326                    .await?;
327                Err(e)
328            }
329            Err(e) => {
330                error!("Failed to claim deposit: {e:?}");
331                self.storage
332                    .update_deposit(
333                        detailed_utxo.txid.to_string(),
334                        detailed_utxo.vout,
335                        UpdateDepositPayload::ClaimError {
336                            error: e.clone().into(),
337                        },
338                    )
339                    .await?;
340                Err(e)
341            }
342        }
343    }
344
345    /// Records the ceiling standing for a deposit. It is written before the claim is
346    /// attempted, so it stands whatever that attempt does, and it governs every
347    /// later automatic attempt, the one at maturity included.
348    ///
349    /// `update_deposit` is a no-op on a deposit with no row, which a manual claim
350    /// can reach before the syncer has inserted one, so a ceiling to store inserts
351    /// the row first.
352    ///
353    /// Returns whether the deposit has a row once this is done.
354    async fn store_max_claim_fee(
355        &self,
356        detailed_utxo: &DetailedUtxo,
357        max_fee: Option<MaxFee>,
358        is_mature: bool,
359        row_exists: bool,
360    ) -> Result<bool, SdkError> {
361        let txid = detailed_utxo.txid.to_string();
362        if let Some(max_fee) = &max_fee {
363            info!(
364                "Recording max claim fee {max_fee:?} on deposit {txid}:{}",
365                detailed_utxo.vout
366            );
367        } else {
368            // Every claim that names no ceiling clears, so this is the ordinary
369            // path and says nothing on its own.
370            debug!(
371                "Clearing any max claim fee on deposit {txid}:{}, \
372                 which returns it to the configured one",
373                detailed_utxo.vout
374            );
375        }
376        let inserted = max_fee.is_some() && !row_exists;
377        if inserted {
378            self.storage
379                .add_deposit(
380                    txid.clone(),
381                    detailed_utxo.vout,
382                    detailed_utxo.value,
383                    is_mature,
384                )
385                .await?;
386        }
387        self.storage
388            .update_deposit(
389                txid,
390                detailed_utxo.vout,
391                UpdateDepositPayload::MaxClaimFee { max_fee },
392            )
393            .await?;
394        Ok(row_exists || inserted)
395    }
396
397    /// Looks up the transfer produced by a static deposit claim, retrying
398    /// while the Spark operators have not yet indexed it. The SSP commits
399    /// the claim synchronously, but there is a brief window before the
400    /// transfer becomes queryable from the operators; transient query
401    /// errors are also retried. Returns the last error if every attempt
402    /// fails.
403    async fn lookup_claim_transfer_with_retry(
404        &self,
405        transfer_id: String,
406    ) -> Result<WalletTransfer, SdkError> {
407        let parsed_id = TransferId::from_str(&transfer_id).map_err(SdkError::Generic)?;
408        let mut last_error: Option<SdkError> = None;
409
410        for attempt in 0..CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS {
411            if attempt > 0 {
412                let delay_ms = CLAIM_TRANSFER_LOOKUP_BASE_DELAY_MS
413                    .saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1)));
414                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
415                trace!(
416                    "Retrying claim transfer lookup (attempt {}/{}) for transfer {transfer_id}",
417                    attempt.saturating_add(1),
418                    CLAIM_TRANSFER_LOOKUP_MAX_ATTEMPTS
419                );
420            }
421
422            match self
423                .spark_wallet
424                .list_transfers(ListTransfersRequest {
425                    transfer_ids: vec![parsed_id.clone()],
426                    paging: None,
427                })
428                .await
429            {
430                Ok(mut resp) => {
431                    if let Some(transfer) = resp.items.pop() {
432                        return Ok(transfer);
433                    }
434                    last_error = None;
435                }
436                Err(e) => last_error = Some(e.into()),
437            }
438        }
439
440        Err(last_error
441            .unwrap_or_else(|| SdkError::Generic("transfer not found after claim".to_string())))
442    }
443
444    async fn store_refund(
445        &self,
446        detailed_utxo: &DetailedUtxo,
447        tx_hex: &str,
448        tx_id: &str,
449        state: RefundState,
450    ) -> Result<(), SdkError> {
451        self.storage
452            .update_deposit(
453                detailed_utxo.txid.to_string(),
454                detailed_utxo.vout,
455                UpdateDepositPayload::Refund {
456                    refund_tx: tx_hex.to_string(),
457                    refund_txid: tx_id.to_string(),
458                    state,
459                },
460            )
461            .await?;
462        Ok(())
463    }
464
465    /// Fee in sats that a new refund has to outbid, or `None` when there is
466    /// nothing to outbid. `None` while the deposit output is unspent: a refund
467    /// that never reached the network conflicts with nothing, so re-creating it
468    /// at a lower fee has to stay possible.
469    async fn refund_fee_to_outbid(
470        &self,
471        detailed_utxo: &DetailedUtxo,
472        deposit: &DepositInfo,
473    ) -> Result<Option<PendingRefund>, SdkError> {
474        let Some(refund_tx) = deposit.refund_tx.as_ref() else {
475            return Ok(None);
476        };
477        // A refund that cannot be decoded is no basis for holding a new one back.
478        let Ok(tx) = deserialize_hex::<Transaction>(refund_tx) else {
479            warn!(
480                "Stored refund of deposit {}:{} does not decode, not requiring a fee bump",
481                detailed_utxo.txid, detailed_utxo.vout
482            );
483            return Ok(None);
484        };
485        let stored = refund_fee_sats(&tx, detailed_utxo.value).map(|fee_sats| PendingRefund {
486            fee_sats,
487            vsize: tx.vsize().try_into().unwrap_or(u64::MAX),
488        });
489
490        let outspend = self
491            .chain_service
492            .get_outspend(detailed_utxo.txid.to_string(), detailed_utxo.vout)
493            .await;
494        match outspend {
495            Ok(Outspend::Unspent) => Ok(None),
496            Ok(Outspend::Spent { txid, status, .. }) if status.confirmed => {
497                Err(SdkError::InvalidInput(format!(
498                    "Deposit {}:{} was already spent by {txid}",
499                    detailed_utxo.txid, detailed_utxo.vout
500                )))
501            }
502            // A conflicting transaction is on the network. The floor is the stored
503            // refund's fee, which is the spender whenever this wallet made it. If
504            // something else did, an underpriced replacement is refused by the
505            // network rather than by this check.
506            Ok(Outspend::Spent { .. }) => Ok(stored),
507            // The outpoint cannot be read, so ask whether the stored refund itself
508            // reached the network. That is authoritative, unlike the recorded state,
509            // which a lost write can leave stale. Gating on a refund that never
510            // landed would raise the bar on every retry, since each attempt stores
511            // its own fee before broadcasting.
512            Err(_) => {
513                let Some(refund_txid) = deposit.refund_tx_id.clone() else {
514                    return Ok(None);
515                };
516                match self.chain_service.get_transaction_status(refund_txid).await {
517                    Ok(status) if status.confirmed => Err(SdkError::InvalidInput(format!(
518                        "Deposit {}:{} was already refunded",
519                        detailed_utxo.txid, detailed_utxo.vout
520                    ))),
521                    Ok(_) => Ok(stored),
522                    Err(_) => Ok(None),
523                }
524            }
525        }
526    }
527
528    /// Confirmations on the deposit transaction, 0 while it is unconfirmed. Needs
529    /// two chain calls: a transaction reports the height it confirmed at, never its
530    /// depth.
531    pub(super) async fn deposit_confirmations(&self, txid: &str) -> Result<u32, SdkError> {
532        self.deposit_confirmations_at_tip(txid, None).await
533    }
534
535    /// Confirmations on the deposit transaction against an already-known chain tip,
536    /// which a caller resolving several deposits fetches once.
537    pub(super) async fn deposit_confirmations_at_tip(
538        &self,
539        txid: &str,
540        tip_height: Option<u32>,
541    ) -> Result<u32, SdkError> {
542        let status = self
543            .chain_service
544            .get_transaction_status(txid.to_string())
545            .await?;
546        // An unconfirmed transaction can still carry a height.
547        if !status.confirmed {
548            return Ok(0);
549        }
550        // Confirmed without a height: at least one deep.
551        let Some(block_height) = status.block_height else {
552            return Ok(1);
553        };
554        let tip_height = match tip_height {
555            Some(tip) => tip,
556            None => self.chain_service.tip_height().await?,
557        };
558        Ok(tip_height.saturating_sub(block_height).saturating_add(1))
559    }
560
561    /// Quotes claiming `detailed_utxo` ahead of maturity. `None` when the provider
562    /// offers nothing for it, including when it cannot yet be quoted at all: this
563    /// is one of two options being priced for display, so it reports absence
564    /// rather than failing the call.
565    async fn fetch_instant_claim_quote(
566        &self,
567        detailed_utxo: &DetailedUtxo,
568    ) -> Option<ClaimDepositQuote> {
569        let quote_result = self
570            .spark_wallet
571            .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
572            .await
573            .inspect_err(|e| {
574                info!(
575                    "No instant quote for {}:{}: {e}",
576                    detailed_utxo.txid, detailed_utxo.vout
577                );
578            })
579            .ok()?;
580        let plan = quote_result
581            .fulfillment_plans
582            .iter()
583            .min_by_key(|p| p.confirmations)?;
584        // What the provider offered, before filtering.
585        info!(
586            "Early claim quoted for {}:{} ({} sats): credits {} at {} confirmations",
587            detailed_utxo.txid,
588            detailed_utxo.vout,
589            detailed_utxo.value,
590            quote_result.quote.credit_amount.original_value,
591            plan.confirmations
592        );
593        Some(claim_deposit_quote(
594            u32::try_from(plan.confirmations.unsigned_abs()).unwrap_or(u32::MAX),
595            detailed_utxo.value,
596            quote_result.quote.credit_amount.original_value,
597            false,
598        ))
599    }
600
601    /// Quotes claiming `detailed_utxo` at maturity. The provider may decline to
602    /// quote a deposit that has not matured, so this falls back to an estimate
603    /// from current on-chain fees.
604    async fn fetch_mature_claim_quote(
605        &self,
606        detailed_utxo: &DetailedUtxo,
607    ) -> Result<ClaimDepositQuote, SdkError> {
608        match self
609            .spark_wallet
610            .fetch_static_deposit_claim_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
611            .await
612        {
613            Ok(quote) => Ok(claim_deposit_quote(
614                maturity_confirmations(self.config.network),
615                detailed_utxo.value,
616                quote.credit_amount_sats,
617                false,
618            )),
619            Err(e) => {
620                info!(
621                    "No mature quote for {}:{}, estimating: {e}",
622                    detailed_utxo.txid, detailed_utxo.vout
623                );
624                let fee_sats = self
625                    .chain_service
626                    .recommended_fees()
627                    .await?
628                    .fastest_fee
629                    .saturating_mul(CLAIM_TX_SIZE_VBYTES);
630                Ok(claim_deposit_quote(
631                    maturity_confirmations(self.config.network),
632                    detailed_utxo.value,
633                    detailed_utxo.value.saturating_sub(fee_sats),
634                    true,
635                ))
636            }
637        }
638    }
639
640    /// Whether the deposit can be claimed at maturity rather than early: true if the
641    /// operators say so, or the chain alone is deep enough. Either suffices because
642    /// the stored flag is only as fresh as the last deposit sync, which need never
643    /// have run, and trusting it alone pays a spread on a deposit that was already
644    /// claimable at maturity.
645    fn is_deposit_mature_at(
646        &self,
647        detailed_utxo: &DetailedUtxo,
648        confirmations: u32,
649        stored: Option<&DepositInfo>,
650    ) -> bool {
651        let stored_mature = stored.is_some_and(|d| d.is_mature);
652        let required = maturity_confirmations(self.config.network);
653        let is_mature = stored_mature || confirmations >= required;
654        info!(
655            "Deposit {}:{} is {} (operators: {}, chain: {}/{} confirmations)",
656            detailed_utxo.txid,
657            detailed_utxo.vout,
658            if is_mature {
659                "mature"
660            } else {
661                "not yet mature"
662            },
663            if stored_mature {
664                "mature"
665            } else {
666                "not mature"
667            },
668            confirmations,
669            required
670        );
671        is_mature
672    }
673
674    /// The stored row for one deposit, or `None` when the syncer has not recorded
675    /// it yet.
676    pub(super) async fn stored_deposit(
677        &self,
678        txid: &str,
679        vout: u32,
680    ) -> Result<Option<DepositInfo>, SdkError> {
681        Ok(self
682            .storage
683            .list_deposits()
684            .await?
685            .into_iter()
686            .find(|d| d.txid == txid && d.vout == vout))
687    }
688
689    /// Claims a specific not-yet-mature deposit instantly, on demand.
690    /// The transfer settles asynchronously, so no payment is returned.
691    async fn instant_claim_deposit(
692        &self,
693        detailed_utxo: &DetailedUtxo,
694        max_fee: Option<MaxFee>,
695        confirmations: u32,
696        row_exists: bool,
697    ) -> Result<ClaimDepositResponse, SdkError> {
698        let resolved_max_fee = self.resolve_max_claim_fee(max_fee).await?;
699        let outcome = match self
700            .instant_claim_utxo(detailed_utxo, resolved_max_fee, confirmations)
701            .await
702        {
703            Ok(outcome) => outcome,
704            // Transient quote-fetch failure: leave unmarked so a retry works.
705            Err(e) => {
706                error!("Instant claim transient error: {e:?}");
707                return Err(e);
708            }
709        };
710
711        // Persist the resolved status. A manual claim can run before the background
712        // sync has inserted the deposit row, in which case update_deposit would be a
713        // no-op and the marker (which stops the sync from re-submitting or
714        // normal-claiming a still-in-flight deposit) would be lost, so insert the row
715        // first when missing. reconcile_deposits removes it once the claim settles.
716        if !row_exists {
717            self.storage
718                .add_deposit(
719                    detailed_utxo.txid.to_string(),
720                    detailed_utxo.vout,
721                    detailed_utxo.value,
722                    false,
723                )
724                .await?;
725        }
726        self.storage
727            .update_deposit(
728                detailed_utxo.txid.to_string(),
729                detailed_utxo.vout,
730                UpdateDepositPayload::InstantClaim {
731                    status: outcome.status(confirmations),
732                },
733            )
734            .await?;
735
736        match &outcome {
737            InstantClaimOutcome::Submitted(claim_id) => info!(
738                "Instant claimed utxo {}:{} with claim_id: {claim_id}",
739                detailed_utxo.txid, detailed_utxo.vout
740            ),
741            InstantClaimOutcome::Declined { error, .. } => {
742                info!("Instant claim declined: {error}");
743            }
744        }
745        Ok(instant_claim_response(&outcome))
746    }
747
748    /// Attempts an instant static deposit claim for `detailed_utxo`, ahead of
749    /// maturity, bounded by the same fee ceiling the claim at maturity uses.
750    /// `Ok(Submitted)` on a submitted claim, `Ok(Declined)` for a terminal outcome
751    /// (no plan offered, spread over the ceiling, or a rejected claim), and `Err`
752    /// for the transient cases that should be retried: a failed quote fetch (the
753    /// SSP may not have indexed the tx yet) and a claim the SSP rejected for
754    /// insufficient depth.
755    pub(super) async fn instant_claim_utxo(
756        &self,
757        detailed_utxo: &DetailedUtxo,
758        resolved_max_fee: Option<(Fee, u64)>,
759        confirmations: u32,
760    ) -> Result<InstantClaimOutcome, SdkError> {
761        // An unresolved max fee is recorded as a zero ceiling: it admits nothing,
762        // and unlike none it stays retryable once one is configured.
763        let max_fee_sats = resolved_max_fee.as_ref().map_or(0, |(_, sats)| *sats);
764
765        let quote_result = self
766            .spark_wallet
767            .fetch_instant_static_deposit_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
768            .await?;
769        info!(
770            "Instant quote for {}:{} ({} sats, ceiling {} sats)",
771            detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value, max_fee_sats
772        );
773        debug!("Instant quote: {quote_result:?}");
774        // Price the spread against the on-chain UTXO value we already hold, not the
775        // SSP-reported deposit amount, so the fee gate does not depend on the quote.
776        match select_instant_claim_plan(
777            &quote_result,
778            detailed_utxo.value,
779            max_fee_sats,
780            confirmations,
781            maturity_confirmations(self.config.network),
782        ) {
783            InstantClaimPlan::Claimable(plan) => {
784                match self
785                    .spark_wallet
786                    .claim_instant_static_deposit(
787                        detailed_utxo.tx.clone(),
788                        quote_result.quote,
789                        plan,
790                    )
791                    .await
792                {
793                    Ok(claim_id) => Ok(InstantClaimOutcome::Submitted(claim_id)),
794                    // A depth rejection: the deposit is below the plan's
795                    // confirmations, or the operators disagree on how deep it is.
796                    Err(e) if is_pending_confirmation_error(&e.to_string()) => Err(e.into()),
797                    // The provider rejected the submission or could not be reached.
798                    Err(e) => {
799                        let error: SdkError = e.into();
800                        Ok(InstantClaimOutcome::Declined {
801                            reason: ClaimDeferredReason::ProviderDeclined {
802                                message: error.to_string(),
803                            },
804                            error,
805                            max_fee_sats: None,
806                        })
807                    }
808                }
809            }
810            InstantClaimPlan::NoPlan => Ok(InstantClaimOutcome::Declined {
811                error: SdkError::Generic("No instant claim plan available".to_string()),
812                max_fee_sats: None,
813                reason: ClaimDeferredReason::NoEarlyClaimAvailable,
814            }),
815            // A quote crediting more than the deposit is the provider's, not a
816            // depth the deposit will reach.
817            InstantClaimPlan::CreditAboveDeposit { credit_sats } => {
818                let message = format!(
819                    "Instant quote credits {credit_sats} sats for {}:{}, which is worth {} sats",
820                    detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value
821                );
822                Ok(InstantClaimOutcome::Declined {
823                    error: SdkError::Generic(message.clone()),
824                    max_fee_sats: None,
825                    reason: ClaimDeferredReason::ProviderDeclined { message },
826                })
827            }
828            InstantClaimPlan::FeeExceeded {
829                quoted_sats,
830                quoted_rate,
831            } => Ok(InstantClaimOutcome::Declined {
832                error: SdkError::MaxDepositClaimFeeExceeded {
833                    tx: detailed_utxo.txid.to_string(),
834                    vout: detailed_utxo.vout,
835                    max_fee: resolved_max_fee.map(|(fee, _)| fee),
836                    required_fee_sats: quoted_sats,
837                    required_fee_rate_sat_per_vbyte: quoted_rate,
838                },
839                max_fee_sats: Some(max_fee_sats),
840                reason: ClaimDeferredReason::MaxFeeExceeded {
841                    required_fee_sats: quoted_sats,
842                    max_fee_sats,
843                },
844            }),
845        }
846    }
847}
848
849/// Result of an instant claim attempt.
850pub(super) enum InstantClaimOutcome {
851    /// The claim was submitted and carries the claim id.
852    Submitted(String),
853    /// The claim was declined: no plan offered, the spread was over the ceiling,
854    /// or the submission failed. `max_fee_sats` is the ceiling that declined it,
855    /// unset when no ceiling was involved.
856    Declined {
857        error: SdkError,
858        max_fee_sats: Option<u64>,
859        reason: ClaimDeferredReason,
860    },
861}
862
863impl InstantClaimOutcome {
864    /// The status to persist on the deposit for this resolved outcome.
865    pub(super) fn status(&self, confirmations: u32) -> InstantClaimStatus {
866        match self {
867            InstantClaimOutcome::Submitted(claim_id) => InstantClaimStatus::Submitted {
868                claim_id: claim_id.clone(),
869            },
870            InstantClaimOutcome::Declined { max_fee_sats, .. } => InstantClaimStatus::Declined {
871                max_fee_sats: *max_fee_sats,
872                confirmations,
873            },
874        }
875    }
876}
877
878/// Whether the provider rejected a claim because the deposit is already claimed,
879/// which is a settled deposit reported as a failure. Neither the SSP nor the
880/// operators return an error code for it, so the message is all there is to go
881/// on.
882pub(super) fn is_already_claimed_error(message: &str) -> bool {
883    message.to_lowercase().contains("already been claimed")
884}
885
886/// Message fragments of the depth rejections that clear on their own. Neither the
887/// SSP nor the operators return an error code for these, so the message is all
888/// there is to go on. `enough confirmations` covers the SSP and the operators
889/// alike, which differ only by contraction; the second covers the SSP's separate
890/// "deep enough on some operators but not all" rejection.
891const PENDING_CONFIRMATION_MARKERS: [&str; 2] =
892    ["enough confirmations", "operators have not seen it"];
893
894/// Whether a rejected claim is the SSP or the operators waiting for the UTXO to
895/// reach the required depth, which clears within seconds. A phrasing outside
896/// [`PENDING_CONFIRMATION_MARKERS`] is terminal, so the deposit stops being
897/// retried and falls through to the claim at maturity.
898fn is_pending_confirmation_error(message: &str) -> bool {
899    let message = message.to_lowercase();
900    PENDING_CONFIRMATION_MARKERS
901        .iter()
902        .any(|marker| message.contains(marker))
903}
904
905/// What an explicit claim runs under, and what it leaves standing on the deposit.
906pub(super) struct ClaimCeiling {
907    /// The ceiling to record on the deposit. `None` clears any standing one.
908    pub stored: Option<MaxFee>,
909    /// The ceiling this claim is held to.
910    pub effective: Option<MaxFee>,
911}
912
913/// Resolves an explicit claim's ceiling. A requested ceiling becomes the deposit's
914/// standing one, so later automatic attempts are held to what the caller chose here.
915/// Requesting none claims under the configured ceiling and clears any standing one.
916pub(super) fn resolve_claim_ceiling(
917    requested: Option<MaxFee>,
918    config_default: Option<MaxFee>,
919) -> ClaimCeiling {
920    ClaimCeiling {
921        stored: requested.clone(),
922        effective: requested.or(config_default),
923    }
924}
925
926/// The ceiling an automatic claim at maturity runs under: whichever of the
927/// deposit's own and the configured one admits more.
928///
929/// A deposit's ceiling governs whether it is claimed early. Letting a lowered one
930/// also cap the claim at maturity would leave a deposit set below the on-chain
931/// claim cost unclaimable until someone intervened, and that claim costs only what
932/// the wallet would have paid anyway.
933///
934/// Takes both already resolved to sats: a fixed amount and a fee rate are not
935/// comparable until they are.
936pub(super) fn larger_ceiling(
937    stored: &MaxFee,
938    stored_sats: u64,
939    config_default: &MaxFee,
940    config_sats: u64,
941) -> MaxFee {
942    if stored_sats >= config_sats {
943        stored.clone()
944    } else {
945        config_default.clone()
946    }
947}
948
949/// The response for a resolved early claim. Neither outcome carries a payment: a
950/// submitted claim settles asynchronously, and a declined one moved nothing.
951pub(super) fn instant_claim_response(outcome: &InstantClaimOutcome) -> ClaimDepositResponse {
952    let outcome = match outcome {
953        InstantClaimOutcome::Submitted(_) => ClaimDepositOutcome::Submitted,
954        InstantClaimOutcome::Declined { reason, .. } => ClaimDepositOutcome::Deferred {
955            reason: reason.clone(),
956        },
957    };
958    ClaimDepositResponse { outcome }
959}
960
961/// Whether a claim has already taken this deposit, still settling or credited.
962/// The chain and the operators go on reporting the UTXO until the provider
963/// spends it, so a deposit that is already spoken for still appears unclaimed.
964pub(super) fn claim_already_made(status: Option<&InstantClaimStatus>) -> bool {
965    matches!(
966        status,
967        Some(InstantClaimStatus::Submitted { .. } | InstantClaimStatus::Claimed)
968    )
969}
970
971/// Whether a deposit's standing ceiling has to be resolved for that deposit alone.
972/// Only one that differs from the configured ceiling does: an equal one resolves to
973/// the same sats as the ceiling already resolved once for the pass.
974pub(super) fn needs_own_ceiling_resolution(
975    stored: Option<&MaxFee>,
976    config_default: Option<&MaxFee>,
977) -> bool {
978    stored.is_some() && stored != config_default
979}
980
981/// Prices one way of claiming a deposit, from the credit it would leave.
982fn claim_deposit_quote(
983    confirmations_required: u32,
984    deposit_sats: u64,
985    credit_amount_sats: u64,
986    is_estimate: bool,
987) -> ClaimDepositQuote {
988    let fee_sats = deposit_sats.saturating_sub(credit_amount_sats);
989    ClaimDepositQuote {
990        confirmations_required,
991        credit_amount_sats,
992        fee_sats,
993        fee_rate_sat_per_vbyte: fee_sats.div_ceil(CLAIM_TX_SIZE_VBYTES),
994        is_estimate,
995    }
996}
997
998/// Classification of an instant quote's shallowest plan against the fee ceiling.
999enum InstantClaimPlan {
1000    /// The plan is within the ceiling and should be claimed.
1001    Claimable(InstantStaticDepositPlan),
1002    /// The quote carried no fulfillment plans at all.
1003    NoPlan,
1004    /// The SSP spread (`deposit - credit`) exceeds the ceiling, in sats and as the
1005    /// on-chain rate it implies over the claim tx (both for the decline message).
1006    FeeExceeded { quoted_sats: u64, quoted_rate: u64 },
1007    /// The quote credits more than the deposit is worth, so there is no spread to
1008    /// price. Carries the credit for the decline message.
1009    CreditAboveDeposit { credit_sats: u64 },
1010}
1011
1012/// Selects the shallowest of the fulfillment plans the SSP returned with the
1013/// quote (the one crediting at the fewest confirmations) and checks the spread
1014/// (`deposit - credit`) against `max_fee_sats`, the same resolved ceiling the
1015/// claim at maturity is held to. An unset ceiling arrives here as zero, which
1016/// admits nothing.
1017fn select_instant_claim_plan(
1018    quote_result: &InstantStaticDepositQuoteResult,
1019    deposit_sats: u64,
1020    max_fee_sats: u64,
1021    confirmations: u32,
1022    maturity_confirmations: u32,
1023) -> InstantClaimPlan {
1024    let Some(plan) = quote_result
1025        .fulfillment_plans
1026        .iter()
1027        .min_by_key(|p| p.confirmations)
1028    else {
1029        return InstantClaimPlan::NoPlan;
1030    };
1031    // The deposit is deep enough to claim at maturity, so an early claim buys
1032    // nothing. The operator feed can still report it immature.
1033    if u64::from(confirmations) >= u64::from(maturity_confirmations) {
1034        return InstantClaimPlan::NoPlan;
1035    }
1036    // Skip plans that only credit once the deposit has matured anyway.
1037    let plan_confirmations = plan.confirmations.unsigned_abs();
1038    if plan_confirmations >= u64::from(maturity_confirmations) {
1039        return InstantClaimPlan::NoPlan;
1040    }
1041    // Skip plans the deposit is not deep enough for yet: submitting one is
1042    // rejected on depth.
1043    if plan_confirmations > u64::from(confirmations) {
1044        return InstantClaimPlan::NoPlan;
1045    }
1046    // Priced off the quote's credit, which is what the claim signs. A credit above
1047    // the deposit's own value is not a fee at all, and must not read as a free
1048    // claim: the claim rejects it at signing time, so reject it here where the
1049    // reason is still attributable.
1050    let credit_sats = quote_result.quote.credit_amount.original_value;
1051    let Some(quoted_sats) = deposit_sats.checked_sub(credit_sats) else {
1052        return InstantClaimPlan::CreditAboveDeposit { credit_sats };
1053    };
1054    if quoted_sats <= max_fee_sats {
1055        InstantClaimPlan::Claimable(plan.clone())
1056    } else {
1057        InstantClaimPlan::FeeExceeded {
1058            quoted_sats,
1059            quoted_rate: quoted_sats.div_ceil(CLAIM_TX_SIZE_VBYTES),
1060        }
1061    }
1062}
1063
1064/// The refund a replacement has to displace. Its size matters as well as its
1065/// fee: the replacement has to beat its feerate, not just its total.
1066#[derive(Clone, Copy)]
1067struct PendingRefund {
1068    fee_sats: u64,
1069    vsize: u64,
1070}
1071
1072/// Rejects a refund that cannot displace one already on the network. A
1073/// replacement only relays if it outbids the refund it conflicts with, and
1074/// overwriting the stored transaction with one that cannot relay would leave the
1075/// deposit with no way out.
1076fn check_replacement_fee(
1077    tx: &Transaction,
1078    deposit_value_sats: u64,
1079    pending: Option<PendingRefund>,
1080) -> Result<(), SdkError> {
1081    let Some(pending) = pending else {
1082        return Ok(());
1083    };
1084    let pending_fee_sats = pending.fee_sats;
1085    let required_fee_sats =
1086        replacement_min_fee_sats(&pending, tx.vsize().try_into().unwrap_or(u64::MAX));
1087    let fee_sats = refund_fee_sats(tx, deposit_value_sats).ok_or_else(|| {
1088        SdkError::Generic("refund pays out more than the deposit holds".to_string())
1089    })?;
1090    if fee_sats < required_fee_sats {
1091        return Err(SdkError::RefundReplacementFeeTooLow {
1092            pending_fee_sats,
1093            required_fee_sats,
1094        });
1095    }
1096    Ok(())
1097}
1098
1099/// Fee a refund pays, from the deposit output it spends. `None` if the refund
1100/// pays out more than the deposit holds.
1101fn refund_fee_sats(refund_tx: &Transaction, deposit_value_sats: u64) -> Option<u64> {
1102    let out_sats: u64 = refund_tx.output.iter().map(|o| o.value.to_sat()).sum();
1103    deposit_value_sats.checked_sub(out_sats)
1104}
1105
1106/// Minimum fee a replacement must pay to displace a refund already on the
1107/// network: more than that refund pays, plus the relay cost of its own size.
1108fn replacement_min_fee_sats(pending: &PendingRefund, replacement_vsize: u64) -> u64 {
1109    // Cover the pending fee plus the replacement's own relay bandwidth.
1110    let bandwidth = pending
1111        .fee_sats
1112        .saturating_add(replacement_vsize.saturating_mul(MIN_RELAY_FEE_SAT_PER_VBYTE));
1113    // And beat its feerate outright, which only bites when the replacement is the
1114    // larger transaction, as it is when the destination widens to taproot. The
1115    // comparison is made on feerates truncated to whole sat/kvB, so a fee that is
1116    // higher as an exact rational can still land in the same bucket and be refused.
1117    let pending_per_kvb = pending
1118        .fee_sats
1119        .saturating_mul(1000)
1120        .checked_div(pending.vsize)
1121        .unwrap_or(u64::MAX);
1122    let feerate = pending_per_kvb
1123        .saturating_add(1)
1124        .saturating_mul(replacement_vsize)
1125        .div_ceil(1000);
1126    bandwidth.max(feerate)
1127}
1128
1129/// Serialises claim attempts on the same deposit within this process.
1130///
1131/// A claim spends several seconds fetching a quote, transferring and signing
1132/// before anything reaches storage, so a second attempt starting in that window
1133/// finds no trace of the first. Holding the outpoint for the whole attempt closes
1134/// that, which persisted state cannot: it is only written once the claim returns.
1135#[derive(Clone, Default)]
1136pub(crate) struct ClaimGuards {
1137    in_flight: Arc<Mutex<HashSet<TxOutput>>>,
1138}
1139
1140impl ClaimGuards {
1141    /// `None` when an attempt on this outpoint is already running.
1142    pub(crate) fn try_acquire(&self, outpoint: TxOutput) -> Option<ClaimGuard> {
1143        let mut in_flight = self
1144            .in_flight
1145            .lock()
1146            .unwrap_or_else(std::sync::PoisonError::into_inner);
1147        if !in_flight.insert(outpoint.clone()) {
1148            return None;
1149        }
1150        Some(ClaimGuard {
1151            guards: self.clone(),
1152            outpoint,
1153        })
1154    }
1155
1156    fn release(&self, outpoint: &TxOutput) {
1157        self.in_flight
1158            .lock()
1159            .unwrap_or_else(std::sync::PoisonError::into_inner)
1160            .remove(outpoint);
1161    }
1162}
1163
1164/// Releases the outpoint when dropped, so a claim that fails or panics does not
1165/// leave it locked out.
1166pub(crate) struct ClaimGuard {
1167    guards: ClaimGuards,
1168    outpoint: TxOutput,
1169}
1170
1171impl Drop for ClaimGuard {
1172    fn drop(&mut self) {
1173        self.guards.release(&self.outpoint);
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use bitcoin::{
1180        Amount, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, absolute::LockTime,
1181        transaction::Version,
1182    };
1183    use spark_wallet::{
1184        CurrencyAmount, InstantStaticDepositPlan, InstantStaticDepositQuote,
1185        InstantStaticDepositQuoteResult,
1186    };
1187
1188    use super::{
1189        ClaimDeferredReason, ClaimDepositOutcome, ClaimGuards, InstantClaimOutcome,
1190        InstantClaimPlan, MaxFee, PendingRefund, SdkError, TxOutput, check_replacement_fee,
1191        claim_deposit_quote, instant_claim_response, is_already_claimed_error,
1192        is_pending_confirmation_error, larger_ceiling, needs_own_ceiling_resolution,
1193        refund_fee_sats, replacement_min_fee_sats, resolve_claim_ceiling,
1194        select_instant_claim_plan,
1195    };
1196
1197    // ---- resolve_claim_ceiling / larger_ceiling ----
1198
1199    fn fixed(amount: u64) -> MaxFee {
1200        MaxFee::Fixed { amount }
1201    }
1202
1203    #[test]
1204    fn a_requested_ceiling_is_used_and_recorded() {
1205        let ceiling = resolve_claim_ceiling(Some(fixed(50_000)), Some(fixed(99)));
1206        assert_eq!(ceiling.effective, Some(fixed(50_000)));
1207        // Recorded so later automatic attempts are held to what the caller chose,
1208        // which is what lets a raised ceiling claim a deposit early unattended.
1209        assert_eq!(ceiling.stored, Some(fixed(50_000)));
1210    }
1211
1212    #[test]
1213    fn requesting_no_ceiling_claims_under_config_and_clears_the_stored_one() {
1214        let ceiling = resolve_claim_ceiling(None, Some(fixed(99)));
1215        assert_eq!(ceiling.effective, Some(fixed(99)));
1216        // A bare claim means "claim under the wallet defaults", so it also retires
1217        // any ceiling standing on the deposit.
1218        assert_eq!(ceiling.stored, None);
1219    }
1220
1221    #[test]
1222    fn no_ceiling_anywhere_leaves_nothing_to_claim_within() {
1223        let ceiling = resolve_claim_ceiling(None, None);
1224        assert_eq!(ceiling.effective, None);
1225        assert_eq!(ceiling.stored, None);
1226    }
1227
1228    #[test]
1229    fn a_requested_ceiling_replaces_the_configured_one() {
1230        // Not combined with it: a request below config must lower the claim, not
1231        // be raised back up to it.
1232        let ceiling = resolve_claim_ceiling(Some(fixed(10)), Some(fixed(99)));
1233        assert_eq!(ceiling.effective, Some(fixed(10)));
1234    }
1235
1236    #[test]
1237    fn a_raised_ceiling_governs_the_claim_at_maturity() {
1238        assert_eq!(
1239            larger_ceiling(&fixed(50_000), 50_000, &fixed(99), 99),
1240            fixed(50_000)
1241        );
1242    }
1243
1244    #[test]
1245    fn a_ceiling_lowered_to_hold_a_deposit_back_does_not_cap_its_mature_claim() {
1246        // Lowering it keeps the deposit from being claimed early. Capping the claim
1247        // at maturity too would leave a deposit set below the on-chain cost stuck
1248        // until someone intervened.
1249        assert_eq!(larger_ceiling(&fixed(10), 10, &fixed(99), 99), fixed(99));
1250    }
1251
1252    #[test]
1253    fn ceilings_are_compared_as_sats_not_by_shape() {
1254        // A rate and a fixed amount are only comparable once resolved, and the rate
1255        // here admits more despite the smaller number on its face.
1256        let rate = MaxFee::Rate { sat_per_vbyte: 5 };
1257        assert_eq!(larger_ceiling(&rate, 495, &fixed(99), 99), rate);
1258        assert_eq!(larger_ceiling(&fixed(99), 99, &rate, 495), rate);
1259    }
1260
1261    #[test]
1262    fn an_equal_ceiling_keeps_the_deposit_own() {
1263        // Nothing to gain from swapping, and the deposit's own is the more specific.
1264        assert_eq!(larger_ceiling(&fixed(99), 99, &fixed(99), 99), fixed(99));
1265    }
1266
1267    // ---- instant_claim_response ----
1268
1269    fn declined(reason: ClaimDeferredReason) -> InstantClaimOutcome {
1270        InstantClaimOutcome::Declined {
1271            error: SdkError::Generic("declined".to_string()),
1272            max_fee_sats: None,
1273            reason,
1274        }
1275    }
1276
1277    #[test]
1278    fn a_submitted_early_claim_returns_no_payment_yet() {
1279        let response =
1280            instant_claim_response(&InstantClaimOutcome::Submitted("claim-1".to_string()));
1281        // The transfer settles asynchronously, so the caller watches for it rather
1282        // than reading it off this response: only Settled carries a payment.
1283        assert!(matches!(response.outcome, ClaimDepositOutcome::Submitted));
1284    }
1285
1286    #[test]
1287    fn a_declined_early_claim_defers_rather_than_failing() {
1288        // The deposit falls through to the claim at maturity, so this is an
1289        // ordinary outcome and the caller is told what it would have cost.
1290        let response = instant_claim_response(&declined(ClaimDeferredReason::MaxFeeExceeded {
1291            required_fee_sats: 5_000,
1292            max_fee_sats: 1_000,
1293        }));
1294        let ClaimDepositOutcome::Deferred { reason } = response.outcome else {
1295            panic!("a declined early claim must defer, not fail")
1296        };
1297        assert_eq!(
1298            reason,
1299            ClaimDeferredReason::MaxFeeExceeded {
1300                required_fee_sats: 5_000,
1301                max_fee_sats: 1_000,
1302            }
1303        );
1304    }
1305
1306    #[test]
1307    fn a_deposit_too_shallow_for_any_plan_defers_without_a_fee() {
1308        let response =
1309            instant_claim_response(&declined(ClaimDeferredReason::NoEarlyClaimAvailable));
1310        let ClaimDepositOutcome::Deferred { reason } = response.outcome else {
1311            panic!("too shallow for a plan must defer")
1312        };
1313        assert_eq!(reason, ClaimDeferredReason::NoEarlyClaimAvailable);
1314    }
1315
1316    #[test]
1317    fn a_provider_decline_is_not_reported_as_a_depth_to_wait_out() {
1318        // Waiting for a confirmation does not address an unreachable provider, so
1319        // this must not collapse into NoEarlyClaimAvailable.
1320        let response = instant_claim_response(&declined(ClaimDeferredReason::ProviderDeclined {
1321            message: "transport error".to_string(),
1322        }));
1323        let ClaimDepositOutcome::Deferred { reason } = response.outcome else {
1324            panic!("a provider decline must defer")
1325        };
1326        assert_eq!(
1327            reason,
1328            ClaimDeferredReason::ProviderDeclined {
1329                message: "transport error".to_string()
1330            }
1331        );
1332    }
1333
1334    #[test]
1335    fn only_a_ceiling_differing_from_config_is_resolved_on_its_own() {
1336        let config = fixed(99);
1337        // Nothing of its own, so the ceiling resolved once for the pass serves.
1338        assert!(!needs_own_ceiling_resolution(None, Some(&config)));
1339        // The same value resolves to the same sats, so re-resolving it would buy a
1340        // second fee lookup and nothing else.
1341        assert!(!needs_own_ceiling_resolution(
1342            Some(&fixed(99)),
1343            Some(&config)
1344        ));
1345        assert!(needs_own_ceiling_resolution(
1346            Some(&fixed(50_000)),
1347            Some(&config)
1348        ));
1349        // Lower than the configured ceiling counts as differing too: the deposit is
1350        // being held back, which the pass ceiling would not do.
1351        assert!(needs_own_ceiling_resolution(
1352            Some(&fixed(10)),
1353            Some(&config)
1354        ));
1355        // Nothing configured at all, so the pass has no ceiling to fall back on.
1356        assert!(needs_own_ceiling_resolution(Some(&fixed(10)), None));
1357        assert!(!needs_own_ceiling_resolution(None, None));
1358        // Equal values of a different variant are still equal.
1359        assert!(!needs_own_ceiling_resolution(
1360            Some(&MaxFee::Rate { sat_per_vbyte: 4 }),
1361            Some(&MaxFee::Rate { sat_per_vbyte: 4 })
1362        ));
1363    }
1364
1365    fn sats(value: u64) -> CurrencyAmount {
1366        CurrencyAmount {
1367            original_value: value,
1368            ..Default::default()
1369        }
1370    }
1371
1372    /// Builds a quote for a `deposit_sats` UTXO with one fulfillment plan per
1373    /// `(confirmations, credit_sats)` pair. The quote-level credit mirrors the
1374    /// first plan's.
1375    fn quote_result(deposit_sats: u64, plans: &[(i64, u64)]) -> InstantStaticDepositQuoteResult {
1376        InstantStaticDepositQuoteResult {
1377            quote: InstantStaticDepositQuote {
1378                id: "quote-id".to_string(),
1379                transaction_id: "tx".to_string(),
1380                output_index: 0,
1381                deposit_amount: sats(deposit_sats),
1382                credit_amount: sats(plans.first().map_or(0, |(_, credit)| *credit)),
1383                quote_signature: "00".to_string(),
1384            },
1385            fulfillment_plans: plans
1386                .iter()
1387                .enumerate()
1388                .map(
1389                    |(i, (confirmations, credit_sats))| InstantStaticDepositPlan {
1390                        id: format!("plan-{i}"),
1391                        amount: sats(*credit_sats),
1392                        confirmations: *confirmations,
1393                    },
1394                )
1395                .collect(),
1396        }
1397    }
1398
1399    #[test]
1400    fn quotes_the_fee_as_the_credit_shortfall() {
1401        // 20_000 deposit crediting 18_810 costs 1_190, which over the 99 vbyte
1402        // claim tx rounds up to 13 sat/vbyte.
1403        let quote = claim_deposit_quote(1, 20_000, 18_810, false);
1404        assert_eq!(quote.confirmations_required, 1);
1405        assert_eq!(quote.credit_amount_sats, 18_810);
1406        assert_eq!(quote.fee_sats, 1_190);
1407        assert_eq!(quote.fee_rate_sat_per_vbyte, 13);
1408        assert!(!quote.is_estimate);
1409    }
1410
1411    #[test]
1412    fn quotes_a_free_claim_at_a_zero_rate() {
1413        let quote = claim_deposit_quote(3, 20_000, 20_000, true);
1414        assert_eq!(quote.fee_sats, 0);
1415        assert_eq!(quote.fee_rate_sat_per_vbyte, 0);
1416        assert!(quote.is_estimate);
1417    }
1418
1419    #[test]
1420    fn quotes_zero_rather_than_underflowing_on_a_credit_above_the_deposit() {
1421        // Not something the provider should return, but the arithmetic must not
1422        // wrap into an enormous fee if it ever does.
1423        let quote = claim_deposit_quote(0, 20_000, 25_000, false);
1424        assert_eq!(quote.fee_sats, 0);
1425        assert_eq!(quote.fee_rate_sat_per_vbyte, 0);
1426    }
1427
1428    #[test]
1429    fn selects_zero_conf_plan_within_ceiling() {
1430        // Spread 1_000, ceiling 2_000 -> claim.
1431        let q = quote_result(100_000, &[(0, 99_000), (1, 99_500)]);
1432        let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 0, 3)
1433        else {
1434            panic!("expected a claimable 0-conf plan");
1435        };
1436        assert_eq!(plan.confirmations, 0);
1437    }
1438
1439    #[test]
1440    fn selects_shallowest_plan_when_no_zero_conf_plan() {
1441        // A deposit that has already confirmed gets no 0-conf plan: claim at the
1442        // shallowest depth offered rather than waiting for maturity.
1443        let q = quote_result(100_000, &[(1, 99_000), (2, 99_500)]);
1444        let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 1, 3)
1445        else {
1446            panic!("expected the 1-conf plan to be claimable");
1447        };
1448        assert_eq!(plan.confirmations, 1);
1449    }
1450
1451    #[test]
1452    fn selects_shallowest_plan_regardless_of_order() {
1453        let q = quote_result(100_000, &[(3, 99_900), (1, 99_500), (0, 99_000)]);
1454        let InstantClaimPlan::Claimable(plan) = select_instant_claim_plan(&q, 100_000, 2_000, 0, 3)
1455        else {
1456            panic!("expected a claimable plan");
1457        };
1458        assert_eq!(plan.confirmations, 0);
1459    }
1460
1461    #[test]
1462    fn skips_a_plan_that_credits_no_sooner_than_maturity() {
1463        // The SSP quotes its floor, not this deposit's depth, so a deposit well
1464        // past maturity is still offered a shallow plan. Claiming it would pay a
1465        // spread for a wait the deposit no longer has.
1466        let q = quote_result(100_000, &[(1, 99_000)]);
1467        assert!(matches!(
1468            select_instant_claim_plan(&q, 100_000, 10_000, 1, 1),
1469            InstantClaimPlan::NoPlan
1470        ));
1471        // The same plan is worth claiming when maturity really is further out.
1472        assert!(matches!(
1473            select_instant_claim_plan(&q, 100_000, 10_000, 1, 3),
1474            InstantClaimPlan::Claimable(_)
1475        ));
1476    }
1477
1478    #[test]
1479    fn skips_when_the_deposit_is_already_deep_enough_to_mature() {
1480        // The operator feed can lag the chain, so a deposit past maturity depth
1481        // still reaches the selector. Paying a spread buys no time.
1482        let q = quote_result(100_000, &[(1, 99_000)]);
1483        assert!(matches!(
1484            select_instant_claim_plan(&q, 100_000, 10_000, 5, 3),
1485            InstantClaimPlan::NoPlan
1486        ));
1487    }
1488
1489    #[test]
1490    fn skips_a_plan_the_deposit_is_not_deep_enough_for() {
1491        // The SSP quotes a floor above this deposit's depth: submitting it is
1492        // rejected on depth, so wait for a confirmation and re-quote.
1493        let q = quote_result(100_000, &[(2, 99_000)]);
1494        assert!(matches!(
1495            select_instant_claim_plan(&q, 100_000, 10_000, 1, 3),
1496            InstantClaimPlan::NoPlan
1497        ));
1498        // The same plan is claimable once the deposit reaches the floor.
1499        assert!(matches!(
1500            select_instant_claim_plan(&q, 100_000, 10_000, 2, 3),
1501            InstantClaimPlan::Claimable(_)
1502        ));
1503    }
1504
1505    #[test]
1506    fn skips_when_no_plans_offered() {
1507        let q = quote_result(100_000, &[]);
1508        assert!(matches!(
1509            select_instant_claim_plan(&q, 100_000, 100_000, 0, 3),
1510            InstantClaimPlan::NoPlan
1511        ));
1512    }
1513
1514    /// The substitution the claim-time binding rejects, seen from the fee gate: a
1515    /// credit above the deposit's value has no spread to price, and saturating it
1516    /// to zero would admit it against any ceiling.
1517    #[test]
1518    fn declines_a_credit_above_the_deposit_value() {
1519        let q = quote_result(100_000, &[(0, 125_000)]);
1520        assert!(matches!(
1521            select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1522            InstantClaimPlan::CreditAboveDeposit {
1523                credit_sats: 125_000
1524            }
1525        ));
1526    }
1527
1528    #[test]
1529    fn declines_when_no_ceiling_is_set() {
1530        // An unset max fee reaches the gate as zero, which admits nothing. It is
1531        // recorded as a real ceiling, so configuring one later still retries.
1532        let q = quote_result(100_000, &[(0, 99_000)]);
1533        assert!(matches!(
1534            select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1535            InstantClaimPlan::FeeExceeded { .. }
1536        ));
1537    }
1538
1539    #[test]
1540    fn gates_on_the_credit_the_claim_signs() {
1541        // The user statement signs the quote's credit, so that is what the ceiling
1542        // has to be checked against. Here the quote credits 90_000 while the plan
1543        // that gets selected (the shallowest) names 99_000: gating on the plan
1544        // would see a 1_000 spread and admit it, when the claim actually authorizes
1545        // a 10_000 one.
1546        let q = quote_result(100_000, &[(3, 90_000), (0, 99_000)]);
1547        assert_eq!(q.quote.credit_amount.original_value, 90_000);
1548        assert!(matches!(
1549            select_instant_claim_plan(&q, 100_000, 2_000, 0, 3),
1550            InstantClaimPlan::FeeExceeded {
1551                quoted_sats: 10_000,
1552                ..
1553            }
1554        ));
1555    }
1556
1557    #[test]
1558    fn skips_when_spread_over_ceiling() {
1559        // Spread 5_000 against a 1_000 ceiling -> skip. The reported rate is the
1560        // spread over the claim tx, so it is comparable with `MaxFee::Rate`.
1561        let q = quote_result(100_000, &[(0, 95_000)]);
1562        assert!(matches!(
1563            select_instant_claim_plan(&q, 100_000, 1_000, 0, 3),
1564            InstantClaimPlan::FeeExceeded {
1565                quoted_sats: 5_000,
1566                quoted_rate: 51
1567            }
1568        ));
1569    }
1570
1571    #[test]
1572    fn rejects_any_spread_at_a_zero_ceiling() {
1573        let q = quote_result(100_000, &[(0, 99_000)]);
1574        assert!(matches!(
1575            select_instant_claim_plan(&q, 100_000, 0, 0, 3),
1576            InstantClaimPlan::FeeExceeded { .. }
1577        ));
1578    }
1579
1580    #[test]
1581    fn accepts_spread_equal_to_ceiling() {
1582        // Inclusive at the ceiling.
1583        let q = quote_result(100_000, &[(0, 99_000)]);
1584        assert!(matches!(
1585            select_instant_claim_plan(&q, 100_000, 1_000, 0, 3),
1586            InstantClaimPlan::Claimable(_)
1587        ));
1588    }
1589
1590    #[test]
1591    fn one_ceiling_admits_small_declines_large() {
1592        // The SSP spread carries a term proportional to the deposit, so an absolute
1593        // ceiling admits small deposits and makes large ones wait: the inverse of a
1594        // bps cap. Spreads model `100 flat + 990 on-chain + 50 bps`.
1595        let ceiling = 2_000;
1596        // 20_000 deposit: spread 1_190 -> claimed.
1597        let small = quote_result(20_000, &[(0, 18_810)]);
1598        assert!(matches!(
1599            select_instant_claim_plan(&small, 20_000, ceiling, 0, 3),
1600            InstantClaimPlan::Claimable(_)
1601        ));
1602        // 1_000_000 deposit: spread 6_090 -> declined, waits for maturity.
1603        let large = quote_result(1_000_000, &[(0, 993_910)]);
1604        assert!(matches!(
1605            select_instant_claim_plan(&large, 1_000_000, ceiling, 0, 3),
1606            InstantClaimPlan::FeeExceeded { .. }
1607        ));
1608    }
1609
1610    #[test]
1611    fn treats_ssp_depth_rejections_as_retryable() {
1612        // Verbatim SSP rejections: the UTXO is not deep enough yet, or is deep
1613        // enough but has not propagated to every operator.
1614        assert!(is_pending_confirmation_error(
1615            "graphql error: UTXO does not have enough confirmations. Required: 1, got: 0"
1616        ));
1617        assert!(is_pending_confirmation_error(
1618            "graphql error: UTXO needs 1 confirmations on every Spark operator before it \
1619             can be claimed. Some operators have not seen it that deep yet. Retry in a \
1620             few seconds."
1621        ));
1622        // The Spark operators phrase it differently again, with a contraction.
1623        assert!(is_pending_confirmation_error(
1624            "deposit tx doesn't have enough confirmations: confirmation height: 100 \
1625             current block height: 100"
1626        ));
1627        // A rejected statement is terminal and must stay that way.
1628        assert!(!is_pending_confirmation_error(
1629            "graphql error: Something went wrong."
1630        ));
1631    }
1632
1633    #[test]
1634    fn prices_spread_off_passed_deposit_not_quote() {
1635        // The quote claims a 100_000 deposit, but we pass the real on-chain value
1636        // (50_000). Spread is priced off the passed value: 50_000 - 49_500 = 500,
1637        // within the 1_000 ceiling -> claim. Pricing off the quote's 100_000 would
1638        // give a 50_500 spread and decline, so a claim proves the passed value
1639        // drives the gate.
1640        let q = quote_result(100_000, &[(0, 49_500)]);
1641        assert!(matches!(
1642            select_instant_claim_plan(&q, 50_000, 1_000, 0, 3),
1643            InstantClaimPlan::Claimable(_)
1644        ));
1645    }
1646
1647    fn outpoint(vout: u32) -> TxOutput {
1648        TxOutput {
1649            txid: "tx".to_string(),
1650            vout,
1651        }
1652    }
1653
1654    fn refund_paying_out(out_sats: u64) -> Transaction {
1655        Transaction {
1656            version: Version::non_standard(3),
1657            lock_time: LockTime::ZERO,
1658            input: vec![TxIn {
1659                previous_output: OutPoint::null(),
1660                ..Default::default()
1661            }],
1662            output: vec![TxOut {
1663                value: Amount::from_sat(out_sats),
1664                script_pubkey: ScriptBuf::new(),
1665            }],
1666        }
1667    }
1668
1669    #[test]
1670    fn a_second_attempt_on_the_same_outpoint_is_refused() {
1671        let guards = ClaimGuards::default();
1672        let first = guards.try_acquire(outpoint(0));
1673        assert!(first.is_some());
1674        assert!(guards.try_acquire(outpoint(0)).is_none());
1675        drop(first);
1676        assert!(guards.try_acquire(outpoint(0)).is_some());
1677    }
1678
1679    #[test]
1680    fn different_outpoints_do_not_block_each_other() {
1681        let guards = ClaimGuards::default();
1682        let _first = guards.try_acquire(outpoint(0));
1683        assert!(guards.try_acquire(outpoint(1)).is_some());
1684    }
1685
1686    #[test]
1687    fn fee_is_what_the_refund_leaves_behind() {
1688        assert_eq!(
1689            refund_fee_sats(&refund_paying_out(99_889), 100_000),
1690            Some(111)
1691        );
1692        assert_eq!(
1693            refund_fee_sats(&refund_paying_out(100_000), 100_000),
1694            Some(0)
1695        );
1696        // A refund cannot pay out more than the deposit holds.
1697        assert_eq!(refund_fee_sats(&refund_paying_out(100_001), 100_000), None);
1698    }
1699
1700    #[test]
1701    fn replacement_must_cover_the_pending_fee_and_its_own_relay() {
1702        // Displacing a 111 sat refund with a 111 vbyte replacement costs 222,
1703        // not 112: the replacement also pays to relay its own bytes.
1704        let pending = PendingRefund {
1705            fee_sats: 111,
1706            vsize: 111,
1707        };
1708        assert_eq!(replacement_min_fee_sats(&pending, 111), 222);
1709        let free = PendingRefund {
1710            fee_sats: 0,
1711            vsize: 111,
1712        };
1713        assert_eq!(replacement_min_fee_sats(&free, 111), 111);
1714    }
1715
1716    #[test]
1717    fn a_larger_replacement_must_beat_the_pending_feerate_too() {
1718        // Covering the pending fee plus the replacement's own bandwidth is not
1719        // enough when the replacement is bigger: 3000 sats over 99 vB is 30.3
1720        // sat/vB, and 3111 over 111 vB would be 28.0, which the network refuses.
1721        let pending = PendingRefund {
1722            fee_sats: 3_000,
1723            vsize: 99,
1724        };
1725        let required = replacement_min_fee_sats(&pending, 111);
1726        assert_eq!(required, 3_364);
1727        assert!(
1728            required * pending.vsize > pending.fee_sats * 111,
1729            "a replacement has to beat the pending feerate outright"
1730        );
1731
1732        // Same size, so paying the bandwidth is all it takes.
1733        assert_eq!(replacement_min_fee_sats(&pending, 99), 3_099);
1734
1735        // Truncation to whole sat/kvB: 1045 over 111 vB and 932 over 99 both come
1736        // to 9414, which is a tie and refused, so the floor has to be 1046.
1737        let tie = PendingRefund {
1738            fee_sats: 932,
1739            vsize: 99,
1740        };
1741        assert_eq!(replacement_min_fee_sats(&tie, 111), 1_046);
1742
1743        // A small pending fee never reaches the feerate rule.
1744        let small = PendingRefund {
1745            fee_sats: 300,
1746            vsize: 99,
1747        };
1748        assert_eq!(replacement_min_fee_sats(&small, 111), 411);
1749    }
1750
1751    #[test]
1752    fn replacement_is_rejected_until_it_outbids_the_pending_refund() {
1753        let deposit = 100_000u64;
1754        let pending = 500u64;
1755        let vsize = refund_paying_out(0).vsize() as u64;
1756        let required = replacement_min_fee_sats(
1757            &PendingRefund {
1758                fee_sats: pending,
1759                vsize,
1760            },
1761            vsize,
1762        );
1763
1764        // Nothing on the network to outbid, so any fee is fine.
1765        assert!(check_replacement_fee(&refund_paying_out(deposit - 1), deposit, None).is_ok());
1766
1767        let pending_refund = PendingRefund {
1768            fee_sats: pending,
1769            vsize,
1770        };
1771
1772        // A single sat short of the floor is not enough, even though it pays
1773        // more than the refund it is trying to displace.
1774        let short = refund_paying_out(deposit - required + 1);
1775        assert!(refund_fee_sats(&short, deposit).unwrap() > pending);
1776        assert!(matches!(
1777            check_replacement_fee(&short, deposit, Some(pending_refund)),
1778            Err(SdkError::RefundReplacementFeeTooLow {
1779                pending_fee_sats,
1780                required_fee_sats,
1781            }) if pending_fee_sats == pending && required_fee_sats == required
1782        ));
1783
1784        // Paying exactly the floor is accepted.
1785        let exact = refund_paying_out(deposit - required);
1786        assert!(check_replacement_fee(&exact, deposit, Some(pending_refund)).is_ok());
1787    }
1788
1789    #[test]
1790    fn an_already_claimed_deposit_is_not_a_claim_failure() {
1791        // What the SSP answers a claim at maturity with when an early claim has
1792        // already taken the UTXO.
1793        assert!(is_already_claimed_error(
1794            "SparkSdkError: Service error: service provider error: graphql error: \
1795             Static deposit has already been claimed, transaction_id: abc, output_index: 0, \
1796             transfer_id: 01a0"
1797        ));
1798        // A genuine failure still records why.
1799        assert!(!is_already_claimed_error(
1800            "deposit does not have enough confirmations"
1801        ));
1802        assert!(!is_already_claimed_error("insufficient funds"));
1803    }
1804}