From fc7e4db14b2f2b495a49df5b4f11c1b9f5e56069 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Thu, 30 Apr 2026 19:37:10 -0400 Subject: [PATCH 1/2] security: add input validation guards across precompile, RPC, and pallets pallet-shielded-pool: - shield: reject duplicate commitments before Merkle insertion - unshield: reject zero-amount withdrawals before ZK check - private_transfer: enforce nullifier/commitment count equality - fees: reject zero-amount in claim_shielded and claim_to_evm pallet-zk-verifier: - register_verification_key: reject duplicate (circuit_id, version) pairs - batch_register_verification_keys: same guard applied per entry - verify_transfer_proof: enforce nullifiers.len() == commitments.len() client/rpc-v2: - get_merkle_proof: cap leaf reads at MAX_RPC_LEAVES (100_000) to prevent DoS - distinguish pool_not_initialized vs pool_is_empty errors --- client/rpc-v2/src/privacy.rs | 131 +++++++++++++++-- frame/shielded-pool/src/operations/fees.rs | 49 +++++++ .../src/operations/private_transfer.rs | 28 ++++ frame/shielded-pool/src/operations/shield.rs | 30 ++++ .../shielded-pool/src/operations/unshield.rs | 27 ++++ frame/zk-verifier/src/lib.rs | 54 +++++++ frame/zk-verifier/src/port.rs | 137 +++++++++++++++++- 7 files changed, 439 insertions(+), 17 deletions(-) diff --git a/client/rpc-v2/src/privacy.rs b/client/rpc-v2/src/privacy.rs index 48a3ba1a..2796119b 100644 --- a/client/rpc-v2/src/privacy.rs +++ b/client/rpc-v2/src/privacy.rs @@ -32,7 +32,12 @@ use std::{marker::PhantomData, sync::Arc}; // ============================================================================ const PALLET: &[u8] = b"ShieldedPool"; -const TREE_DEPTH: usize = 20; + +/// Maximum number of Merkle leaves the RPC will load per request. +/// Prevents DoS via unbounded O(n) storage reads on deep trees. +/// At tree depth 20 the theoretical maximum is 2^20 ≈ 1M leaves; +/// we cap far below that to keep RPC latency bounded. +const MAX_RPC_LEAVES: u32 = 100_000; /// Builds `twox_128(pallet) ++ twox_128(item)` (32 bytes — `StorageValue` key). fn value_key(item: &[u8]) -> Vec { @@ -168,6 +173,25 @@ fn pool_not_initialized() -> ErrorObject<'static> { ) } +fn pool_is_empty() -> ErrorObject<'static> { + ErrorObject::owned( + ErrorCode::InternalError.code(), + "Shielded pool has no commitments", + None::<()>, + ) +} + +fn too_many_leaves(size: u32) -> ErrorObject<'static> { + ErrorObject::owned( + ErrorCode::InternalError.code(), + format!( + "tree_size {size} exceeds RPC limit {MAX_RPC_LEAVES}; \ + use an archive node or paginated access" + ), + None::<()>, + ) +} + // ============================================================================ // Storage helper — shared across all handler methods // ============================================================================ @@ -186,15 +210,15 @@ fn read_storage, BE: sc_client_api::Backe // Pure Merkle path builder — extracted for testability // // Mirrors `IncrementalMerkleTree::generate_proof` in the pallet exactly. -// Returns a `TREE_DEPTH`-element sibling path (raw bytes). +// Returns a `DEFAULT_TREE_DEPTH`-element sibling path (raw bytes). // ============================================================================ fn build_merkle_path(leaves: &[[u8; 32]], leaf_index: usize) -> Vec<[u8; 32]> { let mut current_level = leaves.to_vec(); - let mut path = Vec::with_capacity(TREE_DEPTH); + let mut path = Vec::with_capacity(DEFAULT_TREE_DEPTH); let mut target = leaf_index; - for level in 0..TREE_DEPTH { + for level in 0..DEFAULT_TREE_DEPTH { // Pad odd levels with the canonical zero hash for this level. if current_level.len() % 2 != 0 { current_level.push(get_zero_hash_cached(level)); @@ -254,7 +278,10 @@ where let tree_size = u32::decode(&mut &size_data[..]).map_err(internal_error)?; if tree_size == 0 { - return Err(pool_not_initialized()); + return Err(pool_is_empty()); + } + if tree_size > MAX_RPC_LEAVES { + return Err(too_many_leaves(tree_size)); } if leaf_index >= tree_size { return Err(invalid_params(format!( @@ -292,7 +319,7 @@ where root: format!("0x{}", hex::encode(root.as_bytes())), path, leaf_index, - tree_depth: TREE_DEPTH as u32, + tree_depth: DEFAULT_TREE_DEPTH as u32, }) } @@ -316,7 +343,10 @@ where let tree_size = u32::decode(&mut &size_data[..]).map_err(internal_error)?; if tree_size == 0 { - return Err(pool_not_initialized()); + return Err(pool_is_empty()); + } + if tree_size > MAX_RPC_LEAVES { + return Err(too_many_leaves(tree_size)); } // Load all leaves; find the matching commitment @@ -358,7 +388,7 @@ where root: format!("0x{}", hex::encode(root.as_bytes())), path, leaf_index, - tree_depth: TREE_DEPTH as u32, + tree_depth: DEFAULT_TREE_DEPTH as u32, }) } @@ -401,7 +431,7 @@ where let commitment_count = u32::decode(&mut &size_data[..]).map_err(internal_error)?; if commitment_count == 0 { - return Err(pool_not_initialized()); + return Err(pool_is_empty()); } // Next asset ID — drives the per-asset balance scan @@ -550,8 +580,8 @@ mod tests { let path = build_merkle_path(&leaves, 0); assert_eq!( path.len(), - TREE_DEPTH, - "path len must be {TREE_DEPTH} for n={n}" + DEFAULT_TREE_DEPTH, + "path len must be {DEFAULT_TREE_DEPTH} for n={n}" ); } } @@ -613,7 +643,7 @@ mod tests { // After level 0 the single leaf is hashed with zero_hash[0] to form the // level-1 node. The sibling at level 1 must be zero_hash[1], and so on. #[allow(clippy::needless_range_loop)] - for level in 1..TREE_DEPTH { + for level in 1..DEFAULT_TREE_DEPTH { assert_eq!(path[level], get_zero_hash_cached(level)); } } @@ -868,4 +898,81 @@ mod tests { assert_eq!(h256, H256::zero()); } } + + // ------------------------------------------------------------------------- + // Error constructors + // ------------------------------------------------------------------------- + + mod error_constructors { + use super::*; + use jsonrpsee::types::error::ErrorCode; + + #[test] + fn pool_not_initialized_uses_internal_error_code() { + let e = pool_not_initialized(); + assert_eq!(e.code(), ErrorCode::InternalError.code()); + } + + #[test] + fn pool_is_empty_uses_internal_error_code() { + let e = pool_is_empty(); + assert_eq!(e.code(), ErrorCode::InternalError.code()); + } + + #[test] + fn pool_is_empty_message_differs_from_not_initialized() { + let empty = pool_is_empty(); + let uninit = pool_not_initialized(); + // Must produce distinct messages so callers can distinguish the two states. + assert_ne!(empty.message(), uninit.message()); + } + + #[test] + fn too_many_leaves_includes_tree_size_in_message() { + let e = too_many_leaves(999_999); + assert!( + e.message().contains("999999"), + "message must contain the tree_size" + ); + } + + #[test] + fn too_many_leaves_includes_limit_in_message() { + let e = too_many_leaves(1); + assert!( + e.message().contains(&MAX_RPC_LEAVES.to_string()), + "message must contain MAX_RPC_LEAVES" + ); + } + + #[test] + fn too_many_leaves_uses_internal_error_code() { + let e = too_many_leaves(1); + assert_eq!(e.code(), ErrorCode::InternalError.code()); + } + } + + // ------------------------------------------------------------------------- + // MAX_RPC_LEAVES constant sanity + // ------------------------------------------------------------------------- + + mod rpc_leaf_cap { + use super::*; + + #[test] + fn max_rpc_leaves_is_below_tree_capacity() { + // Tree capacity = 2^DEFAULT_TREE_DEPTH. Cap must be strictly less + // to provide meaningful DoS protection. + let capacity: u64 = 1u64 << DEFAULT_TREE_DEPTH; + assert!( + (MAX_RPC_LEAVES as u64) < capacity, + "MAX_RPC_LEAVES {MAX_RPC_LEAVES} must be < tree capacity {capacity}" + ); + } + + #[test] + fn max_rpc_leaves_is_nonzero() { + assert!(MAX_RPC_LEAVES > 0); + } + } } diff --git a/frame/shielded-pool/src/operations/fees.rs b/frame/shielded-pool/src/operations/fees.rs index ea4b6b17..55e54d5c 100644 --- a/frame/shielded-pool/src/operations/fees.rs +++ b/frame/shielded-pool/src/operations/fees.rs @@ -47,6 +47,8 @@ impl FeeOperation { let amount_u128: u128 = amount.saturated_into(); + ensure!(!amount.is_zero(), Error::::InvalidAmount); + // amount must fit in u64 (circuit signal size) ensure!(amount_u128 <= u64::MAX as u128, Error::::InvalidAmount); @@ -125,6 +127,8 @@ impl FeeOperation { let amount_u128: u128 = amount.saturated_into(); + ensure!(!amount.is_zero(), Error::::InvalidAmount); + // Resolve the registered H160 for this validator. let evm_address = T::Relayer::registered_evm_address(&validator) .ok_or(Error::::RelayerNotRegistered)?; @@ -411,6 +415,32 @@ mod tests { }); } + #[test] + fn claim_shielded_zero_amount_fails_with_invalid_amount() { + // amount == 0 must be rejected before the ZK check. A disclosure proof that + // encodes value=0 is cryptographically valid but inserts a worthless leaf into + // the Merkle tree — cheap spam that wastes tree capacity. + new_test_ext().execute_with(|| { + let validator: u64 = 1; + let asset_id = setup_asset(); + mock_pending_fees_set(validator, asset_id, 500u128); + + let commitment = make_commitment(); + assert_noop!( + FeeOperation::claim_shielded::( + validator, + commitment, + 0u128, + asset_id, + make_memo(), + make_proof(), + make_signals(&commitment, 0u128, asset_id), + ), + crate::pallet::Error::::InvalidAmount + ); + }); + } + // ── ZK proof / public_signals validation ───────────────────────────────── /// A proof of 128 zero bytes — the MockZkVerifier sentinel for "cryptographically @@ -842,4 +872,23 @@ mod tests { assert_eq!(mock_pending_fees_get(validator, asset_id), total - claim); }); } + + #[test] + fn claim_to_evm_zero_amount_fails() { + // amount == 0 must be rejected: a zero-value EVM claim is a no-op that wastes + // block space and emits a misleading event without moving any funds. + new_test_ext().execute_with(|| { + let validator: u64 = 1; + let asset_id = setup_asset(); + + mock_evm_address_set(validator, alice_evm()); + mock_pending_fees_set(validator, asset_id, 500u128); + fund_pool(asset_id, 500u128); + + assert_noop!( + FeeOperation::claim_to_evm::(validator, asset_id, 0u128), + crate::pallet::Error::::InvalidAmount + ); + }); + } } diff --git a/frame/shielded-pool/src/operations/private_transfer.rs b/frame/shielded-pool/src/operations/private_transfer.rs index c0cce3cf..ede651f3 100644 --- a/frame/shielded-pool/src/operations/private_transfer.rs +++ b/frame/shielded-pool/src/operations/private_transfer.rs @@ -24,6 +24,10 @@ impl PrivateTransferOperation { fee: <::Currency as Currency<::AccountId>>::Balance, relayer_evm: Option, ) -> DispatchResult { + ensure!( + nullifiers.len() == commitments.len(), + Error::::TooManyInputsOrOutputs + ); ensure!( encrypted_memos.len() == commitments.len(), Error::::MemoCommitmentMismatch @@ -572,4 +576,28 @@ mod tests { ); }); } + + #[test] + fn execute_nullifier_commitment_count_mismatch_fails() { + // 1 nullifier but 2 output commitments: structurally inconsistent with the + // fixed 2-in/2-out circuit. Must be rejected by the pallet before the ZK check + // so that benchmark mode (no verifier) cannot break value conservation. + new_test_ext().execute_with(|| { + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + + assert_noop!( + PrivateTransferOperation::execute::( + proof(), + KNOWN_ROOT, + nullifiers_of(&[0xF1]), // 1 nullifier + commitments_of(&[0xF2, 0xF3]), // 2 commitments + memos_of(2), + 0u32, + 0u128, + None, + ), + Error::::TooManyInputsOrOutputs + ); + }); + } } diff --git a/frame/shielded-pool/src/operations/shield.rs b/frame/shielded-pool/src/operations/shield.rs index 42cdf825..460d0d0d 100644 --- a/frame/shielded-pool/src/operations/shield.rs +++ b/frame/shielded-pool/src/operations/shield.rs @@ -31,6 +31,11 @@ impl ShieldOperation { Error::::InvalidMemoSize ); + ensure!( + !CommitmentRepository::exists::(&commitment), + Error::::CommitmentAlreadyExists + ); + T::Currency::transfer( &depositor, &Pallet::::pool_account_id(), @@ -318,4 +323,29 @@ mod tests { assert!(!ShieldOperation::is_asset_verified::(id2)); }); } + + #[test] + fn execute_duplicate_commitment_fails() { + // A second shield call with the same commitment bytes must be rejected to prevent + // Merkle-tree spam: an attacker could flood the tree with duplicate leaves, + // consuming tree capacity while the associated notes remain unspendable (a single + // nullifier can only be used once). + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + let c = commitment(0xDE); + + assert_ok!(ShieldOperation::execute::( + 1u64, + asset_id, + 500u128, + c, + memo_valid(), + )); + + assert_noop!( + ShieldOperation::execute::(1u64, asset_id, 500u128, c, memo_valid()), + crate::pallet::Error::::CommitmentAlreadyExists + ); + }); + } } diff --git a/frame/shielded-pool/src/operations/unshield.rs b/frame/shielded-pool/src/operations/unshield.rs index 3e1bba8a..d3e12487 100644 --- a/frame/shielded-pool/src/operations/unshield.rs +++ b/frame/shielded-pool/src/operations/unshield.rs @@ -30,6 +30,7 @@ impl UnshieldOperation { ) -> DispatchResult { let asset = AssetRepository::get_asset::(asset_id).ok_or(Error::::InvalidAssetId)?; ensure!(asset.is_verified, Error::::AssetNotVerified); + ensure!(!amount.is_zero(), Error::::InvalidAmount); ensure!( recipient != Pallet::::pool_account_id(), Error::::InvalidRecipient @@ -235,6 +236,32 @@ mod tests { }); } + #[test] + fn execute_zero_amount_fails() { + // amount == 0 must be rejected before any ZK check so that benchmark mode + // (which skips the verifier) cannot mark a nullifier as spent without moving + // any funds. + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + fund_pool(asset_id, 1_000u128); + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + + assert_noop!( + UnshieldOperation::execute::( + proof(), + KNOWN_ROOT, + nullifier(0x77), + asset_id, + 0u128, + 2u64, + 0u128, + None, + ), + crate::pallet::Error::::InvalidAmount + ); + }); + } + #[test] fn execute_invalid_recipient_pool_account_fails() { new_test_ext().execute_with(|| { diff --git a/frame/zk-verifier/src/lib.rs b/frame/zk-verifier/src/lib.rs index bb30e527..1de81d4b 100644 --- a/frame/zk-verifier/src/lib.rs +++ b/frame/zk-verifier/src/lib.rs @@ -205,6 +205,13 @@ pub mod pallet { verification_key.len() >= 256, Error::::InvalidVerificationKey ); + // Prevent silent overwrite of an existing VK. Replacing a key that is + // already referenced by recorded stats would desync the stats from the + // actual key in use. Use set_active_version to switch active versions. + ensure!( + !VerificationKeys::::contains_key(circuit_id, version), + Error::::CircuitAlreadyExists + ); VerificationKeys::::insert( circuit_id, @@ -337,6 +344,11 @@ pub mod pallet { entry.verification_key.len() >= 256, Error::::InvalidVerificationKey ); + // Same silent-overwrite protection as single register. + ensure!( + !VerificationKeys::::contains_key(entry.circuit_id, entry.version), + Error::::CircuitAlreadyExists + ); VerificationKeys::::insert( entry.circuit_id, @@ -602,6 +614,30 @@ mod tests { }); } + #[test] + fn register_vk_rejects_duplicate_circuit_version() { + // A second Root call with the same (circuit_id, version) must be rejected. + // Silently overwriting would desync VerificationStats from the actual key in + // use and could replace a live key without an on-chain trace. + new_test_ext().execute_with(|| { + assert_ok!(ZkVerifier::register_verification_key( + root().into(), + CircuitId::TRANSFER, + 1, + vk_bytes() + )); + assert_noop!( + ZkVerifier::register_verification_key( + root().into(), + CircuitId::TRANSFER, + 1, + vk_bytes() + ), + Error::::CircuitAlreadyExists + ); + }); + } + // ── set_active_version ──────────────────────────────────────────────────── #[test] @@ -1051,6 +1087,24 @@ mod tests { }); } + #[test] + fn batch_register_rejects_duplicate_circuit_version() { + // If any entry in the batch tries to overwrite an existing (circuit_id, version) + // the entire batch must fail atomically — no partial state changes. + new_test_ext().execute_with(|| { + insert_vk(CircuitId::TRANSFER, 1); + + let entries: BoundedVec> = + vec![make_vk_entry(CircuitId::TRANSFER, 1, false)] // version 1 already exists + .try_into() + .unwrap(); + assert_noop!( + ZkVerifier::batch_register_verification_keys(root().into(), entries), + Error::::CircuitAlreadyExists + ); + }); + } + // ── runtime_api_get_circuit_version_info ────────────────────────────────── #[test] diff --git a/frame/zk-verifier/src/port.rs b/frame/zk-verifier/src/port.rs index aadf3505..b674efb1 100644 --- a/frame/zk-verifier/src/port.rs +++ b/frame/zk-verifier/src/port.rs @@ -8,7 +8,7 @@ use crate::{ Pallet, encoding, - pallet::{ActiveCircuitVersion, Config, Error, VerificationKeys}, + pallet::{ActiveCircuitVersion, Config, Error, VerificationKeys, VerificationStats}, types::CircuitId, verifier, }; @@ -82,6 +82,14 @@ impl ZkVerifierPort for Pallet { fee: u128, version: Option, ) -> Result { + // The 2-in/2-out circuit encodes one nullifier per input note and one + // commitment per output note. A mismatch produces garbage public inputs + // that pass in benchmark/test mode (do_verify returns true unconditionally) + // but would represent a structurally invalid proof in production. + frame_support::ensure!( + nullifiers.len() == commitments.len(), + Error::::InvalidPublicInputs + ); let raw = encoding::encode_transfer(merkle_root, nullifiers, commitments, asset_id, fee); verifier::verify::(CircuitId::TRANSFER, version, proof, raw).map(|(ok, _)| ok) } @@ -145,10 +153,24 @@ impl ZkVerifierPort for Pallet { .map(|s| encoding::decode_disclosure_signals(s).map(PublicInputs::new)) .collect::, _>>()?; - Groth16Verifier::batch_verify(&vk, &all_inputs, &batch_proofs) - .map_err(|_| Error::::BatchVerificationFailed)?; - - Ok(true) + let count = proofs.len() as u64; + + match Groth16Verifier::batch_verify(&vk, &all_inputs, &batch_proofs) { + Ok(_) => { + VerificationStats::::mutate(CircuitId::DISCLOSURE, resolved, |s| { + s.total_verifications = s.total_verifications.saturating_add(count); + s.successful_verifications = s.successful_verifications.saturating_add(count); + }); + Ok(true) + } + Err(_) => { + VerificationStats::::mutate(CircuitId::DISCLOSURE, resolved, |s| { + s.total_verifications = s.total_verifications.saturating_add(count); + s.failed_verifications = s.failed_verifications.saturating_add(count); + }); + Err(Error::::BatchVerificationFailed.into()) + } + } } fn verify_private_link_proof( @@ -400,6 +422,53 @@ mod tests { }); } + #[test] + fn transfer_nullifier_commitment_count_mismatch_is_rejected() { + // The 2-in/2-out circuit requires an equal number of nullifiers and + // commitments. A mismatch produces structurally invalid public inputs that + // in benchmark/test mode would still "verify" because do_verify returns true + // unconditionally. The guard in verify_transfer_proof catches this before + // reaching the verifier. + new_test_ext().execute_with(|| { + assert_err!( + as ZkVerifierPort>::verify_transfer_proof( + &proof(), + &merkle_root(), + &[[0xAAu8; 32]], // 1 nullifier + &[[0xBBu8; 32], [0xCCu8; 32]], // 2 commitments + 0, + 0, + Some(1), + ), + Error::::InvalidPublicInputs + ); + }); + } + + #[test] + fn transfer_empty_nullifiers_with_empty_commitments_is_accepted_by_guard() { + // 0 nullifiers == 0 commitments satisfies the count guard. EmptyPublicInputs + // fires next inside verifier::verify (merkle_root is the only remaining + // element but that still gives 1 input after encode_transfer, so actually + // EmptyProof doesn't fire — but CircuitNotFound does if no VK). Let's just + // confirm the count guard itself doesn't reject the 0==0 case. + new_test_ext().execute_with(|| { + // No VK registered → CircuitNotFound, not InvalidPublicInputs. + assert_err!( + as ZkVerifierPort>::verify_transfer_proof( + &proof(), + &merkle_root(), + &[], + &[], + 0, + 0, + None, + ), + Error::::CircuitNotFound + ); + }); + } + // ── verify_unshield_proof ────────────────────────────────────────────────── #[test] @@ -723,6 +792,64 @@ mod tests { }); } + // ── batch stats tracking ─────────────────────────────────────────────────── + + #[test] + fn batch_failure_increments_failed_and_total_stats() { + // Groth16Verifier rejects bogus data → stats must record the failure. + new_test_ext().execute_with(|| { + use crate::pallet::VerificationStats; + + insert_vk(CircuitId::DISCLOSURE, 1); + activate(CircuitId::DISCLOSURE, 1); + + let _ = as ZkVerifierPort>::batch_verify_disclosure_proofs( + &[proof(), proof()], + &[valid_signals(), valid_signals()], + Some(1), + ); + + let stats = VerificationStats::::get(CircuitId::DISCLOSURE, 1u32); + assert_eq!( + stats.total_verifications, 2, + "total should count batch size" + ); + assert_eq!(stats.failed_verifications, 2, "both proofs failed"); + assert_eq!(stats.successful_verifications, 0); + }); + } + + #[test] + fn batch_stats_are_isolated_from_single_verify_stats() { + // batch and single-proof stats accumulate independently into the same counter. + new_test_ext().execute_with(|| { + use crate::pallet::VerificationStats; + + insert_vk(CircuitId::DISCLOSURE, 1); + activate(CircuitId::DISCLOSURE, 1); + + // Single verify (succeeds in test build via do_verify mock). + let _ = as ZkVerifierPort>::verify_disclosure_proof( + &proof(), + &valid_signals(), + Some(1), + ); + + // Batch verify (fails with bogus data — stats still update). + let _ = as ZkVerifierPort>::batch_verify_disclosure_proofs( + &[proof()], + &[valid_signals()], + Some(1), + ); + + let stats = VerificationStats::::get(CircuitId::DISCLOSURE, 1u32); + // 1 from single (success) + 1 from batch (failure) + assert_eq!(stats.total_verifications, 2); + assert_eq!(stats.successful_verifications, 1); + assert_eq!(stats.failed_verifications, 1); + }); + } + // ── verify_private_link_proof ────────────────────────────────────────────── #[test] From 896bc78e9ce3d4dfa0dade3b2bacc8603d22c732 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Thu, 30 Apr 2026 22:00:31 -0400 Subject: [PATCH 2/2] test: ensure MAX_RPC_LEAVES is non-zero using const assertion --- client/rpc-v2/src/privacy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/rpc-v2/src/privacy.rs b/client/rpc-v2/src/privacy.rs index 2796119b..b0c3acb9 100644 --- a/client/rpc-v2/src/privacy.rs +++ b/client/rpc-v2/src/privacy.rs @@ -972,7 +972,7 @@ mod tests { #[test] fn max_rpc_leaves_is_nonzero() { - assert!(MAX_RPC_LEAVES > 0); + const _: () = assert!(MAX_RPC_LEAVES > 0); } } }