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