use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; pub type Result = std::result::Result; #[derive(Debug)] pub struct Error(String); impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl std::error::Error for Error {} #[derive(Debug, Clone, PartialEq, Eq)] pub struct Invoice { pub amount_atomic: String, pub address: String, pub memo: String, pub ts: i64, pub sig: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Receipt { pub txid: String, pub amount_atomic: String, pub address: String, pub ts: i64, pub sig: Vec, } const INV_PREFIX: &[u8] = b"inv "; const RCP_PREFIX: &[u8] = b"rcp "; const PICONERO: u128 = 1_000_000_000_000; pub fn check_address(addr: &str) -> Result<()> { let ok = match addr.as_bytes().first() { Some(b'4') if addr.len() == 95 || addr.len() == 106 => true, Some(b'8') if addr.len() == 95 => true, _ => false, }; if ok && !addr.contains('\n') { Ok(()) } else { Err(Error("invalid Monero address".into())) } } pub fn parse_atomic(s: &str) -> Result { if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { return Err(Error("amount must be decimal piconero".into())); } let n: u128 = s.parse().map_err(|_| Error("amount out of range".into()))?; if n == 0 { return Err(Error("amount must be > 0".into())); } Ok(n) } pub fn xmr_to_atomic(s: &str) -> Result { let s = s.trim(); let (whole, frac) = match s.split_once('.') { Some((w, f)) => (w, f), None => (s, ""), }; if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) { return Err(Error("invalid XMR amount".into())); } if frac.len() > 12 || !frac.bytes().all(|b| b.is_ascii_digit()) { return Err(Error("invalid XMR amount".into())); } let mut frac_pad = frac.to_string(); while frac_pad.len() < 12 { frac_pad.push('0'); } let combined = format!("{whole}{frac_pad}"); let n: u128 = combined .parse() .map_err(|_| Error("amount out of range".into()))?; if n == 0 { return Err(Error("amount must be > 0".into())); } Ok(n.to_string()) } pub fn atomic_to_xmr_str(s: &str) -> String { let n: u128 = s.parse().unwrap_or(0); let whole = n / PICONERO; let frac = n % PICONERO; if frac == 0 { return whole.to_string(); } let mut f = format!("{frac:012}"); while f.ends_with('0') { f.pop(); } format!("{whole}.{f}") } pub fn invoice_chat_line(amount_atomic: &str, memo: &str) -> String { let xmr = atomic_to_xmr_str(amount_atomic); if memo.is_empty() { format!("[invoice] {xmr} XMR") } else { format!("[invoice] {xmr} XMR — {memo}") } } pub fn receipt_chat_line(amount_atomic: &str, verified: bool) -> String { let xmr = atomic_to_xmr_str(amount_atomic); if verified { format!("[receipt] {xmr} XMR") } else { format!("[receipt] {xmr} XMR — unverified") } } pub fn sign_invoice( identity_sk: &[u8], amount_atomic: &str, address: &str, memo: &str, ts: i64, ) -> Result { check_invoice_fields(amount_atomic, address, memo)?; let sig = sign_bytes(identity_sk, &inv_sign_msg(amount_atomic, address, memo, ts))?; Ok(Invoice { amount_atomic: amount_atomic.to_string(), address: address.to_string(), memo: memo.to_string(), ts, sig, }) } pub fn verify_invoice(identity_pk: &[u8], inv: &Invoice) -> bool { if check_invoice_fields(&inv.amount_atomic, &inv.address, &inv.memo).is_err() { return false; } verify_bytes( identity_pk, &inv_sign_msg(&inv.amount_atomic, &inv.address, &inv.memo, inv.ts), &inv.sig, ) } pub fn encode_invoice(inv: &Invoice) -> Vec { let mut out = Vec::from(INV_PREFIX); out.extend_from_slice(inv.amount_atomic.as_bytes()); out.push(b'\n'); out.extend_from_slice(inv.address.as_bytes()); out.push(b'\n'); out.extend_from_slice(inv.memo.as_bytes()); out.push(b'\n'); out.extend_from_slice(inv.ts.to_string().as_bytes()); out.push(b'\n'); out.extend_from_slice(to_hex(&inv.sig).as_bytes()); out } pub fn decode_invoice(pt: &[u8]) -> Option { let rest = pt.strip_prefix(INV_PREFIX)?; let text = std::str::from_utf8(rest).ok()?; let mut parts = text.splitn(5, '\n'); let amount_atomic = parts.next()?.to_string(); let address = parts.next()?.to_string(); let memo = parts.next()?.to_string(); let ts = parts.next()?.parse::().ok()?; let sig = from_hex(parts.next()?)?; if check_invoice_fields(&amount_atomic, &address, &memo).is_err() { return None; } Some(Invoice { amount_atomic, address, memo, ts, sig, }) } pub fn sign_receipt( identity_sk: &[u8], txid: &str, amount_atomic: &str, address: &str, ts: i64, ) -> Result { check_receipt_fields(txid, amount_atomic, address)?; let sig = sign_bytes(identity_sk, &rcp_sign_msg(txid, amount_atomic, address, ts))?; Ok(Receipt { txid: txid.to_string(), amount_atomic: amount_atomic.to_string(), address: address.to_string(), ts, sig, }) } pub fn verify_receipt(identity_pk: &[u8], rcp: &Receipt) -> bool { if check_receipt_fields(&rcp.txid, &rcp.amount_atomic, &rcp.address).is_err() { return false; } verify_bytes( identity_pk, &rcp_sign_msg(&rcp.txid, &rcp.amount_atomic, &rcp.address, rcp.ts), &rcp.sig, ) } pub fn encode_receipt(rcp: &Receipt) -> Vec { let mut out = Vec::from(RCP_PREFIX); out.extend_from_slice(rcp.txid.as_bytes()); out.push(b'\n'); out.extend_from_slice(rcp.amount_atomic.as_bytes()); out.push(b'\n'); out.extend_from_slice(rcp.address.as_bytes()); out.push(b'\n'); out.extend_from_slice(rcp.ts.to_string().as_bytes()); out.push(b'\n'); out.extend_from_slice(to_hex(&rcp.sig).as_bytes()); out } pub fn decode_receipt(pt: &[u8]) -> Option { let rest = pt.strip_prefix(RCP_PREFIX)?; let text = std::str::from_utf8(rest).ok()?; let mut parts = text.splitn(5, '\n'); let txid = parts.next()?.to_string(); let amount_atomic = parts.next()?.to_string(); let address = parts.next()?.to_string(); let ts = parts.next()?.parse::().ok()?; let sig = from_hex(parts.next()?)?; if check_receipt_fields(&txid, &amount_atomic, &address).is_err() { return None; } Some(Receipt { txid, amount_atomic, address, ts, sig, }) } fn check_invoice_fields(amount_atomic: &str, address: &str, memo: &str) -> Result<()> { parse_atomic(amount_atomic)?; check_address(address)?; if memo.contains('\n') { return Err(Error("memo must not contain newlines".into())); } Ok(()) } fn check_receipt_fields(txid: &str, amount_atomic: &str, address: &str) -> Result<()> { if txid.is_empty() || txid.contains('\n') { return Err(Error("invalid txid".into())); } parse_atomic(amount_atomic)?; check_address(address) } fn inv_sign_msg(amount_atomic: &str, address: &str, memo: &str, ts: i64) -> Vec { let mut msg = Vec::from(amount_atomic.as_bytes()); msg.push(b'\n'); msg.extend_from_slice(address.as_bytes()); msg.push(b'\n'); msg.extend_from_slice(memo.as_bytes()); msg.push(b'\n'); msg.extend_from_slice(ts.to_string().as_bytes()); msg } fn rcp_sign_msg(txid: &str, amount_atomic: &str, address: &str, ts: i64) -> Vec { let mut msg = Vec::from(txid.as_bytes()); msg.push(b'\n'); msg.extend_from_slice(amount_atomic.as_bytes()); msg.push(b'\n'); msg.extend_from_slice(address.as_bytes()); msg.push(b'\n'); msg.extend_from_slice(ts.to_string().as_bytes()); msg } fn sign_bytes(identity_sk: &[u8], msg: &[u8]) -> Result> { let sk_bytes: [u8; 32] = identity_sk .try_into() .map_err(|_| Error("identity secret key must be 32 bytes".into()))?; let sk = SigningKey::from_bytes(&sk_bytes); Ok(sk.sign(msg).to_bytes().to_vec()) } fn verify_bytes(identity_pk: &[u8], msg: &[u8], sig: &[u8]) -> bool { if identity_pk.len() != 32 || sig.len() != 64 { return false; } let pk: [u8; 32] = match identity_pk.try_into() { Ok(p) => p, Err(_) => return false, }; let sig: [u8; 64] = match sig.try_into() { Ok(s) => s, Err(_) => return false, }; let Ok(vk) = VerifyingKey::from_bytes(&pk) else { return false; }; vk.verify(msg, &Signature::from_bytes(&sig)).is_ok() } fn to_hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } fn from_hex(s: &str) -> Option> { if s.is_empty() || !s.len().is_multiple_of(2) { return None; } if !s.bytes().all(|c| c.is_ascii_hexdigit()) { return None; } (0..s.len()) .step_by(2) .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) .collect() }