feat: Monero invoice/receipt frames, wallet RPC, /pay /tip
All checks were successful
ci / test (push) Successful in 2m50s

Signed inv/rcp codecs keyed by identity, payments table with unverified
incoming receipts, optional JSON-RPC sidecar (loopback/.onion HTTP only),
and TUI /pay /tip. OnionWire still holds no spend keys.
This commit is contained in:
Sirius DevOps 2026-09-10 14:37:43 -04:00
parent d304cc4c3b
commit 1a9b54d138
No known key found for this signature in database
11 changed files with 1318 additions and 14 deletions

1
Cargo.lock generated
View file

@ -2605,6 +2605,7 @@ dependencies = [
"ratatui", "ratatui",
"rusqlite", "rusqlite",
"safelog", "safelog",
"serde_json",
"snow", "snow",
"tempfile", "tempfile",
"tokio", "tokio",

View file

@ -15,7 +15,7 @@ safelog = "0.9"
ed25519-dalek = { version = "2", features = ["rand_core"] } ed25519-dalek = { version = "2", features = ["rand_core"] }
rand = "0.8" rand = "0.8"
rusqlite = { version = "0.36", features = ["bundled"] } 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-cell = "0.46"
tor-hsservice = "0.46" tor-hsservice = "0.46"
tor-rtcompat = { version = "0.46", features = ["tokio"] } 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"] } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
x25519-dalek = { version = "2", features = ["static_secrets"] } x25519-dalek = { version = "2", features = ["static_secrets"] }
snow = "0.10" snow = "0.10"
serde_json = "1"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View file

@ -3,10 +3,12 @@ pub mod frame;
pub mod hs; pub mod hs;
pub mod loc; pub mod loc;
pub mod node; pub mod node;
pub mod pay;
pub mod profile; pub mod profile;
pub mod qr; pub mod qr;
pub mod session; pub mod session;
mod store; mod store;
pub mod tui; pub mod tui;
pub mod wallet;
pub use store::{Friend, FriendProfile, Message, SelfIdentity, Store}; pub use store::{Friend, FriendProfile, Message, Payment, PaymentWrite, SelfIdentity, Store};

View file

@ -13,10 +13,12 @@ use crate::dispatch::{self, Kind};
use crate::frame; use crate::frame;
use crate::hs::{self, Client, HS_PORT}; use crate::hs::{self, Client, HS_PORT};
use crate::loc; use crate::loc;
use crate::pay;
use crate::profile; use crate::profile;
use crate::qr; use crate::qr;
use crate::session::{self, Keys}; 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 struct RotateResult {
pub notified: usize, pub notified: usize,
@ -41,6 +43,7 @@ pub struct Node {
hs: Mutex<Option<HsHandle>>, hs: Mutex<Option<HsHandle>>,
onion: Mutex<String>, onion: Mutex<String>,
keys: Keys, keys: Keys,
wallet: Wallet,
} }
impl Node { impl Node {
@ -67,6 +70,7 @@ impl Node {
hs: Mutex::new(None), hs: Mutex::new(None),
onion: Mutex::new(onion), onion: Mutex::new(onion),
keys, keys,
wallet: Wallet::from_env(),
}); });
let rend = spawn_rend(Arc::clone(&node), rend); let rend = spawn_rend(Arc::clone(&node), rend);
*node.hs.lock().map_err(|e| e.to_string())? = Some(HsHandle { _svc: svc, 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 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<String, String> {
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> { pub async fn connect_onion(&self, onion: &str) -> Result<(), String> {
tokio::time::timeout( tokio::time::timeout(
Duration::from_secs(20), Duration::from_secs(20),
@ -404,9 +521,106 @@ impl Node {
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(()) 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( fn spawn_rend(

328
src/pay.rs Normal file
View file

@ -0,0 +1,328 @@
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
pub type Result<T> = std::result::Result<T, Error>;
#[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<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Receipt {
pub txid: String,
pub amount_atomic: String,
pub address: String,
pub ts: i64,
pub sig: Vec<u8>,
}
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<u128> {
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<String> {
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<Invoice> {
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<u8> {
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<Invoice> {
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::<i64>().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<Receipt> {
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<u8> {
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<Receipt> {
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::<i64>().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<u8> {
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<u8> {
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<Vec<u8>> {
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<Vec<u8>> {
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()
}

View file

@ -67,6 +67,29 @@ pub struct Message {
pub plaintext: Vec<u8>, pub plaintext: Vec<u8>,
} }
#[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<String>,
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 { impl Store {
pub fn open() -> Result<Self> { pub fn open() -> Result<Self> {
Self::open_at(&home_dir()?) Self::open_at(&home_dir()?)
@ -126,6 +149,18 @@ impl Store {
xmr_addr TEXT NOT NULL DEFAULT '', xmr_addr TEXT NOT NULL DEFAULT '',
updated_at INTEGER NOT NULL 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); INSERT OR IGNORE INTO self_profile (id) VALUES (1);
", ",
)?; )?;
@ -421,6 +456,65 @@ impl Store {
.map_err(Into::into) .map_err(Into::into)
} }
pub fn insert_payment(&self, friend_pk: &[u8], p: PaymentWrite<'_>) -> Result<i64> {
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<Vec<Payment>> {
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<bool> {
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<()> { pub fn append_message(&self, friend_pk: &[u8], dir: &str, plaintext: &[u8]) -> Result<()> {
if dir != "in" && dir != "out" { if dir != "in" && dir != "out" {
return Err(Error("dir must be in or out".into())); 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<Payment> {
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<FriendProfile> { fn profile_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<FriendProfile> {
Ok(FriendProfile { Ok(FriendProfile {
display_name: row.get(0)?, display_name: row.get(0)?,

View file

@ -59,13 +59,15 @@ OnionWire keys\n\
1 / 2 / 3 focus roster / chat / composer\n\ 1 / 2 / 3 focus roster / chat / composer\n\
j k or Up Down move or scroll focused pane\n\ j k or Up Down move or scroll focused pane\n\
g / G jump to top / bottom\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\ Esc close overlay, go back, clear composer\n\
F2 share QR\n\ F2 share QR\n\
F3 paste a friend QR\n\ F3 paste a friend QR\n\
F4 rotate onion locator\n\ F4 rotate onion locator\n\
F5 selected friend's profile (/who)\n\ F5 selected friend's profile (/who)\n\
/profile edit name, bio, Monero address\n\ /profile edit name, bio, Monero address\n\
/pay <xmr> [memo] invoice to receive\n\
/tip <xmr> [memo] pay selected friend\n\
? this help\n\ ? this help\n\
Ctrl-Q quit\n\ Ctrl-Q quit\n\
\n\ \n\
@ -141,11 +143,13 @@ pub enum WipeKind {
All, All,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlashCmd { pub enum SlashCmd {
Wipe(WipeKind), Wipe(WipeKind),
Profile, Profile,
Who, Who,
Pay { atomic: String, memo: String },
Tip { atomic: String, memo: String },
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -239,13 +243,37 @@ pub enum AppExit {
} }
pub fn parse_cmd(raw: &str) -> Option<SlashCmd> { pub fn parse_cmd(raw: &str) -> Option<SlashCmd> {
match raw.trim() { let s = raw.trim();
"/wipe" => Some(SlashCmd::Wipe(WipeKind::Messages)), match s {
"/wipe-all" => Some(SlashCmd::Wipe(WipeKind::All)), "/wipe" => return Some(SlashCmd::Wipe(WipeKind::Messages)),
"/profile" => Some(SlashCmd::Profile), "/wipe-all" => return Some(SlashCmd::Wipe(WipeKind::All)),
"/who" => Some(SlashCmd::Who), "/profile" => return Some(SlashCmd::Profile),
_ => None, "/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<WipeKind> { pub fn parse_slash(raw: &str) -> Option<WipeKind> {
@ -465,6 +493,8 @@ impl App {
let mut wipe_confirm = None; let mut wipe_confirm = None;
let mut profile_save = None; let mut profile_save = None;
let mut open_profile = false; let mut open_profile = false;
let mut pay_cmd = None;
let mut tip_cmd = None;
match &mut self.screen { match &mut self.screen {
Screen::Main => match key.code { Screen::Main => match key.code {
KeyCode::Tab => self.focus = self.focus.next(), KeyCode::Tab => self.focus = self.focus.next(),
@ -507,6 +537,14 @@ impl App {
self.composer.clear(); self.composer.clear();
self.who_open = true; 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 => {} None => {}
}, },
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
@ -591,6 +629,12 @@ impl App {
if let Some(draft) = profile_save { if let Some(draft) = profile_save {
self.save_profile(draft)?; 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) Ok(None)
} }
@ -768,6 +812,32 @@ impl App {
Ok(()) 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 { fn who_body(&self) -> String {
let Some(friend) = self.friends.get(self.selected) else { let Some(friend) = self.friends.get(self.selected) else {
return "no friend selected\n\nEsc closes".into(); return "no friend selected\n\nEsc closes".into();
@ -894,7 +964,7 @@ impl App {
f.render_widget(chat, panes[1]); f.render_widget(chat, panes[1]);
let cmd = if self.composer.is_empty() { let cmd = if self.composer.is_empty() {
"/wipe /wipe-all /profile /who".to_string() "/wipe /wipe-all /profile /who /pay /tip".to_string()
} else { } else {
self.composer.clone() self.composer.clone()
}; };

283
src/wallet.rs Normal file
View file

@ -0,0 +1,283 @@
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
pub type Result<T> = std::result::Result<T, Error>;
#[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<Endpoint>,
}
#[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<Self> {
Ok(Self {
endpoint: Some(parse_http_url(url)?),
})
}
pub fn configured(&self) -> bool {
self.endpoint.is_some()
}
pub async fn get_address(&self) -> Result<String> {
let v = self
.rpc("get_address", serde_json::json!({"account_index": 0}))
.await?;
json_str(&v, "address")
}
pub async fn create_address(&self) -> Result<String> {
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<String> {
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<Vec<TransferRow>> {
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<serde_json::Value> {
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<Endpoint> {
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<u16> {
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::<std::net::IpAddr>()
.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<Vec<u8>> {
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<serde_json::Value> {
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<String> {
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(),
}
}

197
tests/pay.rs Normal file
View file

@ -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);
}

View file

@ -38,7 +38,9 @@ fn onion_glyph_is_nonempty() {
fn help_overlay_lists_core_bindings() { fn help_overlay_lists_core_bindings() {
let help = help_overlay_text(); let help = help_overlay_text();
assert!(!help.is_empty()); 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:?}"); assert!(help.contains(needle), "help overlay missing {needle:?}");
} }
for line in help.lines() { for line in help.lines() {

98
tests/wallet.rs Normal file
View file

@ -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"));
}