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
4 changes: 2 additions & 2 deletions Cargo.lock

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

111 changes: 100 additions & 11 deletions client/rpc-v2/src/privacy.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
//! Privacy RPC — Orbinum shielded pool (4 endpoints, 1 file)
//! Privacy RPC — Orbinum shielded pool.
//!
//! Endpoints:
//! - `privacy_getMerkleRoot` — current Merkle root as hex
//! - `privacy_getMerkleProof` — sibling path for a leaf index
//! - `privacy_getNullifierStatus` — whether a nullifier has been spent
//! - `privacy_getPoolStats` — aggregate pool statistics
//! - `privacy_getMerkleRoot` — current Merkle root as hex
//! - `privacy_getMerkleProof` — sibling path for a leaf index
//! - `privacy_getMerkleProofByCommitment` — sibling path for a commitment
//! - `privacy_getNullifierStatus` — whether a nullifier has been spent
//! - `privacy_getPoolStats` — aggregate pool statistics
//!
//! The two proof endpoints run on blocking threads and at most
//! `MAX_CONCURRENT_PROOFS` at once: a sealed tree's path is rebuilt from
//! leaves, and inline on the connection task a few of them would stall every
//! other RPC.

use jsonrpsee::{
core::RpcResult,
Expand Down Expand Up @@ -37,7 +43,13 @@ use sp_blockchain::HeaderBackend;
use sp_core::{storage::StorageKey, H256};
use sp_crypto_hashing::{blake2_128, twox_128};
use sp_runtime::traits::Block as BlockT;
use std::{marker::PhantomData, sync::Arc};
use std::{
marker::PhantomData,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};

// ============================================================================
// Storage key helpers
Expand Down Expand Up @@ -123,14 +135,15 @@ pub trait PrivacyApi {
fn get_merkle_root(&self) -> RpcResult<String>;

/// Returns a Merkle sibling-path proof for the leaf at `leaf_index`.
#[method(name = "privacy_getMerkleProof")]
#[method(name = "privacy_getMerkleProof", blocking)]
fn get_merkle_proof(&self, leaf_index: u32) -> RpcResult<MerkleProofResponse>;

/// Returns a Merkle sibling-path proof for the given commitment (`0x`-prefixed hex, 32 bytes).
/// Resolves the leaf index via the on-chain reverse index (O(1)) and reads the
/// stored sibling path (O(depth)). Root and path come from the same block.
/// Returns an error if the commitment is not found in the tree.
#[method(name = "privacy_getMerkleProofByCommitment")]
/// Resolves the leaf index via the on-chain reverse index (O(1)), then reads
/// the stored siblings, rebuilding a sealed tree's pruned ones from its
/// leaves. Root and path come from the same block. Returns an error if the
/// commitment is not found in the tree.
#[method(name = "privacy_getMerkleProofByCommitment", blocking)]
fn get_merkle_proof_by_commitment(&self, commitment: String) -> RpcResult<MerkleProofResponse>;

/// Returns whether the nullifier (`0x`-prefixed hex, 32 bytes) has been spent.
Expand All @@ -146,21 +159,62 @@ pub trait PrivacyApi {
// RPC server
// ============================================================================

/// Merkle proofs computed at once, at most. A sealed tree's path rebuilds
/// pruned siblings from its leaves, so a proof costs real CPU; past this, a
/// request is refused as busy rather than queued behind the others.
const MAX_CONCURRENT_PROOFS: usize = 4;

/// Privacy RPC server for the Orbinum shielded pool.
pub struct PrivacyRpc<C, B, BE> {
client: Arc<C>,
proofs: ProofGate,
_ph: PhantomData<(B, BE)>,
}

impl<C, B, BE> PrivacyRpc<C, B, BE> {
pub fn new(client: Arc<C>) -> Self {
Self {
client,
proofs: ProofGate::new(MAX_CONCURRENT_PROOFS),
_ph: PhantomData,
}
}
}

/// Caps how many proofs run at once. A permit is held for the duration of one
/// request and released when dropped.
struct ProofGate {
in_flight: AtomicUsize,
max: usize,
}

struct ProofPermit<'a>(&'a AtomicUsize);

impl ProofGate {
fn new(max: usize) -> Self {
Self {
in_flight: AtomicUsize::new(0),
max,
}
}

/// A permit, or `None` when `max` proofs are already running.
fn enter(&self) -> Option<ProofPermit<'_>> {
self.in_flight
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
(n < self.max).then_some(n + 1)
})
.ok()
.map(|_| ProofPermit(&self.in_flight))
}
}

