//! 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(dir.path()).expect("open"); (dir, store) } fn addr_std() -> String { format!("4{}", "A".repeat(94)) } fn addr_sub() -> String { format!("8{}", "B".repeat(94)) } fn addr_integrated() -> String { format!("4{}", "C".repeat(105)) } 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(&addr_std()).is_ok()); assert!(pay::check_address(&addr_sub()).is_ok()); assert!(pay::check_address(&addr_integrated()).is_ok()); 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!("5{}", "A".repeat(94))).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); }