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