v0.2.3-pre.006

This commit is contained in:
2026-08-18 12:35:29 +02:00
parent 1e1bc0d421
commit 4439589f0d
12 changed files with 728 additions and 39 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 125
# version: 126
[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.5"
version = "0.2.3-pre.6"
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: supplied blockhash is not valid"},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"airdrop-fixture-signature-111111111111111111111111111111111111111111111111","id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","error":{"code":-32002,"message":"Transaction simulation failed: Error processing Instruction 0","data":{"err":{"InstructionError":[0,"Custom"]},"logs":["Program log: fixture preflight failure"],"unitsConsumed":123}},"id":1}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","result":"send-fixture-signature-22222222222222222222222222222222222222222222222222","id":1}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 14
// version: 15
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -10,10 +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 and seven read wrappers through `pre.004`,
//! including bounded prioritization-fee, address-signature and signature-status queries. `getTransaction`, both write submissions and
//! `simulateTransaction`, plus the
//! `0.2.4` family remain staged.
//! 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.
mod client;
mod constants;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_transactions.rs
// version: 5
// version: 6
/// Binary encoding accepted for serialized transaction input payloads.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
@@ -321,14 +321,12 @@ impl SolanaRequestAirdropConfig {
}
/// Returns whether the airdrop config would serialize to an empty object.
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
return self.recent_blockhash.is_none() && self.commitment.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(blockhash) = self.recent_blockhash.as_ref() {
@@ -395,7 +393,6 @@ impl SolanaSendTransactionConfig {
}
/// Returns whether the send config would serialize to an empty object.
#[cfg(test)]
pub(crate) const fn is_empty(&self) -> bool {
return self.skip_preflight.is_none()
&& self.preflight_commitment.is_none()
@@ -406,7 +403,6 @@ impl SolanaSendTransactionConfig {
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
#[cfg(test)]
pub(crate) fn to_json_value(self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(value) = self.skip_preflight {
@@ -1314,6 +1310,53 @@ impl crate::HttpTransportPool {
};
}
/// Executes typed `requestAirdrop` through the common KSP HTTP transport path.
///
/// The RPC creates and submits a faucet transaction, so its audited descriptor is `WriteSubmission / NeverAfterDispatch`. The optional
/// `recentBlockhash` field is retained from the targeted Agave runtime even though the public Solana page currently documents only `commitment`.
pub async fn request_airdrop(
&self,
role: &crate::HttpRoleName,
recipient: &ksp_core_lib::Pubkey,
lamports: u64,
config: std::option::Option<&crate::SolanaRequestAirdropConfig>,
) -> ksp_core_lib::Result<std::string::String> {
let mut params = std::vec![serde_json::Value::String(recipient.to_string()), serde_json::json!(lamports)];
if let std::option::Option::Some(config) = config
&& !config.is_empty()
{
params.push(config.to_json_value());
}
let value = self.execute_transaction_rpc("requestAirdrop", role, params).await;
return match value {
std::result::Result::Ok(value) => crate::decode_wire_json::<std::string::String>("requestAirdrop", value),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
/// Executes typed `sendTransaction` through the common KSP HTTP transport path.
///
/// Transport forwards an already serialized and signed transaction without decoding or modifying it. `config.maxRetries` controls node-side
/// retransmission only; KSP's HTTP retry policy remains governed by the central `WriteSubmission / NeverAfterDispatch` descriptor.
pub async fn send_transaction(
&self,
role: &crate::HttpRoleName,
transaction: &str,
config: std::option::Option<&crate::SolanaSendTransactionConfig>,
) -> ksp_core_lib::Result<std::string::String> {
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("sendTransaction", role, params).await;
return match value {
std::result::Result::Ok(value) => crate::decode_wire_json::<std::string::String>("sendTransaction", value),
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,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
// version: 13
// version: 14
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -300,3 +300,24 @@ fn public_transaction_pre_005_get_transaction_complete_request_forms_are_availab
assert_eq!(config.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::JsonParsed));
assert_eq!(config.max_supported_transaction_version(), std::option::Option::Some(0));
}
#[test]
fn public_transaction_pre_006_write_wrappers_and_complete_configs_are_available_from_crate_root() {
let _request_airdrop = ksp_onchain_transport_lib::HttpTransportPool::request_airdrop;
let _send_transaction = ksp_onchain_transport_lib::HttpTransportPool::send_transaction;
let airdrop = ksp_onchain_transport_lib::SolanaRequestAirdropConfig::new(
std::option::Option::Some("recent-blockhash".to_owned()),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
);
assert_eq!(airdrop.recent_blockhash(), std::option::Option::Some("recent-blockhash"));
let send = ksp_onchain_transport_lib::SolanaSendTransactionConfig::new(
std::option::Option::Some(false),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed),
std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::Some(5),
std::option::Option::Some(431_000_000),
);
assert_eq!(send.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64));
assert_eq!(send.max_retries(), std::option::Option::Some(5));
assert_eq!(send.min_context_slot(), std::option::Option::Some(431_000_000));
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs
// version: 10
// version: 11
//! Release-level completeness canaries for the `0.2.1` HTTP foundation contract.
@@ -326,3 +326,36 @@ fn release_pre_005_transaction_read_subset_adds_complete_get_transaction_without
assert_eq!(actual.len(), 8);
assert_eq!(deferred.len(), 3);
}
#[test]
fn release_pre_006_transaction_subset_adds_both_write_submissions_without_advancing_simulation() {
let reads = std::vec![
"getFeeForMessage",
"getLatestBlockhash",
"getRecentPrioritizationFees",
"getSignaturesForAddress",
"getSignatureStatuses",
"getTransaction",
"getTransactionCount",
"isBlockhashValid",
];
let writes = std::vec!["requestAirdrop", "sendTransaction"];
for method_name in &reads {
let descriptor = ksp_onchain_transport_lib::find_http_rpc_method(method_name).expect("pre.006 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.006 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);
}
let simulation = ksp_onchain_transport_lib::find_http_rpc_method("simulateTransaction").expect("simulation descriptor must remain registered");
assert_eq!(simulation.operation_kind(), ksp_onchain_transport_lib::RpcOperationKind::Simulation);
assert_eq!(reads.len(), 8);
assert_eq!(writes.len(), 2);
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs
// version: 5
// version: 6
#[test]
fn transaction_encoding_strings_match_current_and_legacy_wire_labels() {
@@ -290,27 +290,34 @@ fn simulation_result_distinguishes_omitted_from_explicit_null_fields() {
}
fn transaction_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],
);
return transaction_pool_for_urls(&[(url, 10)], std::time::Duration::from_secs(1), 0);
}
fn transaction_pool_for_urls(urls: &[(&str, u32)], request_timeout: std::time::Duration, max_retries: u32) -> crate::HttpTransportPool {
let mut endpoints = std::vec::Vec::with_capacity(urls.len());
for (index, (url, priority)) in urls.iter().enumerate() {
let role = crate::HttpEndpointRoleSettings::new(
crate::HttpRoleName::new("default"),
true,
std::vec![crate::HttpRequestKind::wildcard()],
*priority,
crate::HttpRoleLimits::new(std::option::Option::None, std::option::Option::None, std::option::Option::None, std::option::Option::None),
);
endpoints.push(crate::HttpEndpointSettings::new(
format!("fixture-{index}"),
true,
crate::HttpProviderName::new("fixture"),
crate::HttpClusterName::new("local"),
crate::HttpEndpointUrl::parse(url).expect("fixture URL must parse"),
std::time::Duration::from_millis(100),
request_timeout,
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)),
endpoints,
crate::HttpRetrySettings::new(max_retries, std::time::Duration::from_millis(1), std::time::Duration::from_millis(2)),
);
return crate::HttpTransportPool::new(settings).expect("fixture pool must build");
}
@@ -328,6 +335,69 @@ fn serve_transaction_once(body: &'static str) -> (std::string::String, std::thre
return (format!("http://{address}"), handle);
}
fn serve_transaction_status_and_count(status_line: &'static str) -> (std::string::String, std::thread::JoinHandle<(usize, 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 first request");
let first_request = read_transaction_request(&mut stream);
let response = format!("HTTP/1.1 {status_line}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
std::io::Write::write_all(&mut stream, response.as_bytes()).expect("fixture response must write");
let mut count = 1_usize;
listener.set_nonblocking(true).expect("fixture listener must become nonblocking");
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(120);
while std::time::Instant::now() < deadline {
match listener.accept() {
std::result::Result::Ok((mut retry_stream, _)) => {
let _ = read_transaction_request(&mut retry_stream);
std::io::Write::write_all(&mut retry_stream, response.as_bytes()).expect("fixture retry response must write");
count = count.saturating_add(1);
},
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(5));
},
std::result::Result::Err(error) => panic!("fixture listener failed while counting retries: {error}"),
}
}
return (count, first_request);
});
return (format!("http://{address}"), handle);
}
fn serve_transaction_timeout_and_count(delay: std::time::Duration) -> (std::string::String, std::thread::JoinHandle<(usize, 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 first request");
let first_request = read_transaction_request(&mut stream);
std::thread::sleep(delay);
let mut count = 1_usize;
listener.set_nonblocking(true).expect("fixture listener must become nonblocking");
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(120);
while std::time::Instant::now() < deadline {
match listener.accept() {
std::result::Result::Ok((mut retry_stream, _)) => {
let _ = read_transaction_request(&mut retry_stream);
count = count.saturating_add(1);
},
std::result::Result::Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(5));
},
std::result::Result::Err(error) => panic!("fixture listener failed while counting timeout retries: {error}"),
}
}
return (count, first_request);
});
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");
drop(listener);
return format!("http://{address}");
}
fn read_transaction_request(stream: &mut std::net::TcpStream) -> std::string::String {
let mut bytes = std::vec::Vec::new();
let mut buffer = [0_u8; 1024];
@@ -874,3 +944,198 @@ async fn typed_get_transaction_rejects_processed_commitment_before_io() {
assert_eq!(error.context()[1].key(), "commitment");
assert_eq!(error.context()[1].value(), "processed");
}
#[tokio::test(flavor = "current_thread")]
async fn typed_request_airdrop_exposes_runtime_config_and_canonicalizes_empty_config() {
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let config = crate::SolanaRequestAirdropConfig::new(
std::option::Option::Some("recent-blockhash-fixture".to_owned()),
std::option::Option::Some(crate::SolanaCommitment::Finalized),
);
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let signature = pool
.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1_000_000_000, std::option::Option::Some(&config))
.await
.expect("requestAirdrop full-config fixture must succeed");
assert_eq!(signature, "airdrop-fixture-signature-111111111111111111111111111111111111111111111111");
let request = handle.join().expect("fixture server must join");
let body = transaction_request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("requestAirdrop"));
assert_eq!(
body["params"],
serde_json::json!([
"11111111111111111111111111111111",
1000000000,
{"recentBlockhash":"recent-blockhash-fixture","commitment":"finalized"}
])
);
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let empty = crate::SolanaRequestAirdropConfig::default();
let _ = pool
.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::Some(&empty))
.await
.expect("requestAirdrop empty-config fixture must succeed");
let request = handle.join().expect("fixture server must join");
assert_eq!(transaction_request_body(request.as_str())["params"], serde_json::json!(["11111111111111111111111111111111", 1]));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_send_transaction_exposes_all_current_options_and_both_binary_encodings() {
let config = crate::SolanaSendTransactionConfig::new(
std::option::Option::Some(true),
std::option::Option::Some(crate::SolanaCommitment::Confirmed),
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64),
std::option::Option::Some(7),
std::option::Option::Some(431_000_000),
);
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let signature = pool
.send_transaction(&crate::HttpRoleName::new("default"), "opaque-base64-transaction", std::option::Option::Some(&config))
.await
.expect("sendTransaction full-config fixture must succeed");
assert_eq!(signature, "send-fixture-signature-22222222222222222222222222222222222222222222222222");
let request = handle.join().expect("fixture server must join");
let body = transaction_request_body(request.as_str());
assert_eq!(body["method"], serde_json::json!("sendTransaction"));
assert_eq!(
body["params"],
serde_json::json!([
"opaque-base64-transaction",
{
"skipPreflight":true,
"preflightCommitment":"confirmed",
"encoding":"base64",
"maxRetries":7,
"minContextSlot":431000000
}
])
);
let base58 = crate::SolanaSendTransactionConfig::new(
std::option::Option::None,
std::option::Option::None,
std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base58),
std::option::Option::None,
std::option::Option::None,
);
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let _ = pool
.send_transaction(&crate::HttpRoleName::new("default"), "opaque-base58-transaction", std::option::Option::Some(&base58))
.await
.expect("sendTransaction 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::SolanaSendTransactionConfig::default();
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json"));
let pool = transaction_pool_for_url(url.as_str());
let _ = pool
.send_transaction(&crate::HttpRoleName::new("default"), "opaque-default-transaction", std::option::Option::Some(&empty))
.await
.expect("sendTransaction 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_write_wrappers_preserve_rpc_application_errors_without_transport_retry() {
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.error.json"));
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await;
assert_eq!(result.expect_err("requestAirdrop RPC error must propagate").code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
handle.join().expect("fixture server must join");
let (url, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.preflight_error.json"));
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await;
assert_eq!(result.expect_err("sendTransaction preflight 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_write_wrappers_never_resend_after_http_429_dispatch() {
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let (url, handle) = serve_transaction_status_and_count("429 Too Many Requests");
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await;
assert_eq!(result.expect_err("airdrop 429 must stop after dispatch").code(), crate::ERROR_CODE_RATE_LIMITED);
let (count, request) = handle.join().expect("fixture server must join");
assert_eq!(count, 1);
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("requestAirdrop"));
let (url, handle) = serve_transaction_status_and_count("429 Too Many Requests");
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await;
assert_eq!(result.expect_err("send 429 must stop after dispatch").code(), crate::ERROR_CODE_RATE_LIMITED);
let (count, request) = handle.join().expect("fixture server must join");
assert_eq!(count, 1);
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("sendTransaction"));
}
#[tokio::test(flavor = "current_thread")]
async fn typed_write_wrappers_never_resend_after_temporary_http_dispatch() {
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let (url, handle) = serve_transaction_status_and_count("503 Service Unavailable");
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await;
assert_eq!(result.expect_err("airdrop 503 must stop after dispatch").code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED);
let (count, _) = handle.join().expect("fixture server must join");
assert_eq!(count, 1);
let (url, handle) = serve_transaction_status_and_count("503 Service Unavailable");
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(500), 3);
let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await;
assert_eq!(result.expect_err("send 503 must stop after dispatch").code(), crate::ERROR_CODE_HTTP_REQUEST_FAILED);
let (count, _) = handle.join().expect("fixture server must join");
assert_eq!(count, 1);
}
#[tokio::test(flavor = "current_thread")]
async fn typed_write_wrappers_never_resend_after_ambiguous_timeout_dispatch() {
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let (url, handle) = serve_transaction_timeout_and_count(std::time::Duration::from_millis(80));
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(20), 3);
let result = pool.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None).await;
assert_eq!(result.expect_err("airdrop timeout must stop after ambiguous dispatch").code(), crate::ERROR_CODE_TIMEOUT);
let (count, _) = handle.join().expect("fixture server must join");
assert_eq!(count, 1);
let (url, handle) = serve_transaction_timeout_and_count(std::time::Duration::from_millis(80));
let pool = transaction_pool_for_urls(&[(url.as_str(), 10)], std::time::Duration::from_millis(20), 3);
let result = pool.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None).await;
assert_eq!(result.expect_err("send timeout must stop after ambiguous dispatch").code(), crate::ERROR_CODE_TIMEOUT);
let (count, _) = handle.join().expect("fixture server must join");
assert_eq!(count, 1);
}
#[tokio::test(flavor = "current_thread")]
async fn typed_write_wrappers_can_retry_when_connection_failure_proves_not_dispatched() {
let recipient = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let closed = unused_local_url();
let (secondary, handle) = serve_transaction_once(include_str!("../fixtures/http/request_airdrop.success.json"));
let pool = transaction_pool_for_urls(&[(closed.as_str(), 10), (secondary.as_str(), 10)], std::time::Duration::from_millis(500), 1);
let result = pool
.request_airdrop(&crate::HttpRoleName::new("default"), &recipient, 1, std::option::Option::None)
.await
.expect("NotDispatched connection failure may retry requestAirdrop safely");
assert_eq!(result, "airdrop-fixture-signature-111111111111111111111111111111111111111111111111");
let request = handle.join().expect("secondary fixture server must join");
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("requestAirdrop"));
let closed = unused_local_url();
let (secondary, handle) = serve_transaction_once(include_str!("../fixtures/http/send_transaction.success.json"));
let pool = transaction_pool_for_urls(&[(closed.as_str(), 10), (secondary.as_str(), 10)], std::time::Duration::from_millis(500), 1);
let result = pool
.send_transaction(&crate::HttpRoleName::new("default"), "opaque-transaction", std::option::Option::None)
.await
.expect("NotDispatched connection failure may retry sendTransaction safely");
assert_eq!(result, "send-fixture-signature-22222222222222222222222222222222222222222222222222");
let request = handle.join().expect("secondary fixture server must join");
assert_eq!(transaction_request_body(request.as_str())["method"], serde_json::json!("sendTransaction"));
}

