Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
bincode = "1.3"
hex = "0.4"
# #152: `rand_sendTransaction`'s hex body (2.9 MB of hex a bundle transaction) decoded with a
# SIMD/table decoder; `hex` took 18 ms of a 92 ms admission. MIT, no dependencies at this feature set.
faster-hex = { version = "0.10.1", default-features = false }
# The scan heads `rand_getScanLeaves` serves: 1 208 bytes a leaf as base64 rather than hex, a third
# less on the wire. Already in the lock through reqwest/hyper; pinned to that version.
base64 = "=0.22.1"
Expand Down Expand Up @@ -97,7 +100,10 @@ opt-level = 2
# Plonky3 0.7.0 with one fix in two crates (upstream issue Plonky3 #2363, fix PR #2368): HidingFriPcs and
# MerkleTreeHidingMmcs held a spin lock around their RNG across rayon work, and a
# worker waiting on a stolen job could steal another `commit` and spin on its own lock for ever
# (vendor/p3-fri/src/hiding_pcs.rs, "RandProtocol patch"). Everything else is the crates.io source.
# (vendor/p3-fri/src/hiding_pcs.rs, "RandProtocol patch"). v0.8.3 (#151) adds a second change to the
# same two crates: their random columns and Merkle salts are counter-mode ChaCha20 streams, one per
# chunk of rows, filled in parallel (vendor/p3-merkle-tree/src/counter_rng.rs; the proof format and
# the verifier are unchanged). Everything else is the crates.io source.
# `vendor/p3-batch-stark` is a third: p3-batch-stark 0.7.0 with a quotient-layout switch
# (`QuotientLayout`), the circuits fork copied verbatim from circuits 5ff7676; circuits'
# `vendor/PROVENANCE.md` and `recursion/docs/05-quotient-layout.md` are its record.
Expand Down
1 change: 1 addition & 0 deletions crates/randprotocol-node/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
bincode = { workspace = true }
hex = { workspace = true }
faster-hex = { workspace = true }
base64 = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
Expand Down
63 changes: 63 additions & 0 deletions crates/randprotocol-node/src/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,33 @@ pub fn faucet_minters(gs: &randprotocol_core::genesis::GenesisState) -> std::col
}
}

/// #153: why this node cannot serve `rand_mint`, or `None` when it can — the faucet is on (genesis
/// `faucet`), this node runs as a validator, and its key is one of the chain's faucet minters
/// ([`faucet_minters`]: `staking.faucet_minters` where the genesis lists them, else the genesis
/// validators). What `rand_status` / `rand_getEmission` report as `faucet` (true exactly when this
/// is `None`), and what `rand_mint` checks before it builds anything — so a validator bonded after
/// genesis, whose key the pool and the ledger would refuse as a minter, says so up front instead of
/// advertising a faucet whose every mint fails at admission.
pub fn faucet_refusal(gs: &randprotocol_core::genesis::GenesisState, is_validator: bool, key: &randprotocol_core::Address) -> Option<String> {
if !gs.faucet {
return Some("faucet is disabled on this chain".into());
}
if !is_validator {
return Some("faucet mints are signed by validators; ask a validator node".into());
}
let minters = faucet_minters(gs);
if minters.contains(key) {
return None;
}
let source = if gs.staking.as_ref().is_some_and(|s| s.faucet_minters.is_some()) { "staking.faucet_minters" } else { "the genesis validators" };
let list: Vec<String> = minters.iter().map(|a| a.to_base58()).collect();
Some(format!(
"this node's key {key} is not a faucet minter on this chain; the minters are {source} ({}): {}; ask a node run by one of them",
list.len(),
list.join(", ")
))
}

/// A faucet `Mint` whose minter has a row in the validator register but is not in `minters`
/// ([`faucet_minters`]): `TxError::MinterNotAllowed`. `None` for everything else — including a
/// minter with no row at all, which is the ledger's own `MinterNotValidator`, left to `validate`
Expand Down Expand Up @@ -2200,6 +2227,42 @@ mod tests {
assert!(pool.insert(honest, &l, &StubExecutor).is_ok());
}

