fix(wallet): conjunctive receipt verify #4

Merged
sirius merged 1 commit from wt/t_70fc1f81 into main 2026-09-10 23:28:15 +00:00
3 changed files with 91 additions and 8 deletions
Showing only changes of commit 1a465581c2 - Show all commits

View file

@ -30,7 +30,7 @@ A signed `prf` frame is shown to people who already have a session with you. App
## Monero sidecar is not a wallet
OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. Never trust a `rcp` frame without RPC confirmation (`verified` stays 0). Subaddress reuse is the users wallet policy.
OnionWire never holds spend keys. Optional `ONIONWIRE_WALLET_RPC` talks HTTP to a user-hosted `monero-wallet-rpc`. A Noise friend can sign any `rcp`; the signature proves who sent the claim, not that a payment happened. `verified=1` only after a conjunctive RPC match: one `get_transfers` row with the same non-empty `txid`, `amount`, and `address`. Incoming `rcp` stays `verified=0` if RPC is down, errors, or no exact row. Subaddress reuse is the users wallet policy.
## Backup file is the identity

View file

@ -140,12 +140,11 @@ impl Wallet {
}
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))
})
if txid.is_empty() || amount.is_empty() || address.is_empty() {
return false;
}
rows.iter()
.any(|r| r.txid == txid && r.amount == amount && r.address == address)
}
fn parse_http_url(url: &str) -> Result<Endpoint> {

View file

@ -1,9 +1,25 @@
//! M8: optional monero-wallet-rpc JSON client. Mock TCP only — no live monerod.
use onionwire::wallet::{self, Wallet};
use ed25519_dalek::SigningKey;
use onionwire::pay;
use onionwire::wallet::{self, TransferRow, Wallet};
use onionwire::{PaymentWrite, Store};
use rand::rngs::OsRng;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
fn row(txid: &str, amount: &str, address: &str) -> TransferRow {
TransferRow {
txid: txid.into(),
amount: amount.into(),
address: address.into(),
}
}
fn xmr_addr() -> String {
format!("8{}", "B".repeat(94))
}
fn json_rpc_ok(result: &str) -> String {
let body = format!(r#"{{"jsonrpc":"2.0","id":"0","result":{result}}}"#);
format!(
@ -96,3 +112,71 @@ async fn mock_get_transfers_matches_txid() {
));
assert!(!wallet::transfers_match(&rows, "nope", "1", "nope"));
}
#[test]
fn transfers_match_txid_only_wrong_amount_or_addr_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "deadbeef", "99", &addr));
let other = format!("8{}", "C".repeat(94));
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", &other));
}
#[test]
fn transfers_match_address_and_amount_wrong_txid_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "cafebabe", "5", &addr));
}
#[test]
fn transfers_match_honest_triple_is_true() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(wallet::transfers_match(&rows, "deadbeef", "5", &addr));
}
#[test]
fn transfers_match_empty_field_is_false() {
let addr = xmr_addr();
let rows = [row("deadbeef", "5", &addr)];
assert!(!wallet::transfers_match(&rows, "", "5", &addr));
assert!(!wallet::transfers_match(&rows, "deadbeef", "", &addr));
assert!(!wallet::transfers_match(&rows, "deadbeef", "5", ""));
let empty = [row("", "", "")];
assert!(!wallet::transfers_match(&empty, "", "", ""));
}
/// ingest_receipt inserts verified=0, then mark_verified iff transfers_match.
/// A Noise-signed rcp that cites an unrelated wallet row must stay unverified.
#[test]
fn ingest_signed_receipt_mismatched_wallet_history_stays_unverified() {
let dir = tempfile::tempdir().expect("tempdir");
let store = Store::open_at_with_passphrase(dir.path(), "onionwire-test").expect("open");
let sk = SigningKey::generate(&mut OsRng);
let pk = sk.verifying_key().to_bytes();
store.upsert_friend(&pk, "peer.onion", None).unwrap();
let addr = xmr_addr();
let rcp = pay::sign_receipt(&sk.to_bytes(), "unrelated", "5", &addr, 1).unwrap();
assert!(pay::verify_receipt(&pk, &rcp));
let id = store
.insert_payment(
&pk,
PaymentWrite {
dir: "in",
kind: "receipt",
amount_atomic: &rcp.amount_atomic,
address: &rcp.address,
memo: "",
txid: Some(&rcp.txid),
verified: false,
},
)
.unwrap();
let history = [row("unrelated", "99", &format!("8{}", "C".repeat(94)))];
if wallet::transfers_match(&history, &rcp.txid, &rcp.amount_atomic, &rcp.address) {
store.mark_verified(id).unwrap();
}
let rows = store.list_payments(&pk).unwrap();
assert!(!rows[0].verified);
}