v0.2.2-pre.003

This commit is contained in:
2026-08-18 07:31:07 +02:00
parent bec1f2ec08
commit f15448ff6e
19 changed files with 1113 additions and 31 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 112
# version: 113
[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.2-pre.2.fix.2"
version = "0.2.2-pre.3"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000003},"value":{"data":["","base64"],"executable":false,"lamports":1,"owner":"not-a-pubkey","rentEpoch":0,"space":0}},"id":1}

View File

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

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000001},"value":{"data":["AQIDBA==","base64"],"executable":false,"lamports":2039280,"owner":"11111111111111111111111111111111","rentEpoch":18446744073709551615,"space":4}},"id":1}

View File

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

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000004},"value":[{"address":"11111111111111111111111111111111","lamports":999999999},{"address":"ComputeBudget111111111111111111111111111111","lamports":888888888}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid param"},"id":1}

View File

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

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000006},"value":[{"data":["","base64"],"executable":false,"lamports":10,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":0},null,{"data":{"program":"system","parsed":{"type":"nonce"},"space":80},"executable":false,"lamports":20,"owner":"11111111111111111111111111111111","rentEpoch":1,"space":80}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}],"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":410000007},"value":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":42,"owner":"11111111111111111111111111111111","rentEpoch":0,"space":3}}]},"id":1}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/error.rs
// version: 1
// version: 2
/// Error code used when HTTP transport runtime settings are invalid.
pub const ERROR_CODE_INVALID_SETTINGS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_settings");
@@ -25,3 +25,5 @@ pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_l
pub const ERROR_CODE_METHOD_REMOVED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "method_removed");
/// Error code used when a decoded response cannot satisfy the KSP transport contract expected by the caller.
pub const ERROR_CODE_INVALID_RESPONSE: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_response");
/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract.
pub const ERROR_CODE_INVALID_RPC_PARAMETERS: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "invalid_rpc_parameters");

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 8
// version: 9
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -9,8 +9,8 @@
//! This crate owns runtime HTTP transport settings, Solana HTTP JSON-RPC envelopes and the audited standard method registry. It deliberately remains
//! 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 first typed Solana HTTP canaries execute real
//! JSON-RPC requests while the remaining audited methods stay staged by subsequent `0.2.x` releases.
//! are available. The four typed Solana HTTP foundation canaries and the `0.2.2` Accounts wrappers execute real JSON-RPC requests through the shared
//! transport path while the remaining audited methods stay staged by subsequent `0.2.x` prereleases.
mod client;
mod constants;
@@ -45,6 +45,8 @@ pub use self::error::ERROR_CODE_HTTP_CONNECTION_FAILED;
pub use self::error::ERROR_CODE_HTTP_REQUEST_FAILED;
/// Error code used when a decoded response cannot satisfy the expected KSP transport contract.
pub use self::error::ERROR_CODE_INVALID_RESPONSE;
/// Error code used when typed Solana RPC parameters violate a locally enforceable method contract.
pub use self::error::ERROR_CODE_INVALID_RPC_PARAMETERS;
/// Error code used when HTTP transport runtime settings are invalid.
pub use self::error::ERROR_CODE_INVALID_SETTINGS;
/// Error code used when an HTTP JSON-RPC payload cannot be decoded as JSON.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
// version: 2
// version: 3
/// Account-data encoding accepted by Solana HTTP account methods.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -29,7 +29,6 @@ impl SolanaAccountEncoding {
};
}
#[cfg(test)]
fn from_wire(value: &str) -> std::option::Option<Self> {
return match value {
"binary" => std::option::Option::Some(Self::Binary),
@@ -68,7 +67,6 @@ impl SolanaDataSliceConfig {
return self.length;
}
#[cfg(test)]
fn to_json_value(self) -> serde_json::Value {
return serde_json::json!({"offset": self.offset, "length": self.length});
}
@@ -118,9 +116,12 @@ impl SolanaAccountInfoConfig {
return self.context.min_context_slot();
}
fn is_empty(&self) -> bool {
return self.encoding.is_none() && self.data_slice.is_none() && self.commitment().is_none() && self.min_context_slot().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 context_value = self.context.to_json_value();
let mut object = match context_value {
@@ -147,7 +148,6 @@ pub enum SolanaLargestAccountsFilter {
}
impl SolanaLargestAccountsFilter {
#[cfg(test)]
fn as_str(self) -> &'static str {
return match self {
Self::Circulating => "circulating",
@@ -193,9 +193,12 @@ impl SolanaLargestAccountsConfig {
return self.sort_results;
}
fn is_empty(&self) -> bool {
return self.commitment.is_none() && self.filter.is_none() && self.sort_results.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(commitment) = self.commitment {
@@ -248,7 +251,6 @@ impl SolanaMemcmpFilter {
return &self.bytes;
}
#[cfg(test)]
fn to_json_value(&self) -> serde_json::Value {
return match &self.bytes {
crate::SolanaMemcmpBytes::Base58(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base58"}),
@@ -270,7 +272,6 @@ pub enum SolanaProgramAccountFilter {
}
impl SolanaProgramAccountFilter {
#[cfg(test)]
fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::DataSize(size) => serde_json::json!({"dataSize": size}),
@@ -325,9 +326,12 @@ impl SolanaProgramAccountsConfig {
return self.sort_results;
}
fn is_empty(&self) -> bool {
return self.account_config.is_empty() && self.filters.is_empty() && self.with_context.is_none() && self.sort_results.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 account_value = self.account_config.to_json_value();
let mut object = match account_value {
@@ -393,7 +397,6 @@ pub enum SolanaAccountData {
}
impl SolanaAccountData {
#[cfg(test)]
fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccountData>(method, value);
let wire = match decoded {
@@ -477,7 +480,6 @@ impl SolanaAccount {
}
/// Decodes one account DTO 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::<WireAccount>(method, value);
let wire = match decoded {
@@ -526,7 +528,6 @@ impl SolanaKeyedAccount {
}
/// Decodes one keyed account 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::<WireKeyedAccount>(method, value);
let wire = match decoded {
@@ -568,7 +569,6 @@ impl SolanaAccountBalance {
}
/// Decodes one account-balance entry 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::<WireAccountBalance>(method, value);
let wire = match decoded {
@@ -592,7 +592,361 @@ pub enum SolanaProgramAccountsResult {
Context(crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>),
}
#[cfg(test)]
const MAX_MULTIPLE_ACCOUNTS: usize = 100;
const MAX_PROGRAM_ACCOUNT_FILTERS: usize = 4;
const MAX_MEMCMP_BYTES: usize = 128;
impl crate::HttpTransportPool {
/// Executes typed `getAccountInfo` through the common KSP HTTP transport path.
pub async fn get_account_info(
&self,
role: &crate::HttpRoleName,
account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::option::Option<crate::SolanaAccount>>> {
let method_result = account_descriptor("getAccountInfo");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec![serde_json::Value::String(account.to_string())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_account_info_response("getAccountInfo", value);
}
/// Executes typed `getLargestAccounts` through the common KSP HTTP transport path.
pub async fn get_largest_accounts(
&self,
role: &crate::HttpRoleName,
config: std::option::Option<&crate::SolanaLargestAccountsConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaAccountBalance>>> {
let method_result = account_descriptor("getLargestAccounts");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec::Vec::new();
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_largest_accounts_response("getLargestAccounts", value);
}
/// Executes typed `getMinimumBalanceForRentExemption` through the common KSP HTTP transport path.
pub async fn get_minimum_balance_for_rent_exemption(
&self,
role: &crate::HttpRoleName,
data_len: usize,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<u64> {
let method_result = account_descriptor("getMinimumBalanceForRentExemption");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let data_len_value = serde_json::to_value(data_len);
let data_len_value = match data_len_value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_ENCODE_FAILED, "rent-exemption data length could not be encoded")
.with_context("rpc_method", "getMinimumBalanceForRentExemption")
.with_source(error),
);
},
};
let mut params = std::vec![data_len_value];
if let std::option::Option::Some(config) = config
&& config.commitment().is_some()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let decoded = crate::decode_wire_json::<u64>("getMinimumBalanceForRentExemption", value);
return match decoded {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getMultipleAccounts` through the common KSP HTTP transport path.
pub async fn get_multiple_accounts(
&self,
role: &crate::HttpRoleName,
accounts: &[ksp_core_lib::Pubkey],
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<std::option::Option<crate::SolanaAccount>>>> {
if accounts.len() > MAX_MULTIPLE_ACCOUNTS {
return invalid_account_parameters("getMultipleAccounts", "getMultipleAccounts accepts at most 100 public keys", "account_count", accounts.len());
}
let method_result = account_descriptor("getMultipleAccounts");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let addresses = accounts.iter().map(std::string::ToString::to_string).map(serde_json::Value::String).collect::<std::vec::Vec<_>>();
let mut params = std::vec![serde_json::Value::Array(addresses)];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_multiple_accounts_response("getMultipleAccounts", value, accounts.len());
}
/// Executes typed `getProgramAccounts` through the common KSP HTTP transport path.
pub async fn get_program_accounts(
&self,
role: &crate::HttpRoleName,
program_id: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaProgramAccountsConfig>,
) -> ksp_core_lib::Result<crate::SolanaProgramAccountsResult> {
if let std::option::Option::Some(config) = config {
let validation = validate_program_account_filters(config.filters());
if let std::result::Result::Err(error) = validation {
return std::result::Result::Err(error);
}
}
let method_result = account_descriptor("getProgramAccounts");
let method = match method_result {
std::result::Result::Ok(method) => method,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut params = std::vec![serde_json::Value::String(program_id.to_string())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let result = self.execute_standard_rpc(role, method, params).await;
let value = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return decode_program_accounts_response("getProgramAccounts", value);
}
}
fn account_descriptor(method: &str) -> ksp_core_lib::Result<&'static crate::HttpRpcMethodDescriptor> {
let descriptor = crate::find_http_rpc_method(method);
return match descriptor {
std::option::Option::Some(descriptor)
if descriptor.category() == crate::HttpRpcCategory::Accounts && descriptor.coverage_release() == crate::HttpRpcCoverageRelease::V0_2_2 =>
{
std::result::Result::Ok(descriptor)
},
_ => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Accounts descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
fn invalid_account_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 validate_program_account_filters(filters: &[crate::SolanaProgramAccountFilter]) -> ksp_core_lib::Result<()> {
if filters.len() > MAX_PROGRAM_ACCOUNT_FILTERS {
return invalid_account_parameters(
"getProgramAccounts",
"getProgramAccounts accepts at most 4 filters on the targeted Agave runtime",
"filter_count",
filters.len(),
);
}
for filter in filters {
if let crate::SolanaProgramAccountFilter::Memcmp(memcmp) = filter
&& let crate::SolanaMemcmpBytes::Bytes(bytes) = memcmp.bytes()
&& bytes.len() > MAX_MEMCMP_BYTES
{
return invalid_account_parameters(
"getProgramAccounts",
"raw getProgramAccounts memcmp data accepts at most 128 bytes",
"memcmp_byte_count",
bytes.len(),
);
}
}
return std::result::Result::Ok(());
}
fn decode_account_info_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::option::Option<crate::SolanaAccount>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::option::Option<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let account = match wire.value {
std::option::Option::Some(value) => {
let account = crate::SolanaAccount::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => std::option::Option::Some(account),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
std::option::Option::None => std::option::Option::None,
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, account));
}
fn decode_largest_accounts_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaAccountBalance>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<serde_json::Value>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut values = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
let decoded = crate::SolanaAccountBalance::decode_wire(method, value);
match decoded {
std::result::Result::Ok(decoded) => values.push(decoded),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values));
}
fn decode_multiple_accounts_response(
method: &str,
value: serde_json::Value,
expected_count: usize,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<std::option::Option<crate::SolanaAccount>>>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<std::vec::Vec<std::option::Option<serde_json::Value>>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut values = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
match value {
std::option::Option::Some(value) => {
let decoded = crate::SolanaAccount::decode_wire(method, value);
match decoded {
std::result::Result::Ok(decoded) => values.push(std::option::Option::Some(decoded)),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
},
std::option::Option::None => values.push(std::option::Option::None),
}
}
if values.len() != expected_count {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "getMultipleAccounts result count does not match the requested account count")
.with_context("rpc_method", method)
.with_context("expected_count", expected_count.to_string())
.with_context("actual_count", values.len().to_string()),
);
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, values));
}
fn decode_program_accounts_response(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaProgramAccountsResult> {
let decoded = crate::decode_wire_json::<WireProgramAccountsResult>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return match wire {
WireProgramAccountsResult::Accounts(values) => {
let decoded = decode_keyed_accounts(method, values);
match decoded {
std::result::Result::Ok(values) => std::result::Result::Ok(crate::SolanaProgramAccountsResult::Accounts(values)),
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
WireProgramAccountsResult::Context(wire) => {
let context = crate::SolanaRpcContext::decode_wire(method, wire.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let values = decode_keyed_accounts(method, wire.value);
match values {
std::result::Result::Ok(values) => {
std::result::Result::Ok(crate::SolanaProgramAccountsResult::Context(crate::SolanaRpcResponse::new(context, values)))
},
std::result::Result::Err(error) => std::result::Result::Err(error),
}
},
};
}
fn decode_keyed_accounts(method: &str, values: std::vec::Vec<serde_json::Value>) -> ksp_core_lib::Result<std::vec::Vec<crate::SolanaKeyedAccount>> {
let mut decoded_values = std::vec::Vec::with_capacity(values.len());
for value in values {
let decoded = crate::SolanaKeyedAccount::decode_wire(method, value);
match decoded {
std::result::Result::Ok(decoded) => decoded_values.push(decoded),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(decoded_values);
}
#[derive(serde::Deserialize)]
struct WireRpcResponse<T> {
context: serde_json::Value,
value: T,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireProgramAccountsResult {
Context(WireRpcResponse<std::vec::Vec<serde_json::Value>>),
Accounts(std::vec::Vec<serde_json::Value>),
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireAccountData {
@@ -601,7 +955,6 @@ enum WireAccountData {
Encoded((std::string::String, std::string::String)),
}
#[cfg(test)]
#[derive(serde::Deserialize)]
struct WireParsedAccountData {
program: std::string::String,
@@ -609,7 +962,6 @@ struct WireParsedAccountData {
space: u64,
}
#[cfg(test)]
#[derive(serde::Deserialize)]
struct WireAccount {
lamports: u64,
@@ -622,14 +974,12 @@ struct WireAccount {
space: std::option::Option<u64>,
}
#[cfg(test)]
#[derive(serde::Deserialize)]
struct WireKeyedAccount {
pubkey: std::string::String,
account: serde_json::Value,
}
#[cfg(test)]
#[derive(serde::Deserialize)]
struct WireAccountBalance {
address: std::string::String,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
// version: 3
// version: 4
/// Commitment level accepted by typed Solana HTTP RPC adapters.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -45,7 +45,6 @@ impl SolanaCommitmentConfig {
/// 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(commitment) = self.commitment {
@@ -149,8 +148,7 @@ impl<T> SolanaRpcResponse<T> {
return &self.value;
}
/// Creates a contextual response after wire decoding and validation for staged DTO tests.
#[cfg(test)]
/// Creates a contextual response after wire decoding and validation.
#[must_use]
pub(crate) const fn new(context: crate::SolanaRpcContext, value: T) -> Self {
return Self { context, value };
@@ -171,7 +169,6 @@ pub(crate) fn decode_wire_json<T: serde::de::DeserializeOwned>(method: &str, val
}
/// Parses a base58 public key from one wire field without echoing its value into diagnostics.
#[cfg(test)]
pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
let parsed = value.parse::<ksp_core_lib::Pubkey>();
return match parsed {

View File

@@ -198,3 +198,14 @@ fn public_pre_002_shared_rpc_types_are_constructible_from_crate_root() {
);
assert_eq!(vote.vote_pubkey(), std::option::Option::Some(&pubkey));
}
#[test]
fn public_pre_003_account_wrappers_are_available_from_crate_root() {
let _get_account_info = ksp_onchain_transport_lib::HttpTransportPool::get_account_info;
let _get_largest_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_largest_accounts;
let _get_minimum_balance_for_rent_exemption = ksp_onchain_transport_lib::HttpTransportPool::get_minimum_balance_for_rent_exemption;
let _get_multiple_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_multiple_accounts;
let _get_program_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_program_accounts;
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_RPC_PARAMETERS.domain(), "onchain_transport");
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_INVALID_RPC_PARAMETERS.code(), "invalid_rpc_parameters");
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 1
// version: 2
//! Release-level completeness canaries for the `0.2.1` HTTP foundation contract.
@@ -46,3 +46,22 @@ fn release_foundation_canaries_and_historical_statuses_are_exact() {
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NotApplicable);
}
}
#[test]
fn release_pre_003_accounts_subset_is_exact_without_advancing_other_v0_2_2_families() {
let mut account_names = std::vec::Vec::<&str>::new();
for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() {
if descriptor.coverage_release() == ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_2
&& descriptor.category() == ksp_onchain_transport_lib::HttpRpcCategory::Accounts
{
account_names.push(descriptor.method());
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
}
account_names.sort_unstable();
assert_eq!(
account_names,
std::vec!["getAccountInfo", "getLargestAccounts", "getMinimumBalanceForRentExemption", "getMultipleAccounts", "getProgramAccounts",],
);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
// version: 2
// version: 3
#[test]
fn account_config_serializes_all_common_fields() {
@@ -88,3 +88,359 @@ fn staged_largest_and_keyed_account_helpers_match_wire_shapes() {
assert_eq!(balance.address().to_string(), "11111111111111111111111111111111");
assert_eq!(balance.lamports(), 99);
}
fn pool_for_url(url: &str) -> crate::HttpTransportPool {
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard()],
10,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
let endpoint = crate::HttpEndpointSettings::new(
"fixture",
true,
crate::HttpProviderName::new("fixture"),
crate::HttpClusterName::new("local"),
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
std::time::Duration::from_secs(1),
std::time::Duration::from_secs(1),
std::option::Option::Some(1),
std::vec![role],
);
let settings = crate::HttpTransportSettings::new(
std::vec![endpoint],
crate::HttpRetrySettings::new(0, std::time::Duration::from_millis(1), std::time::Duration::from_millis(1)),
);
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
}
fn serve_once(body: &'static str) -> (std::string::String, std::thread::JoinHandle<std::string::String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("fixture listener must bind");
let address = listener.local_addr().expect("fixture listener address must resolve");
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("fixture server must accept one request");
let request = read_request(&mut stream);
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body);
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
return request;
});
return (format!("http://{address}"), handle);
}
fn read_request(stream: &mut std::net::TcpStream) -> std::string::String {
let mut bytes = std::vec::Vec::new();
let mut buffer = [0_u8; 1024];
loop {
let count = std::io::Read::read(stream, &mut buffer).expect("fixture request must read");
if count == 0 {
break;
}
bytes.extend_from_slice(&buffer[..count]);
if request_complete(bytes.as_slice()) {
break;
}
}
return std::string::String::from_utf8(bytes).expect("fixture request must be UTF-8");
}
fn request_complete(bytes: &[u8]) -> bool {
let text = match std::str::from_utf8(bytes) {
std::result::Result::Ok(text) => text,
std::result::Result::Err(_) => return false,
};
let header_end = match text.find("\r\n\r\n") {
std::option::Option::Some(value) => value,
std::option::Option::None => return false,
};
let mut content_length = 0_usize;
for line in text[..header_end].lines() {
let (name, value) = match line.split_once(':') {
std::option::Option::Some(parts) => parts,
std::option::Option::None => continue,
};
if name.eq_ignore_ascii_case("content-length") {
content_length = value.trim().parse::<usize>().expect("content length must parse");
}
}
return bytes.len() >= header_end.saturating_add(4).saturating_add(content_length);
}
fn request_body(request: &str) -> serde_json::Value {
let body = request.split("\r\n\r\n").nth(1).expect("fixture request body must exist");
return serde_json::from_str(body).expect("fixture request body must be JSON");
}
fn fixture_pubkey(value: &str) -> ksp_core_lib::Pubkey {
return value.parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_account_info_serializes_config_and_preserves_account_wire() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.success.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
std::option::Option::Some(crate::SolanaDataSliceConfig::new(2, 4)),
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(400_000_000),
);
let response = pool
.get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config))
.await
.expect("getAccountInfo fixture must succeed");
assert_eq!(response.context().slot(), 410_000_001);
let returned = response.value().as_ref().expect("fixture account must be present");
assert_eq!(returned.lamports(), 2_039_280);
assert_eq!(returned.owner(), &account);
assert_eq!(returned.space(), std::option::Option::Some(4));
assert!(matches!(returned.data(), crate::SolanaAccountData::Encoded { encoding: crate::SolanaAccountEncoding::Base64, .. }));
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getAccountInfo"));
assert_eq!(body["params"][0], serde_json::json!(account.to_string()));
assert_eq!(body["params"][1]["encoding"], serde_json::json!("base64"));
assert_eq!(body["params"][1]["dataSlice"], serde_json::json!({"offset":2,"length":4}));
assert_eq!(body["params"][1]["commitment"], serde_json::json!("finalized"));
assert_eq!(body["params"][1]["minContextSlot"], serde_json::json!(400_000_000_u64));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_account_info_preserves_missing_account_as_none() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.null.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let response = pool
.get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::None)
.await
.expect("missing getAccountInfo fixture must succeed");
assert_eq!(response.value(), &std::option::Option::None);
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"].as_array().map(std::vec::Vec::len), std::option::Option::Some(1));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_account_info_rejects_invalid_owner_without_exposing_value() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_account_info.invalid_owner.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let result = pool.get_account_info(&crate::HttpRoleName::new("default"), &account, std::option::Option::None).await;
let error = result.expect_err("invalid account owner must fail typed decoding");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
assert!(!format!("{error:?}").contains("not-a-pubkey"));
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_largest_accounts_serializes_extended_config_and_decodes_order() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_largest_accounts.success.json"));
let pool = pool_for_url(url.as_str());
let config = crate::SolanaLargestAccountsConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(crate::SolanaLargestAccountsFilter::Circulating),
std::option::Option::Some(true),
);
let response = pool
.get_largest_accounts(&crate::HttpRoleName::new("default"), std::option::Option::Some(&config))
.await
.expect("getLargestAccounts fixture must succeed");
assert_eq!(response.value().len(), 2);
assert_eq!(response.value()[0].lamports(), 999_999_999);
assert_eq!(response.value()[1].lamports(), 888_888_888);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getLargestAccounts"));
assert_eq!(body["params"][0], serde_json::json!({"commitment":"finalized","filter":"circulating","sortResults":true}));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_largest_accounts_rejects_invalid_address() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_largest_accounts.invalid_address.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_largest_accounts(&crate::HttpRoleName::new("default"), std::option::Option::None).await;
let error = result.expect_err("invalid largest-account address must fail typed decoding");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
assert!(!format!("{error:?}").contains("invalid-address"));
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_minimum_balance_for_rent_exemption_serializes_length_and_commitment() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_minimum_balance_for_rent_exemption.success.json"));
let pool = pool_for_url(url.as_str());
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Processed));
let result = pool
.get_minimum_balance_for_rent_exemption(&crate::HttpRoleName::new("default"), 50, std::option::Option::Some(&config))
.await
.expect("rent-exemption fixture must succeed");
assert_eq!(result, 890_880);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getMinimumBalanceForRentExemption"));
assert_eq!(body["params"], serde_json::json!([50,{"commitment":"processed"}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_minimum_balance_for_rent_exemption_preserves_rpc_application_error() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_minimum_balance_for_rent_exemption.error.json"));
let pool = pool_for_url(url.as_str());
let result = pool.get_minimum_balance_for_rent_exemption(&crate::HttpRoleName::new("default"), usize::MAX, std::option::Option::None).await;
let error = result.expect_err("remote invalid parameter 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_multiple_accounts_preserves_order_nulls_and_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_multiple_accounts.success.json"));
let pool = pool_for_url(url.as_str());
let accounts = std::vec![
fixture_pubkey("11111111111111111111111111111111"),
fixture_pubkey("ComputeBudget111111111111111111111111111111"),
fixture_pubkey("Stake11111111111111111111111111111111111111"),
];
let config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
std::option::Option::None,
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(400_000_001),
);
let response = pool
.get_multiple_accounts(&crate::HttpRoleName::new("default"), accounts.as_slice(), std::option::Option::Some(&config))
.await
.expect("getMultipleAccounts fixture must succeed");
assert_eq!(response.value().len(), 3);
assert_eq!(response.value()[0].as_ref().map(crate::SolanaAccount::lamports), std::option::Option::Some(10));
assert!(response.value()[1].is_none());
assert_eq!(response.value()[2].as_ref().map(crate::SolanaAccount::lamports), std::option::Option::Some(20));
assert!(matches!(response.value()[2].as_ref().map(crate::SolanaAccount::data), std::option::Option::Some(crate::SolanaAccountData::JsonParsed(_))));
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getMultipleAccounts"));
assert_eq!(body["params"][0], serde_json::json!(accounts.iter().map(std::string::ToString::to_string).collect::<std::vec::Vec<_>>()));
assert_eq!(body["params"][1]["encoding"], serde_json::json!("jsonParsed"));
assert_eq!(body["params"][1]["commitment"], serde_json::json!("confirmed"));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_multiple_accounts_rejects_more_than_100_before_io() {
let pool = pool_for_url("http://127.0.0.1:9");
let account = fixture_pubkey("11111111111111111111111111111111");
let accounts = std::vec![account; 101];
let result = pool.get_multiple_accounts(&crate::HttpRoleName::new("default"), accounts.as_slice(), std::option::Option::None).await;
let error = result.expect_err("more than 100 accounts must be rejected locally");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[0].key(), "rpc_method");
assert_eq!(error.context()[1].key(), "account_count");
assert_eq!(error.context()[1].value(), "101");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_program_accounts_serializes_filters_and_decodes_bare_result() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_program_accounts.bare.success.json"));
let pool = pool_for_url(url.as_str());
let program_id = fixture_pubkey("11111111111111111111111111111111");
let filters = std::vec![
crate::SolanaProgramAccountFilter::DataSize(3),
crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Base64("AQID".to_owned()))),
crate::SolanaProgramAccountFilter::TokenAccountState,
];
let account_config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
std::option::Option::Some(crate::SolanaDataSliceConfig::new(0, 3)),
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(400_000_002),
);
let config = crate::SolanaProgramAccountsConfig::new(account_config, filters, std::option::Option::Some(false), std::option::Option::Some(true));
let result = pool
.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&config))
.await
.expect("bare getProgramAccounts fixture must succeed");
assert!(matches!(&result, crate::SolanaProgramAccountsResult::Accounts(_)), "bare fixture must preserve the bare account-list result");
if let crate::SolanaProgramAccountsResult::Accounts(accounts) = result {
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0].account().lamports(), 42);
}
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getProgramAccounts"));
assert_eq!(
body["params"][1]["filters"],
serde_json::json!([{"dataSize":3},{"memcmp":{"offset":0,"bytes":"AQID","encoding":"base64"}},"tokenAccountState"]),
);
assert_eq!(body["params"][1]["withContext"], serde_json::json!(false));
assert_eq!(body["params"][1]["sortResults"], serde_json::json!(true));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_program_accounts_preserves_contextual_result() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_program_accounts.context.success.json"));
let pool = pool_for_url(url.as_str());
let program_id = fixture_pubkey("11111111111111111111111111111111");
let config = crate::SolanaProgramAccountsConfig::new(
crate::SolanaAccountInfoConfig::default(),
std::vec::Vec::new(),
std::option::Option::Some(true),
std::option::Option::None,
);
let result = pool
.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&config))
.await
.expect("contextual getProgramAccounts fixture must succeed");
assert!(matches!(&result, crate::SolanaProgramAccountsResult::Context(_)), "contextual fixture must preserve RpcResponse wrapper");
if let crate::SolanaProgramAccountsResult::Context(response) = result {
assert_eq!(response.context().slot(), 410_000_007);
assert_eq!(response.value().len(), 1);
}
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"][1]["withContext"], serde_json::json!(true));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_program_accounts_rejects_filter_cardinality_and_oversized_raw_memcmp_before_io() {
let pool = pool_for_url("http://127.0.0.1:9");
let program_id = fixture_pubkey("11111111111111111111111111111111");
let too_many = crate::SolanaProgramAccountsConfig::new(
crate::SolanaAccountInfoConfig::default(),
std::vec![
crate::SolanaProgramAccountFilter::DataSize(1),
crate::SolanaProgramAccountFilter::DataSize(2),
crate::SolanaProgramAccountFilter::DataSize(3),
crate::SolanaProgramAccountFilter::DataSize(4),
crate::SolanaProgramAccountFilter::DataSize(5),
],
std::option::Option::None,
std::option::Option::None,
);
let result = pool.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&too_many)).await;
let error = result.expect_err("more than four program-account filters must be rejected locally");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].value(), "5");
let oversized = crate::SolanaProgramAccountsConfig::new(
crate::SolanaAccountInfoConfig::default(),
std::vec![crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(0, crate::SolanaMemcmpBytes::Bytes(std::vec![0_u8; 129]),))],
std::option::Option::None,
std::option::Option::None,
);
let result = pool.get_program_accounts(&crate::HttpRoleName::new("default"), &program_id, std::option::Option::Some(&oversized)).await;
let error = result.expect_err("raw memcmp data above 128 bytes must be rejected locally");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
assert_eq!(error.context()[1].value(), "129");
}
#[test]
fn typed_get_multiple_accounts_rejects_response_count_mismatch() {
let value = serde_json::json!({
"context":{"apiVersion":"4.2.1","slot":410000008},
"value":[]
});
let result = super::decode_multiple_accounts_response("getMultipleAccounts", value, 1);
let error = result.expect_err("response count mismatch must fail 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(), "1");
assert_eq!(error.context()[2].key(), "actual_count");
assert_eq!(error.context()[2].value(), "0");
}

335
deltas/0.2.2/pre.003.md Normal file
View File

@@ -0,0 +1,335 @@
<!-- file: deltas/0.2.2/pre.003.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.003` — cinq wrappers HTTP Accounts typés
## Base requise
Livraison précédente corrigée et validée localement par l'opérateur :
```text
0.2.2-pre.002-fix.002
workspace.package.version = "0.2.2-pre.2.fix.2"
```
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
85 unit tests
9 public API tests
2 release completeness tests
0 warning signalé par check/clippy
```
Le plan canonique reste `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md` version 3.
## Objectif
Clôturer la famille Accounts attribuée à `0.2.2` avec cinq wrappers publics typés :
```text
getAccountInfo
getLargestAccounts
getMinimumBalanceForRentExemption
getMultipleAccounts
getProgramAccounts
```
Tous passent par la foundation HTTP `0.2.1` :
```text
wrapper typé
-> descriptor audité
-> execute_standard_rpc
-> pool/admission/retry/deadline
-> reqwest HTTP
-> JSON-RPC validation
-> décodage DTO KSP
```
Aucun wrapper ne contacte `reqwest` directement.
## Version Cargo
Conformément à `VER-ID-009` :
```text
0.2.2-pre.2.fix.2 -> 0.2.2-pre.3
```
Aucune dépendance ni feature Cargo n'est ajoutée.
## Surface typée Accounts
### `getAccountInfo`
Signature publique conceptuelle :
```text
role + Pubkey + Option<SolanaAccountInfoConfig>
-> SolanaRpcResponse<Option<SolanaAccount>>
```
Le wrapper :
- sérialise `encoding`, `dataSlice`, `commitment` et `minContextSlot` ;
- omet l'objet config lorsqu'il est vide ;
- conserve l'absence du compte sous `None` ;
- conserve les formes Account data legacy, tuple encodé et `jsonParsed` ;
- convertit l'owner en `ksp_core_lib::Pubkey` sans recopier une valeur invalide dans le diagnostic.
### `getLargestAccounts`
Signature publique conceptuelle :
```text
role + Option<SolanaLargestAccountsConfig>
-> SolanaRpcResponse<Vec<SolanaAccountBalance>>
```
Le wrapper conserve l'ordre du wire et sérialise les champs KSP déjà audités dans `pre.002`, y compris `filter` et `sortResults` lorsqu'ils sont
explicitement renseignés.
### `getMinimumBalanceForRentExemption`
Signature publique conceptuelle :
```text
role + usize data_len + Option<SolanaCommitmentConfig>
-> u64 lamports
```
Le wrapper encode la longueur de données sans valeur sentinelle et conserve les erreurs JSON-RPC applicatives comme telles.
### `getMultipleAccounts`
Signature publique conceptuelle :
```text
role + &[Pubkey] + Option<SolanaAccountInfoConfig>
-> SolanaRpcResponse<Vec<Option<SolanaAccount>>>
```
Le wrapper :
- refuse localement plus de 100 public keys ;
- sérialise les adresses dans l'ordre fourni ;
- conserve les `null` individuels ;
- vérifie que le nombre d'éléments retournés correspond au nombre d'adresses demandées, afin de ne pas fabriquer silencieusement un mapping
positionnel incohérent.
### `getProgramAccounts`
Signature publique conceptuelle :
```text
role + program Pubkey + Option<SolanaProgramAccountsConfig>
-> SolanaProgramAccountsResult
```
Le résultat préserve les deux formes wire :
```text
Accounts(Vec<SolanaKeyedAccount>)
Context(SolanaRpcResponse<Vec<SolanaKeyedAccount>>)
```
Le wrapper sérialise `dataSize`, `memcmp`, `tokenAccountState`, `withContext`, `sortResults` et la config Account commune.
## Validation locale des paramètres
Nouveau code d'erreur public Transport :
```text
ERROR_CODE_INVALID_RPC_PARAMETERS
onchain_transport.invalid_rpc_parameters
```
Il distingue une requête KSP localement invalide d'une erreur JSON-RPC distante ou d'une réponse invalide.
Les invariants appliqués avant I/O sont :
```text
getMultipleAccounts : <= 100 public keys
getProgramAccounts : <= 4 filters
memcmp raw bytes : <= 128 bytes
```
Pour `memcmp` encodé sous forme base58/base64, KSP ne décode toujours pas localement la chaîne : la limite des 128 octets décodés reste donc
contrôlée par le serveur. Cette décision évite d'ajouter `bs58` ou `base64` uniquement pour une validation anticipée.
## Wire Account activé en production
Les helpers privés préparés par `pre.002` et réellement nécessaires à ces wrappers deviennent production-live :
- sérialisation des configs Account/Largest/Program ;
- conversion des encodings Account ;
- décodage `SolanaAccountData` ;
- décodage Account/KeyedAccount/AccountBalance ;
- conversion `Pubkey` wire ;
- construction interne de `SolanaRpcResponse<T>`.
Les helpers Cluster/Token préparatoires restent inchangés et ne deviennent pas production-live avant leurs tranches respectives.
## Fixtures HTTP déterministes ajoutées
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.null.json
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.invalid_owner.json
crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.invalid_address.json
crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_multiple_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.bare.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.context.success.json
```
Les tests locaux utilisent un serveur HTTP loopback et n'accèdent pas à Internet.
## Couverture de tests ajoutée
Les tests Accounts couvrent :
- request exacte pour chaque méthode ;
- config et omission de config vide ;
- succès typé ;
- account absent ;
- `null` dans `getMultipleAccounts` ;
- ordre de `getMultipleAccounts` ;
- résultat bare et contextualisé de `getProgramAccounts` ;
- invalid owner/address ;
- erreur JSON-RPC applicative ;
- limite des 100 comptes ;
- limite des 4 filtres ;
- limite raw `memcmp` de 128 octets ;
- mismatch cardinalité request/result de `getMultipleAccounts`.
Le test public compile explicitement les cinq méthodes sur `HttpTransportPool` et vérifie le nouveau code d'erreur.
Un canari release supplémentaire fige l'ensemble Accounts `0.2.2` exact :
```text
getAccountInfo
getLargestAccounts
getMinimumBalanceForRentExemption
getMultipleAccounts
getProgramAccounts
```
Il vérifie aussi que ces descriptors restent `Read + RetrySafe`. Aucune famille Tokens/Cluster n'est déclarée typed-complete par cette tranche.
Après application, la cible Transport attendue devient :
```text
98 unit tests
10 public API tests
3 release completeness tests
```
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.null.json
crates/ksp-onchain-transport-lib/fixtures/http/get_account_info.invalid_owner.json
crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_largest_accounts.invalid_address.json
crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_minimum_balance_for_rent_exemption.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_multiple_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.bare.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_program_accounts.context.success.json
deltas/0.2.2/pre.003.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/error.rs
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
CHANGELOG.md
ROADMAP.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/src/executor.rs
crates/ksp-onchain-transport-lib/src/rpc_method.rs
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-config-lib/**
config/**
```
La tranche suivante reste `pre.004 — cinq wrappers Tokens`.
## Validations exécutées
- prise en compte de la validation opérateur propre de `pre.002-fix.002` comme base ;
- relecture des règles `VERSION_WORKFLOW.md` et `RULES_RUST.md` applicables ;
- revérification de la documentation HTTP Solana actuelle pour les cinq méthodes Accounts ;
- recoupement des signatures/limites avec la source Agave `v4.2.1` auditée par le plan ;
- validation JSON syntaxique des dix nouvelles fixtures ;
- contrôle statique de l'absence de `unwrap`, `expect`, `panic!` et `#[allow(dead_code)]` dans le code de production modifié ;
- contrôle statique que les cinq wrappers utilisent `execute_standard_rpc` et aucun appel `reqwest` direct ;
- contrôle statique de l'absence de nouvelle dépendance/feature ;
- contrôle exact des fichiers inclus dans l'archive d'échange.
## Validations non exécutées
Le sandbox d'échange ne fournit pas `cargo`, `rustc` ou `rustfmt`. Les commandes suivantes doivent être exécutées sur le checkout opérateur avant
commit :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
`cargo test --workspace` reste réservé au checkpoint de clôture/session conformément au workflow KSP. Aucun `cargo tree` n'est requis par cette
tranche puisqu'aucune dépendance ni feature n'a changé.
## Décisions prises
- utiliser les DTOs `pre.002` sans créer de seconde couche RPC ;
- conserver `execute_standard_rpc()` comme unique entrée d'exécution HTTP standard ;
- introduire un code d'erreur dédié aux paramètres typed invalides plutôt que détourner `invalid_settings` ;
- vérifier localement les limites dont KSP peut connaître la violation sans dépendance de décodage supplémentaire ;
- préserver les `null`, l'ordre et la dualité bare/context au lieu de normaliser artificiellement les réponses ;
- ne pas modifier Config ;
- ne pas ajouter de logging spécifique par méthode.
## Questions ouvertes
Aucune question bloquante pour `pre.004`.
## Suite
`0.2.2-pre.004` : implémenter les cinq wrappers Tokens (`getTokenAccountBalance`, `getTokenAccountsByDelegate`, `getTokenAccountsByOwner`,
`getTokenLargestAccounts`, `getTokenSupply`) avec les DTOs préparés, selector `Mint | ProgramId`, fixtures HTTP déterministes et conservation de
`uiAmount: null`.