282
deltas/0.2.3/pre.006.md Normal file
View File

@@ -0,0 +1,282 @@
<!-- file: deltas/0.2.3/pre.006.md -->
<!-- version: 1 -->
# Delta `0.2.3-pre.006` — `requestAirdrop` + `sendTransaction` et preuve no-resend
## Base requise
Livraison précédente validée localement par l'opérateur :
```text
0.2.3-pre.005
workspace.package.version = "0.2.3-pre.5"
```
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
168 unit tests
17 public API tests
11 release completeness tests
0 warning signalé par check/clippy
```
## Objectif
Activer les deux écritures de la famille Transactions :
```text
requestAirdrop
sendTransaction
```
Les deux wrappers restent attachés aux descriptors centraux déjà audités :
```text
RpcOperationKind::WriteSubmission
TransportRetryClass::NeverAfterDispatch
```
Après cette tranche :
```text
10 / 11 wrappers Transactions exécutés
8 Read
2 WriteSubmission
1 Simulation encore différée
```
`simulateTransaction` reste réservée à `pre.007`.
## Version Cargo
Conformément à `VER-ID-009` :
```text
0.2.3-pre.5 -> 0.2.3-pre.6
```
Aucune dépendance ni feature Cargo n'est ajoutée.
## Complétude `requestAirdrop`
API publique :
```text
HttpTransportPool::request_airdrop(
role,
recipient: &Pubkey,
lamports: u64,
Option<&SolanaRequestAirdropConfig>,
)
-> Result<String>
```
La page publique Solana courante documente `commitment` dans l'objet de config. La source primaire Agave `v4.2.1` conserve en plus :
```text
recentBlockhash: Option<String>
```
KSP l'expose donc conformément à `KSP-TRANSPORT-007` au lieu de réduire la surface au seul exemple/document public visible.
Le résultat reste la signature de transaction retournée par le faucet sous forme de chaîne wire. Transport ne prend pas une dépendance Signature uniquement
pour redécoder une valeur que le provider vient de produire.
Une config vide est canonicalisée vers l'omission du troisième paramètre, syntaxe strictement équivalente autorisée par `KSP-TRANSPORT-007`.
## Complétude `sendTransaction`
API publique :
```text
HttpTransportPool::send_transaction(
role,
transaction: &str,
Option<&SolanaSendTransactionConfig>,
)
-> Result<String>
```
Transport reçoit une transaction déjà construite, signée et encodée. Il ne la décode, ne la signe et ne la modifie pas.
La config expose la surface courante complète :
```text
encoding Base58 | Base64
skipPreflight Option<bool>
preflightCommitment Option<SolanaCommitment>
maxRetries Option<usize>
minContextSlot Option<u64>
```
Les deux encodings binaires sont exercés par fixtures HTTP locales.
Une config vide est canonicalisée vers l'omission du second paramètre.
### Distinction impérative des retries
```text
SolanaSendTransactionConfig.maxRetries = retransmissions node-side après acceptation RPC
HttpRetrySettings = retries HTTP Transport KSP
```
Le premier champ n'accorde aucune permission de resoumission HTTP au wrapper.
## Preuves end-to-end de no-resend
Les tests ne se limitent pas à `evaluate_transport_retry`.
Ils exercent réellement :
```text
wrapper typed
-> descriptor central
-> execute_standard_rpc
-> client HTTP fixture
```
Pour **chacun** de `requestAirdrop` et `sendTransaction`, un serveur local compte les requêtes reçues.
Cas ambigus après dispatch :
```text
HTTP 429 -> erreur rate_limited -> compteur = 1
HTTP 503 -> erreur http_request_failed -> compteur = 1
timeout -> erreur timeout -> compteur = 1
```
Même avec plusieurs retries configurés dans `HttpRetrySettings`, aucune seconde soumission n'est émise après ces trois états ambigus.
Cas explicitement sûr :
```text
connexion refusée -> ERROR_CODE_HTTP_CONNECTION_FAILED -> NotDispatched
```
Avec deux endpoints de même priorité, la première tentative échoue avant dispatch et le retry central peut atteindre le second endpoint. Cette preuve est
exercée séparément pour les deux wrappers.
## Erreurs RPC applicatives
Les fixtures couvrent aussi :
- une erreur `requestAirdrop` de paramètres/blockhash ;
- une erreur `sendTransaction` de preflight/simulation.
Ces réponses restent `ERROR_CODE_RPC_APPLICATION_ERROR` et ne deviennent jamais des retries transport.
## `KSP-TRANSPORT-007` rétroactif
La question opérateur sur les wrappers antérieurs est enregistrée dans le plan `010`.
Les audits `0.2.1`/`0.2.2` avaient déjà recherché la surface complète. Un contrôle ciblé pendant cette tranche confirme notamment l'alignement KSP avec
Agave `v4.2.1` pour :
```text
RpcAccountInfoConfig
RpcProgramAccountsConfig
RpcLargestAccountsConfig
RpcLeaderScheduleConfig
RpcGetVoteAccountsConfig
```
Mais la règle n'était pas encore un critère de clôture nommé. La prévision devient donc :
```text
pre.007 simulateTransaction complet
pre.008 audit rétroactif KSP-TRANSPORT-007 sur 0.2.1 -> 0.2.3 + corrections éventuelles
pre.009 clôture finale documentaire/validation/smoke/prompt 0.2.4
```
Cette tranche supplémentaire évite de transformer la clôture en remédiation fonctionnelle tardive.
## Documentation/source synchronisée
`crates/ksp-onchain-transport-lib/src/lib.rs` est corrigé pour refléter la surface réellement disponible après `pre.006` :
```text
8 reads Transactions
2 write submissions
simulateTransaction encore staged
```
Le plan `docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md` passe en version 5 et enregistre le nouveau découpage jusqu'à `pre.009`.
`CHANGELOG.md` reste réservé à `0.2.3-rel.001`.
## Tests ajoutés
Sept tests unitaires couvrent :
```text
requestAirdrop config complète + canonicalisation config vide
sendTransaction config complète + base58/base64 + config vide
erreurs RPC applicatives des deux writes
no-resend après HTTP 429 pour les deux writes
no-resend après HTTP 503 pour les deux writes
no-resend après timeout ambigu pour les deux writes
retry/fallback autorisé après connexion NotDispatched pour les deux writes
```
Un test public vérifie la disponibilité des deux wrappers/configs à la racine de crate.
Une canarie release vérifie le sous-ensemble `pre.006` :
```text
8 Read / RetrySafe
2 WriteSubmission / NeverAfterDispatch
simulateTransaction encore différée
```
Cible après application :
```text
175 unit tests
18 public API tests
12 release completeness tests
```
## Fichiers du delta
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
```
Ajoutés :
```text
crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.success.json
crates/ksp-onchain-transport-lib/fixtures/http/request_airdrop.error.json
crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.success.json
crates/ksp-onchain-transport-lib/fixtures/http/send_transaction.preflight_error.json
deltas/0.2.3/pre.006.md
```
## Validations attendues opérateur
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
L'environnement de préparation du delta ne possède pas Cargo/Rustfmt. Aucune de ces commandes n'est donc déclarée réussie avant preuve opérateur.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md -->
<!-- version: 4 -->
<!-- version: 5 -->
# Plan `0.2.3` — HTTP Transactions
@@ -589,6 +589,27 @@ Un candidat raisonnable pour la clôture est un smoke Transport pur combinant `g
simulation live n'est retenue que si une fixture transactionnelle stable peut être fournie sans déplacer la construction/signature métier dans
Transport.
## Audit rétroactif `KSP-TRANSPORT-007` planifié
La règle `KSP-TRANSPORT-007` formalise en `pre.005` un principe déjà appliqué dans les audits de `0.2.1` et `0.2.2`, mais elle n'était pas encore un
critère de clôture nommé lors de ces releases. Un contrôle rétroactif dédié est donc ajouté avant la clôture documentaire.
L'audit `pre.008` couvrira au minimum :
```text
4 wrappers foundation de 0.2.1
22 wrappers Accounts/Tokens/Cluster de 0.2.2
11 wrappers Transactions de 0.2.3
```
Il comparera la surface publique KSP aux paramètres/configs/overloads et formes de réponse des sources normatives courantes retenues. Les syntaxes
strictement équivalentes peuvent rester canonicalisées conformément à la règle. Toute possibilité supportée manquante sera corrigée dans cette tranche ;
si aucun manque n'est trouvé, la tranche fournira la preuve/canarie d'audit sans modification fonctionnelle artificielle.
Un contrôle ciblé effectué pendant `pre.006` confirme déjà que les configs structurantes de `0.2.2` (`RpcAccountInfoConfig`,
`RpcProgramAccountsConfig`, `RpcLargestAccountsConfig`, `RpcLeaderScheduleConfig`, `RpcGetVoteAccountsConfig`) correspondent aux champs Agave
`v4.2.1` exposés par les DTOs KSP. Ce contrôle ne remplace pas l'audit exhaustif `pre.008`.
## Prévision souple des prereleases
```text
@@ -599,11 +620,14 @@ pre.004 getRecentPrioritizationFees + getSignaturesForAddress + getSignatureSta
pre.005 getTransaction moderne + compatibilité legacy + transaction/meta/version wire
pre.006 requestAirdrop + sendTransaction + preuves end-to-end no-resend
pre.007 simulateTransaction + résultat riche + invariants de config
pre.008 audit final + canaries + smoke opt-in si pertinent + README/USAGE + validation + prompt 0.2.4
pre.008 audit rétroactif KSP-TRANSPORT-007 sur 0.2.1 -> 0.2.3 + remédiations éventuelles
pre.009 réaudit final + canaries + smoke opt-in si pertinent + README/USAGE + validation + prompt 0.2.4
```
Une prerelease intermédiaire peut être ajoutée si le volume réel d'une tranche dépasse le budget. La dernière prerelease reste une tranche de
clôture documentaire/validation et ne doit pas devenir une implémentation massive tardive.
Une prerelease intermédiaire peut être ajoutée si le volume réel d'une tranche dépasse le budget. Après formalisation de `KSP-TRANSPORT-007` en
`pre.005`, `pre.008` est désormais réservée à un audit rétroactif explicite des wrappers HTTP déjà livrés depuis `0.2.1`, afin de ne pas transformer la
tranche de clôture en remédiation fonctionnelle tardive. La dernière prerelease reste une tranche de clôture documentaire/validation et ne doit pas
devenir une implémentation massive tardive.
## Critères de clôture de `0.2.3`
@@ -683,3 +707,21 @@ La tranche suivante peut commencer par les primitives wire communes sans réouvr
- aucune nouvelle dépendance Cargo et aucun wrapper `0.2.3` activé prématurément.
La tranche suivante reste `pre.003` : `getFeeForMessage`, `getLatestBlockhash`, `getTransactionCount` et `isBlockhashValid`.
## Résultat de `pre.006`
`pre.006` active les deux écritures Transaction sans introduire de chemin de soumission parallèle :
- `requestAirdrop` expose `Pubkey`, lamports, `commitment` et le `recentBlockhash` encore supporté par Agave `v4.2.1` ;
- `sendTransaction` transporte une transaction déjà signée/encodée et expose `base58/base64`, `skipPreflight`, `preflightCommitment`, `maxRetries` et
`minContextSlot` ;
- `sendTransaction.maxRetries` reste explicitement node-side et ne modifie jamais `HttpRetrySettings` ;
- les deux wrappers passent par leur descriptor central `WriteSubmission / NeverAfterDispatch` puis `execute_standard_rpc` ;
- les fixtures end-to-end comptent les requêtes et prouvent une seule soumission après HTTP 429, HTTP 503 et timeout après dispatch ;
- une connexion refusée classée `NotDispatched` peut encore utiliser la policy centrale de retry/fallback, démontrée pour les deux wrappers ;
- les erreurs RPC applicatives, dont un échec de preflight `sendTransaction`, restent des erreurs applicatives sans retry transport ;
- aucune dépendance transactionnelle/codec supplémentaire n'est ajoutée ;
- `simulateTransaction` reste la seule méthode `0.2.3` non encore activée.
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`.