diff --git a/Cargo.lock b/Cargo.lock index 86db1d492..e5cf96273 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2725,6 +2725,17 @@ dependencies = [ "tonic-web-wasm-client", ] +[[package]] +name = "miden-rpc-client" +version = "0.12.0" +dependencies = [ + "hex", + "miden-node-proto", + "miden-objects", + "tokio", + "tonic", +] + [[package]] name = "miden-stdlib" version = "0.17.2" diff --git a/Cargo.toml b/Cargo.toml index 0a921f43c..2fd7c805a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/testing/node-builder", "crates/testing/prover", "crates/web-client", + "crates/rpc-client", ] default-members = ["bin/miden-cli", "crates/rust-client"] diff --git a/crates/rpc-client/Cargo.toml b/crates/rpc-client/Cargo.toml new file mode 100644 index 000000000..f646a35ad --- /dev/null +++ b/crates/rpc-client/Cargo.toml @@ -0,0 +1,20 @@ +[package] +authors.workspace = true +description = "RPC client for Miden network" +edition.workspace = true +license.workspace = true +name = "miden-rpc-client" +repository.workspace = true +rust-version.workspace = true +version.workspace = true + +[lib] +crate-type = ["lib"] + +[dependencies] +miden-objects = { workspace = true } +miden-node-proto = { branch = "next", git = "https://github.com/0xMiden/miden-node" } + +tonic = { version = "0.13", features = ["tls-native-roots", "tls-ring", "transport"] } +tokio = { workspace = true } +hex = { workspace = true } diff --git a/crates/rpc-client/README.md b/crates/rpc-client/README.md new file mode 100644 index 000000000..1c864714f --- /dev/null +++ b/crates/rpc-client/README.md @@ -0,0 +1,43 @@ +# miden-rpc-client + +Minimal Miden RPC client. + +## Usage + +```rust +use miden_rpc_client::MidenRpcClient; + +#[tokio::main] +async fn main() -> Result<(), String> { + // Connect to Miden node + let mut client = MidenRpcClient::connect("https://node.example.com").await?; + + // Get node status + let status = client.get_status().await?; + println!("Node version: {}", status.version); + + // Get account details + let account_details = client.get_account_details(&account_id).await?; + + Ok(()) +} +``` + +## Available RPC Methods + +1. `get_status` - Node status information +2. `get_block_header` - Block headers with optional MMR proof +3. `submit_transaction` - Submit single proven transaction +4. `sync_state` - Full state sync (accounts, notes, nullifiers) +5. `check_nullifiers` - Nullifier proofs +6. `get_notes_by_id` - Notes matching IDs +7. `get_account_commitment` - Fetch account commitment as hex string +8. `get_account_details` - Full account details including serialized data +9. `get_account_proof` - Account state proof with storage +10. `get_block_by_number` - Raw block data +11. `submit_proven_batch` - Submit transaction batch +12. `sync_account_vault` - Account vault updates within block range +13. `sync_notes` - Note synchronization by tags +14. `sync_storage_maps` - Storage map updates within block range + +For advanced usage, proto types are exported and accessible via `client_mut()`. diff --git a/crates/rpc-client/src/lib.rs b/crates/rpc-client/src/lib.rs new file mode 100644 index 000000000..c82b21d82 --- /dev/null +++ b/crates/rpc-client/src/lib.rs @@ -0,0 +1,367 @@ +//! Miden RPC client +use miden_objects::{ + account::AccountId, + note::{NoteId, NoteTag}, + utils::Serializable, + Word, +}; +use tonic::{Request, transport::{Channel, ClientTlsConfig}}; + +pub use miden_node_proto::generated::{ + account, block_producer, blockchain, note, primitives, rpc, rpc_store, shared, transaction, +}; +pub use rpc::api_client::ApiClient; + + +pub fn word_to_digest(word: Word) -> primitives::Digest { + primitives::Digest { + d0: word[0].as_int(), + d1: word[1].as_int(), + d2: word[2].as_int(), + d3: word[3].as_int(), + } +} + +pub fn note_id_to_digest(note_id: NoteId) -> primitives::Digest { + word_to_digest(note_id.as_word()) +} + +pub fn account_id_to_proto(account_id: &AccountId) -> account::AccountId { + account::AccountId { + id: account_id.to_bytes().to_vec(), + } +} + +pub struct MidenRpcClient { + client: ApiClient, +} + +impl MidenRpcClient { + pub async fn connect(endpoint: impl Into) -> Result { + let endpoint_str = endpoint.into(); + + let channel = Channel::from_shared(endpoint_str.clone()) + .map_err(|e| format!("Invalid endpoint: {}", e))? + .tls_config(ClientTlsConfig::new().with_native_roots()) + .map_err(|e| format!("TLS config error: {}", e))? + .connect() + .await + .map_err(|e| format!("Failed to connect to {}: {}", endpoint_str, e))?; + + let client = ApiClient::new(channel); + + Ok(Self { client }) + } + + /// Get the underlying tonic ApiClient + pub fn client_mut(&mut self) -> &mut ApiClient { + &mut self.client + } + + /// Get the status of the Miden node + pub async fn get_status(&mut self) -> Result { + let response = self + .client + .status(Request::new(())) + .await + .map_err(|e| format!("Status RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Get block header by number with optional MMR proof + pub async fn get_block_header( + &mut self, + block_num: Option, + include_mmr_proof: bool, + ) -> Result { + let request = shared::BlockHeaderByNumberRequest { + block_num, + include_mmr_proof: Some(include_mmr_proof), + }; + + let response = self + .client + .get_block_header_by_number(Request::new(request)) + .await + .map_err(|e| format!("GetBlockHeaderByNumber RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Submit a proven transaction to the network + pub async fn submit_transaction( + &mut self, + proven_tx_bytes: Vec, + ) -> Result { + let request = transaction::ProvenTransaction { + transaction: proven_tx_bytes, + }; + + let response = self + .client + .submit_proven_transaction(Request::new(request)) + .await + .map_err(|e| format!("SubmitProvenTransaction RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Sync state for specified accounts and note tags + pub async fn sync_state( + &mut self, + block_num: u32, + account_ids: &[AccountId], + note_tags: &[NoteTag], + ) -> Result { + let account_ids = account_ids + .iter() + .map(|id| account_id_to_proto(id)) + .collect(); + + let note_tags = note_tags.iter().map(|tag| tag.as_u32()).collect(); + + let request = rpc_store::SyncStateRequest { + block_num, + account_ids, + note_tags, + }; + + let response = self + .client + .sync_state(Request::new(request)) + .await + .map_err(|e| format!("SyncState RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Check nullifiers and get their proofs + pub async fn check_nullifiers( + &mut self, + nullifiers: &[Word], + ) -> Result { + let nullifiers = nullifiers + .iter() + .map(|w| word_to_digest(*w)) + .collect(); + let request = rpc_store::NullifierList { nullifiers }; + + let response = self + .client + .check_nullifiers(Request::new(request)) + .await + .map_err(|e| format!("CheckNullifiers RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Get notes by their IDs + pub async fn get_notes_by_id( + &mut self, + note_ids: &[NoteId], + ) -> Result { + let note_ids = note_ids + .iter() + .map(|id| note::NoteId { + id: Some(note_id_to_digest(*id)), + }) + .collect(); + let request = note::NoteIdList { ids: note_ids }; + + let response = self + .client + .get_notes_by_id(Request::new(request)) + .await + .map_err(|e| format!("GetNotesById RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Fetch account commitment + pub async fn get_account_commitment( + &mut self, + account_id: &AccountId, + ) -> Result { + let account_id_bytes = account_id.to_bytes(); + + let request = Request::new(account::AccountId { + id: account_id_bytes.to_vec(), + }); + + let response = self + .client + .get_account_details(request) + .await + .map_err(|e| format!("RPC call failed: {}", e))?; + + let account_details = response.into_inner(); + + let summary = account_details + .summary + .ok_or_else(|| "No account summary in response".to_string())?; + + let commitment = summary + .account_commitment + .ok_or_else(|| "No commitment in account summary".to_string())?; + + // Convert Digest to hex string + let bytes = [ + commitment.d0.to_le_bytes(), + commitment.d1.to_le_bytes(), + commitment.d2.to_le_bytes(), + commitment.d3.to_le_bytes(), + ].concat(); + + Ok(format!("0x{}", hex::encode(bytes))) + } + + /// Fetch full account details including serialized account data + pub async fn get_account_details( + &mut self, + account_id: &AccountId, + ) -> Result { + let account_id_bytes = account_id.to_bytes(); + + let request = Request::new(account::AccountId { + id: account_id_bytes.to_vec(), + }); + + let response = self + .client + .get_account_details(request) + .await + .map_err(|e| format!("RPC call failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Get account proof for a single account + pub async fn get_account_proof( + &mut self, + account_id: &AccountId, + account_details: Option, + ) -> Result { + let request = rpc_store::AccountProofRequest { + account_id: Some(account_id_to_proto(account_id)), + account_details, + }; + + let response = self + .client + .get_account_proof(Request::new(request)) + .await + .map_err(|e| format!("GetAccountProof RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Get raw block data by block number + pub async fn get_block_by_number( + &mut self, + block_num: u32, + ) -> Result { + let request = blockchain::BlockNumber { block_num }; + + let response = self + .client + .get_block_by_number(Request::new(request)) + .await + .map_err(|e| format!("GetBlockByNumber RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Submit a proven batch of transactions to the network + pub async fn submit_proven_batch( + &mut self, + encoded_batch: Vec, + ) -> Result { + let request = transaction::ProvenTransactionBatch { + encoded: encoded_batch, + }; + + let response = self + .client + .submit_proven_batch(Request::new(request)) + .await + .map_err(|e| format!("SubmitProvenBatch RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + + /// Sync account vault updates within a block range + pub async fn sync_account_vault( + &mut self, + account_id: &AccountId, + block_from: u32, + block_to: Option, + ) -> Result { + let block_range = Some(rpc_store::BlockRange { + block_from, + block_to, + }); + + let request = rpc_store::SyncAccountVaultRequest { + block_range, + account_id: Some(account_id_to_proto(account_id)), + }; + + let response = self + .client + .sync_account_vault(Request::new(request)) + .await + .map_err(|e| format!("SyncAccountVault RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Sync notes by note tags and block height + pub async fn sync_notes( + &mut self, + block_num: u32, + note_tags: &[NoteTag], + ) -> Result { + let note_tags = note_tags.iter().map(|tag| tag.as_u32()).collect(); + + let request = rpc_store::SyncNotesRequest { + block_num, + note_tags, + }; + + let response = self + .client + .sync_notes(Request::new(request)) + .await + .map_err(|e| format!("SyncNotes RPC failed: {}", e))?; + + Ok(response.into_inner()) + } + + /// Sync storage map updates for specified account within a block range + pub async fn sync_storage_maps( + &mut self, + account_id: &AccountId, + block_from: u32, + block_to: Option, + ) -> Result { + let block_range = Some(rpc_store::BlockRange { + block_from, + block_to, + }); + + let request = rpc_store::SyncStorageMapsRequest { + block_range, + account_id: Some(account_id_to_proto(account_id)), + }; + + let response = self + .client + .sync_storage_maps(Request::new(request)) + .await + .map_err(|e| format!("SyncStorageMaps RPC failed: {}", e))?; + + Ok(response.into_inner()) + } +}