Skip to main content

breez_sdk_spark/sdk/
sync.rs

1use platform_utils::time::{Instant, SystemTime};
2use platform_utils::tokio;
3use std::sync::Arc;
4use tracing::{debug, error, info, trace, warn};
5
6use super::{BreezSdk, CLAIM_TX_SIZE_VBYTES, SYNC_PAGING_LIMIT, SyncType, parse_input};
7use crate::{
8    DepositInfo, InputType, MaxFee, PaymentDetails, PaymentType,
9    error::SdkError,
10    events::{InternalSyncedEvent, SdkEvent},
11    lnurl::ListMetadataRequest,
12    models::{Payment, SyncWalletRequest, SyncWalletResponse},
13    persist::{ObjectCacheRepository, UpdateDepositPayload},
14    sync::SparkSyncService,
15    utils::{
16        deposit_chain_syncer::{DepositChainSyncer, TxOutput},
17        payments::update_balances,
18        utxo_fetcher::DetailedUtxo,
19    },
20};
21
22impl BreezSdk {
23    pub(in crate::sdk) async fn sync_single_lnurl_metadata(&self, payment: &mut Payment) {
24        if payment.payment_type != PaymentType::Receive {
25            return;
26        }
27
28        let Some(PaymentDetails::Lightning {
29            invoice,
30            lnurl_receive_metadata,
31            ..
32        }) = &mut payment.details
33        else {
34            return;
35        };
36
37        if lnurl_receive_metadata.is_some() {
38            // Already have lnurl metadata
39            return;
40        }
41
42        let Ok(input) = parse_input(invoice, None).await else {
43            error!(
44                "Failed to parse invoice for lnurl metadata sync: {}",
45                invoice
46            );
47            return;
48        };
49
50        let InputType::Bolt11Invoice(details) = input else {
51            error!(
52                "Input is not a Bolt11 invoice for lnurl metadata sync: {}",
53                invoice
54            );
55            return;
56        };
57
58        // If there is a description hash, we assume this is a lnurl payment.
59        if details.description_hash.is_none() {
60            return;
61        }
62
63        // Let's check whether the lnurl receive metadata was already synced, then return early.
64        // Important: Only return early if metadata is actually present (Some), otherwise we need
65        // to trigger a sync. This prevents a race condition where the payment is in storage but
66        // metadata sync from TransferClaimStarting hasn't completed yet.
67        if let Ok(db_payment) = self.storage.get_payment_by_id(payment.id.clone()).await
68            && let Some(PaymentDetails::Lightning {
69                lnurl_receive_metadata: db_lnurl_receive_metadata @ Some(_),
70                ..
71            }) = db_payment.details
72        {
73            *lnurl_receive_metadata = db_lnurl_receive_metadata;
74            return;
75        }
76
77        // Sync lnurl metadata directly instead of going through the sync trigger,
78        // because this function is called from the sync loop's event handler,
79        // which would deadlock waiting for itself to process the trigger.
80        if let Err(e) = self.sync_lnurl_metadata().await {
81            error!("Failed to sync lnurl metadata for invoice {invoice}: {e}");
82            return;
83        }
84
85        let db_payment = match self.storage.get_payment_by_id(payment.id.clone()).await {
86            Ok(p) => p,
87            Err(e) => {
88                debug!("Payment not found in storage for invoice {}: {e}", invoice);
89                return;
90            }
91        };
92
93        let Some(PaymentDetails::Lightning {
94            lnurl_receive_metadata: db_lnurl_receive_metadata,
95            ..
96        }) = db_payment.details
97        else {
98            debug!(
99                "No lnurl receive metadata in storage for invoice {}",
100                invoice
101            );
102            return;
103        };
104        *lnurl_receive_metadata = db_lnurl_receive_metadata;
105    }
106
107    #[allow(clippy::too_many_lines)]
108    pub(super) async fn sync_wallet_internal(
109        &self,
110        sync_type: SyncType,
111        force: bool,
112    ) -> Result<(), SdkError> {
113        let cache = ObjectCacheRepository::new(self.storage.clone());
114        let sync_interval_secs = u64::from(self.config.sync_interval_secs);
115
116        let now = SystemTime::now()
117            .duration_since(SystemTime::UNIX_EPOCH)
118            .map_or(0, |d| d.as_secs());
119
120        // Skip if we synced recently (unless forced).
121        if !force
122            && let Some(last) = cache.get_last_sync_time().await?
123            && now.saturating_sub(last) < sync_interval_secs
124        {
125            debug!("sync_wallet_internal: Synced recently, skipping");
126            // When another instance shares our storage and keeps winning the sync
127            // race, we would otherwise never emit a Synced event. Emit it here so
128            // consumers are still notified that storage is up to date.
129            self.event_emitter.emit(&SdkEvent::Synced).await;
130            return Ok(());
131        }
132
133        // Update last sync time if this is a full sync.
134        if sync_type.contains(SyncType::Full)
135            && let Err(e) = cache.set_last_sync_time(now).await
136        {
137            error!("sync_wallet_internal: Failed to update last sync time: {e:?}");
138        }
139
140        let start_time = Instant::now();
141
142        let sync_wallet = async {
143            let wallet_synced = if sync_type.contains(SyncType::Wallet) {
144                debug!("sync_wallet_internal: Starting Wallet sync");
145                let wallet_start = Instant::now();
146                match self.spark_wallet.sync().await {
147                    Ok(()) => {
148                        debug!(
149                            "sync_wallet_internal: Wallet sync completed in {:?}",
150                            wallet_start.elapsed()
151                        );
152                        true
153                    }
154                    Err(e) => {
155                        error!(
156                            "sync_wallet_internal: Spark wallet sync failed in {:?}: {e:?}",
157                            wallet_start.elapsed()
158                        );
159                        false
160                    }
161                }
162            } else {
163                trace!("sync_wallet_internal: Skipping Wallet sync");
164                false
165            };
166
167            let wallet_state_synced = if sync_type.contains(SyncType::WalletState) {
168                debug!("sync_wallet_internal: Starting WalletState sync");
169                let wallet_state_start = Instant::now();
170                match self.sync_wallet_state_to_storage().await {
171                    Ok(()) => {
172                        debug!(
173                            "sync_wallet_internal: WalletState sync completed in {:?}",
174                            wallet_state_start.elapsed()
175                        );
176                        true
177                    }
178                    Err(e) => {
179                        error!(
180                            "sync_wallet_internal: Failed to sync wallet state to storage in {:?}: {e:?}",
181                            wallet_state_start.elapsed()
182                        );
183                        false
184                    }
185                }
186            } else {
187                trace!("sync_wallet_internal: Skipping WalletState sync");
188                false
189            };
190
191            (wallet_synced, wallet_state_synced)
192        };
193
194        let sync_lnurl = async {
195            if sync_type.contains(SyncType::LnurlMetadata) {
196                debug!("sync_wallet_internal: Starting LnurlMetadata sync");
197                let lnurl_start = Instant::now();
198                match self.sync_lnurl_metadata().await {
199                    Ok(()) => {
200                        debug!(
201                            "sync_wallet_internal: LnurlMetadata sync completed in {:?}",
202                            lnurl_start.elapsed()
203                        );
204                        true
205                    }
206                    Err(e) => {
207                        error!(
208                            "sync_wallet_internal: Failed to sync lnurl metadata in {:?}: {e:?}",
209                            lnurl_start.elapsed()
210                        );
211                        false
212                    }
213                }
214            } else {
215                trace!("sync_wallet_internal: Skipping LnurlMetadata sync");
216                false
217            }
218        };
219
220        let sync_deposits = async {
221            if sync_type.contains(SyncType::Deposits) {
222                debug!("sync_wallet_internal: Starting Deposits sync");
223                let deposits_start = Instant::now();
224                match self.check_and_claim_static_deposits().await {
225                    Ok(()) => {
226                        debug!(
227                            "sync_wallet_internal: Deposits sync completed in {:?}",
228                            deposits_start.elapsed()
229                        );
230                        true
231                    }
232                    Err(e) => {
233                        error!(
234                            "sync_wallet_internal: Failed to check and claim static deposits in {:?}: {e:?}",
235                            deposits_start.elapsed()
236                        );
237                        false
238                    }
239                }
240            } else {
241                trace!("sync_wallet_internal: Skipping Deposits sync");
242                false
243            }
244        };
245
246        let ((wallet, wallet_state), lnurl_metadata, deposits) =
247            tokio::join!(sync_wallet, sync_lnurl, sync_deposits);
248
249        let elapsed = start_time.elapsed();
250        let event = InternalSyncedEvent {
251            wallet,
252            wallet_state,
253            lnurl_metadata,
254            deposits,
255            storage_incoming: None,
256        };
257        info!("sync_wallet_internal: Wallet sync completed in {elapsed:?}: {event:?}");
258        self.event_emitter.emit_synced(&event).await;
259        Ok(())
260    }
261
262    /// Synchronizes wallet state to persistent storage, making sure we have the latest balances and payments.
263    pub(super) async fn sync_wallet_state_to_storage(&self) -> Result<(), SdkError> {
264        update_balances(self.spark_wallet.clone(), self.storage.clone()).await?;
265
266        let initial_sync_complete = *self.initial_synced_watcher.borrow();
267        let sync_service = SparkSyncService::new(
268            self.spark_wallet.clone(),
269            self.storage.clone(),
270            self.event_emitter.clone(),
271        );
272        sync_service.sync_payments(initial_sync_complete).await?;
273
274        Ok(())
275    }
276
277    pub(super) async fn check_and_claim_static_deposits(&self) -> Result<(), SdkError> {
278        self.maybe_ensure_spark_private_mode_initialized().await?;
279        let existing_deposits = self.storage.list_deposits().await?;
280        let existing_keys: std::collections::HashSet<TxOutput> = existing_deposits
281            .iter()
282            .map(|d| TxOutput {
283                txid: d.txid.clone(),
284                vout: d.vout,
285            })
286            .collect();
287
288        let all_utxos = DepositChainSyncer::new(
289            self.chain_service.clone(),
290            self.storage.clone(),
291            self.spark_wallet.clone(),
292        )
293        .sync()
294        .await?;
295
296        // Emit NewDeposits for any deposits not previously known
297        let new_deposits: Vec<DepositInfo> = all_utxos
298            .iter()
299            .filter(|(u, _)| {
300                !existing_keys.contains(&TxOutput {
301                    txid: u.txid.to_string(),
302                    vout: u.vout,
303                })
304            })
305            .map(|(u, is_mature)| u.clone().into_deposit_info(*is_mature))
306            .collect();
307        if !new_deposits.is_empty() {
308            self.event_emitter
309                .emit(&SdkEvent::NewDeposits { new_deposits })
310                .await;
311        }
312
313        // Only claim UTXOs with sufficient confirmations
314        let to_claim: Vec<_> = all_utxos
315            .into_iter()
316            .filter(|(_, is_mature)| *is_mature)
317            .map(|(u, _)| u)
318            .collect();
319
320        let mut claimed_deposits: Vec<DepositInfo> = Vec::new();
321        let mut unclaimed_deposits: Vec<DepositInfo> = Vec::new();
322        for detailed_utxo in to_claim {
323            match self
324                .claim_utxo(&detailed_utxo, self.config.max_deposit_claim_fee.clone())
325                .await
326            {
327                Ok(_) => {
328                    info!("Claimed utxo {}:{}", detailed_utxo.txid, detailed_utxo.vout);
329                    self.storage
330                        .delete_deposit(detailed_utxo.txid.to_string(), detailed_utxo.vout)
331                        .await?;
332                    claimed_deposits.push(detailed_utxo.into_deposit_info(true));
333                }
334                Err(e) => {
335                    warn!(
336                        "Failed to claim utxo {}:{}: {e}",
337                        detailed_utxo.txid, detailed_utxo.vout
338                    );
339                    unclaimed_deposits
340                        .push(self.record_unclaimed_deposit(&detailed_utxo, e).await?);
341                }
342            }
343        }
344
345        info!("background claim completed, unclaimed deposits: {unclaimed_deposits:?}");
346
347        if !unclaimed_deposits.is_empty() {
348            self.event_emitter
349                .emit(&SdkEvent::UnclaimedDeposits { unclaimed_deposits })
350                .await;
351        }
352        if !claimed_deposits.is_empty() {
353            self.event_emitter
354                .emit(&SdkEvent::ClaimedDeposits { claimed_deposits })
355                .await;
356        }
357        Ok(())
358    }
359
360    /// Persists a claim failure on the deposit and returns the matching
361    /// `DepositInfo` (with `claim_error` set) for the `UnclaimedDeposits` event.
362    async fn record_unclaimed_deposit(
363        &self,
364        utxo: &DetailedUtxo,
365        error: SdkError,
366    ) -> Result<DepositInfo, SdkError> {
367        self.storage
368            .update_deposit(
369                utxo.txid.to_string(),
370                utxo.vout,
371                UpdateDepositPayload::ClaimError {
372                    error: error.clone().into(),
373                },
374            )
375            .await?;
376        let mut info = utxo.clone().into_deposit_info(true);
377        info.claim_error = Some(error.into());
378        Ok(info)
379    }
380
381    pub(super) async fn sync_lnurl_metadata(&self) -> Result<(), SdkError> {
382        let Some(lnurl_server_client) = self.lnurl_server_client.clone() else {
383            return Ok(());
384        };
385
386        let cache = ObjectCacheRepository::new(Arc::clone(&self.storage));
387        let mut updated_after = cache.fetch_lnurl_metadata_updated_after().await?;
388
389        loop {
390            debug!("Syncing lnurl metadata from updated_after {updated_after}");
391            let metadata = lnurl_server_client
392                .list_metadata(&ListMetadataRequest {
393                    offset: None,
394                    limit: Some(SYNC_PAGING_LIMIT),
395                    updated_after: Some(updated_after),
396                })
397                .await?;
398
399            if metadata.metadata.is_empty() {
400                debug!("No more lnurl metadata on offset {updated_after}");
401                break;
402            }
403
404            let len = u32::try_from(metadata.metadata.len())?;
405            let last_updated_at = metadata.metadata.last().map(|m| m.updated_at);
406            self.storage
407                .set_lnurl_metadata(metadata.metadata.into_iter().map(From::from).collect())
408                .await?;
409
410            debug!(
411                "Synchronized {} lnurl metadata at updated_after {updated_after}",
412                len
413            );
414            updated_after = last_updated_at.unwrap_or(updated_after);
415            cache
416                .save_lnurl_metadata_updated_after(updated_after)
417                .await?;
418
419            if len < SYNC_PAGING_LIMIT {
420                // No more invoices to fetch
421                break;
422            }
423        }
424
425        Ok(())
426    }
427
428    /// Submits a static deposit claim for `detailed_utxo` and returns the
429    /// resulting transfer id.
430    pub(super) async fn claim_utxo(
431        &self,
432        detailed_utxo: &DetailedUtxo,
433        max_claim_fee: Option<MaxFee>,
434    ) -> Result<String, SdkError> {
435        info!(
436            "Fetching static deposit claim quote for deposit tx {}:{} and amount: {}",
437            detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value
438        );
439        let quote = self
440            .spark_wallet
441            .fetch_static_deposit_claim_quote(detailed_utxo.tx.clone(), Some(detailed_utxo.vout))
442            .await?;
443
444        let spark_requested_fee_sats = detailed_utxo.value.saturating_sub(quote.credit_amount_sats);
445
446        let spark_requested_fee_rate = spark_requested_fee_sats.div_ceil(CLAIM_TX_SIZE_VBYTES);
447
448        let Some(max_deposit_claim_fee) = max_claim_fee else {
449            return Err(SdkError::MaxDepositClaimFeeExceeded {
450                tx: detailed_utxo.txid.to_string(),
451                vout: detailed_utxo.vout,
452                max_fee: None,
453                required_fee_sats: spark_requested_fee_sats,
454                required_fee_rate_sat_per_vbyte: spark_requested_fee_rate,
455            });
456        };
457        let max_fee = max_deposit_claim_fee
458            .to_fee(self.chain_service.as_ref())
459            .await?;
460        let max_fee_sats = max_fee.to_sats(CLAIM_TX_SIZE_VBYTES);
461        info!(
462            "User max fee: {} spark requested fee: {}",
463            max_fee_sats, spark_requested_fee_sats
464        );
465        if spark_requested_fee_sats > max_fee_sats {
466            return Err(SdkError::MaxDepositClaimFeeExceeded {
467                tx: detailed_utxo.txid.to_string(),
468                vout: detailed_utxo.vout,
469                max_fee: Some(max_fee),
470                required_fee_sats: spark_requested_fee_sats,
471                required_fee_rate_sat_per_vbyte: spark_requested_fee_rate,
472            });
473        }
474
475        info!(
476            "Claiming static deposit for utxo {}:{}",
477            detailed_utxo.txid, detailed_utxo.vout
478        );
479        let credit_amount_sats = quote.credit_amount_sats;
480        let transfer_id = self.spark_wallet.claim_static_deposit(quote).await?;
481        info!(
482            "Claimed static deposit for utxo {}:{} (deposit value {}, credit {}), transfer {transfer_id}",
483            detailed_utxo.txid, detailed_utxo.vout, detailed_utxo.value, credit_amount_sats,
484        );
485        Ok(transfer_id)
486    }
487}
488
489#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
490#[allow(clippy::needless_pass_by_value)]
491impl BreezSdk {
492    /// Synchronizes the wallet with the Spark network
493    #[allow(unused_variables)]
494    pub async fn sync_wallet(
495        &self,
496        request: SyncWalletRequest,
497    ) -> Result<SyncWalletResponse, SdkError> {
498        self.runtime
499            .run_user_sync(self, super::SyncType::Full, true)
500            .await?;
501        Ok(SyncWalletResponse {})
502    }
503}