Skip to main content

breez_sdk_spark/persist/
mod.rs

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