2026-09-10 14:37:43 -04:00
|
|
|
//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod.
|
|
|
|
|
|
2026-09-10 19:29:39 -04:00
|
|
|
use ed25519_dalek::SigningKey;
|
|
|
|
|
use onionwire::pay;
|
|
|
|
|
use onionwire::wallet::{self, TransferRow, Wallet};
|
|
|
|
|
use onionwire::{PaymentWrite, Store};
|
|
|
|
|
use rand::rngs::OsRng;
|
2026-09-10 14:37:43 -04:00
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
|
|
use tokio::net::TcpListener;
|
|
|
|
|
|
2026-09-10 19:29:39 -04:00
|
|
|
fn row(txid: &str, amount: &str, address: &str) -> TransferRow {
|
|
|
|
|
TransferRow {
|
|
|
|
|
txid: txid.into(),
|
|
|
|
|
amount: amount.into(),
|
|
|
|
|
address: address.into(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn xmr_addr() -> String {
|
2026-09-10 20:12:41 -04:00
|
|
|
// Same documented mainnet standard as tests/pay.rs — F4 checksums this.
|
|
|
|
|
"4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"
|
|
|
|
|
.to_string()
|
2026-09-10 19:29:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:37:43 -04:00
|
|
|
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()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 20:17:25 -04:00
|
|
|
#[test]
|
|
|
|
|
fn refuse_url_without_credentials() {
|
|
|
|
|
let err = Wallet::from_url("http://127.0.0.1:18083").unwrap_err();
|
|
|
|
|
let msg = err.to_string().to_ascii_lowercase();
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("credential") || msg.contains("login") || msg.contains("user"),
|
|
|
|
|
"got {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:37:43 -04:00
|
|
|
#[test]
|
|
|
|
|
fn refuse_non_loopback_non_onion_host() {
|
2026-09-10 20:17:25 -04:00
|
|
|
let err = Wallet::from_url("http://ow:secret@example.com:18083").unwrap_err();
|
2026-09-10 14:37:43 -04:00
|
|
|
assert!(
|
|
|
|
|
err.to_string().to_ascii_lowercase().contains("host")
|
|
|
|
|
|| err.to_string().contains("loopback")
|
|
|
|
|
|| err.to_string().contains("onion"),
|
|
|
|
|
"got {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 20:17:25 -04:00
|
|
|
#[test]
|
|
|
|
|
fn refuse_onion_rpc_url() {
|
|
|
|
|
let err = Wallet::from_url(
|
|
|
|
|
"http://ow:s3cretPASS@abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwxyz.onion:18083",
|
|
|
|
|
)
|
|
|
|
|
.unwrap_err();
|
|
|
|
|
let msg = err.to_string();
|
|
|
|
|
assert!(msg.to_ascii_lowercase().contains("onion"), "got {err}");
|
|
|
|
|
assert!(
|
|
|
|
|
!msg.contains("s3cretPASS"),
|
|
|
|
|
"password leaked in error: {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn password_absent_from_url_errors() {
|
|
|
|
|
let err = Wallet::from_url("http://ow:s3cretPASS@example.com:18083").unwrap_err();
|
|
|
|
|
assert!(
|
|
|
|
|
!err.to_string().contains("s3cretPASS"),
|
|
|
|
|
"password leaked in error: {err}"
|
|
|
|
|
);
|
|
|
|
|
let err = Wallet::from_url("http://ow:s3cretPASS@").unwrap_err();
|
|
|
|
|
assert!(
|
|
|
|
|
!err.to_string().contains("s3cretPASS"),
|
|
|
|
|
"password leaked in error: {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:37:43 -04:00
|
|
|
#[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"}"#,
|
|
|
|
|
);
|
2026-09-10 20:17:25 -04:00
|
|
|
tokio::spawn(serve_digest_then(listener, canned));
|
|
|
|
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
2026-09-10 14:37:43 -04:00
|
|
|
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"}"#,
|
|
|
|
|
);
|
2026-09-10 20:17:25 -04:00
|
|
|
tokio::spawn(serve_digest_then(listener, canned.clone()));
|
|
|
|
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
2026-09-10 14:37:43 -04:00
|
|
|
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();
|
2026-09-10 20:17:25 -04:00
|
|
|
tokio::spawn(serve_digest_then(listener, canned));
|
|
|
|
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
2026-09-10 14:37:43 -04:00
|
|
|
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":[]}"#,
|
|
|
|
|
);
|
2026-09-10 20:17:25 -04:00
|
|
|
tokio::spawn(serve_digest_then(listener, canned));
|
|
|
|
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
2026-09-10 14:37:43 -04:00
|
|
|
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"));
|
|
|
|
|
}
|
2026-09-10 19:29:39 -04:00
|
|
|
|
|
|
|
|
#[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);
|
|
|
|
|
}
|
2026-09-10 20:36:10 -04:00
|
|
|
|
2026-09-10 20:17:25 -04:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn oversized_rpc_response_is_err() {
|
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
|
|
|
let addr = listener.local_addr().unwrap();
|
|
|
|
|
let huge = vec![b'A'; 2 * 1024 * 1024];
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
let (mut sock, _) = listener.accept().await.expect("accept");
|
|
|
|
|
let mut buf = vec![0u8; 4096];
|
|
|
|
|
let _ = sock.read(&mut buf).await;
|
|
|
|
|
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n")
|
|
|
|
|
.await
|
|
|
|
|
.expect("hdr");
|
|
|
|
|
sock.write_all(&huge).await.expect("body");
|
|
|
|
|
});
|
|
|
|
|
let w = Wallet::from_url(&format!("http://ow:secret@127.0.0.1:{}", addr.port())).unwrap();
|
|
|
|
|
let err = w.get_address().await.unwrap_err();
|
|
|
|
|
let msg = err.to_string().to_ascii_lowercase();
|
|
|
|
|
assert!(
|
|
|
|
|
msg.contains("large") || msg.contains("size") || msg.contains("cap"),
|
|
|
|
|
"got {err}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!err.to_string().contains("secret"),
|
|
|
|
|
"password leaked: {err}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn digest_401() -> String {
|
|
|
|
|
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Digest realm=\"monero-rpc\", nonce=\"abcnonce\", qop=\"auth\", algorithm=MD5\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn serve_digest_then(listener: TcpListener, ok: String) {
|
|
|
|
|
let (mut sock, _) = listener.accept().await.expect("accept");
|
|
|
|
|
let mut buf = vec![0u8; 8192];
|
|
|
|
|
let _ = sock.read(&mut buf).await;
|
|
|
|
|
sock.write_all(digest_401().as_bytes()).await.expect("401");
|
|
|
|
|
drop(sock);
|
|
|
|
|
|
|
|
|
|
let (mut sock, _) = listener.accept().await.expect("accept2");
|
|
|
|
|
buf.fill(0);
|
|
|
|
|
let n = sock.read(&mut buf).await.unwrap_or(0);
|
|
|
|
|
let req = String::from_utf8_lossy(&buf[..n]);
|
|
|
|
|
assert!(
|
|
|
|
|
req.contains("Authorization: Digest"),
|
|
|
|
|
"missing digest auth: {req}"
|
|
|
|
|
);
|
|
|
|
|
assert!(req.contains("username=\"ow\""), "missing user: {req}");
|
|
|
|
|
assert!(req.contains("response=\""), "missing response: {req}");
|
|
|
|
|
sock.write_all(ok.as_bytes()).await.expect("200");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn mock_digest_auth_accepted() {
|
|
|
|
|
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_digest_then(listener, canned));
|
|
|
|
|
let w = Wallet::from_url(&format!("http://ow:s3cretPASS@127.0.0.1:{}", addr.port())).unwrap();
|
|
|
|
|
let got = w.get_address().await.expect("get_address");
|
|
|
|
|
assert!(got.starts_with('4'), "got {got}");
|
|
|
|
|
}
|