//! 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 { format!("8{}", "B".repeat(94)) } 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); }