diff --git a/client/rpc-v2/src/orbinum/ORBINUM_RPC_API.md b/client/rpc-v2/src/orbinum/ORBINUM_RPC_API.md index 4225e287..43e513c7 100644 --- a/client/rpc-v2/src/orbinum/ORBINUM_RPC_API.md +++ b/client/rpc-v2/src/orbinum/ORBINUM_RPC_API.md @@ -14,18 +14,34 @@ Method names use the `privacy_` prefix. - **Params:** none - **Returns:** `string` - - Current Merkle root as a hex string (typically `0x`-prefixed). + - Current Merkle root as a `0x`-prefixed 32-byte little-endian hex string. ### 2) `privacy_getMerkleProof` - **Params:** - `leaf_index` (`u32`): zero-based index of the commitment leaf. - **Returns:** object (`MerkleProofResponse`) - - `path`: `string[]` (sibling hashes in hex) + - `root`: `string` — Merkle root read from the **same block** as the proof (0x-prefixed LE hex). + - `path`: `string[]` — sibling hashes in 0x-prefixed LE hex, from leaf level to root. - `leaf_index`: `u32` - `tree_depth`: `u32` -### 3) `privacy_getNullifierStatus` +> **Atomicity:** `root` and `path` are always read under the same `best_block` reference, +> eliminating any race condition between a separate `getMerkleRoot` call. + +### 3) `privacy_getMerkleProofByCommitment` + +- **Params:** + - `commitment` (`string`): commitment hash as a `0x`-prefixed 32-byte hex string. +- **Returns:** object (`MerkleProofResponse`) + - `root`: `string` — Merkle root read from the **same block** as the proof (0x-prefixed LE hex). + - `path`: `string[]` — sibling hashes in 0x-prefixed LE hex, from leaf level to root. + - `leaf_index`: `u32` + - `tree_depth`: `u32` + +> **Atomicity:** same guarantee as `privacy_getMerkleProof` — root and path share one block snapshot. + +### 4) `privacy_getNullifierStatus` - **Params:** - `nullifier` (`string`): nullifier hash in hex (with or without `0x` prefix). @@ -33,18 +49,35 @@ Method names use the `privacy_` prefix. - `nullifier`: `string` - `is_spent`: `bool` -### 4) `privacy_getPoolStats` +### 5) `privacy_getPoolStats` - **Params:** none - **Returns:** object (`PoolStatsResponse`) - `merkle_root`: `string` - `commitment_count`: `u32` - `total_balance`: `u128` (minimum units) - - `asset_balances`: `Array<{ asset_id: u32, balance: u128 }>` (solo balances no-cero) + - `asset_balances`: `Array<{ asset_id: u32, balance: u128 }>` (non-zero balances only) - `tree_depth`: `u32` +## MerkleProofResponse shape + +```json +{ + "root": "0x1a2b3c...", + "path": ["0xaabb...", "0xccdd...", "..."], + "leaf_index": 0, + "tree_depth": 20 +} +``` + +All hex strings are **0x-prefixed 32-byte values encoded in little-endian byte order**, +matching the internal Poseidon hash representation used by the shielded-pool pallet. + ## Usage Notes - All methods are query-only and intended for wallets, indexers, and clients. - Hex values are returned as strings. -- `leaf_index` is expected to be within current tree size. \ No newline at end of file +- `leaf_index` is expected to be within current tree size. +- Clients should prefer `privacy_getMerkleProofByCommitment` over calling + `privacy_getMerkleRoot` + `privacy_getMerkleProof` separately, since the combined + endpoint guarantees root/path consistency within a single block. \ No newline at end of file diff --git a/client/rpc-v2/src/orbinum/application/dto/merkle_proof_response.rs b/client/rpc-v2/src/orbinum/application/dto/merkle_proof_response.rs index e5f66696..1e62e6dd 100644 --- a/client/rpc-v2/src/orbinum/application/dto/merkle_proof_response.rs +++ b/client/rpc-v2/src/orbinum/application/dto/merkle_proof_response.rs @@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize}; /// It maps from `domain::MerkleProofPath`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MerkleProofResponse { + /// Current Merkle root (0x-prefixed hex), read from the same block as the proof. + pub root: String, /// Sibling hash path (hex strings). pub path: Vec, /// Leaf index. @@ -18,8 +20,9 @@ pub struct MerkleProofResponse { impl MerkleProofResponse { /// Creates a new `MerkleProofResponse`. - pub fn new(path: Vec, leaf_index: u32, tree_depth: u32) -> Self { + pub fn new(root: String, path: Vec, leaf_index: u32, tree_depth: u32) -> Self { Self { + root, path, leaf_index, tree_depth, @@ -33,9 +36,14 @@ mod tests { #[test] fn should_create_merkle_proof_response() { - let response = - MerkleProofResponse::new(vec!["0xaaaa".to_string(), "0xbbbb".to_string()], 7, 20); + let response = MerkleProofResponse::new( + "0x0000".to_string(), + vec!["0xaaaa".to_string(), "0xbbbb".to_string()], + 7, + 20, + ); + assert_eq!(response.root, "0x0000"); assert_eq!(response.path, vec!["0xaaaa", "0xbbbb"]); assert_eq!(response.leaf_index, 7); assert_eq!(response.tree_depth, 20); diff --git a/client/rpc-v2/src/orbinum/application/services/merkle_proof_service.rs b/client/rpc-v2/src/orbinum/application/services/merkle_proof_service.rs index 29fe9b10..e1c34770 100644 --- a/client/rpc-v2/src/orbinum/application/services/merkle_proof_service.rs +++ b/client/rpc-v2/src/orbinum/application/services/merkle_proof_service.rs @@ -166,6 +166,110 @@ where Ok((root, size, depth)) } + + /// Generates a Merkle proof by leaf index and returns the Merkle root, both + /// read under the **same `best_block`** (atomic — no risk of root/path mismatch). + /// + /// # Errors + /// - `InvalidLeafIndex`: If `leaf_index >= tree_size` + /// - `TreeNotInitialized`: If the tree is empty + pub fn generate_proof_with_root( + &self, + leaf_index: u32, + ) -> ApplicationResult<(MerkleProofPath, Commitment)> { + let block_hash = self.query.best_hash()?; + let tree_size = self.query.get_tree_size(block_hash)?; + + if tree_size.value() == 0 { + return Err(ApplicationError::TreeNotInitialized); + } + if leaf_index >= tree_size.value() { + return Err(ApplicationError::InvalidLeafIndex { + index: leaf_index, + tree_size: tree_size.value(), + }); + } + + let root = self.query.get_merkle_root(block_hash)?; + let path = self.collect_sibling_path(block_hash, leaf_index)?; + let tree_depth = TreeDepth::new(20); + + Ok((MerkleProofPath::new(path, leaf_index, tree_depth), root)) + } + + /// Generates a Merkle proof by commitment and returns the Merkle root, both + /// read under the **same `best_block`** (atomic — no risk of root/path mismatch). + /// + /// # Errors + /// - `CalculationError`: If the commitment is not found in the tree + /// - `TreeNotInitialized`: If the tree is empty + pub fn generate_proof_by_commitment_with_root( + &self, + target: Commitment, + ) -> ApplicationResult<(MerkleProofPath, Commitment)> { + let block_hash = self.query.best_hash()?; + let tree_size = self.query.get_tree_size(block_hash)?; + + if tree_size.value() == 0 { + return Err(ApplicationError::TreeNotInitialized); + } + + let mut found_index: Option = None; + for i in 0..tree_size.value() { + if self.query.get_leaf(block_hash, i)? == target { + found_index = Some(i); + break; + } + } + + let leaf_index = found_index.ok_or_else(|| { + ApplicationError::CalculationError("Commitment not found in Merkle tree".to_string()) + })?; + + let root = self.query.get_merkle_root(block_hash)?; + let path = self.collect_sibling_path(block_hash, leaf_index)?; + let tree_depth = TreeDepth::new(20); + + Ok((MerkleProofPath::new(path, leaf_index, tree_depth), root)) + } + + /// Generates a Merkle proof by scanning for a matching commitment in the tree leaves. + /// + /// # Parameters + /// - `target`: Commitment to search for + /// + /// # Returns + /// - `MerkleProofPath`: Sibling path with leaf index + /// + /// # Errors + /// - `InvalidLeafIndex`: If the commitment is not found in the tree + /// - `TreeNotInitialized`: If the tree is empty + /// - `Domain`: Storage query errors + pub fn generate_proof_by_commitment( + &self, + target: Commitment, + ) -> ApplicationResult { + let block_hash = self.query.best_hash()?; + let tree_size = self.query.get_tree_size(block_hash)?; + + if tree_size.value() == 0 { + return Err(ApplicationError::TreeNotInitialized); + } + + let mut found_index: Option = None; + for i in 0..tree_size.value() { + if self.query.get_leaf(block_hash, i)? == target { + found_index = Some(i); + break; + } + } + + let leaf_index = found_index.ok_or_else(|| { + ApplicationError::CalculationError("Commitment not found in Merkle tree".to_string()) + })?; + + self.generate_proof(leaf_index) + } } #[cfg(test)] @@ -215,6 +319,49 @@ mod tests { } } + /// Mock that returns known commitments for all leaf indices (needed for by-commitment tests). + #[derive(Clone, Copy)] + struct MockQueryWithLeaves { + root: Commitment, + leaf_0: Commitment, + leaf_1: Commitment, + } + + impl BlockchainQuery for MockQueryWithLeaves { + fn best_hash(&self) -> DomainResult { + Ok(BlockHash::new([9u8; 32])) + } + + fn storage_at( + &self, + _block_hash: BlockHash, + _storage_key: &[u8], + ) -> DomainResult>> { + Ok(None) + } + } + + impl MerkleTreeQuery for MockQueryWithLeaves { + fn get_merkle_root(&self, _block_hash: BlockHash) -> DomainResult { + Ok(self.root) + } + + fn get_tree_size(&self, _block_hash: BlockHash) -> DomainResult { + Ok(TreeSize::new(2)) + } + + fn get_leaf(&self, _block_hash: BlockHash, leaf_index: u32) -> DomainResult { + match leaf_index { + 0 => Ok(self.leaf_0), + 1 => Ok(self.leaf_1), + _ => Err(DomainError::LeafIndexOutOfBounds { + index: leaf_index, + tree_size: 2, + }), + } + } + } + #[test] fn should_return_tree_not_initialized_when_tree_is_empty() { let query = MockQuery { @@ -284,4 +431,114 @@ mod tests { assert_eq!(size.value(), 5); assert_eq!(depth.value(), DEFAULT_TREE_DEPTH as u32); } + + // --- generate_proof_with_root --- + + #[test] + fn should_generate_proof_with_root() { + let expected_root = Commitment::new([5u8; 32]); + let sibling = Commitment::new([7u8; 32]); + let query = MockQuery { + root: expected_root, + tree_size: 2, + sibling, + }; + let service = MerkleProofService::new(query); + + let (proof, root) = service + .generate_proof_with_root(0) + .expect("generate_proof_with_root must succeed"); + + assert_eq!(root, expected_root); + assert_eq!(proof.leaf_index(), 0); + assert_eq!(proof.tree_depth().value(), 20); + assert_eq!(proof.path().len(), 20); + assert_eq!(proof.path()[0], sibling); + } + + #[test] + fn should_return_tree_not_initialized_for_generate_proof_with_root() { + let query = MockQuery { + root: Commitment::new([1u8; 32]), + tree_size: 0, + sibling: Commitment::new([2u8; 32]), + }; + let service = MerkleProofService::new(query); + + let result = service.generate_proof_with_root(0); + + assert!(matches!(result, Err(ApplicationError::TreeNotInitialized))); + } + + #[test] + fn should_return_invalid_leaf_index_for_generate_proof_with_root() { + let query = MockQuery { + root: Commitment::new([1u8; 32]), + tree_size: 1, + sibling: Commitment::new([2u8; 32]), + }; + let service = MerkleProofService::new(query); + + let result = service.generate_proof_with_root(1); + + assert!(matches!( + result, + Err(ApplicationError::InvalidLeafIndex { + index: 1, + tree_size: 1 + }) + )); + } + + // --- generate_proof_by_commitment_with_root --- + + #[test] + fn should_generate_proof_by_commitment_with_root() { + let target = Commitment::new([0xAAu8; 32]); + let expected_root = Commitment::new([5u8; 32]); + let query = MockQueryWithLeaves { + root: expected_root, + leaf_0: Commitment::new([0x11u8; 32]), + leaf_1: target, + }; + let service = MerkleProofService::new(query); + + let (proof, root) = service + .generate_proof_by_commitment_with_root(target) + .expect("generate_proof_by_commitment_with_root must succeed"); + + assert_eq!(root, expected_root); + assert_eq!(proof.leaf_index(), 1); + assert_eq!(proof.tree_depth().value(), 20); + assert_eq!(proof.path().len(), 20); + } + + #[test] + fn should_return_tree_not_initialized_for_generate_proof_by_commitment_with_root() { + let query = MockQuery { + root: Commitment::new([1u8; 32]), + tree_size: 0, + sibling: Commitment::new([2u8; 32]), + }; + let service = MerkleProofService::new(query); + + let result = service.generate_proof_by_commitment_with_root(Commitment::new([0xAAu8; 32])); + + assert!(matches!(result, Err(ApplicationError::TreeNotInitialized))); + } + + #[test] + fn should_return_calculation_error_when_commitment_not_found() { + let absent = Commitment::new([0xFFu8; 32]); + let query = MockQueryWithLeaves { + root: Commitment::new([1u8; 32]), + leaf_0: Commitment::new([0x11u8; 32]), + leaf_1: Commitment::new([0x22u8; 32]), + }; + let service = MerkleProofService::new(query); + + let result = service.generate_proof_by_commitment_with_root(absent); + + assert!(matches!(result, Err(ApplicationError::CalculationError(_)))); + } } diff --git a/client/rpc-v2/src/orbinum/presentation/api.rs b/client/rpc-v2/src/orbinum/presentation/api.rs index 029bcc5b..47291714 100644 --- a/client/rpc-v2/src/orbinum/presentation/api.rs +++ b/client/rpc-v2/src/orbinum/presentation/api.rs @@ -77,6 +77,26 @@ pub trait PrivacyApi { #[method(name = "privacy_getMerkleProof")] fn get_merkle_proof(&self, leaf_index: u32) -> RpcResult; + /// Returns a Merkle proof for a leaf identified by its commitment hash. + /// + /// # Parameters + /// - `commitment`: Commitment hash as 0x-prefixed hex string (64 hex chars) + /// + /// # Returns + /// - `MerkleProofResponse`: Proof with path, leaf index, and tree depth + /// + /// # Example + /// ```json + /// { + /// "jsonrpc": "2.0", + /// "method": "privacy_getMerkleProofByCommitment", + /// "params": ["0xabcd...1234"], + /// "id": 1 + /// } + /// ``` + #[method(name = "privacy_getMerkleProofByCommitment")] + fn get_merkle_proof_by_commitment(&self, commitment: String) -> RpcResult; + /// Checks whether a nullifier has been spent. /// /// # Parameters diff --git a/client/rpc-v2/src/orbinum/presentation/handlers/merkle_proof_handler.rs b/client/rpc-v2/src/orbinum/presentation/handlers/merkle_proof_handler.rs index b248119f..0b53f6f6 100644 --- a/client/rpc-v2/src/orbinum/presentation/handlers/merkle_proof_handler.rs +++ b/client/rpc-v2/src/orbinum/presentation/handlers/merkle_proof_handler.rs @@ -10,7 +10,7 @@ use crate::orbinum::{ presentation::validation::{RequestValidator, RpcError}, }; -/// Handler for `privacy_getMerkleProof`. +/// Handler for `privacy_getMerkleProof` and `privacy_getMerkleProofByCommitment`. pub struct MerkleProofHandler { merkle_service: Arc>, } @@ -24,7 +24,7 @@ where Self { merkle_service } } - /// Handles request to generate a Merkle proof. + /// Handles request to generate a Merkle proof by leaf index. /// /// # Parameters /// - `leaf_index`: Leaf index (0-indexed) @@ -39,23 +39,63 @@ where // 1. Validate input RequestValidator::validate_leaf_index(leaf_index)?; - // 2. Generate proof from service - let proof_path = self + // 2. Generate proof + root atomically from the same best_block + let (proof_path, root) = self .merkle_service - .generate_proof(leaf_index) + .generate_proof_with_root(leaf_index) .map_err(RpcError::from_application_error)?; - // 3. Map domain entity to DTO + // 3. Map domain entities to DTO let (path, leaf_idx, tree_depth) = proof_path.into_parts(); let path_hex: Vec = path .into_iter() .map(CommitmentMapper::to_hex_string) .collect(); + let root_hex = CommitmentMapper::to_hex_string(root); - let response = MerkleProofResponse::new(path_hex, leaf_idx, tree_depth.value()); + let response = MerkleProofResponse::new(root_hex, path_hex, leaf_idx, tree_depth.value()); Ok(response) } + + /// Handles request to generate a Merkle proof by commitment hex. + /// + /// # Parameters + /// - `commitment_hex`: Commitment as 0x-prefixed hex string + /// + /// # Returns + /// - `MerkleProofResponse`: DTO with path, leaf index, and tree depth + /// + /// # Errors + /// - `InvalidCommitment`: If the hex string is malformed + /// - `CalculationError`: If the commitment is not found in the tree + /// - `MerkleTreeNotInitialized`: If tree is not initialized + pub fn handle_by_commitment(&self, commitment_hex: String) -> RpcResult { + // 1. Parse commitment hex + let commitment = CommitmentMapper::from_hex_string(&commitment_hex) + .map_err(RpcError::invalid_commitment)?; + + // 2. Generate proof + root atomically from the same best_block + let (proof_path, root) = self + .merkle_service + .generate_proof_by_commitment_with_root(commitment) + .map_err(RpcError::from_application_error)?; + + // 3. Map domain entities to DTO + let (path, leaf_idx, tree_depth) = proof_path.into_parts(); + let path_hex: Vec = path + .into_iter() + .map(CommitmentMapper::to_hex_string) + .collect(); + let root_hex = CommitmentMapper::to_hex_string(root); + + Ok(MerkleProofResponse::new( + root_hex, + path_hex, + leaf_idx, + tree_depth.value(), + )) + } } #[cfg(test)] @@ -107,6 +147,48 @@ mod tests { } } + /// Mock that returns known commitments for all leaf indices (needed for by-commitment tests). + #[derive(Clone, Copy)] + struct MockQueryAll { + leaf_0: Commitment, + leaf_1: Commitment, + } + + impl BlockchainQuery for MockQueryAll { + fn best_hash(&self) -> DomainResult { + Ok(BlockHash::new([2u8; 32])) + } + + fn storage_at( + &self, + _block_hash: BlockHash, + _storage_key: &[u8], + ) -> DomainResult>> { + Ok(None) + } + } + + impl MerkleTreeQuery for MockQueryAll { + fn get_merkle_root(&self, _block_hash: BlockHash) -> DomainResult { + Ok(Commitment::new([3u8; 32])) + } + + fn get_tree_size(&self, _block_hash: BlockHash) -> DomainResult { + Ok(TreeSize::new(2)) + } + + fn get_leaf(&self, _block_hash: BlockHash, leaf_index: u32) -> DomainResult { + match leaf_index { + 0 => Ok(self.leaf_0), + 1 => Ok(self.leaf_1), + _ => Err(DomainError::LeafIndexOutOfBounds { + index: leaf_index, + tree_size: 2, + }), + } + } + } + #[test] fn should_return_merkle_proof_response() { let query = MockQuery { @@ -137,4 +219,39 @@ mod tests { assert!(result.is_err()); } + + #[test] + fn should_include_root_in_handle_response() { + let query = MockQuery { + tree_size: 2, + sibling: Commitment::new([0xBBu8; 32]), + }; + let service = Arc::new(MerkleProofService::new(query)); + let handler = MerkleProofHandler::new(service); + + let response = handler.handle(0).expect("handler should succeed"); + + let expected_root = format!("0x{}", "03".repeat(32)); + assert_eq!(response.root, expected_root); + } + + #[test] + fn should_include_root_in_handle_by_commitment_response() { + let target = Commitment::new([0xCCu8; 32]); + let query = MockQueryAll { + leaf_0: Commitment::new([0x11u8; 32]), + leaf_1: target, + }; + let service = Arc::new(MerkleProofService::new(query)); + let handler = MerkleProofHandler::new(service); + + let commitment_hex = format!("0x{}", "cc".repeat(32)); + let response = handler + .handle_by_commitment(commitment_hex) + .expect("handler should succeed"); + + let expected_root = format!("0x{}", "03".repeat(32)); + assert_eq!(response.root, expected_root); + assert_eq!(response.leaf_index, 1); + } } diff --git a/client/rpc-v2/src/orbinum/presentation/server.rs b/client/rpc-v2/src/orbinum/presentation/server.rs index b62c0f96..6cecfe84 100644 --- a/client/rpc-v2/src/orbinum/presentation/server.rs +++ b/client/rpc-v2/src/orbinum/presentation/server.rs @@ -105,6 +105,10 @@ where self.merkle_proof_handler.handle(leaf_index) } + fn get_merkle_proof_by_commitment(&self, commitment: String) -> RpcResult { + self.merkle_proof_handler.handle_by_commitment(commitment) + } + fn get_nullifier_status(&self, nullifier: String) -> RpcResult { self.nullifier_handler.handle(nullifier) }