diff --git a/Cargo.lock b/Cargo.lock index 457f8818..4e61d158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5754,6 +5754,7 @@ dependencies = [ "jsonrpc-http-server", "proptest", "serde", + "serde_json", "thiserror", "tokio", "tower", diff --git a/zebra-chain/src/transaction.rs b/zebra-chain/src/transaction.rs index c8b65106..ef095467 100644 --- a/zebra-chain/src/transaction.rs +++ b/zebra-chain/src/transaction.rs @@ -25,6 +25,7 @@ pub use joinsplit::JoinSplitData; pub use lock_time::LockTime; pub use memo::Memo; pub use sapling::FieldNotPresent; +pub use serialize::SerializedTransaction; pub use sighash::{HashType, SigHash}; pub use unmined::{UnminedTx, UnminedTxId, VerifiedUnminedTx}; diff --git a/zebra-chain/src/transaction/serialize.rs b/zebra-chain/src/transaction/serialize.rs index 7c321f76..d4ecf873 100644 --- a/zebra-chain/src/transaction/serialize.rs +++ b/zebra-chain/src/transaction/serialize.rs @@ -1,7 +1,7 @@ //! Contains impls of `ZcashSerialize`, `ZcashDeserialize` for all of the //! transaction types, so that all of the serialization logic is in one place. -use std::{convert::TryInto, io, sync::Arc}; +use std::{borrow::Borrow, convert::TryInto, io, sync::Arc}; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use halo2::{arithmetic::FieldExt, pasta::pallas}; @@ -977,3 +977,36 @@ impl TrustedPreallocate for transparent::Output { MAX_BLOCK_BYTES / MIN_TRANSPARENT_OUTPUT_SIZE } } + +/// A serialized transaction. +/// +/// Stores bytes that are guaranteed to be deserializable into a [`Transaction`]. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct SerializedTransaction { + bytes: Vec, +} + +/// Build a [`SerializedTransaction`] by serializing a block. +impl> From for SerializedTransaction { + fn from(tx: B) -> Self { + SerializedTransaction { + bytes: tx + .borrow() + .zcash_serialize_to_vec() + .expect("Writing to a `Vec` should never fail"), + } + } +} + +/// Access the serialized bytes of a [`SerializedTransaction`]. +impl AsRef<[u8]> for SerializedTransaction { + fn as_ref(&self) -> &[u8] { + self.bytes.as_ref() + } +} + +impl From> for SerializedTransaction { + fn from(bytes: Vec) -> Self { + Self { bytes } + } +} diff --git a/zebra-rpc/Cargo.toml b/zebra-rpc/Cargo.toml index 57157603..f68d3627 100644 --- a/zebra-rpc/Cargo.toml +++ b/zebra-rpc/Cargo.toml @@ -33,6 +33,7 @@ serde = { version = "1.0.136", features = ["serde_derive"] } [dev-dependencies] proptest = "0.10.1" +serde_json = "1.0.79" thiserror = "1.0.30" tokio = { version = "1.16.1", features = ["full", "test-util"] } diff --git a/zebra-rpc/src/lib.rs b/zebra-rpc/src/lib.rs index 0d15bd66..785bd265 100644 --- a/zebra-rpc/src/lib.rs +++ b/zebra-rpc/src/lib.rs @@ -7,3 +7,5 @@ pub mod config; pub mod methods; pub mod server; +#[cfg(test)] +mod tests; diff --git a/zebra-rpc/src/methods.rs b/zebra-rpc/src/methods.rs index 0a2259e3..abd84efb 100644 --- a/zebra-rpc/src/methods.rs +++ b/zebra-rpc/src/methods.rs @@ -6,6 +6,8 @@ //! Some parts of the `zcashd` RPC documentation are outdated. //! So this implementation follows the `lightwalletd` client implementation. +use std::{collections::HashSet, io, sync::Arc}; + use futures::{FutureExt, TryFutureExt}; use hex::{FromHex, ToHex}; use jsonrpc_core::{self, BoxFuture, Error, ErrorCode, Result}; @@ -17,7 +19,7 @@ use zebra_chain::{ chain_tip::ChainTip, parameters::Network, serialization::{SerializationError, ZcashDeserialize}, - transaction::{self, Transaction}, + transaction::{self, SerializedTransaction, Transaction}, }; use zebra_network::constants::USER_AGENT; use zebra_node_services::{mempool, BoxError}; @@ -103,6 +105,30 @@ pub trait Rpc { /// zcashd reference: [`getrawmempool`](https://zcash.github.io/rpc/getrawmempool.html) #[rpc(name = "getrawmempool")] fn get_raw_mempool(&self) -> BoxFuture>>; + + /// Returns the raw transaction data, as a [`GetRawTransaction`] JSON string or structure. + /// + /// zcashd reference: [`getrawtransaction`](https://zcash.github.io/rpc/getrawtransaction.html) + /// + /// # Parameters + /// + /// - `txid`: (string, required) The transaction ID of the transaction to be returned. + /// - `verbose`: (numeric, optional, default=0) If 0, return a string of hex-encoded data, otherwise return a JSON object. + /// + /// # Notes + /// + /// We don't currently support the `blockhash` parameter since lightwalletd does not + /// use it. + /// + /// In verbose mode, we only expose the `hex` and `height` fields since + /// lightwalletd uses only those: + /// + #[rpc(name = "getrawtransaction")] + fn get_raw_transaction( + &self, + txid_hex: String, + verbose: u8, + ) -> BoxFuture>; } /// RPC method implementations. @@ -321,6 +347,89 @@ where } .boxed() } + + fn get_raw_transaction( + &self, + txid_hex: String, + verbose: u8, + ) -> BoxFuture> { + let mut state = self.state.clone(); + let mut mempool = self.mempool.clone(); + + async move { + let txid = transaction::Hash::from_hex(txid_hex).map_err(|_| { + Error::invalid_params("transaction ID is not specified as a hex string") + })?; + + // Check the mempool first. + // + // # Correctness + // + // Transactions are removed from the mempool after they are mined into blocks, + // so the transaction could be just in the mempool, just in the state, or in both. + // (And the mempool and state transactions could have different authorising data.) + // But it doesn't matter which transaction we choose, because the effects are the same. + let mut txid_set = HashSet::new(); + txid_set.insert(txid); + let request = mempool::Request::TransactionsByMinedId(txid_set); + + let response = mempool + .ready() + .and_then(|service| service.call(request)) + .await + .map_err(|error| Error { + code: ErrorCode::ServerError(0), + message: error.to_string(), + data: None, + })?; + + match response { + mempool::Response::Transactions(unmined_transactions) => { + if !unmined_transactions.is_empty() { + let tx = unmined_transactions[0].transaction.clone(); + return GetRawTransaction::from_transaction(tx, None, verbose != 0) + .map_err(|error| Error { + code: ErrorCode::ServerError(0), + message: error.to_string(), + data: None, + }); + } + } + _ => unreachable!("unmatched response to a transactionids request"), + }; + + // Now check the state + let request = zebra_state::ReadRequest::Transaction(txid); + let response = state + .ready() + .and_then(|service| service.call(request)) + .await + .map_err(|error| Error { + code: ErrorCode::ServerError(0), + message: error.to_string(), + data: None, + })?; + + match response { + zebra_state::ReadResponse::Transaction(Some((tx, height))) => Ok( + GetRawTransaction::from_transaction(tx, Some(height), verbose != 0).map_err( + |error| Error { + code: ErrorCode::ServerError(0), + message: error.to_string(), + data: None, + }, + )?, + ), + zebra_state::ReadResponse::Transaction(None) => Err(Error { + code: ErrorCode::ServerError(0), + message: "Transaction not found".to_string(), + data: None, + }), + _ => unreachable!("unmatched response to a transaction request"), + } + } + .boxed() + } } #[derive(serde::Serialize, serde::Deserialize)] @@ -362,3 +471,45 @@ pub struct GetBlock(#[serde(with = "hex")] SerializedBlock); /// /// Also see the notes for the [`Rpc::get_best_block_hash` method]. pub struct GetBestBlockHash(#[serde(with = "hex")] block::Hash); + +/// Response to a `getrawtransaction` RPC request. +/// +/// See the notes for the [`Rpc::get_raw_transaction` method]. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(untagged)] +pub enum GetRawTransaction { + /// The raw transaction, encoded as hex bytes. + Raw(#[serde(with = "hex")] SerializedTransaction), + /// The transaction object. + Object { + /// The raw transaction, encoded as hex bytes. + #[serde(with = "hex")] + hex: SerializedTransaction, + /// The height of the block that contains the transaction, or -1 if + /// not applicable. + height: i32, + }, +} + +impl GetRawTransaction { + fn from_transaction( + tx: Arc, + height: Option, + verbose: bool, + ) -> std::result::Result { + if verbose { + Ok(GetRawTransaction::Object { + hex: tx.into(), + height: match height { + Some(height) => height + .0 + .try_into() + .expect("valid block heights are limited to i32::MAX"), + None => -1, + }, + }) + } else { + Ok(GetRawTransaction::Raw(tx.into())) + } + } +} diff --git a/zebra-rpc/src/methods/tests/prop.rs b/zebra-rpc/src/methods/tests/prop.rs index 0311e754..26fb1c07 100644 --- a/zebra-rpc/src/methods/tests/prop.rs +++ b/zebra-rpc/src/methods/tests/prop.rs @@ -12,7 +12,7 @@ use zebra_chain::{ chain_tip::NoChainTip, parameters::Network::*, serialization::{ZcashDeserialize, ZcashSerialize}, - transaction::{Transaction, UnminedTx, UnminedTxId}, + transaction::{self, Transaction, UnminedTx, UnminedTxId}, }; use zebra_node_services::mempool; use zebra_state::BoxError; @@ -322,6 +322,104 @@ proptest! { Ok::<_, TestCaseError>(()) })?; } + + /// Test that the method rejects non-hexadecimal characters. + /// + /// Try to call `get_raw_transaction` using a string parameter that has at least one + /// non-hexadecimal character, and check that it fails with an expected error. + #[test] + fn get_raw_transaction_non_hexadecimal_string_results_in_an_error(non_hex_string in ".*[^0-9A-Fa-f].*") { + let runtime = zebra_test::init_async(); + let _guard = runtime.enter(); + + // CORRECTNESS: Nothing in this test depends on real time, so we can speed it up. + tokio::time::pause(); + + runtime.block_on(async move { + let mut mempool = MockService::build().for_prop_tests(); + let mut state: MockService<_, _, _, BoxError> = MockService::build().for_prop_tests(); + + let rpc = RpcImpl::new( + "RPC test", + Buffer::new(mempool.clone(), 1), + Buffer::new(state.clone(), 1), + NoChainTip, + Mainnet, + ); + + let send_task = tokio::spawn(rpc.get_raw_transaction(non_hex_string, 0)); + + mempool.expect_no_requests().await?; + state.expect_no_requests().await?; + + let result = send_task + .await + .expect("Sending raw transactions should not panic"); + + prop_assert!( + matches!( + result, + Err(Error { + code: ErrorCode::InvalidParams, + .. + }) + ), + "Result is not an invalid parameters error: {result:?}" + ); + + Ok::<_, TestCaseError>(()) + })?; + } + + /// Test that the method rejects an input that's not a transaction. + /// + /// Try to call `get_raw_transaction` using random bytes that fail to deserialize as a + /// transaction, and check that it fails with an expected error. + #[test] + fn get_raw_transaction_invalid_transaction_results_in_an_error(random_bytes in any::>()) { + let runtime = zebra_test::init_async(); + let _guard = runtime.enter(); + + // CORRECTNESS: Nothing in this test depends on real time, so we can speed it up. + tokio::time::pause(); + + prop_assume!(transaction::Hash::zcash_deserialize(&*random_bytes).is_err()); + + runtime.block_on(async move { + let mut mempool = MockService::build().for_prop_tests(); + let mut state: MockService<_, _, _, BoxError> = MockService::build().for_prop_tests(); + + let rpc = RpcImpl::new( + "RPC test", + Buffer::new(mempool.clone(), 1), + Buffer::new(state.clone(), 1), + NoChainTip, + Mainnet, + ); + + let send_task = tokio::spawn(rpc.get_raw_transaction(hex::encode(random_bytes), 0)); + + mempool.expect_no_requests().await?; + state.expect_no_requests().await?; + + let result = send_task + .await + .expect("Sending raw transactions should not panic"); + + prop_assert!( + matches!( + result, + Err(Error { + code: ErrorCode::InvalidParams, + .. + }) + ), + "Result is not an invalid parameters error: {result:?}" + ); + + Ok::<_, TestCaseError>(()) + })?; + } } #[derive(Clone, Copy, Debug, Error)] diff --git a/zebra-rpc/src/methods/tests/vectors.rs b/zebra-rpc/src/methods/tests/vectors.rs index 36dcb8a9..8301fa5f 100644 --- a/zebra-rpc/src/methods/tests/vectors.rs +++ b/zebra-rpc/src/methods/tests/vectors.rs @@ -5,8 +5,11 @@ use std::sync::Arc; use tower::buffer::Buffer; use zebra_chain::{ - block::Block, chain_tip::NoChainTip, parameters::Network::*, - serialization::ZcashDeserializeInto, + block::Block, + chain_tip::NoChainTip, + parameters::Network::*, + serialization::{ZcashDeserializeInto, ZcashSerialize}, + transaction::{UnminedTx, UnminedTxId}, }; use zebra_network::constants::USER_AGENT; use zebra_node_services::BoxError; @@ -148,3 +151,84 @@ async fn rpc_getbestblockhash() { mempool.expect_no_requests().await; } + +#[tokio::test] +async fn rpc_getrawtransaction() { + zebra_test::init(); + + // Create a continuous chain of mainnet blocks from genesis + let blocks: Vec> = zebra_test::vectors::CONTINUOUS_MAINNET_BLOCKS + .iter() + .map(|(_height, block_bytes)| block_bytes.zcash_deserialize_into().unwrap()) + .collect(); + + let mut mempool: MockService<_, _, _, BoxError> = MockService::build().for_unit_tests(); + // Create a populated state service + let (_state, read_state, latest_chain_tip, _chain_tip_change) = + zebra_state::populated_state(blocks.clone(), Mainnet).await; + + // Init RPC + let rpc = RpcImpl::new( + "RPC test", + Buffer::new(mempool.clone(), 1), + read_state, + latest_chain_tip, + Mainnet, + ); + + // Test case where transaction is in mempool. + // Skip genesis because its tx is not indexed. + for block in blocks.iter().skip(1) { + for tx in block.transactions.iter() { + let mempool_req = mempool + .expect_request_that(|request| { + if let mempool::Request::TransactionsByMinedId(ids) = request { + ids.len() == 1 && ids.contains(&tx.hash()) + } else { + false + } + }) + .map(|responder| { + responder.respond(mempool::Response::Transactions(vec![UnminedTx { + id: UnminedTxId::Legacy(tx.hash()), + transaction: tx.clone(), + size: 0, + }])); + }); + let get_tx_req = rpc.get_raw_transaction(tx.hash().encode_hex(), 0u8); + let (response, _) = futures::join!(get_tx_req, mempool_req); + let get_tx = response.expect("We should have a GetRawTransaction struct"); + if let GetRawTransaction::Raw(raw_tx) = get_tx { + assert_eq!(raw_tx.as_ref(), tx.zcash_serialize_to_vec().unwrap()); + } else { + unreachable!("Should return a Raw enum") + } + } + } + + // Test case where transaction is _not_ in mempool. + // Skip genesis because its tx is not indexed. + for block in blocks.iter().skip(1) { + for tx in block.transactions.iter() { + let mempool_req = mempool + .expect_request_that(|request| { + if let mempool::Request::TransactionsByMinedId(ids) = request { + ids.len() == 1 && ids.contains(&tx.hash()) + } else { + false + } + }) + .map(|responder| { + responder.respond(mempool::Response::Transactions(vec![])); + }); + let get_tx_req = rpc.get_raw_transaction(tx.hash().encode_hex(), 0u8); + let (response, _) = futures::join!(get_tx_req, mempool_req); + let get_tx = response.expect("We should have a GetRawTransaction struct"); + if let GetRawTransaction::Raw(raw_tx) = get_tx { + assert_eq!(raw_tx.as_ref(), tx.zcash_serialize_to_vec().unwrap()); + } else { + unreachable!("Should return a Raw enum") + } + } + } +} diff --git a/zebra-rpc/src/tests.rs b/zebra-rpc/src/tests.rs new file mode 100644 index 00000000..ceba02b1 --- /dev/null +++ b/zebra-rpc/src/tests.rs @@ -0,0 +1 @@ +mod vectors; diff --git a/zebra-rpc/src/tests/vectors.rs b/zebra-rpc/src/tests/vectors.rs new file mode 100644 index 00000000..d6aab850 --- /dev/null +++ b/zebra-rpc/src/tests/vectors.rs @@ -0,0 +1,19 @@ +use crate::methods::GetRawTransaction; + +#[test] +pub fn test_transaction_serialization() { + let expected_tx = GetRawTransaction::Raw(vec![0x42].into()); + let expected_json = r#""42""#; + let j = serde_json::to_string(&expected_tx).unwrap(); + + assert_eq!(j, expected_json); + + let expected_tx = GetRawTransaction::Object { + hex: vec![0x42].into(), + height: 1, + }; + let expected_json = r#"{"hex":"42","height":1}"#; + let j = serde_json::to_string(&expected_tx).unwrap(); + + assert_eq!(j, expected_json); +} diff --git a/zebra-state/src/response.rs b/zebra-state/src/response.rs index 8b49019a..63771425 100644 --- a/zebra-state/src/response.rs +++ b/zebra-state/src/response.rs @@ -52,5 +52,5 @@ pub enum ReadResponse { Block(Option>), /// Response to [`ReadRequest::Transaction`] with the specified transaction. - Transaction(Option>), + Transaction(Option<(Arc, block::Height)>), } diff --git a/zebra-state/src/service.rs b/zebra-state/src/service.rs index 7c4b553e..415adf02 100644 --- a/zebra-state/src/service.rs +++ b/zebra-state/src/service.rs @@ -467,7 +467,7 @@ impl StateService { /// Returns the [`Transaction`] with [`transaction::Hash`], /// if it exists in the current best chain. pub fn best_transaction(&self, hash: transaction::Hash) -> Option> { - read::transaction(self.mem.best_chain(), self.disk.db(), hash) + read::transaction(self.mem.best_chain(), self.disk.db(), hash).map(|(tx, _height)| tx) } /// Return the hash for the block at `height` in the current best chain. @@ -957,11 +957,12 @@ impl Service for ReadStateService { let state = self.clone(); async move { - let transaction = state.best_chain_receiver.with_watch_data(|best_chain| { - read::transaction(best_chain, &state.db, hash) - }); + let transaction_and_height = + state.best_chain_receiver.with_watch_data(|best_chain| { + read::transaction(best_chain, &state.db, hash) + }); - Ok(ReadResponse::Transaction(transaction)) + Ok(ReadResponse::Transaction(transaction_and_height)) } .boxed() } diff --git a/zebra-state/src/service/finalized_state/zebra_db/block.rs b/zebra-state/src/service/finalized_state/zebra_db/block.rs index 71c76231..7fa7f9ae 100644 --- a/zebra-state/src/service/finalized_state/zebra_db/block.rs +++ b/zebra-state/src/service/finalized_state/zebra_db/block.rs @@ -111,7 +111,10 @@ impl ZebraDb { /// Returns the [`Transaction`] with [`transaction::Hash`], /// if it exists in the finalized chain. - pub fn transaction(&self, hash: transaction::Hash) -> Option> { + pub fn transaction( + &self, + hash: transaction::Hash, + ) -> Option<(Arc, block::Height)> { self.transaction_location(hash) .map(|TransactionLocation { index, height }| { let block = self @@ -119,7 +122,7 @@ impl ZebraDb { .expect("block will exist if TransactionLocation does"); // TODO: store transactions in a separate database index (#3151) - block.transactions[index.as_usize()].clone() + (block.transactions[index.as_usize()].clone(), height) }) } diff --git a/zebra-state/src/service/non_finalized_state/chain.rs b/zebra-state/src/service/non_finalized_state/chain.rs index 3b8f45c7..86e600a0 100644 --- a/zebra-state/src/service/non_finalized_state/chain.rs +++ b/zebra-state/src/service/non_finalized_state/chain.rs @@ -330,10 +330,13 @@ impl Chain { } /// Returns the [`Transaction`] with [`transaction::Hash`], if it exists in this chain. - pub fn transaction(&self, hash: transaction::Hash) -> Option<&Arc> { + pub fn transaction( + &self, + hash: transaction::Hash, + ) -> Option<(&Arc, block::Height)> { self.tx_by_hash .get(&hash) - .map(|(height, index)| &self.blocks[height].block.transactions[*index]) + .map(|(height, index)| (&self.blocks[height].block.transactions[*index], *height)) } /// Returns the block hash of the tip block. diff --git a/zebra-state/src/service/read.rs b/zebra-state/src/service/read.rs index b7698938..7e773c3f 100644 --- a/zebra-state/src/service/read.rs +++ b/zebra-state/src/service/read.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use zebra_chain::{ - block::Block, + block::{self, Block}, transaction::{self, Transaction}, }; @@ -51,7 +51,7 @@ pub(crate) fn transaction( chain: Option, db: &ZebraDb, hash: transaction::Hash, -) -> Option> +) -> Option<(Arc, block::Height)> where C: AsRef, { @@ -65,6 +65,11 @@ where // (`chain` is always in memory, but `db` stores transactions on disk, with a memory cache.) chain .as_ref() - .and_then(|chain| chain.as_ref().transaction(hash).cloned()) + .and_then(|chain| { + chain + .as_ref() + .transaction(hash) + .map(|(tx, height)| (tx.clone(), height)) + }) .or_else(|| db.transaction(hash)) } diff --git a/zebra-state/src/service/read/tests/vectors.rs b/zebra-state/src/service/read/tests/vectors.rs index db133686..6764d800 100644 --- a/zebra-state/src/service/read/tests/vectors.rs +++ b/zebra-state/src/service/read/tests/vectors.rs @@ -68,7 +68,10 @@ async fn populated_read_state_responds_correctly() -> Result<()> { for transaction in &block.transactions { let transaction_cases = vec![( ReadRequest::Transaction(transaction.hash()), - Ok(ReadResponse::Transaction(Some(transaction.clone()))), + Ok(ReadResponse::Transaction(Some(( + transaction.clone(), + block.coinbase_height().unwrap(), + )))), )]; let transaction_cases = Transcript::from(transaction_cases);