Skip to main content

breez_sdk_spark/sdk/
sync.rs

1use platform_utils::time::{Instant, SystemTime};
2use platform_utils::tokio;
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5use tracing::{debug, error, info, trace, warn};
6
7use super::{
8    BreezSdk, CLAIM_TX_SIZE_VBYTES, SYNC_PAGING_LIMIT, SyncType, deposits::InstantClaimOutcome,
9    parse_input,
10};
11use crate::{
12    DepositInfo, Fee, InputType, InstantClaimDeclineReason, InstantClaimStatus, MaxFee,
13    PaymentDetails, PaymentType,
14    error::SdkError,
15    events::{InternalSyncedEvent, SdkEvent},
16    lnurl::ListMetadataRequest,
17    models::{Payment, SyncWalletRequest, SyncWalletResponse},
18    persist::{ObjectCacheRepository, UpdateDepositPayload},
19    sync::SparkSyncService,
20    utils::{
21        deposit_chain_syncer::{DepositChainSyncer, TxOutput},
22        payments::update_balances,
23        utxo_fetcher::DetailedUtxo,
24    },
25};
26
27/// Whether a matured deposit should be claimed now, given its instant-claim
28/// status. A `Submitted` instant claim is still settling, so the deposit must not
29/// be claimed until that claim settles and it is reconciled out; any other status
30/// (declined, or no instant attempt) is fine to claim.
31fn should_claim_matured_deposit(status: Option<&InstantClaimStatus>) -> bool {
32    !matches!(status, Some(InstantClaimStatus::Submitted { .. }))
33}
34
35/// Whether the cascade should (re)attempt an instant claim at `ceiling_bps`. Only a
36/// `FeeExceeded` decline is retryable, and only at a strictly higher ceiling than the
37/// one that failed: a manual attempt's ceiling may be below the config's, and the
38/// strict bound stops a fixed config ceiling re-quoting every sync. Others are terminal.
39fn instant_claim_worth_attempting(status: Option<&InstantClaimStatus>, ceiling_bps: u32) -> bool {
40    match status {
41        None => true,
42        Some(InstantClaimStatus::Declined {
43            reason: InstantClaimDeclineReason::FeeExceeded { max_bps, .. },
44        }) => ceiling_bps > *max_bps,
45        Some(_) => false,
46    }
47}
48
49/// Indexes the deposits that carry an instant-claim status by their outpoint.
50fn instant_claim_status_map(deposits: &[DepositInfo]) -> HashMap<TxOutput, InstantClaimStatus> {
51    deposits
52        .iter()
53        .filter_map(|d| {
54            d.instant_claim_status.clone().map(|status| {
55                (
56                    TxOutput {
57                        txid: d.txid.clone(),
58                        vout: d.vout,
59                    },
60                    status,
61                )
62            })
63        })
64        .collect()
65}
66
67impl BreezSdk {
68    pub(in crate::sdk) async fn sync_single_lnurl_metadata(&self, payment: &mut Payment) {
69        if payment.payment_type != PaymentType::Receive {
70            return;
71        }
72
73        let Some(PaymentDetails::Lightning {
74            invoice,
75            lnurl_receive_metadata,
76            ..
77        }) = &mut payment.details
78        else {
79            return;
80        };
81
82        if lnurl_receive_metadata.is_some() {
83            // Already have lnurl metadata
84            return;
85        }
86
87        let Ok(input) = parse_input(invoice, None).await else {
88            error!(
89                "Failed to parse invoice for lnurl metadata sync: {}",
90                invoice
91            );
92            return;
93        };
94
95        let InputType::Bolt11Invoice(details) = input else {
96            error!(
97                "Input is not a Bolt11 invoice for lnurl metadata sync: {}",
98                invoice
99            );
100            return;
101        };
102
103        // If there is a description hash, we assume this is a lnurl payment.
104        if details.description_hash.is_none() {
105            return;
106        }
107
108        // Let's check whether the lnurl receive metadata was already synced, then return early.
109        // Important: Only return early if metadata is actually present (Some), otherwise we need
110        // to trigger a sync. This prevents a race condition where the payment is in storage but
111        // metadata sync from TransferClaimStarting hasn't completed yet.
112        if let Ok(db_payment) = self.storage.get_payment_by_id(payment.id.clone()).await
113            && let Some(PaymentDetails::Lightning {
114                lnurl_receive_metadata: db_lnurl_receive_metadata @ Some(_),
115                ..
116            }) = db_payment.details
117        {
118            *lnurl_receive_metadata = db_lnurl_receive_metadata;
119            return;
120        }
121
122        // Sync lnurl metadata directly instead of going through the sync trigger,
123        // because this function is called from the sync loop's event handler,
124        // which would deadlock waiting for itself to process the trigger.
125        if let Err(e) = self.sync_lnurl_metadata().await {
126            error!("Failed to sync lnurl metadata for invoice {invoice}: {e}");
127            return;
128        }
129
130        let db_payment = match self.storage.get_payment_by_id(payment.id.clone()).await {
131            Ok(p) => p,
132            Err(e) => {
133                debug!("Payment not found in storage for invoice {}: {e}", invoice);
134                return;
135            }
136        };
137
138        let Some(PaymentDetails::Lightning {
139            lnurl_receive_metadata: db_lnurl_receive_metadata,
140            ..
141        }) = db_payment.details
142        else {
143            debug!(
144                "No lnurl receive metadata in storage for invoice {}",
145                invoice
146            );
147            return;
148        };
149        *lnurl_receive_metadata = db_lnurl_receive_metadata;
150    }
151
152    #[allow(clippy::too_many_lines)]
153    pub(super) async fn sync_wallet_internal(
154        &self,
155        sync_type: SyncType,
156        force: bool,
157    ) -> Result<(), SdkError> {
158        let cache = ObjectCacheRepository::new(self.storage.clone());
159        let sync_interval_secs = u64::from(self.config.sync_interval_secs);
160
161        let now = SystemTime::now()
162            .duration_since(SystemTime::UNIX_EPOCH)
163            .map_or(0, |d| d.as_secs());
164
165        // Skip if we synced recently (unless forced).
166        if !force
167            && let Some(last) = cache.get_last_sync_time().await?
168            && now.saturating_sub(last) < sync_interval_secs
169        {
170            debug!("sync_wallet_internal: Synced recently, skipping");
171            // When another instance shares our storage and keeps winning the sync
172            // race, we would otherwise never emit a Synced event. Emit it here so
173            // consumers are still notified that storage is up to date.
174            self.event_emitter.emit(&SdkEvent::Synced).await;
175            return Ok(());
176        }
177
178        // Update last sync time if this is a full sync.
179        if sync_type.contains(SyncType::Full)
180            && let Err(e) = cache.set_last_sync_time(now).await
181        {
182            error!("sync_wallet_internal: Failed to update last sync time: {e:?}");
183        }
184
185        let start_time = Instant::now();
186
187        let sync_wallet = async {
188            let wallet_synced = if sync_type.contains(SyncType::Wallet) {
189                debug!("sync_wallet_internal: Starting Wallet sync");
190                let wallet_start = Instant::now();
191                match self.spark_wallet.sync().await {
192                    Ok(()) => {
193                        debug!(
194                            "sync_wallet_internal: Wallet sync completed in {:?}",
195                            wallet_start.elapsed()
196                        );
197                        true
198                    }
199                    Err(e) => {
200                        error!(
201                            "sync_wallet_internal: Spark wallet sync failed in {:?}: {e:?}",
202                            wallet_start.elapsed()
203                        );
204                        false
205                    }
206                }
207            } else {
208                trace!("sync_wallet_internal: Skipping Wallet sync");
209                false
210            };
211
212            let wallet_state_synced = if sync_type.contains(SyncType::WalletState) {
213                debug!("sync_wallet_internal: Starting WalletState sync");
214                let wallet_state_start = Instant::now();
215                match self.sync_wallet_state_to_storage().await {
216                    Ok(()) => {
217                        debug!(
218                            "sync_wallet_internal: WalletState sync completed in {:?}",
219                            wallet_state_start.elapsed()
220                        );
221                        true
222                    }
223                    Err(e) => {
224                        error!(
225                            "sync_wallet_internal: Failed to sync wallet state to storage in {:?}: {e:?}",
226                            wallet_state_start.elapsed()
227                        );
228                        false
229                    }
230                }
231            } else {
232                trace!("sync_wallet_internal: Skipping WalletState sync");
233                false
234            };
235
236            (wallet_synced, wallet_state_synced)
237        };
238
239        let sync_lnurl = async {
240            if sync_type.contains(SyncType::LnurlMetadata) {
241                debug!("sync_wallet_internal: Starting LnurlMetadata sync");
242                let lnurl_start = Instant::now();
243                match self.sync_lnurl_metadata().await {
244                    Ok(()) => {
245                        debug!(
246                            "sync_wallet_internal: LnurlMetadata sync completed in {:?}",
247                            lnurl_start.elapsed()
248                        );
249                        true
250                    }
251                    Err(e) => {
252                        error!(
253                            "sync_wallet_internal: Failed to sync lnurl metadata in {:?}: {e:?}",
254                            lnurl_start.elapsed()
255                        );
256                        false
257                    }
258                }
259            } else {
260                trace!("sync_wallet_internal: Skipping LnurlMetadata sync");
261                false
262            }
263        };
264
265        let sync_deposits = async {
266            if sync_type.contains(SyncType::Deposits) {
267                debug!("sync_wallet_internal: Starting Deposits sync");
268                let deposits_start = Instant::now();
269                match self.check_and_claim_static_deposits().await {
270                    Ok(()) => {
271                        debug!(
272                            "sync_wallet_internal: Deposits sync completed in {:?}",
273                            deposits_start.elapsed()
274                        );
275                        true
276                    }
277                    Err(e) => {
278                        error!(
279                            "sync_wallet_internal: Failed to check and claim static deposits in {:?}: {e:?}",
280                            deposits_start.elapsed()
281                        );
282                        false
283                    }
284                }
285            } else {
286                trace!("sync_wallet_internal: Skipping Deposits sync");
287                false
288            }
289        };
290
291        let ((wallet, wallet_state), lnurl_metadata, deposits) =
292            tokio::join!(sync_wallet, sync_lnurl, sync_deposits);
293
294        let elapsed = start_time.elapsed();
295        let event = InternalSyncedEvent {
296            wallet,
297            wallet_state,
298            lnurl_metadata,
299            deposits,
300            storage_incoming: None,
301        };
302        info!("sync_wallet_internal: Wallet sync completed in {elapsed:?}: {event:?}");
303        self.event_emitter.emit_synced(&event).await;
304        Ok(())
305    }
306
307    /// Synchronizes wallet state to persistent storage, making sure we have the latest balances and payments.
308    pub(super) async fn sync_wallet_state_to_storage(&self) -> Result<(), SdkError> {
309        update_balances(self.spark_wallet.clone(), self.storage.clone()).await?;
310
311        let initial_sync_complete = *self.initial_synced_watcher.borrow();
312        let sync_service = SparkSyncService::new(
313            self.spark_wallet.clone(),
314            self.storage.clone(),
315            self.event_emitter.clone(),
316        );
317        sync_service.sync_payments(initial_sync_complete).await?;
318
319        Ok(())
320    }
321
322    pub(super) async fn check_and_claim_static_deposits(&self) -> Result<(), SdkError> {
323        self.maybe_ensure_spark_private_mode_initialized().await?;
324        let existing_deposits = self.storage.list_deposits().await?;
325        let existing_keys: HashSet<TxOutput> = existing_deposits
326            .iter()
327            .map(|d| TxOutput {
328                txid: d.txid.clone(),
329                vout: d.vout,
330            })
331            .collect();
332
333        let all_utxos = DepositChainSyncer::new(
334            self.chain_service.clone(),
335            self.storage.clone(),
336            self.spark_wallet.clone(),
337        )
338        .sync()
339        .await?;
340
341        // Emit NewDeposits for any deposits not previously known
342        let new_deposits: Vec<DepositInfo> = all_utxos
343            .iter()
344            .filter(|(u, _)| {
345                !existing_keys.contains(&TxOutput {
346                    txid: u.txid.to_string(),
347                    vout: u.vout,
348                })
349            })
350            .map(|(u, is_mature)| u.clone().into_deposit_info(*is_mature))
351            .collect();
352        if !new_deposits.is_empty() {
353            self.event_emitter
354                .emit(&SdkEvent::NewDeposits { new_deposits })
355                .await;
356        }
357
358        // Read after the chain sync (a round-trip per UTXO): a manual instant claim
359        // landing during the sync must be visible here, or the cascade could normal-
360        // claim on top of the in-flight instant one.
361        let instant_status = instant_claim_status_map(&self.storage.list_deposits().await?);
362
363        let mut claimed_deposits: Vec<DepositInfo> = Vec::new();
364        let mut unclaimed_deposits: Vec<DepositInfo> = Vec::new();
365        for (detailed_utxo, is_mature) in all_utxos {
366            let key = TxOutput {
367                txid: detailed_utxo.txid.to_string(),
368                vout: detailed_utxo.vout,
369            };
370            let res = if is_mature {
371                // A submitted instant claim settles asynchronously; until it settles
372                // and the UTXO leaves the operator feed, the deposit can surface as
373                // mature. Claiming it here would race that in-flight claim, so skip it
374                // (reconcile_deposits drops the row once the claim settles).
375                if !should_claim_matured_deposit(instant_status.get(&key)) {
376                    continue;
377                }
378                // Mature deposit: claim via the normal path.
379                self.claim_utxo_and_resolve_deposit(
380                    &detailed_utxo,
381                    self.config.max_deposit_claim_fee.clone(),
382                    &mut claimed_deposits,
383                    &mut unclaimed_deposits,
384                )
385                .await
386            } else {
387                // Not yet mature: attempt a one-shot 0-conf instant claim if enabled.
388                // Skip if instant claims are not enabled (no bps ceiling set).
389                let Some(max_instant_fee_bps) = self.config.max_instant_deposit_claim_fee_bps
390                else {
391                    continue;
392                };
393                // Skip unless the deposit is worth attempting at this ceiling: never
394                // tried, or a prior fee-exceeded decline whose ceiling was lower.
395                if !instant_claim_worth_attempting(instant_status.get(&key), max_instant_fee_bps) {
396                    continue;
397                }
398                self.instant_claim_utxo_and_resolve_deposit(
399                    &detailed_utxo,
400                    max_instant_fee_bps,
401                    &mut claimed_deposits,
402                )
403                .await
404            };
405
406            if let Err(e) = res {
407                warn!(
408                    "Failed to update deposit for utxo {}:{}: {e}",
409                    detailed_utxo.txid, detailed_utxo.vout
410                );
411            }
412        }
413
414        info!("background claim completed, unclaimed deposits: {unclaimed_deposits:?}");
415
416        if !unclaimed_deposits.is_empty() {
417            self.event_emitter
418                .emit(&SdkEvent::UnclaimedDeposits { unclaimed_deposits })
419                .await;
420        }
421        if !claimed_deposits.is_empty() {
422            self.event_emitter
423                .emit(&SdkEvent::ClaimedDeposits { claimed_deposits })
424                .await;
425        }
426        Ok(())
427    }
428
429    async fn claim_utxo_and_resolve_deposit(
430        &self,
431        detailed_utxo: &DetailedUtxo,
432        max_claim_fee: Option<MaxFee>,
433        claimed_deposits: &mut Vec<DepositInfo>,
434        unclaimed_deposits: &mut Vec<DepositInfo>,
435    ) -> Result<(), SdkError> {
436        match self.claim_utxo(detailed_utxo, max_claim_fee).await {
437            Ok(_) => {
438                info!("Claimed utxo {}:{}", detailed_utxo.txid, detailed_utxo.vout);
439                self.storage
440                    .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
441                    .await?;
442                claimed_deposits.push(detailed_utxo.clone().into_deposit_info(true));
443            }
444            Err(e) => {
445                warn!(
446                    "Failed to claim utxo {}:{}: {e}",
447                    detailed_utxo.txid, detailed_utxo.vout
448                );
449                unclaimed_deposits.push(self.record_unclaimed_deposit(detailed_utxo, e).await?);
450            }
451        }
452        Ok(())
453    }
454
455    async fn instant_claim_utxo_and_resolve_deposit(
456        &self,
457        detailed_utxo: &DetailedUtxo,
458        max_instant_fee_bps: u32,
459        claimed_deposits: &mut Vec<DepositInfo>,
460    ) -> Result<(), SdkError> {
461        let outcome = match self
462            .instant_claim_utxo(detailed_utxo, max_instant_fee_bps)
463            .await
464        {
465            Ok(outcome) => outcome,
466            Err(e) => {
467                // Transient transport/indexing error (e.g. the SSP has not indexed
468                // the mempool tx yet): do NOT mark, so the next sync retries.
469                warn!(
470                    "Instant claim transient error for utxo {}:{}, will retry: {e}",
471                    detailed_utxo.txid, detailed_utxo.vout
472                );
473                return Ok(());
474            }
475        };
476
477        // Mark, don't delete: a submitted claim settles asynchronously, so the
478        // marker keeps the next sync from re-attempting instant or normal path
479        // claiming a still-in-flight deposit; reconcile_deposits removes the row
480        // once the UTXO leaves the feed. A declined instant claim is marked so
481        // we don't re-quote it every sync; it is claimed by the normal path at
482        // maturity. The row already exists here (the deposit sync inserted it),
483        // so the update lands.
484        let status = outcome.status();
485        self.storage
486            .update_deposit(
487                detailed_utxo.txid.to_string(),
488                detailed_utxo.vout,
489                UpdateDepositPayload::InstantClaim {
490                    status: status.clone(),
491                },
492            )
493            .await?;
494
495        match outcome {
496            InstantClaimOutcome::Submitted(claim_id) => {
497                info!(
498                    "Instant claimed utxo {}:{} with claim_id: {claim_id}",
499                    detailed_utxo.txid, detailed_utxo.vout
500                );
501                let mut info = detailed_utxo.clone().into_deposit_info(false);
502                info.instant_claim_status = Some(status);
503                claimed_deposits.push(info);
504            }
505            InstantClaimOutcome::Declined { error, .. } => {
506                info!(
507                    "Instant claim declined for utxo {}:{}: {error}",
508                    detailed_utxo.txid, detailed_utxo.vout
509                );
510            }
511        }
512        Ok(())
513    }
514
515    /// Persists a claim failure on the deposit and returns the matching
516    /// `DepositInfo` (with `claim_error` set) for the `UnclaimedDeposits` event.
517    async fn record_unclaimed_deposit(
518        &self,
519        utxo: &DetailedUtxo,
520        error: SdkError,
521    ) -> Result<DepositInfo, SdkError> {
522        self.storage
523            .update_deposit(
524                utxo.txid.to_string(),
525                utxo.vout,
526                UpdateDepositPayload::ClaimError {
527                    error: error.clone().into(),
528                },
529            )
530            .await?;
531        let mut info = utxo.clone().into_deposit_info(true);
532        info.claim_error = Some(error.into());
533        Ok(info)
534    }
535
536    pub(super) async fn sync_lnurl_metadata(&self) -> Result<(), SdkError> {
537        let Some(lnurl_server_client) = self.lnurl_server_client.clone() else {
538            return Ok(());
539        };
540
541        let cache = ObjectCacheRepository::new(Arc::clone(&self.storage));
542        let mut updated_after = cache.fetch_lnurl_metadata_updated_after().await?;
543
544        loop {
545            debug!("Syncing lnurl metadata from updated_after {updated_after}");
546            let metadata = lnurl_server_client
547                .list_metadata(&ListMetadataRequest {
548                    offset: None,
549                    limit: Some(SYNC_PAGING_LIMIT),
550                    updated_after: Some(updated_after),
551                })
552                .await?;
553
554            if metadata.metadata.is_empty() {
555                debug!("No more lnurl metadata on offset {updated_after}");
556                break;
557            }
558
559            let len = u32::try_from(metadata.metadata.len())?;
560            let last_updated_at = metadata.metadata.last().map(|m| m.updated_at);
561            self.storage
562                .set_lnurl_metadata(metadata.metadata.into_iter().map(From::from).collect())
563                .await?;
564
565            debug!(
566                "Synchronized {} lnurl metadata at updated_after {updated_after}",
567                len
568            );
569            updated_after = last_updated_at.unwrap_or(updated_after);
570            cache
571                .save_lnurl_metadata_updated_after(updated_after)
572                .await?;
573
574            if len < SYNC_PAGING_LIMIT {
575                // No more invoices to fetch
576                break;
577            }
578        }
579
580        Ok(())
581    }
582
583    /// Resolves an on-chain max claim fee to `(fee, ceiling_sats)`, where the sat
584    /// ceiling is computed over the claim tx size. `None` means no ceiling is set,
585    /// which the caller treats as rejecting the claim.
586    async fn resolve_max_claim_fee(
587        &self,
588        max_claim_fee: Option<MaxFee>,
589    ) -> Result<Option<(Fee, u64)>, SdkError> {
590        match max_claim_fee {
591            None => Ok(None),
592            Some(max_fee) => {
593                let fee = max_fee.to_fee(self.chain_service.as_ref()).await?;
594                let sats = fee.to_sats(CLAIM_TX_SIZE_VBYTES);
595                Ok(Some((fee, sats)))
596            }
597        }
598    }
599
600    /// Submits a static deposit claim for `detailed_utxo` and returns the
601    /// resulting transfer id.
602    pub(super) async fn claim_utxo(
603        &self,
604        detailed_utxo: &DetailedUtxo,
605        max_claim_fee: Option<MaxFee>,
606    ) -> Result<String, SdkError> {
607        info!(
608            "Fetching static deposit claim quote for deposit tx {}:{} and amount: {}",
609            detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value
610        );
611        let quote = self
612            .spark_wallet
613            .fetch_static_deposit_claim_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
614            .await?;
615
616        let spark_requested_fee_sats = detailed_utxo.value.saturating_sub(quote.credit_amount_sats);
617
618        let spark_requested_fee_rate = spark_requested_fee_sats.div_ceil(CLAIM_TX_SIZE_VBYTES);
619
620        let resolved_max_fee = self.resolve_max_claim_fee(max_claim_fee).await?;
621        if let Some((_, max_fee_sats)) = &resolved_max_fee {
622            info!("User max fee: {max_fee_sats} spark requested fee: {spark_requested_fee_sats}");
623        }
624        let within_limit = resolved_max_fee
625            .as_ref()
626            .is_some_and(|(_, max_fee_sats)| spark_requested_fee_sats <= *max_fee_sats);
627        if !within_limit {
628            return Err(SdkError::MaxDepositClaimFeeExceeded {
629                tx: detailed_utxo.txid.to_string(),
630                vout: detailed_utxo.vout,
631                max_fee: resolved_max_fee.map(|(fee, _)| fee),
632                required_fee_sats: spark_requested_fee_sats,
633                required_fee_rate_sat_per_vbyte: spark_requested_fee_rate,
634            });
635        }
636
637        info!(
638            "Claiming static deposit for utxo {}:{}",
639            detailed_utxo.txid, detailed_utxo.vout
640        );
641        let credit_amount_sats = quote.credit_amount_sats;
642        let transfer_id = self.spark_wallet.claim_static_deposit(quote).await?;
643        info!(
644            "Claimed static deposit for utxo {}:{} (deposit value {}, credit {}), transfer {transfer_id}",
645            detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value, credit_amount_sats,
646        );
647        Ok(transfer_id)
648    }
649}
650
651#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
652#[allow(clippy::needless_pass_by_value)]
653impl BreezSdk {
654    /// Synchronizes the wallet with the Spark network
655    #[allow(unused_variables)]
656    pub async fn sync_wallet(
657        &self,
658        request: SyncWalletRequest,
659    ) -> Result<SyncWalletResponse, SdkError> {
660        self.runtime
661            .run_user_sync(self, super::SyncType::Full, true)
662            .await?;
663        // Awaited rather than left to the background collection, so a caller
664        // that syncs before going offline knows the collection has run by the
665        // time this returns. After the sync, so the leaves it brought in are
666        // collected for too.
667        if self.config.exit_chain_auto_fetch_enabled {
668            self.runtime.collect_exit_chains(self).await?;
669        }
670        Ok(SyncWalletResponse {})
671    }
672}
673
674#[cfg(test)]
675mod tests {
676    use super::{instant_claim_worth_attempting, should_claim_matured_deposit};
677    use crate::{InstantClaimDeclineReason, InstantClaimStatus};
678
679    fn declined(reason: InstantClaimDeclineReason) -> InstantClaimStatus {
680        InstantClaimStatus::Declined { reason }
681    }
682
683    fn submitted() -> InstantClaimStatus {
684        InstantClaimStatus::Submitted {
685            claim_id: "claim-1".to_string(),
686        }
687    }
688
689    fn fee_exceeded(max_bps: u32) -> InstantClaimDeclineReason {
690        InstantClaimDeclineReason::FeeExceeded {
691            max_bps,
692            quoted_bps: 0,
693            quoted_sats: 0,
694        }
695    }
696
697    #[test]
698    fn claim_matured_deposit_skips_only_submitted() {
699        // No instant attempt, or a declined one, falls through to the normal path
700        // claim.
701        assert!(should_claim_matured_deposit(None));
702        assert!(should_claim_matured_deposit(Some(&declined(
703            InstantClaimDeclineReason::NoPlan
704        ))));
705        assert!(should_claim_matured_deposit(Some(&declined(fee_exceeded(
706            400
707        )))));
708        // A submitted instant claim is in flight, so claiming the matured deposit
709        // is skipped until the claim settles.
710        assert!(!should_claim_matured_deposit(Some(&submitted())));
711    }
712
713    #[test]
714    fn instant_retry_only_fee_exceeded_at_a_higher_ceiling() {
715        // Never attempted -> attempt.
716        assert!(instant_claim_worth_attempting(None, 400));
717        // Fee-exceeded at a lower ceiling than we can now offer -> retry.
718        assert!(instant_claim_worth_attempting(
719            Some(&declined(fee_exceeded(100))),
720            400
721        ));
722        // Fee-exceeded at the same or a higher ceiling -> do not re-quote.
723        assert!(!instant_claim_worth_attempting(
724            Some(&declined(fee_exceeded(400))),
725            400
726        ));
727        assert!(!instant_claim_worth_attempting(
728            Some(&declined(fee_exceeded(500))),
729            400
730        ));
731        // Terminal reasons and in-flight submissions are never re-attempted.
732        assert!(!instant_claim_worth_attempting(
733            Some(&declined(InstantClaimDeclineReason::NoPlan)),
734            400
735        ));
736        assert!(!instant_claim_worth_attempting(
737            Some(&declined(InstantClaimDeclineReason::SubmissionFailed)),
738            400
739        ));
740        assert!(!instant_claim_worth_attempting(Some(&submitted()), 400));
741    }
742}