/// #153: `faucet` in `rand_status` means "this node can mint here" — the faucet on, a validator,
/// and a key among the chain's faucet minters — and every other case names its reason, the
/// non-minter's naming the minter set.
#[test]
fn the_faucet_is_served_only_by_a_validator_whose_key_is_a_faucet_minter() {
use crate::storage::fixtures;
use randprotocol_core::genesis::{FaucetMinter, StakingConfig};
use randprotocol_core::UNITS_PER_RAND;
let (a, b, later) = (fixtures::key(1), fixtures::key(2), fixtures::key(9));
let mut g = fixtures::genesis_file_of(1, &[&a, &b], Vec::new(), 10);
g.faucet = true;
let gs = g.build(&randprotocol_core::confidential::StubExecutor).unwrap();
// A genesis validator mints; a key bonded later (the guardian hosts of issue #153) does not.
assert_eq!(faucet_refusal(&gs, true, &a.address()), None);
let why = faucet_refusal(&gs, true, &later.address()).expect("a post-genesis key is no minter");
assert!(why.contains(&later.address().to_base58()) && why.contains("is not a faucet minter"), "{why}");
assert!(why.contains("the genesis validators (2)") && why.contains(&a.address().to_base58()) && why.contains(&b.address().to_base58()), "{why}");
// Not a validator, or no faucet: those reasons first, as before.
assert_eq!(faucet_refusal(&gs, false, &a.address()).as_deref(), Some("faucet mints are signed by validators; ask a validator node"));
g.faucet = false;
let off = g.build(&randprotocol_core::confidential::StubExecutor).unwrap();
assert_eq!(faucet_refusal(&off, true, &a.address()).as_deref(), Some("faucet is disabled on this chain"));
// A genesis that lists its minters: the list decides, and the message names it.
g.faucet = true;
g.staking = Some(StakingConfig {
faucet_budget_per_epoch: 100 * UNITS_PER_RAND,
bond_activation_epochs: 2,
faucet_minters: Some(vec![FaucetMinter(later.address())]),
..Default::default()
});
let listed = g.build(&randprotocol_core::confidential::StubExecutor).unwrap();
assert_eq!(faucet_refusal(&listed, true, &later.address()), None);
let why = faucet_refusal(&listed, true, &a.address()).unwrap();
assert!(why.contains("staking.faucet_minters (1)") && why.contains(&later.address().to_base58()), "{why}");
}

