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 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 details.description_hash.is_none() {
60 return;
61 }
62
63 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 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 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 self.event_emitter.emit(&SdkEvent::Synced).await;
130 return Ok(());
131 }
132
133 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 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 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 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 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 break;
422 }
423 }
424
425 Ok(())
426 }
427
428 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 #[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}