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