1use std::collections::HashMap;
17use std::path::Path;
18use std::str::FromStr;
19use std::sync::{Arc, OnceLock};
20
21use async_trait::async_trait;
22use cdk::cdk_database::{Error as DbError, WalletDatabase};
23use cdk::mint_url::MintUrl;
24use cdk::nuts::{CurrencyUnit, Token};
25use cdk::wallet::{ReceiveOptions, Wallet};
26use cdk::Amount;
27use tokio::sync::Mutex;
28
29const MSAT_PER_SAT: u64 = 1000;
30
31static CASHU_DB: OnceLock<Arc<cdk_redb::wallet::WalletRedbDatabase>> = OnceLock::new();
34
35pub async fn initialize_cashu(db_path: &str) -> Result<(), String> {
36 match cdk_redb::wallet::WalletRedbDatabase::new(Path::new(db_path)) {
37 Ok(db) => {
38 tracing::debug!("Cashu database initialized at: {}", db_path);
39 let _ = CASHU_DB.set(Arc::new(db));
40 Ok(())
41 }
42 Err(e) => {
43 let error = format!("Failed to create Cashu database: {:?}", e);
44 tracing::error!("{}", error);
45 Err(error)
46 }
47 }
48}
49
50#[derive(Debug, thiserror::Error)]
55pub enum RedeemError {
56 #[error("token could not be parsed: {0}")]
57 InvalidToken(String),
58
59 #[error("token's mint URL `{mint_url}` is not in the provider's whitelist")]
60 NonWhitelistedMint { mint_url: String },
61
62 #[error("token has already been spent at the mint")]
63 AlreadySpent,
64
65 #[error("token is in pending state at the mint; retry later")]
66 Pending,
67
68 #[error("network error talking to mint: {0}")]
69 Network(String),
70
71 #[error("token unit `{0}` is not supported by this provider")]
72 UnsupportedUnit(String),
73
74 #[error("mint rejected redemption: {0}")]
75 MintError(String),
76}
77
78#[async_trait]
84pub trait MintRedeemer: Send + Sync {
85 async fn redeem(&self, token_str: &str) -> Result<u64, RedeemError>;
86}
87
88pub async fn validate_and_redeem<R: MintRedeemer + ?Sized>(
93 redeemer: &R,
94 whitelisted_mints: &[String],
95 token_str: &str,
96) -> Result<u64, RedeemError> {
97 let token = Token::from_str(token_str).map_err(|e| RedeemError::InvalidToken(e.to_string()))?;
98
99 let token_mint = token
100 .mint_url()
101 .map_err(|e| RedeemError::InvalidToken(format!("token has no mint URL: {}", e)))?;
102
103 let normalized_whitelist: Vec<MintUrl> = whitelisted_mints
104 .iter()
105 .filter_map(|s| MintUrl::from_str(s).ok())
106 .collect();
107
108 if !normalized_whitelist.iter().any(|m| m == &token_mint) {
109 return Err(RedeemError::NonWhitelistedMint {
110 mint_url: token_mint.to_string(),
111 });
112 }
113
114 redeemer.redeem(token_str).await
115}
116
117pub struct CdkRedeemer {
128 localstore: Arc<dyn WalletDatabase<Err = DbError> + Send + Sync>,
129 seed: [u8; 64],
130 wallets: Mutex<HashMap<(String, CurrencyUnit), Arc<Wallet>>>,
131}
132
133impl CdkRedeemer {
134 pub fn new(
135 localstore: Arc<dyn WalletDatabase<Err = DbError> + Send + Sync>,
136 seed: [u8; 64],
137 ) -> Self {
138 Self {
139 localstore,
140 seed,
141 wallets: Mutex::new(HashMap::new()),
142 }
143 }
144
145 async fn wallet_for(
146 &self,
147 mint_url: &MintUrl,
148 unit: CurrencyUnit,
149 ) -> Result<Arc<Wallet>, RedeemError> {
150 let key = (mint_url.to_string(), unit.clone());
151 let mut wallets = self.wallets.lock().await;
152 if let Some(w) = wallets.get(&key) {
153 return Ok(w.clone());
154 }
155 let wallet = Wallet::new(
156 &mint_url.to_string(),
157 unit,
158 self.localstore.clone(),
159 self.seed,
160 None,
161 )
162 .map_err(|e| RedeemError::MintError(format!("wallet construction failed: {}", e)))?;
163 let wallet = Arc::new(wallet);
164 wallets.insert(key, wallet.clone());
165 Ok(wallet)
166 }
167}
168
169#[async_trait]
170impl MintRedeemer for CdkRedeemer {
171 async fn redeem(&self, token_str: &str) -> Result<u64, RedeemError> {
172 let token =
173 Token::from_str(token_str).map_err(|e| RedeemError::InvalidToken(e.to_string()))?;
174 let mint_url = token
175 .mint_url()
176 .map_err(|e| RedeemError::InvalidToken(e.to_string()))?;
177 let unit = token.unit().unwrap_or(CurrencyUnit::Sat);
178
179 let wallet = self.wallet_for(&mint_url, unit.clone()).await?;
180 let amount = wallet
181 .receive(token_str, ReceiveOptions::default())
182 .await
183 .map_err(map_cdk_error)?;
184 let amount_u64: u64 = amount.into();
185
186 match unit {
187 CurrencyUnit::Sat => Ok(amount_u64
188 .checked_mul(MSAT_PER_SAT)
189 .ok_or_else(|| RedeemError::MintError("amount overflow".to_string()))?),
190 CurrencyUnit::Msat => Ok(amount_u64),
191 other => Err(RedeemError::UnsupportedUnit(format!("{:?}", other))),
192 }
193 }
194}
195
196fn map_cdk_error(e: cdk::Error) -> RedeemError {
197 use cdk::Error as E;
198 match e {
199 E::TokenAlreadySpent => RedeemError::AlreadySpent,
200 E::TokenPending => RedeemError::Pending,
201 E::IncorrectMint => RedeemError::MintError(
202 "wallet's bound mint URL does not match token's (should not happen for per-mint pool)"
203 .to_string(),
204 ),
205 E::UnsupportedUnit => RedeemError::UnsupportedUnit("rejected by mint".to_string()),
206 other => match other.to_string() {
210 s if s.contains("HTTP") || s.contains("network") || s.contains("connection") => {
211 RedeemError::Network(s)
212 }
213 s => RedeemError::MintError(s),
214 },
215 }
216}
217
218pub fn derive_seed_from_nostr_key(nostr_private_key: &str) -> [u8; 64] {
223 use cdk::secp256k1::hashes::{sha256, Hash};
224 let h1 =
225 sha256::Hash::hash(format!("paygress-cashu-wallet-v1:a:{}", nostr_private_key).as_bytes());
226 let h2 =
227 sha256::Hash::hash(format!("paygress-cashu-wallet-v1:b:{}", nostr_private_key).as_bytes());
228 let mut out = [0u8; 64];
229 out[..32].copy_from_slice(&h1.to_byte_array());
230 out[32..].copy_from_slice(&h2.to_byte_array());
231 out
232}
233
234pub async fn split_token_into_n(
254 token_str: &str,
255 n: usize,
256 db_path: &Path,
257) -> Result<Vec<String>, anyhow::Error> {
258 use cdk::wallet::SendOptions;
259 use cdk::Amount;
260 use rand::RngCore;
261
262 if n == 0 {
263 anyhow::bail!("cannot split into 0 shards");
264 }
265
266 let token =
267 Token::from_str(token_str).map_err(|e| anyhow::anyhow!("invalid input token: {}", e))?;
268 let mint_url = token
269 .mint_url()
270 .map_err(|e| anyhow::anyhow!("token has no mint URL: {}", e))?;
271 let unit = token.unit().unwrap_or(CurrencyUnit::Sat);
272
273 let face_value: u64 = token
277 .value()
278 .map_err(|e| anyhow::anyhow!("failed to compute token value: {}", e))?
279 .into();
280 if face_value == 0 {
281 anyhow::bail!("input token has zero face value");
282 }
283 if (face_value as usize) < n {
284 anyhow::bail!(
285 "input token face value ({} {:?}) cannot be split into {} shards (minimum 1 per shard)",
286 face_value,
287 unit,
288 n
289 );
290 }
291
292 let db = cdk_redb::wallet::WalletRedbDatabase::new(db_path).map_err(|e| {
293 anyhow::anyhow!(
294 "failed to open ephemeral wallet db at {}: {}",
295 db_path.display(),
296 e
297 )
298 })?;
299 let db: Arc<dyn WalletDatabase<Err = DbError> + Send + Sync> = Arc::new(db);
300
301 let mut seed = [0u8; 64];
304 rand::thread_rng().fill_bytes(&mut seed);
305
306 let wallet = Wallet::new(&mint_url.to_string(), unit, db, seed, None)
307 .map_err(|e| anyhow::anyhow!("wallet construction failed: {}", e))?;
308
309 let received = wallet
310 .receive(token_str, ReceiveOptions::default())
311 .await
312 .map_err(|e| anyhow::anyhow!("failed to receive input token: {}", e))?;
313 let received_value: u64 = received.into();
314 if (received_value as usize) < n {
315 anyhow::bail!(
316 "received amount ({}) less than shard count ({}); mint may have charged fees",
317 received_value,
318 n
319 );
320 }
321
322 let per_shard_floor = received_value / n as u64;
323 let final_shard = received_value - per_shard_floor * (n as u64 - 1);
324
325 let mut tokens: Vec<String> = Vec::with_capacity(n);
326 for i in 0..n {
327 let amount = if i + 1 == n {
328 final_shard
329 } else {
330 per_shard_floor
331 };
332 let prepared = wallet
333 .prepare_send(Amount::from(amount), SendOptions::default())
334 .await
335 .map_err(|e| anyhow::anyhow!("prepare_send shard {}/{}: {}", i + 1, n, e))?;
336 let token = prepared
337 .confirm(None)
338 .await
339 .map_err(|e| anyhow::anyhow!("confirm send shard {}/{}: {}", i + 1, n, e))?;
340 tokens.push(token.to_string());
341 }
342
343 Ok(tokens)
344}
345
346pub async fn extract_token_value(token_str: &str) -> anyhow::Result<u64> {
360 let token = Token::from_str(token_str)
361 .map_err(|e| anyhow::anyhow!("Failed to decode Cashu token: {}", e))?;
362
363 let amount: Amount = token
367 .value()
368 .map_err(|e| anyhow::anyhow!("Failed to compute token value: {}", e))?;
369 let total_amount: u64 = amount.into();
370 if total_amount == 0 {
371 return Err(anyhow::anyhow!("Token has no proofs"));
372 }
373
374 let total_amount_msats: u64 = match token.unit().unwrap_or(CurrencyUnit::Sat) {
375 CurrencyUnit::Sat => total_amount * MSAT_PER_SAT,
376 CurrencyUnit::Msat => total_amount,
377 unit => return Err(anyhow::anyhow!("Unsupported token unit: {:?}", unit)),
378 };
379
380 Ok(total_amount_msats)
381}