v0.2.4-pre.004

This commit is contained in:
2026-08-18 20:54:16 +02:00
parent e500024c3a
commit 561b6678ed
12 changed files with 764 additions and 13 deletions

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[430000100,430000103,430000109],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[430000200,430000201,430000205],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"slot":430000123,"numTransactions":250000,"numNonVoteTransactions":175000,"numSlots":120,"samplePeriodSecs":60},{"slot":430000000,"numTransactions":200000,"numSlots":110,"samplePeriodSecs":60}],"id":1}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_blocks.rs
// version: 3
// version: 4
/// Transaction detail level accepted by modern `getBlock` requests.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
@@ -570,7 +570,6 @@ impl SolanaPerformanceSample {
}
/// Decodes one performance sample from its Solana JSON wire shape.
#[cfg(test)]
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WirePerformanceSample>(method, value);
return match decoded {
@@ -622,6 +621,93 @@ impl crate::HttpTransportPool {
return self.get_blocks_u64("minimumLedgerSlot", role, std::vec::Vec::new()).await;
}
/// Executes typed `getBlocks` while preserving all four supported parameter overloads.
pub async fn get_blocks(
&self,
role: &crate::HttpRoleName,
start_slot: u64,
end_slot: std::option::Option<u64>,
config: std::option::Option<&crate::SolanaContextConfig>,
) -> ksp_core_lib::Result<std::vec::Vec<u64>> {
let validated = validate_blocks_context_commitment("getBlocks", config);
if let std::result::Result::Err(error) = validated {
return std::result::Result::Err(error);
}
if let std::option::Option::Some(end_slot) = end_slot
&& end_slot >= start_slot
&& end_slot - start_slot > MAX_GET_BLOCKS_RANGE
{
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, "getBlocks range must not exceed 500000 slots")
.with_context("rpc_method", "getBlocks")
.with_context("start_slot", start_slot.to_string())
.with_context("end_slot", end_slot.to_string()),
);
}
let mut params = std::vec![serde_json::json!(start_slot)];
if let std::option::Option::Some(end_slot) = end_slot {
params.push(serde_json::json!(end_slot));
}
push_blocks_explicit_context_config(&mut params, config);
return self.get_blocks_u64_list("getBlocks", role, params).await;
}
/// Executes typed `getBlocksWithLimit` with the runtime-supported inclusive upper bound.
pub async fn get_blocks_with_limit(
&self,
role: &crate::HttpRoleName,
start_slot: u64,
limit: u64,
config: std::option::Option<&crate::SolanaContextConfig>,
) -> ksp_core_lib::Result<std::vec::Vec<u64>> {
let validated = validate_blocks_context_commitment("getBlocksWithLimit", config);
if let std::result::Result::Err(error) = validated {
return std::result::Result::Err(error);
}
if limit > MAX_GET_BLOCKS_RANGE {
return invalid_blocks_limit("getBlocksWithLimit", "getBlocksWithLimit limit must not exceed 500000", limit);
}
let mut params = std::vec![serde_json::json!(start_slot), serde_json::json!(limit)];
push_blocks_explicit_context_config(&mut params, config);
return self.get_blocks_u64_list("getBlocksWithLimit", role, params).await;
}
/// Executes typed `getRecentPerformanceSamples`, preserving runtime order and older sample shapes.
pub async fn get_recent_performance_samples(
&self,
role: &crate::HttpRoleName,
limit: std::option::Option<u64>,
) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaPerformanceSample>> {
if let std::option::Option::Some(limit) = limit
&& limit > MAX_GET_RECENT_PERFORMANCE_SAMPLES
{
return invalid_blocks_limit("getRecentPerformanceSamples", "getRecentPerformanceSamples limit must not exceed 720", limit);
}
let mut params = std::vec::Vec::new();
if let std::option::Option::Some(limit) = limit {
params.push(serde_json::json!(limit));
}
let value = self.execute_blocks_rpc("getRecentPerformanceSamples", role, params).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_performance_samples("getRecentPerformanceSamples", value);
}
async fn get_blocks_u64_list(
&self,
method_name: &'static str,
role: &crate::HttpRoleName,
params: std::vec::Vec<serde_json::Value>,
) -> ksp_core_lib::Result<std::vec::Vec<u64>> {
let value = self.execute_blocks_rpc(method_name, role, params).await;
return match value {
std::result::Result::Ok(value) => crate::decode_wire_json::<std::vec::Vec<u64>>(method_name, value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
async fn get_blocks_u64(
&self,
method_name: &'static str,
@@ -650,6 +736,57 @@ impl crate::HttpTransportPool {
}
}
const MAX_GET_BLOCKS_RANGE: u64 = 500_000;
const MAX_GET_RECENT_PERFORMANCE_SAMPLES: u64 = 720;
fn validate_blocks_context_commitment(method: &'static str, config: std::option::Option<&crate::SolanaContextConfig>) -> ksp_core_lib::Result<()> {
if let std::option::Option::Some(config) = config
&& config.commitment() == std::option::Option::Some(crate::SolanaCommitment::Processed)
{
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
"block-range commitment must be confirmed or finalized when explicitly provided",
)
.with_context("rpc_method", method)
.with_context("commitment", "processed"),
);
}
return std::result::Result::Ok(());
}
fn push_blocks_explicit_context_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaContextConfig>) {
if let std::option::Option::Some(config) = config {
params.push((*config).to_json_value());
}
return;
}
fn invalid_blocks_limit<T>(method: &'static str, message: &'static str, limit: u64) -> ksp_core_lib::Result<T> {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RPC_PARAMETERS, message)
.with_context("rpc_method", method)
.with_context("limit", limit.to_string()),
);
}
fn decode_performance_samples(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaPerformanceSample>> {
let values = crate::decode_wire_json::<std::vec::Vec<serde_json::Value>>(method, value);
let values = match values {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut samples = std::vec::Vec::with_capacity(values.len());
for value in values {
let sample = crate::SolanaPerformanceSample::decode_wire(method, value);
match sample {
std::result::Result::Ok(sample) => samples.push(sample),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(samples);
}
fn push_blocks_context_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaContextConfig>) {
if let std::option::Option::Some(config) = config
&& (config.commitment().is_some() || config.min_context_slot().is_some())
@@ -802,7 +939,6 @@ struct WireConfirmedBlock {
block_height: std::option::Option<u64>,
}
#[cfg(test)]
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WirePerformanceSample {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 17
// version: 18
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -395,3 +395,10 @@ fn public_v0_2_4_pre_003_simple_block_wrappers_are_available_from_crate_root() {
let _get_first_available_block = ksp_onchain_transport_lib::HttpTransportPool::get_first_available_block;
let _minimum_ledger_slot = ksp_onchain_transport_lib::HttpTransportPool::minimum_ledger_slot;
}
#[test]
fn public_v0_2_4_pre_004_range_and_performance_wrappers_are_available_from_crate_root() {
let _get_blocks = ksp_onchain_transport_lib::HttpTransportPool::get_blocks;
let _get_blocks_with_limit = ksp_onchain_transport_lib::HttpTransportPool::get_blocks_with_limit;
let _get_recent_performance_samples = ksp_onchain_transport_lib::HttpTransportPool::get_recent_performance_samples;
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 15
// version: 16
//! Release-level completeness canaries for the staged HTTP wrapper sequence.
@@ -496,3 +496,28 @@ fn release_v0_2_4_pre_003_simple_blocks_subset_is_exact_and_retry_safe() {
assert_eq!(actual, expected);
assert_eq!(actual.len(), 5);
}
#[test]
fn release_v0_2_4_pre_004_range_performance_subset_is_exact_and_retry_safe() {
let mut expected = std::vec!["getBlocks", "getBlocksWithLimit", "getRecentPerformanceSamples"];
let mut actual = std::vec::Vec::new();
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
if descriptor.coverage_release() != ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_4
|| descriptor.category() != ksp_onchain_transport_lib::HttpRpcCategory::Blocks
{
continue;
}
match descriptor.method() {
"getBlocks" | "getBlocksWithLimit" | "getRecentPerformanceSamples" => {
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
actual.push(descriptor.method());
},
_ => {},
}
}
actual.sort_unstable();
expected.sort_unstable();
assert_eq!(actual, expected);
assert_eq!(actual.len(), 3);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_blocks.rs
// version: 2
// version: 3
#[test]
fn transaction_details_and_get_block_config_preserve_all_modern_options() {
@@ -311,3 +311,145 @@ async fn typed_minimum_ledger_slot_has_no_params() {
assert_eq!(body["method"], serde_json::json!("minimumLedgerSlot"));
assert_eq!(body["params"], serde_json::json!([]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_blocks_covers_all_four_overloads_and_preserves_server_order() {
let role = crate::HttpRoleName::new("default");
let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(429_999_999));
let empty_config = crate::SolanaContextConfig::default();
let cases = [
(std::option::Option::None, std::option::Option::None, serde_json::json!([430000100])),
(std::option::Option::Some(430_000_109), std::option::Option::None, serde_json::json!([430000100, 430000109])),
(
std::option::Option::None,
std::option::Option::Some(&config),
serde_json::json!([430000100,{"commitment":"confirmed","minContextSlot":429999999}]),
),
(
std::option::Option::Some(430_000_109),
std::option::Option::Some(&config),
serde_json::json!([430000100,430000109,{"commitment":"confirmed","minContextSlot":429999999}]),
),
(std::option::Option::None, std::option::Option::Some(&empty_config), serde_json::json!([430000100, {}])),
];
for (end_slot, config, expected_params) in cases {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_blocks.success.json"));
let pool = pool_for_url(url.as_str());
let blocks = pool.get_blocks(&role, 430_000_100, end_slot, config).await.expect("getBlocks overload fixture must succeed");
assert_eq!(blocks, std::vec![430_000_100, 430_000_103, 430_000_109]);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getBlocks"));
assert_eq!(body["params"], expected_params);
}
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_blocks_allows_reversed_and_boundary_ranges_but_rejects_oversized_before_io() {
let role = crate::HttpRoleName::new("default");
let (url, handle) = serve_once(include_str!("../fixtures/http/get_blocks.empty.json"));
let pool = pool_for_url(url.as_str());
let blocks = pool
.get_blocks(&role, 430_000_100, std::option::Option::Some(430_000_099), std::option::Option::None)
.await
.expect("reversed getBlocks range must remain a valid RPC request");
assert!(blocks.is_empty());
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([430000100, 430000099]));
let (url, handle) = serve_once(include_str!("../fixtures/http/get_blocks.success.json"));
let pool = pool_for_url(url.as_str());
let blocks = pool
.get_blocks(&role, 10, std::option::Option::Some(500_010), std::option::Option::None)
.await
.expect("500000-slot getBlocks range must remain valid");
assert_eq!(blocks, std::vec![430_000_100, 430_000_103, 430_000_109]);
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([10, 500010]));
let pool = pool_for_url("http://127.0.0.1:9");
let result = pool.get_blocks(&role, 10, std::option::Option::Some(500_011), std::option::Option::None).await;
let error = result.expect_err("range above 500000 must reject before I/O");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].key(), "start_slot");
assert_eq!(error.context()[1].value(), "10");
assert_eq!(error.context()[2].key(), "end_slot");
assert_eq!(error.context()[2].value(), "500011");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_block_range_wrappers_reject_processed_commitment_before_io() {
let role = crate::HttpRoleName::new("default");
let pool = pool_for_url("http://127.0.0.1:9");
let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Processed), std::option::Option::None);
let get_blocks = pool.get_blocks(&role, 1, std::option::Option::None, std::option::Option::Some(&config)).await;
let error = get_blocks.expect_err("getBlocks processed commitment must reject before I/O");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].key(), "commitment");
assert_eq!(error.context()[1].value(), "processed");
let get_blocks_with_limit = pool.get_blocks_with_limit(&role, 1, 1, std::option::Option::Some(&config)).await;
let error = get_blocks_with_limit.expect_err("getBlocksWithLimit processed commitment must reject before I/O");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].key(), "commitment");
assert_eq!(error.context()[1].value(), "processed");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_blocks_with_limit_accepts_zero_and_maximum_and_rejects_above_maximum() {
let role = crate::HttpRoleName::new("default");
let (url, handle) = serve_once(include_str!("../fixtures/http/get_blocks_with_limit.empty.json"));
let pool = pool_for_url(url.as_str());
let blocks = pool.get_blocks_with_limit(&role, 430_000_200, 0, std::option::Option::None).await.expect("zero getBlocksWithLimit limit must remain valid");
assert!(blocks.is_empty());
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([430000200, 0]));
let config = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized), std::option::Option::Some(430_000_000));
let (url, handle) = serve_once(include_str!("../fixtures/http/get_blocks_with_limit.success.json"));
let pool = pool_for_url(url.as_str());
let blocks = pool
.get_blocks_with_limit(&role, 430_000_200, 500_000, std::option::Option::Some(&config))
.await
.expect("maximum getBlocksWithLimit limit must remain valid");
assert_eq!(blocks, std::vec![430_000_200, 430_000_201, 430_000_205]);
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([430000200,500000,{"commitment":"finalized","minContextSlot":430000000}]));
let pool = pool_for_url("http://127.0.0.1:9");
let result = pool.get_blocks_with_limit(&role, 1, 500_001, std::option::Option::None).await;
let error = result.expect_err("getBlocksWithLimit above 500000 must reject before I/O");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].key(), "limit");
assert_eq!(error.context()[1].value(), "500001");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_recent_performance_samples_preserves_default_explicit_limit_order_and_older_shape() {
let role = crate::HttpRoleName::new("default");
for (limit, expected_params) in [(std::option::Option::None, serde_json::json!([])), (std::option::Option::Some(720), serde_json::json!([720]))] {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_recent_performance_samples.success.json"));
let pool = pool_for_url(url.as_str());
let samples = pool.get_recent_performance_samples(&role, limit).await.expect("performance sample fixture must succeed");
assert_eq!(samples.len(), 2);
assert_eq!(samples[0].slot(), 430_000_123);
assert_eq!(samples[0].num_transactions(), 250_000);
assert_eq!(samples[0].num_non_vote_transactions(), std::option::Option::Some(175_000));
assert_eq!(samples[1].slot(), 430_000_000);
assert_eq!(samples[1].num_non_vote_transactions(), std::option::Option::None);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getRecentPerformanceSamples"));
assert_eq!(body["params"], expected_params);
}
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_recent_performance_samples_rejects_above_720_before_io() {
let pool = pool_for_url("http://127.0.0.1:9");
let result = pool.get_recent_performance_samples(&crate::HttpRoleName::new("default"), std::option::Option::Some(721)).await;
let error = result.expect_err("performance sample limit above 720 must reject before I/O");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].key(), "limit");
assert_eq!(error.context()[1].value(), "721");
}