diff --git a/zebra-rpc/src/methods.rs b/zebra-rpc/src/methods.rs index b4105c29dcd..8fd68272512 100644 --- a/zebra-rpc/src/methods.rs +++ b/zebra-rpc/src/methods.rs @@ -38,6 +38,10 @@ use crate::{ queue::Queue, }; +mod errors; + +use errors::{MapServerError, OkOrServerError}; + // We don't use a types/ module here, because it is redundant. pub mod trees; @@ -675,7 +679,6 @@ where } // TODO: - // - use a generic error constructor (#5548) // - use `height_from_signed_int()` to handle negative heights // (this might be better in the state request, because it needs the state height) // - create a function that handles block hashes or heights, and use it in `z_get_treestate()` @@ -727,7 +730,7 @@ where }), _ => unreachable!("unmatched response to a block request"), } - } else if verbosity == 1 { + } else if verbosity == 1 || verbosity == 2 { // # Performance // // This RPC is used in `lightwalletd`'s initial sync of 2 million blocks, @@ -748,6 +751,8 @@ where // must be able to handle chain forks, including a hash for a block that is // later discovered to be on a side chain. + let should_read_block_header = verbosity == 2; + let hash = match hash_or_height { HashOrHeight::Hash(hash) => hash, HashOrHeight::Height(height) => { @@ -776,120 +781,80 @@ where } }; - // Get transaction IDs from the transaction index by block hash - // - // # Concurrency - // - // We look up by block hash so the hash, transaction IDs, and confirmations - // are consistent. - // - // A block's transaction IDs are never modified, so all possible responses are - // valid. Clients that query block heights must be able to handle chain forks, - // including getting transaction IDs from any chain fork. - let request = zebra_state::ReadRequest::TransactionIdsForBlock(hash.into()); - let tx_ids_response_fut = state.clone().oneshot(request); - - // Get block confirmations from the block height index - // - // # Concurrency - // - // We look up by block hash so the hash, transaction IDs, and confirmations - // are consistent. - // - // All possible responses are valid, even if a block is added to the chain, or - // the best chain changes. Clients must be able to handle chain forks, including - // different confirmation values before or after added blocks, and switching - // between -1 and multiple different confirmation values. - - // From - const NOT_IN_BEST_CHAIN_CONFIRMATIONS: i64 = -1; - - let request = zebra_state::ReadRequest::Depth(hash); - let depth_response_fut = state.clone().oneshot(request); + // TODO: look up the height if we only have a hash, + // this needs a new state request for the height -> hash index + let height = hash_or_height.height(); - // Sapling trees - // // # Concurrency // // We look up by block hash so the hash, transaction IDs, and confirmations // are consistent. - let request = zebra_state::ReadRequest::SaplingTree(hash.into()); - let sapling_tree_response_fut = state.clone().oneshot(request); - - // Orchard trees - // - // # Concurrency - // - // We look up by block hash so the hash, transaction IDs, and confirmations - // are consistent. - let request = zebra_state::ReadRequest::OrchardTree(hash.into()); - let orchard_tree_response_fut = state.clone().oneshot(request); + let mut requests = vec![ + // Get transaction IDs from the transaction index by block hash + // + // # Concurrency + // + // A block's transaction IDs are never modified, so all possible responses are + // valid. Clients that query block heights must be able to handle chain forks, + // including getting transaction IDs from any chain fork. + zebra_state::ReadRequest::TransactionIdsForBlock(hash.into()), + // Sapling trees + zebra_state::ReadRequest::SaplingTree(hash.into()), + // Orchard trees + zebra_state::ReadRequest::OrchardTree(hash.into()), + // Get block confirmations from the block height index + // + // # Concurrency + // + // All possible responses are valid, even if a block is added to the chain, or + // the best chain changes. Clients must be able to handle chain forks, including + // different confirmation values before or after added blocks, and switching + // between -1 and multiple different confirmation values. + zebra_state::ReadRequest::Depth(hash), + ]; + + if should_read_block_header { + // Block header + requests.push(zebra_state::ReadRequest::BlockHeader(hash.into())) + } let mut futs = FuturesOrdered::new(); - futs.push_back(tx_ids_response_fut); - futs.push_back(sapling_tree_response_fut); - futs.push_back(orchard_tree_response_fut); - futs.push_back(depth_response_fut); - - let tx_ids_response = futs - .next() - .await - .expect("should have 4 items in futs") - .map_err(|error| Error { - code: ErrorCode::ServerError(0), - message: error.to_string(), - data: None, - })?; - let sapling_tree_response = futs - .next() - .await - .expect("should have 3 items in futs") - .map_err(|error| Error { - code: ErrorCode::ServerError(0), - message: error.to_string(), - data: None, - })?; - - let orchard_tree_response = futs - .next() - .await - .expect("should have 2 items in futs") - .map_err(|error| Error { - code: ErrorCode::ServerError(0), - message: error.to_string(), - data: None, - })?; - - let depth_response = futs - .next() - .await - .expect("should have an item in futs") - .map_err(|error| Error { - code: ErrorCode::ServerError(0), - message: error.to_string(), - data: None, - })?; + for request in requests { + futs.push_back(state.clone().oneshot(request)); + } - let tx = match tx_ids_response { - zebra_state::ReadResponse::TransactionIdsForBlock(Some(tx_ids)) => { - tx_ids.iter().map(|tx_id| tx_id.encode_hex()).collect() - } - zebra_state::ReadResponse::TransactionIdsForBlock(None) => Err(Error { - code: MISSING_BLOCK_ERROR_CODE, - message: "Block not found".to_string(), - data: None, - })?, + let tx_ids_response = futs.next().await.expect("`futs` should not be empty"); + let tx = match tx_ids_response.map_server_error()? { + zebra_state::ReadResponse::TransactionIdsForBlock(tx_ids) => tx_ids + .ok_or_server_error("Block not found")? + .iter() + .map(|tx_id| tx_id.encode_hex()) + .collect(), _ => unreachable!("unmatched response to a transaction_ids_for_block request"), }; - let sapling_note_commitment_tree_count = match sapling_tree_response { - zebra_state::ReadResponse::SaplingTree(Some(nct)) => nct.count(), - zebra_state::ReadResponse::SaplingTree(None) => 0, - _ => unreachable!("unmatched response to a SaplingTree request"), - }; + let sapling_tree_response = futs.next().await.expect("`futs` should not be empty"); + let sapling_note_commitment_tree_count = + match sapling_tree_response.map_server_error()? { + zebra_state::ReadResponse::SaplingTree(Some(nct)) => nct.count(), + zebra_state::ReadResponse::SaplingTree(None) => 0, + _ => unreachable!("unmatched response to a SaplingTree request"), + }; + + let orchard_tree_response = futs.next().await.expect("`futs` should not be empty"); + let orchard_note_commitment_tree_count = + match orchard_tree_response.map_server_error()? { + zebra_state::ReadResponse::OrchardTree(Some(nct)) => nct.count(), + zebra_state::ReadResponse::OrchardTree(None) => 0, + _ => unreachable!("unmatched response to a OrchardTree request"), + }; + + // From + const NOT_IN_BEST_CHAIN_CONFIRMATIONS: i64 = -1; - let confirmations = match depth_response { + let depth_response = futs.next().await.expect("`futs` should not be empty"); + let confirmations = match depth_response.map_server_error()? { // Confirmations are one more than the depth. // Depth is limited by height, so it will never overflow an i64. zebra_state::ReadResponse::Depth(Some(depth)) => i64::from(depth) + 1, @@ -897,14 +862,21 @@ where _ => unreachable!("unmatched response to a depth request"), }; - // TODO: look up the height if we only have a hash, - // this needs a new state request for the height -> hash index - let height = hash_or_height.height(); - - let orchard_note_commitment_tree_count = match orchard_tree_response { - zebra_state::ReadResponse::OrchardTree(Some(nct)) => nct.count(), - zebra_state::ReadResponse::OrchardTree(None) => 0, - _ => unreachable!("unmatched response to a OrchardTree request"), + let time = if should_read_block_header { + let block_header_response = + futs.next().await.expect("`futs` should not be empty"); + + match block_header_response.map_server_error()? { + zebra_state::ReadResponse::BlockHeader(header) => Some( + header + .ok_or_server_error("Block not found")? + .time + .timestamp(), + ), + _ => unreachable!("unmatched response to a BlockHeader request"), + } + } else { + None }; let sapling = SaplingTrees { @@ -921,6 +893,7 @@ where hash: GetBlockHash(hash), confirmations, height, + time, tx, trees, }) @@ -1639,6 +1612,10 @@ pub enum GetBlock { #[serde(skip_serializing_if = "Option::is_none")] height: Option, + /// The height of the requested block. + #[serde(skip_serializing_if = "Option::is_none")] + time: Option, + /// List of transaction IDs in block order, hex-encoded. // // TODO: use a typed Vec here @@ -1655,6 +1632,7 @@ impl Default for GetBlock { hash: GetBlockHash::default(), confirmations: 0, height: None, + time: None, tx: Vec::new(), trees: GetBlockTrees::default(), } diff --git a/zebra-rpc/src/methods/errors.rs b/zebra-rpc/src/methods/errors.rs new file mode 100644 index 00000000000..be9231d058d --- /dev/null +++ b/zebra-rpc/src/methods/errors.rs @@ -0,0 +1,37 @@ +//! Error conversions for Zebra's RPC methods. + +use jsonrpc_core::ErrorCode; + +pub(crate) trait MapServerError { + fn map_server_error(self) -> std::result::Result; +} + +pub(crate) trait OkOrServerError { + fn ok_or_server_error( + self, + message: S, + ) -> std::result::Result; +} + +impl MapServerError for Result +where + E: ToString, +{ + fn map_server_error(self) -> Result { + self.map_err(|error| jsonrpc_core::Error { + code: ErrorCode::ServerError(0), + message: error.to_string(), + data: None, + }) + } +} + +impl OkOrServerError for Option { + fn ok_or_server_error(self, message: S) -> Result { + self.ok_or(jsonrpc_core::Error { + code: ErrorCode::ServerError(0), + message: message.to_string(), + data: None, + }) + } +} diff --git a/zebra-rpc/src/methods/tests/snapshot.rs b/zebra-rpc/src/methods/tests/snapshot.rs index 94e12cc806f..e1c84f4a520 100644 --- a/zebra-rpc/src/methods/tests/snapshot.rs +++ b/zebra-rpc/src/methods/tests/snapshot.rs @@ -2,7 +2,7 @@ //! //! To update these snapshots, run: //! ```sh -//! cargo insta test --review +//! cargo insta test --review -p zebra-rpc --lib -- test_rpc_response_data //! ``` use std::{collections::BTreeMap, sync::Arc}; @@ -167,6 +167,25 @@ async fn test_rpc_response_data_for_network(network: &Network) { .expect("We should have a GetBlock struct"); snapshot_rpc_getblock_verbose("hash_verbosity_1", get_block, &settings); + // `getblock`, verbosity=2, height + let get_block = rpc + .get_block(BLOCK_HEIGHT.to_string(), Some(2u8)) + .await + .expect("We should have a GetBlock struct"); + snapshot_rpc_getblock_verbose("height_verbosity_2", get_block, &settings); + + let get_block = rpc + .get_block(EXCESSIVE_BLOCK_HEIGHT.to_string(), Some(2u8)) + .await; + snapshot_rpc_getblock_invalid("excessive_height_verbosity_2", get_block, &settings); + + // `getblock`, verbosity=2, hash + let get_block = rpc + .get_block(block_hash.to_string(), Some(2u8)) + .await + .expect("We should have a GetBlock struct"); + snapshot_rpc_getblock_verbose("hash_verbosity_2", get_block, &settings); + // `getblock`, no verbosity - defaults to 1, height let get_block = rpc .get_block(BLOCK_HEIGHT.to_string(), None) diff --git a/zebra-rpc/src/methods/tests/snapshots/get_block_invalid_excessive_height_verbosity_2@mainnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_block_invalid_excessive_height_verbosity_2@mainnet_10.snap new file mode 100644 index 00000000000..5c2e6892a7d --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_block_invalid_excessive_height_verbosity_2@mainnet_10.snap @@ -0,0 +1,10 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: response +--- +{ + "Err": { + "code": -8, + "message": "block height not in best chain" + } +} diff --git a/zebra-rpc/src/methods/tests/snapshots/get_block_invalid_excessive_height_verbosity_2@testnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_block_invalid_excessive_height_verbosity_2@testnet_10.snap new file mode 100644 index 00000000000..5c2e6892a7d --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_block_invalid_excessive_height_verbosity_2@testnet_10.snap @@ -0,0 +1,10 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: response +--- +{ + "Err": { + "code": -8, + "message": "block height not in best chain" + } +} diff --git a/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_hash_verbosity_2@mainnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_hash_verbosity_2@mainnet_10.snap new file mode 100644 index 00000000000..3bcf968bed5 --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_hash_verbosity_2@mainnet_10.snap @@ -0,0 +1,13 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: block +--- +{ + "hash": "0007bc227e1c57a4a70e237cad00e7b7ce565155ab49166bc57397a26d339283", + "confirmations": 10, + "time": 1477671596, + "tx": [ + "851bf6fbf7a976327817c738c489d7fa657752445430922d94c983c0b9ed4609" + ], + "trees": {} +} diff --git a/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_hash_verbosity_2@testnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_hash_verbosity_2@testnet_10.snap new file mode 100644 index 00000000000..7ea021d3382 --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_hash_verbosity_2@testnet_10.snap @@ -0,0 +1,13 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: block +--- +{ + "hash": "025579869bcf52a989337342f5f57a84f3a28b968f7d6a8307902b065a668d23", + "confirmations": 10, + "time": 1477674473, + "tx": [ + "f37e9f691fffb635de0999491d906ee85ba40cd36dae9f6e5911a8277d7c5f75" + ], + "trees": {} +} diff --git a/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_height_verbosity_2@mainnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_height_verbosity_2@mainnet_10.snap new file mode 100644 index 00000000000..f18b879f6b3 --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_height_verbosity_2@mainnet_10.snap @@ -0,0 +1,14 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: block +--- +{ + "hash": "0007bc227e1c57a4a70e237cad00e7b7ce565155ab49166bc57397a26d339283", + "confirmations": 10, + "height": 1, + "time": 1477671596, + "tx": [ + "851bf6fbf7a976327817c738c489d7fa657752445430922d94c983c0b9ed4609" + ], + "trees": {} +} diff --git a/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_height_verbosity_2@testnet_10.snap b/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_height_verbosity_2@testnet_10.snap new file mode 100644 index 00000000000..013a4c09b23 --- /dev/null +++ b/zebra-rpc/src/methods/tests/snapshots/get_block_verbose_height_verbosity_2@testnet_10.snap @@ -0,0 +1,14 @@ +--- +source: zebra-rpc/src/methods/tests/snapshot.rs +expression: block +--- +{ + "hash": "025579869bcf52a989337342f5f57a84f3a28b968f7d6a8307902b065a668d23", + "confirmations": 10, + "height": 1, + "time": 1477674473, + "tx": [ + "f37e9f691fffb635de0999491d906ee85ba40cd36dae9f6e5911a8277d7c5f75" + ], + "trees": {} +} diff --git a/zebra-rpc/src/methods/tests/vectors.rs b/zebra-rpc/src/methods/tests/vectors.rs index 28ca7b198ff..1591ab69057 100644 --- a/zebra-rpc/src/methods/tests/vectors.rs +++ b/zebra-rpc/src/methods/tests/vectors.rs @@ -140,6 +140,7 @@ async fn rpc_getblock() { hash: GetBlockHash(block.hash()), confirmations: (blocks.len() - i).try_into().expect("valid i64"), height: Some(Height(i.try_into().expect("valid u32"))), + time: None, tx: block .transactions .iter() @@ -163,6 +164,55 @@ async fn rpc_getblock() { hash: GetBlockHash(block.hash()), confirmations: (blocks.len() - i).try_into().expect("valid i64"), height: None, + time: None, + tx: block + .transactions + .iter() + .map(|tx| tx.hash().encode_hex()) + .collect(), + trees, + } + ); + } + + // Make height calls with verbosity=2 and check response + for (i, block) in blocks.iter().enumerate() { + let get_block = rpc + .get_block(i.to_string(), Some(2u8)) + .await + .expect("We should have a GetBlock struct"); + + assert_eq!( + get_block, + GetBlock::Object { + hash: GetBlockHash(block.hash()), + confirmations: (blocks.len() - i).try_into().expect("valid i64"), + height: Some(Height(i.try_into().expect("valid u32"))), + time: Some(block.header.time.timestamp()), + tx: block + .transactions + .iter() + .map(|tx| tx.hash().encode_hex()) + .collect(), + trees, + } + ); + } + + // Make hash calls with verbosity=2 and check response + for (i, block) in blocks.iter().enumerate() { + let get_block = rpc + .get_block(blocks[i].hash().to_string(), Some(2u8)) + .await + .expect("We should have a GetBlock struct"); + + assert_eq!( + get_block, + GetBlock::Object { + hash: GetBlockHash(block.hash()), + confirmations: (blocks.len() - i).try_into().expect("valid i64"), + height: None, + time: Some(block.header.time.timestamp()), tx: block .transactions .iter() @@ -186,6 +236,7 @@ async fn rpc_getblock() { hash: GetBlockHash(block.hash()), confirmations: (blocks.len() - i).try_into().expect("valid i64"), height: Some(Height(i.try_into().expect("valid u32"))), + time: None, tx: block .transactions .iter() @@ -209,6 +260,7 @@ async fn rpc_getblock() { hash: GetBlockHash(block.hash()), confirmations: (blocks.len() - i).try_into().expect("valid i64"), height: None, + time: None, tx: block .transactions .iter() diff --git a/zebra-state/src/request.rs b/zebra-state/src/request.rs index 2a6b3b1a5b8..6f785a9d250 100644 --- a/zebra-state/src/request.rs +++ b/zebra-state/src/request.rs @@ -614,6 +614,16 @@ pub enum Request { /// [`block::Height`] using `.into()`. Block(HashOrHeight), + /// Looks up a block header by hash or height in the current best chain. + /// + /// Returns + /// + /// [`Response::BlockHeader(block::Header)`](Response::BlockHeader). + /// + /// Note: the [`HashOrHeight`] can be constructed from a [`block::Hash`] or + /// [`block::Height`] using `.into()`. + BlockHeader(HashOrHeight), + /// Request a UTXO identified by the given [`OutPoint`](transparent::OutPoint), /// waiting until it becomes available if it is unknown. /// @@ -725,6 +735,7 @@ impl Request { Request::Transaction(_) => "transaction", Request::UnspentBestChainUtxo { .. } => "unspent_best_chain_utxo", Request::Block(_) => "block", + Request::BlockHeader(_) => "block_header", Request::FindBlockHashes { .. } => "find_block_hashes", Request::FindBlockHeaders { .. } => "find_block_headers", Request::CheckBestChainTipNullifiersAndAnchors(_) => { @@ -776,6 +787,16 @@ pub enum ReadRequest { /// [`block::Height`] using `.into()`. Block(HashOrHeight), + /// Looks up a block header by hash or height in the current best chain. + /// + /// Returns + /// + /// [`Response::BlockHeader(block::Header)`](Response::BlockHeader). + /// + /// Note: the [`HashOrHeight`] can be constructed from a [`block::Hash`] or + /// [`block::Height`] using `.into()`. + BlockHeader(HashOrHeight), + /// Looks up a transaction by hash in the current best chain. /// /// Returns @@ -999,6 +1020,7 @@ impl ReadRequest { ReadRequest::Tip => "tip", ReadRequest::Depth(_) => "depth", ReadRequest::Block(_) => "block", + ReadRequest::BlockHeader(_) => "block_header", ReadRequest::Transaction(_) => "transaction", ReadRequest::TransactionIdsForBlock(_) => "transaction_ids_for_block", ReadRequest::UnspentBestChainUtxo { .. } => "unspent_best_chain_utxo", @@ -1052,6 +1074,7 @@ impl TryFrom for ReadRequest { Request::BestChainBlockHash(hash) => Ok(ReadRequest::BestChainBlockHash(hash)), Request::Block(hash_or_height) => Ok(ReadRequest::Block(hash_or_height)), + Request::BlockHeader(hash_or_height) => Ok(ReadRequest::BlockHeader(hash_or_height)), Request::Transaction(tx_hash) => Ok(ReadRequest::Transaction(tx_hash)), Request::UnspentBestChainUtxo(outpoint) => { Ok(ReadRequest::UnspentBestChainUtxo(outpoint)) diff --git a/zebra-state/src/response.rs b/zebra-state/src/response.rs index ad75287d8f1..242191f8259 100644 --- a/zebra-state/src/response.rs +++ b/zebra-state/src/response.rs @@ -50,6 +50,9 @@ pub enum Response { /// Response to [`Request::Block`] with the specified block. Block(Option>), + /// The response to a `BlockHeader` request. + BlockHeader(Option>), + /// The response to a `AwaitUtxo` request, from any non-finalized chains, finalized chain, /// pending unverified blocks, or blocks received after the request was sent. Utxo(transparent::Utxo), @@ -131,6 +134,9 @@ pub enum ReadResponse { /// Response to [`ReadRequest::Block`] with the specified block. Block(Option>), + /// The response to a `BlockHeader` request. + BlockHeader(Option>), + /// Response to [`ReadRequest::Transaction`] with the specified transaction. Transaction(Option), @@ -265,6 +271,7 @@ impl TryFrom for Response { ReadResponse::BlockHash(hash) => Ok(Response::BlockHash(hash)), ReadResponse::Block(block) => Ok(Response::Block(block)), + ReadResponse::BlockHeader(header) => Ok(Response::BlockHeader(header)), ReadResponse::Transaction(tx_info) => { Ok(Response::Transaction(tx_info.map(|tx_info| tx_info.tx))) } diff --git a/zebra-state/src/service.rs b/zebra-state/src/service.rs index bd0abe8670e..203c1d34c70 100644 --- a/zebra-state/src/service.rs +++ b/zebra-state/src/service.rs @@ -1120,6 +1120,7 @@ impl Service for StateService { | Request::Transaction(_) | Request::UnspentBestChainUtxo(_) | Request::Block(_) + | Request::BlockHeader(_) | Request::FindBlockHashes { .. } | Request::FindBlockHeaders { .. } | Request::CheckBestChainTipNullifiersAndAnchors(_) => { @@ -1288,6 +1289,31 @@ impl Service for ReadStateService { .wait_for_panics() } + // Used by the get_block (verbose) RPC and the StateService. + ReadRequest::BlockHeader(hash_or_height) => { + let state = self.clone(); + + tokio::task::spawn_blocking(move || { + span.in_scope(move || { + let header = state.non_finalized_state_receiver.with_watch_data( + |non_finalized_state| { + read::block_header( + non_finalized_state.best_chain(), + &state.db, + hash_or_height, + ) + }, + ); + + // The work is done in the future. + timer.finish(module_path!(), line!(), "ReadRequest::Block"); + + Ok(ReadResponse::BlockHeader(header)) + }) + }) + .wait_for_panics() + } + // For the get_raw_transaction RPC and the StateService. ReadRequest::Transaction(hash) => { let state = self.clone();