From 7dfe07c79a005d82f8388411130e19ecbfec764f Mon Sep 17 00:00:00 2001 From: Dendi Suhubdy <4477337+dendisuhubdy@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:42:39 +0800 Subject: [PATCH 1/4] rand: a submitted transaction never exits 1 on a failed status poll Once rand_sendTransaction has answered with a hash, rand prints `submitted ` on stderr (kind = the node's action.kind, `transfer` for a plain bundle) before it waits. The commit and receipt waits retry polls that time out, are reset or get a node error, backing off 0.5 s -> 5 s, until the deadline; a wait that never sees the commit is ConfirmationUnknown, which rand exits 4 on ("submitted, confirmation unknown", the hash, the last poll's failure and a `rand tx ` hint). A rejection is still exit 1. Covers every command on the shared wait path: send, token create/mint/burn, program deploy/call/invoke, bond, bridge, multisig, perps and governance bundle-less actions, and faucet. New global --wait-timeout / RAND_WAIT_TIMEOUT. A rescan or height read that fails after a seen commit now says it committed. Exit codes documented in docs/cli.md. Fixes #141 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GFs5h141Ct6RTGGrCTQ1vy --- CHANGELOG.md | 9 +- crates/randprotocol-client/src/lib.rs | 377 +++++++++++++++--- crates/randprotocol-client/src/main.rs | 32 +- crates/randprotocol-client/src/wallet.rs | 12 +- crates/randprotocol-client/tests/cli_perp.rs | 35 ++ crates/randprotocol-client/tests/cli_rpc.rs | 30 ++ .../randprotocol-client/tests/common/mod.rs | 3 + docs/cli.md | 17 + 8 files changed, 458 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2036eb45..397ab299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,14 @@ stopped at v0.4 until 2026-09-30, when the entries v0.5 to v0.6.7 were written f ## Unreleased -Nothing yet. +v0.8.3 (wallet; client-only, no node or chain change): + +- **`rand` no longer exits 1 after a successful submit** (#141): `submitted ` is + printed on stderr as soon as the node accepts a transaction; status polls that time out or are + reset are retried with backoff until the wait's deadline; a wait that never sees the commit exits + **4** ("submitted, confirmation unknown", with a `rand tx ` hint). Exit 1 is a real + failure only. New global `--wait-timeout ` / `RAND_WAIT_TIMEOUT`. Exit codes in + `docs/cli.md`. ## v0.8.2 — 2026-10-27 (chain 21, node-only; the genesis-gated rules wait for the next cut) diff --git a/crates/randprotocol-client/src/lib.rs b/crates/randprotocol-client/src/lib.rs index aa69701f..3e40bc50 100644 --- a/crates/randprotocol-client/src/lib.rs +++ b/crates/randprotocol-client/src/lib.rs @@ -83,6 +83,98 @@ impl std::fmt::Display for SubmitRefused { impl std::error::Error for SubmitRefused {} +/// The exit code `rand` leaves when a transaction was **submitted** — `rand_sendTransaction` +/// answered with its hash — but the wait could not see it commit before its deadline (issue #141): +/// every status poll timed out, was reset or still said pending. The transaction may well commit; +/// exit 1 stays reserved for a real failure (a refusal, a rejection, a bad argument). +pub const CONFIRMATION_UNKNOWN_EXIT: i32 = 4; + +/// A wait that ended without seeing its submitted transaction commit (issue #141): the +/// transaction's fate is unknown, which is not the same thing as failed. `rand` exits +/// [`CONFIRMATION_UNKNOWN_EXIT`] on it; `err.downcast_ref::()` finds it under +/// any context. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConfirmationUnknown { + pub hash: Hash, + /// How long the wait ran. + pub waited: Duration, + /// The last poll's failure, `None` when the last poll answered (pending, or not yet stored). + pub last_error: Option, +} + +impl std::fmt::Display for ConfirmationUnknown { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "transaction {} submitted, confirmation unknown: not committed within {:.0?}", self.hash, self.waited)?; + if let Some(e) = &self.last_error { + write!(f, " (last status poll: {e})")?; + } + write!(f, "; it may still commit — check with `rand tx {}` before retrying", self.hash) + } +} + +impl std::error::Error for ConfirmationUnknown {} + +/// The node's name for an action — `rand_getTransaction`'s `action.kind` — except that a plain +/// bundle (`Action::None`) is a `transfer`, what `rand send` makes. `rand` prints it on the +/// `submitted ` line (issue #141). +pub fn action_kind(action: &randprotocol_core::Action) -> &'static str { + use randprotocol_core::Action as A; + match action { + A::None => "transfer", + A::Mint { .. } => "mint", + A::Deploy { .. } => "deploy", + A::Call { .. } => "call", + A::Bond { .. } => "bond", + A::Unbond { .. } => "unbond", + A::Withdraw { .. } => "withdraw", + A::BridgeAttest { .. } => "bridge_attest", + A::BridgeBurn { .. } => "bridge_burn", + A::RegisterAggregator { .. } => "register_aggregator", + A::UnbondAggregator { .. } => "unbond_aggregator", + A::WithdrawAggregator { .. } => "withdraw_aggregator", + A::SlashAggregator { .. } => "slash_aggregator", + A::Aggregate { .. } => "aggregate", + A::RegisterToken { .. } => "register_token", + A::TokenMint { .. } => "token_mint", + A::SetAuthority { .. } => "set_authority", + A::TokenBurn { .. } => "token_burn", + A::PauseMints { .. } => "pause_mints", + A::UnpauseMints { .. } => "unpause_mints", + A::RegisterBridgedToken { .. } => "register_bridged_token", + A::ListBacking { .. } => "list_backing", + A::RotatePqGuardians { .. } => "rotate_pq_guardians", + A::RotatePauseKey { .. } => "rotate_pause_key", + A::ClaimVested { .. } => "claim_vested", + A::RevokeVesting { .. } => "revoke_vesting", + A::BondVested { .. } => "bond_vested", + A::UnbondVested { .. } => "unbond_vested", + A::AdmitValidator { .. } => "admit_validator", + A::SlashEquivocation { .. } => "slash_equivocation", + A::RotatePqGuardiansV2 { .. } => "rotate_pq_guardians_v2", + A::RotatePauseKeyV2 { .. } => "rotate_pause_key_v2", + A::CancelRotation { .. } => "cancel_rotation", + A::Invoke { .. } => "invoke", + A::PerpDeposit { .. } => "perp_deposit", + A::PerpOrder { .. } => "perp_order", + A::PerpCancel { .. } => "perp_cancel", + A::PerpWithdraw { .. } => "perp_withdraw", + A::PerpOracle { .. } => "perp_oracle", + A::PerpStateProof { .. } => "perp_state_proof", + A::CreateMultisig { .. } => "create_multisig", + A::MultisigDeposit { .. } => "multisig_deposit", + A::MultisigPay { .. } => "multisig_pay", + A::MultisigRotate { .. } => "multisig_rotate", + A::SetRandPrice { .. } => "set_rand_price", + } +} + +/// `submitted `: what `rand` prints (to stderr, so no command's stdout changes) the +/// moment the node accepts a transaction, before any wait — so a wait that is cut short, or a +/// process that is killed, still leaves the hash behind. +pub fn submitted_line(tx: &Transaction, hash: &Hash) -> String { + format!("submitted {} {hash}", action_kind(&tx.action)) +} + /// A u64 amount off an RPC reply, however the node encoded it: a JSON number or a decimal /// string. /// @@ -531,6 +623,12 @@ pub struct RpcClient { scan_chunk: u64, /// How long a read (anything but a large upload) may take, body included ([`READ_TIMEOUT`]). read_timeout: Duration, + /// Print [`submitted_line`] to stderr when `rand_sendTransaction` accepts a transaction + /// (`rand` sets it; a library caller prints what it likes). Issue #141. + announce: bool, + /// `rand --wait-timeout`: every commit or receipt wait runs this long instead of its caller's + /// own allowance. `None`: each caller's. + wait_timeout: Option, } /// How many times a call refused for rate limiting is retried, with waits of `rate_limit_wait` @@ -635,6 +733,57 @@ pub fn upload_timeout(body_bytes: usize) -> Duration { (UPLOAD_SETUP + transfer).min(UPLOAD_TIMEOUT_CAP) } +/// The pace of a wait for a submitted transaction (issue #141): a poll every 300 ms while the +/// node answers, and after a failed poll a wait that doubles from 500 ms to 5 s, so a node that is +/// restarting or a link that is dropping is not hammered — reset by the next answer. Never sleeps +/// past the deadline. +struct PollBackoff { + /// The next wait after a failure. + after_failure: Duration, + /// Whether the last poll failed, and how. + last_error: Option, +} + +impl Default for PollBackoff { + fn default() -> PollBackoff { + PollBackoff { after_failure: POLL_RETRY_FIRST, last_error: None } + } +} + +const POLL_EVERY: Duration = Duration::from_millis(300); +const POLL_RETRY_FIRST: Duration = Duration::from_millis(500); +const POLL_RETRY_MAX: Duration = Duration::from_secs(5); + +impl PollBackoff { + fn answered(&mut self) { + self.after_failure = POLL_RETRY_FIRST; + self.last_error = None; + } + + fn failed(&mut self, e: anyhow::Error) { + self.last_error = Some(format!("{e:#}")); + } + + /// Sleep before the next poll, or end the wait as [`ConfirmationUnknown`] once `timeout` has + /// run out since `start`. + async fn sleep_or_give_up(&mut self, hash: &Hash, start: Instant, timeout: Duration) -> Result<()> { + let elapsed = start.elapsed(); + if elapsed >= timeout { + return Err(ConfirmationUnknown { hash: *hash, waited: elapsed, last_error: self.last_error.take() }.into()); + } + let pause = match self.last_error { + None => POLL_EVERY, + Some(_) => { + let p = self.after_failure; + self.after_failure = (p * 2).min(POLL_RETRY_MAX); + p + } + }; + tokio::time::sleep(pause.min(timeout - elapsed)).await; + Ok(()) + } +} + impl RpcClient { pub fn new(url: impl Into) -> RpcClient { RpcClient { @@ -648,9 +797,25 @@ impl RpcClient { rate_limit_wait: Duration::from_secs(1), scan_chunk: DEFAULT_SCAN_CHUNK, read_timeout: READ_TIMEOUT, + announce: false, + wait_timeout: None, } } + /// This client, printing `submitted ` to stderr as each transaction is accepted + /// ([`submitted_line`], issue #141). + pub fn with_announce(mut self) -> RpcClient { + self.announce = true; + self + } + + /// This client with every commit and receipt wait set to `timeout` (`rand --wait-timeout`), + /// whatever its caller asks for; `None` leaves each caller's. + pub fn with_wait_timeout(mut self, timeout: Option) -> RpcClient { + self.wait_timeout = timeout; + self + } + /// This client with another envelope-fetch width for a scan over heads (`--scan-chunk`; /// [`DEFAULT_SCAN_CHUNK`] otherwise): the wallet reads envelopes in aligned chunks of `chunk` /// leaves, so the node learns which chunk held a leaf of this wallet's and not which leaf; @@ -758,7 +923,13 @@ impl RpcClient { pub async fn send_transaction(&self, tx: &Transaction) -> Result { let v = self.call("rand_sendTransaction", json!([hex::encode(tx.encode())])).await?; - Hash::from_hex(v.as_str().unwrap_or("")).map_err(|e| anyhow!("bad hash in reply: {e}")) + let hash = Hash::from_hex(v.as_str().unwrap_or("")).map_err(|e| anyhow!("bad hash in reply: {e}"))?; + if self.announce { + // Before any wait (issue #141): from here on the transaction may commit whatever a + // later poll says, and this line is how the person — or a script — learns its hash. + eprintln!("{}", submitted_line(tx, &hash)); + } + Ok(hash) } /// The committed transaction itself, decoded from `rand_getRawTransaction` — what reading its @@ -807,55 +978,46 @@ impl RpcClient { /// v0.3, which has no such method and answers `-32601`, it falls back to the old /// `rand_getTransaction` loop for good — `legacy_status` remembers that so later calls do /// not pay for the round trip that will only fail again. + /// + /// Issue #141: the transaction was already accepted when this is called, so a poll that + /// fails — a timeout, a reset, a node error mid-restart — says nothing about it. Such a poll is + /// retried with backoff ([`PollBackoff`]) until the deadline, and a wait that ends without + /// seeing the commit is [`ConfirmationUnknown`], never a plain error. `rand --wait-timeout` + /// ([`RpcClient::with_wait_timeout`]) replaces `timeout`. pub async fn wait_for_transaction(&self, hash: &Hash, timeout: Duration) -> Result { use std::sync::atomic::Ordering; - if self.legacy_status.load(Ordering::Relaxed) { - return self.wait_for_transaction_legacy(hash, timeout).await; - } + let timeout = self.wait_timeout.unwrap_or(timeout); let start = Instant::now(); + let mut backoff = PollBackoff::default(); loop { - let status = match self.transaction_status(std::slice::from_ref(hash)).await { - Ok(s) => s, - Err(e) if is_method_not_found(&e) => { - self.legacy_status.store(true, Ordering::Relaxed); - return self.wait_for_transaction_legacy(hash, timeout).await; - } - Err(e) => return Err(e), - }; - match status.first().and_then(|s| s["status"].as_str()) { - Some("committed") => { - if let Some(r) = self.transaction(hash).await? { - return Ok(r); + let polled: Result> = if self.legacy_status.load(Ordering::Relaxed) { + // The pre-v0.3 wait: `rand_getTransaction` until it is committed. It never sees a + // rejection — that node has nowhere to report one. + self.transaction(hash).await + } else { + match self.transaction_status(std::slice::from_ref(hash)).await { + Err(e) if is_method_not_found(&e) => { + self.legacy_status.store(true, Ordering::Relaxed); + continue; } + Err(e) => Err(e), + Ok(status) => match status.first().and_then(|s| s["status"].as_str()) { + Some("committed") => self.transaction(hash).await, + Some("rejected") => { + let reason = status[0]["reason"].as_str().unwrap_or("refused"); + return Err(anyhow!("transaction {hash} rejected: {reason}")); + } + _ => Ok(None), + }, } - Some("rejected") => { - let reason = status[0]["reason"].as_str().unwrap_or("refused"); - return Err(anyhow!("transaction {hash} rejected: {reason}")); - } - _ => {} - } - if start.elapsed() > timeout { - return Err(anyhow!("transaction {hash} not committed within {timeout:?}")); - } - tokio::time::sleep(Duration::from_millis(300)).await; - } - } - - /// The pre-v0.3 wait: poll `rand_getTransaction` until it is committed or `timeout` elapses. - /// It never sees a rejection — that node has nowhere to report one — so it can only time out. - /// Shared by [`wait_for_transaction`](Self::wait_for_transaction)'s legacy fallback, so the - /// two paths keep one loop body between them. - async fn wait_for_transaction_legacy(&self, hash: &Hash, timeout: Duration) -> Result { - let start = Instant::now(); - loop { - if let Some(r) = self.transaction(hash).await? { - return Ok(r); - } - if start.elapsed() > timeout { - return Err(anyhow!("transaction {hash} not committed within {timeout:?}")); + }; + match polled { + Ok(Some(r)) => return Ok(r), + Ok(None) => backoff.answered(), + Err(e) => backoff.failed(e), } - tokio::time::sleep(Duration::from_millis(300)).await; + backoff.sleep_or_give_up(hash, start, timeout).await?; } } @@ -1131,17 +1293,20 @@ impl RpcClient { Ok(if v.is_null() { None } else { Some(v) }) } - /// Poll until a call's receipt is stored (the tx is committed) or `timeout` elapses. + /// Poll until a call's receipt is stored (the tx is committed) or `timeout` elapses. A failed + /// poll is retried and a wait that runs out is [`ConfirmationUnknown`], exactly as in + /// [`RpcClient::wait_for_transaction`] (issue #141). pub async fn wait_for_receipt(&self, tx: &Hash, timeout: Duration) -> Result { + let timeout = self.wait_timeout.unwrap_or(timeout); let start = Instant::now(); + let mut backoff = PollBackoff::default(); loop { - if let Some(r) = self.receipt(tx).await? { - return Ok(r); - } - if start.elapsed() > timeout { - return Err(anyhow!("no receipt for {tx} within {timeout:?}")); + match self.receipt(tx).await { + Ok(Some(r)) => return Ok(r), + Ok(None) => backoff.answered(), + Err(e) => backoff.failed(e), } - tokio::time::sleep(Duration::from_millis(300)).await; + backoff.sleep_or_give_up(tx, start, timeout).await.map_err(|e| e.context(format!("no receipt for {tx}")))?; } } @@ -1757,6 +1922,120 @@ mod tests { assert!(waited < timeout, "should fail fast, not wait out the timeout: {waited:?}"); } + // ------------------------------------------- a submitted transaction's wait (issue #141) + // + // Once `rand_sendTransaction` has answered with a hash, the transaction may commit whatever a + // status poll says afterwards. A poll that times out or is reset is retried with backoff until + // the wait's deadline, and a wait that ends without seeing the commit is + // `ConfirmationUnknown` — exit 4 in `rand` — never the generic failure. + + /// Polls that fail (reset, a node error, a stalled reply) are retried, and the wait still + /// returns the receipt once the node reports the commit. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_failing_status_poll_is_retried_until_the_commit() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use test_rpc::{rpc_fn, Reply}; + let polls = std::sync::Arc::new(AtomicUsize::new(0)); + let seen = polls.clone(); + let url = rpc_fn(move |m, _| match m { + "rand_getTransactionStatus" => match seen.fetch_add(1, Ordering::SeqCst) { + 0 => Reply::Drop, + 1 => Reply::Stall, + 2 => Reply::Err(-32603, "node loop closed"), + _ => Reply::Ok(json!([{ "status": "committed" }])), + }, + "rand_getTransaction" => Reply::Ok(json!({ "height": 7, "index": 1, "block_hash": "cc".repeat(32) })), + _ => Reply::Err(-32601, "unknown method"), + }) + .await; + let client = RpcClient::new(url).with_read_timeout(Duration::from_millis(200)); + let r = client.wait_for_transaction(&Hash([3; 32]), Duration::from_secs(20)).await.unwrap(); + assert_eq!((r.height, r.index), (7, 1)); + assert_eq!(polls.load(Ordering::SeqCst), 4, "three failed polls, then the commit"); + } + + /// A wait whose every poll fails ends at its deadline as `ConfirmationUnknown`, naming the hash + /// and `rand tx`, and carrying the last poll's failure. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_wait_that_never_sees_the_commit_is_confirmation_unknown() { + use test_rpc::{rpc_fn, Reply}; + let hash = Hash([4; 32]); + for reply in [|| Reply::Stall, || Reply::Drop, || Reply::Err(-32000, "db down"), || Reply::Ok(json!([{ "status": "pending" }]))] { + let url = rpc_fn(move |m, _| match m { + "rand_getTransactionStatus" => reply(), + _ => Reply::Err(-32601, "unknown method"), + }) + .await; + let client = RpcClient::new(url).with_read_timeout(Duration::from_millis(100)); + let started = Instant::now(); + let e = client.wait_for_transaction(&hash, Duration::from_millis(800)).await.unwrap_err(); + assert!(started.elapsed() < Duration::from_secs(5), "the deadline bounds the retries"); + let unknown = e.downcast_ref::().unwrap_or_else(|| panic!("not ConfirmationUnknown: {e:#}")); + assert_eq!(unknown.hash, hash); + let text = e.to_string(); + assert!(text.contains("confirmation unknown") && text.contains(&format!("rand tx {hash}")), "{text}"); + } + } + + /// A rejection is still a failure, not an unknown: the node said the transaction is refused. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_rejection_after_a_failed_poll_is_a_failure() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use test_rpc::{rpc_fn, Reply}; + let polls = std::sync::Arc::new(AtomicUsize::new(0)); + let url = rpc_fn(move |m, _| match m { + "rand_getTransactionStatus" if polls.fetch_add(1, Ordering::SeqCst) == 0 => Reply::Drop, + "rand_getTransactionStatus" => Reply::Ok(json!([{ "status": "rejected", "reason": "bad proof" }])), + _ => Reply::Err(-32601, "unknown method"), + }) + .await; + let e = RpcClient::new(url).wait_for_transaction(&Hash([5; 32]), Duration::from_secs(20)).await.unwrap_err(); + assert!(e.downcast_ref::().is_none(), "{e:#}"); + assert!(e.to_string().contains("rejected: bad proof"), "{e}"); + } + + /// The receipt wait (`rand call`, `rand program invoke`) retries the same way and ends the same + /// way. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_receipt_wait_retries_and_ends_in_confirmation_unknown() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use test_rpc::{rpc_fn, Reply}; + let polls = std::sync::Arc::new(AtomicUsize::new(0)); + let url = rpc_fn(move |m, _| match m { + "rand_getReceipt" if polls.fetch_add(1, Ordering::SeqCst) < 2 => Reply::Drop, + "rand_getReceipt" => Reply::Ok(json!({ "ok": true })), + _ => Reply::Err(-32601, "unknown method"), + }) + .await; + let r = RpcClient::new(url).wait_for_receipt(&Hash([6; 32]), Duration::from_secs(20)).await.unwrap(); + assert_eq!(r["ok"], true); + let url = rpc_fn(|_, _| Reply::Drop).await; + let e = RpcClient::new(url).wait_for_receipt(&Hash([6; 32]), Duration::from_millis(500)).await.unwrap_err(); + assert!(e.downcast_ref::().is_some(), "{e:#}"); + } + + /// `with_wait_timeout` (`rand --wait-timeout`) overrides every caller's own wait. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_wait_timeout_override_wins_over_the_callers() { + use test_rpc::{rpc_fn, Reply}; + let url = rpc_fn(|_, _| Reply::Ok(json!([{ "status": "pending" }]))).await; + let client = RpcClient::new(url).with_wait_timeout(Some(Duration::from_millis(300))); + let started = Instant::now(); + let e = client.wait_for_transaction(&Hash([7; 32]), Duration::from_secs(600)).await.unwrap_err(); + assert!(started.elapsed() < Duration::from_secs(5)); + assert!(e.downcast_ref::().is_some(), "{e:#}"); + } + + /// The kind `rand` prints on its `submitted ` line: the node's own name for the + /// action (`rand_getTransaction`'s `action.kind`), a plain bundle a transfer. + #[test] + fn the_submitted_line_names_the_action_as_the_node_does() { + assert_eq!(action_kind(&randprotocol_core::Action::None), "transfer"); + let tx = Transaction { chain_id: 1, bundle: None, action: randprotocol_core::Action::PauseMints { nonce: 1, signature: randprotocol_core::Signature::empty() } }; + assert_eq!(action_kind(&tx.action), "pause_mints"); + assert_eq!(submitted_line(&tx, &Hash([1; 32])), format!("submitted pause_mints {}", Hash([1; 32]))); + } + // ---------------------------------------------------- the call limits (Task 5) /// `rand_getProgramPublic`'s one hex string: each word as its four little-endian bytes, the diff --git a/crates/randprotocol-client/src/main.rs b/crates/randprotocol-client/src/main.rs index bff50fe3..ed6af89c 100644 --- a/crates/randprotocol-client/src/main.rs +++ b/crates/randprotocol-client/src/main.rs @@ -69,6 +69,11 @@ struct Cli { /// the node's page cap; `1` is refused — it would name the leaf. `docs/rpc.md`, scan heads. #[arg(long, global = true, value_name = "N", default_value_t = randprotocol_client::DEFAULT_SCAN_CHUNK, value_parser = scan_chunk_value)] scan_chunk: u64, + /// How long, in seconds, a command waits for its transaction to commit (or for a call's + /// receipt) before it gives up — overriding each command's own allowance. A wait that runs + /// out after the node accepted the transaction exits 4, "submitted, confirmation unknown". + #[arg(long, global = true, env = "RAND_WAIT_TIMEOUT", value_name = "SECS")] + wait_timeout: Option, #[command(subcommand)] cmd: Cmd, } @@ -2089,8 +2094,22 @@ async fn perp( Ok(()) } +/// `rand`'s exit codes: 0 done, 1 failed, 2 a usage error (clap's), [`STALE_READ_EXIT`] (3) a +/// stale `program invoke`, and [`randprotocol_client::CONFIRMATION_UNKNOWN_EXIT`] (4) a +/// transaction the node accepted whose commit the wait could not see (issue #141): the hash was +/// printed (`submitted `), and `rand tx ` says what became of it. #[tokio::main] async fn main() -> Result<()> { + match run().await { + Err(e) if e.downcast_ref::().is_some() => { + eprintln!("Error: {e:#}"); + std::process::exit(randprotocol_client::CONFIRMATION_UNKNOWN_EXIT); + } + r => r, + } +} + +async fn run() -> Result<()> { let cli = Cli::parse(); randprotocol_client::memory::set_skip(cli.skip_memory_check); // The thread pool a local proof runs on, sized before anything proves (`--threads`, else @@ -2103,7 +2122,10 @@ async fn main() -> Result<()> { eprintln!("warning: {e}"); } } - let rpc = RpcClient::new(cli.rpc.clone()).with_scan_chunk(cli.scan_chunk); + let rpc = RpcClient::new(cli.rpc.clone()) + .with_scan_chunk(cli.scan_chunk) + .with_announce() + .with_wait_timeout(cli.wait_timeout.map(Duration::from_secs)); match cli.cmd { Cmd::Keygen => { let w = Wallet::generate(); @@ -3509,8 +3531,12 @@ async fn main() -> Result<()> { report(&result.submission, "token registration"); println!("index {}, id {} ({})", result.index, result.id.to_hex(), randprotocol_core::token_id::encode(&result.id)); if !no_wait { - let committed = rpc.call("rand_getTransaction", serde_json::json!([result.submission.hash.to_hex()])).await?; - println!("committed at height {}", committed["height"]); + // The wait above saw the commit; a failed read of its height now is not a failure + // of the registration (issue #141). + match rpc.call("rand_getTransaction", serde_json::json!([result.submission.hash.to_hex()])).await { + Ok(committed) => println!("committed at height {}", committed["height"]), + Err(e) => eprintln!("warning: committed, but reading its height failed ({e:#}); `rand tx {}` shows it", result.submission.hash), + } } } Cmd::Token(TokenCmd::Mint { asset, to, amount, authority_key, fee, no_wait, cuda }) => { diff --git a/crates/randprotocol-client/src/wallet.rs b/crates/randprotocol-client/src/wallet.rs index c403cc2c..f07aad26 100644 --- a/crates/randprotocol-client/src/wallet.rs +++ b/crates/randprotocol-client/src/wallet.rs @@ -2936,7 +2936,8 @@ async fn settle( // notes spendable, where marking them here would strand them until the store is thrown // away and rebuilt. rpc.wait_for_transaction(hash, COMMIT_TIMEOUT).await?; - scan(rpc, w, store).await?; + // Committed: a rescan that fails now does not undo it, and says so (issue #141). + scan(rpc, w, store).await.with_context(|| format!("{hash} committed, but the rescan after it failed; `rand balance` rescans"))?; } else { // Nothing will confirm these for the caller, so they are held back rather than declared // spent: `pending` keeps them out of coin selection without the one-way write that @@ -7734,7 +7735,8 @@ pub(crate) mod tests { // The send goes through; the wait's very first call does not. c.fail = Some("rand_getTransactionStatus"); } - let rpc = serve(&chain).await; + // Issue #141: the failing poll is retried until the wait's deadline; a short one here. + let rpc = serve(&chain).await.with_wait_timeout(Some(std::time::Duration::from_millis(500))); let mut store = NoteStore::default(); let kp = Keypair::generate(); @@ -7757,8 +7759,10 @@ pub(crate) mod tests { .await .unwrap_err(); - // It is an `RpcError`, exactly as a refusal is — and it is not a `SubmitRefused`. - assert!(e.downcast_ref::().is_some(), "{e}"); + // Issue #141: the failing poll ends the wait as `ConfirmationUnknown` (its `RpcError` + // named inside) — and it is not a `SubmitRefused`. + assert!(e.downcast_ref::().is_some(), "{e}"); + assert!(format!("{e:#}").contains("injected failure"), "the poll's failure is named: {e:#}"); assert!(e.downcast_ref::().is_none(), "a post-send failure is not a refusal: {e}"); // The registration did reach the node, so the key is kept. assert_eq!(chain.lock().unwrap().sent.len(), 1, "the transaction was submitted"); diff --git a/crates/randprotocol-client/tests/cli_perp.rs b/crates/randprotocol-client/tests/cli_perp.rs index 724373c5..c2ea71e3 100644 --- a/crates/randprotocol-client/tests/cli_perp.rs +++ b/crates/randprotocol-client/tests/cli_perp.rs @@ -699,3 +699,38 @@ fn perp_prove_refuses_a_bad_fri_name_and_a_missing_job() { r.err ); } + +/// Issue #141, on a bundle-less submission (`perp order`, the governance path every signed +/// action shares): `submitted perp_order ` on stderr as soon as the node accepts it — stdout +/// keeps its `order ` contract — and a wait that never sees the commit exits 4, not 1. +#[test] +fn a_perp_order_whose_commit_cannot_be_seen_exits_4_after_naming_its_hash() { + let dir = tempfile::tempdir().unwrap(); + let key = keygen(&dir); + let trade = dir.path().join("t.json"); + assert!(rand(DEAD, &key, &["perp", "keygen", "--out", trade.to_str().unwrap()]).ok); + let node = fake(Box::new(|m, _| match m { + "rand_chainId" => Ok(json!(14)), + "rand_getPerps" => Ok(perps_state()), + "rand_getHead" => Ok(json!({ "height": 40 })), + "rand_getPerpAccount" => Ok(Value::Null), + "rand_sendTransaction" => Ok(json!("ab".repeat(32))), + "rand_getTransactionStatus" => Ok(json!([{ "status": "pending" }])), + _ => Err((-32601, "method not found".into())), + })); + let r = rand( + &node.url, + &key, + &[ + "--wait-timeout", "1", "perp", "order", "--trading-key", trade.to_str().unwrap(), "--market", "2", "--side", "buy", + "--size", "1", "--price", "5", + ], + ); + let hash = "ab".repeat(32); + assert_eq!(r.code, Some(4), "stdout {}\nstderr {}", r.out, r.err); + assert!(r.err.contains(&format!("submitted perp_order {hash}")), "{}", r.err); + let submitted_at = r.err.find("submitted perp_order").unwrap(); + let unknown_at = r.err.find("confirmation unknown").expect("the unknown is said"); + assert!(submitted_at < unknown_at, "the hash is printed before the wait ends: {}", r.err); + assert!(r.err.contains(&format!("rand tx {hash}")), "{}", r.err); +} diff --git a/crates/randprotocol-client/tests/cli_rpc.rs b/crates/randprotocol-client/tests/cli_rpc.rs index 8223ca6b..8d06b0bd 100644 --- a/crates/randprotocol-client/tests/cli_rpc.rs +++ b/crates/randprotocol-client/tests/cli_rpc.rs @@ -632,6 +632,36 @@ fn faucet_mints_to_the_wallet_address_and_waits_for_the_commit() { ); } +/// Issue #141: the node accepted the mint and every status poll then fails. `rand` must not +/// exit 1 (a failure the caller would retry): it exits 4, "submitted, confirmation unknown", with +/// the hash and a `rand tx` hint, after printing the hash before it waited. +#[test] +fn a_faucet_mint_whose_status_polls_fail_exits_4_with_the_hash() { + let hash = "aa".repeat(32); + let node = fake(Box::new(|m, _| match m { + "rand_mint" => Ok(json!("aa".repeat(32))), + "rand_getTransactionStatus" => Err((-32603, "node loop closed".into())), + _ => Err((-32601, "method not found".into())), + })); + let dir = tempfile::tempdir().unwrap(); + let key = keygen(&dir); + let r = rand(&node.url, &key, &["--wait-timeout", "2", "faucet", "--amount", "1"]); + assert_eq!(r.code, Some(4), "stdout {}\nstderr {}", r.out, r.err); + assert!(r.out.contains(&format!("submitted mint {hash}")), "{}", r.out); + assert!(r.err.contains("confirmation unknown") && r.err.contains(&format!("rand tx {hash}")), "{}", r.err); + assert!(r.err.contains("node loop closed"), "the last poll's failure is named: {}", r.err); + let polls = node.methods().iter().filter(|m| *m == "rand_getTransactionStatus").count(); + assert!(polls >= 2, "the failed poll was retried, not fatal: {polls}"); + + // A rejection is still exit 1. + let rejecting = fake(Box::new(|m, _| match m { + "rand_mint" => Ok(json!("aa".repeat(32))), + "rand_getTransactionStatus" => Ok(json!([{ "status": "rejected", "reason": "no" }])), + _ => Err((-32601, "method not found".into())), + })); + assert_eq!(rand(&rejecting.url, &key, &["faucet", "--amount", "1"]).code, Some(1)); +} + #[test] fn a_rejected_faucet_mint_reports_the_admission_reason() { let node = fake(Box::new(|m, _| match m { diff --git a/crates/randprotocol-client/tests/common/mod.rs b/crates/randprotocol-client/tests/common/mod.rs index f7f9fcfd..1691abf5 100644 --- a/crates/randprotocol-client/tests/common/mod.rs +++ b/crates/randprotocol-client/tests/common/mod.rs @@ -91,6 +91,8 @@ impl Fake { pub struct Run { pub ok: bool, + /// The exit code (`None` if a signal ended it). + pub code: Option, pub out: String, pub err: String, } @@ -106,6 +108,7 @@ pub fn rand(rpc: &str, key: &std::path::Path, args: &[&str]) -> Run { .expect("run rand"); Run { ok: o.status.success(), + code: o.status.code(), out: String::from_utf8_lossy(&o.stdout).into(), err: String::from_utf8_lossy(&o.stderr).into(), } diff --git a/docs/cli.md b/docs/cli.md index 23d758a1..86227eca 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -538,6 +538,23 @@ Global options, accepted before or after the subcommand: | `--threads ` | | every core | CPU threads a proof made on this machine uses; `RAYON_NUM_THREADS` when set and no flag; `0` refused. A wallet proves one bundle with a person waiting, so the default is every core (`docs/node-hardware.md` §6: 8 threads 17.1 s, 16 threads 13.9 s on an M4 Max) | | `--cpu` | | off | prove on the CPU even when a GPU is visible to a build with the CUDA backend; without `--cpu` or `--cuda` such a build takes the GPU and says so once on stderr | | `--scan-chunk ` | | `256` | how many leaves wide each envelope read is when a scan over scan heads (`rand_getScanLeaves`) fetches the leaves this wallet's keys opened (audit v10, RPC-201). The whole aligned chunk is read with `rand_getCommitments`, so the node learns which 256-leaf chunk held one of this wallet's leaves, not which leaf; the leaves between two of the wallet's in one chunk ride in the same read. `0` reads every envelope (what a wallet did before scan heads: the node learns nothing, at full bandwidth — the setting for a node you do not trust with your note count). `2`..`1000` (the node's page cap); `1` is refused, since it would read exactly the wanted leaf. Applies to every command that scans (`sync`, `balance`, `send`, …); see `docs/rpc.md`, scan heads, for the trade-off | +| `--wait-timeout ` | `RAND_WAIT_TIMEOUT` | each command's own (`send` and the other bundle commands 180 s, `faucet` 60 s, `call`/`invoke` receipts 120 s) | how long a command waits for its transaction to commit, or for a call's receipt, before it gives up. A wait that runs out after the node accepted the transaction exits **4** (below), never 1 | + +**Submitting, and exit codes (v0.8.3, issue #141).** The moment the node accepts a transaction +(`rand_sendTransaction` answers with its hash) `rand` prints `submitted ` on +**stderr** — `` is the node's own name for the action (`rand_getTransaction`'s +`action.kind`; a plain `send` is `transfer`) — before it waits, so the hash survives a cut-short +wait or a killed process; stdout is unchanged (`faucet` keeps its own `submitted mint …` line on +stdout). The wait then polls `rand_getTransactionStatus`: a poll that times out, is reset or gets a +node error is retried, every 0.5 s doubling to 5 s, until the wait's deadline (`--wait-timeout`). + +| exit | meaning | +|---|---| +| 0 | done | +| 1 | failed: a refusal, a rejection, a bad argument, an unreadable file | +| 2 | a usage error (the argument parser's) | +| 3 | `program invoke` only: a stale cell read; re-quote and retry | +| 4 | **submitted, confirmation unknown**: the node accepted the transaction but the wait never saw it commit. stderr names the hash and the last poll's failure, and ends with the hint to check `rand tx ` before retrying. The transaction may well commit, so a script must not resubmit on 4 without checking | | command | arguments | behaviour | |---|---|---| From f7edd663dc2e95948fc0063d6fa3689fe5e1542a Mon Sep 17 00:00:00 2001 From: Dendi Suhubdy <4477337+dendisuhubdy@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:44:37 +0800 Subject: [PATCH 2/4] rand token create: --initial and --fixed-supply read display units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--initial` and `--fixed-supply` now read display units at the token's own --decimals, like `token mint --amount`, `token burn` and `send --asset`: `--decimals 6 --initial 1000000` is a million tokens, not one. The base-unit form moves to `--initial-units` / `--fixed-supply-units`; clap refuses a flag together with its twin. A fractional amount past --decimals is refused, naming the flag and the -units alternative. On success `token create` echoes the mint as `minted ( units) to `. Behaviour change, listed in CHANGELOG (Unreleased, v0.8.3); docs/cli.md, docs/tokens.md and docs/shielded.md updated. Fixes #142 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GFs5h141Ct6RTGGrCTQ1vy --- CHANGELOG.md | 5 + crates/randprotocol-client/src/main.rs | 97 +++++++++++++++++-- .../tests/cli_token_bridge.rs | 9 ++ docs/cli.md | 2 +- docs/shielded.md | 2 +- docs/tokens.md | 10 +- 6 files changed, 109 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 397ab299..a4151843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ v0.8.3 (wallet; client-only, no node or chain change): **4** ("submitted, confirmation unknown", with a `rand tx ` hint). Exit 1 is a real failure only. New global `--wait-timeout ` / `RAND_WAIT_TIMEOUT`. Exit codes in `docs/cli.md`. +- **Behaviour change: `rand token create --initial` and `--fixed-supply` read display units** at + `--decimals` (#142), as `token mint --amount`, `token burn` and `send --asset` always have: + `--decimals 6 --initial 1000000` is now a million tokens, not one. The base-unit form is + `--initial-units` / `--fixed-supply-units`. Scripts that passed base units must switch to the + `-units` flags. `token create` echoes the mint as `minted ( units)`. ## v0.8.2 — 2026-10-27 (chain 21, node-only; the genesis-gated rules wait for the next cut) diff --git a/crates/randprotocol-client/src/main.rs b/crates/randprotocol-client/src/main.rs index ed6af89c..8c6752fc 100644 --- a/crates/randprotocol-client/src/main.rs +++ b/crates/randprotocol-client/src/main.rs @@ -618,17 +618,26 @@ enum TokenCmd { /// The 32-byte salt the asset id is over, hex. Random if not given. #[arg(long)] salt: Option, - /// Fixed supply: mint exactly this many units at registration, forever — `authority` is - /// `none`. Needs `--to`; mutually exclusive with `--authority-key-out`. - #[arg(long)] - fixed_supply: Option, + /// Fixed supply: mint exactly this much at registration, forever — `authority` is + /// `none`. In display units at `--decimals` (`--decimals 6 --fixed-supply 1000000` is a + /// million tokens), as `token mint --amount` reads them. Needs `--to`; mutually exclusive + /// with `--authority-key-out`. + #[arg(long, value_name = "AMOUNT", conflicts_with = "fixed_supply_units")] + fixed_supply: Option, + /// `--fixed-supply` in base units (`--decimals 6 --fixed-supply-units 1000000` is one token). + #[arg(long, value_name = "UNITS")] + fixed_supply_units: Option, /// A fresh Dilithium2 key file is written here (0600, refusing to overwrite) and becomes /// the token's mint authority. Mutually exclusive with `--fixed-supply`. #[arg(long)] authority_key_out: Option, - /// With `--authority-key-out`: mint this many units at registration too. Needs `--to`. - #[arg(long)] - initial: Option, + /// With `--authority-key-out`: mint this much at registration too, in display units at + /// `--decimals`, as `token mint --amount` reads them. Needs `--to`. + #[arg(long, value_name = "AMOUNT", conflicts_with = "initial_units")] + initial: Option, + /// `--initial` in base units. + #[arg(long, value_name = "UNITS")] + initial_units: Option, /// The initial mint's recipient. Required with `--fixed-supply` or `--initial`. #[arg(long)] to: Option, @@ -1167,6 +1176,18 @@ struct PayoutFile { /// re-quote the cells and retry rather than treat it as a hard refusal. const STALE_READ_EXIT: i32 = 3; +/// `token create --initial` / `--fixed-supply` (issue #142): the display form at the token's own +/// `decimals` — what `token mint --amount` reads — or its `-units` twin in base units (clap keeps +/// the two apart). `None` when neither is given. +fn token_create_amount(display: Option<&str>, units: Option, decimals: u8, flag: &str) -> Result> { + match (display, units) { + (Some(text), _) => wallet::parse_decimal(text, decimals) + .map(Some) + .map_err(|e| anyhow!("{flag}: {e} ({flag} is in display units at --decimals {decimals}; {flag}-units takes base units)")), + (None, units) => Ok(units), + } +} + /// A decimal amount in units (never RAND's decimals: a payout may be a token's). fn parse_units(s: &str, what: &str) -> Result { s.trim().parse::().with_context(|| format!("{what}: {s:?} is not a decimal amount in units")) @@ -3430,7 +3451,26 @@ async fn run() -> Result<()> { report(&s, "backing listing"); println!("listed chain {chain} token {} under asset {asset}, at list_nonce {}", hex::encode(token), state.list_nonce); } - Cmd::Token(TokenCmd::Create { name, symbol, decimals, salt, fixed_supply, authority_key_out, initial, to, program, fee, no_wait, cuda }) => { + Cmd::Token(TokenCmd::Create { + name, + symbol, + decimals, + salt, + fixed_supply, + fixed_supply_units, + authority_key_out, + initial, + initial_units, + to, + program, + fee, + no_wait, + cuda, + }) => { + // Issue #142: both amounts in display units at the token's own decimals, like every + // other token amount; the `-units` flags are the base-unit form. + let fixed_supply = token_create_amount(fixed_supply.as_deref(), fixed_supply_units, decimals, "--fixed-supply")?; + let initial = token_create_amount(initial.as_deref(), initial_units, decimals, "--initial")?; // Every cheap refusal — the flag combination — before any key file, network read or // proof. let program = program.as_deref().map(|p| Hash::from_hex(p).context("--program: invalid program id")).transpose()?; @@ -3530,6 +3570,10 @@ async fn run() -> Result<()> { }; report(&result.submission, "token registration"); println!("index {}, id {} ({})", result.index, result.id.to_hex(), randprotocol_core::token_id::encode(&result.id)); + if let (Some(units), Some(to)) = (initial_amount, &recipient) { + // Both forms, as `token mint` echoes them (issue #142). + println!("minted {} to {to}", memo_display::sanitize(&wallet::display_amount(units, decimals, &symbol))); + } if !no_wait { // The wait above saw the commit; a failed read of its height now is not a failure // of the registration (issue #141). @@ -4156,6 +4200,20 @@ mod tests { assert!(matches!(parse(&["sync", "--rescan"]), Ok(Cmd::Sync { rescan: true }))); } + /// Issue #142: `token create --initial` / `--fixed-supply` read display units at the token's + /// own `--decimals`, as `token mint --amount` does; the `-units` twins read base units. + #[test] + fn token_create_amounts_are_display_units_at_the_tokens_decimals() { + assert_eq!(token_create_amount(Some("1000000"), None, 6, "--initial").unwrap(), Some(1_000_000_000_000)); + assert_eq!(token_create_amount(Some("9.36"), None, 6, "--initial").unwrap(), Some(9_360_000)); + assert_eq!(token_create_amount(Some("5"), None, 0, "--fixed-supply").unwrap(), Some(5)); + assert_eq!(token_create_amount(None, Some(1_000_000), 6, "--initial").unwrap(), Some(1_000_000)); + assert_eq!(token_create_amount(None, None, 6, "--initial").unwrap(), None); + let e = token_create_amount(Some("1.5"), None, 0, "--fixed-supply").unwrap_err().to_string(); + assert!(e.contains("--fixed-supply") && e.contains("at most 0 decimals"), "{e}"); + assert!(token_create_amount(Some("99999999999"), None, 9, "--initial").is_err(), "past u64 is refused, not wrapped"); + } + /// The five `rand token` commands (T8b): `create` (both authority branches), `mint`, /// `set-authority` (both of `--new-key`/`--renounce`), `info` and `list`. #[test] @@ -4168,7 +4226,7 @@ mod tests { else { panic!("create --fixed-supply parses") }; - assert_eq!((name.as_str(), symbol.as_str(), decimals, fixed_supply, to_arg.as_deref()), ("Fixed Coin", "FIX", 6, Some(1_000_000), Some(to))); + assert_eq!((name.as_str(), symbol.as_str(), decimals, fixed_supply.as_deref(), to_arg.as_deref()), ("Fixed Coin", "FIX", 6, Some("1000000"), Some(to))); let Ok(Cmd::Token(TokenCmd::Create { authority_key_out, initial, .. })) = parse(&[ "token", "create", "--name", "Keyed Coin", "--symbol", "KEY", "--decimals", "8", @@ -4176,7 +4234,26 @@ mod tests { ]) else { panic!("create --authority-key-out parses") }; - assert_eq!((authority_key_out.as_deref(), initial), (Some(Path::new("authority.key.json")), Some(500))); + assert_eq!((authority_key_out.as_deref(), initial.as_deref()), (Some(Path::new("authority.key.json")), Some("500"))); + + // Issue #142: the base-unit forms are their own flags, and each excludes its display twin. + let Ok(Cmd::Token(TokenCmd::Create { fixed_supply, fixed_supply_units, .. })) = + parse(&["token", "create", "--name", "F", "--symbol", "F", "--decimals", "6", "--fixed-supply-units", "1000000", "--to", to]) + else { + panic!("create --fixed-supply-units parses") + }; + assert_eq!((fixed_supply, fixed_supply_units), (None, Some(1_000_000))); + let Ok(Cmd::Token(TokenCmd::Create { initial, initial_units, .. })) = parse(&[ + "token", "create", "--name", "K", "--symbol", "K", "--decimals", "6", "--authority-key-out", "a.json", "--initial-units", "7", "--to", to, + ]) else { + panic!("create --initial-units parses") + }; + assert_eq!((initial, initial_units), (None, Some(7))); + for both in [["--initial", "1", "--initial-units", "1"], ["--fixed-supply", "1", "--fixed-supply-units", "1"]] { + let mut args = vec!["token", "create", "--name", "K", "--symbol", "K", "--decimals", "6", "--to", to]; + args.extend(both); + assert!(parse(&args).is_err(), "{both:?} together is refused"); + } // Final review B3: `token mint` and `token burn` read AMOUNT in the token's display // units, like `send --asset` — one convention across the CLI. diff --git a/crates/randprotocol-client/tests/cli_token_bridge.rs b/crates/randprotocol-client/tests/cli_token_bridge.rs index 9ebf8123..3418a695 100644 --- a/crates/randprotocol-client/tests/cli_token_bridge.rs +++ b/crates/randprotocol-client/tests/cli_token_bridge.rs @@ -358,6 +358,15 @@ fn token_create_refuses_every_bad_flag_combination_before_the_network() { "--program is the token's whole authority", ), (vec!["--program", "nothex"], "--program: invalid program id"), + // Issue #142: display units at `--decimals` (2 here), like `token mint --amount`. + ( + vec!["--authority-key-out", out_s, "--initial", "1.234", "--to", &to], + "at most 2 decimals", + ), + ( + vec!["--fixed-supply", "1", "--fixed-supply-units", "100", "--to", &to], + "cannot be used with", + ), ]; for (extra, want) in cases { let mut args = vec![ diff --git a/docs/cli.md b/docs/cli.md index 86227eca..5e35a962 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -590,7 +590,7 @@ node error is retried, every 0.5 s doubling to 5 s, until the wait's deadline (` | `token list-backing` | `--asset `, `--chain`, `--token `, `--decimals`, `--pq ` (required), `--fee `, `--no-wait`, `--cuda` | add a backing to a bridged token (B4), authorised by a PQ guardian quorum (`rand-bridge-gov pq-list`'s file), paid by a fee bundle from this wallet (`--fee` default: the bundle base). The backing and the quorum at the node's `list_nonce` are checked before a key file is opened or anything proved; then the fee bundle is one four-slot hidden-asset bundle of RAND — the fee from slots 2–3, slots 0–1 dummies sealed to nobody, `burn_a`/`burn_r`/`burn_asset` zero — proved locally against the whole transaction, the `--pq` quorum included, so nobody can swap the co-signatures after the proof | | `bridge-burn ` | `--relayer-fee N`, `--fee ` (default `0.01`), `--no-wait`, `--cuda`, `--yes` | burn a bridged asset to another chain: check the chain has a bridge, holds `ASSET` in its registry, that the coin `TOKEN` on `TO_CHAIN` backs it and has at least `AMOUNT` locked, and that `AMOUNT` and the relayer fee are whole release units; then prove **one** bundle that spends the asset in slots 0–1 and burns exactly `AMOUNT` of it (`burn_a`, `burn_asset`) and pays the RAND fee from slots 2–3. `--relayer-fee` is a *portion* of `AMOUNT` paid to the relayer on the destination chain, not an extra charge. On a chain with a bridge fee (`rand_getBridgeState.fees`, v0.6.8, `docs/bridge.md` §25) it first prints the fee the chain keeps (in the asset) and the amount that will be released, and asks to confirm (`--yes` skips the question; a non-terminal stdin without it is refused); the locked and relayer-fee checks are then on the released amount | | `token burn ` | `--fee ` (default `0.001`), `--no-wait`, `--cuda` | destroy `AMOUNT` — in the token's display units, at its registry row's own `decimals`, as `send --asset` reads it — of an RPL token this wallet holds (`ASSET`: index, or `rpl1…`/hex resolved from the whole registry listing, as for `send`); its public supply drops by exactly that. One bundle, shaped like a bridge burn's. A bridged token is refused before proving: it leaves through `bridge-burn` | -| `token create` | `--name`, `--symbol`, `--decimals <0..=9>`, `--salt ` (random if not given), then either `--fixed-supply --to `, `--authority-key-out [--initial --to ]` or `--program ` (RPL-2: the token's mint authority is that program, and only its invokes mint or burn it; starts at zero supply, so no `--initial`; needs the chain's `program_state` section) (exactly one of the three; `--initial`/`--to` must be given together), `--fee ` (default: the bundle base plus the registry's `registration_fee`, refused when the node reports a registration fee above 10 RAND — pass `--fee` to pay it deliberately; the fee is printed before proving), `--no-wait`, `--cuda` | register a token (RPL spec §4) at the registry's next index. Fixed supply (`--fixed-supply`, mints once, forever — authority `none`) or `Key`-authorised (`--authority-key-out`: a fresh Dilithium2 key file, `rand-node keygen`'s own shape, written 0600 and refusing to overwrite — mintable again with `token mint`), with or without an initial mint. Reads `next_index` and `registration_fee` off `rand_getTokens` before proving; the initial mint's note (recipient, `r`, `time`, envelope) is built first, since the asset id binds the whole initial mint. The `Key` branch's fresh authority key is written to `.pending` right before the one call that can lose a race (`IndexMismatch`) is attempted — never before — then promoted to `FILE` once the chain accepts the registration, or discarded if the chain itself refuses it; a transport failure of unknown outcome leaves it at `.pending` rather than guessing. On `IndexMismatch` (another registration won the race) prints "another token took index N first — re-run to register at N+1"; on success prints the index and the id, hex and `rpl1…` | +| `token create` | `--name`, `--symbol`, `--decimals <0..=9>`, `--salt ` (random if not given), then either `--fixed-supply --to `, `--authority-key-out [--initial --to ]` or `--program ` (RPL-2: the token's mint authority is that program, and only its invokes mint or burn it; starts at zero supply, so no `--initial`; needs the chain's `program_state` section) (exactly one of the three; `--initial`/`--to` must be given together), `--fee ` (default: the bundle base plus the registry's `registration_fee`, refused when the node reports a registration fee above 10 RAND — pass `--fee` to pay it deliberately; the fee is printed before proving), `--no-wait`, `--cuda` | register a token (RPL spec §4) at the registry's next index. Fixed supply (`--fixed-supply`, mints once, forever — authority `none`) or `Key`-authorised (`--authority-key-out`: a fresh Dilithium2 key file, `rand-node keygen`'s own shape, written 0600 and refusing to overwrite — mintable again with `token mint`), with or without an initial mint. `--fixed-supply` and `--initial` are in **display units at `--decimals`**, as `token mint --amount` reads them (v0.8.3, issue #142: `--decimals 6 --initial 1000000` is a million tokens; before v0.8.3 it was one); `--fixed-supply-units ` / `--initial-units ` take base units instead, and each excludes its display twin. On success it echoes the mint both ways, `minted 1000000.000000 NTK (1000000000000 units) to `. Reads `next_index` and `registration_fee` off `rand_getTokens` before proving; the initial mint's note (recipient, `r`, `time`, envelope) is built first, since the asset id binds the whole initial mint. The `Key` branch's fresh authority key is written to `.pending` right before the one call that can lose a race (`IndexMismatch`) is attempted — never before — then promoted to `FILE` once the chain accepts the registration, or discarded if the chain itself refuses it; a transport failure of unknown outcome leaves it at `.pending` rather than guessing. On `IndexMismatch` (another registration won the race) prints "another token took index N first — re-run to register at N+1"; on success prints the index and the id, hex and `rpl1…` | | `token mint` | `--asset `, `--to `, `--amount ` (the token's display units, at its registry row's own `decimals` — as `send --asset` and `token burn` read it), `--authority-key `, `--fee ` (default `0.001`), `--no-wait`, `--cuda` | mint more of a `Key`-authorised token. Reads the token's row off `rand_getTokens` for its `mint_nonce`; refused up front, before any note or proof, if `--asset` is not `Key`-authorised or `--authority-key` is not that key. Signs `token_mint_message` (the note's commitment and the envelope's digest included) with the authority key | | `token set-authority` | `--asset `, `--authority-key `, then either `--new-key ` or `--renounce` (exactly one), `--fee ` (default `0.001`), `--no-wait`, `--cuda` | hand a `Key`-authorised token to another key, or renounce minting for good (`--renounce`: no key can ever mint it again). Same up-front refusals as `token mint`; signs `set_authority_message` | | `token info ` | | one token's full `rand_getTokens` row: name, symbol, decimals, authority (kind, and for `key` the public key and address), `mint_nonce`, total supply, `registered_at`, id (hex and `id_text`) and, if bridged, each backing (chain, token, decimals, locked, `mint_cap_per_day`, `minted_today`, `mint_day`). Resolved from the **whole** listing — never `rand_getToken` — the same privacy reason as `send`'s asset resolution, and held to the same checks (the row's id fields agree, its index is 1 or above; `token mint` and `token set-authority` read their row the same way) | diff --git a/docs/shielded.md b/docs/shielded.md index c5929162..7ba0eac8 100644 --- a/docs/shielded.md +++ b/docs/shielded.md @@ -403,7 +403,7 @@ the pool back together. | `rand bridge-rotate --pq ` | submit a guardian-set rotation with the PQ quorum; one RAND fee bundle | | `rand bridge-pause --sig ` / `rand bridge-unpause --pq ` | pause or lift bridge minting; bundle-less, fee-less, no key file | | `rand token register-bridged … --pq ` / `rand token list-backing … --pq ` | list a bridged token or a new backing after genesis; one RAND fee bundle | -| `rand token create --name … --symbol … --decimals … (--fixed-supply --to \| --authority-key-out [--initial --to ])` | register a token (RPL spec §4) at the next index — fixed supply or `Key`-authorised (a fresh Dilithium2 key file, `rand-node keygen`'s shape); one RAND fee bundle | +| `rand token create --name … --symbol … --decimals … (--fixed-supply --to \| --authority-key-out [--initial --to ])` | register a token (RPL spec §4) at the next index; amounts in display units at `--decimals` (`--fixed-supply-units`/`--initial-units` for base units) — fixed supply or `Key`-authorised (a fresh Dilithium2 key file, `rand-node keygen`'s shape); one RAND fee bundle | | `rand token mint --asset --to --amount --authority-key ` | mint more of a `Key`-authorised token, signed by its authority; one RAND fee bundle | | `rand token set-authority --asset --authority-key (--new-key \| --renounce)` | hand a `Key`-authorised token to another key, or renounce minting for good | | `rand token info ` / `rand token list` | a token's public row, or every token's, from the whole `rand_getTokens` listing | diff --git a/docs/tokens.md b/docs/tokens.md index 2b3458c1..0ba8df48 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -237,7 +237,7 @@ Full flag detail is `docs/cli.md`'s `rand` (wallet) table; this is the token-spe | command | what it does | |---|---| -| `rand token create --name … --symbol … --decimals <0..=9> --salt ` then either `--fixed-supply --to ` or `--authority-key-out [--initial --to ]` | register a token at the registry's next index: fixed supply (mints once, authority `none`) or `Key`-authorised (writes a fresh Dilithium2 key file), with or without an initial mint. Reads `next_index` and `registration_fee` off `rand_getTokens` before proving; prints the index and the id, hex and `rpl1…`, on success, or "another token took index N first — re-run to register at N+1" on `IndexMismatch` | +| `rand token create --name … --symbol … --decimals <0..=9> --salt ` then either `--fixed-supply --to ` or `--authority-key-out [--initial --to ]` (display units at `--decimals`; `--fixed-supply-units` / `--initial-units` for base units) | register a token at the registry's next index: fixed supply (mints once, authority `none`) or `Key`-authorised (writes a fresh Dilithium2 key file), with or without an initial mint. Reads `next_index` and `registration_fee` off `rand_getTokens` before proving; prints the index and the id, hex and `rpl1…`, on success, or "another token took index N first — re-run to register at N+1" on `IndexMismatch` | | `rand token mint --asset --to --amount --authority-key ` | mint more of a `Key`-authorised token, signed by its authority; `N` is in the token's display units (`1.5` of a 6-decimal token is 1 500 000 units); refused up front if `--asset` is not `Key`-authorised or `--authority-key` is the wrong key | | `rand token set-authority --asset --authority-key (--new-key \| --renounce)` | hand a `Key`-authorised token to another key, or renounce minting for good | | `rand token info ` | one token's full row: name, symbol, decimals, authority, `mint_nonce`, total supply, `registered_at`, id (hex and `rpl1…`), and — if bridged — every backing | @@ -249,14 +249,16 @@ Full flag detail is `docs/cli.md`'s `rand` (wallet) table; this is the token-spe `--asset` (on `send`, `token mint`, `token set-authority`, `token burn`, `token info`) accepts an index, the `rpl1…` text form or 64 hex, and is always resolved from the node's **whole** `rand_getTokens` listing, never a per-token lookup — a wallet about to move a token should not tell -the node which one it cares about (§11). Amounts (`send`, `token mint`, `token burn`, and a +the node which one it cares about (§11). Amounts (`send`, `token mint`, `token burn`, `token create --initial`/`--fixed-supply`, and a `randpay:` link's `amount`) are in the token's **display units**, read at its registry row's own `decimals`: `1.5` of a token with 6 decimals is 1 500 000 of its smallest unit, and more fractional digits than the token has is refused. On chain the amount is always the smallest-unit integer; `send`'s confirmation prints both (`1.500000 USDX (1500000 units)`), so a node that lied about `decimals` shows before anything is sent. (Before the address-sharing release `send ---asset`, `token mint` and `token burn` took whole smallest units; `token create`'s -`--fixed-supply`/`--initial` still do.) +--asset`, `token mint` and `token burn` took whole smallest units. `token create`'s +`--fixed-supply`/`--initial` did until v0.8.3 (issue #142); the base-unit form is now +`--fixed-supply-units`/`--initial-units`, and `token create` echoes the mint both ways, as `token +mint` does.) ## 11. The RPC From b6271842efa2d195f29cfc76f43924ff914aa91f Mon Sep 17 00:00:00 2001 From: Dendi Suhubdy <4477337+dendisuhubdy@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:48:42 +0800 Subject: [PATCH 3/4] wallet scan: read every envelope in fixed pages, whatever the wallet owns Since scan heads (v0.8.0) `leaf_page` read `rand_getScanLeaves`, trial- decrypted the heads, and then fetched full envelopes with `rand_getCommitments` only around the leaves `may_be_ours` matched: first by gap-merged span, from v0.8.2 by aligned 256-leaf chunk (RPC-201). Either way the node serving the scan saw, keyed by IP, which leaves (or chunks) were the wallet's, and a leaf index plus its height names the transaction. `leaf_page` now reads `rand_getCommitments(cursor, 500)` and nothing else, so the request sequence depends only on the cursor and the tree's size; ownership is decided locally after the page arrives. The heads/chunk machinery (`fetch_chunks`, `heads_page`, `may_be_ours`, `RpcClient::{with_scan_chunk, scan_chunk}`, DEFAULT/MAX_SCAN_CHUNK) is gone; `--scan-chunk` is still parsed (hidden) and ignored with a warning. `RpcClient::scan_leaves` stays for other callers. Tests: two wallets, one owning every kind of leaf and one owning none, make byte-identical request logs (every method, every param) over the same chain, with full pages only and no heads read; the same over 2 000 leaves of chain 21's 1 860-byte envelope, which also measures the cost: 3 885 B of JSON a leaf vs 1 720 B for a head (2.26x, the whole increase for a wallet whose notes sat in no chunk; 1.07x for this test's wallet, whose notes sat in 4 of 8 chunks). Docs: shielded.md's scanning bullet now says what the node sees; rpc.md's scan-heads section and changelog; cli.md's --scan-chunk row. Fixes #147 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GFs5h141Ct6RTGGrCTQ1vy --- CHANGELOG.md | 5 + crates/randprotocol-client/src/lib.rs | 28 -- crates/randprotocol-client/src/main.rs | 45 +-- crates/randprotocol-client/src/wallet.rs | 390 +++++++++-------------- docs/cli.md | 2 +- docs/rpc.md | 49 ++- docs/shielded.md | 12 +- 7 files changed, 195 insertions(+), 336 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4151843..3348a726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ v0.8.3 (wallet; client-only, no node or chain change): `--decimals 6 --initial 1000000` is now a million tokens, not one. The base-unit form is `--initial-units` / `--fixed-supply-units`. Scripts that passed base units must switch to the `-units` flags. `token create` echoes the mint as `minted ( units)`. +- **Privacy: the scan no longer reveals which leaves are the wallet's** (#147): `rand` pages + `rand_getCommitments` over the whole range in fixed 500-row pages and decides ownership locally, + so its requests do not depend on what it owns; it no longer reads `rand_getScanLeaves`. Cost: at + most ~2.26× the bytes of the v0.8.0–v0.8.2 heads scan (3 885 vs 1 720 bytes of JSON a leaf on + chain 21's envelope). `--scan-chunk` is accepted and ignored, with a warning. ## v0.8.2 — 2026-10-27 (chain 21, node-only; the genesis-gated rules wait for the next cut) diff --git a/crates/randprotocol-client/src/lib.rs b/crates/randprotocol-client/src/lib.rs index 3e40bc50..e73b462f 100644 --- a/crates/randprotocol-client/src/lib.rs +++ b/crates/randprotocol-client/src/lib.rs @@ -583,16 +583,7 @@ pub fn usd_subsidy_of(v: &Value) -> Option RpcClient { - self.scan_chunk = chunk; - self - } - - /// The envelope-fetch width ([`RpcClient::with_scan_chunk`]). - pub fn scan_chunk(&self) -> u64 { - self.scan_chunk - } - /// This client with another read timeout (tests: milliseconds instead of [`READ_TIMEOUT`]). pub fn with_read_timeout(mut self, timeout: Duration) -> RpcClient { self.read_timeout = timeout; diff --git a/crates/randprotocol-client/src/main.rs b/crates/randprotocol-client/src/main.rs index 8c6752fc..7cd894f3 100644 --- a/crates/randprotocol-client/src/main.rs +++ b/crates/randprotocol-client/src/main.rs @@ -62,13 +62,10 @@ struct Cli { /// (`rand-prover run` has the same flag). Without it the proof is refused before any work. #[arg(long, global = true, env = "RAND_SKIP_MEMORY_CHECK")] skip_memory_check: bool, - /// How many leaves wide each envelope read is when a scan over scan heads fetches the leaves - /// this wallet's keys opened: the whole aligned chunk is read, so the node learns which - /// chunk held one of this wallet's leaves and not which leaf. `0` reads every envelope (what - /// a wallet did before scan heads: the node learns nothing, at full bandwidth). At most 1000, - /// the node's page cap; `1` is refused — it would name the leaf. `docs/rpc.md`, scan heads. - #[arg(long, global = true, value_name = "N", default_value_t = randprotocol_client::DEFAULT_SCAN_CHUNK, value_parser = scan_chunk_value)] - scan_chunk: u64, + /// Ignored since v0.8.3 (issue #147): every scan reads every envelope, in fixed pages, so the + /// node learns nothing about which leaves are this wallet's. Accepted so old scripts still run. + #[arg(long, global = true, value_name = "N", hide = true)] + scan_chunk: Option, /// How long, in seconds, a command waits for its transaction to commit (or for a call's /// receipt) before it gives up — overriding each command's own allowance. A wait that runs /// out after the node accepted the transaction exits 4, "submitted, confirmation unknown". @@ -78,18 +75,6 @@ struct Cli { cmd: Cmd, } -/// `--scan-chunk`'s range: `0` (every envelope) or 2..=1000 (the node's page cap); `1` would -/// fetch exactly the wanted leaf, which is what the chunk exists to avoid (audit v10, RPC-201). -fn scan_chunk_value(s: &str) -> std::result::Result { - let n: u64 = s.parse().map_err(|_| format!("`{s}` is not a number of leaves"))?; - match n { - 0 => Ok(0), - 1 => Err("a chunk of 1 reads exactly the wanted leaf and hides nothing; use 0 to read every envelope, or 2 or more".into()), - n if n > randprotocol_client::MAX_SCAN_CHUNK => Err(format!("at most {} leaves, the node's page cap", randprotocol_client::MAX_SCAN_CHUNK)), - n => Ok(n), - } -} - #[derive(Subcommand)] enum Cmd { /// Create a new spend-key file (refuses to overwrite). @@ -2143,8 +2128,10 @@ async fn run() -> Result<()> { eprintln!("warning: {e}"); } } + if cli.scan_chunk.is_some() { + eprintln!("warning: --scan-chunk is ignored since v0.8.3: a scan reads every envelope, in fixed pages, so the node cannot tell which leaves are yours (issue #147)"); + } let rpc = RpcClient::new(cli.rpc.clone()) - .with_scan_chunk(cli.scan_chunk) .with_announce() .with_wait_timeout(cli.wait_timeout.map(Duration::from_secs)); match cli.cmd { @@ -4176,19 +4163,15 @@ mod tests { assert!(e.contains("--expect-to rand1nope"), "{e}"); } - /// `--scan-chunk` (audit v10, RPC-201) is global, so every command that scans takes it: 0 or - /// 2..=1000, 256 by default; 1 and anything past the node's page cap are refused at parse. + /// `--scan-chunk` (audit v10, RPC-201) is still a global flag, hidden: accepted and ignored. #[test] - fn scan_chunk_is_global_and_bounded() { + fn scan_chunk_is_still_accepted_and_ignored() { + // Issue #147: an old script's `--scan-chunk N` still parses (and is warned about); it no + // longer changes what a scan reads. let chunk = |args: &[&str]| Cli::try_parse_from([&["rand"], args].concat()).map(|c| c.scan_chunk); - assert_eq!(chunk(&["sync"]).unwrap(), randprotocol_client::DEFAULT_SCAN_CHUNK); - assert_eq!(chunk(&["sync", "--scan-chunk", "0"]).unwrap(), 0); - assert_eq!(chunk(&["--scan-chunk", "64", "balance"]).unwrap(), 64); - assert_eq!(chunk(&["sync", "--scan-chunk", "1000"]).unwrap(), 1000); - for bad in ["1", "1001", "many"] { - let err = chunk(&["sync", "--scan-chunk", bad]).unwrap_err().to_string(); - assert!(err.contains("scan-chunk"), "{bad}: {err}"); - } + assert_eq!(chunk(&["sync"]).unwrap(), None); + assert_eq!(chunk(&["sync", "--scan-chunk", "0"]).unwrap(), Some(0)); + assert_eq!(chunk(&["--scan-chunk", "64", "balance"]).unwrap(), Some(64)); } /// `rand sync` scans from where the store left off; `--rescan` starts the store over first diff --git a/crates/randprotocol-client/src/wallet.rs b/crates/randprotocol-client/src/wallet.rs index f07aad26..cedabca8 100644 --- a/crates/randprotocol-client/src/wallet.rs +++ b/crates/randprotocol-client/src/wallet.rs @@ -21,7 +21,7 @@ use crate::prover::RemoteProver; use crate::tree::LocalTree; -use crate::{AssetRow, ChainLimits, CommitmentRow, RpcClient, ScanLeaf}; +use crate::{AssetRow, ChainLimits, CommitmentRow, RpcClient}; use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; @@ -41,7 +41,7 @@ use randprotocol_zkvm::executor::{prove_auth, prove_bundle_for, ZkExecutor}; use randprotocol_zkvm::hidden::{self, HiddenDigestInput, HiddenDigestInputV3, HiddenOutput, A_SLOTS, SLOTS}; use randprotocol_zkvm::machine::{Backend, FriProfile}; use randprotocol_zkvm::notes::{Note, SpendKey, ViewingKey}; -use randprotocol_zkvm::viewing::{EnvelopeHead, ScanKey, TxKey}; +use randprotocol_zkvm::viewing::{ScanKey, TxKey}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -1238,110 +1238,21 @@ enum ScanPass { RebuildTree, } -/// Whether [`classify`] could find anything in a leaf whose envelope has this head: a head of -/// exactly [`EnvelopeHead`]'s size that one of `w`'s keys opens, or a head of any other non-zero -/// length, which is read in full rather than guessed at. An empty head opens for nobody. -fn may_be_ours(w: &Wallet, leaf: &ScanLeaf) -> bool { - if leaf.head.is_empty() { - return false; - } - match EnvelopeHead::from_bytes(&leaf.head) { - Some(head) => head.receiver_key(leaf.cm, &w.scan_key).is_some() || head.sender_key(leaf.cm, &w.scan_key).is_some(), - None => true, - } -} - -/// The `rand_getCommitments` reads — `(from, limit)` — that fetch the envelopes of `wanted` -/// (sorted leaf indices) at a fetch width of `chunk` leaves (audit v10, RPC-201). Each read is one -/// aligned chunk, `from` a multiple of `chunk` and `limit` the whole chunk, so the node learns -/// which chunk held a leaf of the wallet's, not which leaf (a partial tip chunk narrows that; docs/rpc.md): a read is never one wanted leaf -/// (for a chunk of at least 2), and the leaves between two wanted ones in a chunk ride in the -/// same read — the gap merging the scan always did, now inside the chunk. A chunk is read once -/// however many wanted leaves it holds; a read past the tree's end simply comes back short. -/// `chunk` is at least 1 here: the caller reads the page in full for `0`. -pub(crate) fn fetch_chunks(wanted: &[u64], chunk: u64) -> Vec<(u64, usize)> { - // (Callers page by `heads_page`, so every wanted leaf of one page lies in chunks wholly inside it.) - debug_assert!(chunk >= 1, "a zero chunk reads the page in full"); - let chunk = chunk.max(1); - let mut reads: Vec<(u64, usize)> = Vec::new(); - for &index in wanted { - let start = index - index % chunk; - if reads.last().is_none_or(|(from, _)| *from != start) { - reads.push((start, chunk as usize)); - } - } - reads -} - -/// The heads page [`leaf_page`] reads for a cursor at `from` under a scan chunk of `chunk` -/// (`chunk >= 1`): `(start, limit)`, `start` the chunk boundary at or below `from` and `limit` the -/// smallest multiple of `chunk` that is at least [`PAGE`] (audit v10, RPC-201 review). Pages then -/// begin and end on chunk boundaries, so no chunk straddles two pages — a chunk read during a page -/// says only "a leaf of this chunk", never "of the part of it this page covers" — and each chunk is -/// read at most once per pass. Within the 1000-row cap for every legal chunk (≤ 1000). -pub(crate) fn heads_page(from: u64, chunk: u64) -> (u64, usize) { - let chunk = chunk.max(1); - let start = from - from % chunk; - let limit = (PAGE as u64).div_ceil(chunk) * chunk; - (start, limit as usize) -} - -/// One page of leaves from `from`, as [`offer_row`] takes them. Against a node that serves scan -/// heads, the page is read as heads and only the leaves [`may_be_ours`] — and the ones `rebuilt` -/// does not already account for — are read again in full, by whole aligned chunks of -/// [`RpcClient::scan_chunk`] leaves ([`fetch_chunks`]; RPC-201), so the node learns at most -/// which chunk held one of the wallet's leaves; every other row of the page carries an empty -/// envelope, which [`classify`] skips exactly as it would have skipped the real one. A scan chunk -/// of `0`, or an older node, reads the page in full. -async fn leaf_page(rpc: &RpcClient, w: &Wallet, from: u64, rebuilt: &BTreeMap) -> Result> { - let chunk = rpc.scan_chunk(); - if chunk == 0 { - return rpc.commitments(from, PAGE).await; - } - // RPC-201: the heads page starts on the chunk boundary at or below the cursor and spans whole - // chunks (`heads_page`); the leaves under the cursor were offered by the pass before and are - // dropped here, unread in full. - let (start, limit) = heads_page(from, chunk); - let Some(mut leaves) = rpc.scan_leaves(start, limit).await? else { - return rpc.commitments(from, PAGE).await; - }; - let served = leaves.len(); - leaves.retain(|l| l.index >= from); - // N4: a non-empty page with nothing at or past the cursor may be the tip — or a node (or a - // relay with a smaller row cap) that stopped short of the cursor, which must not end the pass - // silently. Ask once more from the cursor itself: empty, it is the tip; otherwise those rows - // are the page (unaligned, against such a node only). - if served > 0 && leaves.is_empty() { - let Some(rest) = rpc.scan_leaves(from, limit - (from - start) as usize).await? else { - return rpc.commitments(from, PAGE).await; - }; - leaves = rest; - leaves.retain(|l| l.index >= from); - } - let wanted: Vec = leaves.iter().filter(|l| !rebuilt.contains_key(&l.cm) && may_be_ours(w, l)).map(|l| l.index).collect(); - let mut full: BTreeMap = BTreeMap::new(); - for (start, span) in fetch_chunks(&wanted, chunk) { - for row in rpc.commitments(start, span).await? { - // Rows of the chunk outside this page are dropped: the page before read them, or the - // page after will — the chunk is read whole for the node's sake, not the wallet's. - full.insert(row.index, row); - } - } - leaves - .into_iter() - .map(|l| match full.remove(&l.index) { - // The node's two answers for one leaf must agree, or its envelope is not this leaf's. - Some(row) if row.cm == l.cm && row.height == l.height => Ok(row), - Some(_) => Err(anyhow!("getCommitments and getScanLeaves disagree about leaf {}", l.index)), - None if wanted.binary_search(&l.index).is_ok() => Err(anyhow!("getCommitments did not serve leaf {}", l.index)), - None => Ok(CommitmentRow { - index: l.index, - cm: l.cm, - envelope: Envelope { kem_ct: Vec::new(), to_receiver: Vec::new(), to_sender: Vec::new(), body: Vec::new() }, - height: l.height, - }), - }) - .collect() +/// One page of leaves from `from`, envelopes and all, as [`offer_row`] takes them: one +/// `rand_getCommitments(from, PAGE)` read. +/// +/// Issue #147: the read is the same for every wallet. Which leaves are this wallet's is decided +/// here, by trial decryption, after the whole page has arrived — never by what is asked for — so +/// the request sequence a scan makes depends only on the cursor and the tree's size, and a node +/// (keyed by the caller's IP) learns nothing about which leaves the wallet owns. The scan-heads +/// path (v0.8.0–v0.8.2) read `rand_getScanLeaves` and then fetched full envelopes only around the +/// leaves a key opened — even in aligned chunks (audit v10, RPC-201) that told the node which +/// chunks held the wallet's notes, and a leaf index plus its height names the transaction. Full +/// pages cost at most ~2.26× the bytes of the heads path, the cost for a wallet whose notes sit in +/// no chunk, and less the more chunks held one (measured in +/// `a_scan_reads_the_same_full_pages_for_every_wallet_and_measures_their_cost`). +async fn leaf_page(rpc: &RpcClient, from: u64) -> Result> { + rpc.commitments(from, PAGE).await } /// One scan pass: page every commitment this wallet has not seen yet, growing the wallet's own @@ -1358,7 +1269,7 @@ async fn scan_pass(rpc: &RpcClient, w: &Wallet, store: &mut NoteStore) -> Result let mut tip: Option = None; loop { - let rows = leaf_page(rpc, w, store.scanned_index, &rebuilt).await?; + let rows = leaf_page(rpc, store.scanned_index).await?; if rows.is_empty() { break; } @@ -1387,7 +1298,7 @@ async fn scan_pass(rpc: &RpcClient, w: &Wallet, store: &mut NoteStore) -> Result // deposit is in the store from then on. let mut from = 0; while !rebuilt.is_empty() { - let rows = leaf_page(rpc, w, from, &rebuilt).await?; + let rows = leaf_page(rpc, from).await?; if rows.is_empty() { // Every leaf there is has been offered and some rebuilt note still has no leaf: the // node rendered a public-note transaction whose commitment its own tree does not hold. @@ -5415,159 +5326,124 @@ pub(crate) mod tests { c } - /// The scan-head path (`rand_getScanLeaves`, bodies fetched only for the leaves a key opens) - /// ends with exactly the store the full path (`rand_getCommitments` for every leaf) ends with, - /// and reads far fewer full envelopes doing it. + /// Issue #147: the scan's requests must not depend on which leaves are the wallet's. Two + /// wallets — one that owns leaves all over this chain (received, sent, change, a dummy, an odd + /// head, public notes) and one that owns none — scanning the same chain from an empty store + /// make exactly the same calls with exactly the same parameters, every method included; and the + /// leaves are read only as whole fixed pages, never as heads. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_scan_over_heads_finds_exactly_what_a_full_scan_finds() { + async fn two_wallets_make_identical_requests_whatever_they_own() { let me = Wallet::from_spend_key(SpendKey([13; 8])); + let nobody = Wallet::from_spend_key(SpendKey([99; 8])); let mut c = every_kind_of_leaf(&me); c.headers_carry_public_notes = true; - let leaves = c.leaves.len(); + c.scan_leaves = true; + let leaves = c.leaves.len() as u64; let chain = Arc::new(Mutex::new(c)); + let mut logs = Vec::new(); let mut stores = Vec::new(); - let mut served = Vec::new(); - // The full path; heads at the default chunk (wider than this chain: one read of everything); - // heads at a 4-leaf chunk; heads with chunk 0 (every envelope, the legacy privacy). - for (heads, chunk) in [(false, crate::DEFAULT_SCAN_CHUNK), (true, crate::DEFAULT_SCAN_CHUNK), (true, 4), (true, 0)] { - { - let mut c = chain.lock().unwrap(); - (c.scan_leaves, c.full_rows_served, c.scan_leaf_asks) = (heads, 0, 0); - c.commitment_reads.clear(); - } - // A client of its own each time: the fallback is remembered per client. - let rpc = serve(&chain).await.with_scan_chunk(chunk); + for w in [&me, &nobody] { + chain.lock().unwrap().requests.clear(); + let rpc = serve(&chain).await; let mut store = NoteStore::default(); - scan(&rpc, &me, &mut store).await.unwrap(); - assert_eq!(store.scanned_index, leaves as u64); - let c = chain.lock().unwrap(); - served.push((c.full_rows_served, c.scan_leaf_asks, c.commitment_reads.clone())); - stores.push(serde_json::to_value(&store).unwrap()); - } - assert!(stores.iter().all(|s| *s == stores[0]), "the same notes, sent rows, cursor and tree every way"); - let notes = stores[1]["notes"].as_array().unwrap(); - assert_eq!(notes.len(), 3 + 2 + 1 + 1, "three public notes, two fundings, the change, the odd-head note"); - assert_eq!(stores[1]["sent"].as_array().unwrap().len(), 1); - let (legacy_rows, _, _) = &served[0]; - assert!(*legacy_rows >= leaves, "the full path reads every envelope"); - // Wanted in full: the two fundings, sent, change, dummy, owned-by-another, the odd head — - // never a stranger's leaf, a garbage envelope, nor a public note (found from its - // transaction, not its envelope). RPC-201: what is *read* is the aligned chunk around each, - // so the node learns the chunk and not the leaf. - let (default_rows, default_asks, default_reads) = &served[1]; - assert!(*default_asks >= 1); - assert_eq!(default_reads, &vec![(0, crate::DEFAULT_SCAN_CHUNK)], "a chain shorter than one chunk: one read of chunk 0, whole"); - assert_eq!(*default_rows, leaves, "which is every leaf there is"); - let (small_rows, _, small_reads) = &served[2]; - assert!(small_reads.iter().all(|(from, limit)| from % 4 == 0 && *limit == 4), "every read is one aligned 4-leaf chunk: {small_reads:?}"); - assert!(small_reads.windows(2).all(|w| w[0].0 < w[1].0), "each chunk read once, in order"); - assert!(*small_rows < leaves && *small_rows > 7, "fewer than every envelope, more than the wanted leaves alone: {small_rows} of {leaves}"); - let (zero_rows, zero_asks, zero_reads) = &served[3]; - assert_eq!(*zero_asks, 0, "chunk 0 asks for no heads"); - assert!(*zero_rows >= leaves && zero_reads.iter().all(|(_, limit)| *limit == PAGE as u64), "chunk 0 pages every envelope: {zero_reads:?}"); - } - - /// Audit v10, RPC-201: the reads a scan over heads makes for its wanted leaves are whole - /// aligned chunks — `from` a multiple of the chunk, `limit` the chunk — one per chunk that - /// holds a wanted leaf, in order; never a read of exactly one wanted leaf, however the wanted - /// leaves fall. The gap merging stays: wanted leaves of one chunk share its read. - #[test] - fn envelope_reads_are_aligned_chunks_and_never_a_single_wanted_leaf() { - assert_eq!(fetch_chunks(&[], 256), vec![]); - assert_eq!(fetch_chunks(&[5], 256), vec![(0, 256)]); - assert_eq!(fetch_chunks(&[255, 256], 256), vec![(0, 256), (256, 256)]); - assert_eq!(fetch_chunks(&[28, 29, 30, 700, 701], 256), vec![(0, 256), (512, 256)], "a chunk is read once for all its wanted leaves"); - assert_eq!(fetch_chunks(&[3, 4, 9], 4), vec![(0, 4), (4, 4), (8, 4)]); - for chunk in [2u64, 3, 4, 8, 256, 1000] { - for wanted in [vec![0u64], vec![1], vec![chunk - 1], vec![chunk], vec![2 * chunk + 1], vec![7, 7 + chunk, 7 + 5 * chunk], (0..40).collect()] { - let reads = fetch_chunks(&wanted, chunk); - for (from, limit) in &reads { - assert_eq!(from % chunk, 0, "aligned: {reads:?} for {wanted:?} at {chunk}"); - assert_eq!(*limit as u64, chunk, "whole: {reads:?}"); - assert!(!(wanted.contains(from) && *limit == 1), "never one wanted leaf"); - } - assert!(wanted.iter().all(|w| reads.iter().any(|(from, limit)| *from <= *w && *w < from + *limit as u64)), "every wanted leaf is covered"); - assert!(reads.windows(2).all(|w| w[0].0 < w[1].0), "once per chunk, in order"); + scan(&rpc, w, &mut store).await.unwrap(); + assert_eq!(store.scanned_index, leaves); + logs.push(chain.lock().unwrap().requests.clone()); + stores.push(store); + } + assert_eq!(stores[0].notes.len(), 3 + 2 + 1 + 1, "the owner found its notes"); + assert!(stores[1].notes.is_empty(), "the other found none"); + assert_eq!(logs[0], logs[1], "the node sees the same request sequence from both wallets"); + let reads: Vec<&serde_json::Value> = logs[0].iter().filter(|(m, _)| m == "rand_getCommitments").map(|(_, p)| p).collect(); + assert!(!reads.is_empty()); + assert!(reads.iter().all(|p| p[1] == serde_json::json!(PAGE)), "fixed-size pages only: {reads:?}"); + assert_eq!(reads[0][0], serde_json::json!(0), "from the start of the range"); + assert!(logs[0].iter().all(|(m, _)| m != "rand_getScanLeaves"), "no heads read, even from a node that serves them"); + } + + /// The same holds over a long range with sparse ownership; and the byte cost of the fix is measured. Chain 21's envelope (`envelope_bytes` 1860, + /// the memo format) on 2 000 leaves, the wallet owning six of them in four of the eight 256-leaf chunks: + /// the scan now reads every full envelope, which the heads path (v0.8.0–v0.8.2) traded for a + /// heads read plus the full rows of each chunk holding one of the wallet's leaves. Both costs + /// are counted from this fake's own replies (the JSON bytes of each `result`) and printed (run + /// with `--nocapture`). A full row is ~2.26× a head, which is the whole cost for a wallet whose notes + /// sit in no chunk; this wallet's old scan already read half the envelopes, so here it is ~1.07×. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_scan_reads_the_same_full_pages_for_every_wallet_and_measures_their_cost() { + use randprotocol_core::notes::EnvelopeFormat; + let me = Wallet::from_spend_key(SpendKey([21; 8])); + let stranger = Wallet::from_spend_key(SpendKey([22; 8])); + let nobody = Wallet::from_spend_key(SpendKey([23; 8])); + let seal = |from: &Wallet, to: &Wallet, n: &Note| { + randprotocol_zkvm::address::seal_note_as(EnvelopeFormat::Memo, &from.vk, &to.address, n, &TxKey::random(), "").unwrap() + }; + let mut c = ChainState::new(); + let filler = seal(&stranger, &stranger, &Note::new(stranger.vk.pk(), stranger.vk.pk(), 1, 0, 1)); + assert_eq!(filler.kem_ct.len() + filler.to_receiver.len() + filler.to_sender.len() + filler.body.len(), 1860, "chain 21's envelope"); + const LEAVES: u64 = 2000; + let owned = [10u64, 11, 700, 701, 1500, 1999]; + let mut block = Vec::new(); + for i in 0..LEAVES { + block.push(if owned.contains(&i) { + let n = Note::new(me.vk.pk(), stranger.vk.pk(), 5 + i, 0, 1); + (n.commitment(), seal(&stranger, &me, &n)) + } else { + // Strangers' traffic: one envelope, reused under distinct commitments. + (Note::new(stranger.vk.pk(), stranger.vk.pk(), i, 0, 1).commitment(), filler.clone()) + }); + if block.len() == 100 { + c.commit(Vec::new(), std::mem::take(&mut block)); } } - } + c.scan_leaves = true; + let chain = Arc::new(Mutex::new(c)); - /// RPC-201 review: heads pages start on the chunk boundary at or below the cursor and span - /// whole chunks, at least a [`PAGE`] and within the node's 1000-row cap, so a chunk never - /// straddles two pages; a pass that pages from 0 visits each chunk in exactly one page. - #[test] - fn heads_pages_are_aligned_to_the_chunk_and_no_chunk_straddles_two() { - assert_eq!(heads_page(0, 256), (0, 512)); - assert_eq!(heads_page(512, 256), (512, 512)); - assert_eq!(heads_page(300, 256), (256, 512), "a mid-chunk cursor pages from its chunk's start"); - assert_eq!(heads_page(7, 1000), (0, 1000)); - assert_eq!(heads_page(9, 4), (8, 500)); - for chunk in [2u64, 3, 4, 7, 256, 333, 500, 501, 1000] { - for from in [0u64, 1, chunk - 1, chunk, 5 * chunk + 3, 123_457] { - let (start, limit) = heads_page(from, chunk); - assert_eq!(start % chunk, 0); - assert!(start <= from && from < start + chunk); - assert_eq!(limit as u64 % chunk, 0, "whole chunks"); - assert!((PAGE..=1000).contains(&limit), "{limit} at chunk {chunk}"); + let mut logs = Vec::new(); + let mut full_bytes = 0; + for w in [&me, &nobody] { + { + let mut c = chain.lock().unwrap(); + c.requests.clear(); + c.reply_bytes.clear(); } - // A pass paging from 0 with full pages: each page starts where the last ended, on a boundary. - let mut from = 0u64; - for _ in 0..5 { - let (start, limit) = heads_page(from, chunk); - assert_eq!(start, from, "every page after the first starts at the previous end"); - from = start + limit as u64; + let rpc = serve(&chain).await; + let mut store = NoteStore::default(); + scan(&rpc, w, &mut store).await.unwrap(); + if logs.is_empty() { + assert_eq!(store.notes.len(), owned.len()); } + let c = chain.lock().unwrap(); + logs.push(c.requests.clone()); + full_bytes = c.reply_bytes.get("rand_getCommitments").copied().unwrap_or(0); } - } - - /// RPC-201 re-review, N4: a node (or relay) whose heads page is capped below the distance - /// from the chunk boundary to the cursor answers a page that lies wholly under the cursor. - /// That is not the tip: the scan must still reach the end, not stop there silently. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_heads_page_wholly_under_the_cursor_does_not_end_the_scan() { - let me = Wallet::from_spend_key(SpendKey([15; 8])); - let mut c = every_kind_of_leaf(&me); - c.headers_carry_public_notes = true; - c.scan_leaves = true; - c.scan_leaves_cap = Some(2); - let leaves = c.leaves.len() as u64; - assert!(leaves > 4, "the cursor passes a chunk boundary + 2"); - let chain = Arc::new(Mutex::new(c)); - let rpc = serve(&chain).await.with_scan_chunk(4); - let mut store = NoteStore::default(); - scan(&rpc, &me, &mut store).await.unwrap(); - assert_eq!(store.scanned_index, leaves, "every leaf, though two of the heads pages lay under the cursor"); - } - - /// Against a node without `rand_getScanLeaves`, the scan falls back to full pages and asks - /// for the method once per client, not once per page. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_node_without_scan_heads_is_asked_once_then_paged_in_full() { - let me = Wallet::from_spend_key(SpendKey([14; 8])); - let mut c = every_kind_of_leaf(&me); - c.headers_carry_public_notes = true; - let chain = Arc::new(Mutex::new(c)); - let rpc = serve(&chain).await; - let mut store = NoteStore::default(); - scan(&rpc, &me, &mut store).await.unwrap(); - chain.lock().unwrap().fund(&me, 2, 0); - scan(&rpc, &me, &mut store).await.unwrap(); - assert_eq!(chain.lock().unwrap().scan_leaf_asks, 1, "one -32601, remembered by the client"); - assert_eq!(store.notes.len(), 3 + 2 + 1 + 1 + 1); - } + assert_eq!(logs[0], logs[1], "identical request sequences over 2 000 leaves"); - /// A refusal that is not "no such method" is an error of the scan, not a reason to fall back. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_failing_scan_head_page_fails_the_scan() { - let me = Wallet::from_spend_key(SpendKey([15; 8])); - let mut c = every_kind_of_leaf(&me); - c.scan_leaves = true; - c.fail = Some("rand_getScanLeaves"); - let chain = Arc::new(Mutex::new(c)); - let rpc = serve(&chain).await; - let err = scan(&rpc, &me, &mut NoteStore::default()).await.unwrap_err(); - assert!(err.to_string().contains("injected"), "{err}"); - assert_eq!(chain.lock().unwrap().full_rows_served, 0, "no silent fallback to full pages"); + // What the heads path read for the same range: every head, plus each 256-leaf chunk that + // held an owned leaf (chunks 0, 2, 5 and 7 here), in full. + let mut c = chain.lock().unwrap(); + let bytes_of = |r: Reply| match r { + Reply::Ok(v) => v.to_string().len(), + _ => panic!("the fake refused"), + }; + let heads_bytes = bytes_of(c.answer("rand_getScanLeaves", &serde_json::json!([0, LEAVES]))); + let mut chunks: Vec = owned.iter().map(|i| i - i % 256).collect(); + chunks.dedup(); + let chunk_bytes: usize = chunks.iter().map(|start| bytes_of(c.answer("rand_getCommitments", &serde_json::json!([start, 256])))).sum(); + let old = heads_bytes + chunk_bytes; + let ratio = full_bytes as f64 / old as f64; + let per_leaf_full = full_bytes as f64 / LEAVES as f64; + let per_leaf_head = heads_bytes as f64 / LEAVES as f64; + eprintln!( + "issue #147 scan cost over {LEAVES} leaves (envelope 1860 B): full pages {full_bytes} B ({per_leaf_full:.0} B/leaf); \ + heads path {old} B = heads {heads_bytes} B ({per_leaf_head:.0} B/leaf) + {} owned chunks {chunk_bytes} B; ratio {ratio:.2}x; \ + full/heads per leaf {:.2}x", + chunks.len(), + per_leaf_full / per_leaf_head + ); + assert!(full_bytes >= LEAVES as usize * 3720, "every envelope, hex: {full_bytes}"); + // The worst case is a wallet whose leaves fall in no chunk: it paid for the heads alone. + assert!((2.0..2.6).contains(&(per_leaf_full / per_leaf_head)), "full/heads per leaf"); + assert!(ratio >= 1.0 && ratio < per_leaf_full / per_leaf_head, "{ratio}"); } #[test] @@ -5854,6 +5730,11 @@ pub(crate) mod tests { full_rows_served: usize, /// Every `rand_getCommitments` read this node was asked for, `(from, limit)`, in order. commitment_reads: Vec<(u64, u64)>, + /// Every call this node was asked, `(method, params)`, in order (issue #147: what the + /// node can see of a scan). + requests: Vec<(String, serde_json::Value)>, + /// The JSON bytes of every `result` served, by method. + reply_bytes: BTreeMap, /// `rand_getLimits`' `envelope_bytes` (task 7): `None` — this fake's default — is a chain /// that predates the field, same as a genuinely absent one; `Some(MEMO_ENVELOPE_BYTES)` /// is the memo format. @@ -5945,6 +5826,8 @@ pub(crate) mod tests { scan_leaf_asks: 0, full_rows_served: 0, commitment_reads: Vec::new(), + requests: Vec::new(), + reply_bytes: BTreeMap::new(), hc_bundle: ZkExecutor::hc_bundle(), hc_auth: None, hc_auth_multisig: None, @@ -6142,7 +6025,18 @@ pub(crate) mod tests { /// The chain behind a real socket, and a client for it. async fn serve(chain: &Arc>) -> RpcClient { let c = chain.clone(); - RpcClient::new(rpc_fn(move |m, p| c.lock().unwrap().answer(m, p)).await) + RpcClient::new( + rpc_fn(move |m, p| { + let mut c = c.lock().unwrap(); + c.requests.push((m.to_string(), p.clone())); + let reply = c.answer(m, p); + if let Reply::Ok(v) = &reply { + *c.reply_bytes.entry(m.to_string()).or_default() += v.to_string().len(); + } + reply + }) + .await, + ) } /// Private multisig (`msig_private`'s tests): a chain whose `rand_status` names bundle guest v3, diff --git a/docs/cli.md b/docs/cli.md index 5e35a962..e2cbb0c2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -537,7 +537,7 @@ Global options, accepted before or after the subcommand: | `--max-prover-fee ` | | `1` | the most a paired prover may charge per bundle, in RAND (display units, up to 9 decimals). A quote above it is refused before any bundle is built, on every command (`the prover quotes RAND; the cap is RAND (--max-prover-fee) — not building the bundle`); `0` refuses any fee. A fee on a chain whose bundle guest is v1 or v2 is refused whatever the cap: only a v3 witness can carry it | | `--threads ` | | every core | CPU threads a proof made on this machine uses; `RAYON_NUM_THREADS` when set and no flag; `0` refused. A wallet proves one bundle with a person waiting, so the default is every core (`docs/node-hardware.md` §6: 8 threads 17.1 s, 16 threads 13.9 s on an M4 Max) | | `--cpu` | | off | prove on the CPU even when a GPU is visible to a build with the CUDA backend; without `--cpu` or `--cuda` such a build takes the GPU and says so once on stderr | -| `--scan-chunk ` | | `256` | how many leaves wide each envelope read is when a scan over scan heads (`rand_getScanLeaves`) fetches the leaves this wallet's keys opened (audit v10, RPC-201). The whole aligned chunk is read with `rand_getCommitments`, so the node learns which 256-leaf chunk held one of this wallet's leaves, not which leaf; the leaves between two of the wallet's in one chunk ride in the same read. `0` reads every envelope (what a wallet did before scan heads: the node learns nothing, at full bandwidth — the setting for a node you do not trust with your note count). `2`..`1000` (the node's page cap); `1` is refused, since it would read exactly the wanted leaf. Applies to every command that scans (`sync`, `balance`, `send`, …); see `docs/rpc.md`, scan heads, for the trade-off | +| `--scan-chunk ` | | | ignored since v0.8.3 (issue #147), with a warning, so old scripts still run: every scan reads every envelope, paging `rand_getCommitments` in fixed 500-row pages from the store's cursor to the tip, and decides ownership locally — the requests are the same whatever leaves the wallet owns, so the node learns nothing about which are its own (v0.8.0–v0.8.2 read full envelopes only around the leaves its keys opened, which the node could see). The cost is ~2.26× the bytes of the old heads-only read at most (`docs/rpc.md`, `rand_getScanLeaves`) | | `--wait-timeout ` | `RAND_WAIT_TIMEOUT` | each command's own (`send` and the other bundle commands 180 s, `faucet` 60 s, `call`/`invoke` receipts 120 s) | how long a command waits for its transaction to commit, or for a call's receipt, before it gives up. A wait that runs out after the node accepted the transaction exits **4** (below), never 1 | **Submitting, and exit codes (v0.8.3, issue #141).** The moment the node accepts a transaction diff --git a/docs/rpc.md b/docs/rpc.md index 82d335e3..fc9ebbae 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -205,31 +205,23 @@ a wallet seals, empty for a chain-made note with an empty envelope. The head is all a scan needs to decide ownership: decapsulating `kem_ct` and opening `to_receiver` (or opening `to_sender` under `ovk`) succeeds only for the right key, and yields -the transaction key. A wallet pages heads, then reads with `rand_getCommitments` the leaves one -of its keys opened — and any head that is not 1 208 bytes, rather than guessing at its split. -On chain 20's first 1000 leaves a head row is 1 718 bytes of JSON against a full row's 3 876. -A node too old to have the method answers `-32601`; `rand` then pages `rand_getCommitments` as -before. - -**The privacy trade-off (audit v10, RPC-201).** The node serving the scan sees which leaves a -wallet reads in full after paging heads, and those are the wallet's own leaves — a node that -served full pages to everyone learned nothing. `rand` therefore reads envelopes in whole -**aligned chunks**: a wanted leaf at index `i` is fetched as `rand_getCommitments(i - i mod N, N)` -with `N` = `--scan-chunk` (256 by default), so the node learns that *some* leaf of the chunk is the -wallet's, not which one (but see the tip, below), and the leaves between two of the wallet's in one chunk ride in the -same read. Heads are paged on chunk boundaries too (each page starts at a multiple of `N` and -spans whole chunks, at least 500 leaves), so a chunk never straddles two pages and is read once -per scan however many of the wallet's leaves it holds; a read is never one leaf (`--scan-chunk 1` -is refused). What the node still learns: how many chunks hold a leaf of the wallet's — a *lower* -bound on its note count (several of its notes can share a chunk), plus every chunk holding a head -that is not 1 208 bytes, which is read whatever its owner — and the wallet's timing. **The tip is -the weak spot:** the last chunk is partial while the chain grows, so a wanted leaf in it is hidden -only among the leaves written so far — right after a new block that can be a single bundle — and -the chunk is read again on each later scan as it fills, which tells the node a leaf of the wallet's -is in it. A wallet that cares can scan less often than blocks arrive, or use `--scan-chunk 0`. `--scan-chunk 0` reads every envelope, as a wallet before scan -heads did: the node learns nothing from the reads, at a full page's bandwidth — the setting for a -node you do not trust with your note count. Larger `N` (up to the 1000-row cap) hides more at -more bandwidth per wanted leaf; 256 full rows are ~1 MB of JSON per chunk that holds a note. +the transaction key. On chain 20's first 1000 leaves a head row is 1 718 bytes of JSON against a +full row's 3 876. + +**`rand` no longer scans with it (v0.8.3, issue #147).** From v0.8.0 to v0.8.2 the wallet paged +heads and then read with `rand_getCommitments` only the envelopes around the leaves its keys +opened — exactly, and from v0.8.2 by aligned chunk (`--scan-chunk`, audit v10 RPC-201). Either +way the reads depended on which leaves were the wallet's, so the node serving the scan learned, by +the caller's IP, which leaves (or which chunks of leaves) it owned; a leaf index and its height +identify the transaction. A wallet that wants a scan to reveal nothing must read the same thing +whatever it owns, and only full envelopes can serve every wallet, so `rand` pages +`rand_getCommitments` over the whole range in fixed pages of 500 rows, decides ownership locally, +and never calls this method. Measured on chain 21's 1 860-byte envelope (the client's +`a_scan_reads_the_same_full_pages_for_every_wallet_and_measures_their_cost`): 3 885 bytes of JSON +a leaf against a head's 1 720, ~2.26× — the whole increase for a wallet whose notes fell in no +256-leaf chunk, less for one whose did (1.07× with notes in four of eight chunks). The method +stays on the node for other clients; what a client that uses it reveals by its follow-up reads is +its own choice. ### `rand_getNullifiers` Params: `[from_height]` or `[from_height, limit]`. Result: every nullifier published from that @@ -1992,6 +1984,13 @@ What changed for clients, in one place. Newest first. - `rand_getSupply`: `perps_collateral_in`, `perps_collateral_out`, `perps_collateral_held` (`"0"` without a `perps` section). +### v0.8.3 — the wallet's scan reads every envelope again (client-only; issue #147) + +`rand` pages `rand_getCommitments` over the whole scanned range in fixed 500-row pages and no +longer calls `rand_getScanLeaves`, so its requests are the same whichever leaves it owns. +`--scan-chunk` is accepted and ignored, with a warning. No RPC method changes; the cost is in the +scan-heads section above. + ### v0.8.2 — the wallet's scan reads envelopes by aligned chunk (client-only; RPC-201) Against a node with `rand_getScanLeaves`, `rand` no longer reads with `rand_getCommitments` exactly diff --git a/docs/shielded.md b/docs/shielded.md index 7ba0eac8..cd895e48 100644 --- a/docs/shielded.md +++ b/docs/shielded.md @@ -655,9 +655,15 @@ worth naming. already reads, and computes every witness itself: a send never calls `rand_getWitness`, and the only tree question left is `rand_getAnchor`, which names no leaf (audit v3 PRIV-1). The method stays on the node for wallets built before this change. -- **Scanning.** `rand_getCommitments` hands out everything to everyone, so scanning itself - reveals nothing about which leaves are yours — but it does tell the node that *somebody* at - your IP is scanning, and how far. Trial decryption is local and, on that path, the node never +- **Scanning.** `rand` reads the commitment tree in fixed pages of full envelopes + (`rand_getCommitments(from, 500)` from its cursor to the tip) and decides which leaves are its + own locally, by trial decryption, after the page has arrived. The requests are the same for + every wallet scanning the same range, whatever it owns, so the scan reveals nothing about which + leaves are yours (v0.8.3, issue #147; from v0.8.0 to v0.8.2 the wallet read full envelopes only + around the leaves its keys opened, which told the node which leaves — or which 256-leaf chunks + — were its). What the node does learn: that *somebody* at your IP is scanning, when, from which + cursor and how far — which bounds when the wallet last synced — and, on a first sync, that it + is a fresh store. Trial decryption is local and, on that path, the node never sees a viewing key. The exception is the explorer's import below: a node an operator handed a viewing key to knows exactly which leaves that key opens — which the key's holder could have computed anyway — and the IP of whoever asked it to. From a5d55b69787cbc644e6f835ce3a1695a4fc30611 Mon Sep 17 00:00:00 2001 From: Dendi Suhubdy <4477337+dendisuhubdy@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:52:26 +0800 Subject: [PATCH 4/4] rand: --proxy / RAND_PROXY routes the node and the prover through SOCKS5 or HTTP Enables reqwest's `socks` feature in the client (no new crates: reqwest 0.12.28 proxies through hyper-util, already in the lock). New global `--proxy ` / `RAND_PROXY` takes socks5h:// (Tor: socks5h://127.0.0.1:9050, names resolved at the proxy), socks5://, http:// or https://; anything else is refused naming --proxy. `rand` sets it as the process default before any client exists (`set_default_proxy`), so every RpcClient and every RemoteProver (--prover, `prover pair`, the trusted pool) goes through it; explicit `RpcClient::with_proxy` / `RemoteProver::with_proxy` exist too. A proxy is added with reqwest's `.proxy()`, which turns the HTTP(S)_PROXY environment off, and a proxy that is down fails the request: nothing falls back to a direct connection. Tests, against a minimal SOCKS5 stub (no auth, CONNECT): the RPC client and the remote prover reach their servers only as the stub's CONNECT target; a dead proxy fails the call; the scheme check; and the `rand` binary end to end with --proxy and with RAND_PROXY, plus a dead proxy that leaves the node untouched. Docs: --proxy row and a Tor recipe in docs/cli.md, a line in docs/shielded.md, CHANGELOG. Fixes #148 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GFs5h141Ct6RTGGrCTQ1vy --- CHANGELOG.md | 4 + crates/randprotocol-client/Cargo.toml | 3 +- crates/randprotocol-client/src/lib.rs | 150 +++++++++++++++++- crates/randprotocol-client/src/main.rs | 11 ++ crates/randprotocol-client/src/prover.rs | 36 ++++- crates/randprotocol-client/tests/cli_rpc.rs | 31 ++++ .../randprotocol-client/tests/common/mod.rs | 59 +++++++ docs/cli.md | 22 +++ docs/shielded.md | 3 +- 9 files changed, 311 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3348a726..983661ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ v0.8.3 (wallet; client-only, no node or chain change): so its requests do not depend on what it owns; it no longer reads `rand_getScanLeaves`. Cost: at most ~2.26× the bytes of the v0.8.0–v0.8.2 heads scan (3 885 vs 1 720 bytes of JSON a leaf on chain 21's envelope). `--scan-chunk` is accepted and ignored, with a warning. +- **`--proxy ` / `RAND_PROXY`** (#148): every request to the node and to a paired prover goes + through a SOCKS5 (`socks5h://` for Tor, `socks5://`) or HTTP(S) CONNECT proxy; a dead proxy fails + the command, never a direct connection. reqwest's `socks` feature is enabled in the client (no + new crates: reqwest 0.12.28 proxies through hyper-util). Tor recipe in `docs/cli.md`. ## v0.8.2 — 2026-10-27 (chain 21, node-only; the genesis-gated rules wait for the next cut) diff --git a/crates/randprotocol-client/Cargo.toml b/crates/randprotocol-client/Cargo.toml index ff2f2273..e9000fc3 100644 --- a/crates/randprotocol-client/Cargo.toml +++ b/crates/randprotocol-client/Cargo.toml @@ -19,7 +19,8 @@ randprotocol-msig = { workspace = true } randprotocol-prover = { workspace = true, default-features = false, features = ["parallel"] } # Available memory for the local-proving gate (`memory.rs`); the same version and features the prover's service build uses. sysinfo = { version = "0.33", default-features = false, features = ["system"] } -reqwest = { workspace = true } +# `socks`: `rand --proxy socks5h://…` (Tor, issue #148). +reqwest = { workspace = true, features = ["socks"] } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/randprotocol-client/src/lib.rs b/crates/randprotocol-client/src/lib.rs index e73b462f..4fe267d6 100644 --- a/crates/randprotocol-client/src/lib.rs +++ b/crates/randprotocol-client/src/lib.rs @@ -771,13 +771,60 @@ impl PollBackoff { } } +/// `--proxy` / `RAND_PROXY` (issue #148): a SOCKS5 proxy — `socks5h://host:port`, which resolves +/// names at the proxy (Tor's `socks5h://127.0.0.1:9050`; nothing is looked up here), or +/// `socks5://`, which resolves them on this machine — or an HTTP(S) CONNECT proxy, `http://` / +/// `https://`. Every request a client built with it makes goes through it, and a proxy that is +/// down fails the request: there is no fallback to a direct connection. +pub fn parse_proxy(url: &str) -> Result { + let scheme = url.split_once("://").map(|(s, _)| s.to_ascii_lowercase()); + match scheme.as_deref() { + Some("socks5h" | "socks5" | "http" | "https") => {} + _ => return Err(anyhow!("--proxy {url:?}: a proxy is socks5h://host:port (Tor: socks5h://127.0.0.1:9050), socks5://, http:// or https://")), + } + let u = reqwest::Url::parse(url).map_err(|e| anyhow!("--proxy {url:?}: not a URL ({e})"))?; + if u.host_str().is_none_or(str::is_empty) { + return Err(anyhow!("--proxy {url:?}: names no host")); + } + reqwest::Proxy::all(url).map_err(|e| anyhow!("--proxy {url:?}: {e}")) +} + +/// The proxy every client this process builds goes through unless told otherwise +/// ([`set_default_proxy`]; `rand` sets it from `--proxy` / `RAND_PROXY` before anything connects). +static DEFAULT_PROXY: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Route every [`RpcClient`] and [`prover::RemoteProver`] built from now on through `url` +/// ([`parse_proxy`]). Once per process; a second call is refused. +pub fn set_default_proxy(url: &str) -> Result<()> { + DEFAULT_PROXY.set(parse_proxy(url)?).map_err(|_| anyhow!("the proxy is already set")) +} + +/// A client builder carrying `proxy`, or the process's default ([`set_default_proxy`]) when +/// `None`; with neither, reqwest's own (the `HTTP(S)_PROXY` environment). Adding a proxy turns the +/// environment's off, so `--proxy` always wins. +pub(crate) fn http_builder(proxy: Option) -> reqwest::ClientBuilder { + let b = reqwest::Client::builder(); + match proxy.or_else(|| DEFAULT_PROXY.get().cloned()) { + Some(p) => b.proxy(p), + None => b, + } +} + impl RpcClient { + /// This client, going through the proxy at `url` ([`parse_proxy`]) — or, for `None`, the + /// process's default ([`set_default_proxy`]). + pub fn with_proxy(mut self, url: Option<&str>) -> Result { + let proxy = url.map(parse_proxy).transpose()?; + self.http = http_builder(proxy).timeout(READ_TIMEOUT).build().context("building the http client")?; + Ok(self) + } + pub fn new(url: impl Into) -> RpcClient { RpcClient { url: url.into(), // The per-request timeout for an upload is set on the request itself, which overrides // this one; this is the read timeout. - http: reqwest::Client::builder().timeout(READ_TIMEOUT).build().expect("client"), + http: http_builder(None).timeout(READ_TIMEOUT).build().expect("client"), legacy_status: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), legacy_scan: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), envelope_format: std::sync::Arc::new(tokio::sync::OnceCell::new()), @@ -1687,6 +1734,68 @@ pub(crate) mod test_rpc { } } +/// A minimal SOCKS5 proxy for the `--proxy` tests (issue #148): no authentication, CONNECT +/// only, to an IPv4, IPv6 or domain target, then bytes copied both ways. Returns its +/// `socks5h://` URL and every `host:port` it was asked to connect to. +#[cfg(test)] +pub(crate) mod socks_stub { + use std::sync::{Arc, Mutex}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + pub async fn socks5_stub() -> (String, Arc>>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { return }; + let log = log.clone(); + tokio::spawn(async move { + let mut hello = [0u8; 2]; + sock.read_exact(&mut hello).await.ok()?; + let mut methods = vec![0u8; hello[1] as usize]; + sock.read_exact(&mut methods).await.ok()?; + (hello[0] == 5 && methods.contains(&0)).then_some(())?; + sock.write_all(&[5, 0]).await.ok()?; + let mut req = [0u8; 4]; + sock.read_exact(&mut req).await.ok()?; + (req[0] == 5 && req[1] == 1).then_some(())?; + let host = match req[3] { + 1 => { + let mut ip = [0u8; 4]; + sock.read_exact(&mut ip).await.ok()?; + std::net::Ipv4Addr::from(ip).to_string() + } + 4 => { + let mut ip = [0u8; 16]; + sock.read_exact(&mut ip).await.ok()?; + format!("[{}]", std::net::Ipv6Addr::from(ip)) + } + 3 => { + let mut len = [0u8; 1]; + sock.read_exact(&mut len).await.ok()?; + let mut name = vec![0u8; len[0] as usize]; + sock.read_exact(&mut name).await.ok()?; + String::from_utf8(name).ok()? + } + _ => return None, + }; + let mut port = [0u8; 2]; + sock.read_exact(&mut port).await.ok()?; + let target = format!("{host}:{}", u16::from_be_bytes(port)); + log.lock().unwrap().push(target.clone()); + let mut upstream = tokio::net::TcpStream::connect(&target).await.ok()?; + sock.write_all(&[5, 0, 0, 1, 0, 0, 0, 0, 0, 0]).await.ok()?; + let _ = tokio::io::copy_bidirectional(&mut sock, &mut upstream).await; + Some(()) + }); + } + }); + (format!("socks5h://{addr}"), seen) + } +} + #[cfg(test)] mod tests { use super::*; @@ -2008,6 +2117,45 @@ mod tests { assert_eq!(submitted_line(&tx, &Hash([1; 32])), format!("submitted pause_mints {}", Hash([1; 32]))); } + // ----------------------------------------------------- --proxy / RAND_PROXY (issue #148) + + /// Every RPC call goes through the proxy: the node is reached only by way of the SOCKS stub, + /// which saw the node's address as its CONNECT target. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_rpc_client_with_a_proxy_reaches_the_node_through_it() { + use test_rpc::{rpc_fn, Reply}; + let node = rpc_fn(|_, _| Reply::Ok(json!({ "height": 3 }))).await; + let (proxy, seen) = socks_stub::socks5_stub().await; + let client = RpcClient::new(node.clone()).with_proxy(Some(&proxy)).unwrap(); + assert_eq!(client.head().await.unwrap()["height"], 3); + let target = node.trim_start_matches("http://").to_string(); + assert!(seen.lock().unwrap().contains(&target), "the proxy carried the call: {:?}", seen.lock().unwrap()); + } + + /// A proxy that is down fails the call — it never falls back to a direct connection, which + /// would hand the node the IP the proxy was there to hide. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_dead_proxy_fails_the_call_rather_than_going_direct() { + use test_rpc::{rpc_fn, Reply}; + let node = rpc_fn(|_, _| Reply::Ok(json!({ "height": 3 }))).await; + let dead = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); + let client = RpcClient::new(node).with_proxy(Some(&format!("socks5h://{dead}"))).unwrap(); + assert!(client.head().await.is_err(), "a dead proxy must not be bypassed"); + } + + /// The schemes `--proxy` takes: SOCKS5 (`socks5h://` resolves names at the proxy, Tor's way; + /// `socks5://` resolves them here) and an HTTP(S) CONNECT proxy. Anything else is refused. + #[test] + fn a_proxy_url_is_held_to_the_supported_schemes() { + for ok in ["socks5h://127.0.0.1:9050", "socks5://127.0.0.1:1080", "http://127.0.0.1:8080", "https://proxy.example:443"] { + assert!(parse_proxy(ok).is_ok(), "{ok}"); + } + for bad in ["socks4://127.0.0.1:9050", "ftp://x:1", "127.0.0.1:9050", "not a url", ""] { + let e = parse_proxy(bad).unwrap_err().to_string(); + assert!(e.contains("--proxy"), "{bad}: {e}"); + } + } + // ---------------------------------------------------- the call limits (Task 5) /// `rand_getProgramPublic`'s one hex string: each word as its four little-endian bytes, the diff --git a/crates/randprotocol-client/src/main.rs b/crates/randprotocol-client/src/main.rs index 7cd894f3..f0c6f14e 100644 --- a/crates/randprotocol-client/src/main.rs +++ b/crates/randprotocol-client/src/main.rs @@ -71,6 +71,12 @@ struct Cli { /// out after the node accepted the transaction exits 4, "submitted, confirmation unknown". #[arg(long, global = true, env = "RAND_WAIT_TIMEOUT", value_name = "SECS")] wait_timeout: Option, + /// Send every request — to the node and to a paired prover — through this proxy: + /// `socks5h://127.0.0.1:9050` for Tor (names resolved at the proxy), `socks5://`, or an + /// `http://`/`https://` CONNECT proxy. A proxy that is down fails the command; nothing falls + /// back to a direct connection. `docs/cli.md` has a Tor recipe. + #[arg(long, global = true, env = "RAND_PROXY", value_name = "URL")] + proxy: Option, #[command(subcommand)] cmd: Cmd, } @@ -2128,6 +2134,11 @@ async fn run() -> Result<()> { eprintln!("warning: {e}"); } } + // Before any client is built: every `RpcClient` and `RemoteProver` this process makes goes + // through it (issue #148). + if let Some(proxy) = &cli.proxy { + randprotocol_client::set_default_proxy(proxy)?; + } if cli.scan_chunk.is_some() { eprintln!("warning: --scan-chunk is ignored since v0.8.3: a scan reads every envelope, in fixed pages, so the node cannot tell which leaves are yours (issue #147)"); } diff --git a/crates/randprotocol-client/src/prover.rs b/crates/randprotocol-client/src/prover.rs index 1a4acbd4..51ba7ac0 100644 --- a/crates/randprotocol-client/src/prover.rs +++ b/crates/randprotocol-client/src/prover.rs @@ -357,6 +357,16 @@ pub struct RemoteProver { fee_announced: std::sync::atomic::AtomicBool, } +/// The prover link's HTTP client: 10 s to connect, 60 s a request, through `proxy` or the +/// process's default proxy. +fn prover_http(proxy: Option) -> Result { + crate::http_builder(proxy) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(60)) + .build() + .context("building the prover's http client") +} + /// Reads the digest a bundle proof of the guest `hc` publishes, at a FRI profile. pub(crate) type PublishedDigest = std::sync::Arc Result + Send + Sync>; @@ -368,11 +378,7 @@ fn executor_digest(profile: FriProfile, hc: &Word8, proof: &[u8]) -> Result RemoteProver { - let http = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(60)) - .build() - .expect("an http client"); + let http = prover_http(None).expect("an http client"); let no_verify = std::env::var("RAND_PROVER_NO_VERIFY").is_ok_and(|v| v == "1"); RemoteProver { paired, @@ -388,6 +394,14 @@ impl RemoteProver { } } + /// This prover, reached through the proxy at `url` (`--proxy`, issue #148) — or, for `None`, + /// the process's default ([`crate::set_default_proxy`]), which [`RemoteProver::new`] already + /// uses. + pub fn with_proxy(mut self, url: Option<&str>) -> Result { + self.http = prover_http(url.map(crate::parse_proxy).transpose()?)?; + Ok(self) + } + /// The cap on a quoted fee (`--max-prover-fee`, base units); 0 refuses any fee. pub fn with_max_fee(mut self, cap: u64) -> RemoteProver { self.max_fee = cap; @@ -1044,6 +1058,18 @@ mod tests { } } + /// Issue #148: with a proxy the wallet reaches its paired prover only through it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_remote_prover_with_a_proxy_is_reached_through_it() { + let (paired, state) = fake(Finish::Done { flip: 0, publish_flip: 0, proof_len: 100 }, true, |_| {}).await; + let target = paired.url.trim_start_matches("http://").trim_end_matches('/').to_string(); + let (proxy, seen) = crate::socks_stub::socks5_stub().await; + let r = RemoteProver::new(paired).with_proxy(Some(&proxy)).unwrap(); + r.info().await.unwrap(); + assert_eq!(state.lock().unwrap().seen, vec!["prover_info".to_string()]); + assert!(seen.lock().unwrap().contains(&target), "{:?} via {target}", seen.lock().unwrap()); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_fingerprint_mismatch_is_refused_at_info() { let (paired, state) = fake(Finish::Done { flip: 0, publish_flip: 0, proof_len: 100 }, true, |i| { diff --git a/crates/randprotocol-client/tests/cli_rpc.rs b/crates/randprotocol-client/tests/cli_rpc.rs index 8d06b0bd..c661fab1 100644 --- a/crates/randprotocol-client/tests/cli_rpc.rs +++ b/crates/randprotocol-client/tests/cli_rpc.rs @@ -388,6 +388,37 @@ fn node_reading() -> Fake { })) } +/// Issue #148: `--proxy` (and `RAND_PROXY`) sends the RPC through a SOCKS5 proxy — the node is +/// reached only as the proxy's CONNECT target — and a dead proxy fails the command instead of +/// falling back to a direct connection. +#[test] +fn proxy_routes_the_rpc_through_socks5_and_a_dead_proxy_is_never_bypassed() { + let node = node_reading(); + let dir = tempfile::tempdir().unwrap(); + let key = dir.path().join("none.json"); + let target = node.url.trim_start_matches("http://").to_string(); + + let (proxy, seen) = socks5_stub(); + let r = rand(&node.url, &key, &["--proxy", &proxy, "head"]); + assert!(r.ok, "{}", r.err); + assert_eq!(serde_json::from_str::(&r.out).unwrap()["height"], 7); + assert!(seen.lock().unwrap().contains(&target), "{:?}", seen.lock().unwrap()); + + let (proxy, seen) = socks5_stub(); + let r = rand_env(&node.url, &key, &["head"], &[("RAND_PROXY", &proxy)]); + assert!(r.ok, "{}", r.err); + assert!(seen.lock().unwrap().contains(&target), "RAND_PROXY too: {:?}", seen.lock().unwrap()); + + let calls = node.calls.lock().unwrap().len(); + let dead = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap(); + let r = rand(&node.url, &key, &["--proxy", &format!("socks5h://{dead}"), "head"]); + assert!(!r.ok, "a dead proxy fails the command"); + assert_eq!(node.calls.lock().unwrap().len(), calls, "and the node was never reached directly"); + + let r = rand(&node.url, &key, &["--proxy", "socks4://127.0.0.1:9050", "head"]); + assert!(!r.ok && r.err.contains("--proxy"), "{}", r.err); +} + #[test] fn head_status_peers_validators_print_the_reply_as_pretty_json() { let node = node_reading(); diff --git a/crates/randprotocol-client/tests/common/mod.rs b/crates/randprotocol-client/tests/common/mod.rs index 1691abf5..5851ade7 100644 --- a/crates/randprotocol-client/tests/common/mod.rs +++ b/crates/randprotocol-client/tests/common/mod.rs @@ -98,7 +98,13 @@ pub struct Run { } pub fn rand(rpc: &str, key: &std::path::Path, args: &[&str]) -> Run { + rand_env(rpc, key, args, &[]) +} + +/// [`rand`] with extra environment variables. +pub fn rand_env(rpc: &str, key: &std::path::Path, args: &[&str], env: &[(&str, &str)]) -> Run { let o = Command::new(env!("CARGO_BIN_EXE_rand")) + .envs(env.iter().copied()) .arg("--key") .arg(key) .args(["--rpc", rpc]) @@ -126,3 +132,56 @@ pub fn keygen(dir: &tempfile::TempDir) -> std::path::PathBuf { pub fn address_of(key: &std::path::Path) -> String { rand(DEAD, key, &["address"]).out.trim().to_string() } + +/// A minimal SOCKS5 proxy (no auth, CONNECT only) for the `--proxy` tests (issue #148): returns +/// its `socks5h://` URL and every `host:port` it was asked to connect to. +pub fn socks5_stub() -> (String, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("socks5h://{}", listener.local_addr().unwrap()); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + std::thread::spawn(move || { + for sock in listener.incoming() { + let Ok(mut sock) = sock else { return }; + let log = log.clone(); + std::thread::spawn(move || -> Option<()> { + let mut hello = [0u8; 2]; + sock.read_exact(&mut hello).ok()?; + let mut methods = vec![0u8; hello[1] as usize]; + sock.read_exact(&mut methods).ok()?; + sock.write_all(&[5, 0]).ok()?; + let mut req = [0u8; 4]; + sock.read_exact(&mut req).ok()?; + let host = match req[3] { + 1 => { + let mut ip = [0u8; 4]; + sock.read_exact(&mut ip).ok()?; + std::net::Ipv4Addr::from(ip).to_string() + } + 3 => { + let mut len = [0u8; 1]; + sock.read_exact(&mut len).ok()?; + let mut name = vec![0u8; len[0] as usize]; + sock.read_exact(&mut name).ok()?; + String::from_utf8(name).ok()? + } + _ => return None, + }; + let mut port = [0u8; 2]; + sock.read_exact(&mut port).ok()?; + let target = format!("{host}:{}", u16::from_be_bytes(port)); + log.lock().unwrap().push(target.clone()); + let upstream = std::net::TcpStream::connect(&target).ok()?; + sock.write_all(&[5, 0, 0, 1, 0, 0, 0, 0, 0, 0]).ok()?; + let (mut a, mut b) = (sock.try_clone().ok()?, upstream.try_clone().ok()?); + let (mut sock, mut upstream) = (sock, upstream); + let t = std::thread::spawn(move || std::io::copy(&mut a, &mut b)); + let _ = std::io::copy(&mut upstream, &mut sock); + let _ = sock.shutdown(std::net::Shutdown::Both); + let _ = t.join(); + Some(()) + }); + } + }); + (url, seen) +} diff --git a/docs/cli.md b/docs/cli.md index e2cbb0c2..4432df81 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -539,6 +539,7 @@ Global options, accepted before or after the subcommand: | `--cpu` | | off | prove on the CPU even when a GPU is visible to a build with the CUDA backend; without `--cpu` or `--cuda` such a build takes the GPU and says so once on stderr | | `--scan-chunk ` | | | ignored since v0.8.3 (issue #147), with a warning, so old scripts still run: every scan reads every envelope, paging `rand_getCommitments` in fixed 500-row pages from the store's cursor to the tip, and decides ownership locally — the requests are the same whatever leaves the wallet owns, so the node learns nothing about which are its own (v0.8.0–v0.8.2 read full envelopes only around the leaves its keys opened, which the node could see). The cost is ~2.26× the bytes of the old heads-only read at most (`docs/rpc.md`, `rand_getScanLeaves`) | | `--wait-timeout ` | `RAND_WAIT_TIMEOUT` | each command's own (`send` and the other bundle commands 180 s, `faucet` 60 s, `call`/`invoke` receipts 120 s) | how long a command waits for its transaction to commit, or for a call's receipt, before it gives up. A wait that runs out after the node accepted the transaction exits **4** (below), never 1 | +| `--proxy ` | `RAND_PROXY` | none (reqwest's `HTTP(S)_PROXY` environment) | send every request — to the node, and to a paired prover (`--prover`, `prover pair`) — through this proxy (v0.8.3, issue #148): `socks5h://host:port` (names resolved at the proxy: Tor's `socks5h://127.0.0.1:9050`), `socks5://` (names resolved on this machine, which leaks them to its resolver), or an `http://`/`https://` CONNECT proxy. A proxy that is down or refuses fails the command; nothing ever falls back to a direct connection. Overrides `HTTP(S)_PROXY`. Recipe below | **Submitting, and exit codes (v0.8.3, issue #141).** The moment the node accepts a transaction (`rand_sendTransaction` answers with its hash) `rand` prints `submitted ` on @@ -678,6 +679,27 @@ units), where `bytes` is the proof plus the sealed transcript — or, on a node policy (`rand_getLimits.gas_metering`), the greater of that and the gas floor of the proof's header (`docs/fees.md` §1.1), printed as `gas bound …` before the bundle is proved. +### Through Tor (`--proxy`) + +The node's operator sees the IP every request comes from, and a submission's timing links the +transaction to it (`docs/shielded.md`, what the node learns). To keep the IP out of that, run Tor +and point `rand` at its SOCKS port: + +```sh +brew install tor && brew services start tor # or: apt install tor; systemctl start tor +export RAND_PROXY=socks5h://127.0.0.1:9050 # every rand command from this shell +rand --rpc https://rpc.randprotocol.org balance +rand send rand1… 1.5 --yes # node and paired prover both via Tor +``` + +Use `socks5h://`, not `socks5://`: with `h` the node's name is resolved by Tor, so this machine's +DNS resolver never sees which node you use. The RPC URL must be one the Tor exit can reach — a +public `https://` endpoint or an onion address; `http://127.0.0.1:8545` through Tor reaches the +exit's own loopback, not yours. A paired prover is reached the same way, so a prover on +`http://127.0.0.1` cannot be used with `--proxy` either. Tor hides the IP, not the timing: a +wallet that scans or sends at the same moments every day is still a pattern. If Tor is not +running the command fails (`connecting to …`) rather than going direct. + ### A first shielded transfer ```bash diff --git a/docs/shielded.md b/docs/shielded.md index cd895e48..4b0735e4 100644 --- a/docs/shielded.md +++ b/docs/shielded.md @@ -663,7 +663,8 @@ worth naming. around the leaves its keys opened, which told the node which leaves — or which 256-leaf chunks — were its). What the node does learn: that *somebody* at your IP is scanning, when, from which cursor and how far — which bounds when the wallet last synced — and, on a first sync, that it - is a fresh store. Trial decryption is local and, on that path, the node never + is a fresh store. `--proxy socks5h://127.0.0.1:9050` (Tor; `docs/cli.md`) keeps the IP itself + from the node. Trial decryption is local and, on that path, the node never sees a viewing key. The exception is the explorer's import below: a node an operator handed a viewing key to knows exactly which leaves that key opens — which the key's holder could have computed anyway — and the IP of whoever asked it to.