diff --git a/Cargo.lock b/Cargo.lock index f983937..0ada483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2605,6 +2605,7 @@ dependencies = [ "ratatui", "rusqlite", "safelog", + "serde_json", "snow", "tempfile", "tokio", diff --git a/Cargo.toml b/Cargo.toml index eaded20..98eb8d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ safelog = "0.9" ed25519-dalek = { version = "2", features = ["rand_core"] } rand = "0.8" rusqlite = { version = "0.36", features = ["bundled"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "time", "net"] } tor-cell = "0.46" tor-hsservice = "0.46" tor-rtcompat = { version = "0.46", features = ["tokio"] } @@ -23,6 +23,7 @@ qrcode = { version = "0.14.1", default-features = false } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } x25519-dalek = { version = "2", features = ["static_secrets"] } snow = "0.10" +serde_json = "1" [dev-dependencies] tempfile = "3" diff --git a/src/lib.rs b/src/lib.rs index 1c01562..90b0d32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,10 +3,12 @@ pub mod frame; pub mod hs; pub mod loc; pub mod node; +pub mod pay; pub mod profile; pub mod qr; pub mod session; mod store; pub mod tui; +pub mod wallet; -pub use store::{Friend, FriendProfile, Message, SelfIdentity, Store}; +pub use store::{Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store}; diff --git a/src/node.rs b/src/node.rs index 288ea48..dcb9fe4 100644 --- a/src/node.rs +++ b/src/node.rs @@ -13,10 +13,12 @@ use crate::dispatch::{self, Kind}; use crate::frame; use crate::hs::{self, Client, HS_PORT}; use crate::loc; +use crate::pay; use crate::profile; use crate::qr; use crate::session::{self, Keys}; -use crate::store::{Friend, FriendProfile, Message, Store}; +use crate::store::{Friend, FriendProfile, Message, PaymentWrite, Store}; +use crate::wallet::{self, Wallet}; pub struct RotateResult { pub notified: usize, @@ -41,6 +43,7 @@ pub struct Node { hs: Mutex>, onion: Mutex, keys: Keys, + wallet: Wallet, } impl Node { @@ -67,6 +70,7 @@ impl Node { hs: Mutex::new(None), onion: Mutex::new(onion), keys, + wallet: Wallet::from_env(), }); let rend = spawn_rend(Arc::clone(&node), rend); *node.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, rend }); @@ -202,6 +206,119 @@ impl Node { self.try_send(&onion, friend_pk, &prekey, &pt).await } + /// Invoice to receive. Wallet subaddress if up, else self_profile.xmr_addr. + pub async fn pay_invoice( + &self, + friend_pk: &[u8], + atomic: &str, + memo: &str, + ) -> Result<(), String> { + let address = self.invoice_address().await?; + let inv = pay::sign_invoice(&self.keys.identity_sk, atomic, &address, memo, unix_now()) + .map_err(|e| e.to_string())?; + let pt = pay::encode_invoice(&inv); + self.send_once(friend_pk, &pt).await?; + let store = self.store.lock().map_err(|e| e.to_string())?; + store + .insert_payment( + friend_pk, + PaymentWrite { + dir: "out", + kind: "invoice", + amount_atomic: atomic, + address: &address, + memo, + txid: None, + verified: false, + }, + ) + .map_err(|e| e.to_string())?; + store + .append_message( + friend_pk, + "out", + pay::invoice_chat_line(atomic, memo).as_bytes(), + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + + /// Pay selected friend via wallet RPC, then signed receipt. + pub async fn tip(&self, friend_pk: &[u8], atomic: &str, _memo: &str) -> Result<(), String> { + if !self.wallet.configured() { + return Err("wallet not connected — set ONIONWIRE_WALLET_RPC".into()); + } + let address = self + .friend_profile(friend_pk)? + .map(|p| p.xmr_addr) + .unwrap_or_default(); + if address.is_empty() { + return Err("friend has no Monero address — they must /profile".into()); + } + pay::check_address(&address).map_err(|e| e.to_string())?; + let amount: u64 = atomic + .parse() + .map_err(|_| "amount too large for wallet RPC".to_string())?; + let txid = self + .wallet + .transfer(&address, amount) + .await + .map_err(|e| e.to_string())?; + let rcp = pay::sign_receipt(&self.keys.identity_sk, &txid, atomic, &address, unix_now()) + .map_err(|e| e.to_string())?; + let pt = pay::encode_receipt(&rcp); + self.send_once(friend_pk, &pt).await?; + let store = self.store.lock().map_err(|e| e.to_string())?; + store + .insert_payment( + friend_pk, + PaymentWrite { + dir: "out", + kind: "receipt", + amount_atomic: atomic, + address: &address, + memo: "", + txid: Some(&txid), + verified: true, + }, + ) + .map_err(|e| e.to_string())?; + store + .append_message( + friend_pk, + "out", + pay::receipt_chat_line(atomic, true).as_bytes(), + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + + async fn invoice_address(&self) -> Result { + if self.wallet.configured() + && let Ok(addr) = self.wallet.create_address().await + && pay::check_address(&addr).is_ok() + { + return Ok(addr); + } + let profile_addr = self.self_profile()?.xmr_addr; + if profile_addr.is_empty() { + return Err("no receive address — set profile xmr or ONIONWIRE_WALLET_RPC".into()); + } + pay::check_address(&profile_addr).map_err(|e| e.to_string())?; + Ok(profile_addr) + } + + async fn send_once(&self, friend_pk: &[u8], plaintext: &[u8]) -> Result<(), String> { + let (onion, prekey) = { + let f = self.friend(friend_pk)?; + if f.prekey.len() != 32 { + return Err("friend missing prekey".into()); + } + (f.onion, f.prekey) + }; + self.try_send(&onion, friend_pk, &prekey, plaintext).await + } + pub async fn connect_onion(&self, onion: &str) -> Result<(), String> { tokio::time::timeout( Duration::from_secs(20), @@ -404,9 +521,106 @@ impl Node { .map_err(|e| e.to_string())?; Ok(()) } - Kind::Invoice | Kind::Receipt | Kind::Ping | Kind::Drop => Ok(()), + Kind::Invoice => self.ingest_invoice(&sess.peer_identity, &pt).await, + Kind::Receipt => self.ingest_receipt(&sess.peer_identity, &pt).await, + Kind::Ping | Kind::Drop => Ok(()), } } + + async fn ingest_invoice(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> { + let Some(inv) = pay::decode_invoice(pt) else { + return Ok(()); + }; + if !pay::verify_invoice(peer, &inv) { + eprintln!("inv dropped (bad sig)"); + return Ok(()); + } + let store = self.store.lock().map_err(|e| e.to_string())?; + store + .insert_payment( + peer, + PaymentWrite { + dir: "in", + kind: "invoice", + amount_atomic: &inv.amount_atomic, + address: &inv.address, + memo: &inv.memo, + txid: None, + verified: false, + }, + ) + .map_err(|e| e.to_string())?; + store + .append_message( + peer, + "in", + pay::invoice_chat_line(&inv.amount_atomic, &inv.memo).as_bytes(), + ) + .map_err(|e| e.to_string())?; + Ok(()) + } + + async fn ingest_receipt(&self, peer: &[u8], pt: &[u8]) -> Result<(), String> { + let Some(rcp) = pay::decode_receipt(pt) else { + return Ok(()); + }; + if !pay::verify_receipt(peer, &rcp) { + eprintln!("rcp dropped (bad sig)"); + return Ok(()); + } + let id = { + let store = self.store.lock().map_err(|e| e.to_string())?; + store + .insert_payment( + peer, + PaymentWrite { + dir: "in", + kind: "receipt", + amount_atomic: &rcp.amount_atomic, + address: &rcp.address, + memo: "", + txid: Some(&rcp.txid), + verified: false, + }, + ) + .map_err(|e| e.to_string())? + }; + let mut verified = false; + if self.wallet.configured() { + match self.wallet.get_transfers().await { + Ok(rows) + if wallet::transfers_match( + &rows, + &rcp.txid, + &rcp.amount_atomic, + &rcp.address, + ) => + { + self.store + .lock() + .map_err(|e| e.to_string())? + .mark_verified(id) + .map_err(|e| e.to_string())?; + verified = true; + } + Ok(_) => {} + Err(e) => eprintln!("receipt unverified: {e}"), + } + } + if !verified { + eprintln!("receipt unverified"); + } + self.store + .lock() + .map_err(|e| e.to_string())? + .append_message( + peer, + "in", + pay::receipt_chat_line(&rcp.amount_atomic, verified).as_bytes(), + ) + .map_err(|e| e.to_string())?; + Ok(()) + } } fn spawn_rend( diff --git a/src/pay.rs b/src/pay.rs new file mode 100644 index 0000000..faae2dd --- /dev/null +++ b/src/pay.rs @@ -0,0 +1,328 @@ +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for Error {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Invoice { + pub amount_atomic: String, + pub address: String, + pub memo: String, + pub ts: i64, + pub sig: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Receipt { + pub txid: String, + pub amount_atomic: String, + pub address: String, + pub ts: i64, + pub sig: Vec, +} + +const INV_PREFIX: &[u8] = b"inv "; +const RCP_PREFIX: &[u8] = b"rcp "; +const PICONERO: u128 = 1_000_000_000_000; + +pub fn check_address(addr: &str) -> Result<()> { + let ok = match addr.as_bytes().first() { + Some(b'4') if addr.len() == 95 || addr.len() == 106 => true, + Some(b'8') if addr.len() == 95 => true, + _ => false, + }; + if ok && !addr.contains('\n') { + Ok(()) + } else { + Err(Error("invalid Monero address".into())) + } +} + +pub fn parse_atomic(s: &str) -> Result { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return Err(Error("amount must be decimal piconero".into())); + } + let n: u128 = s.parse().map_err(|_| Error("amount out of range".into()))?; + if n == 0 { + return Err(Error("amount must be > 0".into())); + } + Ok(n) +} + +pub fn xmr_to_atomic(s: &str) -> Result { + let s = s.trim(); + let (whole, frac) = match s.split_once('.') { + Some((w, f)) => (w, f), + None => (s, ""), + }; + if whole.is_empty() || !whole.bytes().all(|b| b.is_ascii_digit()) { + return Err(Error("invalid XMR amount".into())); + } + if frac.len() > 12 || !frac.bytes().all(|b| b.is_ascii_digit()) { + return Err(Error("invalid XMR amount".into())); + } + let mut frac_pad = frac.to_string(); + while frac_pad.len() < 12 { + frac_pad.push('0'); + } + let combined = format!("{whole}{frac_pad}"); + let n: u128 = combined + .parse() + .map_err(|_| Error("amount out of range".into()))?; + if n == 0 { + return Err(Error("amount must be > 0".into())); + } + Ok(n.to_string()) +} + +pub fn atomic_to_xmr_str(s: &str) -> String { + let n: u128 = s.parse().unwrap_or(0); + let whole = n / PICONERO; + let frac = n % PICONERO; + if frac == 0 { + return whole.to_string(); + } + let mut f = format!("{frac:012}"); + while f.ends_with('0') { + f.pop(); + } + format!("{whole}.{f}") +} + +pub fn invoice_chat_line(amount_atomic: &str, memo: &str) -> String { + let xmr = atomic_to_xmr_str(amount_atomic); + if memo.is_empty() { + format!("[invoice] {xmr} XMR") + } else { + format!("[invoice] {xmr} XMR — {memo}") + } +} + +pub fn receipt_chat_line(amount_atomic: &str, verified: bool) -> String { + let xmr = atomic_to_xmr_str(amount_atomic); + if verified { + format!("[receipt] {xmr} XMR") + } else { + format!("[receipt] {xmr} XMR — unverified") + } +} + +pub fn sign_invoice( + identity_sk: &[u8], + amount_atomic: &str, + address: &str, + memo: &str, + ts: i64, +) -> Result { + check_invoice_fields(amount_atomic, address, memo)?; + let sig = sign_bytes(identity_sk, &inv_sign_msg(amount_atomic, address, memo, ts))?; + Ok(Invoice { + amount_atomic: amount_atomic.to_string(), + address: address.to_string(), + memo: memo.to_string(), + ts, + sig, + }) +} + +pub fn verify_invoice(identity_pk: &[u8], inv: &Invoice) -> bool { + if check_invoice_fields(&inv.amount_atomic, &inv.address, &inv.memo).is_err() { + return false; + } + verify_bytes( + identity_pk, + &inv_sign_msg(&inv.amount_atomic, &inv.address, &inv.memo, inv.ts), + &inv.sig, + ) +} + +pub fn encode_invoice(inv: &Invoice) -> Vec { + let mut out = Vec::from(INV_PREFIX); + out.extend_from_slice(inv.amount_atomic.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(inv.address.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(inv.memo.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(inv.ts.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(to_hex(&inv.sig).as_bytes()); + out +} + +pub fn decode_invoice(pt: &[u8]) -> Option { + let rest = pt.strip_prefix(INV_PREFIX)?; + let text = std::str::from_utf8(rest).ok()?; + let mut parts = text.splitn(5, '\n'); + let amount_atomic = parts.next()?.to_string(); + let address = parts.next()?.to_string(); + let memo = parts.next()?.to_string(); + let ts = parts.next()?.parse::().ok()?; + let sig = from_hex(parts.next()?)?; + if check_invoice_fields(&amount_atomic, &address, &memo).is_err() { + return None; + } + Some(Invoice { + amount_atomic, + address, + memo, + ts, + sig, + }) +} + +pub fn sign_receipt( + identity_sk: &[u8], + txid: &str, + amount_atomic: &str, + address: &str, + ts: i64, +) -> Result { + check_receipt_fields(txid, amount_atomic, address)?; + let sig = sign_bytes(identity_sk, &rcp_sign_msg(txid, amount_atomic, address, ts))?; + Ok(Receipt { + txid: txid.to_string(), + amount_atomic: amount_atomic.to_string(), + address: address.to_string(), + ts, + sig, + }) +} + +pub fn verify_receipt(identity_pk: &[u8], rcp: &Receipt) -> bool { + if check_receipt_fields(&rcp.txid, &rcp.amount_atomic, &rcp.address).is_err() { + return false; + } + verify_bytes( + identity_pk, + &rcp_sign_msg(&rcp.txid, &rcp.amount_atomic, &rcp.address, rcp.ts), + &rcp.sig, + ) +} + +pub fn encode_receipt(rcp: &Receipt) -> Vec { + let mut out = Vec::from(RCP_PREFIX); + out.extend_from_slice(rcp.txid.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(rcp.amount_atomic.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(rcp.address.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(rcp.ts.to_string().as_bytes()); + out.push(b'\n'); + out.extend_from_slice(to_hex(&rcp.sig).as_bytes()); + out +} + +pub fn decode_receipt(pt: &[u8]) -> Option { + let rest = pt.strip_prefix(RCP_PREFIX)?; + let text = std::str::from_utf8(rest).ok()?; + let mut parts = text.splitn(5, '\n'); + let txid = parts.next()?.to_string(); + let amount_atomic = parts.next()?.to_string(); + let address = parts.next()?.to_string(); + let ts = parts.next()?.parse::().ok()?; + let sig = from_hex(parts.next()?)?; + if check_receipt_fields(&txid, &amount_atomic, &address).is_err() { + return None; + } + Some(Receipt { + txid, + amount_atomic, + address, + ts, + sig, + }) +} + +fn check_invoice_fields(amount_atomic: &str, address: &str, memo: &str) -> Result<()> { + parse_atomic(amount_atomic)?; + check_address(address)?; + if memo.contains('\n') { + return Err(Error("memo must not contain newlines".into())); + } + Ok(()) +} + +fn check_receipt_fields(txid: &str, amount_atomic: &str, address: &str) -> Result<()> { + if txid.is_empty() || txid.contains('\n') { + return Err(Error("invalid txid".into())); + } + parse_atomic(amount_atomic)?; + check_address(address) +} + +fn inv_sign_msg(amount_atomic: &str, address: &str, memo: &str, ts: i64) -> Vec { + let mut msg = Vec::from(amount_atomic.as_bytes()); + msg.push(b'\n'); + msg.extend_from_slice(address.as_bytes()); + msg.push(b'\n'); + msg.extend_from_slice(memo.as_bytes()); + msg.push(b'\n'); + msg.extend_from_slice(ts.to_string().as_bytes()); + msg +} + +fn rcp_sign_msg(txid: &str, amount_atomic: &str, address: &str, ts: i64) -> Vec { + let mut msg = Vec::from(txid.as_bytes()); + msg.push(b'\n'); + msg.extend_from_slice(amount_atomic.as_bytes()); + msg.push(b'\n'); + msg.extend_from_slice(address.as_bytes()); + msg.push(b'\n'); + msg.extend_from_slice(ts.to_string().as_bytes()); + msg +} + +fn sign_bytes(identity_sk: &[u8], msg: &[u8]) -> Result> { + let sk_bytes: [u8; 32] = identity_sk + .try_into() + .map_err(|_| Error("identity secret key must be 32 bytes".into()))?; + let sk = SigningKey::from_bytes(&sk_bytes); + Ok(sk.sign(msg).to_bytes().to_vec()) +} + +fn verify_bytes(identity_pk: &[u8], msg: &[u8], sig: &[u8]) -> bool { + if identity_pk.len() != 32 || sig.len() != 64 { + return false; + } + let pk: [u8; 32] = match identity_pk.try_into() { + Ok(p) => p, + Err(_) => return false, + }; + let sig: [u8; 64] = match sig.try_into() { + Ok(s) => s, + Err(_) => return false, + }; + let Ok(vk) = VerifyingKey::from_bytes(&pk) else { + return false; + }; + vk.verify(msg, &Signature::from_bytes(&sig)).is_ok() +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn from_hex(s: &str) -> Option> { + if s.is_empty() || !s.len().is_multiple_of(2) { + return None; + } + if !s.bytes().all(|c| c.is_ascii_hexdigit()) { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} diff --git a/src/store.rs b/src/store.rs index 7391164..da3f371 100644 --- a/src/store.rs +++ b/src/store.rs @@ -67,6 +67,29 @@ pub struct Message { pub plaintext: Vec, } +#[derive(Debug)] +pub struct Payment { + pub id: i64, + pub dir: String, + pub kind: String, + pub amount_atomic: String, + pub address: String, + pub memo: String, + pub txid: Option, + pub verified: bool, + pub created_at: i64, +} + +pub struct PaymentWrite<'a> { + pub dir: &'a str, + pub kind: &'a str, + pub amount_atomic: &'a str, + pub address: &'a str, + pub memo: &'a str, + pub txid: Option<&'a str>, + pub verified: bool, +} + impl Store { pub fn open() -> Result { Self::open_at(&home_dir()?) @@ -126,6 +149,18 @@ impl Store { xmr_addr TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS payments ( + id INTEGER PRIMARY KEY, + friend_id INTEGER NOT NULL REFERENCES friends(id), + dir TEXT NOT NULL CHECK(dir IN ('in','out')), + kind TEXT NOT NULL CHECK(kind IN ('invoice','receipt')), + amount_atomic TEXT NOT NULL, + address TEXT NOT NULL, + memo TEXT NOT NULL DEFAULT '', + txid TEXT, + verified INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + ); INSERT OR IGNORE INTO self_profile (id) VALUES (1); ", )?; @@ -421,6 +456,65 @@ impl Store { .map_err(Into::into) } + pub fn insert_payment(&self, friend_pk: &[u8], p: PaymentWrite<'_>) -> Result { + if p.dir != "in" && p.dir != "out" { + return Err(Error("dir must be in or out".into())); + } + if p.kind != "invoice" && p.kind != "receipt" { + return Err(Error("kind must be invoice or receipt".into())); + } + crate::pay::parse_atomic(p.amount_atomic).map_err(|e| Error(e.to_string()))?; + crate::pay::check_address(p.address).map_err(|e| Error(e.to_string()))?; + let friend_id: i64 = self + .conn + .query_row( + "SELECT id FROM friends WHERE pubkey = ?1", + params![friend_pk], + |row| row.get(0), + ) + .map_err(|_| Error("friend not found".into()))?; + self.conn.execute( + "INSERT INTO payments (friend_id, dir, kind, amount_atomic, address, memo, txid, verified, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + friend_id, + p.dir, + p.kind, + p.amount_atomic, + p.address, + p.memo, + p.txid, + i64::from(p.verified), + unix_now() + ], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn list_payments(&self, friend_pk: &[u8]) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT p.id, p.dir, p.kind, p.amount_atomic, p.address, p.memo, p.txid, p.verified, p.created_at + FROM payments p + JOIN friends f ON f.id = p.friend_id + WHERE f.pubkey = ?1 + ORDER BY p.id", + )?; + let rows = stmt.query_map(params![friend_pk], payment_from_row)?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) + } + + pub fn mark_verified(&self, id: i64) -> Result { + let n = self.conn.execute( + "UPDATE payments SET verified = 1 WHERE id = ?1", + params![id], + )?; + Ok(n > 0) + } + pub fn append_message(&self, friend_pk: &[u8], dir: &str, plaintext: &[u8]) -> Result<()> { if dir != "in" && dir != "out" { return Err(Error("dir must be in or out".into())); @@ -480,6 +574,20 @@ impl Store { } } +fn payment_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Payment { + id: row.get(0)?, + dir: row.get(1)?, + kind: row.get(2)?, + amount_atomic: row.get(3)?, + address: row.get(4)?, + memo: row.get(5)?, + txid: row.get(6)?, + verified: row.get::<_, i64>(7)? != 0, + created_at: row.get(8)?, + }) +} + fn profile_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(FriendProfile { display_name: row.get(0)?, diff --git a/src/tui.rs b/src/tui.rs index c6535a4..8466061 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -59,13 +59,15 @@ OnionWire keys\n\ 1 / 2 / 3 focus roster / chat / composer\n\ j k or Up Down move or scroll focused pane\n\ g / G jump to top / bottom\n\ - Enter /wipe /wipe-all /profile /who\n\ + Enter /wipe /wipe-all /profile /who /pay /tip\n\ Esc close overlay, go back, clear composer\n\ F2 share QR\n\ F3 paste a friend QR\n\ F4 rotate onion locator\n\ F5 selected friend's profile (/who)\n\ /profile edit name, bio, Monero address\n\ + /pay [memo] invoice to receive\n\ + /tip [memo] pay selected friend\n\ ? this help\n\ Ctrl-Q quit\n\ \n\ @@ -141,11 +143,13 @@ pub enum WipeKind { All, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SlashCmd { Wipe(WipeKind), Profile, Who, + Pay { atomic: String, memo: String }, + Tip { atomic: String, memo: String }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -239,13 +243,37 @@ pub enum AppExit { } pub fn parse_cmd(raw: &str) -> Option { - match raw.trim() { - "/wipe" => Some(SlashCmd::Wipe(WipeKind::Messages)), - "/wipe-all" => Some(SlashCmd::Wipe(WipeKind::All)), - "/profile" => Some(SlashCmd::Profile), - "/who" => Some(SlashCmd::Who), - _ => None, + let s = raw.trim(); + match s { + "/wipe" => return Some(SlashCmd::Wipe(WipeKind::Messages)), + "/wipe-all" => return Some(SlashCmd::Wipe(WipeKind::All)), + "/profile" => return Some(SlashCmd::Profile), + "/who" => return Some(SlashCmd::Who), + _ => {} } + if let Some(rest) = s.strip_prefix("/pay") + && (rest.is_empty() || rest.starts_with(char::is_whitespace)) + { + return parse_amount_cmd(rest).map(|(atomic, memo)| SlashCmd::Pay { atomic, memo }); + } + if let Some(rest) = s.strip_prefix("/tip") + && (rest.is_empty() || rest.starts_with(char::is_whitespace)) + { + return parse_amount_cmd(rest).map(|(atomic, memo)| SlashCmd::Tip { atomic, memo }); + } + None +} + +fn parse_amount_cmd(rest: &str) -> Option<(String, String)> { + let rest = rest.trim(); + if rest.is_empty() { + return None; + } + let mut parts = rest.splitn(2, char::is_whitespace); + let amount = parts.next()?.trim(); + let memo = parts.next().unwrap_or("").trim().to_string(); + let atomic = crate::pay::xmr_to_atomic(amount).ok()?; + Some((atomic, memo)) } pub fn parse_slash(raw: &str) -> Option { @@ -465,6 +493,8 @@ impl App { let mut wipe_confirm = None; let mut profile_save = None; let mut open_profile = false; + let mut pay_cmd = None; + let mut tip_cmd = None; match &mut self.screen { Screen::Main => match key.code { KeyCode::Tab => self.focus = self.focus.next(), @@ -507,6 +537,14 @@ impl App { self.composer.clear(); self.who_open = true; } + Some(SlashCmd::Pay { atomic, memo }) => { + self.composer.clear(); + pay_cmd = Some((atomic, memo)); + } + Some(SlashCmd::Tip { atomic, memo }) => { + self.composer.clear(); + tip_cmd = Some((atomic, memo)); + } None => {} }, KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -591,6 +629,12 @@ impl App { if let Some(draft) = profile_save { self.save_profile(draft)?; } + if let Some((atomic, memo)) = pay_cmd { + self.send_pay(&atomic, &memo)?; + } + if let Some((atomic, memo)) = tip_cmd { + self.send_tip(&atomic, &memo)?; + } Ok(None) } @@ -768,6 +812,32 @@ impl App { Ok(()) } + fn send_pay(&mut self, atomic: &str, memo: &str) -> Result<(), String> { + let Some(friend) = self.friends.get(self.selected) else { + self.status_note = Some("no friend selected".into()); + return Ok(()); + }; + let pk = friend.pubkey.clone(); + match self.rt.block_on(self.node.pay_invoice(&pk, atomic, memo)) { + Ok(()) => self.status_note = Some("invoice sent".into()), + Err(e) => self.status_note = Some(e), + } + Ok(()) + } + + fn send_tip(&mut self, atomic: &str, memo: &str) -> Result<(), String> { + let Some(friend) = self.friends.get(self.selected) else { + self.status_note = Some("no friend selected".into()); + return Ok(()); + }; + let pk = friend.pubkey.clone(); + match self.rt.block_on(self.node.tip(&pk, atomic, memo)) { + Ok(()) => self.status_note = Some("tip sent".into()), + Err(e) => self.status_note = Some(e), + } + Ok(()) + } + fn who_body(&self) -> String { let Some(friend) = self.friends.get(self.selected) else { return "no friend selected\n\nEsc closes".into(); @@ -894,7 +964,7 @@ impl App { f.render_widget(chat, panes[1]); let cmd = if self.composer.is_empty() { - "/wipe /wipe-all /profile /who".to_string() + "/wipe /wipe-all /profile /who /pay /tip".to_string() } else { self.composer.clone() }; diff --git a/src/wallet.rs b/src/wallet.rs new file mode 100644 index 0000000..c3a7004 --- /dev/null +++ b/src/wallet.rs @@ -0,0 +1,283 @@ +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub struct Error(String); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for Error {} + +#[derive(Debug, Clone)] +struct Endpoint { + host: String, + port: u16, + path: String, +} + +#[derive(Debug, Clone)] +pub struct Wallet { + endpoint: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferRow { + pub txid: String, + pub amount: String, + pub address: String, +} + +const RPC_TIMEOUT: Duration = Duration::from_secs(5); + +impl Wallet { + pub fn disabled() -> Self { + Self { endpoint: None } + } + + pub fn from_env() -> Self { + match std::env::var("ONIONWIRE_WALLET_RPC") { + Ok(s) if !s.trim().is_empty() => match Self::from_url(s.trim()) { + Ok(w) => w, + Err(e) => { + eprintln!("ONIONWIRE_WALLET_RPC: {e}"); + Self::disabled() + } + }, + _ => Self::disabled(), + } + } + + pub fn from_url(url: &str) -> Result { + Ok(Self { + endpoint: Some(parse_http_url(url)?), + }) + } + + pub fn configured(&self) -> bool { + self.endpoint.is_some() + } + + pub async fn get_address(&self) -> Result { + let v = self + .rpc("get_address", serde_json::json!({"account_index": 0})) + .await?; + json_str(&v, "address") + } + + pub async fn create_address(&self) -> Result { + let v = self + .rpc("create_address", serde_json::json!({"account_index": 0})) + .await?; + json_str(&v, "address") + } + + pub async fn transfer(&self, address: &str, amount: u64) -> Result { + let v = self + .rpc( + "transfer", + serde_json::json!({ + "destinations": [{"amount": amount, "address": address}], + "account_index": 0 + }), + ) + .await?; + json_str(&v, "tx_hash") + } + + pub async fn get_transfers(&self) -> Result> { + let v = self + .rpc( + "get_transfers", + serde_json::json!({"in": true, "pending": true}), + ) + .await?; + let mut out = Vec::new(); + for key in ["in", "pending"] { + if let Some(arr) = v.get(key).and_then(|x| x.as_array()) { + for item in arr { + out.push(TransferRow { + txid: json_str(item, "txid").unwrap_or_default(), + amount: json_amount(item), + address: json_str(item, "address").unwrap_or_default(), + }); + } + } + } + Ok(out) + } + + async fn rpc(&self, method: &str, params: serde_json::Value) -> Result { + let ep = self + .endpoint + .as_ref() + .ok_or_else(|| Error("not configured".into()))?; + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": "0", + "method": method, + "params": params, + }) + .to_string(); + let host_hdr = host_header(&ep.host, ep.port); + let req = format!( + "POST {} HTTP/1.1\r\nHost: {host_hdr}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + ep.path, + body.len() + ); + let raw = tokio::time::timeout(RPC_TIMEOUT, http_post(ep, req.as_bytes())) + .await + .map_err(|_| Error("wallet RPC timed out".into()))??; + parse_json_rpc(&raw) + } +} + +pub fn transfers_match(rows: &[TransferRow], txid: &str, amount: &str, address: &str) -> bool { + rows.iter().any(|r| { + r.txid == txid + || (!address.is_empty() + && r.address == address + && (amount.is_empty() || r.amount == amount)) + }) +} + +fn parse_http_url(url: &str) -> Result { + let rest = url + .strip_prefix("http://") + .ok_or_else(|| Error("wallet RPC must be http:// (no TLS)".into()))?; + if rest.contains("://") { + return Err(Error("wallet RPC must be http:// (no TLS)".into())); + } + let (hostport, path) = match rest.split_once('/') { + Some((hp, p)) => (hp, format!("/{p}")), + None => (rest, "/json_rpc".into()), + }; + let path = if path == "/" { + "/json_rpc".into() + } else { + path + }; + let (host, port) = parse_hostport(hostport)?; + if !allowed_host(&host) { + return Err(Error("wallet RPC host must be loopback or .onion".into())); + } + Ok(Endpoint { host, port, path }) +} + +fn parse_hostport(hostport: &str) -> Result<(String, u16)> { + if let Some(rest) = hostport.strip_prefix('[') { + let (host, rest) = rest + .split_once(']') + .ok_or_else(|| Error("invalid IPv6 host".into()))?; + let port = match rest.strip_prefix(':') { + Some(p) if !p.is_empty() => parse_port(p)?, + _ => 18083, + }; + if host.is_empty() { + return Err(Error("empty host".into())); + } + return Ok((host.to_string(), port)); + } + match hostport.rsplit_once(':') { + Some((h, p)) if !h.is_empty() && !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => { + Ok((h.to_string(), parse_port(p)?)) + } + _ if !hostport.is_empty() => Ok((hostport.to_string(), 18083)), + _ => Err(Error("empty host".into())), + } +} + +fn parse_port(p: &str) -> Result { + p.parse() + .map_err(|_| Error("invalid wallet RPC port".into())) +} + +fn allowed_host(host: &str) -> bool { + let h = host.trim(); + if h.to_ascii_lowercase().ends_with(".onion") { + return true; + } + if h.eq_ignore_ascii_case("localhost") { + return true; + } + h.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +fn host_header(host: &str, port: u16) -> String { + if host.contains(':') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +async fn http_post(ep: &Endpoint, req: &[u8]) -> Result> { + let mut stream = TcpStream::connect((ep.host.as_str(), ep.port)) + .await + .map_err(|e| Error(format!("wallet connect: {e}")))?; + stream + .write_all(req) + .await + .map_err(|e| Error(format!("wallet write: {e}")))?; + let mut buf = Vec::new(); + stream + .read_to_end(&mut buf) + .await + .map_err(|e| Error(format!("wallet read: {e}")))?; + Ok(buf) +} + +fn parse_json_rpc(raw: &[u8]) -> Result { + let text = std::str::from_utf8(raw).map_err(|_| Error("wallet RPC not UTF-8".into()))?; + let (head, body) = text + .split_once("\r\n\r\n") + .or_else(|| text.split_once("\n\n")) + .ok_or_else(|| Error("wallet RPC truncated".into()))?; + let status = head.lines().next().unwrap_or(""); + if status.contains(" 3") { + return Err(Error("wallet RPC redirect refused".into())); + } + if !status.contains(" 200 ") && !status.ends_with(" 200") { + return Err(Error(format!("wallet RPC HTTP {status}"))); + } + let json: serde_json::Value = + serde_json::from_str(body.trim()).map_err(|e| Error(format!("wallet RPC json: {e}")))?; + if let Some(err) = json.get("error") { + let msg = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("rpc error"); + return Err(Error(msg.into())); + } + json.get("result") + .cloned() + .ok_or_else(|| Error("wallet RPC missing result".into())) +} + +fn json_str(v: &serde_json::Value, key: &str) -> Result { + v.get(key) + .and_then(|x| x.as_str()) + .map(str::to_string) + .ok_or_else(|| Error(format!("wallet RPC missing {key}"))) +} + +fn json_amount(v: &serde_json::Value) -> String { + match v.get("amount") { + Some(serde_json::Value::Number(n)) => n + .as_u64() + .map(|x| x.to_string()) + .or_else(|| n.as_i64().map(|x| x.to_string())) + .unwrap_or_default(), + Some(serde_json::Value::String(s)) => s.clone(), + _ => String::new(), + } +} diff --git a/tests/pay.rs b/tests/pay.rs new file mode 100644 index 0000000..24fd6a4 --- /dev/null +++ b/tests/pay.rs @@ -0,0 +1,197 @@ +//! 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(dir.path()).expect("open"); + (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); +} diff --git a/tests/tui_chrome.rs b/tests/tui_chrome.rs index 9ef12bb..ddff62f 100644 --- a/tests/tui_chrome.rs +++ b/tests/tui_chrome.rs @@ -38,7 +38,9 @@ fn onion_glyph_is_nonempty() { fn help_overlay_lists_core_bindings() { let help = help_overlay_text(); assert!(!help.is_empty()); - for needle in ["Tab", "F2", "F3", "F4", "F5", "/profile", "Ctrl-Q", "?"] { + for needle in [ + "Tab", "F2", "F3", "F4", "F5", "/profile", "/pay", "/tip", "Ctrl-Q", "?", + ] { assert!(help.contains(needle), "help overlay missing {needle:?}"); } for line in help.lines() { diff --git a/tests/wallet.rs b/tests/wallet.rs new file mode 100644 index 0000000..6483f19 --- /dev/null +++ b/tests/wallet.rs @@ -0,0 +1,98 @@ +//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod. + +use onionwire::wallet::{self, Wallet}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +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")); +}