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
2 changes: 1 addition & 1 deletion Cargo.lock

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

30 changes: 30 additions & 0 deletions client/rpc-v2/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Changelog for `fc-rpc-v2`

## [Unreleased]

## [2.0.1-dev] - 2026-10-18

### Fixed

- `chain_isValidator` reads `pallet_session::Validators` (the validator
accounts) instead of `pallet_aura::Authorities` (their Aura keys). An operator
that registered its session keys from its account, with a key that is not the
account, was reported as not a validator.
- A `Session::Validators` value that does not decode exactly, truncated or with
trailing bytes, is an internal error instead of a partial match. The decoding
is a pure function with unit tests.

### Documentation

- The module docs of `privacy` list `privacy_getSubtreeRoots`, state that every
endpoint reads the best block, and describe the proof gate as it is: half the
cores at once, a bounded queue, a bounded wait.

## [2.0.0-dev]

Orbinum's node RPC beside the Ethereum layer:

- `privacy_*`: Merkle root, proofs by leaf index and by commitment, subtree
roots, nullifier status and pool statistics of the shielded pool, read from
the best block; proofs run on blocking threads behind a bounded gate.
- `chain_isValidator`: whether an SS58 account is an Aura authority.
2 changes: 1 addition & 1 deletion client/rpc-v2/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "fc-rpc-v2"
version = "2.0.0-dev"
version = "2.0.1-dev"
license = "GPL-3.0-or-later WITH Classpath-exception-2.0"
description = "Ethereum RPC (web3) compatibility layer for Substrate."
authors = { workspace = true }
Expand Down
12 changes: 6 additions & 6 deletions client/rpc-v2/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ io.merge(ChainRpc::new(client.clone()).into_rpc())?;

### `chain_isValidator`

Returns `true` if the given SS58-encoded account is an active Aura block author.
Returns `true` if the given SS58-encoded account validates the current session. A validator is its account, not its Aura key: an operator may register its session keys from that account.

Reads `pallet_aura::Authorities` directly from storage — no runtime API call is made. Intended for UIs that need to gate actions on validator eligibility without waiting for an extrinsic to fail.
Reads `pallet_session::Validators` at the best block directly from storage — no runtime API call is made. Intended for UIs that need to gate actions on validator eligibility without waiting for an extrinsic to fail.

**Parameters**

Expand All @@ -63,11 +63,11 @@ Reads `pallet_aura::Authorities` directly from storage — no runtime API call i
{ "jsonrpc": "2.0", "result": true, "id": 1 }
```

**Storage read:** `Twox128("Aura") ++ Twox128("Authorities")` → SCALE `Vec<[u8; 32]>`
**Storage read:** `Twox128("Session") ++ Twox128("Validators")` → SCALE `Vec<AccountId32>`, which must decode exactly

**Errors**
- `InvalidParams` — invalid SS58 address
- `InternalError` — storage decode failure
- `InternalError` — the stored value does not decode exactly (truncated or trailing bytes)

---

Expand Down Expand Up @@ -240,11 +240,11 @@ All endpoints query pallet storage directly using `sc_client_api::StorageProvide
- No runtime upgrade required to add or change these endpoints.
- Keys are built with the standard Substrate hasher layout:

### `chain.rs` — `pallet_aura`
### `chain.rs` — `pallet_session`

| Storage item | Key layout |
|---|---|
| `Authorities` | `Twox128("Aura") ++ Twox128("Authorities")` |
| `Validators` | `Twox128("Session") ++ Twox128("Validators")` |

### `privacy.rs` — `pallet_shielded_pool`

Expand Down
100 changes: 73 additions & 27 deletions client/rpc-v2/src/chain.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
//! Chain RPC — general chain state queries.
//!
//! Endpoints:
//! - `chain_isValidator` — returns `true` if the given SS58 account is an active Aura validator.
//! - `chain_isValidator` — returns `true` if the given SS58 account is an active session validator.

use jsonrpsee::{
core::RpcResult,
proc_macros::rpc,
types::error::{ErrorCode, ErrorObject},
};
use sc_client_api::StorageProvider as ScStorageProvider;
use scale_codec::Decode;
use scale_codec::DecodeAll;
use sp_blockchain::HeaderBackend;
use sp_core::{crypto::Ss58Codec, storage::StorageKey};
use sp_crypto_hashing::twox_128;
Expand All @@ -20,9 +20,21 @@ use std::{marker::PhantomData, sync::Arc};
// Storage key helpers
// ============================================================================

