onionwire/tests/pay.rs
Sirius DevOps 666b9d2e86
All checks were successful
ci / test (pull_request) Successful in 3m16s
fix(pay): checksum Monero addresses (base58 + keccak)
Reject prefix+length junk so /pay, /tip, and profile save cannot
sign or store garbage xmr_addr. Empty profile address stays allowed.
2026-09-10 20:01:45 -04:00

209 lines
7.2 KiB
Rust

//! 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);
}