Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion crates/superbank-rpc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ writer that matches the same schemas).
- `getBlock`
- `getBlockHeight`
- `getSlot`
- `getEpochInfo`
- `getTransactionCount`
- `getLatestBlockhash`
- `getBlockTime`
Expand All @@ -31,7 +32,7 @@ Notes:
- `processed` commitment is supported for a subset of methods when compiled with
`--features grpc-head-cache` and enabled at runtime with `HEAD_CACHE_ENABLED=true`
(see "Optional gRPC head cache" below).
- `getBlockHeight`, `getSlot`, and `getTransactionCount` accept an optional single config object as the sole param:
- `getBlockHeight`, `getSlot`, `getEpochInfo`, and `getTransactionCount` accept an optional single config object as the sole param:
- `commitment`: `processed|confirmed|finalized` (defaults to `finalized`; `processed` requires the head cache)
- `minContextSlot`: optional `u64`; if the server's current context slot is below this value, the
call fails with JSON-RPC error `-32016` ("Minimum context slot has not been reached") and
Expand Down
162 changes: 159 additions & 3 deletions crates/superbank-rpc/src/handlers/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ use crate::clickhouse::{QueryTimings, StoredBlockPayload, StoredBlockRecord};
use crate::handlers::{
RouteMetric,
types::{
GetBlockHeightConfig, GetBlocksConfig, GetInflationRewardConfig, GetLatestBlockhashConfig,
GetLatestBlockhashResult, GetLatestBlockhashValue, GetSlotConfig, InflationRewardInfo,
MAX_GET_BLOCKS_RANGE, RpcContextSlot, reject_unknown_fields,
EpochInfo, GetBlockHeightConfig, GetBlocksConfig, GetInflationRewardConfig,
GetLatestBlockhashConfig, GetLatestBlockhashResult, GetLatestBlockhashValue, GetSlotConfig,
InflationRewardInfo, MAX_GET_BLOCKS_RANGE, RpcContextSlot, reject_unknown_fields,
},
};
use crate::hydration::{BlockHydrationError, hydrate_block_payload};
Expand Down Expand Up @@ -626,6 +626,162 @@ pub(crate) async fn handle_get_slot(
Ok(json_rpc_success_response(id, json!(slot)))
}

