iris_chat_core/payments/
cashu.rs1use 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#[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 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
151struct 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}