Skip to main content

breez_sdk_spark/sdk/
sync.rs

1use platform_utils::time::Instant;
2use platform_utils::tokio;
3use std::collections::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,
9    deposits::{
10        InstantClaimOutcome, claim_already_made, is_already_claimed_error, larger_ceiling,
11        needs_own_ceiling_resolution,
12    },
13};
14use crate::utils::time::now_secs;
15use crate::{
16    DepositInfo, Fee, InputType, InstantClaimStatus, MaxFee, PaymentDetails, PaymentType,
17    error::SdkError,
18    events::{InternalSyncedEvent, SdkEvent},
19    lnurl::ListMetadataRequest,
20    models::{Payment, SyncWalletRequest, SyncWalletResponse},
21    persist::{ObjectCacheRepository, UpdateDepositPayload},
22    sync::SparkSyncService,
23    utils::{
24        deposit_chain_syncer::{DepositChainSyncer, TxOutput},
25        payments::update_balances,
26        utxo_fetcher::DetailedUtxo,
27    },
28};
29
30/// Whether the cascade should (re)attempt an early claim. A decline is retried
31/// once the deposit gains a confirmation, or once the ceiling that declined it
32/// rises.
33fn instant_claim_worth_attempting(
34    status: Option<&InstantClaimStatus>,
35    confirmations: u32,
36    ceiling_sats: u64,
37) -> bool {
38    match status {
39        Some(InstantClaimStatus::Declined {
40            max_fee_sats,
41            confirmations: declined_at,
42        }) => confirmations > *declined_at || max_fee_sats.is_some_and(|prev| ceiling_sats > prev),
43        _ => true,
44    }
45}
46
47impl BreezSdk {
48    pub(in crate::sdk) async fn sync_single_lnurl_metadata(&self, payment: &mut Payment) {
49        if payment.payment_type != PaymentType::Receive {
50            return;
51        }
52
53        let Some(PaymentDetails::Lightning {
54            invoice,
55            lnurl_receive_metadata,
56            ..
57        }) = &mut payment.details
58        else {
59            return;
60        };
61
62        if lnurl_receive_metadata.is_some() {
63            // Already have lnurl metadata
64            return;
65        }
66
67        let Ok(input) = self.input_parser.parse(invoice).await.map(InputType::from) else {
68            error!(
69                "Failed to parse invoice for lnurl metadata sync of payment {}",
70                payment.id
71            );
72            debug!("Unparseable invoice: {invoice}");
73            return;
74        };
75
76        let InputType::Bolt11Invoice(details) = input else {
77            error!(
78                "Input is not a Bolt11 invoice for lnurl metadata sync of payment {}",
79                payment.id
80            );
81            debug!("Non-Bolt11 input: {invoice}");
82            return;
83        };
84
85        // If there is a description hash, we assume this is a lnurl payment.
86        if details.description_hash.is_none() {
87            return;
88        }
89
90        // Let's check whether the lnurl receive metadata was already synced, then return early.
91        // Important: Only return early if metadata is actually present (Some), otherwise we need
92        // to trigger a sync. This prevents a race condition where the payment is in storage but
93        // metadata sync from TransferClaimStarting hasn't completed yet.
94        if let Ok(db_payment) = self.storage.get_payment_by_id(payment.id.clone()).await
95            && let Some(PaymentDetails::Lightning {
96                lnurl_receive_metadata: db_lnurl_receive_metadata @ Some(_),
97                ..
98            }) = db_payment.details
99        {
100            *lnurl_receive_metadata = db_lnurl_receive_metadata;
101            return;
102        }
103
104        // Sync lnurl metadata directly instead of going through the sync trigger,
105        // because this function is called from the sync loop's event handler,
106        // which would deadlock waiting for itself to process the trigger.
107        if let Err(e) = self.sync_lnurl_metadata().await {
108            error!(
109                "Failed to sync lnurl metadata for payment {}: {e}",
110                payment.id
111            );
112            return;
113        }
114
115        let db_payment = match self.storage.get_payment_by_id(payment.id.clone()).await {
116            Ok(p) => p,
117            Err(e) => {
118                debug!("Payment not found in storage for invoice {}: {e}", invoice);
119                return;
120            }
121        };
122
123        let Some(PaymentDetails::Lightning {
124            lnurl_receive_metadata: db_lnurl_receive_metadata,
125            ..
126        }) = db_payment.details
127        else {
128            debug!(
129                "No lnurl receive metadata in storage for invoice {}",
130                invoice
131            );
132            return;
133        };
134        *lnurl_receive_metadata = db_lnurl_receive_metadata;
135    }
136
137    #[allow(clippy::too_many_lines)]
138    pub(super) async fn sync_wallet_internal(
139        &self,
140        sync_type: SyncType,
141        force: bool,
142    ) -> Result<(), SdkError> {
143        let cache = ObjectCacheRepository::new(self.storage.clone());
144        let sync_interval_secs = u64::from(self.config.sync_interval_secs);
145        let now = now_secs();
146
147        // Skip if we synced recently (unless forced).
148        if !force
149            && let Some(last) = cache.get_last_sync_time().await?
150            && now.saturating_sub(last) < sync_interval_secs
151        {
152            debug!("sync_wallet_internal: Synced recently, skipping");
153            // When another instance shares our storage and keeps winning the sync
154            // race, we would otherwise never emit a Synced event. Emit it here so
155            // consumers are still notified that storage is up to date.
156            self.event_emitter.emit(&SdkEvent::Synced).await;
157            return Ok(());
158        }
159
160        // Update last sync time if this is a full sync.
161        if sync_type.contains(SyncType::Full)
162            && let Err(e) = cache.set_last_sync_time(now).await
163        {
164            error!("sync_wallet_internal: Failed to update last sync time: {e:?}");
165        }
166
167        let start_time = Instant::now();
168
169        let sync_wallet = async {
170            let wallet_synced = if sync_type.contains(SyncType::Wallet) {
171                debug!("sync_wallet_internal: Starting Wallet sync");
172                let wallet_start = Instant::now();
173                match self.spark_wallet.sync().await {
174                    Ok(()) => {
175                        debug!(
176                            "sync_wallet_internal: Wallet sync completed in {:?}",
177                            wallet_start.elapsed()
178                        );
179                        true
180                    }
181                    Err(e) => {
182                        error!(
183                            "sync_wallet_internal: Spark wallet sync failed in {:?}: {e:?}",
184                            wallet_start.elapsed()
185                        );
186                        false
187                    }
188                }
189            } else {
190                trace!("sync_wallet_internal: Skipping Wallet sync");
191                false
192            };
193
194            // Finish any Lightning send that committed leaves with the operators
195            // but never reached the SSP. Runs with the wallet sync because it
196            // needs the operators' view of the transfer.
197            if sync_type.contains(SyncType::Wallet) {
198                self.lightning_sender.resume_pending_sends().await;
199            }
200
201            let wallet_state_synced = if sync_type.contains(SyncType::WalletState) {
202                debug!("sync_wallet_internal: Starting WalletState sync");
203                let wallet_state_start = Instant::now();
204                match self.sync_wallet_state_to_storage().await {
205                    Ok(()) => {
206                        debug!(
207                            "sync_wallet_internal: WalletState sync completed in {:?}",
208                            wallet_state_start.elapsed()
209                        );
210                        true
211                    }
212                    Err(e) => {
213                        error!(
214                            "sync_wallet_internal: Failed to sync wallet state to storage in {:?}: {e:?}",
215                            wallet_state_start.elapsed()
216                        );
217                        false
218                    }
219                }
220            } else {
221                trace!("sync_wallet_internal: Skipping WalletState sync");
222                false
223            };
224
225            (wallet_synced, wallet_state_synced)
226        };
227
228        let sync_lnurl = async {
229            if sync_type.contains(SyncType::LnurlMetadata) {
230                debug!("sync_wallet_internal: Starting LnurlMetadata sync");
231                let lnurl_start = Instant::now();
232                match self.sync_lnurl_metadata().await {
233                    Ok(()) => {
234                        debug!(
235                            "sync_wallet_internal: LnurlMetadata sync completed in {:?}",
236                            lnurl_start.elapsed()
237                        );
238                        true
239                    }
240                    Err(e) => {
241                        error!(
242                            "sync_wallet_internal: Failed to sync lnurl metadata in {:?}: {e:?}",
243                            lnurl_start.elapsed()
244                        );
245                        false
246                    }
247                }
248            } else {
249                trace!("sync_wallet_internal: Skipping LnurlMetadata sync");
250                false
251            }
252        };
253
254        let sync_deposits = async {
255            if sync_type.contains(SyncType::Deposits) {
256                debug!("sync_wallet_internal: Starting Deposits sync");
257                let deposits_start = Instant::now();
258                match self.check_and_claim_static_deposits().await {
259                    Ok(()) => {
260                        debug!(
261                            "sync_wallet_internal: Deposits sync completed in {:?}",
262                            deposits_start.elapsed()
263                        );
264                        true
265                    }
266                    Err(e) => {
267                        error!(
268                            "sync_wallet_internal: Failed to check and claim static deposits in {:?}: {e:?}",
269                            deposits_start.elapsed()
270                        );
271                        false
272                    }
273                }
274            } else {
275                trace!("sync_wallet_internal: Skipping Deposits sync");
276                false
277            }
278        };
279
280        let ((wallet, wallet_state), lnurl_metadata, deposits) =
281            tokio::join!(sync_wallet, sync_lnurl, sync_deposits);
282
283        let elapsed = start_time.elapsed();
284        let event = InternalSyncedEvent {
285            wallet,
286            wallet_state,
287            lnurl_metadata,
288            deposits,
289            storage_incoming: None,
290        };
291        info!("sync_wallet_internal: Wallet sync completed in {elapsed:?}: {event:?}");
292        self.event_emitter.emit_synced(&event).await;
293        Ok(())
294    }
295
296    /// Synchronizes wallet state to persistent storage, making sure we have the latest balances and payments.
297    pub(super) async fn sync_wallet_state_to_storage(&self) -> Result<(), SdkError> {
298        update_balances(self.spark_wallet.clone(), self.storage.clone()).await?;
299
300        let initial_sync_complete = *self.initial_synced_watcher.borrow();
301        let sync_service = SparkSyncService::new(
302            self.spark_wallet.clone(),
303            self.storage.clone(),
304            self.event_emitter.clone(),
305        );
306        sync_service.sync_payments(initial_sync_complete).await?;
307
308        Ok(())
309    }
310
311    #[allow(clippy::too_many_lines)]
312    pub(super) async fn check_and_claim_static_deposits(&self) -> Result<(), SdkError> {
313        self.maybe_ensure_spark_private_mode_initialized().await?;
314        let existing_deposits = self.storage.list_deposits().await?;
315        let existing_keys: HashSet<TxOutput> = existing_deposits
316            .iter()
317            .map(|d| TxOutput {
318                txid: d.txid.clone(),
319                vout: d.vout,
320            })
321            .collect();
322
323        let all_utxos = DepositChainSyncer::new(
324            self.chain_service.clone(),
325            self.storage.clone(),
326            self.spark_wallet.clone(),
327        )
328        .sync()
329        .await?;
330
331        // Emit NewDeposits for any deposits not previously known
332        let new_deposits: Vec<DepositInfo> = all_utxos
333            .iter()
334            .filter(|(u, _)| {
335                !existing_keys.contains(&TxOutput {
336                    txid: u.txid.to_string(),
337                    vout: u.vout,
338                })
339            })
340            .map(|(u, is_mature)| u.clone().into_deposit_info(*is_mature))
341            .collect();
342        if !new_deposits.is_empty() {
343            self.event_emitter
344                .emit(&SdkEvent::NewDeposits { new_deposits })
345                .await;
346        }
347
348        let has_immature = all_utxos.iter().any(|(_, is_mature)| !is_mature);
349        // Resolved once per pass, and only when an immature deposit could use it.
350        // A deposit whose own ceiling differs from it resolves that separately, below.
351        let instant_ceiling = if has_immature {
352            match self
353                .resolve_max_claim_fee(self.config.max_deposit_claim_fee.clone())
354                .await
355            {
356                Ok(resolved) => resolved,
357                Err(e) => {
358                    warn!("Could not resolve the max claim fee, skipping instant claims: {e}");
359                    None
360                }
361            }
362        } else {
363            None
364        };
365
366        // The tip is the same for every deposit in the pass, so it is read once and
367        // reused. Read on first need rather than up front: whether any deposit gets
368        // as far as needing it depends on the ceiling each one resolves to, which is
369        // only known inside the loop. A failed read falls back to a per-deposit one.
370        let mut tip_height: Option<u32> = None;
371
372        let mut claimed_deposits: Vec<DepositInfo> = Vec::new();
373        let mut unclaimed_deposits: Vec<DepositInfo> = Vec::new();
374        for (detailed_utxo, is_mature) in all_utxos {
375            let key = TxOutput {
376                txid: detailed_utxo.txid.to_string(),
377                vout: detailed_utxo.vout,
378            };
379            // Skip a deposit an explicit claim_deposit call is already working on.
380            let Some(_claim_guard) = self.claim_guards.try_acquire(key.clone()) else {
381                continue;
382            };
383            let stored = match self.stored_deposit(&key.txid, key.vout).await {
384                Ok(stored) => stored,
385                Err(e) => {
386                    warn!(
387                        "Could not read the stored deposit {}:{}: {e}",
388                        key.txid, key.vout
389                    );
390                    continue;
391                }
392            };
393            let instant_status = stored.as_ref().and_then(|d| d.instant_claim_status.clone());
394            if claim_already_made(instant_status.as_ref()) {
395                continue;
396            }
397            let stored_max_fee = stored.as_ref().and_then(|d| d.max_claim_fee.as_ref());
398            if let Some(max_fee) = stored_max_fee {
399                debug!(
400                    "Deposit {}:{} runs under its own max claim fee {max_fee:?} \
401                     rather than the configured one",
402                    detailed_utxo.txid, detailed_utxo.vout
403                );
404            }
405            let res = if is_mature {
406                // Mature deposit: claim via the normal path.
407                self.claim_utxo_and_resolve_deposit(
408                    &detailed_utxo,
409                    self.mature_claim_ceiling(stored_max_fee).await,
410                    stored_max_fee.cloned(),
411                    &mut claimed_deposits,
412                    &mut unclaimed_deposits,
413                )
414                .await
415            } else {
416                // Not yet mature: attempt one early claim, held to the same
417                // ceiling. Without a ceiling there is nothing to claim within.
418                let ceiling = if needs_own_ceiling_resolution(
419                    stored_max_fee,
420                    self.config.max_deposit_claim_fee.as_ref(),
421                ) {
422                    match self.resolve_max_claim_fee(stored_max_fee.cloned()).await {
423                        Ok(resolved) => resolved,
424                        Err(e) => {
425                            warn!(
426                                "Could not resolve the max claim fee for {}:{}: {e}",
427                                detailed_utxo.txid, detailed_utxo.vout
428                            );
429                            continue;
430                        }
431                    }
432                } else {
433                    instant_ceiling.clone()
434                };
435                let Some(ceiling) = ceiling else {
436                    continue;
437                };
438                if tip_height.is_none() {
439                    tip_height = self.chain_service.tip_height().await.ok();
440                }
441                let Ok(confirmations) = self
442                    .deposit_confirmations_at_tip(&detailed_utxo.txid.to_string(), tip_height)
443                    .await
444                else {
445                    continue;
446                };
447                if !instant_claim_worth_attempting(
448                    instant_status.as_ref(),
449                    confirmations,
450                    ceiling.1,
451                ) {
452                    continue;
453                }
454                self.instant_claim_utxo_and_resolve_deposit(
455                    &detailed_utxo,
456                    Some(ceiling),
457                    confirmations,
458                    stored_max_fee.cloned(),
459                    &mut claimed_deposits,
460                )
461                .await
462            };
463
464            if let Err(e) = res {
465                warn!(
466                    "Failed to update deposit for utxo {}:{}: {e}",
467                    detailed_utxo.txid, detailed_utxo.vout
468                );
469            }
470        }
471
472        info!(
473            "background claim completed, unclaimed deposits: {}",
474            unclaimed_deposits.len()
475        );
476        debug!("unclaimed deposits: {unclaimed_deposits:?}");
477
478        if !unclaimed_deposits.is_empty() {
479            self.event_emitter
480                .emit(&SdkEvent::UnclaimedDeposits { unclaimed_deposits })
481                .await;
482        }
483        if !claimed_deposits.is_empty() {
484            self.event_emitter
485                .emit(&SdkEvent::ClaimedDeposits { claimed_deposits })
486                .await;
487        }
488        Ok(())
489    }
490
491    /// The ceiling a mature deposit's automatic claim runs under. Both sides are
492    /// resolved here because comparing them needs sats, and a ceiling that will not
493    /// resolve leaves the deposit on its own, which is what was asked for.
494    async fn mature_claim_ceiling(&self, stored: Option<&MaxFee>) -> Option<MaxFee> {
495        let config_default = self.config.max_deposit_claim_fee.as_ref();
496        let (Some(stored), Some(config_default)) = (stored, config_default) else {
497            return stored.or(config_default).cloned();
498        };
499        let stored_sats = self.ceiling_sats(stored).await;
500        let config_sats = self.ceiling_sats(config_default).await;
501        match (stored_sats, config_sats) {
502            (Some(stored_sats), Some(config_sats)) => Some(larger_ceiling(
503                stored,
504                stored_sats,
505                config_default,
506                config_sats,
507            )),
508            _ => Some(stored.clone()),
509        }
510    }
511
512    /// One ceiling in sats, or `None` when it cannot be resolved.
513    async fn ceiling_sats(&self, max_fee: &MaxFee) -> Option<u64> {
514        self.resolve_max_claim_fee(Some(max_fee.clone()))
515            .await
516            .inspect_err(|e| warn!("Could not resolve a max claim fee: {e}"))
517            .ok()
518            .flatten()
519            .map(|(_, sats)| sats)
520    }
521
522    async fn claim_utxo_and_resolve_deposit(
523        &self,
524        detailed_utxo: &DetailedUtxo,
525        max_claim_fee: Option<MaxFee>,
526        stored_max_fee: Option<MaxFee>,
527        claimed_deposits: &mut Vec<DepositInfo>,
528        unclaimed_deposits: &mut Vec<DepositInfo>,
529    ) -> Result<(), SdkError> {
530        match self.claim_utxo(detailed_utxo, max_claim_fee).await {
531            Ok(_) => {
532                info!("Claimed utxo {}:{}", detailed_utxo.txid, detailed_utxo.vout);
533                self.storage
534                    .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
535                    .await?;
536                let mut info = detailed_utxo.clone().into_deposit_info(true);
537                info.max_claim_fee = stored_max_fee;
538                claimed_deposits.push(info);
539            }
540            // The deposit is settled, not failed: an earlier claim took it, here
541            // or elsewhere. Marking it stops every later pass re-claiming it, and
542            // records no claim error, which would have asked the user to act on a
543            // deposit nothing more can be done with.
544            Err(e) if is_already_claimed_error(&e.to_string()) => {
545                info!(
546                    "Deposit {}:{} was already claimed, marking it",
547                    detailed_utxo.txid, detailed_utxo.vout
548                );
549                self.storage
550                    .update_deposit(
551                        detailed_utxo.txid.to_string(),
552                        detailed_utxo.vout,
553                        UpdateDepositPayload::InstantClaim {
554                            status: InstantClaimStatus::Claimed,
555                        },
556                    )
557                    .await?;
558            }
559            Err(e) => {
560                warn!(
561                    "Failed to claim utxo {}:{}: {e}",
562                    detailed_utxo.txid, detailed_utxo.vout
563                );
564                unclaimed_deposits.push(
565                    self.record_unclaimed_deposit(detailed_utxo, e, stored_max_fee)
566                        .await?,
567                );
568            }
569        }
570        Ok(())
571    }
572
573    async fn instant_claim_utxo_and_resolve_deposit(
574        &self,
575        detailed_utxo: &DetailedUtxo,
576        resolved_max_fee: Option<(Fee, u64)>,
577        confirmations: u32,
578        stored_max_fee: Option<MaxFee>,
579        claimed_deposits: &mut Vec<DepositInfo>,
580    ) -> Result<(), SdkError> {
581        let outcome = match self
582            .instant_claim_utxo(detailed_utxo, resolved_max_fee, confirmations)
583            .await
584        {
585            Ok(outcome) => outcome,
586            Err(e) => {
587                // Transient transport/indexing error (e.g. the SSP has not indexed
588                // the mempool tx yet): do NOT mark, so the next sync retries.
589                warn!(
590                    "Instant claim transient error for utxo {}:{}, will retry: {e}",
591                    detailed_utxo.txid, detailed_utxo.vout
592                );
593                return Ok(());
594            }
595        };
596
597        // Mark, don't delete: reconcile_deposits removes the row once the UTXO
598        // leaves the feed.
599        let status = outcome.status(confirmations);
600        self.storage
601            .update_deposit(
602                detailed_utxo.txid.to_string(),
603                detailed_utxo.vout,
604                UpdateDepositPayload::InstantClaim {
605                    status: status.clone(),
606                },
607            )
608            .await?;
609
610        match outcome {
611            InstantClaimOutcome::Submitted(claim_id) => {
612                info!(
613                    "Instant claimed utxo {}:{} with claim_id: {claim_id}",
614                    detailed_utxo.txid, detailed_utxo.vout
615                );
616                let mut info = detailed_utxo.clone().into_deposit_info(false);
617                info.instant_claim_status = Some(status);
618                info.max_claim_fee = stored_max_fee;
619                claimed_deposits.push(info);
620            }
621            InstantClaimOutcome::Declined { error, .. } => {
622                // Declining an early claim leaves the deposit to claim at maturity,
623                // which is the ordinary outcome rather than a failure. No claim_error
624                // is persisted and no UnclaimedDeposits event fires, unlike a claim at
625                // maturity that fails.
626                info!(
627                    "Instant claim declined for utxo {}:{}: {error}",
628                    detailed_utxo.txid, detailed_utxo.vout
629                );
630            }
631        }
632        Ok(())
633    }
634
635    /// Persists a claim failure on the deposit and returns the matching
636    /// `DepositInfo`, carrying the failure and the deposit's own ceiling, for the
637    /// `UnclaimedDeposits` event.
638    async fn record_unclaimed_deposit(
639        &self,
640        utxo: &DetailedUtxo,
641        error: SdkError,
642        max_claim_fee: Option<MaxFee>,
643    ) -> Result<DepositInfo, SdkError> {
644        self.storage
645            .update_deposit(
646                utxo.txid.to_string(),
647                utxo.vout,
648                UpdateDepositPayload::ClaimError {
649                    error: error.clone().into(),
650                },
651            )
652            .await?;
653        let mut info = utxo.clone().into_deposit_info(true);
654        info.claim_error = Some(error.into());
655        info.max_claim_fee = max_claim_fee;
656        Ok(info)
657    }
658
659    pub(super) async fn sync_lnurl_metadata(&self) -> Result<(), SdkError> {
660        let Some(lnurl_server_client) = self.lnurl_server_client.clone() else {
661            return Ok(());
662        };
663
664        let cache = ObjectCacheRepository::new(Arc::clone(&self.storage));
665        let mut updated_after = cache.fetch_lnurl_metadata_updated_after().await?;
666
667        loop {
668            debug!("Syncing lnurl metadata from updated_after {updated_after}");
669            let metadata = lnurl_server_client
670                .list_metadata(&ListMetadataRequest {
671                    offset: None,
672                    limit: Some(SYNC_PAGING_LIMIT),
673                    updated_after: Some(updated_after),
674                })
675                .await?;
676
677            if metadata.metadata.is_empty() {
678                debug!("No more lnurl metadata on offset {updated_after}");
679                break;
680            }
681
682            let len = u32::try_from(metadata.metadata.len())?;
683            let last_updated_at = metadata.metadata.last().map(|m| m.updated_at);
684            self.storage
685                .set_lnurl_metadata(metadata.metadata.into_iter().map(From::from).collect())
686                .await?;
687
688            debug!(
689                "Synchronized {} lnurl metadata at updated_after {updated_after}",
690                len
691            );
692            updated_after = last_updated_at.unwrap_or(updated_after);
693            cache
694                .save_lnurl_metadata_updated_after(updated_after)
695                .await?;
696
697            if len < SYNC_PAGING_LIMIT {
698                // No more invoices to fetch
699                break;
700            }
701        }
702
703        Ok(())
704    }
705
706    /// Resolves an on-chain max claim fee to `(fee, ceiling_sats)`, where the sat
707    /// ceiling is computed over the claim tx size. `None` means no ceiling is set,
708    /// which the caller treats as rejecting the claim.
709    pub(super) async fn resolve_max_claim_fee(
710        &self,
711        max_claim_fee: Option<MaxFee>,
712    ) -> Result<Option<(Fee, u64)>, SdkError> {
713        match max_claim_fee {
714            None => Ok(None),
715            Some(max_fee) => {
716                let fee = max_fee.to_fee(self.chain_service.as_ref()).await?;
717                let sats = fee.to_sats(CLAIM_TX_SIZE_VBYTES);
718                Ok(Some((fee, sats)))
719            }
720        }
721    }
722
723    /// Submits a static deposit claim for `detailed_utxo` and returns the
724    /// resulting transfer id.
725    pub(super) async fn claim_utxo(
726        &self,
727        detailed_utxo: &DetailedUtxo,
728        max_claim_fee: Option<MaxFee>,
729    ) -> Result<String, SdkError> {
730        info!(
731            "Fetching static deposit claim quote for deposit tx {}:{} and amount: {}",
732            detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value
733        );
734        let quote = self
735            .spark_wallet
736            .fetch_static_deposit_claim_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
737            .await?;
738
739        // The quote is bound to this outpoint, so its credit is priced against the
740        // value read from the funding output. A credit above that value is not a
741        // fee at all, and must not read as a free claim.
742        let spark_requested_fee_sats = detailed_utxo
743            .value
744            .checked_sub(quote.credit_amount_sats)
745            .ok_or_else(|| {
746                SdkError::Generic(format!(
747                    "Static deposit quote credits {} sats for {}:{}, which is worth {} sats",
748                    quote.credit_amount_sats,
749                    detailed_utxo.txid,
750                    detailed_utxo.vout,
751                    detailed_utxo.value
752                ))
753            })?;
754
755        let spark_requested_fee_rate = spark_requested_fee_sats.div_ceil(CLAIM_TX_SIZE_VBYTES);
756
757        let resolved_max_fee = self.resolve_max_claim_fee(max_claim_fee).await?;
758        if let Some((_, max_fee_sats)) = &resolved_max_fee {
759            info!(
760                "Claiming {}:{} under a {max_fee_sats} sat ceiling, \
761                 provider asks {spark_requested_fee_sats} sats",
762                detailed_utxo.txid, detailed_utxo.vout
763            );
764        }
765        let within_limit = resolved_max_fee
766            .as_ref()
767            .is_some_and(|(_, max_fee_sats)| spark_requested_fee_sats <= *max_fee_sats);
768        if !within_limit {
769            return Err(SdkError::MaxDepositClaimFeeExceeded {
770                tx: detailed_utxo.txid.to_string(),
771                vout: detailed_utxo.vout,
772                max_fee: resolved_max_fee.map(|(fee, _)| fee),
773                required_fee_sats: spark_requested_fee_sats,
774                required_fee_rate_sat_per_vbyte: spark_requested_fee_rate,
775            });
776        }
777
778        info!(
779            "Claiming static deposit for utxo {}:{}",
780            detailed_utxo.txid, detailed_utxo.vout
781        );
782        let credit_amount_sats = quote.credit_amount_sats;
783        let transfer_id = self
784            .spark_wallet
785            .claim_static_deposit(&detailed_utxo.tx, quote)
786            .await?;
787        info!(
788            "Claimed static deposit for utxo {}:{} (deposit value {}, credit {}), transfer {transfer_id}",
789            detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value, credit_amount_sats,
790        );
791        Ok(transfer_id)
792    }
793}
794
795#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
796#[allow(clippy::needless_pass_by_value)]
797impl BreezSdk {
798    /// Synchronizes the wallet with the Spark network.
799    ///
800    /// Also collects the data a unilateral exit needs for any leaf still missing
801    /// it, and waits for that before returning. This happens regardless of
802    /// [`exit_chain_auto_fetch_enabled`](crate::Config::exit_chain_auto_fetch_enabled),
803    /// which governs only the automatic collection: syncing is how to run one at
804    /// a moment of your choosing with that turned off.
805    #[allow(unused_variables)]
806    pub async fn sync_wallet(
807        &self,
808        request: SyncWalletRequest,
809    ) -> Result<SyncWalletResponse, SdkError> {
810        self.runtime
811            .run_user_sync(self, super::SyncType::Full, true)
812            .await?;
813        // Whatever `exit_chain_auto_fetch_enabled` says: that flag governs the
814        // automatic collection, and an explicit sync is the way to ask for one
815        // with it turned off. Awaited rather than left to the background, so a
816        // caller that syncs before going offline knows the collection has run by
817        // the time this returns. After the sync, so the leaves it brought in are
818        // collected for too.
819        self.runtime.collect_exit_chains(self).await?;
820        Ok(SyncWalletResponse {})
821    }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::{claim_already_made, instant_claim_worth_attempting};
827    use crate::InstantClaimStatus;
828
829    fn declined(max_fee_sats: Option<u64>, confirmations: u32) -> InstantClaimStatus {
830        InstantClaimStatus::Declined {
831            max_fee_sats,
832            confirmations,
833        }
834    }
835
836    #[test]
837    fn instant_retry_declines_once_per_confirmation() {
838        // Never attempted -> attempt.
839        assert!(instant_claim_worth_attempting(None, 1, 500));
840        // A confirmation deeper than the decline: the provider lowers both its
841        // spread and its floor with depth, so retry.
842        assert!(instant_claim_worth_attempting(
843            Some(&declined(Some(500), 1)),
844            2,
845            500
846        ));
847        assert!(instant_claim_worth_attempting(
848            Some(&declined(None, 1)),
849            2,
850            500
851        ));
852        // Same depth, but the ceiling that declined it has been raised.
853        assert!(instant_claim_worth_attempting(
854            Some(&declined(Some(500), 1)),
855            1,
856            600
857        ));
858        // Same depth and the same ceiling: nothing has moved, so do not re-quote.
859        assert!(!instant_claim_worth_attempting(
860            Some(&declined(Some(500), 1)),
861            1,
862            500
863        ));
864        // A decline no ceiling caused does not re-quote on a ceiling change.
865        assert!(!instant_claim_worth_attempting(
866            Some(&declined(None, 1)),
867            1,
868            600
869        ));
870        // A shallower depth than the decline, as a reorg would give.
871        assert!(!instant_claim_worth_attempting(
872            Some(&declined(Some(500), 2)),
873            1,
874            500
875        ));
876    }
877
878    #[test]
879    fn a_ceiling_set_on_one_deposit_drives_its_retry() {
880        // Declined under the configured ceiling, then given a higher one of its
881        // own at the same depth: the deposit is re-quoted without waiting for a
882        // confirmation. This is what makes raising a ceiling claim a deposit
883        // early unattended.
884        assert!(instant_claim_worth_attempting(
885            Some(&declined(Some(99), 0)),
886            0,
887            50_000
888        ));
889        // Lowered below the ceiling that declined it, at the same depth: nothing
890        // has opened up, so the hold-to-maturity direction costs no provider call.
891        assert!(!instant_claim_worth_attempting(
892            Some(&declined(Some(50_000), 0)),
893            0,
894            99
895        ));
896    }
897
898    #[test]
899    fn a_claim_already_taken_is_recognised() {
900        // The cascade skips these before either claim path runs, so this is the
901        // guard rather than a second opinion alongside one.
902        let submitted = InstantClaimStatus::Submitted {
903            claim_id: "claim-1".to_string(),
904        };
905        assert!(claim_already_made(Some(&submitted)));
906        // A credited deposit is still reported until the provider spends the
907        // output, so it has to be skipped for as long as the record lives.
908        assert!(claim_already_made(Some(&InstantClaimStatus::Claimed)));
909        assert!(!claim_already_made(None));
910        // A decline leaves the deposit to be claimed at maturity.
911        assert!(!claim_already_made(Some(&declined(Some(500), 1))));
912    }
913}