1use platform_utils::time::{Instant, SystemTime};
2use platform_utils::tokio;
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5use tracing::{debug, error, info, trace, warn};
6
7use super::{
8 BreezSdk, CLAIM_TX_SIZE_VBYTES, SYNC_PAGING_LIMIT, SyncType, deposits::InstantClaimOutcome,
9 parse_input,
10};
11use crate::{
12 DepositInfo, Fee, InputType, InstantClaimDeclineReason, InstantClaimStatus, MaxFee,
13 PaymentDetails, PaymentType,
14 error::SdkError,
15 events::{InternalSyncedEvent, SdkEvent},
16 lnurl::ListMetadataRequest,
17 models::{Payment, SyncWalletRequest, SyncWalletResponse},
18 persist::{ObjectCacheRepository, UpdateDepositPayload},
19 sync::SparkSyncService,
20 utils::{
21 deposit_chain_syncer::{DepositChainSyncer, TxOutput},
22 payments::update_balances,
23 utxo_fetcher::DetailedUtxo,
24 },
25};
26
27fn should_claim_matured_deposit(status: Option<&InstantClaimStatus>) -> bool {
32 !matches!(status, Some(InstantClaimStatus::Submitted { .. }))
33}
34
35fn instant_claim_worth_attempting(status: Option<&InstantClaimStatus>, ceiling_bps: u32) -> bool {
40 match status {
41 None => true,
42 Some(InstantClaimStatus::Declined {
43 reason: InstantClaimDeclineReason::FeeExceeded { max_bps, .. },
44 }) => ceiling_bps > *max_bps,
45 Some(_) => false,
46 }
47}
48
49fn instant_claim_status_map(deposits: &[DepositInfo]) -> HashMap<TxOutput, InstantClaimStatus> {
51 deposits
52 .iter()
53 .filter_map(|d| {
54 d.instant_claim_status.clone().map(|status| {
55 (
56 TxOutput {
57 txid: d.txid.clone(),
58 vout: d.vout,
59 },
60 status,
61 )
62 })
63 })
64 .collect()
65}
66
67impl BreezSdk {
68 pub(in crate::sdk) async fn sync_single_lnurl_metadata(&self, payment: &mut Payment) {
69 if payment.payment_type != PaymentType::Receive {
70 return;
71 }
72
73 let Some(PaymentDetails::Lightning {
74 invoice,
75 lnurl_receive_metadata,
76 ..
77 }) = &mut payment.details
78 else {
79 return;
80 };
81
82 if lnurl_receive_metadata.is_some() {
83 return;
85 }
86
87 let Ok(input) = parse_input(invoice, None).await else {
88 error!(
89 "Failed to parse invoice for lnurl metadata sync: {}",
90 invoice
91 );
92 return;
93 };
94
95 let InputType::Bolt11Invoice(details) = input else {
96 error!(
97 "Input is not a Bolt11 invoice for lnurl metadata sync: {}",
98 invoice
99 );
100 return;
101 };
102
103 if details.description_hash.is_none() {
105 return;
106 }
107
108 if let Ok(db_payment) = self.storage.get_payment_by_id(payment.id.clone()).await
113 && let Some(PaymentDetails::Lightning {
114 lnurl_receive_metadata: db_lnurl_receive_metadata @ Some(_),
115 ..
116 }) = db_payment.details
117 {
118 *lnurl_receive_metadata = db_lnurl_receive_metadata;
119 return;
120 }
121
122 if let Err(e) = self.sync_lnurl_metadata().await {
126 error!("Failed to sync lnurl metadata for invoice {invoice}: {e}");
127 return;
128 }
129
130 let db_payment = match self.storage.get_payment_by_id(payment.id.clone()).await {
131 Ok(p) => p,
132 Err(e) => {
133 debug!("Payment not found in storage for invoice {}: {e}", invoice);
134 return;
135 }
136 };
137
138 let Some(PaymentDetails::Lightning {
139 lnurl_receive_metadata: db_lnurl_receive_metadata,
140 ..
141 }) = db_payment.details
142 else {
143 debug!(
144 "No lnurl receive metadata in storage for invoice {}",
145 invoice
146 );
147 return;
148 };
149 *lnurl_receive_metadata = db_lnurl_receive_metadata;
150 }
151
152 #[allow(clippy::too_many_lines)]
153 pub(super) async fn sync_wallet_internal(
154 &self,
155 sync_type: SyncType,
156 force: bool,
157 ) -> Result<(), SdkError> {
158 let cache = ObjectCacheRepository::new(self.storage.clone());
159 let sync_interval_secs = u64::from(self.config.sync_interval_secs);
160
161 let now = SystemTime::now()
162 .duration_since(SystemTime::UNIX_EPOCH)
163 .map_or(0, |d| d.as_secs());
164
165 if !force
167 && let Some(last) = cache.get_last_sync_time().await?
168 && now.saturating_sub(last) < sync_interval_secs
169 {
170 debug!("sync_wallet_internal: Synced recently, skipping");
171 self.event_emitter.emit(&SdkEvent::Synced).await;
175 return Ok(());
176 }
177
178 if sync_type.contains(SyncType::Full)
180 && let Err(e) = cache.set_last_sync_time(now).await
181 {
182 error!("sync_wallet_internal: Failed to update last sync time: {e:?}");
183 }
184
185 let start_time = Instant::now();
186
187 let sync_wallet = async {
188 let wallet_synced = if sync_type.contains(SyncType::Wallet) {
189 debug!("sync_wallet_internal: Starting Wallet sync");
190 let wallet_start = Instant::now();
191 match self.spark_wallet.sync().await {
192 Ok(()) => {
193 debug!(
194 "sync_wallet_internal: Wallet sync completed in {:?}",
195 wallet_start.elapsed()
196 );
197 true
198 }
199 Err(e) => {
200 error!(
201 "sync_wallet_internal: Spark wallet sync failed in {:?}: {e:?}",
202 wallet_start.elapsed()
203 );
204 false
205 }
206 }
207 } else {
208 trace!("sync_wallet_internal: Skipping Wallet sync");
209 false
210 };
211
212 let wallet_state_synced = if sync_type.contains(SyncType::WalletState) {
213 debug!("sync_wallet_internal: Starting WalletState sync");
214 let wallet_state_start = Instant::now();
215 match self.sync_wallet_state_to_storage().await {
216 Ok(()) => {
217 debug!(
218 "sync_wallet_internal: WalletState sync completed in {:?}",
219 wallet_state_start.elapsed()
220 );
221 true
222 }
223 Err(e) => {
224 error!(
225 "sync_wallet_internal: Failed to sync wallet state to storage in {:?}: {e:?}",
226 wallet_state_start.elapsed()
227 );
228 false
229 }
230 }
231 } else {
232 trace!("sync_wallet_internal: Skipping WalletState sync");
233 false
234 };
235
236 (wallet_synced, wallet_state_synced)
237 };
238
239 let sync_lnurl = async {
240 if sync_type.contains(SyncType::LnurlMetadata) {
241 debug!("sync_wallet_internal: Starting LnurlMetadata sync");
242 let lnurl_start = Instant::now();
243 match self.sync_lnurl_metadata().await {
244 Ok(()) => {
245 debug!(
246 "sync_wallet_internal: LnurlMetadata sync completed in {:?}",
247 lnurl_start.elapsed()
248 );
249 true
250 }
251 Err(e) => {
252 error!(
253 "sync_wallet_internal: Failed to sync lnurl metadata in {:?}: {e:?}",
254 lnurl_start.elapsed()
255 );
256 false
257 }
258 }
259 } else {
260 trace!("sync_wallet_internal: Skipping LnurlMetadata sync");
261 false
262 }
263 };
264
265 let sync_deposits = async {
266 if sync_type.contains(SyncType::Deposits) {
267 debug!("sync_wallet_internal: Starting Deposits sync");
268 let deposits_start = Instant::now();
269 match self.check_and_claim_static_deposits().await {
270 Ok(()) => {
271 debug!(
272 "sync_wallet_internal: Deposits sync completed in {:?}",
273 deposits_start.elapsed()
274 );
275 true
276 }
277 Err(e) => {
278 error!(
279 "sync_wallet_internal: Failed to check and claim static deposits in {:?}: {e:?}",
280 deposits_start.elapsed()
281 );
282 false
283 }
284 }
285 } else {
286 trace!("sync_wallet_internal: Skipping Deposits sync");
287 false
288 }
289 };
290
291 let ((wallet, wallet_state), lnurl_metadata, deposits) =
292 tokio::join!(sync_wallet, sync_lnurl, sync_deposits);
293
294 let elapsed = start_time.elapsed();
295 let event = InternalSyncedEvent {
296 wallet,
297 wallet_state,
298 lnurl_metadata,
299 deposits,
300 storage_incoming: None,
301 };
302 info!("sync_wallet_internal: Wallet sync completed in {elapsed:?}: {event:?}");
303 self.event_emitter.emit_synced(&event).await;
304 Ok(())
305 }
306
307 pub(super) async fn sync_wallet_state_to_storage(&self) -> Result<(), SdkError> {
309 update_balances(self.spark_wallet.clone(), self.storage.clone()).await?;
310
311 let initial_sync_complete = *self.initial_synced_watcher.borrow();
312 let sync_service = SparkSyncService::new(
313 self.spark_wallet.clone(),
314 self.storage.clone(),
315 self.event_emitter.clone(),
316 );
317 sync_service.sync_payments(initial_sync_complete).await?;
318
319 Ok(())
320 }
321
322 pub(super) async fn check_and_claim_static_deposits(&self) -> Result<(), SdkError> {
323 self.maybe_ensure_spark_private_mode_initialized().await?;
324 let existing_deposits = self.storage.list_deposits().await?;
325 let existing_keys: HashSet<TxOutput> = existing_deposits
326 .iter()
327 .map(|d| TxOutput {
328 txid: d.txid.clone(),
329 vout: d.vout,
330 })
331 .collect();
332
333 let all_utxos = DepositChainSyncer::new(
334 self.chain_service.clone(),
335 self.storage.clone(),
336 self.spark_wallet.clone(),
337 )
338 .sync()
339 .await?;
340
341 let new_deposits: Vec<DepositInfo> = all_utxos
343 .iter()
344 .filter(|(u, _)| {
345 !existing_keys.contains(&TxOutput {
346 txid: u.txid.to_string(),
347 vout: u.vout,
348 })
349 })
350 .map(|(u, is_mature)| u.clone().into_deposit_info(*is_mature))
351 .collect();
352 if !new_deposits.is_empty() {
353 self.event_emitter
354 .emit(&SdkEvent::NewDeposits { new_deposits })
355 .await;
356 }
357
358 let instant_status = instant_claim_status_map(&self.storage.list_deposits().await?);
362
363 let mut claimed_deposits: Vec<DepositInfo> = Vec::new();
364 let mut unclaimed_deposits: Vec<DepositInfo> = Vec::new();
365 for (detailed_utxo, is_mature) in all_utxos {
366 let key = TxOutput {
367 txid: detailed_utxo.txid.to_string(),
368 vout: detailed_utxo.vout,
369 };
370 let res = if is_mature {
371 if !should_claim_matured_deposit(instant_status.get(&key)) {
376 continue;
377 }
378 self.claim_utxo_and_resolve_deposit(
380 &detailed_utxo,
381 self.config.max_deposit_claim_fee.clone(),
382 &mut claimed_deposits,
383 &mut unclaimed_deposits,
384 )
385 .await
386 } else {
387 let Some(max_instant_fee_bps) = self.config.max_instant_deposit_claim_fee_bps
390 else {
391 continue;
392 };
393 if !instant_claim_worth_attempting(instant_status.get(&key), max_instant_fee_bps) {
396 continue;
397 }
398 self.instant_claim_utxo_and_resolve_deposit(
399 &detailed_utxo,
400 max_instant_fee_bps,
401 &mut claimed_deposits,
402 )
403 .await
404 };
405
406 if let Err(e) = res {
407 warn!(
408 "Failed to update deposit for utxo {}:{}: {e}",
409 detailed_utxo.txid, detailed_utxo.vout
410 );
411 }
412 }
413
414 info!("background claim completed, unclaimed deposits: {unclaimed_deposits:?}");
415
416 if !unclaimed_deposits.is_empty() {
417 self.event_emitter
418 .emit(&SdkEvent::UnclaimedDeposits { unclaimed_deposits })
419 .await;
420 }
421 if !claimed_deposits.is_empty() {
422 self.event_emitter
423 .emit(&SdkEvent::ClaimedDeposits { claimed_deposits })
424 .await;
425 }
426 Ok(())
427 }
428
429 async fn claim_utxo_and_resolve_deposit(
430 &self,
431 detailed_utxo: &DetailedUtxo,
432 max_claim_fee: Option<MaxFee>,
433 claimed_deposits: &mut Vec<DepositInfo>,
434 unclaimed_deposits: &mut Vec<DepositInfo>,
435 ) -> Result<(), SdkError> {
436 match self.claim_utxo(detailed_utxo, max_claim_fee).await {
437 Ok(_) => {
438 info!("Claimed utxo {}:{}", detailed_utxo.txid, detailed_utxo.vout);
439 self.storage
440 .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
441 .await?;
442 claimed_deposits.push(detailed_utxo.clone().into_deposit_info(true));
443 }
444 Err(e) => {
445 warn!(
446 "Failed to claim utxo {}:{}: {e}",
447 detailed_utxo.txid, detailed_utxo.vout
448 );
449 unclaimed_deposits.push(self.record_unclaimed_deposit(detailed_utxo, e).await?);
450 }
451 }
452 Ok(())
453 }
454
455 async fn instant_claim_utxo_and_resolve_deposit(
456 &self,
457 detailed_utxo: &DetailedUtxo,
458 max_instant_fee_bps: u32,
459 claimed_deposits: &mut Vec<DepositInfo>,
460 ) -> Result<(), SdkError> {
461 let outcome = match self
462 .instant_claim_utxo(detailed_utxo, max_instant_fee_bps)
463 .await
464 {
465 Ok(outcome) => outcome,
466 Err(e) => {
467 warn!(
470 "Instant claim transient error for utxo {}:{}, will retry: {e}",
471 detailed_utxo.txid, detailed_utxo.vout
472 );
473 return Ok(());
474 }
475 };
476
477 let status = outcome.status();
485 self.storage
486 .update_deposit(
487 detailed_utxo.txid.to_string(),
488 detailed_utxo.vout,
489 UpdateDepositPayload::InstantClaim {
490 status: status.clone(),
491 },
492 )
493 .await?;
494
495 match outcome {
496 InstantClaimOutcome::Submitted(claim_id) => {
497 info!(
498 "Instant claimed utxo {}:{} with claim_id: {claim_id}",
499 detailed_utxo.txid, detailed_utxo.vout
500 );
501 let mut info = detailed_utxo.clone().into_deposit_info(false);
502 info.instant_claim_status = Some(status);
503 claimed_deposits.push(info);
504 }
505 InstantClaimOutcome::Declined { error, .. } => {
506 info!(
507 "Instant claim declined for utxo {}:{}: {error}",
508 detailed_utxo.txid, detailed_utxo.vout
509 );
510 }
511 }
512 Ok(())
513 }
514
515 async fn record_unclaimed_deposit(
518 &self,
519 utxo: &DetailedUtxo,
520 error: SdkError,
521 ) -> Result<DepositInfo, SdkError> {
522 self.storage
523 .update_deposit(
524 utxo.txid.to_string(),
525 utxo.vout,
526 UpdateDepositPayload::ClaimError {
527 error: error.clone().into(),
528 },
529 )
530 .await?;
531 let mut info = utxo.clone().into_deposit_info(true);
532 info.claim_error = Some(error.into());
533 Ok(info)
534 }
535
536 pub(super) async fn sync_lnurl_metadata(&self) -> Result<(), SdkError> {
537 let Some(lnurl_server_client) = self.lnurl_server_client.clone() else {
538 return Ok(());
539 };
540
541 let cache = ObjectCacheRepository::new(Arc::clone(&self.storage));
542 let mut updated_after = cache.fetch_lnurl_metadata_updated_after().await?;
543
544 loop {
545 debug!("Syncing lnurl metadata from updated_after {updated_after}");
546 let metadata = lnurl_server_client
547 .list_metadata(&ListMetadataRequest {
548 offset: None,
549 limit: Some(SYNC_PAGING_LIMIT),
550 updated_after: Some(updated_after),
551 })
552 .await?;
553
554 if metadata.metadata.is_empty() {
555 debug!("No more lnurl metadata on offset {updated_after}");
556 break;
557 }
558
559 let len = u32::try_from(metadata.metadata.len())?;
560 let last_updated_at = metadata.metadata.last().map(|m| m.updated_at);
561 self.storage
562 .set_lnurl_metadata(metadata.metadata.into_iter().map(From::from).collect())
563 .await?;
564
565 debug!(
566 "Synchronized {} lnurl metadata at updated_after {updated_after}",
567 len
568 );
569 updated_after = last_updated_at.unwrap_or(updated_after);
570 cache
571 .save_lnurl_metadata_updated_after(updated_after)
572 .await?;
573
574 if len < SYNC_PAGING_LIMIT {
575 break;
577 }
578 }
579
580 Ok(())
581 }
582
583 async fn resolve_max_claim_fee(
587 &self,
588 max_claim_fee: Option<MaxFee>,
589 ) -> Result<Option<(Fee, u64)>, SdkError> {
590 match max_claim_fee {
591 None => Ok(None),
592 Some(max_fee) => {
593 let fee = max_fee.to_fee(self.chain_service.as_ref()).await?;
594 let sats = fee.to_sats(CLAIM_TX_SIZE_VBYTES);
595 Ok(Some((fee, sats)))
596 }
597 }
598 }
599
600 pub(super) async fn claim_utxo(
603 &self,
604 detailed_utxo: &DetailedUtxo,
605 max_claim_fee: Option<MaxFee>,
606 ) -> Result<String, SdkError> {
607 info!(
608 "Fetching static deposit claim quote for deposit tx {}:{} and amount: {}",
609 detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value
610 );
611 let quote = self
612 .spark_wallet
613 .fetch_static_deposit_claim_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
614 .await?;
615
616 let spark_requested_fee_sats = detailed_utxo.value.saturating_sub(quote.credit_amount_sats);
617
618 let spark_requested_fee_rate = spark_requested_fee_sats.div_ceil(CLAIM_TX_SIZE_VBYTES);
619
620 let resolved_max_fee = self.resolve_max_claim_fee(max_claim_fee).await?;
621 if let Some((_, max_fee_sats)) = &resolved_max_fee {
622 info!("User max fee: {max_fee_sats} spark requested fee: {spark_requested_fee_sats}");
623 }
624 let within_limit = resolved_max_fee
625 .as_ref()
626 .is_some_and(|(_, max_fee_sats)| spark_requested_fee_sats <= *max_fee_sats);
627 if !within_limit {
628 return Err(SdkError::MaxDepositClaimFeeExceeded {
629 tx: detailed_utxo.txid.to_string(),
630 vout: detailed_utxo.vout,
631 max_fee: resolved_max_fee.map(|(fee, _)| fee),
632 required_fee_sats: spark_requested_fee_sats,
633 required_fee_rate_sat_per_vbyte: spark_requested_fee_rate,
634 });
635 }
636
637 info!(
638 "Claiming static deposit for utxo {}:{}",
639 detailed_utxo.txid, detailed_utxo.vout
640 );
641 let credit_amount_sats = quote.credit_amount_sats;
642 let transfer_id = self.spark_wallet.claim_static_deposit(quote).await?;
643 info!(
644 "Claimed static deposit for utxo {}:{} (deposit value {}, credit {}), transfer {transfer_id}",
645 detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value, credit_amount_sats,
646 );
647 Ok(transfer_id)
648 }
649}
650
651#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
652#[allow(clippy::needless_pass_by_value)]
653impl BreezSdk {
654 #[allow(unused_variables)]
656 pub async fn sync_wallet(
657 &self,
658 request: SyncWalletRequest,
659 ) -> Result<SyncWalletResponse, SdkError> {
660 self.runtime
661 .run_user_sync(self, super::SyncType::Full, true)
662 .await?;
663 if self.config.exit_chain_auto_fetch_enabled {
668 self.runtime.collect_exit_chains(self).await?;
669 }
670 Ok(SyncWalletResponse {})
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::{instant_claim_worth_attempting, should_claim_matured_deposit};
677 use crate::{InstantClaimDeclineReason, InstantClaimStatus};
678
679 fn declined(reason: InstantClaimDeclineReason) -> InstantClaimStatus {
680 InstantClaimStatus::Declined { reason }
681 }
682
683 fn submitted() -> InstantClaimStatus {
684 InstantClaimStatus::Submitted {
685 claim_id: "claim-1".to_string(),
686 }
687 }
688
689 fn fee_exceeded(max_bps: u32) -> InstantClaimDeclineReason {
690 InstantClaimDeclineReason::FeeExceeded {
691 max_bps,
692 quoted_bps: 0,
693 quoted_sats: 0,
694 }
695 }
696
697 #[test]
698 fn claim_matured_deposit_skips_only_submitted() {
699 assert!(should_claim_matured_deposit(None));
702 assert!(should_claim_matured_deposit(Some(&declined(
703 InstantClaimDeclineReason::NoPlan
704 ))));
705 assert!(should_claim_matured_deposit(Some(&declined(fee_exceeded(
706 400
707 )))));
708 assert!(!should_claim_matured_deposit(Some(&submitted())));
711 }
712
713 #[test]
714 fn instant_retry_only_fee_exceeded_at_a_higher_ceiling() {
715 assert!(instant_claim_worth_attempting(None, 400));
717 assert!(instant_claim_worth_attempting(
719 Some(&declined(fee_exceeded(100))),
720 400
721 ));
722 assert!(!instant_claim_worth_attempting(
724 Some(&declined(fee_exceeded(400))),
725 400
726 ));
727 assert!(!instant_claim_worth_attempting(
728 Some(&declined(fee_exceeded(500))),
729 400
730 ));
731 assert!(!instant_claim_worth_attempting(
733 Some(&declined(InstantClaimDeclineReason::NoPlan)),
734 400
735 ));
736 assert!(!instant_claim_worth_attempting(
737 Some(&declined(InstantClaimDeclineReason::SubmissionFailed)),
738 400
739 ));
740 assert!(!instant_claim_worth_attempting(Some(&submitted()), 400));
741 }
742}