Skip to main content

iris_chat_core/
payments.rs

1//! Local payment presentation only. Never contacts a mint, pays an invoice, or
2//! redeems a token. The original encrypted message remains the source of truth.
3mod 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/// A compact link with UTF-16 offsets, like MessageMention. The URI contains
20/// bearer/payment data: do not log it or send it to a link-preview service.
21#[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    // The maintained BOLT11 parser checks the checksum, signature and required
38    // fields. An expired invoice is still readable; the wallet decides payment.
39    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/// Recognizes standalone payments, including optional URI schemes. Invalid,
59/// embedded-URL and oversized candidates remain ordinary selectable text.
60#[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;