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
31fn 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 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 details.description_hash.is_none() {
88 return;
89 }
90
91 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 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 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 self.event_emitter.emit(&SdkEvent::Synced).await;
158 return Ok(());
159 }
160
161 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 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 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 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 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 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 let fee_market = FeeMarket::new(self.chain_service.as_ref());
365 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 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 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 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 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 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 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 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 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 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 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 info!(
676 "Instant claim declined for utxo {}:{}: {error}",
677 detailed_utxo.txid, detailed_utxo.vout
678 );
679 }
680 }
681 Ok(())
682 }
683
684 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 break;
749 }
750 }
751
752 Ok(())
753 }
754
755 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 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 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 #[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 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 assert!(instant_claim_worth_attempting(None, 1, 500));
902 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 assert!(instant_claim_worth_attempting(
916 Some(&declined(Some(500), 1)),
917 1,
918 600
919 ));
920 assert!(!instant_claim_worth_attempting(
922 Some(&declined(Some(500), 1)),
923 1,
924 500
925 ));
926 assert!(!instant_claim_worth_attempting(
928 Some(&declined(None, 1)),
929 1,
930 600
931 ));
932 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 assert!(instant_claim_worth_attempting(
947 Some(&declined(Some(99), 0)),
948 0,
949 50_000
950 ));
951 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 let submitted = InstantClaimStatus::Submitted {
965 claim_id: "claim-1".to_string(),
966 };
967 assert!(claim_already_made(Some(&submitted)));
968 assert!(claim_already_made(Some(&InstantClaimStatus::Claimed)));
971 assert!(!claim_already_made(None));
972 assert!(!claim_already_made(Some(&declined(Some(500), 1))));
974 }
975}