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