v0.2.3-pre.004

This commit is contained in:
2026-08-18 12:14:59 +02:00
parent 2a7f3e8f40
commit bd26d4f636
14 changed files with 801 additions and 21 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 123
# version: 124
[workspace]
resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib"]
[workspace.package]
version = "0.2.3-pre.3"
version = "0.2.3-pre.4"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"slot":431000030,"prioritizationFee":1200},{"slot":431000029,"prioritizationFee":0}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000052},"value":[null]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000051},"value":[]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000053},"value":[null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":431000050},"value":[null,{"slot":431000049,"confirmations":null,"status":{"Ok":null},"err":null,"confirmationStatus":"finalized"},{"slot":431000048,"confirmations":2,"status":{"Err":{"InstructionError":[0,"Custom"]}},"err":{"InstructionError":[0,"Custom"]},"confirmationStatus":"confirmed"}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32015,"message":"Transaction history is not available from this node"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"signature":"newest-signature","slot":431000040,"err":null,"memo":"memo-newest","blockTime":1787000100,"confirmationStatus":"finalized","transactionIndex":9},{"signature":"older-signature","slot":431000039,"err":{"InstructionError":[0,"Custom"]},"memo":null,"blockTime":null,"confirmationStatus":"confirmed"}],"id":1}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 13
// version: 14
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -10,9 +10,10 @@
//! independent from `ksp-config-lib`, Store and Program layers. `ksp-config-lib` now constructs these public settings through its one-way Config ->
//! Transport adapter without creating a reverse dependency. Logical endpoint clients, priority-aware pools, bounded admission limits and retry/no-resend policy
//! are available. The four typed Solana HTTP foundation canaries plus all 22 typed `0.2.2` Accounts, Tokens and Cluster wrappers execute real JSON-RPC
//! requests through the shared transport path. `0.2.3` exposes its shared Transaction wire/config primitives and its first four read wrappers:
//! `getFeeForMessage`, `getLatestBlockhash`, `getTransactionCount` and `isBlockhashValid`. The remaining seven Transaction wrappers and the `0.2.4` family
//! remain staged.
//! requests through the shared transport path. `0.2.3` exposes its shared Transaction wire/config primitives and seven read wrappers through `pre.004`,
//! including bounded prioritization-fee, address-signature and signature-status queries. `getTransaction`, both write submissions and
//! `simulateTransaction`, plus the
//! `0.2.4` family remain staged.
mod client;
mod constants;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_transactions.rs
// version: 3
// version: 4
/// Binary encoding accepted for serialized transaction input payloads.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -236,14 +236,12 @@ impl SolanaSignaturesForAddressConfig {
}
/// Returns whether the pagination config would serialize to an empty object.
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
return self.before.is_none() && self.until.is_none() && self.limit.is_none() && self.commitment().is_none() && self.min_context_slot().is_none();
}
/// Serializes this pagination config to the Solana JSON-RPC wire object.
#[must_use]
#[cfg(test)]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let context = self.context.to_json_value();
let mut object = match context {
@@ -283,14 +281,12 @@ impl SolanaSignatureStatusesConfig {
}
/// Returns whether the status config would serialize to an empty object.
#[cfg(test)]
pub(crate) const fn is_empty(&self) -> bool {
return self.search_transaction_history.is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
#[cfg(test)]
pub(crate) fn to_json_value(self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(value) = self.search_transaction_history {
@@ -651,7 +647,6 @@ impl SolanaPrioritizationFee {
}
/// Decodes one prioritization-fee sample from the 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::<WirePrioritizationFee>(method, value);
return match decoded {
@@ -728,7 +723,6 @@ impl SolanaSignatureInfo {
}
/// Decodes one signature record from the 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::<WireSignatureInfo>(method, value);
let wire = match decoded {
@@ -794,7 +788,6 @@ impl SolanaSignatureStatus {
}
/// Decodes one present signature status from the 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::<WireSignatureStatus>(method, value);
let wire = match decoded {
@@ -1102,6 +1095,10 @@ impl SolanaSimulateTransactionResult {
}
}
const MAX_RECENT_PRIORITIZATION_FEE_ACCOUNTS: usize = 128;
const MAX_SIGNATURES_FOR_ADDRESS_LIMIT: usize = 1_000;
const MAX_SIGNATURE_STATUSES: usize = 256;
impl crate::HttpTransportPool {
/// Executes typed `getFeeForMessage` through the common KSP HTTP transport path.
pub async fn get_fee_for_message(
@@ -1154,6 +1151,106 @@ impl crate::HttpTransportPool {
};
}
/// Executes typed `getRecentPrioritizationFees` through the common KSP HTTP transport path.
pub async fn get_recent_prioritization_fees(
&self,
role: &crate::HttpRoleName,
writable_accounts: std::option::Option<&[ksp_core_lib::Pubkey]>,
) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaPrioritizationFee>> {
if let std::option::Option::Some(writable_accounts) = writable_accounts
&& writable_accounts.len() > MAX_RECENT_PRIORITIZATION_FEE_ACCOUNTS
{
return invalid_transaction_parameters(
"getRecentPrioritizationFees",
"getRecentPrioritizationFees accepts at most 128 account addresses",
"account_count",
writable_accounts.len(),
);
}
let mut params = std::vec::Vec::new();
if let std::option::Option::Some(writable_accounts) = writable_accounts {
let mut addresses = std::vec::Vec::with_capacity(writable_accounts.len());
for account in writable_accounts {
addresses.push(serde_json::Value::String(account.to_string()));
}
params.push(serde_json::Value::Array(addresses));
}
let value = self.execute_transaction_rpc("getRecentPrioritizationFees", 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_prioritization_fees("getRecentPrioritizationFees", value);
}
/// Executes typed `getSignaturesForAddress` through the common KSP HTTP transport path.
pub async fn get_signatures_for_address(
&self,
role: &crate::HttpRoleName,
address: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaSignaturesForAddressConfig>,
) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaSignatureInfo>> {
if let std::option::Option::Some(limit) = config.and_then(crate::SolanaSignaturesForAddressConfig::limit)
&& (limit == 0 || limit > MAX_SIGNATURES_FOR_ADDRESS_LIMIT)
{
return invalid_transaction_parameters("getSignaturesForAddress", "getSignaturesForAddress limit must be between 1 and 1000", "limit", limit);
}
let mut params = std::vec![serde_json::Value::String(address.to_string())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let value = self.execute_transaction_rpc("getSignaturesForAddress", 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_signature_infos("getSignaturesForAddress", value);
}
/// Executes typed `getSignatureStatuses` through the common KSP HTTP transport path.
pub async fn get_signature_statuses(
&self,
role: &crate::HttpRoleName,
signatures: &[std::string::String],
config: std::option::Option<&crate::SolanaSignatureStatusesConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<std::option::Option<crate::SolanaSignatureStatus>>>> {
if signatures.len() > MAX_SIGNATURE_STATUSES {
return invalid_transaction_parameters(
"getSignatureStatuses",
"getSignatureStatuses accepts at most 256 signatures",
"signature_count",
signatures.len(),
);
}
let mut signature_values = std::vec::Vec::with_capacity(signatures.len());
for signature in signatures {
signature_values.push(serde_json::Value::String(signature.clone()));
}
let mut params = std::vec![serde_json::Value::Array(signature_values)];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push((*config).to_json_value());
}
let value = self.execute_transaction_rpc("getSignatureStatuses", role, params).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let contextual = decode_transaction_contextual_wire("getSignatureStatuses", value);
let (context, value) = match contextual {
std::result::Result::Ok(contextual) => contextual,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let statuses = decode_signature_statuses("getSignatureStatuses", value, signatures.len());
return match statuses {
std::result::Result::Ok(statuses) => std::result::Result::Ok(crate::SolanaRpcResponse::new(context, statuses)),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getTransactionCount` through the common KSP HTTP transport path.
pub async fn get_transaction_count(
&self,
@@ -1232,6 +1329,84 @@ fn decode_transaction_contextual_wire(method: &str, value: serde_json::Value) ->
};
}
fn decode_prioritization_fees(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaPrioritizationFee>> {
let decoded = crate::decode_wire_json::<std::vec::Vec<serde_json::Value>>(method, value);
let values = match decoded {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut fees = std::vec::Vec::with_capacity(values.len());
for value in values {
let fee = crate::SolanaPrioritizationFee::decode_wire(method, value);
match fee {
std::result::Result::Ok(fee) => fees.push(fee),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(fees);
}
fn decode_signature_infos(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaSignatureInfo>> {
let decoded = crate::decode_wire_json::<std::vec::Vec<serde_json::Value>>(method, value);
let values = match decoded {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut infos = std::vec::Vec::with_capacity(values.len());
for value in values {
let info = crate::SolanaSignatureInfo::decode_wire(method, value);
match info {
std::result::Result::Ok(info) => infos.push(info),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(infos);
}
fn decode_signature_statuses(
method: &str,
value: serde_json::Value,
expected_count: usize,
) -> ksp_core_lib::Result<std::vec::Vec<std::option::Option<crate::SolanaSignatureStatus>>> {
let decoded = crate::decode_wire_json::<std::vec::Vec<std::option::Option<serde_json::Value>>>(method, value);
let values = match decoded {
std::result::Result::Ok(values) => values,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
if values.len() != expected_count {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "getSignatureStatuses result count does not match the requested signature count")
.with_context("rpc_method", method)
.with_context("expected_count", expected_count.to_string())
.with_context("actual_count", values.len().to_string()),
);
}
let mut statuses = std::vec::Vec::with_capacity(values.len());
for value in values {
let value = match value {
std::option::Option::Some(value) => value,
std::option::Option::None => {
statuses.push(std::option::Option::None);
continue;
},
};
let status = crate::SolanaSignatureStatus::decode_wire(method, value);
match status {
std::result::Result::Ok(status) => statuses.push(std::option::Option::Some(status)),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(statuses);
}
fn invalid_transaction_parameters<T>(method: &str, message: &str, field: &'static str, value: usize) -> 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(field, value.to_string()),
);
}
fn transaction_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
let descriptor = crate::find_http_rpc_method(method);
return match descriptor {
@@ -1257,7 +1432,6 @@ fn invalid_transaction_wire(method: &str, field: &str, message: &'static str) ->
return ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method).with_context("field", field);
}
#[cfg(test)]
fn decode_confirmation_status(
method: &str,
field: &str,
@@ -1386,7 +1560,6 @@ struct WireLatestBlockhash {
last_valid_block_height: u64,
}
#[cfg(test)]
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WirePrioritizationFee {
@@ -1394,7 +1567,6 @@ struct WirePrioritizationFee {
prioritization_fee: u64,
}
#[cfg(test)]
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSignatureInfo {
@@ -1412,7 +1584,6 @@ struct WireSignatureInfo {
transaction_index: std::option::Option<u32>,
}
#[cfg(test)]
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSignatureStatus {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 11
// version: 12
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -278,3 +278,10 @@ fn public_transaction_pre_003_read_wrappers_are_available_from_crate_root() {
let _get_transaction_count = ksp_onchain_transport_lib::HttpTransportPool::get_transaction_count;
let _is_blockhash_valid = ksp_onchain_transport_lib::HttpTransportPool::is_blockhash_valid;
}
#[test]
fn public_transaction_pre_004_additional_read_wrappers_are_available_from_crate_root() {
let _get_recent_prioritization_fees = ksp_onchain_transport_lib::HttpTransportPool::get_recent_prioritization_fees;
let _get_signatures_for_address = ksp_onchain_transport_lib::HttpTransportPool::get_signatures_for_address;
let _get_signature_statuses = ksp_onchain_transport_lib::HttpTransportPool::get_signature_statuses;
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 8
// version: 9
//! Release-level completeness canaries for the `0.2.1` HTTP foundation contract.
@@ -259,3 +259,35 @@ fn release_pre_003_transaction_read_subset_is_exact_and_retry_safe() {
assert_eq!(actual, expected);
assert_eq!(actual.len(), 4);
}
#[test]
fn release_pre_004_transaction_read_subset_is_exact_and_retry_safe() {
let mut expected = std::vec![
"getFeeForMessage",
"getLatestBlockhash",
"getRecentPrioritizationFees",
"getSignaturesForAddress",
"getSignatureStatuses",
"getTransactionCount",
"isBlockhashValid",
];
let deferred = std::vec!["getTransaction", "requestAirdrop", "sendTransaction", "simulateTransaction"];
let mut actual = std::vec::Vec::new();
for method_name in &expected {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.004 Transaction descriptor must exist");
actual.push(descriptor.method());
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
for method_name in &deferred {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("deferred Transaction descriptor must remain registered");
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
}
actual.sort_unstable();
expected.sort_unstable();
assert_eq!(actual, expected);
assert_eq!(actual.len(), 7);
assert_eq!(deferred.len(), 4);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
// version: 3
// version: 4
#[test]
fn transaction_encoding_strings_match_current_and_legacy_wire_labels() {
@@ -470,3 +470,219 @@ async fn typed_is_blockhash_valid_preserves_opaque_blockhash_and_contextual_bool
assert_eq!(body["method"], serde_json::json!("isBlockhashValid"));
assert_eq!(body["params"], serde_json::json!(["opaque-blockhash", {"commitment":"processed","minContextSlot":431000004}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_recent_prioritization_fees_serializes_addresses_and_preserves_server_order() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_recent_prioritization_fees.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let first = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let second = "SysvarRent111111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let first_text = first.to_string();
let second_text = second.to_string();
let accounts = std::vec![first, second];
let fees = pool
.get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::Some(accounts.as_slice()))
.await
.expect("getRecentPrioritizationFees fixture must succeed");
assert_eq!(fees.len(), 2);
assert_eq!(fees[0].slot(), 431_000_030);
assert_eq!(fees[0].prioritization_fee(), 1_200);
assert_eq!(fees[1].slot(), 431_000_029);
assert_eq!(fees[1].prioritization_fee(), 0);
let request = handle.join().expect("fixture server must join");
let body = transaction_request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getRecentPrioritizationFees"));
assert_eq!(body["params"], serde_json::json!([[first_text, second_text]]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_recent_prioritization_fees_omits_optional_accounts_and_rejects_more_than_128_before_io() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_recent_prioritization_fees.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let fees = pool
.get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::None)
.await
.expect("getRecentPrioritizationFees without addresses must succeed");
assert_eq!(fees.len(), 2);
let request = handle.join().expect("fixture server must join");
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!([]));
let account = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let max_accounts = std::vec![account; 128];
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_recent_prioritization_fees.success.json"));
let max_pool = transaction_pool_for_url(url.as_str());
let fees = max_pool
.get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::Some(max_accounts.as_slice()))
.await
.expect("exactly 128 prioritization-fee accounts must remain accepted");
assert_eq!(fees.len(), 2);
let request = handle.join().expect("fixture server must join");
let body = transaction_request_body(request.as_str());
assert_eq!(body["params"][0].as_array().expect("address parameter must be an array").len(), 128);
let invalid_pool = transaction_pool_for_url("http://127.0.0.1:9");
let accounts = std::vec![account; 129];
let result = invalid_pool.get_recent_prioritization_fees(&crate::HttpRoleName::new("default"), std::option::Option::Some(accounts.as_slice())).await;
let error = result.expect_err("more than 128 prioritization-fee accounts must reject before I/O");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].key(), "account_count");
assert_eq!(error.context()[1].value(), "129");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_signatures_for_address_serializes_pagination_and_preserves_newest_first_result() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signatures_for_address.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let config = crate::SolanaSignaturesForAddressConfig::new(
std::option::Option::Some("before-signature".to_owned()),
std::option::Option::Some("until-signature".to_owned()),
std::option::Option::Some(1_000),
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(431_000_000),
);
let signatures = pool
.get_signatures_for_address(&crate::HttpRoleName::new("default"), &address, std::option::Option::Some(&config))
.await
.expect("getSignaturesForAddress fixture must succeed");
assert_eq!(signatures.len(), 2);
assert_eq!(signatures[0].signature(), "newest-signature");
assert_eq!(signatures[0].slot(), 431_000_040);
assert_eq!(signatures[0].memo(), std::option::Option::Some("memo-newest"));
assert_eq!(signatures[0].block_time(), std::option::Option::Some(1_787_000_100));
assert_eq!(signatures[0].confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized));
assert_eq!(signatures[0].transaction_index(), std::option::Option::Some(9));
assert_eq!(signatures[1].signature(), "older-signature");
assert!(signatures[1].err().is_some());
assert_eq!(signatures[1].memo(), std::option::Option::None);
assert_eq!(signatures[1].block_time(), std::option::Option::None);
assert_eq!(signatures[1].transaction_index(), std::option::Option::None);
let request = handle.join().expect("fixture server must join");
let body = transaction_request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getSignaturesForAddress"));
assert_eq!(
body["params"],
serde_json::json!([
address.to_string(),
{
"before":"before-signature",
"until":"until-signature",
"limit":1000,
"commitment":"finalized",
"minContextSlot":431000000
}
])
);
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_signatures_for_address_rejects_limit_outside_1_to_1000_before_io() {
let pool = transaction_pool_for_url("http://127.0.0.1:9");
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
for limit in [0_usize, 1_001_usize] {
let config = crate::SolanaSignaturesForAddressConfig::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(limit),
std::option::Option::None,
std::option::Option::None,
);
let result = pool.get_signatures_for_address(&crate::HttpRoleName::new("default"), &address, std::option::Option::Some(&config)).await;
let error = result.expect_err("out-of-range getSignaturesForAddress limit 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(), limit.to_string());
}
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_signatures_for_address_preserves_rpc_application_error() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signatures_for_address.error.json"));
let pool = transaction_pool_for_url(url.as_str());
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let result = pool.get_signatures_for_address(&crate::HttpRoleName::new("default"), &address, std::option::Option::None).await;
let error = result.expect_err("remote transaction-history error must remain an RPC application error");
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_signature_statuses_preserves_positional_nulls_and_history_config() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let signatures = std::vec!["missing-signature".to_owned(), "finalized-signature".to_owned(), "confirmed-signature".to_owned()];
let config = crate::SolanaSignatureStatusesConfig::new(std::option::Option::Some(true));
let response = pool
.get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::Some(&config))
.await
.expect("getSignatureStatuses fixture must succeed");
assert_eq!(response.context().slot(), 431_000_050);
assert_eq!(response.context().api_version(), std::option::Option::Some("4.2.1"));
assert_eq!(response.value().len(), 3);
assert!(response.value()[0].is_none());
let finalized = response.value()[1].as_ref().expect("second positional status must be present");
assert_eq!(finalized.slot(), 431_000_049);
assert_eq!(finalized.confirmations(), std::option::Option::None);
assert_eq!(finalized.status(), &serde_json::json!({"Ok":null}));
assert_eq!(finalized.confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized));
let confirmed = response.value()[2].as_ref().expect("third positional status must be present");
assert_eq!(confirmed.confirmations(), std::option::Option::Some(2));
assert!(confirmed.err().is_some());
let request = handle.join().expect("fixture server must join");
let body = transaction_request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getSignatureStatuses"));
assert_eq!(body["params"], serde_json::json!([signatures, {"searchTransactionHistory":true}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_signature_statuses_accepts_empty_input_and_omits_empty_config() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.empty.json"));
let pool = transaction_pool_for_url(url.as_str());
let signatures = std::vec::Vec::<std::string::String>::new();
let config = crate::SolanaSignatureStatusesConfig::default();
let response = pool
.get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::Some(&config))
.await
.expect("empty getSignatureStatuses input must remain accepted");
assert!(response.value().is_empty());
let request = handle.join().expect("fixture server must join");
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!([[]]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_signature_statuses_accepts_256_and_rejects_more_than_256_before_io() {
let signatures = std::vec!["signature".to_owned(); 256];
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.max_256.json"));
let pool = transaction_pool_for_url(url.as_str());
let response = pool
.get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::None)
.await
.expect("exactly 256 signatures must remain accepted");
assert_eq!(response.value().len(), 256);
let request = handle.join().expect("fixture server must join");
let body = transaction_request_body(request.as_str());
assert_eq!(body["params"][0].as_array().expect("signature parameter must be an array").len(), 256);
let invalid_pool = transaction_pool_for_url("http://127.0.0.1:9");
let signatures = std::vec!["signature".to_owned(); 257];
let result = invalid_pool.get_signature_statuses(&crate::HttpRoleName::new("default"), signatures.as_slice(), std::option::Option::None).await;
let error = result.expect_err("more than 256 signatures must reject before I/O");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].key(), "signature_count");
assert_eq!(error.context()[1].value(), "257");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_signature_statuses_rejects_response_count_mismatch() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/get_signature_statuses.count_mismatch.json"));
let pool = transaction_pool_for_url(url.as_str());
let requested = std::vec!["first".to_owned(), "second".to_owned()];
let result = pool.get_signature_statuses(&crate::HttpRoleName::new("default"), requested.as_slice(), std::option::Option::None).await;
let error = result.expect_err("status response count mismatch must reject typed decoding");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
assert_eq!(error.context()[1].key(), "expected_count");
assert_eq!(error.context()[1].value(), "2");
assert_eq!(error.context()[2].key(), "actual_count");
assert_eq!(error.context()[2].value(), "1");
handle.join().expect("fixture server must join");
}

346
deltas/0.2.3/pre.004.md Normal file
View File

@@ -0,0 +1,346 @@
<!-- file: deltas/0.2.3/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.3-pre.004` — lectures Transaction avec cardinalités et statuts positionnels
## Base requise
Livraison précédente validée localement par l'opérateur :
```text
0.2.3-pre.003
workspace.package.version = "0.2.3-pre.3"
```
La validation opérateur du 2026-08-18 a confirmé :
```text
cargo fmt --all OK
cargo check --workspace OK
cargo clippy --workspace --all-targets OK
cargo test -p ksp-onchain-transport-lib OK
```
Résultats Transport de cette base :
```text
152 unit tests
15 public API tests
9 release completeness tests
0 warning signalé par check/clippy
```
Le plan canonique reste `docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md` version 3.
## Objectif
Implémenter les trois wrappers Transaction `Read / RetrySafe` prévus par la tranche `pre.004` :
```text
getRecentPrioritizationFees
getSignaturesForAddress
getSignatureStatuses
```
Ils rejoignent les quatre reads de `pre.003`, portant la surface Transaction typée exécutée à :
```text
7 / 11 wrappers
```
Les quatre méthodes restantes restent volontairement différées :
```text
getTransaction
requestAirdrop
sendTransaction
simulateTransaction
```
## Version Cargo
Conformément à `VER-ID-009` :
```text
0.2.3-pre.3 -> 0.2.3-pre.4
```
Aucune dépendance ni feature Cargo n'est ajoutée.
## `getRecentPrioritizationFees`
Surface publique conceptuelle :
```text
role + Option<&[Pubkey]>
-> Vec<SolanaPrioritizationFee>
```
Le tableau d'adresses reste optionnel :
- `None` omet entièrement le paramètre ;
- `Some(slice)` sérialise exactement un tableau d'adresses ;
- l'ordre des adresses caller est conservé ;
- les résultats restent dans l'ordre renvoyé par le provider.
La cardinalité runtime documentée est appliquée avant I/O :
```text
0..=128 accepté
129+ ERROR_CODE_INVALID_RPC_PARAMETERS
```
Le test couvre explicitement la borne `128` puis le rejet de `129`.
Aucune règle locale n'est créée à partir de la profondeur actuelle de cache des prioritization fees : la valeur d'environ 150 blocs est une
caractéristique runtime du noeud et non une cardinalité client.
## `getSignaturesForAddress`
Surface publique conceptuelle :
```text
role + Pubkey + Option<SolanaSignaturesForAddressConfig>
-> Vec<SolanaSignatureInfo>
```
La config préparée en `pre.002` devient production-live pour cette méthode :
```text
before
until
limit
commitment
minContextSlot
```
Une config absente ou entièrement vide est omise. La limite est validée avant I/O exactement selon le runtime audité :
```text
1..=1000 accepté quand limit est fourni
0 rejeté
1001+ rejeté
```
La fixture de succès utilise explicitement `limit = 1000`, puis les tests rejettent `0` et `1001`.
Le résultat conserve notamment :
```text
signature
slot
err nullable
memo nullable
blockTime nullable
confirmationStatus nullable
transactionIndex optionnel
```
L'ordre `newest -> oldest` du provider n'est jamais retrié côté KSP. Les signatures restent des chaînes wire opaques dans cette release.
Une fixture JSON-RPC d'erreur vérifie qu'une indisponibilité de transaction history reste une `RPC_APPLICATION_ERROR`, sans être transformée en
retry Transport.
## `getSignatureStatuses`
Surface publique conceptuelle :
```text
role + &[String] + Option<SolanaSignatureStatusesConfig>
-> SolanaRpcResponse<Vec<Option<SolanaSignatureStatus>>>
```
La config `searchTransactionHistory` préparée en `pre.002` devient production-live. Une config vide est omise.
Le tableau d'entrée suit exactement la borne Agave auditée :
```text
0..=256 accepté
257+ ERROR_CODE_INVALID_RPC_PARAMETERS
```
Le tableau vide reste volontairement accepté, conformément au runtime `v4.2.1` audité en `pre.001`. La borne `256` est exercée par une fixture
contenant 256 positions `null` et `257` est rejeté avant I/O.
Le résultat conserve strictement la correspondance positionnelle :
```text
entrée[i] <-> value[i]
null -> None
objet -> Some(SolanaSignatureStatus)
```
KSP vérifie maintenant que le provider retourne exactement autant de positions que de signatures demandées. Une longueur différente produit
`ERROR_CODE_INVALID_RESPONSE`, car continuer masquerait la correspondance signature/statut.
Pour un statut présent, les champs suivants restent préservés :
```text
slot
confirmations nullable
status legacy lossless JSON
err nullable
confirmationStatus nullable
```
La forme `status` demeure un champ legacy du wire courant ; elle n'est pas confondue avec les anciennes méthodes RPC Deprecated.
## Helpers activés en production
Seuls les helpers nécessaires à cette tranche quittent `#[cfg(test)]` :
```text
SolanaSignaturesForAddressConfig::{is_empty,to_json_value}
SolanaSignatureStatusesConfig::{is_empty,to_json_value}
SolanaPrioritizationFee::decode_wire
SolanaSignatureInfo::decode_wire
SolanaSignatureStatus::decode_wire
decode_confirmation_status
WirePrioritizationFee
WireSignatureInfo
WireSignatureStatus
```
Les helpers de `getTransaction`, `requestAirdrop`, `sendTransaction` et `simulateTransaction` restent staged/test-only jusqu'à leur tranche
propriétaire. Aucun `#[allow(dead_code)]` n'est introduit.
## Fixtures HTTP déterministes ajoutées
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_recent_prioritization_fees.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.empty.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.max_256.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.count_mismatch.json
```
Les tests HTTP utilisent uniquement un serveur loopback local.
## Couverture de tests ajoutée
Neuf tests unitaires HTTP supplémentaires couvrent :
- paramètres exacts de `getRecentPrioritizationFees` et ordre serveur ;
- omission du paramètre optionnel ;
- acceptation de 128 adresses et rejet local de 129 ;
- pagination/config complète de `getSignaturesForAddress` avec `limit = 1000` ;
- rejet local de `limit = 0` et `limit = 1001` ;
- propagation d'une erreur JSON-RPC applicative ;
- `getSignatureStatuses` avec `searchTransactionHistory = true` et positions `null` ;
- tableau vide + config vide omise ;
- borne 256 acceptée, 257 rejetée et longueur de réponse incohérente rejetée.
Un test public compile les trois nouveaux wrappers depuis la crate root.
Une nouvelle canarie release fige le sous-ensemble Read exécuté cumulé de `pre.003 + pre.004` :
```text
getFeeForMessage
getLatestBlockhash
getRecentPrioritizationFees
getSignaturesForAddress
getSignatureStatuses
getTransactionCount
isBlockhashValid
```
Chaque descriptor reste :
```text
Transactions / V0_2_3 / Read / RetrySafe
```
La canarie confirme aussi que les quatre méthodes différées restent enregistrées dans `V0_2_3` sans les compter comme surface typée achevée.
Après application, la cible Transport attendue devient :
```text
161 unit tests
16 public API tests
10 release completeness tests
```
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_recent_prioritization_fees.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signatures_for_address.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.empty.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.max_256.json
crates/ksp-onchain-transport-lib/fixtures/http/get_signature_statuses.count_mismatch.json
deltas/0.2.3/pre.004.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_transactions.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
```
Restent inchangés dans cette tranche :
```text
CHANGELOG.md
ROADMAP.md
docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/src/executor.rs
crates/ksp-onchain-transport-lib/src/resilience.rs
crates/ksp-onchain-transport-lib/src/rpc_method.rs
```
## Contrôles statiques effectués dans l'environnement d'échange
- les sept nouvelles fixtures sont du JSON valide ;
- aucune dépendance n'est ajoutée ;
- aucun `reqwest`, `tracing`, retry local ou client parallèle n'est introduit par le diff ;
- aucun `#[allow(dead_code)]` n'est introduit ;
- les helpers des tranches futures restent `#[cfg(test)]` ;
- aucune ligne Rust modifiée ne dépasse 160 colonnes ;
- `CHANGELOG.md`, `ROADMAP.md`, le plan `010`, `executor.rs`, `resilience.rs` et `rpc_method.rs` restent identiques à `pre.003`.
`cargo`, `rustc` et `rustfmt` ne sont pas installés dans l'environnement d'échange. Aucune validation Cargo de `pre.004` n'est donc déclarée
réussie ici.
## Validation opérateur requise
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
La validation doit notamment confirmer :
```text
0 warning check/clippy
161 unit tests
16 public API tests
10 release completeness tests
```
Le smoke Devnet reste opt-in et n'est pas requis pour cette tranche locale déterministe.
## Hors scope maintenu
`pre.004` ne traite pas :
- `getTransaction` moderne/legacy ;
- `requestAirdrop` ;
- `sendTransaction` ;
- la preuve end-to-end no-resend des writes ;
- `simulateTransaction` ;
- les 15 méthodes `0.2.4` Blocks/Economics ;
- un nouveau smoke cross-crates.
La prochaine tranche reste `pre.005` : `getTransaction` moderne, compatibilité legacy et wire transaction/meta/version.