/// Where a genesis lists `staking.faucet_minters` the pool's minters are that list, not the
/// genesis validators: a listed non-genesis key is admitted, an unlisted genesis key is not.
#[test]
Expand Down
49 changes: 42 additions & 7 deletions crates/randprotocol-node/src/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2665,7 +2665,8 @@ pub async fn start_with(cfg: NodeConfig, rpc_options: RpcOptions, net_options: N
let status = Arc::new(RwLock::new(NodeStatus {
is_validator: hs.is_validator(),
active_validator: hs.current_set().contains(&key.address()),
faucet: gs.faucet,
// #153: "this node can mint here", not just "the genesis turns the faucet on".
faucet: admission::faucet_refusal(&gs, hs.is_validator(), &key.address()).is_none(),
confidential: gs.confidential,
testnet: gs.testnet,
fri_profile: gs.fri_profile.clone(),
Expand Down Expand Up @@ -3827,12 +3828,14 @@ impl Node {
/// preserving and no reason to keep an outgoing-viewing record, so the `to_sender` half of
/// the envelope is addressed to a key that is dropped on the next line and never recoverable.
/// Only `to` can open the note, which is the whole intent.
///
/// #153: and only a key among the chain's faucet minters (`admission::faucet_refusal`, the
/// predicate `rand_status.faucet` reports) — refused here, naming the minter set, before
/// anything is built, rather than by the pool after.
async fn mint(&mut self, to: ShieldedAddress, amount: u64) -> std::result::Result<Hash, String> {
if !self.gs.faucet {
return Err("faucet is disabled on this chain".into());
}
if !self.hs.is_validator() {
return Err("faucet mints are signed by validators; ask a validator node".into());
let key = Keypair::from_seed(self.cfg.seed).expect("seed validated at startup");
if let Some(why) = admission::faucet_refusal(&self.gs, self.hs.is_validator(), &key.address()) {
return Err(why);
}
if amount > FAUCET_MAX_UNITS {
return Err(format!("mint of {amount} exceeds the faucet cap of {FAUCET_MAX_UNITS}"));
Expand All @@ -3848,7 +3851,6 @@ impl Node {
admission::FAUCET_MINT_PER_SEC
));
}
let key = Keypair::from_seed(self.cfg.seed).expect("seed validated at startup");
let height = self.hs.tip_ledger().height();
let envelope_bytes = self.hs.tip_ledger().envelope_bytes();
let domain = *self.hs.tip_ledger().binding_domain();
Expand Down Expand Up @@ -7607,6 +7609,39 @@ mod tests {
(node, seen_rx, applied_rx)
}

/// #153: a validator whose key is outside the chain's faucet minters — a guardian host bonded
/// after genesis — refuses `rand_mint` up front, naming the minter set, and nothing reaches the
/// pool.
#[tokio::test]
async fn a_validator_outside_the_faucet_minters_refuses_rand_mint_up_front() {
use randprotocol_core::genesis::{FaucetMinter, StakingConfig};
let mint_on = |minters: Vec<randprotocol_core::Address>| async move {
let dir = tempfile::tempdir().unwrap();
let storage = Storage::open(dir.path()).unwrap();
let mut g = crate::storage::fixtures::genesis_file_of(1, &[&key(1)], Vec::new(), 10);
g.faucet = true;
g.staking = Some(StakingConfig {
faucet_budget_per_epoch: 1_000 * randprotocol_core::UNITS_PER_RAND,
bond_activation_epochs: 2,
faucet_minters: Some(minters.into_iter().map(FaucetMinter).collect()),
..Default::default()
});
let gs = g.build(&StubExecutor).unwrap();
storage.init_genesis(&gs).unwrap();
let hs = resume_consensus(&storage, &gs, Some(key(1)), Duration::from_secs(1), Duration::from_secs(8), Arc::new(StubExecutor)).unwrap();
let (mut node, _seen) = bare_node(storage, gs, hs);
let r = node.mint(crate::storage::fixtures::payout(9), 1_000).await;
(r, node.mempool.len())
};
let (r, pooled) = mint_on(vec![key(9).address()]).await;
let why = r.unwrap_err();
assert!(why.contains(&format!("this node's key {} is not a faucet minter", key(1).address())), "{why}");
assert!(why.contains("staking.faucet_minters (1)") && why.contains(&key(9).address().to_base58()), "{why}");
assert_eq!(pooled, 0, "refused before a mint was built or pooled");
// (A listed key gets past this check — `admission::faucet_refusal`'s own test; building the
// mint itself needs the real executor, `a_faucet_mint_is_sealed_in_the_chains_declared_envelope_format`.)
}

/// One connected peer claiming `height`.
fn claiming_peer(node: &mut Node, height: u64) -> PeerId {
let p = PeerId::random();
Expand Down
54 changes: 52 additions & 2 deletions crates/randprotocol-node/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@ pub struct NodeStatus {
/// validator that has bonded in but whose epoch has not arrived — or one that has unbonded
/// below the minimum — is `is_validator` without being `active_validator`.
pub active_validator: bool,
/// #153: this node can serve `rand_mint` — the genesis turns the faucet on, this node runs as a
/// validator, and its key is one of the chain's faucet minters (`admission::faucet_refusal`).
/// Before v0.8.3 it was the genesis flag alone, `true` on validators whose every mint failed.
pub faucet: bool,
pub confidential: bool,
/// Audit v6, STAKE-2: the genesis says `testnet: true` — the marker that lets a faucet sit
Expand Down Expand Up @@ -1598,6 +1601,20 @@ fn parse_shielded(params: &Value, idx: usize) -> Result<ShieldedAddress, RpcErro
ShieldedAddress::parse(&s).map_err(|e| RpcError::invalid_params(format!("address: {e}")))
}

/// #152: a hex body, with or without `0x` — `rand_sendTransaction`'s transaction, 5.8 MB of hex for a
/// bundle transaction — decoded by `faster-hex` (SIMD-checked, table-decoded) instead of `hex`,
/// which took 18 ms of a 92 ms admission. The same verdicts as `hex::decode`: either case, and
/// `None` for an odd length or any non-hex character.
pub fn decode_hex_body(s: &str) -> Option<Vec<u8>> {
let src = s.strip_prefix("0x").unwrap_or(s).as_bytes();
if !src.len().is_multiple_of(2) {
return None;
}
let mut out = vec![0u8; src.len() / 2];
faster_hex::hex_decode(src, &mut out).ok()?;
Some(out)
}

/// A 32-byte value as hex, with or without `0x`: a bridge token address or emitter.
fn parse_bytes32(params: &Value, idx: usize, name: &str) -> Result<[u8; 32], RpcError> {
let s: String = param(params, idx, name)?;
Expand Down Expand Up @@ -2765,8 +2782,7 @@ async fn dispatch(st: &RpcState, req: &Request) -> Result<Value, RpcError> {
}
"rand_sendTransaction" => {
let raw: String = param(p, 0, "tx")?;
let bytes = hex::decode(raw.strip_prefix("0x").unwrap_or(&raw))
.map_err(|_| RpcError::invalid_params("tx must be hex"))?;
let bytes = decode_hex_body(&raw).ok_or_else(|| RpcError::invalid_params("tx must be hex"))?;
let tx = Transaction::decode(&bytes).map_err(|e| RpcError::invalid_params(format!("tx decode: {e}")))?;
// The body limit admits more than a block can carry — two `max_proof_bytes` proofs
// are a legal `Call` by the per-part caps and can still be over `max_block_bytes` — so
Expand Down Expand Up @@ -5861,6 +5877,40 @@ pub(crate) mod tests {
assert_eq!(call(&st, "rand_sendTransaction", json!(["00"])).await.unwrap_err().code, -32602);
}

/// #152: `rand_sendTransaction`'s faster-hex decode gives exactly `hex::decode`'s verdicts —
/// the same bytes for every valid body (either case, mixed, with or without `0x`), a refusal
/// for every odd length and every non-hex character, at every position a SIMD lane boundary
/// could hide one in.
#[test]
fn the_hex_body_decodes_as_hex_does() {
use rand::{Rng, SeedableRng};
let mut rng = rand::rngs::StdRng::seed_from_u64(152);
for len in [0usize, 1, 2, 7, 15, 16, 31, 32, 33, 63, 64, 65, 127, 1000, 4097] {
let bytes: Vec<u8> = (0..len).map(|_| rng.gen()).collect();
let lower = hex::encode(&bytes);
let upper = lower.to_uppercase();
let mixed: String = lower.chars().enumerate().map(|(i, c)| if i % 3 == 0 { c.to_ascii_uppercase() } else { c }).collect();
for body in [lower.clone(), upper, mixed, format!("0x{lower}")] {
assert_eq!(decode_hex_body(&body), Some(bytes.clone()), "{len} bytes");
assert_eq!(decode_hex_body(&body).ok_or(()), hex::decode(body.strip_prefix("0x").unwrap_or(&body)).map_err(|_| ()));
}
if len > 0 {
assert_eq!(decode_hex_body(&lower[1..]), None, "odd length");
for pos in [0, lower.len() / 2, lower.len() - 1] {
for bad in ['g', 'G', 'x', ' ', '/', ':', '@', '`', '\u{7f}'] {
let mut b = lower.clone().into_bytes();
b[pos] = bad as u8;
let b = String::from_utf8(b).unwrap();
assert_eq!(decode_hex_body(&b), None, "{bad:?} at {pos} of {len} bytes");
assert!(hex::decode(&b).is_err());
}
}
}
}
assert_eq!(decode_hex_body("0x"), Some(vec![]));
assert_eq!(decode_hex_body("0X00"), None, "only a lowercase 0x prefix, as before");
}

#[tokio::test]
async fn status_reports_note_and_nullifier_counts() {
let (_d, st, _gs) = chain();
Expand Down
Loading
Loading