feat: Monero invoice/receipt frames, wallet RPC, /pay /tip
All checks were successful
ci / test (push) Successful in 2m50s
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:
parent
d304cc4c3b
commit
1a9b54d138
11 changed files with 1318 additions and 14 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2605,6 +2605,7 @@ dependencies = [
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"safelog",
|
"safelog",
|
||||||
|
"serde_json",
|
||||||
"snow",
|
"snow",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
218
src/node.rs
218
src/node.rs
|
|
@ -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
328
src/pay.rs
Normal 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()
|
||||||
|
}
|
||||||
108
src/store.rs
108
src/store.rs
|
|
@ -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)?,
|
||||||
|
|
|
||||||
88
src/tui.rs
88
src/tui.rs
|
|
@ -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
283
src/wallet.rs
Normal 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
197
tests/pay.rs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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
98
tests/wallet.rs
Normal 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"));
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue