Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion packages/zaino-fetch/src/jsonrpsee/connector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,14 @@ pub enum RpcRequestError<MethodError> {
/// wasn't accounted for as a MethodError. This means that either
/// Zaino has not yet accounted for the possibilty of this error,
/// or the Node returned an undocumented/malformed error response.
///
/// The payload is `#[source]` so the node's typed rejection (usually an
/// [`RpcError`]) stays reachable through `source()` chains: the serving
/// surfaces recover rejection codes and messages by downcast-walking those
/// chains, and severing the link here masks every unmapped node rejection
/// as a generic internal error (zingolabs/zaino#1404).
#[error("unexpected error response from server: {0}")]
UnexpectedErrorResponse(Box<dyn std::error::Error + Send + Sync + 'static>),
UnexpectedErrorResponse(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
}

/// JsonRpSee Client config data.
Expand Down Expand Up @@ -1070,6 +1076,37 @@ pub async fn test_node_and_return_url(
mod tests {
use super::*;

/// zaino-serve attributes node rejections by downcast-walking `source()`
/// chains for the typed [`RpcError`] (see
/// `sendrawtransaction_error_object_from_indexer_error` in
/// `zaino-serve/src/rpc/jsonrpc/service.rs`). `UnexpectedErrorResponse` is
/// the variant that carries a node's structured rejection whenever no
/// method-specific mapping exists (every `impl_rpc_error_passthrough!`
/// type, including `sendrawtransaction`'s), so its boxed payload must stay
/// reachable through `source()`. Without the link, every such rejection is
/// masked as a generic internal error (zingolabs/zaino#1404).
#[test]
fn unexpected_error_response_exposes_rpc_error_via_source() {
use crate::jsonrpsee::response::SendTransactionError;

let rejection = RpcError::new_from_legacycode(
zebra_rpc::server::error::LegacyCode::Verify,
"failed to validate tx: transparent input not found",
);
let error: RpcRequestError<SendTransactionError> =
RpcRequestError::UnexpectedErrorResponse(Box::new(rejection));

let source = std::error::Error::source(&error)
.expect("the boxed node rejection must be exposed via source()");
let rpc_error = source
.downcast_ref::<RpcError>()
.expect("the source must downcast to the typed RpcError");
assert_eq!(
rpc_error.code,
zebra_rpc::server::error::LegacyCode::Verify as i64
);
}

#[test]
fn test_resolve_address_wraps_common_function() {
// Verify the wrapper correctly converts io::Error to TransportError
Expand Down
17 changes: 15 additions & 2 deletions packages/zaino-state/src/chain_index/source/mockchain_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -807,8 +807,21 @@ impl BlockchainSource for MockchainSource {
&self,
_raw_transaction_hex: String,
) -> BlockchainSourceResult<zebra_rpc::methods::SentTransactionHash> {
// The mock chain has no mempool to accept submissions.
unimplemented!("MockchainSource cannot serve send_raw_transaction")
// The mock chain has no mempool to accept submissions, so every one is
// rejected — shaped exactly like a validator rejection (a typed
// `RpcError` inside `RpcRequestError::UnexpectedErrorResponse`) so tests
// can exercise the full rejection-attribution path a real zebrad
// rejection travels (zingolabs/zaino#1404).
Err(BlockchainSourceError::unrecoverable(
zaino_fetch::jsonrpsee::connector::RpcRequestError::<
zaino_fetch::jsonrpsee::response::SendTransactionError,
>::UnexpectedErrorResponse(Box::new(
zaino_fetch::jsonrpsee::connector::RpcError::new_from_legacycode(
zebra_rpc::server::error::LegacyCode::Verify,
"MockchainSource rejects all transaction submissions (static chain, no mempool)",
),
)),
))
}

async fn get_treestate_by_id(
Expand Down
62 changes: 49 additions & 13 deletions packages/zaino-state/src/chain_index/tests/mockchain_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,18 @@ async fn chaintip_update_subscriber_absent_without_tip_stream() {
);
}

/// The code of the first typed [`RpcError`](zaino_fetch::jsonrpsee::connector::RpcError)
/// in `error`'s `source()` chain, if any — the same walk zaino-serve's
/// `sendrawtransaction_error_object_from_indexer_error` performs to recover
/// zcashd legacy error codes.
fn rpc_error_code_in_source_chain(error: &(dyn std::error::Error + 'static)) -> Option<i64> {
std::iter::successors(Some(error), |source| source.source()).find_map(|source| {
source
.downcast_ref::<zaino_fetch::jsonrpsee::connector::RpcError>()
.map(|rpc_error| rpc_error.code)
})
}

/// `sendrawtransaction` rejections must carry zcashd's legacy error code:
/// zaino-serve forwards the code by downcast-walking the `source()` chain for
/// the typed `RpcError` (`sendrawtransaction_error_object_from_indexer_error`),
Expand All @@ -1273,7 +1285,8 @@ async fn chaintip_update_subscriber_absent_without_tip_stream() {
///
/// Invalid hex fails in local validation before the source is consulted, so
/// this also pins that the rejection happens without a validator round-trip
/// (the mock's `send_raw_transaction` would panic if reached).
/// (the mock's `send_raw_transaction` returns its own distinct rejection, code
/// `-25`, so reaching it would fail the `-8` assertion below).
#[tokio::test(flavor = "multi_thread")]
async fn send_raw_transaction_invalid_hex_keeps_legacy_error_code() {
let (_blocks, _indexer, index_reader, _mockchain) =
Expand All @@ -1284,20 +1297,43 @@ async fn send_raw_transaction_invalid_hex_keeps_legacy_error_code() {
.await
.expect_err("invalid hex must be rejected");

let mut current: Option<&(dyn std::error::Error + 'static)> = Some(&error);
let mut rpc_error_code = None;
while let Some(source_error) = current {
if let Some(rpc_error) =
source_error.downcast_ref::<zaino_fetch::jsonrpsee::connector::RpcError>()
{
rpc_error_code = Some(rpc_error.code);
break;
}
current = source_error.source();
}
assert_eq!(
rpc_error_code,
rpc_error_code_in_source_chain(&error),
Some(zebra_rpc::server::error::LegacyCode::InvalidParameter as i64),
"the typed RpcError (legacy code -8) must stay reachable via the source() chain"
);
}

/// A node rejection of `sendrawtransaction` must stay attributable end to end
/// (zingolabs/zaino#1404): the typed `RpcError` reachable via `source()` for
/// zaino-serve's JSON-RPC error-code recovery walk, and the rejection text
/// present in the wallet-visible `tonic::Status` on the gRPC surface. zainod
/// 0.6.0 masked both as the fixed string "InternalServerError: error receiving
/// data from backing node". The mockchain source stands in for zebrad by
/// rejecting every submission with a validator-shaped `-25` error.
#[tokio::test(flavor = "multi_thread")]
async fn send_raw_transaction_node_rejection_stays_attributable() {
let (_blocks, _indexer, index_reader, _mockchain) =
load_test_vectors_and_sync_chain_index(MockchainMode::Static).await;

// Valid hex, so local validation passes and the (rejecting) source is consulted.
let error = index_reader
.send_raw_transaction("deadbeef".to_string())
.await
.expect_err("the mockchain source rejects every submission");

assert_eq!(
rpc_error_code_in_source_chain(&error),
Some(zebra_rpc::server::error::LegacyCode::Verify as i64),
"the node's typed RpcError (legacy code -25) must stay reachable via the source() chain"
);

let status = tonic::Status::from(crate::error::NodeBackedIndexerServiceError::from(error));
assert!(
status
.message()
.contains("rejects all transaction submissions"),
"wallet-visible status must carry the node's rejection reason, got: {:?}",
status.message()
);
}
64 changes: 63 additions & 1 deletion packages/zaino-state/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ impl From<GetBlockRangeError> for NodeBackedIndexerServiceError {
}
}

/// The first typed JSON-RPC error in `error`'s `source()` chain, if any — the
/// node's structured rejection that a generic wrapper message otherwise hides.
/// The gRPC counterpart of zaino-serve's JSON-RPC error-code recovery walk
/// (`sendrawtransaction_error_object_from_indexer_error`).
fn rpc_error_in_source_chain<'error>(
error: &'error (dyn std::error::Error + 'static),
) -> Option<&'error zaino_fetch::jsonrpsee::connector::RpcError> {
std::iter::successors(Some(error), |source| source.source())
.find_map(|source| source.downcast_ref())
}

#[allow(deprecated)]
impl From<NodeBackedIndexerServiceError> for tonic::Status {
fn from(error: NodeBackedIndexerServiceError) -> Self {
Expand All @@ -130,7 +141,16 @@ impl From<NodeBackedIndexerServiceError> for tonic::Status {
tonic::Status::internal(format!("RPC error: {err:?}"))
}
NodeBackedIndexerServiceError::ChainIndexError(err) => match err.kind {
ChainIndexErrorKind::InternalServerError => tonic::Status::internal(err.message),
// A `ChainIndexError`'s own message may be a generic wrapper (e.g.
// `backing_validator`'s fixed string), so recover the node's typed
// rejection from the source chain when one is present: wallets
// must be able to tell "the node rejected this, and here is why"
// apart from "the indexer's connection to the node broke"
// (zingolabs/zaino#1404).
ChainIndexErrorKind::InternalServerError => match rpc_error_in_source_chain(&err) {
Some(rpc_error) => tonic::Status::internal(rpc_error.to_string()),
None => tonic::Status::internal(err.message),
},
ChainIndexErrorKind::InvalidSnapshot => {
tonic::Status::failed_precondition(err.message)
}
Expand Down Expand Up @@ -642,3 +662,45 @@ impl From<MempoolError> for ChainIndexError {
}
}
}

#[cfg(test)]
mod tonic_status_from_service_error {
use super::*;
use crate::chain_index::source::BlockchainSourceError;
use zaino_fetch::jsonrpsee::connector::{RpcError, RpcRequestError};
use zaino_fetch::jsonrpsee::response::SendTransactionError;
use zebra_rpc::server::error::LegacyCode;

/// A zebrad `sendrawtransaction` rejection reaches the gRPC surface as
/// `RpcError` → `RpcRequestError::UnexpectedErrorResponse` →
/// `BlockchainSourceError::unrecoverable` → `ChainIndexError::backing_validator`
/// → [`NodeBackedIndexerServiceError`] → [`tonic::Status`]. The wallet-visible
/// status must still name the node's rejection reason and its legacy error
/// code; zainod 0.6.0 masked both as the fixed string "InternalServerError:
/// error receiving data from backing node" (zingolabs/zaino#1404).
#[test]
fn send_rejection_survives_conversion_to_tonic_status() {
let rejection = RpcError::new_from_legacycode(
LegacyCode::Verify,
"failed to validate tx: transparent input not found",
);
let request_error: RpcRequestError<SendTransactionError> =
RpcRequestError::UnexpectedErrorResponse(Box::new(rejection));
let source_error = BlockchainSourceError::unrecoverable(request_error);
let index_error = ChainIndexError::backing_validator(source_error);

let status = tonic::Status::from(NodeBackedIndexerServiceError::from(index_error));

assert_eq!(status.code(), tonic::Code::Internal);
assert!(
status.message().contains("failed to validate tx"),
"wallet-visible status must carry the node's rejection reason, got: {:?}",
status.message()
);
assert!(
status.message().contains("-25"),
"wallet-visible status must carry the legacy error code, got: {:?}",
status.message()
);
}
}
Loading