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}