//! M8: signed Monero invoice/receipt frames + store + slash parse. use ed25519_dalek::SigningKey; use onionwire::pay::{self}; use onionwire::tui::{SlashCmd, WipeKind, parse_cmd, parse_slash}; use onionwire::{PaymentWrite, Store}; use rand::rngs::OsRng; fn store() -> (tempfile::TempDir, Store) { let dir = tempfile::tempdir().expect("tempdir"); let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open"); (dir, store) } // Official mainnet standard from Monero docs (not live RPC). const MAINNET_STD: &str = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"; // Same documented spend/view keys, mainnet subaddress (0x2A) and integrated (0x13 + 8 zero pid). const MAINNET_SUB: &str = "8BTd81B7syWcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv25pnJx6"; const MAINNET_INT: &str = "4LL9oSLmtpccfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2WK48GNSUQf17NLRTG"; // Same keys, stagenet standard (0x18). const STAGENET_STD: &str = "5AqWsUSEwACcfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv23X7tqA"; fn addr_std() -> String { MAINNET_STD.to_string() } fn addr_sub() -> String { MAINNET_SUB.to_string() } fn payw<'a>( dir: &'a str, kind: &'a str, amount: &'a str, address: &'a str, memo: &'a str, txid: Option<&'a str>, verified: bool, ) -> PaymentWrite<'a> { PaymentWrite { dir, kind, amount_atomic: amount, address, memo, txid, verified, } } #[test] fn valid_and_invalid_xmr_addresses() { assert!(pay::check_address(MAINNET_STD).is_ok()); assert!(pay::check_address(MAINNET_SUB).is_ok()); assert!(pay::check_address(MAINNET_INT).is_ok()); assert!(pay::check_address(STAGENET_STD).is_ok()); // prefix+length junk that the old checker accepted assert!(pay::check_address(&format!("4{}", "A".repeat(94))).is_err()); assert!(pay::check_address(&format!("8{}", "B".repeat(94))).is_err()); assert!(pay::check_address(&format!("4{}", "C".repeat(105))).is_err()); assert!(pay::check_address(&format!("4{}", "A".repeat(93))).is_err()); assert!(pay::check_address(&format!("8{}", "B".repeat(95))).is_err()); assert!(pay::check_address(&format!("{MAINNET_STD}\n")).is_err()); let mut bad_ck = MAINNET_STD.to_string(); bad_ck.replace_range(94..95, "f"); assert!(pay::check_address(&bad_ck).is_err()); assert!(pay::check_address("").is_err()); assert!(pay::check_address("not-an-address").is_err()); } #[test] fn atomic_display_trims_trailing_zeros() { assert_eq!(pay::atomic_to_xmr_str("1000000000000"), "1"); assert_eq!(pay::atomic_to_xmr_str("120000000000"), "0.12"); assert_eq!(pay::atomic_to_xmr_str("1"), "0.000000000001"); } #[test] fn invoice_sign_verify_and_wrong_key() { let sk = SigningKey::generate(&mut OsRng); let pk = sk.verifying_key().to_bytes(); let addr = addr_std(); let inv = pay::sign_invoice(&sk.to_bytes(), "120000000000", &addr, "coffee", 42).unwrap(); assert!(pay::verify_invoice(&pk, &inv)); let other = SigningKey::generate(&mut OsRng); assert!(!pay::verify_invoice( &other.verifying_key().to_bytes(), &inv )); let bytes = pay::encode_invoice(&inv); let parsed = pay::decode_invoice(&bytes).expect("invoice frame"); assert_eq!(parsed.amount_atomic, "120000000000"); assert_eq!(parsed.address, addr); assert_eq!(parsed.memo, "coffee"); assert_eq!(parsed.ts, 42); assert_eq!(parsed.sig, inv.sig); } #[test] fn receipt_sign_verify_roundtrip() { let sk = SigningKey::generate(&mut OsRng); let pk = sk.verifying_key().to_bytes(); let addr = addr_sub(); let rcp = pay::sign_receipt(&sk.to_bytes(), "deadbeef", "1", &addr, 99).unwrap(); assert!(pay::verify_receipt(&pk, &rcp)); let bytes = pay::encode_receipt(&rcp); let parsed = pay::decode_receipt(&bytes).expect("receipt frame"); assert_eq!(parsed.txid, "deadbeef"); assert_eq!(parsed.amount_atomic, "1"); assert_eq!(parsed.address, addr); assert_eq!(parsed.ts, 99); } #[test] fn chat_is_not_invoice_or_receipt() { assert!(pay::decode_invoice(b"hello wire").is_none()); assert!(pay::decode_receipt(b"hello wire").is_none()); } #[test] fn zero_amount_rejected() { let sk = [7u8; 32]; let addr = addr_std(); assert!(pay::sign_invoice(&sk, "0", &addr, "", 1).is_err()); assert!(pay::sign_receipt(&sk, "tx", "0", &addr, 1).is_err()); } #[test] fn store_insert_invoice_for_friend_unknown_fails() { let (_dir, store) = store(); let sk = SigningKey::generate(&mut OsRng); let pk = sk.verifying_key().to_bytes(); let addr = addr_std(); assert!( store .insert_payment(&pk, payw("out", "invoice", "1", &addr, "m", None, false)) .is_err() ); store.upsert_friend(&pk, "a.onion", None).unwrap(); let id = store .insert_payment(&pk, payw("out", "invoice", "1", &addr, "m", None, false)) .unwrap(); assert!(id > 0); let rows = store.list_payments(&pk).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].kind, "invoice"); assert_eq!(rows[0].dir, "out"); assert_eq!(rows[0].amount_atomic, "1"); assert!(!rows[0].verified); } #[test] fn incoming_receipt_is_not_verified() { let (_dir, store) = store(); let pk = [9u8; 32]; store.upsert_friend(&pk, "b.onion", None).unwrap(); let addr = addr_sub(); let id = store .insert_payment( &pk, payw("in", "receipt", "5", &addr, "", Some("txid1"), false), ) .unwrap(); let rows = store.list_payments(&pk).unwrap(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].kind, "receipt"); assert_eq!(rows[0].txid.as_deref(), Some("txid1")); assert!(!rows[0].verified); assert!(store.mark_verified(id).unwrap()); let rows = store.list_payments(&pk).unwrap(); assert!(rows[0].verified); } #[test] fn invoice_chat_line_never_raw_prefix() { let line = pay::invoice_chat_line("120000000000", "coffee"); assert_eq!(line, "[invoice] 0.12 XMR — coffee"); assert!(!line.contains("inv ")); let bare = pay::invoice_chat_line("120000000000", ""); assert_eq!(bare, "[invoice] 0.12 XMR"); let r = pay::receipt_chat_line("120000000000", false); assert_eq!(r, "[receipt] 0.12 XMR — unverified"); let v = pay::receipt_chat_line("120000000000", true); assert_eq!(v, "[receipt] 0.12 XMR"); assert!(!v.contains("rcp ")); } #[test] fn slash_pay_tip_and_wipe_still_parse() { assert_eq!( parse_cmd("/pay 0.12 coffee please"), Some(SlashCmd::Pay { atomic: "120000000000".into(), memo: "coffee please".into(), }) ); assert_eq!( parse_cmd(" /tip 1 "), Some(SlashCmd::Tip { atomic: "1000000000000".into(), memo: String::new(), }) ); assert_eq!(parse_cmd("/pay"), None); assert_eq!(parse_cmd("/pay 0"), None); assert_eq!(parse_cmd("/wipe"), Some(SlashCmd::Wipe(WipeKind::Messages))); assert_eq!(parse_slash("/wipe-all"), Some(WipeKind::All)); assert_eq!(parse_slash("/pay 0.12"), None); }