v0.2.2-pre.004

This commit is contained in:
2026-08-18 08:09:20 +02:00
parent c4636ac8b9
commit 1d1bc6a4d6
16 changed files with 814 additions and 16 deletions

View File

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

View File

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

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000001},"value":{"amount":"18446744073709551615","decimals":9,"uiAmount":null,"uiAmountString":"18446744073.709551615"}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000002},"value":[{"pubkey":"ComputeBudget111111111111111111111111111111","account":{"data":{"program":"spl-token","parsed":{"type":"account","info":{"tokenAmount":{"amount":"1","decimals":1,"uiAmount":0.1,"uiAmountString":"0.1"}}},"space":165},"executable":false,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":9,"space":165}}]},"id":1}

View File

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

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":null,"slot":420000003},"value":[{"pubkey":"Stake11111111111111111111111111111111111111","account":{"data":["AQID","base64"],"executable":false,"lamports":2039280,"owner":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA","rentEpoch":10,"space":165}}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"slot":420000006},"value":[{"address":"invalid-address","amount":"1","decimals":0,"uiAmount":1.0,"uiAmountString":"1"}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000005},"value":[{"address":"11111111111111111111111111111111","amount":"9000","decimals":2,"uiAmount":90.0,"uiAmountString":"90"},{"address":"ComputeBudget111111111111111111111111111111","amount":"8000","decimals":2,"uiAmount":80.0,"uiAmountString":"80"}]},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":{"context":{"apiVersion":"4.2.1","slot":420000007},"value":{"amount":"1000000000000000000000000","decimals":6,"uiAmount":1000000000000000000.0,"uiAmountString":"1000000000000000000"}},"id":1}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 9
// version: 10
#![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 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.
//! are available. The four typed Solana HTTP foundation canaries plus the `0.2.2` Accounts and Tokens 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;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
// version: 3
// version: 4
/// Account-data encoding accepted by Solana HTTP account methods.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -116,7 +116,7 @@ impl SolanaAccountInfoConfig {
return self.context.min_context_slot();
}
fn is_empty(&self) -> bool {
pub(crate) 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();
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
// version: 2
// version: 3
/// Exclusive selector accepted by token-account list RPC methods.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -13,7 +13,6 @@ pub enum SolanaTokenAccountSelector {
impl SolanaTokenAccountSelector {
/// Serializes the exclusive selector to the Solana JSON-RPC wire object.
#[must_use]
#[cfg(test)]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::Mint(pubkey) => serde_json::json!({"mint": pubkey.to_string()}),
@@ -57,7 +56,6 @@ impl SolanaTokenAmount {
}
/// Decodes one token amount 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::<WireTokenAmount>(method, value);
return match decoded {
@@ -93,7 +91,6 @@ impl SolanaTokenAccountBalance {
}
/// Decodes one token-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::<WireTokenAccountBalance>(method, value);
let wire = match decoded {
@@ -115,7 +112,6 @@ impl SolanaTokenAccountBalance {
}
}
#[cfg(test)]
#[derive(serde::Deserialize)]
struct WireTokenAmount {
amount: std::string::String,
@@ -126,7 +122,6 @@ struct WireTokenAmount {
ui_amount_string: std::string::String,
}
#[cfg(test)]
#[derive(serde::Deserialize)]
struct WireTokenAccountBalance {
address: std::string::String,
@@ -138,6 +133,222 @@ struct WireTokenAccountBalance {
ui_amount_string: std::string::String,
}
#[derive(serde::Deserialize)]
struct WireRpcResponse<T> {
context: serde_json::Value,
value: T,
}
impl crate::HttpTransportPool {
/// Executes typed `getTokenAccountBalance` through the common KSP HTTP transport path.
pub async fn get_token_account_balance(
&self,
role: &crate::HttpRoleName,
token_account: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
let method_result = token_descriptor("getTokenAccountBalance");
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(token_account.to_string())];
push_commitment_config(&mut params, config);
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_token_amount_response("getTokenAccountBalance", value);
}
/// Executes typed `getTokenAccountsByDelegate` through the common KSP HTTP transport path.
pub async fn get_token_accounts_by_delegate(
&self,
role: &crate::HttpRoleName,
delegate: &ksp_core_lib::Pubkey,
selector: &crate::SolanaTokenAccountSelector,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
return self.get_token_accounts_list("getTokenAccountsByDelegate", role, delegate, selector, config).await;
}
/// Executes typed `getTokenAccountsByOwner` through the common KSP HTTP transport path.
pub async fn get_token_accounts_by_owner(
&self,
role: &crate::HttpRoleName,
owner: &ksp_core_lib::Pubkey,
selector: &crate::SolanaTokenAccountSelector,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
return self.get_token_accounts_list("getTokenAccountsByOwner", role, owner, selector, config).await;
}
/// Executes typed `getTokenLargestAccounts` through the common KSP HTTP transport path.
pub async fn get_token_largest_accounts(
&self,
role: &crate::HttpRoleName,
mint: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaTokenAccountBalance>>> {
let method_result = token_descriptor("getTokenLargestAccounts");
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(mint.to_string())];
push_commitment_config(&mut params, config);
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_token_account_balances_response("getTokenLargestAccounts", value);
}
/// Executes typed `getTokenSupply` through the common KSP HTTP transport path.
pub async fn get_token_supply(
&self,
role: &crate::HttpRoleName,
mint: &ksp_core_lib::Pubkey,
config: std::option::Option<&crate::SolanaCommitmentConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
let method_result = token_descriptor("getTokenSupply");
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(mint.to_string())];
push_commitment_config(&mut params, config);
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_token_amount_response("getTokenSupply", value);
}
async fn get_token_accounts_list(
&self,
method_name: &'static str,
role: &crate::HttpRoleName,
address: &ksp_core_lib::Pubkey,
selector: &crate::SolanaTokenAccountSelector,
config: std::option::Option<&crate::SolanaAccountInfoConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
let method_result = token_descriptor(method_name);
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(address.to_string()), selector.to_json_value()];
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_keyed_accounts_response(method_name, value);
}
}
fn token_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::Tokens && 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 Tokens descriptor is missing from the audited 0.2.2 registry")
.with_context("rpc_method", method),
),
};
}
fn push_commitment_config(params: &mut std::vec::Vec<serde_json::Value>, config: std::option::Option<&crate::SolanaCommitmentConfig>) {
if let std::option::Option::Some(config) = config
&& config.commitment().is_some()
{
params.push(config.to_json_value());
}
return;
}
fn decode_token_amount_response(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaTokenAmount>> {
let decoded = crate::decode_wire_json::<WireRpcResponse<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 amount = crate::SolanaTokenAmount::decode_wire(method, wire.value);
let amount = match amount {
std::result::Result::Ok(amount) => amount,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, amount));
}
fn decode_keyed_accounts_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>> {
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 accounts = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
let account = crate::SolanaKeyedAccount::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => accounts.push(account),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, accounts));
}
fn decode_token_account_balances_response(
method: &str,
value: serde_json::Value,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaTokenAccountBalance>>> {
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 accounts = std::vec::Vec::with_capacity(wire.value.len());
for value in wire.value {
let account = crate::SolanaTokenAccountBalance::decode_wire(method, value);
match account {
std::result::Result::Ok(account) => accounts.push(account),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(crate::SolanaRpcResponse::new(context, accounts));
}
#[cfg(test)]
#[path = "../unit_tests/rpc_tokens.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 6
// version: 7
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -209,3 +209,15 @@ fn public_pre_003_account_wrappers_are_available_from_crate_root() {
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");
}
#[test]
fn public_pre_004_token_wrappers_are_available_from_crate_root() {
let _get_token_account_balance = ksp_onchain_transport_lib::HttpTransportPool::get_token_account_balance;
let _get_token_accounts_by_delegate = ksp_onchain_transport_lib::HttpTransportPool::get_token_accounts_by_delegate;
let _get_token_accounts_by_owner = ksp_onchain_transport_lib::HttpTransportPool::get_token_accounts_by_owner;
let _get_token_largest_accounts = ksp_onchain_transport_lib::HttpTransportPool::get_token_largest_accounts;
let _get_token_supply = ksp_onchain_transport_lib::HttpTransportPool::get_token_supply;
let mint = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let selector = ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(mint);
assert!(matches!(selector, ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(_)));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 2
// version: 3
//! Release-level completeness canaries for the `0.2.1` HTTP foundation contract.
@@ -65,3 +65,22 @@ fn release_pre_003_accounts_subset_is_exact_without_advancing_other_v0_2_2_famil
std::vec!["getAccountInfo", "getLargestAccounts", "getMinimumBalanceForRentExemption", "getMultipleAccounts", "getProgramAccounts",],
);
}
#[test]
fn release_pre_004_tokens_subset_is_exact_and_retry_safe() {
let mut token_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::Tokens
{
token_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);
}
}
token_names.sort_unstable();
assert_eq!(
token_names,
std::vec!["getTokenAccountBalance", "getTokenAccountsByDelegate", "getTokenAccountsByOwner", "getTokenLargestAccounts", "getTokenSupply",],
);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs
// version: 2
// version: 3
#[test]
fn token_selector_is_exclusive_by_construction() {
@@ -31,3 +31,227 @@ fn staged_token_account_balance_helper_preserves_address_and_amount() {
assert_eq!(balance.address().to_string(), "11111111111111111111111111111111");
assert_eq!(balance.amount().amount(), "10");
}
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_token_account_balance_serializes_commitment_and_preserves_nullable_ui_amount() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_account_balance.success.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
let response = pool
.get_token_account_balance(&crate::HttpRoleName::new("default"), &account, std::option::Option::Some(&config))
.await
.expect("token balance fixture must succeed");
assert_eq!(response.context().slot(), 420_000_001);
assert_eq!(response.value().amount(), "18446744073709551615");
assert_eq!(response.value().ui_amount(), std::option::Option::None);
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getTokenAccountBalance"));
assert_eq!(body["params"], serde_json::json!([account.to_string(),{"commitment":"finalized"}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_account_balance_preserves_rpc_application_error() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_account_balance.error.json"));
let pool = pool_for_url(url.as_str());
let account = fixture_pubkey("11111111111111111111111111111111");
let result = pool.get_token_account_balance(&crate::HttpRoleName::new("default"), &account, std::option::Option::None).await;
let error = result.expect_err("remote invalid token account 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_token_accounts_by_delegate_serializes_program_selector_and_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_accounts_by_delegate.success.json"));
let pool = pool_for_url(url.as_str());
let delegate = fixture_pubkey("11111111111111111111111111111111");
let program_id = fixture_pubkey("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
let selector = crate::SolanaTokenAccountSelector::ProgramId(program_id);
let config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::JsonParsed),
std::option::Option::None,
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(420_000_000),
);
let response = pool
.get_token_accounts_by_delegate(&crate::HttpRoleName::new("default"), &delegate, &selector, std::option::Option::Some(&config))
.await
.expect("delegate token accounts fixture must succeed");
assert_eq!(response.context().slot(), 420_000_002);
assert_eq!(response.value().len(), 1);
assert!(matches!(response.value()[0].account().data(), 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!("getTokenAccountsByDelegate"));
assert_eq!(body["params"][0], serde_json::json!(delegate.to_string()));
assert_eq!(body["params"][1], serde_json::json!({"programId":"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"}));
assert_eq!(body["params"][2], serde_json::json!({"encoding":"jsonParsed","commitment":"finalized","minContextSlot":420000000}));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_accounts_by_owner_serializes_mint_selector_and_omits_empty_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_accounts_by_owner.success.json"));
let pool = pool_for_url(url.as_str());
let owner = fixture_pubkey("11111111111111111111111111111111");
let mint = fixture_pubkey("ComputeBudget111111111111111111111111111111");
let selector = crate::SolanaTokenAccountSelector::Mint(mint);
let config = crate::SolanaAccountInfoConfig::default();
let response = pool
.get_token_accounts_by_owner(&crate::HttpRoleName::new("default"), &owner, &selector, std::option::Option::Some(&config))
.await
.expect("owner token accounts fixture must succeed");
assert_eq!(response.context().api_version(), std::option::Option::None);
assert_eq!(response.value().len(), 1);
assert_eq!(response.value()[0].pubkey().to_string(), "Stake11111111111111111111111111111111111111");
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getTokenAccountsByOwner"));
assert_eq!(body["params"], serde_json::json!([owner.to_string(),{"mint":"ComputeBudget111111111111111111111111111111"}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_accounts_by_owner_rejects_invalid_account_pubkey_response() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_accounts_by_owner.invalid_pubkey.json"));
let pool = pool_for_url(url.as_str());
let owner = fixture_pubkey("11111111111111111111111111111111");
let mint = fixture_pubkey("ComputeBudget111111111111111111111111111111");
let selector = crate::SolanaTokenAccountSelector::Mint(mint);
let result = pool.get_token_accounts_by_owner(&crate::HttpRoleName::new("default"), &owner, &selector, std::option::Option::None).await;
let error = result.expect_err("invalid account pubkey must reject the typed response");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_largest_accounts_decodes_order_and_commitment() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_largest_accounts.success.json"));
let pool = pool_for_url(url.as_str());
let mint = fixture_pubkey("11111111111111111111111111111111");
let config = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed));
let response = pool
.get_token_largest_accounts(&crate::HttpRoleName::new("default"), &mint, std::option::Option::Some(&config))
.await
.expect("largest token accounts fixture must succeed");
assert_eq!(response.value().len(), 2);
assert_eq!(response.value()[0].amount().amount(), "9000");
assert_eq!(response.value()[1].amount().amount(), "8000");
let request = handle.join().expect("fixture server must join");
assert_eq!(request_body(request.as_str())["params"], serde_json::json!([mint.to_string(),{"commitment":"confirmed"}]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_largest_accounts_rejects_invalid_address() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_largest_accounts.invalid_address.json"));
let pool = pool_for_url(url.as_str());
let mint = fixture_pubkey("11111111111111111111111111111111");
let result = pool.get_token_largest_accounts(&crate::HttpRoleName::new("default"), &mint, std::option::Option::None).await;
let error = result.expect_err("invalid token-account address must reject the typed response");
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_get_token_supply_preserves_exact_amount_and_omits_absent_config() {
let (url, handle) = serve_once(include_str!("../fixtures/http/get_token_supply.success.json"));
let pool = pool_for_url(url.as_str());
let mint = fixture_pubkey("11111111111111111111111111111111");
let response = pool
.get_token_supply(&crate::HttpRoleName::new("default"), &mint, std::option::Option::None)
.await
.expect("token supply fixture must succeed");
assert_eq!(response.context().slot(), 420_000_007);
assert_eq!(response.value().amount(), "1000000000000000000000000");
assert_eq!(response.value().ui_amount_string(), "1000000000000000000");
let request = handle.join().expect("fixture server must join");
let body = request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("getTokenSupply"));
assert_eq!(body["params"], serde_json::json!([mint.to_string()]));
}

324
deltas/0.2.2/pre.004.md Normal file
View File

@@ -0,0 +1,324 @@
<!-- file: deltas/0.2.2/pre.004.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.004` — cinq wrappers HTTP Tokens typés
## Base requise
Livraison précédente corrigée et validée localement par l'opérateur :
```text
0.2.2-pre.003-fix.001
workspace.package.version = "0.2.2-pre.3.fix.1"
```
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
98 unit tests
10 public API tests
3 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 Tokens attribuée à `0.2.2` avec cinq wrappers publics typés :
```text
getTokenAccountBalance
getTokenAccountsByDelegate
getTokenAccountsByOwner
getTokenLargestAccounts
getTokenSupply
```
Tous passent par la foundation HTTP commune :
```text
wrapper typé
-> descriptor audité
-> execute_standard_rpc
-> pool/admission/retry/deadline
-> reqwest HTTP
-> JSON-RPC validation
-> décodage DTO KSP
```
Aucun wrapper Token ne contacte `reqwest` directement et aucune crate SPL n'est ajoutée.
## Version Cargo
Conformément à `VER-ID-009` :
```text
0.2.2-pre.3.fix.1 -> 0.2.2-pre.4
```
Aucune dépendance ni feature Cargo n'est ajoutée ou retirée.
## Réaudit ciblé du contrat courant
Les pages RPC Solana courantes ont été revérifiées le 2026-08-18 pour cette tranche :
```text
https://solana.com/docs/rpc/http/gettokenaccountbalance
https://solana.com/docs/rpc/http/gettokenaccountsbydelegate
https://solana.com/docs/rpc/http/gettokenaccountsbyowner
https://solana.com/docs/rpc/http/gettokenlargestaccounts
https://solana.com/docs/rpc/http/gettokensupply
```
Le contrat reste cohérent avec le plan `009` :
- `getTokenAccountBalance`, `getTokenLargestAccounts` et `getTokenSupply` acceptent un `commitment` optionnel ;
- `getTokenAccountsByDelegate` et `getTokenAccountsByOwner` prennent un selector exclusif `{mint}` ou `{programId}` puis une config Account optionnelle ;
- les cinq réponses sont contextualisées ;
- les deux méthodes de liste renvoient des `pubkey + account` et réutilisent donc le DTO Account générique ;
- `TokenAmount.uiAmount` reste nullable ; `uiAmountString` reste la représentation textuelle à préserver ;
- `getTokenLargestAccounts` renvoie les 20 plus gros token accounts côté RPC, sans que KSP invente un tri supplémentaire.
Le réaudit complémentaire Agave `v4.2.1` déjà fixé en `pre.001-fix.001` reste la référence d'implémentation ciblée ; aucune divergence
nouvelle nécessitant un changement de DTO n'a été identifiée pour cette tranche.
## Surface typée Tokens
### `getTokenAccountBalance`
Signature publique conceptuelle :
```text
role + token account Pubkey + Option<SolanaCommitmentConfig>
-> SolanaRpcResponse<SolanaTokenAmount>
```
Le wrapper préserve `amount`, `decimals`, `uiAmount` nullable et `uiAmountString`, et conserve une erreur JSON-RPC distante comme erreur
applicative RPC.
### `getTokenAccountsByDelegate`
Signature publique conceptuelle :
```text
role + delegate Pubkey + SolanaTokenAccountSelector + Option<SolanaAccountInfoConfig>
-> SolanaRpcResponse<Vec<SolanaKeyedAccount>>
```
Le selector KSP empêche par construction les objets contenant simultanément `mint` et `programId`. La config Account réutilise les encodings,
`dataSlice`, `commitment` et `minContextSlot` activés en `pre.003`.
### `getTokenAccountsByOwner`
Signature publique conceptuelle :
```text
role + owner Pubkey + SolanaTokenAccountSelector + Option<SolanaAccountInfoConfig>
-> SolanaRpcResponse<Vec<SolanaKeyedAccount>>
```
Le wrapper partage le même chemin privé de sérialisation/décodage que la variante Delegate afin d'éviter deux implémentations de wire parallèles.
Un config Account explicitement vide est omis du tableau `params`.
### `getTokenLargestAccounts`
Signature publique conceptuelle :
```text
role + mint Pubkey + Option<SolanaCommitmentConfig>
-> SolanaRpcResponse<Vec<SolanaTokenAccountBalance>>
```
L'ordre retourné par le serveur est conservé. Chaque `address` est validée et convertie en `ksp_core_lib::Pubkey` sans recopier une valeur wire
invalide dans le diagnostic.
### `getTokenSupply`
Signature publique conceptuelle :
```text
role + mint Pubkey + Option<SolanaCommitmentConfig>
-> SolanaRpcResponse<SolanaTokenAmount>
```
`amount` reste une chaîne exacte et KSP ne convertit pas la supply en type métier SPL ni en entier borné localement.
## Mutualisation activée en production
Les helpers Token préparés en `pre.002` deviennent production-live uniquement parce qu'ils ont maintenant des consommateurs runtime réels :
- sérialisation de `SolanaTokenAccountSelector` ;
- décodage `SolanaTokenAmount` ;
- décodage `SolanaTokenAccountBalance` ;
- wire structs Token privés.
`SolanaAccountInfoConfig::is_empty` devient `pub(crate)` pour permettre aux deux wrappers de liste Token de réutiliser la règle d'omission de
config vide déjà utilisée par Accounts. Cette modification reste interne à la crate et n'élargit pas l'API publique externe.
Les helpers Cluster préparatoires restent inchangés et ne deviennent pas production-live avant `pre.005`/`pre.006`.
## Validation locale des paramètres
Aucun nouveau code d'erreur local n'est nécessaire dans cette tranche :
- le selector `Mint | ProgramId` garantit exactement une variante par construction ;
- les owner/delegate/mint/token-account sont des `Pubkey` typées avant l'appel ;
- les configs Account/Commitment réutilisent les types déjà validés ;
- aucune cardinalité supplémentaire documentée ne doit être imposée côté client.
`ERROR_CODE_INVALID_RPC_PARAMETERS` introduit en `pre.003` reste inchangé.
## Fixtures HTTP déterministes ajoutées
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_token_account_balance.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_account_balance.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_delegate.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_owner.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_owner.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_largest_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_largest_accounts.invalid_address.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_supply.success.json
```
Les tests utilisent un serveur HTTP loopback et n'accèdent pas à Internet.
## Couverture de tests ajoutée
Les tests Token couvrent notamment :
- request exacte de chacune des cinq méthodes ;
- selector `programId` pour Delegate ;
- selector `mint` pour Owner ;
- config Account complète et omission d'un config explicitement vide ;
- commitment optionnel ;
- `uiAmount: null` ;
- montant/supply textuel exact ;
- conservation de l'ordre pour `getTokenLargestAccounts` ;
- décodage `jsonParsed` des accounts sous forme JSON opaque Transport ;
- erreur JSON-RPC applicative ;
- rejet d'une `pubkey` ou `address` invalide dans une réponse typée.
Le test public compile explicitement les cinq méthodes depuis `HttpTransportPool`.
Un canari release fige l'ensemble Tokens `0.2.2` exact :
```text
getTokenAccountBalance
getTokenAccountsByDelegate
getTokenAccountsByOwner
getTokenLargestAccounts
getTokenSupply
```
Il vérifie également `Read + RetrySafe` pour chacun. Les 12 wrappers Cluster restent hors de cette tranche.
Après application, la cible Transport attendue devient :
```text
106 unit tests
11 public API tests
4 release completeness tests
```
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/fixtures/http/get_token_account_balance.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_account_balance.error.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_delegate.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_owner.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_accounts_by_owner.invalid_pubkey.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_largest_accounts.success.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_largest_accounts.invalid_address.json
crates/ksp-onchain-transport-lib/fixtures/http/get_token_supply.success.json
deltas/0.2.2/pre.004.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.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
deltas/0.2.2/pre.003.md
deltas/0.2.2/pre.003-fix.001.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
ROADMAP.md
CHANGELOG.md
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-onchain-transport-lib/src/rpc_method.rs
crates/ksp-config-lib/**
config/**
```
Les deltas déjà publiés restent des traces historiques et ne sont pas réécrits.
## Contrôles statiques effectués pendant la préparation
- `workspace.package.version` vaut `0.2.2-pre.4` ;
- les huit nouvelles fixtures JSON sont syntaxiquement valides ;
- les cinq wrappers utilisent `execute_standard_rpc` ;
- aucun `reqwest` direct n'est ajouté dans `rpc_tokens.rs` ;
- aucun `#[allow(dead_code)]` ou suppression de lint n'est ajouté ;
- aucun helper Cluster n'est activé en production ;
- aucune dépendance ou feature Cargo n'est ajoutée ;
- aucune crate SPL n'est importée ;
- la surface publique Accounts de `pre.003` reste présente ;
- les deltas historiques restent inchangés.
## Validations Rust non exécutées dans le sandbox
Le sandbox de préparation ne fournit pas la toolchain Cargo utilisée par le checkout opérateur. Les commandes suivantes doivent donc être rejouées
après application :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Les validations ne sont pas déclarées réussies tant qu'elles n'ont pas été exécutées sur le checkout opérateur.
## Questions ouvertes
Aucune question bloquante.
## Suite
Après validation et commit de `0.2.2-pre.004`, poursuivre avec `0.2.2-pre.005` pour les sept wrappers Cluster simples :
```text
getClusterNodes
getEpochInfo
getEpochSchedule
getHighestSnapshotSlot
getIdentity
getMaxRetransmitSlot
getMaxShredInsertSlot
```