Skip to main content

iris_chat_core/payments/
cashu.rs

1use base64::{
2    engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD},
3    Engine,
4};
5use serde::Deserialize;
6use url::Url;
7
8#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
9pub struct CashuMintAmount {
10    pub url: String,
11    pub name: String,
12    pub amount: u64,
13}
14
15/// Unverified, sender-provided metadata. This does not establish that the mint
16/// issued the proofs, that spending conditions are met, or that funds remain.
17#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
18pub struct CashuTokenMetadata {
19    pub amount: u64,
20    pub unit: String,
21    pub memo: Option<String>,
22    pub mints: Vec<CashuMintAmount>,
23    pub proof_count: u32,
24}
25
26#[uniffi::export]
27pub fn parse_cashu_token(text: String) -> Option<CashuTokenMetadata> {
28    if text.len() > super::MAX_TEXT_BYTES {
29        return None;
30    }
31    let raw = super::strip_scheme(text.trim(), "cashu:");
32    let payload = raw.get(6..)?;
33    let decoded = URL_SAFE_NO_PAD
34        .decode(payload)
35        .or_else(|_| URL_SAFE.decode(payload))
36        .ok()?;
37    let mut result = match raw.get(..6)? {
38        "cashuA" => parse_v3(&decoded)?,
39        "cashuB" => parse_v4(&decoded)?,
40        _ => return None,
41    };
42    if result.unit.is_empty()
43        || result.unit.len() > 12
44        || !result.unit.bytes().all(|b| b.is_ascii_alphabetic())
45        || result.memo.as_ref().is_some_and(|m| m.len() > 1024)
46        || result.proof_count == 0
47        || result.proof_count > 256
48    {
49        return None;
50    }
51    for mint in &result.mints {
52        result.amount = result.amount.checked_add(mint.amount)?;
53    }
54    (result.amount > 0).then_some(result)
55}
56
57fn mint_amount(raw: &str, amount: u64) -> Option<CashuMintAmount> {
58    if raw.len() > 1024 || raw.chars().any(|c| c.is_whitespace() || c.is_control()) {
59        return None;
60    }
61    let url = Url::parse(raw).ok()?;
62    if !matches!(url.scheme(), "https" | "http")
63        || !url.username().is_empty()
64        || url.password().is_some()
65        || url.query().is_some()
66        || url.fragment().is_some()
67    {
68        return None;
69    }
70    // HTTP mints (including local test mints) are metadata only; no fetch occurs.
71    let name = url.host_str()?.to_owned();
72    Some(CashuMintAmount {
73        url: url.as_str().trim_end_matches('/').to_owned(),
74        name,
75        amount,
76    })
77}
78
79fn keyset(bytes: &[u8]) -> bool {
80    matches!(bytes.len(), 8 | 33)
81}
82fn signature(bytes: &[u8]) -> bool {
83    bytes.len() == 33 && matches!(bytes.first(), Some(2 | 3))
84}
85fn hex(text: &str) -> Option<Vec<u8>> {
86    if !text.len().is_multiple_of(2) || !text.is_ascii() {
87        return None;
88    }
89    text.as_bytes()
90        .as_chunks::<2>()
91        .0
92        .iter()
93        .map(|pair| {
94            let high = char::from(*pair.first()?).to_digit(16)?;
95            let low = char::from(*pair.get(1)?).to_digit(16)?;
96            Some((high * 16 + low) as u8)
97        })
98        .collect()
99}
100
101#[derive(Deserialize)]
102struct V3 {
103    token: Vec<V3Mint>,
104    unit: Option<String>,
105    memo: Option<String>,
106}
107#[derive(Deserialize)]
108struct V3Mint {
109    mint: String,
110    proofs: Vec<V3Proof>,
111}
112#[derive(Deserialize)]
113struct V3Proof {
114    amount: u64,
115    id: String,
116    secret: String,
117    #[serde(rename = "C")]
118    signature: String,
119}
120
121fn parse_v3(bytes: &[u8]) -> Option<CashuTokenMetadata> {
122    let token: V3 = serde_json::from_slice(bytes).ok()?;
123    let mut result = CashuTokenMetadata {
124        amount: 0,
125        unit: token.unit.unwrap_or_else(|| "sat".to_owned()),
126        memo: token.memo,
127        mints: vec![],
128        proof_count: 0,
129    };
130    for mint in token.token {
131        if mint.proofs.is_empty() {
132            return None;
133        }
134        let mut amount = 0u64;
135        for proof in mint.proofs {
136            if proof.amount == 0
137                || proof.secret.is_empty()
138                || !keyset(&hex(&proof.id)?)
139                || !signature(&hex(&proof.signature)?)
140            {
141                return None;
142            }
143            amount = amount.checked_add(proof.amount)?;
144            result.proof_count = result.proof_count.checked_add(1)?;
145        }
146        result.mints.push(mint_amount(&mint.mint, amount)?);
147    }
148    Some(result)
149}
150
151// Require CBOR byte strings, not arrays/strings that merely resemble them.
152struct Bytes(Vec<u8>);
153impl<'de> Deserialize<'de> for Bytes {
154    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
155        struct Visitor;
156        impl<'de> serde::de::Visitor<'de> for Visitor {
157            type Value = Bytes;
158            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
159                f.write_str("a byte string")
160            }
161            fn visit_bytes<E: serde::de::Error>(self, value: &[u8]) -> Result<Bytes, E> {
162                Ok(Bytes(value.to_vec()))
163            }
164            fn visit_byte_buf<E: serde::de::Error>(self, value: Vec<u8>) -> Result<Bytes, E> {
165                Ok(Bytes(value))
166            }
167        }
168        deserializer.deserialize_byte_buf(Visitor)
169    }
170}
171#[derive(Deserialize)]
172struct V4 {
173    m: String,
174    u: String,
175    d: Option<String>,
176    t: Vec<V4Keyset>,
177}
178#[derive(Deserialize)]
179struct V4Keyset {
180    i: Bytes,
181    p: Vec<V4Proof>,
182}
183#[derive(Deserialize)]
184struct V4Proof {
185    a: u64,
186    s: String,
187    c: Bytes,
188}
189
190fn parse_v4(bytes: &[u8]) -> Option<CashuTokenMetadata> {
191    let mut reader = bytes;
192    let token: V4 = ciborium::de::from_reader_with_recursion_limit(&mut reader, 16).ok()?;
193    if !reader.is_empty() {
194        return None;
195    }
196    let mut amount = 0u64;
197    let mut proof_count = 0u32;
198    for group in token.t {
199        if !keyset(&group.i.0) || group.p.is_empty() {
200            return None;
201        }
202        for proof in group.p {
203            if proof.a == 0 || proof.s.is_empty() || !signature(&proof.c.0) {
204                return None;
205            }
206            amount = amount.checked_add(proof.a)?;
207            proof_count = proof_count.checked_add(1)?;
208        }
209    }
210    Some(CashuTokenMetadata {
211        amount: 0,
212        unit: token.u,
213        memo: token.d,
214        mints: vec![mint_amount(&token.m, amount)?],
215        proof_count,
216    })
217}