iris_chat_core/
payments.rs1mod cashu;
4pub use cashu::{parse_cashu_token, CashuMintAmount, CashuTokenMetadata};
5
6use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescriptionRef, Currency};
7
8const MAX_TEXT_BYTES: usize = 16_000;
9const MAX_PAYMENTS: usize = 4;
10
11#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
12pub struct LightningInvoiceMetadata {
13 pub amount_msats: Option<u64>,
14 pub description: Option<String>,
15 pub network: String,
16 pub expires_at_secs: u64,
17}
18
19#[derive(uniffi::Record, Clone)]
22pub struct MessagePaymentLink {
23 pub start: u32,
24 pub length: u32,
25 pub label: String,
26 pub uri: String,
27 pub cashu: Option<CashuTokenMetadata>,
28 pub lightning: Option<LightningInvoiceMetadata>,
29}
30
31#[uniffi::export]
32pub fn parse_lightning_invoice(text: String) -> Option<LightningInvoiceMetadata> {
33 if text.len() > MAX_TEXT_BYTES {
34 return None;
35 }
36 let raw = strip_scheme(text.trim(), "lightning:");
37 let invoice: Bolt11Invoice = raw.parse().ok()?;
40 let network = match invoice.currency() {
41 Currency::Bitcoin => "Bitcoin",
42 Currency::BitcoinTestnet => "Testnet",
43 Currency::Regtest => "Regtest",
44 Currency::Signet => "Signet",
45 _ => return None,
46 };
47 Some(LightningInvoiceMetadata {
48 amount_msats: invoice.amount_milli_satoshis(),
49 description: match invoice.description() {
50 Bolt11InvoiceDescriptionRef::Direct(value) => Some(value.to_string()),
51 Bolt11InvoiceDescriptionRef::Hash(_) => None,
52 },
53 network: network.to_owned(),
54 expires_at_secs: invoice.expires_at()?.as_secs(),
55 })
56}
57
58#[uniffi::export]
61pub fn message_payment_links(text: String) -> Vec<MessagePaymentLink> {
62 if text.len() > MAX_TEXT_BYTES {
63 return vec![];
64 }
65 let mut links = Vec::new();
66 let mut offset = 0;
67 for part in text.split_inclusive(char::is_whitespace) {
68 let trimmed = part
69 .trim_end()
70 .trim_end_matches(['.', ',', ';', '!', '?', ')', ']', '}', '>', '\'', '"']);
71 let candidate = trimmed.trim_start_matches(['(', '[', '{', '<', '\'', '"']);
72 let start = offset + trimmed.len() - candidate.len();
73 offset += part.len();
74 let cashu_raw = strip_scheme(candidate, "cashu:");
75 let lightning_raw = strip_scheme(candidate, "lightning:");
76 let (label, uri, cashu, lightning) =
77 if cashu_raw.starts_with("cashuA") || cashu_raw.starts_with("cashuB") {
78 let Some(token) = parse_cashu_token(candidate.to_owned()) else {
79 continue;
80 };
81 let unit = if token.unit == "sat" {
82 "sats"
83 } else {
84 &token.unit
85 };
86 let mint = if token.mints.len() == 1 {
87 token
88 .mints
89 .first()
90 .map(|m| m.name.clone())
91 .unwrap_or_default()
92 } else {
93 format!("{} mints", token.mints.len())
94 };
95 let test_money = if token
96 .mints
97 .iter()
98 .any(|mint| mint.url == "https://testnut.cashu.space")
99 {
100 " · Test money"
101 } else {
102 ""
103 };
104 (
105 format!(
106 "Cashu · {} {unit} · {mint}{test_money} · Not checked",
107 token.amount
108 ),
109 format!("cashu:{cashu_raw}"),
110 Some(token),
111 None,
112 )
113 } else if lightning_raw
114 .get(..2)
115 .is_some_and(|p| p.eq_ignore_ascii_case("ln"))
116 {
117 let Some(invoice) = parse_lightning_invoice(candidate.to_owned()) else {
118 continue;
119 };
120 let amount = match invoice.amount_msats {
121 Some(msats) if msats % 1000 == 0 => format!("{} sats", msats / 1000),
122 Some(msats) => format!("{}.{:03} sats", msats / 1000, msats % 1000),
123 None => "Choose amount".to_owned(),
124 };
125 let network = if invoice.network == "Bitcoin" {
126 String::new()
127 } else {
128 format!(" · {}", invoice.network)
129 };
130 (
131 format!("Lightning · {amount}{network}"),
132 format!("lightning:{lightning_raw}"),
133 None,
134 Some(invoice),
135 )
136 } else {
137 continue;
138 };
139 links.push(MessagePaymentLink {
140 start: text.get(..start).unwrap_or_default().encode_utf16().count() as u32,
141 length: candidate.encode_utf16().count() as u32,
142 label,
143 uri,
144 cashu,
145 lightning,
146 });
147 if links.len() == MAX_PAYMENTS {
148 break;
149 }
150 }
151 links
152}
153
154fn strip_scheme<'a>(text: &'a str, scheme: &str) -> &'a str {
155 if text
156 .get(..scheme.len())
157 .is_some_and(|p| p.eq_ignore_ascii_case(scheme))
158 {
159 text.get(scheme.len()..).unwrap_or_default()
160 } else {
161 text
162 }
163}
164
165#[cfg(test)]
166mod tests;