Skip to main content

breez_sdk_spark/persist/
mod.rs

1pub(crate) mod backend;
2#[cfg(feature = "mysql")]
3pub mod mysql;
4pub(crate) mod path;
5#[cfg(feature = "postgres")]
6pub mod postgres;
7#[cfg(feature = "sqlite")]
8pub(crate) mod sqlite;
9
10// The `sqlite`, `postgres` and `mysql` storage backends use native-only Rust
11// drivers and cannot be built for the wasm32 target. WASM builds use a
12// JS-backed storage backend instead, so none of these features apply there.
13#[cfg(all(
14    any(feature = "sqlite", feature = "postgres", feature = "mysql"),
15    target_family = "wasm",
16    target_os = "unknown"
17))]
18compile_error!(
19    "the `sqlite`, `postgres` and `mysql` storage features are native-only and \
20     cannot be enabled for the wasm32 target"
21);
22
23use std::{collections::HashMap, sync::Arc};
24
25use macros::async_trait;
26use serde::{Deserialize, Serialize};
27use thiserror::Error;
28
29use crate::{
30    AssetFilter, Contact, ConversionInfo, ConversionStatus, DepositClaimError, DepositInfo,
31    InstantClaimStatus, LightningAddressInfo, ListContactsRequest, ListPaymentsRequest,
32    LnurlPayInfo, LnurlWithdrawInfo, PaymentDetailsFilter, PaymentStatus, PaymentType,
33    SparkHtlcStatus, TokenBalance, TokenMetadata, TokenTransactionType,
34    models::Payment,
35    sync_storage::{IncomingChange, OutgoingChange, Record, UnversionedRecordChange},
36};
37
38const ACCOUNT_INFO_KEY: &str = "account_info";
39const LAST_SYNC_TIME_KEY: &str = "last_sync_time";
40pub(crate) const LIGHTNING_ADDRESS_KEY: &str = "lightning_address";
41const LNURL_METADATA_UPDATED_AFTER_KEY: &str = "lnurl_metadata_updated_after";
42const SYNC_OFFSET_KEY: &str = "sync_offset";
43const TX_CACHE_KEY: &str = "tx_cache";
44// Note: the key "static_deposit_address" may still exist in storage from older versions.
45const TOKEN_METADATA_KEY_PREFIX: &str = "token_metadata_";
46const PAYMENT_METADATA_KEY_PREFIX: &str = "payment_metadata";
47const PUBLISHED_PACKAGE_KEY_PREFIX: &str = "published_package_";
48const SPARK_PRIVATE_MODE_INITIALIZED_KEY: &str = "spark_private_mode_initialized";
49pub(crate) const STABLE_BALANCE_ACTIVE_LABEL_KEY: &str = "stable_balance_active_label";
50const PENDING_CONVERSIONS_KEY: &str = "pending_conversions";
51
52/// Wrapper stored in the cache that carries context about whether the value
53/// was written as part of a recovery or a client-initiated change.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub(crate) struct CachedLightningAddress {
56    pub address: Option<LightningAddressInfo>,
57    pub recovered: bool,
58}
59
60/// Parses a cached lightning address value.
61pub(crate) fn parse_cached_lightning_address(
62    value: &str,
63) -> Result<CachedLightningAddress, StorageError> {
64    serde_json::from_str(value).map_err(|e| StorageError::Serialization(e.to_string()))
65}
66
67#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
68pub enum UpdateDepositPayload {
69    ClaimError {
70        error: DepositClaimError,
71    },
72    Refund {
73        refund_txid: String,
74        refund_tx: String,
75    },
76    InstantClaim {
77        status: InstantClaimStatus,
78    },
79}
80
81#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
82pub struct SetLnurlMetadataItem {
83    pub payment_hash: String,
84    pub sender_comment: Option<String>,
85    pub nostr_zap_request: Option<String>,
86    pub nostr_zap_receipt: Option<String>,
87}
88
89impl From<lnurl_models::ListMetadataMetadata> for SetLnurlMetadataItem {
90    fn from(value: lnurl_models::ListMetadataMetadata) -> Self {
91        SetLnurlMetadataItem {
92            payment_hash: value.payment_hash,
93            sender_comment: value.sender_comment,
94            nostr_zap_request: value.nostr_zap_request,
95            nostr_zap_receipt: value.nostr_zap_receipt,
96        }
97    }
98}
99
100/// Errors that can occur during storage operations
101#[derive(Debug, Error, Clone)]
102#[cfg_attr(feature = "uniffi", derive(uniffi::Error))]
103pub enum StorageError {
104    /// Connection-related errors (pool exhaustion, timeouts, connection refused).
105    /// These are often transient and may be retried.
106    #[error("Connection error: {0}")]
107    Connection(String),
108
109    #[error("Underlying implementation error: {0}")]
110    Implementation(String),
111
112    /// Database initialization error
113    #[error("Failed to initialize database: {0}")]
114    InitializationError(String),
115
116    #[error("Failed to serialize/deserialize data: {0}")]
117    Serialization(String),
118
119    #[error("Not found")]
120    NotFound,
121}
122
123impl From<serde_json::Error> for StorageError {
124    fn from(e: serde_json::Error) -> Self {
125        StorageError::Serialization(e.to_string())
126    }
127}
128
129impl From<std::num::TryFromIntError> for StorageError {
130    fn from(e: std::num::TryFromIntError) -> Self {
131        StorageError::Implementation(format!("integer overflow: {e}"))
132    }
133}
134
135/// Selects payments by conversion type + status for background tasks.
136#[derive(Debug, Clone)]
137#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
138pub enum ConversionFilter {
139    /// AMM conversions that need a refund (clawback).
140    AmmRefundNeeded,
141    /// Orchestra orders that have not yet reached a terminal state.
142    OrchestraPending,
143    /// Boltz reverse swaps that have not yet reached a terminal state. Lives on
144    /// the Lightning leg (the hold-invoice pay), so it is selected via the
145    /// [`StoragePaymentDetailsFilter::Lightning`] filter.
146    BoltzPending,
147}
148
149/// Storage-internal variant of [`PaymentDetailsFilter`].
150#[derive(Debug, Clone)]
151#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
152pub enum StoragePaymentDetailsFilter {
153    Spark {
154        htlc_status: Option<Vec<SparkHtlcStatus>>,
155        conversion_filter: Option<ConversionFilter>,
156    },
157    Token {
158        conversion_filter: Option<ConversionFilter>,
159        tx_hash: Option<String>,
160        tx_type: Option<TokenTransactionType>,
161    },
162    Lightning {
163        htlc_status: Option<Vec<SparkHtlcStatus>>,
164        conversion_filter: Option<ConversionFilter>,
165    },
166}
167
168impl From<PaymentDetailsFilter> for StoragePaymentDetailsFilter {
169    fn from(filter: PaymentDetailsFilter) -> Self {
170        match filter {
171            PaymentDetailsFilter::Spark {
172                htlc_status,
173                conversion_refund_needed,
174            } => StoragePaymentDetailsFilter::Spark {
175                htlc_status,
176                conversion_filter: conversion_refund_needed
177                    .and_then(|v| v.then_some(ConversionFilter::AmmRefundNeeded)),
178            },
179            PaymentDetailsFilter::Token {
180                conversion_refund_needed,
181                tx_hash,
182                tx_type,
183            } => StoragePaymentDetailsFilter::Token {
184                conversion_filter: conversion_refund_needed
185                    .and_then(|v| v.then_some(ConversionFilter::AmmRefundNeeded)),
186                tx_hash,
187                tx_type,
188            },
189            PaymentDetailsFilter::Lightning { htlc_status } => {
190                StoragePaymentDetailsFilter::Lightning {
191                    htlc_status,
192                    conversion_filter: None,
193                }
194            }
195        }
196    }
197}
198
199impl From<StoragePaymentDetailsFilter> for PaymentDetailsFilter {
200    fn from(filter: StoragePaymentDetailsFilter) -> Self {
201        match filter {
202            StoragePaymentDetailsFilter::Spark {
203                htlc_status,
204                conversion_filter,
205            } => PaymentDetailsFilter::Spark {
206                htlc_status,
207                conversion_refund_needed: conversion_filter
208                    .map(|f| matches!(f, ConversionFilter::AmmRefundNeeded)),
209            },
210            StoragePaymentDetailsFilter::Token {
211                conversion_filter,
212                tx_hash,
213                tx_type,
214            } => PaymentDetailsFilter::Token {
215                conversion_refund_needed: conversion_filter
216                    .map(|f| matches!(f, ConversionFilter::AmmRefundNeeded)),
217                tx_hash,
218                tx_type,
219            },
220            StoragePaymentDetailsFilter::Lightning { htlc_status, .. } => {
221                PaymentDetailsFilter::Lightning { htlc_status }
222            }
223        }
224    }
225}
226
227/// Storage-internal variant of [`ListPaymentsRequest`] that uses
228/// [`StoragePaymentDetailsFilter`] instead of the public [`PaymentDetailsFilter`].
229#[derive(Debug, Clone, Default)]
230#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
231pub struct StorageListPaymentsRequest {
232    #[cfg_attr(feature = "uniffi", uniffi(default=None))]
233    pub type_filter: Option<Vec<PaymentType>>,
234    #[cfg_attr(feature = "uniffi", uniffi(default=None))]
235    pub status_filter: Option<Vec<PaymentStatus>>,
236    #[cfg_attr(feature = "uniffi", uniffi(default=None))]
237    pub asset_filter: Option<AssetFilter>,
238    #[cfg_attr(feature = "uniffi", uniffi(default=None))]
239    pub payment_details_filter: Option<Vec<StoragePaymentDetailsFilter>>,
240    #[cfg_attr(feature = "uniffi", uniffi(default=None))]
241    pub from_timestamp: Option<u64>,
242    #[cfg_attr(feature = "uniffi", uniffi(default=None))]
243    pub to_timestamp: Option<u64>,
244    #[cfg_attr(feature = "uniffi", uniffi(default=None))]
245    pub offset: Option<u32>,
246    #[cfg_attr(feature = "uniffi", uniffi(default=None))]
247    pub limit: Option<u32>,
248    #[cfg_attr(feature = "uniffi", uniffi(default=None))]
249    pub sort_ascending: Option<bool>,
250}
251
252impl From<ListPaymentsRequest> for StorageListPaymentsRequest {
253    fn from(request: ListPaymentsRequest) -> Self {
254        StorageListPaymentsRequest {
255            type_filter: request.type_filter,
256            status_filter: request.status_filter,
257            asset_filter: request.asset_filter,
258            payment_details_filter: request
259                .payment_details_filter
260                .map(|filters| filters.into_iter().map(Into::into).collect()),
261            from_timestamp: request.from_timestamp,
262            to_timestamp: request.to_timestamp,
263            offset: request.offset,
264            limit: request.limit,
265            sort_ascending: request.sort_ascending,
266        }
267    }
268}
269
270impl From<StorageListPaymentsRequest> for ListPaymentsRequest {
271    fn from(request: StorageListPaymentsRequest) -> Self {
272        ListPaymentsRequest {
273            type_filter: request.type_filter,
274            status_filter: request.status_filter,
275            asset_filter: request.asset_filter,
276            payment_details_filter: request
277                .payment_details_filter
278                .map(|filters| filters.into_iter().map(Into::into).collect()),
279            from_timestamp: request.from_timestamp,
280            to_timestamp: request.to_timestamp,
281            offset: request.offset,
282            limit: request.limit,
283            sort_ascending: request.sort_ascending,
284        }
285    }
286}
287
288/// Metadata associated with a payment that cannot be extracted from the Spark operator.
289#[derive(Clone, Default, Deserialize, Serialize)]
290#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
291pub struct PaymentMetadata {
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub parent_payment_id: Option<String>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub lnurl_pay_info: Option<LnurlPayInfo>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub lnurl_withdraw_info: Option<LnurlWithdrawInfo>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub lnurl_description: Option<String>,
300    /// Conversion info for this payment. Defaults `"type"` to `"amm"` when the
301    /// tag is missing (pre-migration sync records).
302    #[serde(
303        skip_serializing_if = "Option::is_none",
304        deserialize_with = "deserialize_conversion_info_with_default_type",
305        default
306    )]
307    pub conversion_info: Option<ConversionInfo>,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub conversion_status: Option<ConversionStatus>,
310}
311
312#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
313pub(crate) fn parse_payment_status(value: &str) -> Result<PaymentStatus, StorageError> {
314    value
315        .parse()
316        .map_err(|e| StorageError::Implementation(format!("invalid payment status: {e}")))
317}
318
319/// Deserializes `ConversionInfo` leniently — ensures the `"type"` tag exists
320/// (defaulting to `"amm"` for pre-migration sync records), then deserializes.
321/// The `entry().or_insert_with()` is a no-op hash lookup when the tag already
322/// exists, so the happy path has minimal overhead beyond the `Value` intermediary.
323fn deserialize_conversion_info_with_default_type<'de, D>(
324    deserializer: D,
325) -> Result<Option<ConversionInfo>, D::Error>
326where
327    D: serde::Deserializer<'de>,
328{
329    use serde::de::Deserialize;
330
331    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
332    match value {
333        None => Ok(None),
334        Some(mut v) => {
335            if let Some(obj) = v.as_object_mut() {
336                obj.entry("type")
337                    .or_insert_with(|| serde_json::Value::String("amm".to_string()));
338            }
339            match serde_json::from_value::<ConversionInfo>(v) {
340                Ok(info) => Ok(Some(info)),
341                Err(_) => Ok(None),
342            }
343        }
344    }
345}
346
347/// A cross-chain swap row as persisted and synced. Shared across providers
348/// (Boltz, Orchestra, future) so each provider's adapter writes opaque
349/// JSON into `data` and (optionally) opaque ciphertext into `secrets`.
350///
351/// For providers with money-critical secrets, the adapter lifts them out of
352/// the swap JSON, ECIES-encrypts them, and carries only the ciphertext in
353/// `secrets`. The storage layer treats both fields as opaque, so it needs
354/// no signer.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
357#[serde(rename_all = "camelCase")]
358pub struct StoredCrossChainSwap {
359    /// Provider tag (e.g. `"boltz"`, `"orchestra"`).
360    pub provider: String,
361    /// Provider-scoped swap id (boltz swap id, orchestra quote-or-order id).
362    pub id: String,
363    /// Lifted from the underlying swap's terminal flag into an indexed column
364    /// so `list_active_cross_chain_swaps` filters without parsing `data`.
365    pub is_terminal: bool,
366    /// Lifted from the underlying swap's `updated_at` into a column so the
367    /// row's freshness is inspectable without parsing `data`.
368    pub updated_at: u64,
369    /// Serialized JSON owned by the cross-chain provider's storage adapter.
370    pub data: String,
371    /// Base64 of the ECIES ciphertext of the provider's lifted secrets.
372    /// Empty for providers with no money-critical secrets to protect at rest.
373    pub secrets: String,
374}
375
376/// Trait for persistent storage
377#[cfg_attr(feature = "uniffi", uniffi::export(with_foreign))]
378#[async_trait]
379pub trait Storage: Send + Sync {
380    async fn delete_cached_item(&self, key: String) -> Result<(), StorageError>;
381    async fn get_cached_item(&self, key: String) -> Result<Option<String>, StorageError>;
382    async fn set_cached_item(&self, key: String, value: String) -> Result<(), StorageError>;
383    /// Lists payments with optional filters and pagination
384    ///
385    /// # Arguments
386    ///
387    /// * `list_payments_request` - The request to list payments
388    ///
389    /// # Returns
390    ///
391    /// A vector of payments or a `StorageError`
392    async fn list_payments(
393        &self,
394        request: StorageListPaymentsRequest,
395    ) -> Result<Vec<Payment>, StorageError>;
396
397    /// Inserts or updates a payment unless it would replace a terminal status.
398    ///
399    /// Same-status updates are still persisted so details can be enriched.
400    ///
401    /// Returns `true` if the caller should emit a payment event (new payment was
402    /// inserted, or status transitioned). Returns `false` for redundant
403    /// same-status updates and for rejected updates against a terminal stored
404    /// status
405    async fn apply_payment_update(&self, payment: Payment) -> Result<bool, StorageError>;
406
407    /// Inserts payment metadata into storage
408    ///
409    /// # Arguments
410    ///
411    /// * `payment_id` - The ID of the payment
412    /// * `metadata` - The metadata to insert
413    ///
414    /// # Returns
415    ///
416    /// Success or a `StorageError`
417    async fn insert_payment_metadata(
418        &self,
419        payment_id: String,
420        metadata: PaymentMetadata,
421    ) -> Result<(), StorageError>;
422
423    /// Gets a payment by its ID
424    /// # Arguments
425    ///
426    /// * `id` - The ID of the payment to retrieve
427    ///
428    /// # Returns
429    ///
430    /// The payment if found or None if not found
431    async fn get_payment_by_id(&self, id: String) -> Result<Payment, StorageError>;
432
433    /// Gets a payment by its invoice
434    /// # Arguments
435    ///
436    /// * `invoice` - The invoice of the payment to retrieve
437    /// # Returns
438    ///
439    /// The payment if found or None if not found
440    async fn get_payment_by_invoice(
441        &self,
442        invoice: String,
443    ) -> Result<Option<Payment>, StorageError>;
444
445    /// Gets payments that have any of the specified parent payment IDs.
446    /// Used to load related payments for a set of parent payments.
447    ///
448    /// # Arguments
449    ///
450    /// * `parent_payment_ids` - The IDs of the parent payments
451    ///
452    /// # Returns
453    ///
454    /// A map of `parent_payment_id` -> Vec<Payment> or a `StorageError`
455    async fn get_payments_by_parent_ids(
456        &self,
457        parent_payment_ids: Vec<String>,
458    ) -> Result<HashMap<String, Vec<Payment>>, StorageError>;
459
460    /// Add a deposit to storage (upsert: updates `is_mature` and `amount_sats` on conflict)
461    /// # Arguments
462    ///
463    /// * `txid` - The transaction ID of the deposit
464    /// * `vout` - The output index of the deposit
465    /// * `amount_sats` - The amount of the deposit in sats
466    /// * `is_mature` - Whether the deposit UTXO has enough confirmations to be claimable
467    ///
468    /// # Returns
469    ///
470    /// Success or a `StorageError`
471    async fn add_deposit(
472        &self,
473        txid: String,
474        vout: u32,
475        amount_sats: u64,
476        is_mature: bool,
477    ) -> Result<(), StorageError>;
478
479    /// Removes an unclaimed deposit from storage
480    /// # Arguments
481    ///
482    /// * `txid` - The transaction ID of the deposit
483    /// * `vout` - The output index of the deposit
484    ///
485    /// # Returns
486    ///
487    /// Success or a `StorageError`
488    async fn delete_deposit(&self, txid: String, vout: u32) -> Result<(), StorageError>;
489
490    /// Lists all unclaimed deposits from storage
491    /// # Returns
492    ///
493    /// A vector of `DepositInfo` or a `StorageError`
494    async fn list_deposits(&self) -> Result<Vec<DepositInfo>, StorageError>;
495
496    /// Updates or inserts unclaimed deposit details
497    /// # Arguments
498    ///
499    /// * `txid` - The transaction ID of the deposit
500    /// * `vout` - The output index of the deposit
501    /// * `payload` - The payload for the update
502    ///
503    /// # Returns
504    ///
505    /// Success or a `StorageError`
506    async fn update_deposit(
507        &self,
508        txid: String,
509        vout: u32,
510        payload: UpdateDepositPayload,
511    ) -> Result<(), StorageError>;
512
513    async fn set_lnurl_metadata(
514        &self,
515        metadata: Vec<SetLnurlMetadataItem>,
516    ) -> Result<(), StorageError>;
517
518    /// Lists contacts from storage with optional pagination
519    async fn list_contacts(
520        &self,
521        request: ListContactsRequest,
522    ) -> Result<Vec<Contact>, StorageError>;
523
524    /// Gets a single contact by its ID
525    async fn get_contact(&self, id: String) -> Result<Contact, StorageError>;
526
527    /// Inserts or updates a contact in storage (upsert by id).
528    /// Preserves `created_at` on update.
529    async fn insert_contact(&self, contact: Contact) -> Result<(), StorageError>;
530
531    /// Deletes a contact by its ID
532    async fn delete_contact(&self, id: String) -> Result<(), StorageError>;
533
534    /// Inserts or overwrites a cross-chain swap row (upsert by `(provider, id)`).
535    async fn set_cross_chain_swap(&self, swap: StoredCrossChainSwap) -> Result<(), StorageError>;
536
537    /// Gets a single cross-chain swap row by its `(provider, id)`, or `None` if absent.
538    async fn get_cross_chain_swap(
539        &self,
540        provider: String,
541        id: String,
542    ) -> Result<Option<StoredCrossChainSwap>, StorageError>;
543
544    /// Lists all non-terminal cross-chain swap rows for a single provider
545    /// (`provider = ? AND is_terminal = false`).
546    async fn list_active_cross_chain_swaps(
547        &self,
548        provider: String,
549    ) -> Result<Vec<StoredCrossChainSwap>, StorageError>;
550
551    // Sync storage methods
552    async fn add_outgoing_change(
553        &self,
554        record: UnversionedRecordChange,
555    ) -> Result<u64, StorageError>;
556    async fn complete_outgoing_sync(
557        &self,
558        record: Record,
559        local_revision: u64,
560    ) -> Result<(), StorageError>;
561    async fn get_pending_outgoing_changes(
562        &self,
563        limit: u32,
564    ) -> Result<Vec<OutgoingChange>, StorageError>;
565
566    /// Get the last committed sync revision.
567    ///
568    /// The `sync_revision` table tracks the highest revision that has been committed
569    /// (i.e. acknowledged by the server or received from it). It does NOT include
570    /// pending outgoing queue ids. This value is used by the sync protocol to
571    /// request changes from the server.
572    async fn get_last_revision(&self) -> Result<u64, StorageError>;
573
574    /// Insert incoming records from remote sync
575    async fn insert_incoming_records(&self, records: Vec<Record>) -> Result<(), StorageError>;
576
577    /// Delete an incoming record after it has been processed
578    async fn delete_incoming_record(&self, record: Record) -> Result<(), StorageError>;
579
580    /// Get incoming records that need to be processed, up to the specified limit
581    async fn get_incoming_records(&self, limit: u32) -> Result<Vec<IncomingChange>, StorageError>;
582
583    /// Get the latest outgoing record if any exists
584    async fn get_latest_outgoing_change(&self) -> Result<Option<OutgoingChange>, StorageError>;
585
586    /// Update the sync state record from an incoming record
587    async fn update_record_from_incoming(&self, record: Record) -> Result<(), StorageError>;
588}
589
590pub(crate) struct ObjectCacheRepository {
591    storage: Arc<dyn Storage>,
592}
593
594impl ObjectCacheRepository {
595    pub(crate) fn new(storage: Arc<dyn Storage>) -> Self {
596        ObjectCacheRepository { storage }
597    }
598
599    pub(crate) async fn save_account_info(
600        &self,
601        value: &CachedAccountInfo,
602    ) -> Result<(), StorageError> {
603        self.storage
604            .set_cached_item(ACCOUNT_INFO_KEY.to_string(), serde_json::to_string(value)?)
605            .await?;
606        Ok(())
607    }
608
609    pub(crate) async fn fetch_account_info(
610        &self,
611    ) -> Result<Option<CachedAccountInfo>, StorageError> {
612        let value = self
613            .storage
614            .get_cached_item(ACCOUNT_INFO_KEY.to_string())
615            .await?;
616        match value {
617            Some(value) => Ok(Some(serde_json::from_str(&value)?)),
618            None => Ok(None),
619        }
620    }
621
622    pub(crate) async fn save_sync_info(&self, value: &CachedSyncInfo) -> Result<(), StorageError> {
623        self.storage
624            .set_cached_item(SYNC_OFFSET_KEY.to_string(), serde_json::to_string(value)?)
625            .await?;
626        Ok(())
627    }
628
629    pub(crate) async fn fetch_sync_info(&self) -> Result<Option<CachedSyncInfo>, StorageError> {
630        let value = self
631            .storage
632            .get_cached_item(SYNC_OFFSET_KEY.to_string())
633            .await?;
634        match value {
635            Some(value) => Ok(Some(serde_json::from_str(&value)?)),
636            None => Ok(None),
637        }
638    }
639
640    /// Records a successfully published signed package under its package id
641    /// (swap transfer id or token partial-transaction digest), mapping to the
642    /// resulting payment id ("swap" for swap packages), so a replayed publish
643    /// can answer without re-submitting.
644    pub(crate) async fn save_published_package(
645        &self,
646        package_id: &str,
647        payment_id: &str,
648    ) -> Result<(), StorageError> {
649        self.storage
650            .set_cached_item(
651                format!("{PUBLISHED_PACKAGE_KEY_PREFIX}{package_id}"),
652                payment_id.to_string(),
653            )
654            .await?;
655        Ok(())
656    }
657
658    pub(crate) async fn fetch_published_package(
659        &self,
660        package_id: &str,
661    ) -> Result<Option<String>, StorageError> {
662        self.storage
663            .get_cached_item(format!("{PUBLISHED_PACKAGE_KEY_PREFIX}{package_id}"))
664            .await
665    }
666
667    pub(crate) async fn save_tx(&self, txid: &str, value: &CachedTx) -> Result<(), StorageError> {
668        self.storage
669            .set_cached_item(
670                format!("{TX_CACHE_KEY}-{txid}"),
671                serde_json::to_string(value)?,
672            )
673            .await?;
674        Ok(())
675    }
676
677    pub(crate) async fn fetch_tx(&self, txid: &str) -> Result<Option<CachedTx>, StorageError> {
678        let value = self
679            .storage
680            .get_cached_item(format!("{TX_CACHE_KEY}-{txid}"))
681            .await?;
682        match value {
683            Some(value) => Ok(Some(serde_json::from_str(&value)?)),
684            None => Ok(None),
685        }
686    }
687
688    pub(crate) async fn save_lightning_address(
689        &self,
690        value: &LightningAddressInfo,
691        recovered: bool,
692    ) -> Result<(), StorageError> {
693        let cached = CachedLightningAddress {
694            address: Some(value.clone()),
695            recovered,
696        };
697        self.storage
698            .set_cached_item(
699                LIGHTNING_ADDRESS_KEY.to_string(),
700                serde_json::to_string(&cached)?,
701            )
702            .await?;
703        Ok(())
704    }
705
706    /// Marks the lightning address as "no address registered" by storing `None`.
707    pub(crate) async fn delete_lightning_address(
708        &self,
709        recovered: bool,
710    ) -> Result<(), StorageError> {
711        let cached = CachedLightningAddress {
712            address: None,
713            recovered,
714        };
715        self.storage
716            .set_cached_item(
717                LIGHTNING_ADDRESS_KEY.to_string(),
718                serde_json::to_string(&cached)?,
719            )
720            .await?;
721        Ok(())
722    }
723
724    /// Returns:
725    /// - `Ok(None)` — key absent, never recovered
726    /// - `Ok(Some(None))` — recovered, no address registered
727    /// - `Ok(Some(Some(info)))` — recovered, has address
728    pub(crate) async fn fetch_lightning_address(
729        &self,
730    ) -> Result<Option<Option<LightningAddressInfo>>, StorageError> {
731        let value = self
732            .storage
733            .get_cached_item(LIGHTNING_ADDRESS_KEY.to_string())
734            .await?;
735        match value {
736            Some(value) => {
737                let cached = parse_cached_lightning_address(&value)?;
738                Ok(Some(cached.address))
739            }
740            None => Ok(None),
741        }
742    }
743
744    pub(crate) async fn save_token_metadata(
745        &self,
746        value: &TokenMetadata,
747    ) -> Result<(), StorageError> {
748        self.storage
749            .set_cached_item(
750                format!("{TOKEN_METADATA_KEY_PREFIX}{}", value.identifier),
751                serde_json::to_string(value)?,
752            )
753            .await?;
754        Ok(())
755    }
756
757    pub(crate) async fn fetch_token_metadata(
758        &self,
759        identifier: &str,
760    ) -> Result<Option<TokenMetadata>, StorageError> {
761        let value = self
762            .storage
763            .get_cached_item(format!("{TOKEN_METADATA_KEY_PREFIX}{identifier}"))
764            .await?;
765        match value {
766            Some(value) => Ok(Some(serde_json::from_str(&value)?)),
767            None => Ok(None),
768        }
769    }
770
771    pub(crate) async fn save_payment_metadata(
772        &self,
773        identifier: &str,
774        value: &PaymentMetadata,
775    ) -> Result<(), StorageError> {
776        self.storage
777            .set_cached_item(
778                format!("{PAYMENT_METADATA_KEY_PREFIX}-{identifier}"),
779                serde_json::to_string(value)?,
780            )
781            .await?;
782        Ok(())
783    }
784
785    pub(crate) async fn fetch_payment_metadata(
786        &self,
787        identifier: &str,
788    ) -> Result<Option<PaymentMetadata>, StorageError> {
789        let value = self
790            .storage
791            .get_cached_item(format!("{PAYMENT_METADATA_KEY_PREFIX}-{identifier}"))
792            .await?;
793        match value {
794            Some(value) => Ok(Some(serde_json::from_str(&value)?)),
795            None => Ok(None),
796        }
797    }
798
799    pub(crate) async fn delete_payment_metadata(
800        &self,
801        identifier: &str,
802    ) -> Result<(), StorageError> {
803        self.storage
804            .delete_cached_item(format!("{PAYMENT_METADATA_KEY_PREFIX}-{identifier}"))
805            .await?;
806        Ok(())
807    }
808
809    pub(crate) async fn save_spark_private_mode_initialized(&self) -> Result<(), StorageError> {
810        self.storage
811            .set_cached_item(
812                SPARK_PRIVATE_MODE_INITIALIZED_KEY.to_string(),
813                "true".to_string(),
814            )
815            .await?;
816        Ok(())
817    }
818
819    pub(crate) async fn fetch_spark_private_mode_initialized(&self) -> Result<bool, StorageError> {
820        let value = self
821            .storage
822            .get_cached_item(SPARK_PRIVATE_MODE_INITIALIZED_KEY.to_string())
823            .await?;
824        match value {
825            Some(value) => Ok(value == "true"),
826            None => Ok(false),
827        }
828    }
829
830    pub(crate) async fn save_stable_balance_active_label(
831        &self,
832        label: &str,
833    ) -> Result<(), StorageError> {
834        self.storage
835            .set_cached_item(
836                STABLE_BALANCE_ACTIVE_LABEL_KEY.to_string(),
837                label.to_string(),
838            )
839            .await
840    }
841
842    pub(crate) async fn fetch_stable_balance_active_label(
843        &self,
844    ) -> Result<Option<String>, StorageError> {
845        self.storage
846            .get_cached_item(STABLE_BALANCE_ACTIVE_LABEL_KEY.to_string())
847            .await
848    }
849
850    pub(crate) async fn delete_stable_balance_active_label(&self) -> Result<(), StorageError> {
851        self.storage
852            .delete_cached_item(STABLE_BALANCE_ACTIVE_LABEL_KEY.to_string())
853            .await
854    }
855
856    pub(crate) async fn save_pending_conversions(
857        &self,
858        pending: &[super::stable_balance::PendingConversion],
859    ) -> Result<(), StorageError> {
860        self.storage
861            .set_cached_item(
862                PENDING_CONVERSIONS_KEY.to_string(),
863                serde_json::to_string(pending)?,
864            )
865            .await?;
866        Ok(())
867    }
868
869    pub(crate) async fn fetch_pending_conversions(
870        &self,
871    ) -> Result<Option<Vec<super::stable_balance::PendingConversion>>, StorageError> {
872        let value = self
873            .storage
874            .get_cached_item(PENDING_CONVERSIONS_KEY.to_string())
875            .await?;
876        match value {
877            Some(value) => Ok(Some(serde_json::from_str(&value)?)),
878            None => Ok(None),
879        }
880    }
881
882    pub(crate) async fn delete_pending_conversions(&self) -> Result<(), StorageError> {
883        self.storage
884            .delete_cached_item(PENDING_CONVERSIONS_KEY.to_string())
885            .await
886    }
887
888    pub(crate) async fn save_lnurl_metadata_updated_after(
889        &self,
890        offset: i64,
891    ) -> Result<(), StorageError> {
892        self.storage
893            .set_cached_item(
894                LNURL_METADATA_UPDATED_AFTER_KEY.to_string(),
895                offset.to_string(),
896            )
897            .await?;
898        Ok(())
899    }
900
901    pub(crate) async fn fetch_lnurl_metadata_updated_after(&self) -> Result<i64, StorageError> {
902        let value = self
903            .storage
904            .get_cached_item(LNURL_METADATA_UPDATED_AFTER_KEY.to_string())
905            .await?;
906        match value {
907            Some(value) => Ok(value.parse().map_err(|_| {
908                StorageError::Serialization("invalid lnurl_metadata_updated_after".to_string())
909            })?),
910            None => Ok(0),
911        }
912    }
913
914    pub(crate) async fn get_last_sync_time(&self) -> Result<Option<u64>, StorageError> {
915        let value = self
916            .storage
917            .get_cached_item(LAST_SYNC_TIME_KEY.to_string())
918            .await?;
919        match value {
920            Some(v) => Ok(Some(v.parse().map_err(|_| {
921                StorageError::Serialization("invalid last_sync_time".to_string())
922            })?)),
923            None => Ok(None),
924        }
925    }
926
927    pub(crate) async fn set_last_sync_time(&self, time: u64) -> Result<(), StorageError> {
928        self.storage
929            .set_cached_item(LAST_SYNC_TIME_KEY.to_string(), time.to_string())
930            .await
931    }
932}
933
934#[derive(Serialize, Deserialize, Default)]
935pub(crate) struct CachedAccountInfo {
936    pub(crate) balance_sats: u64,
937    #[serde(default)]
938    pub(crate) token_balances: HashMap<String, TokenBalance>,
939}
940
941#[derive(Serialize, Deserialize, Default)]
942pub(crate) struct CachedSyncInfo {
943    pub(crate) offset: u64,
944    pub(crate) last_synced_final_token_payment_id: Option<String>,
945}
946
947#[derive(Serialize, Deserialize, Default)]
948pub(crate) struct CachedTx {
949    pub(crate) raw_tx: String,
950}
951
952#[cfg(feature = "test-utils")]
953pub mod tests;