diff --git a/Cargo.lock b/Cargo.lock index c3e27f2..0f810fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4378,6 +4378,7 @@ dependencies = [ "serde_json", "storage", "tiny-keccak", + "tempfile", ] [[package]] diff --git a/common/src/rpc_primitives/requests.rs b/common/src/rpc_primitives/requests.rs index 159c4f6..29612c5 100644 --- a/common/src/rpc_primitives/requests.rs +++ b/common/src/rpc_primitives/requests.rs @@ -42,6 +42,11 @@ pub struct GetAccountBalanceRequest { pub address: String, } +#[derive(Serialize, Deserialize, Debug)] +pub struct GetTransactionByHashRequest { + pub hash: String, +} + parse_request!(HelloRequest); parse_request!(RegisterAccountRequest); parse_request!(SendTxRequest); @@ -50,6 +55,7 @@ parse_request!(GetGenesisIdRequest); parse_request!(GetLastBlockRequest); parse_request!(GetInitialTestnetAccountsRequest); parse_request!(GetAccountBalanceRequest); +parse_request!(GetTransactionByHashRequest); #[derive(Serialize, Deserialize, Debug)] pub struct HelloResponse { @@ -85,3 +91,8 @@ pub struct GetLastBlockResponse { pub struct GetAccountBalanceResponse { pub balance: u64, } + +#[derive(Serialize, Deserialize, Debug)] +pub struct GetTransactionByHashResponse { + pub transaction: Option, +} diff --git a/sequencer_core/Cargo.toml b/sequencer_core/Cargo.toml index 0c676e0..f148655 100644 --- a/sequencer_core/Cargo.toml +++ b/sequencer_core/Cargo.toml @@ -14,6 +14,7 @@ rand.workspace = true elliptic-curve.workspace = true k256.workspace = true tiny-keccak.workspace = true +tempfile.workspace = true [dependencies.storage] path = "../storage" diff --git a/sequencer_core/src/sequencer_store/block_store.rs b/sequencer_core/src/sequencer_store/block_store.rs index 8c556a3..dbefdc2 100644 --- a/sequencer_core/src/sequencer_store/block_store.rs +++ b/sequencer_core/src/sequencer_store/block_store.rs @@ -1,11 +1,13 @@ -use std::path::Path; +use std::{collections::HashMap, path::Path}; use anyhow::Result; -use common::block::Block; +use common::{block::Block, merkle_tree_public::TreeHashType, transaction::Transaction}; use storage::RocksDBIO; pub struct SequecerBlockStore { dbio: RocksDBIO, + // TODO: Consider adding the hashmap to the database for faster recovery. + tx_hash_to_block_map: HashMap, pub genesis_id: u64, } @@ -15,11 +17,21 @@ impl SequecerBlockStore { /// /// ATTENTION: Will overwrite genesis block. pub fn open_db_with_genesis(location: &Path, genesis_block: Option) -> Result { + let tx_hash_to_block_map = if let Some(block) = &genesis_block { + block_to_transactions_map(block) + } else { + HashMap::new() + }; + let dbio = RocksDBIO::new(location, genesis_block)?; let genesis_id = dbio.get_meta_first_block_in_db()?; - Ok(Self { dbio, genesis_id }) + Ok(Self { + dbio, + genesis_id, + tx_hash_to_block_map, + }) } ///Reopening existing database @@ -31,7 +43,96 @@ impl SequecerBlockStore { Ok(self.dbio.get_block(id)?) } - pub fn put_block_at_id(&self, block: Block) -> Result<()> { - Ok(self.dbio.put_block(block, false)?) + pub fn put_block_at_id(&mut self, block: Block) -> Result<()> { + let new_transactions_map = block_to_transactions_map(&block); + self.dbio.put_block(block, false)?; + self.tx_hash_to_block_map.extend(new_transactions_map); + Ok(()) + } + + /// Returns the transaction corresponding to the given hash, if it exists in the blockchain. + pub fn get_transaction_by_hash(&self, hash: TreeHashType) -> Option { + let block_id = self.tx_hash_to_block_map.get(&hash); + let block = block_id.map(|&id| self.get_block_at_id(id)); + if let Some(Ok(block)) = block { + for transaction in block.transactions.into_iter() { + if transaction.body().hash() == hash { + return Some(transaction); + } + } + } + None + } +} + +fn block_to_transactions_map(block: &Block) -> HashMap { + block + .transactions + .iter() + .map(|transaction| (transaction.body().hash(), block.block_id)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use common::transaction::{SignaturePrivateKey, TransactionBody}; + use tempfile::tempdir; + + fn create_dummy_block_with_transaction(block_id: u64) -> (Block, Transaction) { + let body = TransactionBody { + tx_kind: common::transaction::TxKind::Public, + execution_input: Default::default(), + execution_output: Default::default(), + utxo_commitments_spent_hashes: Default::default(), + utxo_commitments_created_hashes: Default::default(), + nullifier_created_hashes: Default::default(), + execution_proof_private: Default::default(), + encoded_data: Default::default(), + ephemeral_pub_key: Default::default(), + commitment: Default::default(), + tweak: Default::default(), + secret_r: Default::default(), + sc_addr: Default::default(), + state_changes: Default::default(), + }; + let tx = Transaction::new(body, SignaturePrivateKey::from_slice(&[1; 32]).unwrap()); + ( + Block { + block_id, + prev_block_id: block_id - 1, + prev_block_hash: [0; 32], + hash: [1; 32], + transactions: vec![tx.clone()], + data: vec![], + }, + tx, + ) + } + + #[test] + fn test_get_transaction_by_hash() { + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path(); + let genesis_block = Block { + block_id: 0, + prev_block_id: 0, + prev_block_hash: [0; 32], + hash: [1; 32], + transactions: vec![], + data: vec![], + }; + // Start an empty node store + let mut node_store = + SequecerBlockStore::open_db_with_genesis(path, Some(genesis_block)).unwrap(); + let (block, tx) = create_dummy_block_with_transaction(1); + // Try retrieve a tx that's not in the chain yet. + let retrieved_tx = node_store.get_transaction_by_hash(tx.body().hash()); + assert_eq!(None, retrieved_tx); + // Add the block with the transaction + node_store.put_block_at_id(block).unwrap(); + // Try again + let retrieved_tx = node_store.get_transaction_by_hash(tx.body().hash()); + assert_eq!(Some(tx), retrieved_tx); } } diff --git a/sequencer_rpc/src/process.rs b/sequencer_rpc/src/process.rs index b1237ad..40283b6 100644 --- a/sequencer_rpc/src/process.rs +++ b/sequencer_rpc/src/process.rs @@ -25,6 +25,7 @@ pub const GET_BLOCK: &str = "get_block"; pub const GET_GENESIS: &str = "get_genesis"; pub const GET_LAST_BLOCK: &str = "get_last_block"; pub const GET_ACCOUNT_BALANCE: &str = "get_account_balance"; +pub const GET_TRANSACTION_BY_HASH: &str = "get_transaction_by_hash"; pub const HELLO_FROM_SEQUENCER: &str = "HELLO_FROM_SEQUENCER"; @@ -161,6 +162,7 @@ impl JsonHandler { let address = address_bytes .try_into() .map_err(|_| RpcError::invalid_params("invalid length".to_string()))?; + let balance = { let state = self.sequencer_state.lock().await; state.store.acc_store.get_account_balance(&address) @@ -171,6 +173,24 @@ impl JsonHandler { respond(helperstruct) } + /// Returns the transaction corresponding to the given hash, if it exists in the blockchain. + /// The hash must be a valid hex string of the correct length. + async fn process_get_transaction_by_hash(&self, request: Request) -> Result { + let get_transaction_req = GetTransactionByHashRequest::parse(Some(request.params))?; + let bytes: Vec = hex::decode(get_transaction_req.hash) + .map_err(|_| RpcError::invalid_params("invalid hex".to_string()))?; + let hash: TreeHashType = bytes + .try_into() + .map_err(|_| RpcError::invalid_params("invalid length".to_string()))?; + + let transaction = { + let state = self.sequencer_state.lock().await; + state.store.block_store.get_transaction_by_hash(hash) + }; + let helperstruct = GetTransactionByHashResponse { transaction }; + respond(helperstruct) + } + pub async fn process_request_internal(&self, request: Request) -> Result { match request.method.as_ref() { HELLO => self.process_temp_hello(request).await, @@ -181,6 +201,7 @@ impl JsonHandler { GET_LAST_BLOCK => self.process_get_last_block(request).await, GET_INITIAL_TESTNET_ACCOUNTS => self.get_initial_testnet_accounts(request).await, GET_ACCOUNT_BALANCE => self.process_get_account_balance(request).await, + GET_TRANSACTION_BY_HASH => self.process_get_transaction_by_hash(request).await, _ => Err(RpcErr(RpcError::method_not_found(request.method))), } } @@ -586,4 +607,115 @@ mod tests { assert_eq!(response, expected_response); } + + #[actix_web::test] + async fn test_get_transaction_by_hash_for_non_existent_hash() { + let json_handler = json_handler_for_tests(); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "method": "get_transaction_by_hash", + "params": { "hash": "cafe".repeat(16) }, + "id": 1 + }); + let expected_response = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "result": { + "transaction": null + } + }); + + let response = call_rpc_handler_with_json(json_handler, request).await; + + assert_eq!(response, expected_response); + } + + #[actix_web::test] + async fn test_get_transaction_by_hash_for_invalid_hex() { + let json_handler = json_handler_for_tests(); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "method": "get_transaction_by_hash", + "params": { "hash": "not_a_valid_hex" }, + "id": 1 + }); + let expected_response = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32602, + "message": "Invalid params", + "data": "invalid hex" + } + }); + + let response = call_rpc_handler_with_json(json_handler, request).await; + + assert_eq!(response, expected_response); + } + + #[actix_web::test] + async fn test_get_transaction_by_hash_for_invalid_length() { + let json_handler = json_handler_for_tests(); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "method": "get_transaction_by_hash", + "params": { "hash": "cafecafe" }, + "id": 1 + }); + let expected_response = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32602, + "message": "Invalid params", + "data": "invalid length" + } + }); + + let response = call_rpc_handler_with_json(json_handler, request).await; + + assert_eq!(response, expected_response); + } + + #[actix_web::test] + async fn test_get_transaction_by_hash_for_existing_transaction() { + let json_handler = json_handler_for_tests(); + let request = serde_json::json!({ + "jsonrpc": "2.0", + "method": "get_transaction_by_hash", + "params": { "hash": "ca8e38269c0137d27cbe7c55d240a834b46e86e236578b9a1a3a25b3dabc5709" }, + "id": 1 + }); + let expected_response = serde_json::json!({ + "id": 1, + "jsonrpc": "2.0", + "result": { + "transaction": { + "body": { + "commitment": [], + "encoded_data": [], + "ephemeral_pub_key": [], + "execution_input": [], + "execution_output": [], + "execution_proof_private": "", + "nullifier_created_hashes": [], + "sc_addr": "", + "secret_r": vec![0; 32], + "state_changes": [null, 0], + "tweak": "0".repeat(64), + "tx_kind": "Public", + "utxo_commitments_created_hashes": [], + "utxo_commitments_spent_hashes": [] + }, + "public_key": "3056301006072A8648CE3D020106052B8104000A034200041B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F70BEAF8F588B541507FED6A642C5AB42DFDF8120A7F639DE5122D47A69A8E8D1", + "signature": "28CB6CA744864340A3441CB48D5700690F90130DE0760EE5C640F85F4285C5FD2BD7D0E270EC2AC82E4124999E63659AA9C33CF378F959EDF4E50F2626EA3B99" + } + } + }); + + let response = call_rpc_handler_with_json(json_handler, request).await; + + assert_eq!(response, expected_response); + } }