pub(crate) async fn handle_get_epoch_info(
state: Arc<AppState>,
id: Value,
params: Option<Vec<Value>>,
) -> Result<Response, StatusCode> {
let mut route = RouteMetric::for_state("getEpochInfo", state.as_ref());

// getEpochInfo accepts the same optional config as getSlot: an object with
// an optional commitment and minContextSlot.
let config = match params.filter(|v| !v.is_empty()) {
None => GetSlotConfig::default(),
Some(mut params) => {
if params.len() != 1 {
route.invalid_params();
return Ok(json_rpc_error_response(
id,
-32602,
"Invalid params: expected a single config object",
None,
));
}

let value = params.remove(0);
if value.is_null() {
GetSlotConfig::default()
} else if value.is_object() {
match serde_json::from_value::<GetSlotConfig>(value) {
Ok(config) => config,
Err(e) => {
route.invalid_params();
return Ok(json_rpc_error_response(
id,
-32602,
format!("Invalid params: failed to parse config ({e})"),
None,
));
}
}
} else {
route.invalid_params();
return Ok(json_rpc_error_response(
id,
-32602,
"Invalid params: config must be an object",
None,
));
}
}
};

let commitment = config.commitment.unwrap_or_default();

if commitment.is_processed() {
#[cfg(feature = "grpc-head-cache")]
{
if state.head_cache.is_none() {
route.invalid_params();
return Ok(json_rpc_error_response(
id,
-32602,
"Only confirmed or finalized commitments are supported",
Some(json!({ "requestedCommitment": commitment.commitment })),
));
}
}
#[cfg(not(feature = "grpc-head-cache"))]
{
route.invalid_params();
return Ok(json_rpc_error_response(
id,
-32602,
"Only confirmed or finalized commitments are supported",
Some(json!({ "requestedCommitment": commitment.commitment })),
));
}
}

// Resolve a single ClickHouse-resident context slot and derive every field
// from it, so absoluteSlot, blockHeight and transactionCount form a
// consistent snapshot. Epoch math assumes the default schedule with no
// warmup, matching the rest of the codebase (see getInflationReward).
let slot = match state
.latest_slot_cache
Comment thread
jenish-25 marked this conversation as resolved.
.get_or_refresh(&state.clickhouse)
.await
{
Ok(slot) => slot,
Err(e) => {
metrics::backend_error("get_latest_finalized_slot");
error!("Failed to fetch latest slot for getEpochInfo: {}", e);
return Ok(json_rpc_internal_error_response(id));
}
};
route.source_clickhouse();

if let Some(min_context_slot) = config.min_context_slot
&& slot < min_context_slot
{
route.rpc_error();
return Ok(json_rpc_error_response(
id,
JSON_RPC_SERVER_ERROR_MIN_CONTEXT_SLOT_NOT_REACHED as i32,
"Minimum context slot has not been reached",
Some(json!({ "contextSlot": slot })),
));
}

let block_height = match state
.latest_block_height_cache
.get_or_refresh(slot, &state.clickhouse)
.await
{
Ok(Some(height)) => height,
Ok(None) => {
route.source_none();
error!(slot, "Block height unavailable for getEpochInfo");
return Ok(json_rpc_internal_error_response(id));
}
Err(e) => {
metrics::backend_error("get_block_height_by_slot");
error!(
"Failed to query ClickHouse block height at slot {} for getEpochInfo: {}",
slot, e
);
return Ok(json_rpc_internal_error_response(id));
}
};

let (transaction_count, timings) =
match state.clickhouse.get_transaction_count_by_slot(slot).await {
Ok(result) => result,
Err(e) => {
metrics::backend_error("get_transaction_count_by_slot");
error!(
"Failed to query ClickHouse transaction count at slot {} for getEpochInfo: {}",
slot, e
);
return Ok(json_rpc_internal_error_response(id));
}
};

let epoch_info = EpochInfo {
absolute_slot: slot,
block_height,
epoch: slot / DEFAULT_SLOTS_PER_EPOCH,
slot_index: slot % DEFAULT_SLOTS_PER_EPOCH,
slots_in_epoch: DEFAULT_SLOTS_PER_EPOCH,
transaction_count: Some(transaction_count),
};

route.success();
let mut resp = json_rpc_success_response(id, json!(epoch_info));
add_downstream_header(&mut resp, &timings);
Ok(resp)
}