/// `twox_128("Aura") ++ twox_128("Authorities")` — `StorageValue` key for `pallet_aura`.
fn aura_authorities_key() -> Vec<u8> {
[twox_128(b"Aura"), twox_128(b"Authorities")].concat()
/// `twox_128("Session") ++ twox_128("Validators")` — `StorageValue` key for the
/// current session's validator accounts in `pallet_session`.
fn session_validators_key() -> Vec<u8> {
[twox_128(b"Session"), twox_128(b"Validators")].concat()
}

/// Whether `account` is in the SCALE-encoded `Session::Validators` value `raw`
/// (`Vec<AccountId32>`, each account its 32 bytes). An absent value means no
/// validators. A value that does not decode exactly is an error, not a miss.
fn contains_validator(raw: Option<&[u8]>, account: &[u8; 32]) -> Result<bool, scale_codec::Error> {
let Some(mut bytes) = raw else {
return Ok(false);
};
let validators = Vec::<[u8; 32]>::decode_all(&mut bytes)?;
Ok(validators.contains(account))
}

fn read_storage<B: BlockT, C: ScStorageProvider<B, BE>, BE: sc_client_api::Backend<B>>(
Expand All @@ -41,9 +53,11 @@ fn read_storage<B: BlockT, C: ScStorageProvider<B, BE>, BE: sc_client_api::Backe

#[rpc(server)]
pub trait ChainApi {
/// Returns `true` if the given SS58-encoded account is an active Aura validator.
/// Returns `true` if the given SS58-encoded account validates the current session.
///
/// Reads `pallet_aura::Authorities` directly from storage at the best known block.
/// A validator is its account, not its Aura key: an operator may register its
/// session keys from that account, so the key need not equal the account.
/// Reads `pallet_session::Validators` from storage at the best known block.
/// No runtime API call is performed.
#[method(name = "chain_isValidator")]
fn is_validator(&self, account: String) -> RpcResult<bool>;
Expand Down Expand Up @@ -104,38 +118,70 @@ where
.map_err(|e| invalid_params(format!("invalid SS58 address: {e:?}")))?;
let target: [u8; 32] = *account_id.as_ref();

// Read `Aura::Authorities` from storage at the best known block.
// Read `Session::Validators` from storage at the best known block.
let best = self.client.info().best_hash;
let raw = read_storage(&*self.client, best, aura_authorities_key())?;

let Some(bytes) = raw else {
// Storage item absent → chain has no configured validators.
return Ok(false);
};
let raw = read_storage(&*self.client, best, session_validators_key())?;

// SCALE-decode as `Vec<[u8; 32]>`.
// `AuraId = sr25519::Public`, which serialises as a plain 32-byte array.
let authorities = Vec::<[u8; 32]>::decode(&mut bytes.as_slice())
.map_err(|e| internal_error(format!("failed to decode Aura::Authorities: {e}")))?;

Ok(authorities.iter().any(|auth| auth == &target))
contains_validator(raw.as_deref(), &target)
.map_err(|e| internal_error(format!("failed to decode Session::Validators: {e}")))
}
}

#[cfg(test)]
mod tests {
use super::aura_authorities_key;
use super::{contains_validator, session_validators_key};
use scale_codec::Encode;
use sp_crypto_hashing::twox_128;

const ALICE: [u8; 32] = [1; 32];
const BOB: [u8; 32] = [2; 32];

#[test]
fn session_validators_key_is_32_bytes() {
assert_eq!(session_validators_key().len(), 32);
}

#[test]
fn session_validators_key_uses_correct_hashes() {
let key = session_validators_key();
assert_eq!(&key[..16], &twox_128(b"Session"));
assert_eq!(&key[16..], &twox_128(b"Validators"));
}

#[test]
fn absent_value_has_no_validators() {
assert_eq!(contains_validator(None, &ALICE), Ok(false));
}

#[test]
fn empty_set_has_no_validators() {
let raw = Vec::<[u8; 32]>::new().encode();
assert_eq!(contains_validator(Some(&raw), &ALICE), Ok(false));
}

#[test]
fn member_is_found() {
let raw = vec![BOB, ALICE].encode();
assert_eq!(contains_validator(Some(&raw), &ALICE), Ok(true));
assert_eq!(contains_validator(Some(&raw), &BOB), Ok(true));
}

#[test]
fn non_member_is_not_found() {
let raw = vec![BOB].encode();
assert_eq!(contains_validator(Some(&raw), &ALICE), Ok(false));
}

#[test]
fn aura_authorities_key_is_32_bytes() {
assert_eq!(aura_authorities_key().len(), 32);
fn truncated_value_is_an_error() {
let raw = vec![ALICE].encode();
assert!(contains_validator(Some(&raw[..raw.len() - 1]), &ALICE).is_err());
}

#[test]
fn aura_authorities_key_uses_correct_hashes() {
let key = aura_authorities_key();
assert_eq!(&key[..16], &twox_128(b"Aura"));
assert_eq!(&key[16..], &twox_128(b"Authorities"));
fn trailing_bytes_are_an_error() {
let mut raw = vec![ALICE].encode();
raw.push(0);
assert!(contains_validator(Some(&raw), &ALICE).is_err());
}
}
16 changes: 11 additions & 5 deletions client/rpc-v2/src/privacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,19 @@
//! - `privacy_getMerkleRoot` — current Merkle root as hex
//! - `privacy_getMerkleProof` — sibling path for a leaf index
//! - `privacy_getMerkleProofByCommitment` — sibling path for a commitment
//! - `privacy_getSubtreeRoots` — a tree's level-6 subtree roots
//! - `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.
//! Every endpoint reads the best block, not the finalized one: a wallet builds
//! a path for a note that landed a block ago, and the root it proves against
//! stays valid for the retention window whichever fork imports the spend.
//!
//! The two proof endpoints and `privacy_getSubtreeRoots` run on blocking
//! threads, at most `proof_slots` (half the cores) at once; the rest queue, up
//! to `MAX_QUEUED_PROOFS` for at most `MAX_PROOF_WAIT`, and are refused as busy
//! past that. 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 @@ -232,7 +238,7 @@ impl<C, B, BE> PrivacyRpc<C, B, BE> {
///
/// A request takes a free slot, or waits for one behind at most `max_queued`
/// others and for at most `max_wait`; it is refused only when the queue is full
/// or the wait runs out. Both proof RPCs are `blocking`, so each waits on its own
/// or the wait runs out. Every gated RPC is `blocking`, so each waits on its own
/// thread. A slot is freed when its permit drops, even if the proof panics.
struct ProofGate {
state: Mutex<GateState>,
Expand Down
Loading