impl Drop for ProofPermit<'_> {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::AcqRel);
}
}

// ============================================================================
// Error helpers
// ============================================================================
Expand Down Expand Up @@ -189,6 +243,14 @@ fn pool_not_initialized() -> ErrorObject<'static> {
)
}

fn busy() -> ErrorObject<'static> {
ErrorObject::owned(
ErrorCode::ServerIsBusy.code(),
"Too many Merkle proofs in flight, try again later",
None::<()>,
)
}

fn pool_is_empty() -> ErrorObject<'static> {
ErrorObject::owned(
ErrorCode::InternalError.code(),
Expand Down Expand Up @@ -233,6 +295,7 @@ where
}

fn get_merkle_proof(&self, leaf_index: u32) -> RpcResult<MerkleProofResponse> {
let _permit = self.proofs.enter().ok_or_else(busy)?;
// Both runtime-API calls execute at the same block, so root and path
// can never mismatch.
let best_hash = self.client.info().best_hash;
Expand Down Expand Up @@ -280,6 +343,7 @@ where
}

fn get_merkle_proof_by_commitment(&self, commitment: String) -> RpcResult<MerkleProofResponse> {
let _permit = self.proofs.enter().ok_or_else(busy)?;
let best_hash = self.client.info().best_hash;
let api = self.client.runtime_api();

Expand Down Expand Up @@ -430,6 +494,31 @@ mod tests {
use super::*;
use sp_crypto_hashing::twox_128;

// -------------------------------------------------------------------------
// Proof concurrency gate
// -------------------------------------------------------------------------

mod proof_gate {
use super::*;

#[test]
fn refuses_past_the_cap_and_frees_a_slot_on_drop() {
let gate = ProofGate::new(2);
let a = gate.enter().expect("first permit");
let b = gate.enter().expect("second permit");
assert!(gate.enter().is_none(), "a third proof must be refused");
drop(a);
let c = gate.enter().expect("a slot frees when a permit drops");
drop((b, c));
assert_eq!(gate.in_flight.load(Ordering::Acquire), 0);
}

#[test]
fn the_busy_error_is_the_standard_server_busy_code() {
assert_eq!(busy().code(), ErrorCode::ServerIsBusy.code());
}
}

// -------------------------------------------------------------------------
// Storage key helpers
// -------------------------------------------------------------------------
Expand Down
12 changes: 12 additions & 0 deletions frame/shielded-pool/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to `pallet-shielded-pool` will be documented in this file.

## [Unreleased]

## [0.23.0] - 2026-10-08

### Changed

- `get_merkle_path`: a sealed tree's node missing at or above the prune cut
(pruned under an earlier, higher cut) is rebuilt from the leaves instead of
read as zero, so lowering `SealedTreePrunedBelowLevel` on a live chain keeps
every path valid. The runtime lowers it from 10 to 6 in spec 18: a path now
rebuilds its pruned siblings from 62 leaves instead of 1_022.
- `SealedTreePrunedBelowLevel` documents the trade per cut (`2^(21−c) − 2`
stored nodes, `2^c − 2` leaves per path) and why lowering it is safe.

## [0.22.0] - 2026-10-08

### Changed
Expand Down
2 changes: 1 addition & 1 deletion frame/shielded-pool/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pallet-shielded-pool"
version = "0.22.0"
version = "0.23.0"
description = "Shielded pool pallet for private transactions using ZK proofs"
authors = ["Orbinum Team"]
license = "GPL-3.0-or-later"
Expand Down
15 changes: 8 additions & 7 deletions frame/shielded-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,14 +245,15 @@ pub mod pallet {
/// tree is immutable, so anything dropped here is recomputed from
/// `MerkleLeaves` on demand.
///
/// The trade is storage against query latency, and it is lopsided: nodes
/// concentrate at the bottom, so cutting at level 10 drops 99.8% of the
/// entries (1_048_574 -> 2_046 per tree) while a path costs 2^10 leaf
/// reads and 1_023 Poseidon hashes — about 60ms native, ~180ms in Wasm.
/// Cutting at 12 frees only 0.15% more for four times the work.
/// The trade is storage against query latency. A cut `c` keeps
/// 2^(21−c) − 2 nodes per tree and makes a path read 2^c − 2 leaves:
/// nodes concentrate at the bottom, so each level less halves the reads and
/// doubles what is stored. Paths are served by the public RPC, so the read
/// cost is also what an attacker can make a node pay per request.
///
/// Configurable rather than fixed: the recompute cost tracks validator
/// hardware. Must be non-zero and below the tree depth (`integrity_test`).
/// Lowering it on a live chain is safe: a sealed tree's node pruned under an
/// earlier, higher cut is rebuilt from the leaves when a path needs it.
/// Must be non-zero and below the tree depth (`integrity_test`).
#[pallet::constant]
type SealedTreePrunedBelowLevel: Get<u8>;

Expand Down
4 changes: 4 additions & 0 deletions frame/shielded-pool/src/merkle/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use super::hashing::{hash_pair, hash_pair_poseidon};
use crate::types::Hash;
use sp_std::vec::Vec;

/// Root of a `DEPTH`-level tree holding `leaves` from index 0, the rest empty.
/// Empty input yields the zero leaf, not the empty tree's root.
pub fn compute_root_from_leaves_poseidon<const DEPTH: usize>(leaves: &[Hash]) -> Hash {
if leaves.is_empty() {
return [0u8; 32];
Expand Down Expand Up @@ -41,6 +43,8 @@ pub fn compute_root_from_leaves_poseidon<const DEPTH: usize>(leaves: &[Hash]) ->
current_level.first().copied().unwrap_or([0u8; 32])
}

/// Root of a `DEPTH`-level tree holding `leaves` from index 0, the rest empty.
/// Empty input yields the empty tree's root.
pub fn compute_root_from_leaves<const DEPTH: usize>(leaves: &[Hash]) -> Hash {
if leaves.is_empty() {
let mut current = [0u8; 32];
Expand Down
23 changes: 9 additions & 14 deletions frame/shielded-pool/src/merkle/hashing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,9 @@ use ark_ff::BigInteger;

/// Digest of an empty subtree rooted at `level`.
///
/// Iterative rather than recursive. The recursion this replaces spent one stack
/// frame per level, and `get_zero_hash_cached` falls through to here for any
/// level past its 21-entry table — with `usize` being 32 bits under Wasm, a
/// caller passing a large level would exhaust the runtime's fixed 1 MB stack.
/// A stack overflow there takes the node down rather than failing a call, while
/// a loop just runs long: slow is recoverable, overflowing is not.
/// A loop, not recursion: `get_zero_hash_cached` falls through to here past its
/// table, and recursing once per level could exhaust the runtime's fixed stack —
/// a node-level failure, where a long loop is only slow.
pub fn zero_hash_at_level(level: usize) -> [u8; 32] {
let mut current = [0u8; 32];
for _ in 0..level {
Expand All @@ -23,11 +20,11 @@ pub fn zero_hash_at_level(level: usize) -> [u8; 32] {
current
}

/// Cached zero hashes for Poseidon (lazy-initialized, thread-safe).
/// Zero hashes for levels 0..=20, computed once.
static ZERO_HASHES_POSEIDON: once_cell::race::OnceBox<[[u8; 32]; 21]> =
once_cell::race::OnceBox::new();

/// Get precomputed zero hash at level (optimized with cache).
/// [`zero_hash_at_level`], from the table for the tree's own levels.
#[inline]
pub fn get_zero_hash_cached(level: usize) -> [u8; 32] {
if level < 21 {
Expand All @@ -44,7 +41,7 @@ pub fn get_zero_hash_cached(level: usize) -> [u8; 32] {
zero_hash_at_level(level)
}

/// Hash two nodes together using Poseidon (ZK-friendly, ~300 constraints).
/// Poseidon over two field elements, as the circuits hash Merkle nodes.
#[inline]
pub fn hash_pair_poseidon(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
use ark_bn254::Fr as Bn254Fr;
Expand All @@ -61,10 +58,8 @@ pub fn hash_pair_poseidon(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {

let hash_fr = hasher.hash_2([FieldElement::new(left_fr), FieldElement::new(right_fr)]);

// BN254 `Fr` always yields 32 bytes, so the clamp never binds today. It is
// here because this runs on the block-import path, where slicing past the
// end would panic the node rather than fail a call — the same reason
// `recipient_to_field` is written this way.
// `Fr` always yields 32 bytes, so the clamp never binds; it stays because a
// slice past the end here would panic block import.
let mut hash_bytes = [0u8; 32];
let bigint = hash_fr.inner().into_bigint();
let bytes = bigint.to_bytes_le();
Expand All @@ -73,7 +68,7 @@ pub fn hash_pair_poseidon(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
hash_bytes
}

/// Hash pair — always uses Poseidon.
/// The tree's node hash.
pub fn hash_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
hash_pair_poseidon(left, right)
}
Loading
Loading