2026-09-10 14:37:43 -04:00
|
|
|
//! 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");
|
2026-09-10 17:19:50 -04:00
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
|
2026-09-10 14:37:43 -04:00
|
|
|
(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);
|
|
|
|
|
}
|