enum TransactionCountPlan {
ClickHouseThroughSlot {
context_slot: u64,
Expand Down
2 changes: 2 additions & 0 deletions crates/superbank-rpc/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ fn metrics_method_label(method: &str) -> &'static str {
"getBlock" => "getBlock",
"getBlockHeight" => "getBlockHeight",
"getSlot" => "getSlot",
"getEpochInfo" => "getEpochInfo",
"getTransactionCount" => "getTransactionCount",
"getLatestBlockhash" => "getLatestBlockhash",
"getBlockTime" => "getBlockTime",
Expand Down Expand Up @@ -861,6 +862,7 @@ async fn dispatch_json_rpc_request(
blocks::handle_get_block_height(state, id_for_dispatch, params).await
}
"getSlot" => blocks::handle_get_slot(state, id_for_dispatch, params).await,
"getEpochInfo" => blocks::handle_get_epoch_info(state, id_for_dispatch, params).await,
"getTransactionCount" => {
blocks::handle_get_transaction_count(state, id_for_dispatch, params).await
}
Expand Down
11 changes: 11 additions & 0 deletions crates/superbank-rpc/src/handlers/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,17 @@ pub(crate) struct InflationRewardInfo {
pub(crate) commission: Option<u8>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EpochInfo {
pub(crate) absolute_slot: u64,
pub(crate) block_height: u64,
pub(crate) epoch: u64,
pub(crate) slot_index: u64,
pub(crate) slots_in_epoch: u64,
pub(crate) transaction_count: Option<u64>,
}

#[derive(Debug, Serialize)]
pub(crate) struct GetLatestBlockhashResult {
pub(crate) context: RpcContextSlot,
Expand Down
111 changes: 110 additions & 1 deletion crates/superbank-rpc/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ use crate::handlers::signatures::{
handle_get_signature_statuses, handle_get_signatures_for_address,
};
use crate::handlers::transactions::handle_get_transactions_for_address;
use crate::handlers::types::MAX_GET_BLOCKS_RANGE;
use crate::handlers::types::{EpochInfo, MAX_GET_BLOCKS_RANGE};
use crate::hydration::BlockHydrationError;
use crate::hydration::build_transaction_status_meta;
use crate::hydration::{
Expand Down Expand Up @@ -3515,6 +3515,115 @@ async fn emit_http_errors_keeps_success_http_200() {
assert!(parsed.error.is_none());
}

#[tokio::test]
async fn get_epoch_info_rejects_non_object_config() {
let state = test_state();
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getEpochInfo",
"params": ["finalized"]
});

let response = handle_json_rpc_value(state, &request).await;
assert_eq!(response.status(), StatusCode::OK);
let parsed = parse_json_rpc_response(response).await;
// -32602 rather than -32601 also confirms the method is wired to the handler.
assert_eq!(parsed.error.expect("invalid params").code, -32602);
assert!(parsed.result.is_none());
}

#[tokio::test]
async fn get_epoch_info_rejects_unknown_config_field() {
let state = test_state();
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getEpochInfo",
"params": [{ "notARealField": true }]
});

let response = handle_json_rpc_value(state, &request).await;
assert_eq!(response.status(), StatusCode::OK);
let parsed = parse_json_rpc_response(response).await;
assert_eq!(parsed.error.expect("invalid params").code, -32602);
}

#[tokio::test]
async fn get_epoch_info_rejects_more_than_one_param() {
let state = test_state();
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getEpochInfo",
"params": [{}, {}]
});

let response = handle_json_rpc_value(state, &request).await;
assert_eq!(response.status(), StatusCode::OK);
let parsed = parse_json_rpc_response(response).await;
assert_eq!(parsed.error.expect("invalid params").code, -32602);
}

#[tokio::test]
async fn get_epoch_info_rejects_processed_commitment() {
// test_state has no head cache, so processed commitment is unsupported.
let state = test_state();
let request = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "getEpochInfo",
"params": [{ "commitment": "processed" }]
});

let response = handle_json_rpc_value(state, &request).await;
assert_eq!(response.status(), StatusCode::OK);
let parsed = parse_json_rpc_response(response).await;
let err = parsed.error.expect("commitment rejected");
assert_eq!(err.code, -32602);
assert_eq!(
err.message,
"Only confirmed or finalized commitments are supported"
);
}

#[test]
fn epoch_info_serializes_with_solana_field_names() {
let value = serde_json::to_value(EpochInfo {
absolute_slot: 500,
block_height: 480,
epoch: 1,
slot_index: 68,
slots_in_epoch: 432_000,
transaction_count: Some(1_234),
})
.expect("serialize");

assert_eq!(
value,
json!({
"absoluteSlot": 500,
"blockHeight": 480,
"epoch": 1,
"slotIndex": 68,
"slotsInEpoch": 432_000,
"transactionCount": 1_234
})
);

// transactionCount is nullable, matching the Solana spec.
let null_count = serde_json::to_value(EpochInfo {
absolute_slot: 1,
block_height: 1,
epoch: 0,
slot_index: 1,
slots_in_epoch: 432_000,
transaction_count: None,
})
.expect("serialize");
assert_eq!(null_count.get("transactionCount"), Some(&Value::Null));
}

#[tokio::test]
async fn emit_http_errors_promotes_mixed_batch_with_server_error() {
let state =
Expand Down