[verified] fix(wallet): conjunctive receipt verify
Some checks failed
ci / test (pull_request) Failing after 35s
Some checks failed
ci / test (pull_request) Failing after 35s
Require non-empty txid AND amount AND address against one get_transfers row. Signed rcp alone never sets verified=1.
This commit is contained in:
parent
2b42864eba
commit
1a465581c2
3 changed files with 91 additions and 8 deletions
|
|
@ -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 user’s 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 user’s wallet policy.
|
||||
|
||||
## Backup file is the identity
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue