onionwire/tests/wallet.rs
Sirius DevOps bfd28fce6b
All checks were successful
ci / test (pull_request) Successful in 2m49s
fix(ci): serialize Pi jobs; valid XMR fixture after F4
Overlapping Actions used a fixed docker name onionwire-ci (Conflict)
and OOM-killed with 137. Unique run_id names + one-at-a-time
concurrency. F1 ingest test still used 8+B*94 which F4 checksums reject.
2026-09-10 20:12:41 -04:00

184 lines
6.8 KiB
Rust

//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod.
use ed25519_dalek::SigningKey;
use onionwire::pay;
use onionwire::wallet::{self, TransferRow, Wallet};
use onionwire::{PaymentWrite, Store};
use rand::rngs::OsRng;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn row(txid: &str, amount: &str, address: &str) -> TransferRow {
TransferRow {
txid: txid.into(),
amount: amount.into(),
address: address.into(),
}
}
fn xmr_addr() -> String {
// Same documented mainnet standard as tests/pay.rs — F4 checksums this.
"4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"
.to_string()
}
fn json_rpc_ok(result: &str) -> String {
let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#);
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
async fn serve_once(listener: TcpListener, response: String) {
let (mut sock, _) = listener.accept().await.expect("accept");
let mut buf = vec![0u8; 4096];
let _ = sock.read(&mut buf).await;
sock.write_all(response.as_bytes()).await.expect("write");
}
#[test]
fn disabled_create_address_is_not_configured() {
let w = Wallet::disabled();
let err = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(w.create_address())
.unwrap_err();
assert!(err.to_string().contains("not configured"), "got {err}");
}
#[test]
fn refuse_non_loopback_non_onion_host() {
let err = Wallet::from_url("http://example.com:18083").unwrap_err();
assert!(
err.to_string().to_ascii_lowercase().contains("host")
|| err.to_string().contains("loopback")
|| err.to_string().contains("onion"),
"got {err}"
);
}
#[tokio::test]
async fn mock_get_address_parses_string() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let canned = json_rpc_ok(
r#"{"address":"4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}"#,
);
tokio::spawn(serve_once(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
let got = w.get_address().await.expect("get_address");
assert!(got.starts_with('4'), "got {got}");
}
#[tokio::test]
async fn mock_create_address_and_transfer() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let canned = json_rpc_ok(
r#"{"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB","tx_hash":"abc123"}"#,
);
tokio::spawn(serve_once(listener, canned.clone()));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
let created = w.create_address().await.expect("create_address");
assert!(created.starts_with('8'));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(serve_once(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
let txid = w
.transfer("8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", 1)
.await
.expect("transfer");
assert_eq!(txid, "abc123");
}
#[tokio::test]
async fn mock_get_transfers_matches_txid() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let canned = json_rpc_ok(
r#"{"in":[{"txid":"deadbeef","amount":5,"address":"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}],"pending":[]}"#,
);
tokio::spawn(serve_once(listener, canned));
let w = Wallet::from_url(&format!("http://127.0.0.1:{}", addr.port())).unwrap();
let rows = w.get_transfers().await.expect("get_transfers");
assert!(wallet::transfers_match(
&rows,
"deadbeef",
"5",
"8BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
));
assert!(!wallet::transfers_match(&rows, "nope", "1", "nope"));
}
#[test]
fn transfers_match_txid_only_wrong_amount_or_addr_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "deadbeef", "99", &addr));
let other = format!("8{}", "C".repeat(94));
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", &other));
}
#[test]
fn transfers_match_address_and_amount_wrong_txid_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "cafebabe", "5", &addr));
}
#[test]
fn transfers_match_honest_triple_is_true() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(wallet::transfers_match(&rows, "deadbeef", "5", &addr));
}
#[test]
fn transfers_match_empty_field_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "", "5", &addr));
assert!(!wallet::transfers_match(&rows, "deadbeef", "", &addr));
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", ""));
let empty = [row("", "", "")];
assert!(!wallet::transfers_match(&empty, "", "", ""));
}
/// ingest_receipt inserts verified=0, then mark_verified iff transfers_match.
/// A Noise-signed rcp that cites an unrelated wallet row must stay unverified.
#[test]
fn ingest_signed_receipt_mismatched_wallet_history_stays_unverified() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
store.upsert_friend(&pk, "peer.onion", None).unwrap();
let addr = xmr_addr();
let rcp = pay::sign_receipt(&sk.to_bytes(), "unrelated", "5", &addr, 1).unwrap();
assert!(pay::verify_receipt(&pk, &rcp));
let id = store
.insert_payment(
&pk,
PaymentWrite {
dir: "in",
kind: "receipt",
amount_atomic: &rcp.amount_atomic,
address: &rcp.address,
memo: "",
txid: Some(&rcp.txid),
verified: false,
},
)
.unwrap();
let history = [row("unrelated", "99", &format!("8{}", "C".repeat(94)))];
if wallet::transfers_match(&history, &rcp.txid, &rcp.amount_atomic, &rcp.address) {
store.mark_verified(id).unwrap();
}
let rows = store.list_payments(&pk).unwrap();
assert!(!rows[0].verified);
}