v0.2.3-pre.007

This commit is contained in:
2026-08-18 16:12:07 +02:00
parent fb32ed03f8
commit 704110d509
11 changed files with 782 additions and 19 deletions

View File

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

View File

@@ -0,0 +1,8 @@
{
"jsonrpc": "2.0",
"error": {
"code": -32602,
"message": "invalid transaction: failed to sanitize accounts offsets correctly"
},
"id": 1
}

View File

@@ -0,0 +1,18 @@
{
"jsonrpc": "2.0",
"result": {
"context": {
"slot": 431000031,
"apiVersion": "4.2.1"
},
"value": {
"err": null,
"logs": null,
"accounts": null,
"unitsConsumed": 99,
"returnData": null,
"replacementBlockhash": null
}
},
"id": 1
}

View File

@@ -0,0 +1,60 @@
{
"jsonrpc": "2.0",
"result": {
"context": {
"slot": 431000030,
"apiVersion": "4.2.1"
},
"value": {
"err": null,
"logs": [
"Program 11111111111111111111111111111111 invoke [1]",
"Program 11111111111111111111111111111111 success"
],
"accounts": [
{
"lamports": 1000,
"data": [
"",
"base64"
],
"owner": "11111111111111111111111111111111",
"executable": false,
"rentEpoch": 0,
"space": 0
},
null
],
"unitsConsumed": 150,
"loadedAccountsDataSize": 64,
"returnData": {
"programId": "11111111111111111111111111111111",
"data": [
"AQI=",
"base64"
]
},
"innerInstructions": [],
"replacementBlockhash": {
"blockhash": "ComputeBudget111111111111111111111111111111",
"lastValidBlockHeight": 430123999
},
"fee": 5000,
"preBalances": [
10000,
0
],
"postBalances": [
5000,
5000
],
"preTokenBalances": [],
"postTokenBalances": [],
"loadedAddresses": {
"writable": [],
"readonly": []
}
}
},
"id": 1
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 15
// version: 16
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -10,9 +10,9 @@
//! independent from `ksp-config-lib`, Store and Program layers. `ksp-config-lib` now constructs these public settings through its one-way Config ->
//! Transport adapter without creating a reverse dependency. Logical endpoint clients, priority-aware pools, bounded admission limits and retry/no-resend policy
//! are available. The four typed Solana HTTP foundation canaries plus all 22 typed `0.2.2` Accounts, Tokens and Cluster wrappers execute real JSON-RPC
//! requests through the shared transport path. `0.2.3` exposes its shared Transaction wire/config primitives, all eight read wrappers and both write
//! submissions through `pre.006`, including complete modern/legacy `getTransaction` coverage and centralized no-resend protection for writes.
//! `simulateTransaction` and the `0.2.4` family remain staged.
//! requests through the shared transport path. `0.2.3` exposes its shared Transaction wire/config primitives and all eleven Transaction wrappers through
//! `pre.007`: eight reads, two write submissions with centralized no-resend protection, and retry-safe `simulateTransaction`, including complete
//! modern/legacy `getTransaction` coverage. The `0.2.4` family remains staged.
mod client;
mod constants;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_transactions.rs
// version: 6
// version: 7
/// Binary encoding accepted for serialized transaction input payloads.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -452,7 +452,6 @@ impl SolanaSimulationAccountsConfig {
/// Serializes the nested account 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(encoding) = self.encoding {
@@ -545,7 +544,6 @@ impl SolanaSimulateTransactionConfig {
}
/// Returns whether the simulation config would serialize to an empty object.
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
return self.commitment.is_none()
&& self.encoding.is_none()
@@ -558,7 +556,6 @@ impl SolanaSimulateTransactionConfig {
/// 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 {
@@ -1050,7 +1047,6 @@ impl SolanaSimulateTransactionResult {
}
/// Decodes a simulation result while preserving optional/nullable current Agave fields.
#[cfg(test)]
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireSimulateTransactionResult>(method, value);
let wire = match decoded {
@@ -1357,6 +1353,65 @@ impl crate::HttpTransportPool {
};
}
/// Executes typed `simulateTransaction` through the common KSP HTTP transport path.
///
/// Transport forwards the already encoded transaction as opaque text. The wrapper enforces only deterministic RPC invariants that do not require
/// decoding transaction bytes: `sigVerify` cannot be combined with `replaceRecentBlockhash`, and simulation account-return encoding cannot use the
/// legacy `binary` / `base58` account encodings rejected by the targeted Agave runtime.
pub async fn simulate_transaction(
&self,
role: &crate::HttpRoleName,
transaction: &str,
config: std::option::Option<&crate::SolanaSimulateTransactionConfig>,
) -> ksp_core_lib::Result<crate::SolanaRpcResponse<crate::SolanaSimulateTransactionResult>> {
if let std::option::Option::Some(config) = config {
if config.sig_verify() == std::option::Option::Some(true) && config.replace_recent_blockhash() == std::option::Option::Some(true) {
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
"simulateTransaction sigVerify may not be used with replaceRecentBlockhash",
)
.with_context("rpc_method", "simulateTransaction"),
);
}
if let std::option::Option::Some(accounts) = config.accounts()
&& let std::option::Option::Some(encoding) = accounts.encoding()
&& (encoding == crate::SolanaAccountEncoding::Binary || encoding == crate::SolanaAccountEncoding::Base58)
{
return std::result::Result::Err(
ksp_core_lib::Error::new(
crate::ERROR_CODE_INVALID_RPC_PARAMETERS,
"simulateTransaction account-return encoding must be base64, base64+zstd, or jsonParsed",
)
.with_context("rpc_method", "simulateTransaction")
.with_context("accounts_encoding", encoding.as_str()),
);
}
}
let mut params = std::vec![serde_json::Value::String(transaction.to_owned())];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let value = self.execute_transaction_rpc("simulateTransaction", role, params).await;
let value = match value {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let contextual = decode_transaction_contextual_wire("simulateTransaction", value);
let (context, value) = match contextual {
std::result::Result::Ok(contextual) => contextual,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let result = crate::SolanaSimulateTransactionResult::decode_wire("simulateTransaction", value);
return match result {
std::result::Result::Ok(result) => std::result::Result::Ok(crate::SolanaRpcResponse::new(context, result)),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `getTransactionCount` through the common KSP HTTP transport path.
pub async fn get_transaction_count(
&self,
@@ -1612,7 +1667,6 @@ fn decode_transaction_version_field(
};
}
#[cfg(test)]
fn decode_simulation_accounts_field(
method: &str,
field: crate::SolanaWireField<std::vec::Vec<std::option::Option<serde_json::Value>>>,
@@ -1640,7 +1694,6 @@ fn decode_simulation_accounts_field(
return std::result::Result::Ok(crate::SolanaWireField::Value(accounts));
}
#[cfg(test)]
fn decode_replacement_blockhash_field(
method: &str,
field: crate::SolanaWireField<serde_json::Value>,
@@ -1716,7 +1769,6 @@ struct WireConfirmedTransaction {
transaction_index: crate::SolanaWireField<u32>,
}
#[cfg(test)]
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireSimulateTransactionResult {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 14
// version: 15
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -321,3 +321,29 @@ fn public_transaction_pre_006_write_wrappers_and_complete_configs_are_available_
assert_eq!(send.max_retries(), std::option::Option::Some(5));
assert_eq!(send.min_context_slot(), std::option::Option::Some(431_000_000));
}
#[test]
fn public_transaction_pre_007_simulation_wrapper_and_complete_config_are_available_from_crate_root() {
let _simulate_transaction = ksp_onchain_transport_lib::HttpTransportPool::simulate_transaction;
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let accounts = ksp_onchain_transport_lib::SolanaSimulationAccountsConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64Zstd),
std::vec![address],
);
let config = ksp_onchain_transport_lib::SolanaSimulateTransactionConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::Some(false),
std::option::Option::Some(true),
std::option::Option::Some(431_000_000),
std::option::Option::Some(true),
std::option::Option::Some(accounts),
);
assert_eq!(config.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed));
assert_eq!(config.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64));
assert_eq!(config.sig_verify(), std::option::Option::Some(true));
assert_eq!(
config.accounts().and_then(ksp_onchain_transport_lib::SolanaSimulationAccountsConfig::encoding),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::Base64Zstd),
);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 11
// version: 12
//! Release-level completeness canaries for the `0.2.1` HTTP foundation contract.
@@ -196,7 +196,7 @@ fn release_pre_007_v0_2_2_exact_set_is_complete_without_advancing_future_release
}
#[test]
fn release_v0_2_3_descriptor_set_and_safety_classes_are_exact_before_wrapper_completion() {
fn release_v0_2_3_descriptor_set_and_safety_classes_are_exact() {
let mut expected = std::vec![
"getFeeForMessage",
"getLatestBlockhash",
@@ -359,3 +359,44 @@ fn release_pre_006_transaction_subset_adds_both_write_submissions_without_advanc
assert_eq!(reads.len(), 8);
assert_eq!(writes.len(), 2);
}
#[test]
fn release_pre_007_completes_all_v0_2_3_transaction_wrappers_with_exact_safety_classes() {
let reads = std::vec![
"getFeeForMessage",
"getLatestBlockhash",
"getRecentPrioritizationFees",
"getSignaturesForAddress",
"getSignatureStatuses",
"getTransaction",
"getTransactionCount",
"isBlockhashValid",
];
let writes = std::vec!["requestAirdrop", "sendTransaction"];
let simulations = std::vec!["simulateTransaction"];
for method_name in &reads {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.007 read descriptor must exist");
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Read);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
for method_name in &writes {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.007 write descriptor must exist");
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::WriteSubmission);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NeverAfterDispatch);
}
for method_name in &simulations {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.007 simulation descriptor must exist");
assert_eq!(descriptor.category(), ksp_onchain_transport_lib::HttpRpcCategory::Transactions);
assert_eq!(descriptor.coverage_release(), ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3);
assert_eq!(descriptor.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Simulation);
assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe);
}
assert_eq!(reads.len(), 8);
assert_eq!(writes.len(), 2);
assert_eq!(simulations.len(), 1);
assert_eq!(reads.len() + writes.len() + simulations.len(), 11);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
// version: 7
// version: 8
#[test]
fn transaction_encoding_strings_match_current_and_legacy_wire_labels() {
@@ -391,6 +391,42 @@ fn serve_transaction_timeout_and_count(delay: std::time::Duration) -> (std::stri
return (format!("http://{address}"), handle);
}
fn serve_transaction_status_then_body(
status_line: &'static str,
body: &'static str,
) -> (std::string::String, std::thread::JoinHandle<(std::string::String, 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 first_stream, _) = listener.accept().expect("fixture server must accept first request");
let first_request = read_transaction_request(&mut first_stream);
let first_response = format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
std::io::Write::write_all(&mut first_stream, first_response.as_bytes()).expect("fixture first response must write");
listener.set_nonblocking(true).expect("fixture listener must become nonblocking");
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
return loop {
match listener.accept() {
std::result::Result::Ok((mut retry_stream, _)) => {
let retry_request = read_transaction_request(&mut retry_stream);
let retry_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 retry_stream, retry_response.as_bytes()).expect("fixture retry response must write");
break (first_request, retry_request);
},
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if std::time::Instant::now() >= deadline {
panic!("fixture server did not receive retry before deadline");
}
std::thread::sleep(std::time::Duration::from_millis(5));
},
std::result::Result::Err(error) => panic!("fixture listener failed while waiting for retry: {error}"),
}
};
});
return (format!("http://{address}"), handle);
}
fn unused_local_url() -> std::string::String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("unused-port probe must bind");
let address = listener.local_addr().expect("unused-port probe address must resolve");
@@ -1139,3 +1175,193 @@ async fn typed_write_wrappers_can_retry_when_connection_failure_proves_not_dispa
let request = handle.join().expect("secondary fixture server must join");
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("sendTransaction"));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_simulate_transaction_serializes_complete_config_and_preserves_rich_result() {
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(crate::SolanaAccountEncoding::Base64), std::vec![address]);
let config = crate::SolanaSimulateTransactionConfig::new(
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::Some(true),
std::option::Option::Some(false),
std::option::Option::Some(431_000_000),
std::option::Option::Some(true),
std::option::Option::Some(accounts),
);
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let response = pool
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-base64-transaction", std::option::Option::Some(&config))
.await
.expect("simulateTransaction rich fixture must succeed");
assert_eq!(response.context().slot(), 431_000_030);
assert_eq!(response.context().api_version(), std::option::Option::Some("4.2.1"));
assert!(response.value().err().is_null());
assert_eq!(response.value().units_consumed().value(), std::option::Option::Some(&150));
assert_eq!(response.value().loaded_accounts_data_size().value(), std::option::Option::Some(&64));
assert_eq!(response.value().fee().value(), std::option::Option::Some(&5_000));
assert_eq!(response.value().pre_balances().value(), std::option::Option::Some(&std::vec![10_000, 0]));
assert_eq!(response.value().post_balances().value(), std::option::Option::Some(&std::vec![5_000, 5_000]));
let returned_accounts = response.value().accounts().value().expect("simulation accounts must be present");
assert_eq!(returned_accounts.len(), 2);
assert!(returned_accounts[0].is_some());
assert!(returned_accounts[1].is_none());
let replacement = response.value().replacement_blockhash().value().expect("replacement blockhash must be present");
assert_eq!(replacement.blockhash(), "ComputeBudget111111111111111111111111111111");
assert_eq!(replacement.last_valid_block_height(), 430_123_999);
let request = handle.join().expect("fixture server must join");
let body = transaction_request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("simulateTransaction"));
assert_eq!(
body["params"],
serde_json::json!([
"opaque-base64-transaction",
{
"commitment":"confirmed",
"encoding":"base64",
"replaceRecentBlockhash":true,
"sigVerify":false,
"minContextSlot":431000000,
"innerInstructions":true,
"accounts":{"encoding":"base64","addresses":["11111111111111111111111111111111"]}
}
])
);
}
#[tokio::test(flavor = "current_thread")]
async fn typed_simulate_transaction_supports_base58_and_omits_empty_config() {
let base58 = crate::SolanaSimulateTransactionConfig::new(
std::option::Option::None,
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base58),
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
);
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json"));
let pool = transaction_pool_for_url(url.as_str());
let _ = pool
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-base58-transaction", std::option::Option::Some(&base58))
.await
.expect("simulateTransaction base58 fixture must succeed");
let request = handle.join().expect("fixture server must join");
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-base58-transaction", {"encoding":"base58"}]));
let empty = crate::SolanaSimulateTransactionConfig::default();
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json"));
let pool = transaction_pool_for_url(url.as_str());
let _ = pool
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-default-transaction", std::option::Option::Some(&empty))
.await
.expect("simulateTransaction empty config must canonicalize to omission");
let request = handle.join().expect("fixture server must join");
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["opaque-default-transaction"]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_simulate_transaction_supports_all_current_account_return_encodings() {
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let encodings = [crate::SolanaAccountEncoding::Base64, crate::SolanaAccountEncoding::Base64Zstd, crate::SolanaAccountEncoding::JsonParsed];
for encoding in encodings {
let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(encoding), std::vec![address]);
let config = crate::SolanaSimulateTransactionConfig::new(
std::option::Option::None,
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(accounts),
);
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json"));
let pool = transaction_pool_for_url(url.as_str());
let _ = pool
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::Some(&config))
.await
.expect("supported simulation account encoding must execute");
let request = handle.join().expect("fixture server must join");
let body = transaction_request_body(request.as_str());
assert_eq!(body["params"][1]["accounts"]["encoding"], serde_json::json!(encoding.as_str()));
}
}
#[tokio::test(flavor = "current_thread")]
async fn typed_simulate_transaction_rejects_deterministic_invalid_configs_before_io() {
let pool = transaction_pool_for_url(unused_local_url().as_str());
let conflicting = crate::SolanaSimulateTransactionConfig::new(
std::option::Option::None,
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::Some(true),
std::option::Option::Some(true),
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
);
let result = pool.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::Some(&conflicting)).await;
assert_eq!(result.expect_err("conflicting simulation flags must fail before I/O").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
let address = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let rejected_encodings = [crate::SolanaAccountEncoding::Binary, crate::SolanaAccountEncoding::Base58];
for encoding in rejected_encodings {
let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(encoding), std::vec![address]);
let config = crate::SolanaSimulateTransactionConfig::new(
std::option::Option::None,
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(accounts),
);
let result = pool.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::Some(&config)).await;
assert_eq!(result.expect_err("unsupported simulation account encoding must fail before I/O").code(), crate::ERROR_CODE_INVALID_RPC_PARAMETERS);
}
}
#[tokio::test(flavor = "current_thread")]
async fn typed_simulate_transaction_preserves_partial_null_and_omitted_result() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.partial.json"));
let pool = transaction_pool_for_url(url.as_str());
let response = pool
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None)
.await
.expect("partial simulateTransaction fixture must succeed");
handle.join().expect("fixture server must join");
assert!(response.value().err().is_null());
assert!(response.value().logs().is_null());
assert!(response.value().accounts().is_null());
assert_eq!(response.value().units_consumed().value(), std::option::Option::Some(&99));
assert!(response.value().loaded_accounts_data_size().is_omitted());
assert!(response.value().return_data().is_null());
assert!(response.value().inner_instructions().is_omitted());
assert!(response.value().replacement_blockhash().is_null());
assert!(response.value().fee().is_omitted());
assert!(response.value().loaded_addresses().is_omitted());
}
#[tokio::test(flavor = "current_thread")]
async fn typed_simulate_transaction_preserves_rpc_application_error() {
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/simulate_transaction.error.json"));
let pool = transaction_pool_for_url(url.as_str());
let result = pool.simulate_transaction(&crate::HttpRoleName::new("default"), "invalid-opaque-transaction", std::option::Option::None).await;
assert_eq!(result.expect_err("simulation RPC error must propagate").code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
handle.join().expect("fixture server must join");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_simulate_transaction_retries_temporary_http_failure_safely() {
let (url, handle) = serve_transaction_status_then_body("503 Service Unavailable", include_str!("../fixtures/http/simulate_transaction.partial.json"));
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 1);
let response = pool
.simulate_transaction(&crate::HttpRoleName::new("default"), "opaque-retry-safe-transaction", std::option::Option::None)
.await
.expect("retry-safe simulation must recover after temporary HTTP failure");
assert_eq!(response.value().units_consumed().value(), std::option::Option::Some(&99));
let (first, second) = handle.join().expect("retry fixture server must join");
assert_eq!(transaction_request_body(first.as_str())["method"], serde_json::json!("simulateTransaction"));
assert_eq!(transaction_request_body(second.as_str())["method"], serde_json::json!("simulateTransaction"));
}

313
deltas/0.2.3/pre.007.md Normal file
View File

@@ -0,0 +1,313 @@
<!-- file: deltas/0.2.3/pre.007.md -->
<!-- version: 1 -->
# Delta `0.2.3-pre.007` — `simulateTransaction` complet
## Base requise
Livraison précédente corrigée et validée localement par l'opérateur :
```text
0.2.3-pre.006-fix.001
workspace.package.version = "0.2.3-pre.6.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
175 unit tests
18 public API tests
12 release completeness tests
1 smoke Devnet ignored / opt-in
0 warning signalé par check/clippy
```
## Objectif
Activer la dernière méthode Transactions de `0.2.3` :
```text
simulateTransaction
```
Après cette tranche :
```text
11 / 11 wrappers Transactions exécutés
8 Read / RetrySafe
2 WriteSubmission / NeverAfterDispatch
1 Simulation / RetrySafe
```
La tranche ne décode ni ne construit une transaction Solana et n'ajoute aucune dépendance codec/SDK.
## Version Cargo
Conformément à `VER-ID-009` :
```text
0.2.3-pre.6.fix.1 -> 0.2.3-pre.7
```
Le header du `Cargo.toml` passe de `version: 127` à `version: 128`.
Aucune dépendance ni feature Cargo n'est ajoutée.
## Complétude `KSP-TRANSPORT-007`
La page Solana RPC courante documente pour `simulateTransaction` :
```text
transaction string
commitment
encoding = base58 | base64
replaceRecentBlockhash
sigVerify
minContextSlot
innerInstructions
accounts
```
La source primaire Agave `v4.2.1` confirme les mêmes champs dans `RpcSimulateTransactionConfig`, avec des booléens runtime par défaut à `false`.
KSP expose donc la config complète via `SolanaSimulateTransactionConfig`. Les booléens restent `Option<bool>` côté KSP afin de distinguer l'omission de
l'envoi explicite de `false` sans modifier la sémantique RPC.
Les deux encodings de transaction réellement acceptés sont couverts :
```text
base58
base64
```
La transaction reste une chaîne opaque : Transport ne tente pas de reproduire le décodage, la désérialisation ou la sanitization Agave.
## Sous-config `accounts`
Agave `v4.2.1` utilise `RpcSimulateTransactionAccountsConfig` avec :
```text
encoding: Option<UiAccountEncoding>
addresses: Vec<String>
```
La source runtime rejette explicitement les encodings Account legacy :
```text
binary
base58
```
KSP accepte donc les variantes actuellement compatibles avec ce retour :
```text
base64
base64+zstd
jsonParsed
```
Ces trois variantes sont exercées par tests HTTP. `binary` et `base58` sont rejetées avant I/O avec `ERROR_CODE_INVALID_RPC_PARAMETERS`.
Quand aucun encoding Account n'est fourni, KSP omet le champ et laisse le runtime appliquer son défaut `base64`.
Agave borne dynamiquement le nombre d'adresses demandées au nombre de comptes contenu dans la transaction. Comme KSP ne décode pas la transaction dans
cette release, cette limite reste volontairement côté runtime/provider au lieu d'introduire une validation partielle ou un codec transactionnel.
## Invariant `sigVerify` / `replaceRecentBlockhash`
Agave `v4.2.1` refuse explicitement :
```text
sigVerify = true
replaceRecentBlockhash = true
```
avec l'erreur de paramètres indiquant que `sigVerify` ne peut pas être utilisé avec `replaceRecentBlockhash`.
Cet invariant est déterministe et ne dépend pas du contenu encodé de la transaction. KSP le rejette donc avant toute sélection/exécution HTTP.
## Résultat riche et lossless
`SolanaSimulateTransactionResult` devient le décodeur production du résultat contextualisé.
Les champs courants Agave `v4.2.1` sont tous préservés :
```text
err
logs
accounts
unitsConsumed
loadedAccountsDataSize
returnData
innerInstructions
replacementBlockhash
fee
preBalances
postBalances
preTokenBalances
postTokenBalances
loadedAddresses
```
La page publique Solana résume seulement un sous-ensemble de ces champs. KSP conserve également les champs runtime supplémentaires afin de respecter
`KSP-TRANSPORT-007` et de ne pas perdre de données disponibles.
Les champs optionnels utilisent `SolanaWireField<T>` lorsque l'absence, le `null` explicite et la valeur concrète doivent rester distinguables :
```text
Omitted
Null
Value(T)
```
Les comptes retournés réutilisent `SolanaAccount` et conservent les `null` positionnels. Les sous-arbres Program/SPL riches (`returnData`, inner
instructions, token balances, loaded addresses) restent lossless via `serde_json::Value` aux frontières déjà prévues, sans imposer les crates
transaction-status à Transport.
## Retry de simulation
Le descriptor existant reste :
```text
RpcOperationKind::Simulation
TransportRetryClass::RetrySafe
```
Contrairement à `requestAirdrop` et `sendTransaction`, la simulation n'est pas soumise à la règle no-resend d'une écriture : elle ne diffuse pas la
transaction au cluster.
Un test end-to-end exerce réellement :
```text
simulate_transaction
-> descriptor central
-> execute_standard_rpc
-> HTTP 503
-> retry central
-> HTTP 200
-> résultat typé
```
Le test démontre donc que la classification `RetrySafe` est utilisée par le vrai chemin wrapper/executor et pas uniquement par une fonction pure de
policy.
## Tests ajoutés
Sept tests unitaires ajoutent :
```text
config complète + résultat riche
base58 + canonicalisation config vide
base64 / base64+zstd / jsonParsed pour accounts
rejet avant I/O du conflit sigVerify/replaceRecentBlockhash et de binary/base58 account encoding
préservation omitted/null/value d'un résultat partiel
préservation d'une erreur RPC applicative
retry sûr après HTTP 503 puis succès
```
Un test public vérifie la disponibilité du wrapper et de la config complète à la racine de crate.
Une canarie release vérifie la couverture finale exacte :
```text
8 Read / RetrySafe
2 WriteSubmission / NeverAfterDispatch
1 Simulation / RetrySafe
11 / 11 méthodes Transactions V0_2_3
```
Résultat attendu après application :
```text
182 unit tests
19 public API tests
13 release completeness tests
1 smoke Devnet ignored / opt-in
```
## Documentation synchronisée
`crates/ksp-onchain-transport-lib/src/lib.rs` indique désormais que les 11 wrappers Transactions sont actifs après `pre.007`.
`docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md` passe en version 6 et enregistre le résultat de la tranche.
Le planning reste :
```text
pre.008 audit rétroactif KSP-TRANSPORT-007 sur 0.2.1 -> 0.2.3 + remédiations éventuelles
pre.009 clôture finale documentaire/validation/smoke/prompt 0.2.4
```
`CHANGELOG.md` reste réservé à `0.2.3-rel.001`.
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_transactions.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
crates/ksp-onchain-transport-lib/tests/release_completeness.rs
docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md
```
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.success.json
crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.partial.json
crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction.error.json
deltas/0.2.3/pre.007.md
```
## Dépendances
Aucune dépendance ajoutée, supprimée ou modifiée.
En particulier, restent absentes :
```text
base64 direct dans Transport
bs58 direct dans Transport
wincode
solana-client
crate RPC SDK
transaction-status client types
```
## Validations à exécuter
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Aucune commande n'est déclarée réussie sans exécution réelle ou preuve opérateur.
## Suite
Après validation et commit attendu :
```text
v0.2.3-pre.007
```
passer à :
```text
0.2.3-pre.008
réaudit rétroactif KSP-TRANSPORT-007 sur les wrappers HTTP 0.2.1 -> 0.2.3
+ remédiations éventuelles avant la clôture
```

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md -->
<!-- version: 5 -->
<!-- version: 6 -->
# Plan `0.2.3` — HTTP Transactions
@@ -725,3 +725,22 @@ La tranche suivante reste `pre.003` : `getFeeForMessage`, `getLatestBlockhash`,
La prévision est ajustée : `pre.008` devient l'audit rétroactif `KSP-TRANSPORT-007` et la clôture finale est déplacée à `pre.009`.
## Résultat de `pre.007`
`pre.007` active la dernière méthode Transaction, `simulateTransaction`, et porte donc la couverture typée `0.2.3` à **11/11** :
- transaction d'entrée conservée comme chaîne opaque, sans dépendance codec/SDK ;
- encodings d'entrée `base58` et `base64` couverts ;
- config complète conservée : `commitment`, `encoding`, `replaceRecentBlockhash`, `sigVerify`, `minContextSlot`, `innerInstructions`, `accounts` ;
- sous-config `accounts` capable d'émettre `base64`, `base64+zstd` et `jsonParsed`, avec rejet local de `binary/base58` conformément à Agave `v4.2.1` ;
- incompatibilité déterministe `sigVerify=true` + `replaceRecentBlockhash=true` rejetée avant I/O ;
- limite dynamique du nombre d'adresses `accounts` laissée au runtime puisque KSP ne décode pas la transaction ;
- résultat riche préservé losslessly, y compris les champs Agave `v4.2.1` au-delà du résumé documentaire public : `fee`, balances, token balances et loaded addresses ;
- `omitted/null/value` conservés pour les champs optionnels avec `SolanaWireField<T>` ;
- accounts de simulation décodés via le wire Account KSP existant, avec `null` positionnels conservés ;
- erreur RPC applicative conservée sans transformation en erreur transport ;
- descriptor central `Simulation / RetrySafe` exercé end-to-end par un scénario HTTP 503 puis succès, démontrant que la simulation peut être retentée ;
- canarie release exacte `8 Read / 2 WriteSubmission / 1 Simulation`, soit les 11 méthodes `V0_2_3`.
Aucune dépendance n'est ajoutée. Le travail fonctionnel initialement planifié pour les 11 Transactions est désormais implémenté ; `pre.008` reste volontairement
réservée au réaudit rétroactif `KSP-TRANSPORT-007` des wrappers HTTP `0.2.1` -> `0.2.3`, puis `pre.009` à la clôture.