Skip to main content

paygress/
cashu.rs

1// Cashu Token Utilities
2//
3// Provides:
4// - `validate_and_redeem` / `MintRedeemer` / `CdkRedeemer`: the canonical
5//   redemption path used by the Nostr-DM provider (`src/provider.rs`).
6//   This actually swaps proofs at the mint via NUT-03, defeating
7//   single- and cross-provider replay.
8// - `extract_token_value`: legacy face-value parser. Still used by the
9//   K8s + ngx_l402 + HTTP path (sidecar_service / pod_provisioning /
10//   interfaces::http_l402). Those callers rely on ngx_l402 to perform
11//   redemption at the nginx layer. Unit 7 of the 12-month plan
12//   feature-gates that whole path behind the `kubernetes` Cargo
13//   feature; once gated out of the default build, this function can be
14//   removed.
15
16use 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
31// Legacy database singleton kept so `initialize_cashu` continues to work
32// for callers that haven't migrated to `CdkRedeemer` yet.
33static 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/// Errors from the Nostr-DM redemption path. Preserved as a structured
51/// enum (rather than `anyhow::Error`) so callers can map specific cdk
52/// failure modes onto specific Nostr error responses without string
53/// matching.
54#[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/// The redemption surface that `validate_and_redeem` calls into.
79///
80/// Implementors are responsible for swapping the encoded token at the
81/// mint and returning the redeemed amount in **msats**. They do NOT
82/// re-check the whitelist; that happens in `validate_and_redeem`.
83#[async_trait]
84pub trait MintRedeemer: Send + Sync {
85    async fn redeem(&self, token_str: &str) -> Result<u64, RedeemError>;
86}
87
88/// Parse and validate the token, enforce the per-provider whitelist,
89/// then delegate to the redeemer. The whitelist check happens **before**
90/// any mint contact so a malicious token pointed at an attacker-
91/// controlled mint never causes a network call from the provider.
92pub 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
117/// Production redeemer backed by `cdk::wallet::Wallet`.
118///
119/// Maintains one wallet per `(mint_url, unit)` pair, lazily created on
120/// first use. All wallets share a single `WalletDatabase` (a redb file)
121/// so proofs, keysets, and quotes for every mint live in one place.
122///
123/// The `seed` is used by cdk for deterministic blinding-factor
124/// derivation. See `derive_seed_from_nostr_key` for the production
125/// derivation; tests can construct `CdkRedeemer` directly with any
126/// 32-byte seed.
127pub 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        // cdk doesn't surface a distinct Network variant; treat
207        // serialization/HTTP errors uniformly as Network so callers can
208        // signal "retry later" to the consumer.
209        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
218/// Derive a 64-byte wallet seed from the provider's Nostr private key.
219/// cdk's `Wallet::new` requires `[u8; 64]` (BIP-39-style seed length).
220/// We hash twice with distinct domain separators so the two halves
221/// are independent.
222pub 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
234/// Split one Cashu token into N tokens of approximately equal face
235/// value. Used by `paygress batch --split-token ... --shards N` so
236/// users don't have to hand-mint N tokens before fanning out.
237///
238/// Flow: open an ephemeral wallet at `db_path`, swap the input token
239/// in (mint round-trip), then prepare+send N tokens whose face
240/// values sum to the received amount. The first `N-1` shards each
241/// get `received / N` (integer floor); the final shard absorbs any
242/// remainder so the totals reconcile exactly.
243///
244/// Caveats:
245///   - This is `cdk::wallet` 0.9. Modern mints with v2 (66-char)
246///     keyset IDs (e.g. mint.minibits.cash) may fail at receive due
247///     to the same parsing issue we hit on the redeemer side. Tested
248///     today against `testnut.cashu.space`. cdk 0.14 upgrade is
249///     tracked separately.
250///   - The wallet's localstore at `db_path` is left in place after
251///     the split; callers wanting truly ephemeral semantics should
252///     remove it. The batch coordinator does.
253pub 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    // Face-value pre-check: bail before touching the mint if N is
274    // mathematically infeasible. Keeps the error fast and the token
275    // unspent on bad input.
276    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    // Random seed — the wallet is ephemeral, so deterministic
302    // derivation buys us nothing. cdk's Wallet::new requires [u8; 64].
303    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
346/// **Legacy face-value parser.** Returns the sum of `proof.amount` from
347/// a decoded token in msats, **without contacting the mint**. This is
348/// vulnerable to single- and cross-provider replay.
349///
350/// Used today by the K8s + ngx_l402 + HTTP path
351/// (`src/sidecar_service.rs`, `src/pod_provisioning.rs`,
352/// `src/interfaces/http_l402.rs`), where ngx_l402 performs Cashu
353/// redemption at the nginx layer before forwarding the request. The
354/// Nostr-DM path no longer calls this — it uses
355/// `validate_and_redeem` instead.
356///
357/// Will be removed once Unit 7 feature-gates the K8s pipeline behind
358/// the `kubernetes` Cargo feature.
359pub 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    // cdk 0.14 made `Token::proofs(&keysets)` require keyset metadata,
364    // but `Token::value()` still works without — it's just the sum of
365    // proof amounts. That's exactly what this legacy function does.
366    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}