Skip to main content

cdk_common/wallet/saga/
mod.rs

1//! Wallet saga types for crash-tolerant recovery
2//!
3//! Sagas represent in-progress wallet operations that need to survive crashes.
4//! They use **optimistic locking** via the `version` field to handle concurrent
5//! access from multiple wallet instances safely.
6//!
7//! # Optimistic Locking
8//!
9//! When multiple wallet instances share the same database (e.g., mobile app
10//! backgrounded while desktop app runs), they might both try to recover the
11//! same incomplete saga. Optimistic locking prevents conflicts:
12//!
13//! 1. Each saga has a `version` number starting at 0
14//! 2. When updating, the database checks: `WHERE id = ? AND version = ?`
15//! 3. If the version matches, the update succeeds and `version` increments
16//! 4. If the version doesn't match, another instance modified it first
17//!
18//! This is preferable to pessimistic locking (mutexes) because:
19//! - Works across process boundaries (multiple wallet instances)
20//! - No deadlock risk
21//! - No lock expiration/cleanup needed
22//! - Conflicts are rare in practice (sagas are short-lived)
23//!
24//! Instance A reads saga with version=1
25//! Instance B reads saga with version=1
26//! Instance A updates successfully, version becomes 2
27//! Instance B's update fails (version mismatch) - it knows to skip
28
29use serde::{Deserialize, Serialize};
30
31use crate::mint_url::MintUrl;
32use crate::nuts::CurrencyUnit;
33use crate::wallet::OperationKind;
34use crate::Amount;
35
36mod issue;
37mod melt;
38mod receive;
39mod send;
40mod swap;
41
42pub use issue::{IssueSagaState, MintOperationData};
43pub use melt::{MeltOperationData, MeltSagaState};
44pub use receive::{ReceiveOperationData, ReceiveSagaState};
45pub use send::{SendOperationData, SendSagaState};
46pub use swap::{SwapOperationData, SwapSagaState};
47
48/// Wallet saga state for different operation types
49#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "type", content = "state", rename_all = "snake_case")]
51pub enum WalletSagaState {
52    /// Send saga states
53    Send(SendSagaState),
54    /// Receive saga states
55    Receive(ReceiveSagaState),
56    /// Swap saga states
57    Swap(SwapSagaState),
58    /// Mint (issue) saga states
59    Issue(IssueSagaState),
60    /// Melt saga states
61    Melt(MeltSagaState),
62}
63
64impl WalletSagaState {
65    /// Get the operation kind
66    pub fn kind(&self) -> OperationKind {
67        match self {
68            WalletSagaState::Send(_) => OperationKind::Send,
69            WalletSagaState::Receive(_) => OperationKind::Receive,
70            WalletSagaState::Swap(_) => OperationKind::Swap,
71            WalletSagaState::Issue(_) => OperationKind::Mint,
72            WalletSagaState::Melt(_) => OperationKind::Melt,
73        }
74    }
75
76    /// Get string representation of the inner state
77    pub fn state_str(&self) -> &'static str {
78        match self {
79            WalletSagaState::Send(s) => match s {
80                SendSagaState::ProofsReserved => "proofs_reserved",
81                SendSagaState::TokenCreated => "token_created",
82                SendSagaState::RollingBack => "rolling_back",
83            },
84            WalletSagaState::Receive(s) => match s {
85                ReceiveSagaState::ProofsPending => "proofs_pending",
86                ReceiveSagaState::SwapRequested => "swap_requested",
87            },
88            WalletSagaState::Swap(s) => match s {
89                SwapSagaState::ProofsReserved => "proofs_reserved",
90                SwapSagaState::SwapRequested => "swap_requested",
91            },
92            WalletSagaState::Issue(s) => match s {
93                IssueSagaState::SecretsPrepared => "secrets_prepared",
94                IssueSagaState::MintRequested => "mint_requested",
95            },
96            WalletSagaState::Melt(s) => match s {
97                MeltSagaState::ProofsReserved => "proofs_reserved",
98                MeltSagaState::MeltRequested => "melt_requested",
99                MeltSagaState::PaymentPending => "payment_pending",
100            },
101        }
102    }
103}
104
105/// Operation data enum
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
108pub enum OperationData {
109    /// Send operation data
110    Send(SendOperationData),
111    /// Receive operation data
112    Receive(ReceiveOperationData),
113    /// Swap operation data
114    Swap(SwapOperationData),
115    /// Mint operation data
116    Mint(MintOperationData),
117    /// Melt operation data
118    Melt(MeltOperationData),
119}
120
121impl OperationData {
122    /// Get the operation kind
123    pub fn kind(&self) -> OperationKind {
124        match self {
125            OperationData::Send(_) => OperationKind::Send,
126            OperationData::Receive(_) => OperationKind::Receive,
127            OperationData::Swap(_) => OperationKind::Swap,
128            OperationData::Mint(_) => OperationKind::Mint,
129            OperationData::Melt(_) => OperationKind::Melt,
130        }
131    }
132}
133
134/// Wallet saga for crash-tolerant recovery.
135///
136/// Sagas represent in-progress wallet operations that need to survive crashes.
137/// They use **optimistic locking** via the `version` field to handle concurrent
138/// access from multiple wallet instances safely.
139///
140/// # Optimistic Locking
141///
142/// When multiple wallet instances share the same database (e.g., mobile app
143/// backgrounded while desktop app runs), they might both try to recover the
144/// same incomplete saga. Optimistic locking prevents conflicts:
145///
146/// 1. Each saga has a `version` number starting at 0
147/// 2. When updating, the database checks: `WHERE id = ? AND version = ?`
148/// 3. If the version matches, the update succeeds and `version` increments
149/// 4. If the version doesn't match, another instance modified it first
150///
151/// This is preferable to pessimistic locking (mutexes) because:
152/// - Works across process boundaries (multiple wallet instances)
153/// - No deadlock risk
154/// - No lock expiration/cleanup needed
155/// - Conflicts are rare in practice (sagas are short-lived)
156///
157/// Instance A reads saga with version=1
158/// Instance B reads saga with version=1
159/// Instance A updates successfully, version becomes 2
160/// Instance B's update fails (version mismatch) - it knows to skip
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct WalletSaga {
163    /// Unique operation ID
164    pub id: uuid::Uuid,
165    /// Operation kind (derived from state)
166    pub kind: OperationKind,
167    /// Saga state (operation-specific)
168    pub state: WalletSagaState,
169    /// Amount involved in the operation
170    pub amount: Amount,
171    /// Mint URL
172    pub mint_url: MintUrl,
173    /// Currency unit
174    pub unit: CurrencyUnit,
175    /// Quote ID (for mint/melt operations)
176    pub quote_id: Option<String>,
177    /// Creation timestamp (unix seconds)
178    pub created_at: u64,
179    /// Last update timestamp (unix seconds)
180    pub updated_at: u64,
181    /// Operation-specific data
182    pub data: OperationData,
183    /// Version number for optimistic locking.
184    ///
185    /// Incremented on each update. Used to detect concurrent modifications:
186    /// - If update succeeds: this instance "won" the race
187    /// - If update fails (version mismatch): another instance modified it
188    ///
189    /// Recovery code should treat version conflicts as "someone else handled it"
190    /// and skip to the next saga rather than retrying.
191    pub version: u32,
192}
193
194impl WalletSaga {
195    /// Create a new wallet saga.
196    ///
197    /// The saga is created with `version = 0`. Each successful update
198    /// will increment the version for optimistic locking.
199    pub fn new(
200        id: uuid::Uuid,
201        state: WalletSagaState,
202        amount: Amount,
203        mint_url: MintUrl,
204        unit: CurrencyUnit,
205        data: OperationData,
206    ) -> Self {
207        let now = std::time::SystemTime::now()
208            .duration_since(std::time::UNIX_EPOCH)
209            .unwrap_or_default()
210            .as_secs();
211
212        let quote_id = match &data {
213            OperationData::Mint(d) => Some(d.quote_id.clone()),
214            OperationData::Melt(d) => Some(d.quote_id.clone()),
215            _ => None,
216        };
217
218        Self {
219            id,
220            kind: state.kind(),
221            state,
222            amount,
223            mint_url,
224            unit,
225            quote_id,
226            created_at: now,
227            updated_at: now,
228            data,
229            version: 0,
230        }
231    }
232
233    /// Update the saga state and increment the version.
234    ///
235    /// This prepares the saga for an optimistic locking update.
236    /// The database layer will verify the previous version matches
237    /// before applying the update.
238    pub fn update_state(&mut self, state: WalletSagaState) {
239        self.state = state;
240        self.kind = state.kind();
241        self.updated_at = std::time::SystemTime::now()
242            .duration_since(std::time::UNIX_EPOCH)
243            .unwrap_or_default()
244            .as_secs();
245        self.version += 1;
246    }
247}