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
30fn 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 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 details.description_hash.is_none() {
87 return;
88 }
89
90 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 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 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 self.event_emitter.emit(&SdkEvent::Synced).await;
157 return Ok(());
158 }
159
160 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 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 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 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 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 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 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 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 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 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 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 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 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 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 info!(
627 "Instant claim declined for utxo {}:{}: {error}",
628 detailed_utxo.txid, detailed_utxo.vout
629 );
630 }
631 }
632 Ok(())
633 }
634
635 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 break;
700 }
701 }
702
703 Ok(())
704 }
705
706 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 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 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 #[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 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 assert!(instant_claim_worth_attempting(None, 1, 500));
840 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 assert!(instant_claim_worth_attempting(
854 Some(&declined(Some(500), 1)),
855 1,
856 600
857 ));
858 assert!(!instant_claim_worth_attempting(
860 Some(&declined(Some(500), 1)),
861 1,
862 500
863 ));
864 assert!(!instant_claim_worth_attempting(
866 Some(&declined(None, 1)),
867 1,
868 600
869 ));
870 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 assert!(instant_claim_worth_attempting(
885 Some(&declined(Some(99), 0)),
886 0,
887 50_000
888 ));
889 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 let submitted = InstantClaimStatus::Submitted {
903 claim_id: "claim-1".to_string(),
904 };
905 assert!(claim_already_made(Some(&submitted)));
906 assert!(claim_already_made(Some(&InstantClaimStatus::Claimed)));
909 assert!(!claim_already_made(None));
910 assert!(!claim_already_made(Some(&declined(Some(500), 1))));
912 }
913}