diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index daa58ef60..e7ad1859b 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -95,6 +95,12 @@ zaino-fetch = { workspace = true } zaino-testutils = { workspace = true } tracing.workspace = true +# Pin to a yanked version that `zcash_protocol ^0.7.2` transitively requires +# via `core2 = "^0.3"`. All 0.3.x are yanked, so fresh resolves without this +# explicit pin fail. The root workspace `Cargo.lock` already has 0.3.3 locked. +# TODO: drop when `zcash_protocol` no longer pulls `core2 0.3.x`. +core2 = "=0.3.3" + [dev-dependencies] anyhow = { workspace = true } diff --git a/integration-tests/tests/fetch_service.rs b/integration-tests/tests/fetch_service.rs index 932c6d11b..11463a8ed 100644 --- a/integration-tests/tests/fetch_service.rs +++ b/integration-tests/tests/fetch_service.rs @@ -3,6 +3,7 @@ use futures::StreamExt as _; use hex::ToHex as _; use zaino_fetch::jsonrpsee::connector::{test_node_and_return_url, JsonRpSeeConnector}; +use zaino_fetch::jsonrpsee::request::block_selector::BlockSelector; use zaino_proto::proto::compact_formats::CompactBlock; use zaino_proto::proto::service::{ AddressList, BlockId, BlockRange, GetAddressUtxosArg, GetMempoolTxRequest, GetSubtreeRootsArg, @@ -13,11 +14,12 @@ use zaino_state::FetchServiceSubscriber; #[allow(deprecated)] use zaino_state::{FetchService, LightWalletIndexer, Status, StatusType, ZcashIndexer}; use zaino_testutils::{TestManager, ValidatorExt, ValidatorKind}; +use zebra_chain::block::Height; use zebra_chain::parameters::subsidy::ParameterSubsidy as _; use zebra_chain::subtree::NoteCommitmentSubtreeIndex; use zebra_rpc::client::ValidateAddressResponse; use zebra_rpc::methods::{ - GetAddressBalanceRequest, GetAddressTxIdsRequest, GetBlock, GetBlockHash, + GetAddressBalanceRequest, GetAddressTxIdsRequest, GetBlock, GetBlockHashResponse, }; use zip32::AccountId; @@ -922,17 +924,56 @@ async fn fetch_service_get_best_blockhash(validator: &Validator _ => None, }; - let fetch_service_get_best_blockhash: GetBlockHash = + let fetch_service_get_best_blockhash: GetBlockHashResponse = dbg!(fetch_service_subscriber.get_best_blockhash().await.unwrap()); assert_eq!( fetch_service_get_best_blockhash.hash(), - ret.expect("ret to be Some(GetBlockHash) not None") + ret.expect("ret to be Some(GetBlockHashResponse) not None") ); test_manager.close().await; } +#[allow(deprecated)] +async fn fetch_service_get_blockhash(validator: &ValidatorKind) { + let mut test_manager = + TestManager::::launch(validator, None, None, None, true, false, false) + .await + .unwrap(); + let fetch_service_subscriber = test_manager.service_subscriber.take().unwrap(); + + test_manager + .generate_blocks_and_poll_indexer(5, &fetch_service_subscriber) + .await; + + // Resolve the expected hash for a fixed, non-tip height via z_get_block. + let inspected_block: GetBlock = fetch_service_subscriber + .z_get_block("7".to_string(), Some(1)) + .await + .unwrap(); + let expected_hash_at_height = match inspected_block { + GetBlock::Object(obj) => obj.hash(), + GetBlock::Raw(_) => panic!("expected Object variant with verbosity=1"), + }; + + let got_by_height: GetBlockHashResponse = fetch_service_subscriber + .get_blockhash(BlockSelector::Height(Height(7))) + .await + .unwrap(); + assert_eq!(got_by_height.hash(), expected_hash_at_height); + + // Tip selector should agree with get_best_blockhash. + let got_by_tip: GetBlockHashResponse = fetch_service_subscriber + .get_blockhash(BlockSelector::Tip) + .await + .unwrap(); + let best = fetch_service_subscriber.get_best_blockhash().await.unwrap(); + assert_eq!(got_by_tip.hash(), best.hash()); + + test_manager.close().await; +} + #[allow(deprecated)] async fn fetch_service_get_block_count(validator: &ValidatorKind) { let mut test_manager = @@ -2301,6 +2342,11 @@ mod zcashd { fetch_service_get_best_blockhash::(&ValidatorKind::Zcashd).await; } + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn blockhash() { + fetch_service_get_blockhash::(&ValidatorKind::Zcashd).await; + } + #[tokio::test(flavor = "multi_thread")] pub(crate) async fn block_count() { fetch_service_get_block_count::(&ValidatorKind::Zcashd).await; @@ -2570,6 +2616,11 @@ mod zebrad { fetch_service_get_best_blockhash::(&ValidatorKind::Zebrad).await; } + #[tokio::test(flavor = "multi_thread")] + pub(crate) async fn blockhash() { + fetch_service_get_blockhash::(&ValidatorKind::Zebrad).await; + } + #[tokio::test(flavor = "multi_thread")] pub(crate) async fn block_count() { fetch_service_get_block_count::(&ValidatorKind::Zebrad).await; diff --git a/integration-tests/tests/state_service.rs b/integration-tests/tests/state_service.rs index 7189987ae..c51648da2 100644 --- a/integration-tests/tests/state_service.rs +++ b/integration-tests/tests/state_service.rs @@ -1,6 +1,7 @@ use futures::StreamExt; use zaino_common::network::ActivationHeights; use zaino_common::{DatabaseConfig, ServiceConfig, StorageConfig}; +use zaino_fetch::jsonrpsee::request::block_selector::BlockSelector; use zaino_fetch::jsonrpsee::response::address_deltas::GetAddressDeltasParams; use zaino_proto::proto::service::{BlockId, BlockRange, PoolType, TransparentAddressBlockFilter}; use zaino_state::ChainIndex as _; @@ -16,9 +17,12 @@ use zaino_testutils::{TestManager, ValidatorKind, ZEBRAD_TESTNET_CACHE_DIR}; use zainodlib::config::ZainodConfig; use zainodlib::error::IndexerError; use zcash_local_net::validator::{zebrad::Zebrad, Validator}; +use zebra_chain::block::Height; use zebra_chain::parameters::NetworkKind; use zebra_chain::subtree::NoteCommitmentSubtreeIndex; -use zebra_rpc::methods::{GetAddressBalanceRequest, GetAddressTxIdsRequest, GetInfo}; +use zebra_rpc::methods::{ + GetAddressBalanceRequest, GetAddressTxIdsRequest, GetBlockHashResponse, GetInfo, +}; use zip32::AccountId; #[allow(deprecated)] @@ -533,6 +537,86 @@ async fn state_service_get_block_object( test_manager.close().await; } +async fn state_service_get_blockhash( + validator: &ValidatorKind, + chain_cache: Option, + network: NetworkKind, +) { + let ( + mut test_manager, + _fetch_service, + fetch_service_subscriber, + _state_service, + state_service_subscriber, + ) = create_test_manager_and_services::( + validator, + chain_cache, + false, + false, + Some(network), + ) + .await; + + let target_height: u32 = match network { + NetworkKind::Regtest => 1, + _ => 1_000_000, + }; + + // Reference hash: pull block at target_height via z_get_block. + let reference_block = fetch_service_subscriber + .z_get_block(target_height.to_string(), Some(1)) + .await + .unwrap(); + let expected_hash_at_height = match reference_block { + zebra_rpc::methods::GetBlock::Object(obj) => obj.hash(), + zebra_rpc::methods::GetBlock::Raw(_) => panic!("expected Object variant with verbosity=1"), + }; + + let got_by_height: GetBlockHashResponse = state_service_subscriber + .get_blockhash(BlockSelector::Height(Height(target_height))) + .await + .unwrap(); + assert_eq!(got_by_height.hash(), expected_hash_at_height); + + // Tip selector should agree with get_best_blockhash. + let got_by_tip: GetBlockHashResponse = state_service_subscriber + .get_blockhash(BlockSelector::Tip) + .await + .unwrap(); + let best = state_service_subscriber.get_best_blockhash().await.unwrap(); + assert_eq!(got_by_tip.hash(), best.hash()); + + test_manager.close().await; +} + +async fn state_service_get_blockhash_out_of_range_returns_err(validator: &ValidatorKind) { + let ( + mut test_manager, + _fetch_service, + _fetch_service_subscriber, + _state_service, + state_service_subscriber, + ) = create_test_manager_and_services::( + validator, + None, + false, + false, + Some(NetworkKind::Regtest), + ) + .await; + + // A regtest chain at launch has only a handful of blocks; 9_999 is far beyond any tip. + let far_beyond_tip = BlockSelector::Height(Height(9_999)); + let result = state_service_subscriber.get_blockhash(far_beyond_tip).await; + + assert!( + result.is_err(), + "out-of-range height must return Err, not panic or succeed" + ); + + test_manager.close().await; +} + async fn state_service_get_raw_mempool(validator: &ValidatorKind) { let ( mut test_manager, @@ -2415,6 +2499,16 @@ mod zebra { .await; } + #[tokio::test(flavor = "multi_thread")] + async fn blockhash_regtest() { + state_service_get_blockhash(&ValidatorKind::Zebrad, None, NetworkKind::Regtest).await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn blockhash_out_of_range_returns_err() { + state_service_get_blockhash_out_of_range_returns_err(&ValidatorKind::Zebrad).await; + } + #[ignore = "requires fully synced testnet."] #[tokio::test(flavor = "multi_thread")] async fn block_object_testnet() { diff --git a/integration-tests/zaino-testutils/src/lib.rs b/integration-tests/zaino-testutils/src/lib.rs index 10ab11389..2a1263180 100644 --- a/integration-tests/zaino-testutils/src/lib.rs +++ b/integration-tests/zaino-testutils/src/lib.rs @@ -639,17 +639,36 @@ where n: u32, chain_index: &NodeBackedChainIndexSubscriber, ) { - async fn current_tip_height(chain_index: &NodeBackedChainIndexSubscriber) -> u32 { - let snapshot = chain_index.snapshot_nonfinalized_state().await.unwrap(); - u32::from(chain_index.best_chaintip(&snapshot).await.unwrap().height) - } - let chain_height = self.local_net.get_chain_height().await; let mut next_block_height = chain_height + 1; let mut interval = tokio::time::interval(std::time::Duration::from_millis(200)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); interval.tick().await; - while current_tip_height(chain_index).await < chain_height + n { + // Read the current nonfinalized tip height via the public ChainIndex API. + // `snapshot_nonfinalized_state` became async returning a Result, and the + // snapshot's inner `NonfinalizedBlockCacheSnapshot` is now `pub(crate)`, + // so external callers must go through `best_chaintip(&snapshot)`. + // + // Friction note: this patch was needed because the integration-tests + // workspace is separate from the root workspace, so these call sites + // rotted silently across a dev merge — root-workspace CI never compiled + // them. Expect the same class of breakage to recur in `chain_cache.rs` + // and anywhere else that consumes `ChainIndex::Snapshot` from outside + // `zaino-state`, until the split-workspace issue is addressed. + // See https://github.com/zingolabs/zaino/issues/1043. + async fn read_tip_height(chain_index: &NodeBackedChainIndexSubscriber) -> u32 { + let snapshot = chain_index + .snapshot_nonfinalized_state() + .await + .expect("chain index snapshot should succeed during test setup"); + let tip = chain_index + .best_chaintip(&snapshot) + .await + .expect("best chaintip should be available during test setup"); + u32::from(tip.height) + } + + while read_tip_height(chain_index).await < chain_height + n { // Check liveness - fail fast if the chain index is dead if !chain_index.is_live() { let status = chain_index.combined_status(); @@ -663,7 +682,7 @@ where interval.tick().await; } else { self.local_net.generate_blocks(1).await.unwrap(); - while current_tip_height(chain_index).await != next_block_height { + while read_tip_height(chain_index).await != next_block_height { if !chain_index.is_live() { let status = chain_index.combined_status(); panic!( diff --git a/packages/zaino-fetch/src/jsonrpsee.rs b/packages/zaino-fetch/src/jsonrpsee.rs index b510d849e..d0d6b7470 100644 --- a/packages/zaino-fetch/src/jsonrpsee.rs +++ b/packages/zaino-fetch/src/jsonrpsee.rs @@ -2,4 +2,5 @@ pub mod connector; pub mod error; +pub mod request; pub mod response; diff --git a/packages/zaino-fetch/src/jsonrpsee/connector.rs b/packages/zaino-fetch/src/jsonrpsee/connector.rs index 045875cad..72fdaf0df 100644 --- a/packages/zaino-fetch/src/jsonrpsee/connector.rs +++ b/packages/zaino-fetch/src/jsonrpsee/connector.rs @@ -22,6 +22,7 @@ use std::{ use tracing::error; use zebra_rpc::client::ValidateAddressResponse; +use crate::jsonrpsee::request::block_selector::BlockSelector; use crate::jsonrpsee::response::address_deltas::GetAddressDeltasError; use crate::jsonrpsee::{ error::{JsonRpcError, TransportError}, @@ -34,10 +35,10 @@ use crate::jsonrpsee::{ peer_info::GetPeerInfo, z_validate_address::{ZValidateAddressError, ZValidateAddressResponse}, GetBalanceError, GetBalanceResponse, GetBlockCountResponse, GetBlockError, GetBlockHash, - GetBlockResponse, GetBlockchainInfoResponse, GetInfoResponse, GetMempoolInfoResponse, - GetSubtreesError, GetSubtreesResponse, GetTransactionResponse, GetTreestateError, - GetTreestateResponse, GetUtxosError, GetUtxosResponse, SendTransactionError, - SendTransactionResponse, TxidsError, TxidsResponse, + GetBlockHashByIndex, GetBlockHashError, GetBlockResponse, GetBlockchainInfoResponse, + GetInfoResponse, GetMempoolInfoResponse, GetSubtreesError, GetSubtreesResponse, + GetTransactionResponse, GetTreestateError, GetTreestateResponse, GetUtxosError, + GetUtxosResponse, SendTransactionError, SendTransactionResponse, TxidsError, TxidsResponse, }, }; @@ -625,6 +626,20 @@ impl JsonRpSeeConnector { .await } + /// Returns the hash of the block at the given index in the best valid block chain. + /// + /// zcashd reference: [`getblockhash`](https://zcash.github.io/rpc/getblockhash.html) + /// method: post + /// tags: blockchain + pub async fn get_blockhash( + &self, + block_index: BlockSelector, + ) -> Result> { + let params = [serde_json::to_value(block_index).map_err(RpcRequestError::JsonRpc)?]; + let wrapped: GetBlockHashByIndex = self.send_request("getblockhash", params).await?; + Ok(wrapped.0) + } + /// Returns the height of the most recent block in the best valid block chain /// (equivalently, the number of blocks in this chain excluding the genesis block). /// diff --git a/packages/zaino-fetch/src/jsonrpsee/request.rs b/packages/zaino-fetch/src/jsonrpsee/request.rs new file mode 100644 index 000000000..aa483fd6e --- /dev/null +++ b/packages/zaino-fetch/src/jsonrpsee/request.rs @@ -0,0 +1,7 @@ +//! Request parameter types for jsonRPSeeConnector. +//! +//! These types model the inputs accepted by Zebra's JSON-RPC endpoints, kept +//! separate from [`crate::jsonrpsee::response`] so request and response +//! shapes do not end up colocated under a single module name. + +pub mod block_selector; diff --git a/packages/zaino-fetch/src/jsonrpsee/request/block_selector.rs b/packages/zaino-fetch/src/jsonrpsee/request/block_selector.rs new file mode 100644 index 000000000..dd022fe78 --- /dev/null +++ b/packages/zaino-fetch/src/jsonrpsee/request/block_selector.rs @@ -0,0 +1,238 @@ +//! Block index request parameter for `getblockhash`. + +use core::fmt; + +use serde::{de::Visitor, Deserialize, Deserializer, Serialize, Serializer}; +use zebra_chain::block::Height; + +/// Block index argument to `getblockhash`. +/// +/// Mirrors zcashd's convention where `-1` selects the chain tip. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BlockSelector { + /// The current chain tip. + Tip, + /// An absolute block height. + Height(Height), +} + +impl BlockSelector { + /// Resolve to a concrete height given the current tip. + #[inline] + pub fn resolve(self, tip: Height) -> Height { + match self { + BlockSelector::Tip => tip, + BlockSelector::Height(h) => h, + } + } + + /// Convenience: returns `Some(h)` if absolute, else `None`. + #[inline] + pub fn height(self) -> Option { + match self { + BlockSelector::Tip => None, + BlockSelector::Height(h) => Some(h), + } + } +} + +impl<'de> Deserialize<'de> for BlockSelector { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct SelVisitor; + + impl<'de> Visitor<'de> for SelVisitor { + type Value = BlockSelector; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "an integer height ≥ 0, -1 for tip, or a string like \"tip\"/\"-1\"/\"42\"" + ) + } + + fn visit_i64(self, v: i64) -> Result + where + E: serde::de::Error, + { + if v == -1 { + Ok(BlockSelector::Tip) + } else if v >= 0 && v <= u32::MAX as i64 { + Ok(BlockSelector::Height(Height(v as u32))) + } else { + Err(E::custom("block height out of range")) + } + } + + fn visit_u64(self, v: u64) -> Result + where + E: serde::de::Error, + { + if v <= u32::MAX as u64 { + Ok(BlockSelector::Height(Height(v as u32))) + } else { + Err(E::custom("block height out of range")) + } + } + + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + let s = s.trim(); + if s.eq_ignore_ascii_case("tip") { + return Ok(BlockSelector::Tip); + } + let v: i64 = s + .parse() + .map_err(|_| E::custom("invalid block index string"))?; + self.visit_i64(v) + } + } + + deserializer.deserialize_any(SelVisitor) + } +} + +impl Serialize for BlockSelector { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match *self { + BlockSelector::Tip => serializer.serialize_i64(-1), // mirrors zcashd “-1 = tip” + BlockSelector::Height(h) => serializer.serialize_u64(h.0 as u64), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{self, json}; + + #[test] + fn deserialize_numbers_succeeds() { + // JSON numbers + let selector_from_negative_one: BlockSelector = serde_json::from_str("-1").unwrap(); + assert_eq!(selector_from_negative_one, BlockSelector::Tip); + + let selector_from_spaced_negative_one: BlockSelector = + serde_json::from_str(" -1 ").unwrap(); + assert_eq!(selector_from_spaced_negative_one, BlockSelector::Tip); + + let selector_from_zero: BlockSelector = serde_json::from_str("0").unwrap(); + assert_eq!(selector_from_zero, BlockSelector::Height(Height(0))); + + let selector_from_forty_two: BlockSelector = serde_json::from_str("42").unwrap(); + assert_eq!(selector_from_forty_two, BlockSelector::Height(Height(42))); + + let selector_from_max_u32: BlockSelector = + serde_json::from_str(&u32::MAX.to_string()).unwrap(); + assert_eq!( + selector_from_max_u32, + BlockSelector::Height(Height(u32::MAX)) + ); + } + + #[test] + fn deserialize_strings_succeeds() { + // JSON strings + let selector_from_tip_literal: BlockSelector = serde_json::from_str(r#""tip""#).unwrap(); + assert_eq!(selector_from_tip_literal, BlockSelector::Tip); + + let selector_from_case_insensitive_tip: BlockSelector = + serde_json::from_str(r#"" TIP ""#).unwrap(); + assert_eq!(selector_from_case_insensitive_tip, BlockSelector::Tip); + + let selector_from_negative_one_string: BlockSelector = + serde_json::from_str(r#""-1""#).unwrap(); + assert_eq!(selector_from_negative_one_string, BlockSelector::Tip); + + let selector_from_numeric_string: BlockSelector = serde_json::from_str(r#""42""#).unwrap(); + assert_eq!( + selector_from_numeric_string, + BlockSelector::Height(Height(42)) + ); + + let selector_from_spaced_numeric_string: BlockSelector = + serde_json::from_str(r#"" 17 ""#).unwrap(); + assert_eq!( + selector_from_spaced_numeric_string, + BlockSelector::Height(Height(17)) + ); + } + + #[test] + fn deserialize_with_invalid_inputs_fails() { + // Numbers: invalid negative and too large + assert!(serde_json::from_str::("-2").is_err()); + assert!(serde_json::from_str::("9223372036854775807").is_err()); + + // Strings: invalid negative, too large, and malformed + assert!(serde_json::from_str::(r#""-2""#).is_err()); + + let value_exceeding_u32_maximum = (u32::MAX as u64 + 1).to_string(); + let json_string_exceeding_u32_maximum = format!(r#""{}""#, value_exceeding_u32_maximum); + assert!(serde_json::from_str::(&json_string_exceeding_u32_maximum).is_err()); + + assert!(serde_json::from_str::(r#""nope""#).is_err()); + assert!(serde_json::from_str::(r#""""#).is_err()); + } + + #[test] + fn serialize_values_match_expected_representations() { + let json_value_for_tip = serde_json::to_value(BlockSelector::Tip).unwrap(); + assert_eq!(json_value_for_tip, json!(-1)); + + let json_value_for_zero_height = + serde_json::to_value(BlockSelector::Height(Height(0))).unwrap(); + assert_eq!(json_value_for_zero_height, json!(0)); + + let json_value_for_specific_height = + serde_json::to_value(BlockSelector::Height(Height(42))).unwrap(); + assert_eq!(json_value_for_specific_height, json!(42)); + + let json_value_for_maximum_height = + serde_json::to_value(BlockSelector::Height(Height(u32::MAX))).unwrap(); + assert_eq!(json_value_for_maximum_height, json!(u32::MAX as u64)); + } + + #[test] + fn json_round_trip_preserves_value() { + let test_cases = [ + BlockSelector::Tip, + BlockSelector::Height(Height(0)), + BlockSelector::Height(Height(1)), + BlockSelector::Height(Height(42)), + BlockSelector::Height(Height(u32::MAX)), + ]; + + for test_case in test_cases { + let serialized_json_string = serde_json::to_string(&test_case).unwrap(); + let round_tripped_selector: BlockSelector = + serde_json::from_str(&serialized_json_string).unwrap(); + assert_eq!( + round_tripped_selector, test_case, + "Round trip failed for {test_case:?} via {serialized_json_string}" + ); + } + } + + #[test] + fn resolve_and_helper_methods_work_as_expected() { + let tip_height = Height(100); + + // Tip resolves to the current tip height + let selector_tip = BlockSelector::Tip; + assert_eq!(selector_tip.resolve(tip_height), tip_height); + assert_eq!(selector_tip.height(), None); + + // Absolute height resolves to itself + let selector_absolute_height = BlockSelector::Height(Height(90)); + assert_eq!(selector_absolute_height.resolve(tip_height), Height(90)); + assert_eq!(selector_absolute_height.height(), Some(Height(90))); + } +} diff --git a/packages/zaino-fetch/src/jsonrpsee/response.rs b/packages/zaino-fetch/src/jsonrpsee/response.rs index cef1a3d75..f46ece91a 100644 --- a/packages/zaino-fetch/src/jsonrpsee/response.rs +++ b/packages/zaino-fetch/src/jsonrpsee/response.rs @@ -504,12 +504,95 @@ impl Default for GetBlockHash { } } -impl From for zebra_rpc::methods::GetBlockHash { +impl From for zebra_rpc::methods::GetBlockHashResponse { fn from(value: GetBlockHash) -> Self { zebra_rpc::methods::GetBlockHashResponse::new(value.0) } } +/// Internal deserialization target for the `getblockhash` RPC request. +/// +/// Wraps [`GetBlockHash`] so that the `getblockhash` call site can bind +/// [`GetBlockHashError`] as its error type via [`ResponseToError`], without +/// affecting the `Infallible` error type used by `getbestblockhash`, which +/// shares [`GetBlockHash`] as its response shape. +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(transparent)] +pub(crate) struct GetBlockHashByIndex(pub(crate) GetBlockHash); + +impl ResponseToError for GetBlockHashByIndex { + type RpcError = GetBlockHashError; +} + +#[cfg(test)] +mod get_block_hash_by_index { + use super::*; + + #[test] + fn transparent_deserialize_matches_inner_get_block_hash() { + let hex_json = "\"0000000000000000000000000000000000000000000000000000000000000001\""; + let wrapped: GetBlockHashByIndex = serde_json::from_str(hex_json).unwrap(); + let direct: GetBlockHash = serde_json::from_str(hex_json).unwrap(); + assert_eq!(wrapped.0, direct); + } +} + +/// Error type for the `getblockhash` RPC request. +#[derive(Debug, thiserror::Error)] +pub enum GetBlockHashError { + /// The requested block index is outside the best chain's valid range. + /// + /// Maps from zcashd's `RPC_INVALID_PARAMETER` (code `-8`) with message + /// "Block number out of range." + #[error("{0}")] + HeightOutOfRange(String), +} + +impl TryFrom for GetBlockHashError { + type Error = RpcError; + + fn try_from(value: RpcError) -> Result { + if value.code == -8 { + Ok(Self::HeightOutOfRange(value.message)) + } else { + Err(value) + } + } +} + +#[cfg(test)] +mod get_block_hash_error { + use super::*; + + #[test] + fn code_neg8_maps_to_height_out_of_range() { + let rpc_err = RpcError { + code: -8, + message: "Block number out of range.".into(), + data: None, + }; + match GetBlockHashError::try_from(rpc_err) { + Ok(GetBlockHashError::HeightOutOfRange(msg)) => { + assert_eq!(msg, "Block number out of range."); + } + other => panic!("expected Ok(HeightOutOfRange), got {other:?}"), + } + } + + #[test] + fn non_neg8_code_passes_through_unchanged() { + let rpc_err = RpcError { + code: -32600, + message: "unrelated server error".into(), + data: None, + }; + let returned = GetBlockHashError::try_from(rpc_err) + .expect_err("non-(-8) code should round-trip as Err(original)"); + assert_eq!(returned.code, -32600); + assert_eq!(returned.message, "unrelated server error"); + } +} + /// A wrapper struct for a zebra serialized block. /// /// Stores bytes that are guaranteed to be deserializable into a [`zebra_chain::block::Block`]. diff --git a/packages/zaino-serve/src/rpc/jsonrpc/service.rs b/packages/zaino-serve/src/rpc/jsonrpc/service.rs index 545122b6d..814ffc4a8 100644 --- a/packages/zaino-serve/src/rpc/jsonrpc/service.rs +++ b/packages/zaino-serve/src/rpc/jsonrpc/service.rs @@ -1,5 +1,6 @@ //! Zcash RPC implementations. +use zaino_fetch::jsonrpsee::request::block_selector::BlockSelector; use zaino_fetch::jsonrpsee::response::block_deltas::BlockDeltas; use zaino_fetch::jsonrpsee::response::block_header::GetBlockHeader; use zaino_fetch::jsonrpsee::response::block_subsidy::GetBlockSubsidy; @@ -18,7 +19,7 @@ use zebra_rpc::client::{ }; use zebra_rpc::methods::{ AddressBalance, GetAddressBalanceRequest, GetAddressTxIdsRequest, GetAddressUtxos, GetBlock, - GetBlockHash, GetInfo, GetRawTransaction, SentTransactionHash, + GetBlockHashResponse, GetInfo, GetRawTransaction, SentTransactionHash, }; use jsonrpsee::types::ErrorObjectOwned; @@ -90,7 +91,18 @@ pub trait ZcashIndexerRpc { /// [The function in rpc/blockchain.cpp](https://github.com/zcash/zcash/blob/654a8be2274aa98144c80c1ac459400eaf0eacbe/src/rpc/blockchain.cpp#L325) /// where `return chainActive.Tip()->GetBlockHash().GetHex();` is the [return expression](https://github.com/zcash/zcash/blob/654a8be2274aa98144c80c1ac459400eaf0eacbe/src/rpc/blockchain.cpp#L339)returning a `std::string` #[method(name = "getbestblockhash")] - async fn get_best_blockhash(&self) -> Result; + async fn get_best_blockhash(&self) -> Result; + + /// Returns the hash of the block at the given index in the best valid block chain. + /// + /// zcashd reference: [`getblockhash`](https://zcash.github.io/rpc/getblockhash.html) + /// method: post + /// tags: blockchain + #[method(name = "getblockhash")] + async fn get_blockhash( + &self, + block_index: BlockSelector, + ) -> Result; /// Returns the proof-of-work difficulty as a multiple of the minimum difficulty. /// @@ -443,7 +455,7 @@ impl ZcashIndexerRpcServer for JsonR })?) } - async fn get_best_blockhash(&self) -> Result { + async fn get_best_blockhash(&self) -> Result { self.service_subscriber .inner_ref() .get_best_blockhash() @@ -457,6 +469,23 @@ impl ZcashIndexerRpcServer for JsonR }) } + async fn get_blockhash( + &self, + block_index: BlockSelector, + ) -> Result { + self.service_subscriber + .inner_ref() + .get_blockhash(block_index) + .await + .map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + "Internal server error", + Some(e.to_string()), + ) + }) + } + async fn get_blockchain_info(&self) -> Result { self.service_subscriber .inner_ref() diff --git a/packages/zaino-state/src/backends/fetch.rs b/packages/zaino-state/src/backends/fetch.rs index 86f942c16..927b9aa81 100644 --- a/packages/zaino-state/src/backends/fetch.rs +++ b/packages/zaino-state/src/backends/fetch.rs @@ -27,6 +27,7 @@ use zaino_fetch::{ chain::{transaction::FullTransaction, utils::ParseFromSlice}, jsonrpsee::{ connector::{JsonRpSeeConnector, RpcError}, + request::block_selector::BlockSelector, response::{ address_deltas::{GetAddressDeltasParams, GetAddressDeltasResponse}, block_deltas::BlockDeltas, @@ -496,6 +497,13 @@ impl ZcashIndexer for FetchServiceSubscriber { Ok(self.fetcher.get_best_blockhash().await?.into()) } + async fn get_blockhash( + &self, + block_index: BlockSelector, + ) -> Result { + Ok(self.fetcher.get_blockhash(block_index).await?.into()) + } + /// Returns the current block count in the best valid block chain. /// /// zcashd reference: [`getblockcount`](https://zcash.github.io/rpc/getblockcount.html) diff --git a/packages/zaino-state/src/backends/state.rs b/packages/zaino-state/src/backends/state.rs index 7e6e38721..1371d5099 100644 --- a/packages/zaino-state/src/backends/state.rs +++ b/packages/zaino-state/src/backends/state.rs @@ -27,6 +27,7 @@ use zaino_fetch::{ chain::{transaction::FullTransaction, utils::ParseFromSlice}, jsonrpsee::{ connector::{JsonRpSeeConnector, RpcError}, + request::block_selector::BlockSelector, response::{ address_deltas::{BlockInfo, GetAddressDeltasParams, GetAddressDeltasResponse}, block_deltas::{BlockDelta, BlockDeltas, InputDelta, OutputDelta}, @@ -74,7 +75,7 @@ use zebra_rpc::{ }, methods::{ chain_tip_difficulty, AddressBalance, ConsensusBranchIdHex, GetAddressTxIdsRequest, - GetAddressUtxos, GetBlock, GetBlockHash, GetBlockHeader as GetBlockHeaderZebra, + GetAddressUtxos, GetBlock, GetBlockHashResponse, GetBlockHeader as GetBlockHeaderZebra, GetBlockHeaderObject, GetBlockTransaction, GetBlockTrees, GetBlockchainInfoResponse, GetInfo, GetRawTransaction, NetworkUpgradeInfo, NetworkUpgradeStatus, SentTransactionHash, TipConsensusBranch, ValidateAddresses as _, @@ -1569,7 +1570,7 @@ impl ZcashIndexer for StateServiceSubscriber { /// [In the rpc definition](https://github.com/zcash/zcash/blob/654a8be2274aa98144c80c1ac459400eaf0eacbe/src/rpc/common.h#L48) there are no required params, or optional params. /// [The function in rpc/blockchain.cpp](https://github.com/zcash/zcash/blob/654a8be2274aa98144c80c1ac459400eaf0eacbe/src/rpc/blockchain.cpp#L325) /// where `return chainActive.Tip()->GetBlockHash().GetHex();` is the [return expression](https://github.com/zcash/zcash/blob/654a8be2274aa98144c80c1ac459400eaf0eacbe/src/rpc/blockchain.cpp#L339)returning a `std::string` - async fn get_best_blockhash(&self) -> Result { + async fn get_best_blockhash(&self) -> Result { // return should be valid hex encoded. // Hash from zebra says: // Return the hash bytes in big-endian byte-order suitable for printing out byte by byte. @@ -1577,7 +1578,7 @@ impl ZcashIndexer for StateServiceSubscriber { // Zebra displays transaction and block hashes in big-endian byte-order, // following the u256 convention set by Bitcoin and zcashd. match self.read_state_service.best_tip() { - Some(x) => return Ok(GetBlockHash::new(x.1)), + Some(x) => return Ok(GetBlockHashResponse::new(x.1)), None => { // try RPC if state read fails: Ok(self.rpc_client.get_best_blockhash().await?.into()) @@ -1585,6 +1586,27 @@ impl ZcashIndexer for StateServiceSubscriber { } } + async fn get_blockhash( + &self, + block_index: BlockSelector, + ) -> Result { + let (tip, _hash) = self.read_state_service.best_tip().unwrap(); + + let selected_block_height = block_index.resolve(tip); + + let block = self + .z_get_block(selected_block_height.0.to_string(), Some(1)) + .await + .unwrap(); + + let block_hash = match block { + GetBlock::Raw(_serialized_block) => todo!(), + GetBlock::Object(block_object) => block_object.hash(), + }; + + Ok(GetBlockHashResponse::new(block_hash)) + } + /// Returns the current block count in the best valid block chain. /// /// zcashd reference: [`getblockcount`](https://zcash.github.io/rpc/getblockcount.html) diff --git a/packages/zaino-state/src/indexer.rs b/packages/zaino-state/src/indexer.rs index 7354e68e3..c9ecea475 100644 --- a/packages/zaino-state/src/indexer.rs +++ b/packages/zaino-state/src/indexer.rs @@ -4,15 +4,18 @@ use async_trait::async_trait; use tokio::{sync::mpsc, time::timeout}; use tracing::warn; -use zaino_fetch::jsonrpsee::response::{ - address_deltas::{GetAddressDeltasParams, GetAddressDeltasResponse}, - block_deltas::BlockDeltas, - block_header::GetBlockHeader, - block_subsidy::GetBlockSubsidy, - mining_info::GetMiningInfoWire, - peer_info::GetPeerInfo, - z_validate_address::ZValidateAddressResponse, - GetMempoolInfoResponse, GetNetworkSolPsResponse, +use zaino_fetch::jsonrpsee::{ + request::block_selector::BlockSelector, + response::{ + address_deltas::{GetAddressDeltasParams, GetAddressDeltasResponse}, + block_deltas::BlockDeltas, + block_header::GetBlockHeader, + block_subsidy::GetBlockSubsidy, + mining_info::GetMiningInfoWire, + peer_info::GetPeerInfo, + z_validate_address::ZValidateAddressResponse, + GetMempoolInfoResponse, GetNetworkSolPsResponse, + }, }; use zaino_proto::proto::{ compact_formats::CompactBlock, @@ -30,7 +33,7 @@ use zebra_rpc::{ client::{GetSubtreesByIndexResponse, GetTreestateResponse, ValidateAddressResponse}, methods::{ AddressBalance, GetAddressBalanceRequest, GetAddressTxIdsRequest, GetAddressUtxos, - GetBlock, GetBlockHash, GetBlockchainInfoResponse, GetInfo, GetRawTransaction, + GetBlock, GetBlockHashResponse, GetBlockchainInfoResponse, GetInfo, GetRawTransaction, SentTransactionHash, }, }; @@ -384,7 +387,17 @@ pub trait ZcashIndexer: Send + Sync + 'static { /// [In the rpc definition](https://github.com/zcash/zcash/blob/654a8be2274aa98144c80c1ac459400eaf0eacbe/src/rpc/common.h#L48) there are no required params, or optional params. /// [The function in rpc/blockchain.cpp](https://github.com/zcash/zcash/blob/654a8be2274aa98144c80c1ac459400eaf0eacbe/src/rpc/blockchain.cpp#L325) /// where `return chainActive.Tip()->GetBlockHash().GetHex();` is the [return expression](https://github.com/zcash/zcash/blob/654a8be2274aa98144c80c1ac459400eaf0eacbe/src/rpc/blockchain.cpp#L339) returning a `std::string` - async fn get_best_blockhash(&self) -> Result; + async fn get_best_blockhash(&self) -> Result; + + /// Returns the hash of the block at the given index in the best valid block chain. + /// + /// zcashd reference: [`getblockhash`](https://zcash.github.io/rpc/getblockhash.html) + /// method: post + /// tags: blockchain + async fn get_blockhash( + &self, + block_index: BlockSelector, + ) -> Result; /// Returns all transaction ids in the memory pool, as a JSON array. /// diff --git a/tools/scripts/test-getblockhash.sh b/tools/scripts/test-getblockhash.sh new file mode 100755 index 000000000..2808e49a4 --- /dev/null +++ b/tools/scripts/test-getblockhash.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Run the integration tests that exercise the `getblockhash` feature, +# plus one wallet_to_validator control test. +# +# - fetch_service::{zcashd,zebrad}::get::blockhash green +# - state_service::zebra::get::blockhash_regtest green (happy path) +# - state_service::zebra::get::blockhash_out_of_range_returns_err red until the +# StateService `.unwrap()`/`todo!()` bug (item 1) is fixed +# - wallet_to_validator::zcashd::connect_to_node_get_info control + +set -euo pipefail + +# integration-tests is a separate cargo workspace from the root, so nextest +# needs --manifest-path to see the fetch_service/state_service/wallet_to_validator +# test binaries (matching the convention in Makefile.toml's $ITESTS). +exec makers container-test \ + --manifest-path integration-tests/Cargo.toml \ + -E '(binary(fetch_service) & test(=zcashd::get::blockhash)) | (binary(fetch_service) & test(=zebrad::get::blockhash)) | (binary(state_service) & test(=zebra::get::blockhash_regtest)) | (binary(state_service) & test(=zebra::get::blockhash_out_of_range_returns_err)) | (binary(wallet_to_validator) & test(=zcashd::connect_to_node_get_info))' \ + "$@"