v0.2.2-pre.002

This commit is contained in:
2026-08-18 07:15:59 +02:00
parent bffb4f9a31
commit e68f073505
18 changed files with 2049 additions and 78 deletions

View File

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

View File

@@ -0,0 +1,5 @@
[
{"lamports":1,"data":"3MN5","owner":"11111111111111111111111111111111","executable":false,"rentEpoch":0,"space":null},
{"lamports":2,"data":["KLUv/Q==","base64+zstd"],"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":1,"space":4},
{"lamports":3,"data":{"program":"spl-token","parsed":{"type":"account","info":{"state":"initialized"}},"space":165},"owner":"11111111111111111111111111111111","executable":false,"rentEpoch":2,"space":165}
]

View File

@@ -0,0 +1 @@
{"pubkey":"11111111111111111111111111111111","featureSet":123,"gossip":"127.0.0.1:8001","pubsub":null,"rpc":"127.0.0.1:8899","serveRepair":"127.0.0.1:8003","shredVersion":456,"tpu":"127.0.0.1:8004","tpuForwards":null,"tpuForwardsQuic":"127.0.0.1:8006","tpuQuic":"127.0.0.1:8005","tpuVote":null,"tvu":"127.0.0.1:8002","version":"4.2.1","clientId":"Agave"}

View File

@@ -0,0 +1 @@
{"amount":"18446744073709551615","decimals":9,"uiAmount":null,"uiAmountString":"18446744073.709551615"}

View File

@@ -0,0 +1 @@
{"votePubkey":"11111111111111111111111111111111","nodePubkey":"11111111111111111111111111111111","activatedStake":424242,"commission":8,"inflationRewardsCommissionBps":750,"epochVoteAccount":true,"epochCredits":[[700,100,90],[701,115,100]],"lastVote":999,"rootSlot":990}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 6
// version: 7
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
@@ -19,8 +19,12 @@ mod executor;
mod json_rpc;
mod pool;
mod resilience;
mod rpc_accounts;
mod rpc_canary;
mod rpc_cluster;
mod rpc_common;
mod rpc_method;
mod rpc_tokens;
mod settings;
pub(crate) use self::constants::TRACING_TARGET;
@@ -91,16 +95,84 @@ pub use self::resilience::evaluate_transport_retry;
pub use self::rpc_canary::GetBalanceConfig;
/// Typed lamport balance returned by the `getBalance` canary.
pub use self::rpc_canary::GetBalanceResult;
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
pub use self::rpc_canary::SolanaCommitment;
/// Typed genesis hash returned by the `getGenesisHash` canary.
pub use self::rpc_canary::SolanaGenesisHash;
/// Typed healthy result returned by the `getHealth` canary.
pub use self::rpc_canary::SolanaNodeHealth;
/// Typed software-version response returned by the `getVersion` canary.
pub use self::rpc_canary::SolanaNodeVersion;
/// Typed Solana RPC context used by the initial account canary.
pub use self::rpc_canary::SolanaRpcContext;
/// Account-data encoding accepted by Solana HTTP account methods.
pub use self::rpc_accounts::SolanaAccountEncoding;
/// Typed transport-level Solana account without Program/SPL decoding.
pub use self::rpc_accounts::SolanaAccount;
/// Address and lamport balance returned by `getLargestAccounts`.
pub use self::rpc_accounts::SolanaAccountBalance;
/// Wire-preserving account data returned by Solana HTTP account methods.
pub use self::rpc_accounts::SolanaAccountData;
/// Shared account configuration used by account-info and token-account list methods.
pub use self::rpc_accounts::SolanaAccountInfoConfig;
/// Byte range requested from account data without decoding it locally.
pub use self::rpc_accounts::SolanaDataSliceConfig;
/// One public key plus its account returned by account-list RPC methods.
pub use self::rpc_accounts::SolanaKeyedAccount;
/// Filter accepted by `getLargestAccounts`.
pub use self::rpc_accounts::SolanaLargestAccountsFilter;
/// Optional configuration for `getLargestAccounts`.
pub use self::rpc_accounts::SolanaLargestAccountsConfig;
/// Bytes used by a `memcmp` program-account filter.
pub use self::rpc_accounts::SolanaMemcmpBytes;
/// One `memcmp` filter applied to account data.
pub use self::rpc_accounts::SolanaMemcmpFilter;
/// Parsed account payload returned by the RPC node for `jsonParsed` account data.
pub use self::rpc_accounts::SolanaParsedAccountData;
/// Filter accepted by the current `getProgramAccounts` implementation.
pub use self::rpc_accounts::SolanaProgramAccountFilter;
/// Configuration for `getProgramAccounts`.
pub use self::rpc_accounts::SolanaProgramAccountsConfig;
/// Result union returned by `getProgramAccounts` with or without an RPC context.
pub use self::rpc_accounts::SolanaProgramAccountsResult;
/// Contact information returned for one cluster node.
pub use self::rpc_cluster::SolanaClusterNode;
/// Epoch-credit history entry returned by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaEpochCredits;
/// Epoch information returned by `getEpochInfo`.
pub use self::rpc_cluster::SolanaEpochInfo;
/// Epoch schedule returned by `getEpochSchedule`.
pub use self::rpc_cluster::SolanaEpochSchedule;
/// Leader schedule mapping validator identities to relative epoch slot indices.
pub use self::rpc_cluster::SolanaLeaderSchedule;
/// Optional configuration accepted by `getLeaderSchedule`.
pub use self::rpc_cluster::SolanaLeaderScheduleConfig;
/// Typed parameter overload for `getLeaderSchedule`.
pub use self::rpc_cluster::SolanaLeaderScheduleRequest;
/// Highest full and optional incremental snapshot slots returned by `getHighestSnapshotSlot`.
pub use self::rpc_cluster::SolanaSnapshotSlotInfo;
/// One validator vote-account record returned by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountInfo;
/// Current and delinquent validator vote-account groups returned by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountStatus;
/// Configuration accepted by `getVoteAccounts`.
pub use self::rpc_cluster::SolanaVoteAccountsConfig;
/// Commitment level accepted by typed Solana HTTP RPC adapters.
pub use self::rpc_common::SolanaCommitment;
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
pub use self::rpc_common::SolanaCommitmentConfig;
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
pub use self::rpc_common::SolanaContextConfig;
/// Typed Solana RPC context shared by contextual HTTP responses.
pub use self::rpc_common::SolanaRpcContext;
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
pub use self::rpc_common::SolanaRpcResponse;
/// Exclusive selector accepted by token-account list RPC methods.
pub use self::rpc_tokens::SolanaTokenAccountSelector;
/// Token-account balance entry returned by `getTokenLargestAccounts`.
pub use self::rpc_tokens::SolanaTokenAccountBalance;
/// Token amount returned by Solana HTTP token RPC methods.
pub use self::rpc_tokens::SolanaTokenAmount;
/// Decodes one private serde wire type into the shared Transport error domain.
pub(crate) use self::rpc_common::decode_wire_json;
/// Parses a base58 public key without echoing its wire value into diagnostics.
pub(crate) use self::rpc_common::parse_wire_pubkey;
/// Functional category used by the audited Solana HTTP JSON-RPC registry.
pub use self::rpc_method::HttpRpcCategory;
/// Release that owns typed KSP coverage for one audited HTTP RPC method.

View File

@@ -0,0 +1,632 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
// version: 1
/// Account-data encoding accepted by Solana HTTP account methods.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaAccountEncoding {
/// Legacy binary/base58 request encoding.
Binary,
/// Base58 text encoding.
Base58,
/// Base64 text encoding.
Base64,
/// Parsed JSON representation when the RPC node has a parser for the account owner.
JsonParsed,
/// Base64 text containing zstd-compressed bytes.
Base64Zstd,
}
impl SolanaAccountEncoding {
/// Returns the Solana JSON-RPC encoding string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Binary => "binary",
Self::Base58 => "base58",
Self::Base64 => "base64",
Self::JsonParsed => "jsonParsed",
Self::Base64Zstd => "base64+zstd",
};
}
fn from_wire(value: &str) -> std::option::Option<Self> {
return match value {
"binary" => std::option::Option::Some(Self::Binary),
"base58" => std::option::Option::Some(Self::Base58),
"base64" => std::option::Option::Some(Self::Base64),
"jsonParsed" => std::option::Option::Some(Self::JsonParsed),
"base64+zstd" => std::option::Option::Some(Self::Base64Zstd),
_ => std::option::Option::None,
};
}
}
/// Byte range requested from account data without decoding it locally.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SolanaDataSliceConfig {
offset: usize,
length: usize,
}
impl SolanaDataSliceConfig {
/// Creates an account-data slice configuration.
#[must_use]
pub const fn new(offset: usize, length: usize) -> Self {
return Self { offset, length };
}
/// Returns the byte offset.
#[must_use]
pub const fn offset(&self) -> usize {
return self.offset;
}
/// Returns the requested byte length.
#[must_use]
pub const fn length(&self) -> usize {
return self.length;
}
fn to_json_value(self) -> serde_json::Value {
return serde_json::json!({"offset": self.offset, "length": self.length});
}
}
/// Shared account configuration used by account-info and token-account list methods.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaAccountInfoConfig {
encoding: std::option::Option<crate::SolanaAccountEncoding>,
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
context: crate::SolanaContextConfig,
}
impl SolanaAccountInfoConfig {
/// Creates an explicit account-info configuration.
#[must_use]
pub const fn new(
encoding: std::option::Option<crate::SolanaAccountEncoding>,
data_slice: std::option::Option<crate::SolanaDataSliceConfig>,
commitment: std::option::Option<crate::SolanaCommitment>,
min_context_slot: std::option::Option<u64>,
) -> Self {
return Self { encoding, data_slice, context: crate::SolanaContextConfig::new(commitment, min_context_slot) };
}
/// Returns the optional account-data encoding.
#[must_use]
pub const fn encoding(&self) -> std::option::Option<crate::SolanaAccountEncoding> {
return self.encoding;
}
/// Returns the optional account-data slice.
#[must_use]
pub const fn data_slice(&self) -> std::option::Option<crate::SolanaDataSliceConfig> {
return self.data_slice;
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.context.commitment();
}
/// Returns the optional minimum context slot.
#[must_use]
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
return self.context.min_context_slot();
}
/// Returns whether this config serializes to an empty JSON object.
#[must_use]
pub(crate) const fn is_empty(&self) -> bool {
return self.encoding.is_none() && self.data_slice.is_none() && self.context.is_empty();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let context_value = self.context.to_json_value();
let mut object = match context_value {
serde_json::Value::Object(object) => object,
_ => serde_json::Map::new(),
};
if let std::option::Option::Some(encoding) = self.encoding {
object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned()));
}
if let std::option::Option::Some(data_slice) = self.data_slice {
object.insert("dataSlice".to_owned(), data_slice.to_json_value());
}
return serde_json::Value::Object(object);
}
}
/// Filter accepted by `getLargestAccounts`.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaLargestAccountsFilter {
/// Return only circulating accounts.
Circulating,
/// Return only non-circulating accounts.
NonCirculating,
}
impl SolanaLargestAccountsFilter {
fn as_str(self) -> &'static str {
return match self {
Self::Circulating => "circulating",
Self::NonCirculating => "nonCirculating",
};
}
}
/// Optional configuration for `getLargestAccounts`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaLargestAccountsConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
filter: std::option::Option<crate::SolanaLargestAccountsFilter>,
sort_results: std::option::Option<bool>,
}
impl SolanaLargestAccountsConfig {
/// Creates a largest-accounts configuration.
#[must_use]
pub const fn new(
commitment: std::option::Option<crate::SolanaCommitment>,
filter: std::option::Option<crate::SolanaLargestAccountsFilter>,
sort_results: std::option::Option<bool>,
) -> Self {
return Self { commitment, filter, sort_results };
}
/// Returns the optional commitment.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional circulating-account filter.
#[must_use]
pub const fn filter(&self) -> std::option::Option<crate::SolanaLargestAccountsFilter> {
return self.filter;
}
/// Returns the optional server-side result-sorting request.
#[must_use]
pub const fn sort_results(&self) -> std::option::Option<bool> {
return self.sort_results;
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
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 {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(filter) = self.filter {
object.insert("filter".to_owned(), serde_json::Value::String(filter.as_str().to_owned()));
}
if let std::option::Option::Some(sort_results) = self.sort_results {
object.insert("sortResults".to_owned(), serde_json::Value::Bool(sort_results));
}
return serde_json::Value::Object(object);
}
}
/// Bytes used by a `memcmp` program-account filter.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaMemcmpBytes {
/// Base58-encoded bytes.
Base58(std::string::String),
/// Base64-encoded bytes.
Base64(std::string::String),
/// Raw byte array.
Bytes(std::vec::Vec<u8>),
}
/// One `memcmp` filter applied to account data.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaMemcmpFilter {
offset: usize,
bytes: crate::SolanaMemcmpBytes,
}
impl SolanaMemcmpFilter {
/// Creates a `memcmp` filter without locally decoding encoded string data.
#[must_use]
pub fn new(offset: usize, bytes: crate::SolanaMemcmpBytes) -> Self {
return Self { offset, bytes };
}
/// Returns the account-data byte offset.
#[must_use]
pub const fn offset(&self) -> usize {
return self.offset;
}
/// Returns the encoded or raw bytes.
#[must_use]
pub const fn bytes(&self) -> &crate::SolanaMemcmpBytes {
return &self.bytes;
}
fn to_json_value(&self) -> serde_json::Value {
return match &self.bytes {
crate::SolanaMemcmpBytes::Base58(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base58"}),
crate::SolanaMemcmpBytes::Base64(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "base64"}),
crate::SolanaMemcmpBytes::Bytes(bytes) => serde_json::json!({"offset": self.offset, "bytes": bytes, "encoding": "bytes"}),
};
}
}
/// Filter accepted by the current `getProgramAccounts` implementation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaProgramAccountFilter {
/// Require an exact account data size.
DataSize(u64),
/// Compare bytes at one account-data offset.
Memcmp(crate::SolanaMemcmpFilter),
/// Require a valid SPL Token account-state layout according to the RPC implementation.
TokenAccountState,
}
impl SolanaProgramAccountFilter {
fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::DataSize(size) => serde_json::json!({"dataSize": size}),
Self::Memcmp(filter) => serde_json::json!({"memcmp": filter.to_json_value()}),
Self::TokenAccountState => serde_json::Value::String("tokenAccountState".to_owned()),
};
}
}
/// Configuration for `getProgramAccounts` built from the shared account config plus program filters.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaProgramAccountsConfig {
account_config: crate::SolanaAccountInfoConfig,
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
with_context: std::option::Option<bool>,
sort_results: std::option::Option<bool>,
}
impl SolanaProgramAccountsConfig {
/// Creates a program-accounts configuration.
#[must_use]
pub fn new(
account_config: crate::SolanaAccountInfoConfig,
filters: std::vec::Vec<crate::SolanaProgramAccountFilter>,
with_context: std::option::Option<bool>,
sort_results: std::option::Option<bool>,
) -> Self {
return Self { account_config, filters, with_context, sort_results };
}
/// Returns the shared account configuration.
#[must_use]
pub const fn account_config(&self) -> &crate::SolanaAccountInfoConfig {
return &self.account_config;
}
/// Returns the ordered program-account filters.
#[must_use]
pub fn filters(&self) -> &[crate::SolanaProgramAccountFilter] {
return self.filters.as_slice();
}
/// Returns the optional context-wrapper request.
#[must_use]
pub const fn with_context(&self) -> std::option::Option<bool> {
return self.with_context;
}
/// Returns the optional server-side sorting request.
#[must_use]
pub const fn sort_results(&self) -> std::option::Option<bool> {
return self.sort_results;
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
let account_value = self.account_config.to_json_value();
let mut object = match account_value {
serde_json::Value::Object(object) => object,
_ => serde_json::Map::new(),
};
if !self.filters.is_empty() {
let values = self.filters.iter().map(crate::SolanaProgramAccountFilter::to_json_value).collect::<std::vec::Vec<_>>();
object.insert("filters".to_owned(), serde_json::Value::Array(values));
}
if let std::option::Option::Some(with_context) = self.with_context {
object.insert("withContext".to_owned(), serde_json::Value::Bool(with_context));
}
if let std::option::Option::Some(sort_results) = self.sort_results {
object.insert("sortResults".to_owned(), serde_json::Value::Bool(sort_results));
}
return serde_json::Value::Object(object);
}
}
/// Parsed account payload returned by the RPC node for `jsonParsed` account data.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaParsedAccountData {
program: std::string::String,
parsed: serde_json::Value,
space: u64,
}
impl SolanaParsedAccountData {
/// Returns the parser/program label reported by the RPC node.
#[must_use]
pub fn program(&self) -> &str {
return self.program.as_str();
}
/// Returns the parsed JSON payload without converting it to a Program/SPL domain model.
#[must_use]
pub const fn parsed(&self) -> &serde_json::Value {
return &self.parsed;
}
/// Returns the account-data space reported inside the parsed payload.
#[must_use]
pub const fn space(&self) -> u64 {
return self.space;
}
}
/// Wire-preserving account data returned by Solana HTTP account methods.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaAccountData {
/// Legacy single-string binary form retained for backwards compatibility.
LegacyBinary(std::string::String),
/// Encoded tuple `[data, encoding]`.
Encoded {
/// Encoded account bytes.
data: std::string::String,
/// Encoding label returned by the RPC node.
encoding: crate::SolanaAccountEncoding,
},
/// Parsed JSON object returned by the RPC node.
JsonParsed(crate::SolanaParsedAccountData),
}
impl SolanaAccountData {
fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccountData>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return match wire {
WireAccountData::LegacyBinary(value) => std::result::Result::Ok(Self::LegacyBinary(value)),
WireAccountData::JsonParsed(value) => std::result::Result::Ok(Self::JsonParsed(crate::SolanaParsedAccountData {
program: value.program,
parsed: value.parsed,
space: value.space,
})),
WireAccountData::Encoded((data, encoding)) => {
let parsed = crate::SolanaAccountEncoding::from_wire(encoding.as_str());
let encoding = match parsed {
std::option::Option::Some(encoding) => encoding,
std::option::Option::None => {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "account data tuple uses an unknown encoding")
.with_context("rpc_method", method),
);
}
};
if encoding == crate::SolanaAccountEncoding::Binary || encoding == crate::SolanaAccountEncoding::JsonParsed {
return std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "account data tuple uses an invalid tuple encoding")
.with_context("rpc_method", method),
);
}
std::result::Result::Ok(Self::Encoded { data, encoding })
}
};
}
}
/// Typed transport-level Solana account without Program/SPL decoding.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaAccount {
lamports: u64,
data: crate::SolanaAccountData,
owner: ksp_core_lib::Pubkey,
executable: bool,
rent_epoch: u64,
space: std::option::Option<u64>,
}
impl SolanaAccount {
/// Returns the account balance in lamports.
#[must_use]
pub const fn lamports(&self) -> u64 {
return self.lamports;
}
/// Returns the wire-preserving account data.
#[must_use]
pub const fn data(&self) -> &crate::SolanaAccountData {
return &self.data;
}
/// Returns the account owner program public key.
#[must_use]
pub const fn owner(&self) -> &ksp_core_lib::Pubkey {
return &self.owner;
}
/// Returns whether the account is executable.
#[must_use]
pub const fn executable(&self) -> bool {
return self.executable;
}
/// Returns the rent epoch reported by the RPC node.
#[must_use]
pub const fn rent_epoch(&self) -> u64 {
return self.rent_epoch;
}
/// Returns the optional account data-space field.
#[must_use]
pub const fn space(&self) -> std::option::Option<u64> {
return self.space;
}
/// Decodes one account DTO from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccount>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let owner = crate::parse_wire_pubkey(method, "owner", wire.owner.as_str());
let owner = match owner {
std::result::Result::Ok(owner) => owner,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let data = crate::SolanaAccountData::decode_wire(method, wire.data);
let data = match data {
std::result::Result::Ok(data) => data,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self {
lamports: wire.lamports,
data,
owner,
executable: wire.executable,
rent_epoch: wire.rent_epoch,
space: wire.space,
});
}
}
/// One public key plus its account returned by account-list RPC methods.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaKeyedAccount {
pubkey: ksp_core_lib::Pubkey,
account: crate::SolanaAccount,
}
impl SolanaKeyedAccount {
/// Returns the account public key.
#[must_use]
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey {
return &self.pubkey;
}
/// Returns the account payload.
#[must_use]
pub const fn account(&self) -> &crate::SolanaAccount {
return &self.account;
}
/// Decodes one keyed account from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireKeyedAccount>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pubkey = crate::parse_wire_pubkey(method, "pubkey", wire.pubkey.as_str());
let pubkey = match pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let account = crate::SolanaAccount::decode_wire(method, wire.account);
let account = match account {
std::result::Result::Ok(account) => account,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { pubkey, account });
}
}
/// Address and lamport balance returned by `getLargestAccounts`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaAccountBalance {
address: ksp_core_lib::Pubkey,
lamports: u64,
}
impl SolanaAccountBalance {
/// Returns the account address.
#[must_use]
pub const fn address(&self) -> &ksp_core_lib::Pubkey {
return &self.address;
}
/// Returns the balance in lamports.
#[must_use]
pub const fn lamports(&self) -> u64 {
return self.lamports;
}
/// Decodes one account-balance entry from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireAccountBalance>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let address = crate::parse_wire_pubkey(method, "address", wire.address.as_str());
return match address {
std::result::Result::Ok(address) => std::result::Result::Ok(Self { address, lamports: wire.lamports }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Result union returned by `getProgramAccounts` with or without an RPC context.
#[derive(Clone, Debug, PartialEq)]
pub enum SolanaProgramAccountsResult {
/// Bare account list returned when `withContext` is false or absent.
Accounts(std::vec::Vec<crate::SolanaKeyedAccount>),
/// Contextual account list returned when `withContext` is true.
Context(crate::SolanaRpcResponse<std::vec::Vec<crate::SolanaKeyedAccount>>),
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum WireAccountData {
LegacyBinary(std::string::String),
JsonParsed(WireParsedAccountData),
Encoded((std::string::String, std::string::String)),
}
#[derive(serde::Deserialize)]
struct WireParsedAccountData {
program: std::string::String,
parsed: serde_json::Value,
space: u64,
}
#[derive(serde::Deserialize)]
struct WireAccount {
lamports: u64,
data: serde_json::Value,
owner: std::string::String,
executable: bool,
#[serde(rename = "rentEpoch")]
rent_epoch: u64,
#[serde(default)]
space: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
struct WireKeyedAccount {
pubkey: std::string::String,
account: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct WireAccountBalance {
address: std::string::String,
lamports: u64,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_accounts.rs"]
mod tests;

View File

@@ -1,30 +1,7 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_canary.rs
// version: 1
// version: 2
/// Commitment level accepted by the initial typed Solana HTTP canary adapters.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaCommitment {
/// Query the most recent processed bank.
Processed,
/// Query a bank confirmed by cluster vote.
Confirmed,
/// Query a finalized bank.
Finalized,
}
impl SolanaCommitment {
/// Returns the Solana JSON-RPC commitment string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Processed => "processed",
Self::Confirmed => "confirmed",
Self::Finalized => "finalized",
};
}
}
/// Optional typed configuration for `getBalance`.
/// Optional typed configuration for `getBalance` retained for the `0.2.1` public canary contract.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GetBalanceConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
@@ -55,14 +32,7 @@ impl GetBalanceConfig {
}
fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
object.insert("minContextSlot".to_owned(), serde_json::Value::Number(min_context_slot.into()));
}
return serde_json::Value::Object(object);
return crate::SolanaContextConfig::new(self.commitment, self.min_context_slot).to_json_value();
}
}
@@ -108,30 +78,6 @@ impl SolanaNodeVersion {
}
}
/// Typed Solana RPC context used by the initial account canary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaRpcContext {
slot: u64,
api_version: std::option::Option<std::string::String>,
}
impl SolanaRpcContext {
/// Returns the context slot reported by the RPC node.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the optional RPC API version reported by the node.
#[must_use]
pub fn api_version(&self) -> std::option::Option<&str> {
return match self.api_version.as_ref() {
std::option::Option::Some(value) => std::option::Option::Some(value.as_str()),
std::option::Option::None => std::option::Option::None,
};
}
}
/// Typed lamport balance returned by the `getBalance` canary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GetBalanceResult {
@@ -245,10 +191,12 @@ impl crate::HttpTransportPool {
std::result::Result::Ok(decoded) => decoded,
std::result::Result::Err(error) => return invalid_canary_decode("getBalance", error),
};
return std::result::Result::Ok(crate::GetBalanceResult {
context: crate::SolanaRpcContext { slot: decoded.context.slot, api_version: decoded.context.api_version },
value: decoded.value,
});
let context = crate::SolanaRpcContext::decode_wire("getBalance", decoded.context);
let context = match context {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(crate::GetBalanceResult { context, value: decoded.value });
}
}
@@ -260,16 +208,9 @@ struct WireNodeVersion {
feature_set: std::option::Option<u32>,
}
#[derive(serde::Deserialize)]
struct WireRpcContext {
slot: u64,
#[serde(rename = "apiVersion", default)]
api_version: std::option::Option<std::string::String>,
}
#[derive(serde::Deserialize)]
struct WireBalanceResult {
context: WireRpcContext,
context: serde_json::Value,
value: u64,
}

View File

@@ -0,0 +1,577 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
// version: 1
/// Contact information returned for one cluster node.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaClusterNode {
pubkey: ksp_core_lib::Pubkey,
feature_set: std::option::Option<u32>,
gossip: std::option::Option<std::string::String>,
pubsub: std::option::Option<std::string::String>,
rpc: std::option::Option<std::string::String>,
serve_repair: std::option::Option<std::string::String>,
shred_version: std::option::Option<u16>,
tpu: std::option::Option<std::string::String>,
tpu_forwards: std::option::Option<std::string::String>,
tpu_forwards_quic: std::option::Option<std::string::String>,
tpu_quic: std::option::Option<std::string::String>,
tpu_vote: std::option::Option<std::string::String>,
tvu: std::option::Option<std::string::String>,
version: std::option::Option<std::string::String>,
client_id: std::option::Option<std::string::String>,
}
impl SolanaClusterNode {
/// Returns the node identity public key.
#[must_use]
pub const fn pubkey(&self) -> &ksp_core_lib::Pubkey { return &self.pubkey; }
/// Returns the optional feature-set identifier.
#[must_use]
pub const fn feature_set(&self) -> std::option::Option<u32> { return self.feature_set; }
/// Returns the optional gossip endpoint.
#[must_use]
pub fn gossip(&self) -> std::option::Option<&str> { return self.gossip.as_deref(); }
/// Returns the optional PubSub endpoint.
#[must_use]
pub fn pubsub(&self) -> std::option::Option<&str> { return self.pubsub.as_deref(); }
/// Returns the optional JSON-RPC endpoint.
#[must_use]
pub fn rpc(&self) -> std::option::Option<&str> { return self.rpc.as_deref(); }
/// Returns the optional repair endpoint.
#[must_use]
pub fn serve_repair(&self) -> std::option::Option<&str> { return self.serve_repair.as_deref(); }
/// Returns the optional shred version.
#[must_use]
pub const fn shred_version(&self) -> std::option::Option<u16> { return self.shred_version; }
/// Returns the optional TPU endpoint.
#[must_use]
pub fn tpu(&self) -> std::option::Option<&str> { return self.tpu.as_deref(); }
/// Returns the optional TPU forwards endpoint.
#[must_use]
pub fn tpu_forwards(&self) -> std::option::Option<&str> { return self.tpu_forwards.as_deref(); }
/// Returns the optional TPU forwards QUIC endpoint.
#[must_use]
pub fn tpu_forwards_quic(&self) -> std::option::Option<&str> { return self.tpu_forwards_quic.as_deref(); }
/// Returns the optional TPU QUIC endpoint.
#[must_use]
pub fn tpu_quic(&self) -> std::option::Option<&str> { return self.tpu_quic.as_deref(); }
/// Returns the optional TPU vote endpoint.
#[must_use]
pub fn tpu_vote(&self) -> std::option::Option<&str> { return self.tpu_vote.as_deref(); }
/// Returns the optional TVU endpoint.
#[must_use]
pub fn tvu(&self) -> std::option::Option<&str> { return self.tvu.as_deref(); }
/// Returns the optional software-version string.
#[must_use]
pub fn version(&self) -> std::option::Option<&str> { return self.version.as_deref(); }
/// Returns the optional Agave client identifier extension.
#[must_use]
pub fn client_id(&self) -> std::option::Option<&str> { return self.client_id.as_deref(); }
/// Decodes one cluster-node contact record from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireClusterNode>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let pubkey = crate::parse_wire_pubkey(method, "pubkey", wire.pubkey.as_str());
let pubkey = match pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self {
pubkey,
feature_set: wire.feature_set,
gossip: wire.gossip,
pubsub: wire.pubsub,
rpc: wire.rpc,
serve_repair: wire.serve_repair,
shred_version: wire.shred_version,
tpu: wire.tpu,
tpu_forwards: wire.tpu_forwards,
tpu_forwards_quic: wire.tpu_forwards_quic,
tpu_quic: wire.tpu_quic,
tpu_vote: wire.tpu_vote,
tvu: wire.tvu,
version: wire.version,
client_id: wire.client_id,
});
}
}
/// Epoch information returned by `getEpochInfo`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaEpochInfo {
absolute_slot: u64,
block_height: u64,
epoch: u64,
slot_index: u64,
slots_in_epoch: u64,
transaction_count: std::option::Option<u64>,
}
impl SolanaEpochInfo {
/// Returns the absolute slot.
#[must_use]
pub const fn absolute_slot(&self) -> u64 { return self.absolute_slot; }
/// Returns the block height.
#[must_use]
pub const fn block_height(&self) -> u64 { return self.block_height; }
/// Returns the epoch number.
#[must_use]
pub const fn epoch(&self) -> u64 { return self.epoch; }
/// Returns the slot index within the epoch.
#[must_use]
pub const fn slot_index(&self) -> u64 { return self.slot_index; }
/// Returns the number of slots in the epoch.
#[must_use]
pub const fn slots_in_epoch(&self) -> u64 { return self.slots_in_epoch; }
/// Returns the nullable transaction count.
#[must_use]
pub const fn transaction_count(&self) -> std::option::Option<u64> { return self.transaction_count; }
/// Decodes epoch information from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireEpochInfo>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
absolute_slot: wire.absolute_slot,
block_height: wire.block_height,
epoch: wire.epoch,
slot_index: wire.slot_index,
slots_in_epoch: wire.slots_in_epoch,
transaction_count: wire.transaction_count,
}),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Epoch schedule returned by `getEpochSchedule`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaEpochSchedule {
first_normal_epoch: u64,
first_normal_slot: u64,
leader_schedule_slot_offset: u64,
slots_per_epoch: u64,
warmup: bool,
}
impl SolanaEpochSchedule {
/// Returns the first normal epoch.
#[must_use]
pub const fn first_normal_epoch(&self) -> u64 { return self.first_normal_epoch; }
/// Returns the first normal slot.
#[must_use]
pub const fn first_normal_slot(&self) -> u64 { return self.first_normal_slot; }
/// Returns the leader-schedule slot offset.
#[must_use]
pub const fn leader_schedule_slot_offset(&self) -> u64 { return self.leader_schedule_slot_offset; }
/// Returns the number of slots per epoch.
#[must_use]
pub const fn slots_per_epoch(&self) -> u64 { return self.slots_per_epoch; }
/// Returns whether epoch warmup is enabled.
#[must_use]
pub const fn warmup(&self) -> bool { return self.warmup; }
/// Decodes an epoch schedule from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireEpochSchedule>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
first_normal_epoch: wire.first_normal_epoch,
first_normal_slot: wire.first_normal_slot,
leader_schedule_slot_offset: wire.leader_schedule_slot_offset,
slots_per_epoch: wire.slots_per_epoch,
warmup: wire.warmup,
}),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Highest full and optional incremental snapshot slots returned by `getHighestSnapshotSlot`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaSnapshotSlotInfo {
full: u64,
incremental: std::option::Option<u64>,
}
impl SolanaSnapshotSlotInfo {
/// Returns the highest full snapshot slot.
#[must_use]
pub const fn full(&self) -> u64 { return self.full; }
/// Returns the optional highest incremental snapshot slot.
#[must_use]
pub const fn incremental(&self) -> std::option::Option<u64> { return self.incremental; }
/// Decodes snapshot-slot information from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireSnapshotSlotInfo>(method, value);
return match decoded {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self { full: wire.full, incremental: wire.incremental }),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Optional configuration accepted by `getLeaderSchedule`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaLeaderScheduleConfig {
identity: std::option::Option<ksp_core_lib::Pubkey>,
commitment: std::option::Option<crate::SolanaCommitment>,
}
impl SolanaLeaderScheduleConfig {
/// Creates a leader-schedule configuration.
#[must_use]
pub const fn new(identity: std::option::Option<ksp_core_lib::Pubkey>, commitment: std::option::Option<crate::SolanaCommitment>) -> Self {
return Self { identity, commitment };
}
/// Returns the optional validator identity filter.
#[must_use]
pub const fn identity(&self) -> std::option::Option<&ksp_core_lib::Pubkey> { return self.identity.as_ref(); }
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> { return self.commitment; }
fn is_empty(&self) -> bool { return self.identity.is_none() && self.commitment.is_none(); }
fn to_json_value(&self) -> serde_json::Value {
let mut object = serde_json::Map::new();
if let std::option::Option::Some(identity) = self.identity.as_ref() {
object.insert("identity".to_owned(), serde_json::Value::String(identity.to_string()));
}
if let std::option::Option::Some(commitment) = self.commitment {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
return serde_json::Value::Object(object);
}
}
/// Typed parameter overload for `getLeaderSchedule`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaLeaderScheduleRequest {
/// Query the current epoch, optionally with a config object.
CurrentEpoch(std::option::Option<crate::SolanaLeaderScheduleConfig>),
/// Query the epoch containing one slot, optionally with a config object.
Slot {
/// Slot whose epoch should be queried.
slot: u64,
/// Optional leader-schedule config sent as the second positional parameter.
config: std::option::Option<crate::SolanaLeaderScheduleConfig>,
},
}
impl Default for SolanaLeaderScheduleRequest {
fn default() -> Self { return Self::CurrentEpoch(std::option::Option::None); }
}
impl SolanaLeaderScheduleRequest {
/// Serializes the typed overload to the exact positional JSON-RPC params.
#[must_use]
pub(crate) fn to_json_params(&self) -> std::vec::Vec<serde_json::Value> {
return match self {
Self::CurrentEpoch(std::option::Option::None) => std::vec::Vec::new(),
Self::CurrentEpoch(std::option::Option::Some(config)) if config.is_empty() => std::vec::Vec::new(),
Self::CurrentEpoch(std::option::Option::Some(config)) => std::vec![config.to_json_value()],
Self::Slot { slot, config: std::option::Option::None } => std::vec![serde_json::json!(slot)],
Self::Slot { slot, config: std::option::Option::Some(config) } if config.is_empty() => std::vec![serde_json::json!(slot)],
Self::Slot { slot, config: std::option::Option::Some(config) } => std::vec![serde_json::json!(slot), config.to_json_value()],
};
}
}
/// Leader schedule mapping validator identities to relative epoch slot indices.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaLeaderSchedule {
entries: std::collections::BTreeMap<ksp_core_lib::Pubkey, std::vec::Vec<usize>>,
}
impl SolanaLeaderSchedule {
/// Returns the complete leader schedule map.
#[must_use]
pub const fn entries(&self) -> &std::collections::BTreeMap<ksp_core_lib::Pubkey, std::vec::Vec<usize>> { return &self.entries; }
/// Decodes a leader schedule map from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<std::collections::BTreeMap<std::string::String, std::vec::Vec<usize>>>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut entries = std::collections::BTreeMap::new();
for (identity, slots) in wire {
let pubkey = crate::parse_wire_pubkey(method, "leader_identity", identity.as_str());
let pubkey = match pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
entries.insert(pubkey, slots);
}
return std::result::Result::Ok(Self { entries });
}
}
/// Configuration accepted by `getVoteAccounts`.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SolanaVoteAccountsConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
vote_pubkey: std::option::Option<ksp_core_lib::Pubkey>,
keep_unstaked_delinquents: std::option::Option<bool>,
delinquent_slot_distance: std::option::Option<u64>,
}
impl SolanaVoteAccountsConfig {
/// Creates a vote-accounts configuration.
#[must_use]
pub const fn new(
commitment: std::option::Option<crate::SolanaCommitment>,
vote_pubkey: std::option::Option<ksp_core_lib::Pubkey>,
keep_unstaked_delinquents: std::option::Option<bool>,
delinquent_slot_distance: std::option::Option<u64>,
) -> Self {
return Self { commitment, vote_pubkey, keep_unstaked_delinquents, delinquent_slot_distance };
}
/// Returns the optional commitment.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> { return self.commitment; }
/// Returns the optional vote-account public key filter.
#[must_use]
pub const fn vote_pubkey(&self) -> std::option::Option<&ksp_core_lib::Pubkey> { return self.vote_pubkey.as_ref(); }
/// Returns whether unstaked delinquent validators should be kept.
#[must_use]
pub const fn keep_unstaked_delinquents(&self) -> std::option::Option<bool> { return self.keep_unstaked_delinquents; }
/// Returns the optional delinquent slot distance.
#[must_use]
pub const fn delinquent_slot_distance(&self) -> std::option::Option<u64> { return self.delinquent_slot_distance; }
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
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 {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(vote_pubkey) = self.vote_pubkey.as_ref() {
object.insert("votePubkey".to_owned(), serde_json::Value::String(vote_pubkey.to_string()));
}
if let std::option::Option::Some(value) = self.keep_unstaked_delinquents {
object.insert("keepUnstakedDelinquents".to_owned(), serde_json::Value::Bool(value));
}
if let std::option::Option::Some(value) = self.delinquent_slot_distance {
object.insert("delinquentSlotDistance".to_owned(), serde_json::Value::Number(value.into()));
}
return serde_json::Value::Object(object);
}
}
/// One epoch-credit history entry returned by `getVoteAccounts`.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct SolanaEpochCredits {
epoch: u64,
credits: u64,
previous_credits: u64,
}
impl SolanaEpochCredits {
/// Returns the epoch number.
#[must_use]
pub const fn epoch(&self) -> u64 { return self.epoch; }
/// Returns cumulative credits at the end of the epoch.
#[must_use]
pub const fn credits(&self) -> u64 { return self.credits; }
/// Returns cumulative credits before the epoch.
#[must_use]
pub const fn previous_credits(&self) -> u64 { return self.previous_credits; }
}
/// One validator vote-account record returned by `getVoteAccounts`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaVoteAccountInfo {
vote_pubkey: ksp_core_lib::Pubkey,
node_pubkey: ksp_core_lib::Pubkey,
activated_stake: u64,
commission: u8,
inflation_rewards_commission_bps: std::option::Option<u16>,
epoch_vote_account: bool,
epoch_credits: std::vec::Vec<crate::SolanaEpochCredits>,
last_vote: u64,
root_slot: u64,
}
impl SolanaVoteAccountInfo {
/// Returns the vote account public key.
#[must_use]
pub const fn vote_pubkey(&self) -> &ksp_core_lib::Pubkey { return &self.vote_pubkey; }
/// Returns the validator identity public key.
#[must_use]
pub const fn node_pubkey(&self) -> &ksp_core_lib::Pubkey { return &self.node_pubkey; }
/// Returns the activated stake in lamports.
#[must_use]
pub const fn activated_stake(&self) -> u64 { return self.activated_stake; }
/// Returns the legacy/effective percentage commission field.
#[must_use]
pub const fn commission(&self) -> u8 { return self.commission; }
/// Returns the optional raw inflation-rewards commission in basis points.
#[must_use]
pub const fn inflation_rewards_commission_bps(&self) -> std::option::Option<u16> { return self.inflation_rewards_commission_bps; }
/// Returns whether the vote account is staked for the current epoch.
#[must_use]
pub const fn epoch_vote_account(&self) -> bool { return self.epoch_vote_account; }
/// Returns the bounded RPC epoch-credit history.
#[must_use]
pub fn epoch_credits(&self) -> &[crate::SolanaEpochCredits] { return self.epoch_credits.as_slice(); }
/// Returns the latest voted slot or zero when no vote exists.
#[must_use]
pub const fn last_vote(&self) -> u64 { return self.last_vote; }
/// Returns the current root slot or zero when no root exists.
#[must_use]
pub const fn root_slot(&self) -> u64 { return self.root_slot; }
/// Decodes one vote-account record from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireVoteAccountInfo>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let vote_pubkey = crate::parse_wire_pubkey(method, "votePubkey", wire.vote_pubkey.as_str());
let vote_pubkey = match vote_pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let node_pubkey = crate::parse_wire_pubkey(method, "nodePubkey", wire.node_pubkey.as_str());
let node_pubkey = match node_pubkey {
std::result::Result::Ok(pubkey) => pubkey,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut epoch_credits = std::vec::Vec::with_capacity(wire.epoch_credits.len());
for entry in wire.epoch_credits {
epoch_credits.push(crate::SolanaEpochCredits { epoch: entry[0], credits: entry[1], previous_credits: entry[2] });
}
return std::result::Result::Ok(Self {
vote_pubkey,
node_pubkey,
activated_stake: wire.activated_stake,
commission: wire.commission,
inflation_rewards_commission_bps: wire.inflation_rewards_commission_bps,
epoch_vote_account: wire.epoch_vote_account,
epoch_credits,
last_vote: wire.last_vote,
root_slot: wire.root_slot,
});
}
}
/// Current and delinquent validator vote-account groups returned by `getVoteAccounts`.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaVoteAccountStatus {
current: std::vec::Vec<crate::SolanaVoteAccountInfo>,
delinquent: std::vec::Vec<crate::SolanaVoteAccountInfo>,
}
impl SolanaVoteAccountStatus {
/// Returns current vote accounts.
#[must_use]
pub fn current(&self) -> &[crate::SolanaVoteAccountInfo] { return self.current.as_slice(); }
/// Returns delinquent vote accounts.
#[must_use]
pub fn delinquent(&self) -> &[crate::SolanaVoteAccountInfo] { return self.delinquent.as_slice(); }
/// Decodes the complete vote-account status response from the Solana JSON wire shape.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireVoteAccountStatus>(method, value);
let wire = match decoded {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let mut current = std::vec::Vec::with_capacity(wire.current.len());
for value in wire.current {
let decoded = crate::SolanaVoteAccountInfo::decode_wire(method, value);
match decoded {
std::result::Result::Ok(info) => current.push(info),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
let mut delinquent = std::vec::Vec::with_capacity(wire.delinquent.len());
for value in wire.delinquent {
let decoded = crate::SolanaVoteAccountInfo::decode_wire(method, value);
match decoded {
std::result::Result::Ok(info) => delinquent.push(info),
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
}
return std::result::Result::Ok(Self { current, delinquent });
}
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireClusterNode {
pubkey: std::string::String,
#[serde(default)] feature_set: std::option::Option<u32>,
#[serde(default)] gossip: std::option::Option<std::string::String>,
#[serde(default)] pubsub: std::option::Option<std::string::String>,
#[serde(default)] rpc: std::option::Option<std::string::String>,
#[serde(default)] serve_repair: std::option::Option<std::string::String>,
#[serde(default)] shred_version: std::option::Option<u16>,
#[serde(default)] tpu: std::option::Option<std::string::String>,
#[serde(default)] tpu_forwards: std::option::Option<std::string::String>,
#[serde(default)] tpu_forwards_quic: std::option::Option<std::string::String>,
#[serde(default)] tpu_quic: std::option::Option<std::string::String>,
#[serde(default)] tpu_vote: std::option::Option<std::string::String>,
#[serde(default)] tvu: std::option::Option<std::string::String>,
#[serde(default)] version: std::option::Option<std::string::String>,
#[serde(default)] client_id: std::option::Option<std::string::String>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireEpochInfo {
absolute_slot: u64,
block_height: u64,
epoch: u64,
slot_index: u64,
slots_in_epoch: u64,
transaction_count: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireEpochSchedule {
first_normal_epoch: u64,
first_normal_slot: u64,
leader_schedule_slot_offset: u64,
slots_per_epoch: u64,
warmup: bool,
}
#[derive(serde::Deserialize)]
struct WireSnapshotSlotInfo {
full: u64,
incremental: std::option::Option<u64>,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireVoteAccountInfo {
vote_pubkey: std::string::String,
node_pubkey: std::string::String,
activated_stake: u64,
commission: u8,
#[serde(default)]
inflation_rewards_commission_bps: std::option::Option<u16>,
epoch_vote_account: bool,
epoch_credits: std::vec::Vec<[u64; 3]>,
last_vote: u64,
root_slot: u64,
}
#[derive(serde::Deserialize)]
struct WireVoteAccountStatus {
current: std::vec::Vec<serde_json::Value>,
delinquent: std::vec::Vec<serde_json::Value>,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_cluster.rs"]
mod tests;

View File

@@ -0,0 +1,205 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_common.rs
// version: 1
/// Commitment level accepted by typed Solana HTTP RPC adapters.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum SolanaCommitment {
/// Query the most recent processed bank.
Processed,
/// Query a bank confirmed by cluster vote.
Confirmed,
/// Query a finalized bank.
Finalized,
}
impl SolanaCommitment {
/// Returns the Solana JSON-RPC commitment string.
#[must_use]
pub const fn as_str(self) -> &'static str {
return match self {
Self::Processed => "processed",
Self::Confirmed => "confirmed",
Self::Finalized => "finalized",
};
}
}
/// Optional commitment-only configuration shared by typed Solana HTTP RPC methods.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaCommitmentConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
}
impl SolanaCommitmentConfig {
/// Creates an explicit commitment-only configuration.
#[must_use]
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>) -> Self {
return Self { commitment };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns whether this config serializes to an empty JSON object.
#[must_use]
pub(crate) const fn is_empty(&self) -> bool {
return self.commitment.is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
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 {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
return serde_json::Value::Object(object);
}
}
/// Optional commitment and minimum-context configuration shared by typed Solana HTTP RPC methods.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SolanaContextConfig {
commitment: std::option::Option<crate::SolanaCommitment>,
min_context_slot: std::option::Option<u64>,
}
impl SolanaContextConfig {
/// Creates an explicit context-aware RPC configuration.
#[must_use]
pub const fn new(commitment: std::option::Option<crate::SolanaCommitment>, min_context_slot: std::option::Option<u64>) -> Self {
return Self { commitment, min_context_slot };
}
/// Returns the optional commitment level.
#[must_use]
pub const fn commitment(&self) -> std::option::Option<crate::SolanaCommitment> {
return self.commitment;
}
/// Returns the optional minimum context slot.
#[must_use]
pub const fn min_context_slot(&self) -> std::option::Option<u64> {
return self.min_context_slot;
}
/// Returns whether this config serializes to an empty JSON object.
#[must_use]
pub(crate) const fn is_empty(&self) -> bool {
return self.commitment.is_none() && self.min_context_slot.is_none();
}
/// Serializes this config to the Solana JSON-RPC wire object.
#[must_use]
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 {
object.insert("commitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned()));
}
if let std::option::Option::Some(min_context_slot) = self.min_context_slot {
object.insert("minContextSlot".to_owned(), serde_json::Value::Number(min_context_slot.into()));
}
return serde_json::Value::Object(object);
}
}
/// Typed Solana RPC context shared by contextual HTTP responses.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SolanaRpcContext {
slot: u64,
api_version: std::option::Option<std::string::String>,
}
impl SolanaRpcContext {
/// Returns the context slot reported by the RPC node.
#[must_use]
pub const fn slot(&self) -> u64 {
return self.slot;
}
/// Returns the optional RPC API version reported by the node.
#[must_use]
pub fn api_version(&self) -> std::option::Option<&str> {
return match self.api_version.as_ref() {
std::option::Option::Some(value) => std::option::Option::Some(value.as_str()),
std::option::Option::None => std::option::Option::None,
};
}
/// Decodes one RPC context from a parsed JSON value.
pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<Self> {
let decoded = crate::decode_wire_json::<WireRpcContext>(method, value);
let context = match decoded {
std::result::Result::Ok(context) => context,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return std::result::Result::Ok(Self { slot: context.slot, api_version: context.api_version });
}
}
/// Generic contextual result returned by typed Solana HTTP RPC adapters.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaRpcResponse<T> {
context: crate::SolanaRpcContext,
value: T,
}
impl<T> SolanaRpcResponse<T> {
/// Returns the Solana response context.
#[must_use]
pub const fn context(&self) -> &crate::SolanaRpcContext {
return &self.context;
}
/// Returns the typed response value.
#[must_use]
pub const fn value(&self) -> &T {
return &self.value;
}
/// Creates a contextual response after wire decoding and validation.
#[must_use]
pub(crate) const fn new(context: crate::SolanaRpcContext, value: T) -> Self {
return Self { context, value };
}
}
/// Decodes one private serde wire type and maps shape failures to the shared Transport error domain.
pub(crate) fn decode_wire_json<T: serde::de::DeserializeOwned>(method: &str, value: serde_json::Value) -> ksp_core_lib::Result<T> {
let decoded = serde_json::from_value::<T>(value);
return match decoded {
std::result::Result::Ok(decoded) => std::result::Result::Ok(decoded),
std::result::Result::Err(error) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response has an invalid wire shape")
.with_context("rpc_method", method)
.with_source(error),
),
};
}
/// Parses a base58 public key from one wire field without echoing its value into diagnostics.
pub(crate) fn parse_wire_pubkey(method: &str, field: &str, value: &str) -> ksp_core_lib::Result<ksp_core_lib::Pubkey> {
let parsed = value.parse::<ksp_core_lib::Pubkey>();
return match parsed {
std::result::Result::Ok(pubkey) => std::result::Result::Ok(pubkey),
std::result::Result::Err(_) => std::result::Result::Err(
ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "typed Solana HTTP response contains an invalid public key")
.with_context("rpc_method", method)
.with_context("field", field),
),
};
}
#[derive(serde::Deserialize)]
struct WireRpcContext {
slot: u64,
#[serde(rename = "apiVersion", default)]
api_version: std::option::Option<std::string::String>,
}
#[cfg(test)]
#[path = "../unit_tests/rpc_common.rs"]
mod tests;

View File

@@ -0,0 +1,138 @@
// file: crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
// version: 1
/// Exclusive selector accepted by token-account list RPC methods.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SolanaTokenAccountSelector {
/// Select token accounts for one mint.
Mint(ksp_core_lib::Pubkey),
/// Select token accounts owned by one token program.
ProgramId(ksp_core_lib::Pubkey),
}
impl SolanaTokenAccountSelector {
/// Serializes the exclusive selector to the Solana JSON-RPC wire object.
#[must_use]
pub(crate) fn to_json_value(&self) -> serde_json::Value {
return match self {
Self::Mint(pubkey) => serde_json::json!({"mint": pubkey.to_string()}),
Self::ProgramId(pubkey) => serde_json::json!({"programId": pubkey.to_string()}),
};
}
}
/// Token amount returned by Solana HTTP token RPC methods.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaTokenAmount {
amount: std::string::String,
decimals: u8,
ui_amount: std::option::Option<f64>,
ui_amount_string: std::string::String,
}
impl SolanaTokenAmount {
/// Returns the integer token amount as an exact decimal string.
#[must_use]
pub fn amount(&self) -> &str {
return self.amount.as_str();
}
/// Returns the mint decimal precision.
#[must_use]
pub const fn decimals(&self) -> u8 {
return self.decimals;
}
/// Returns the nullable floating-point UI amount exactly as provided by RPC.
#[must_use]
pub const fn ui_amount(&self) -> std::option::Option<f64> {
return self.ui_amount;
}
/// Returns the exact UI amount string provided by RPC.
#[must_use]
pub fn ui_amount_string(&self) -> &str {
return self.ui_amount_string.as_str();
}
/// Decodes one token amount from the Solana JSON wire shape.
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 {
std::result::Result::Ok(wire) => std::result::Result::Ok(Self {
amount: wire.amount,
decimals: wire.decimals,
ui_amount: wire.ui_amount,
ui_amount_string: wire.ui_amount_string,
}),
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
}
/// Token-account balance entry returned by `getTokenLargestAccounts`.
#[derive(Clone, Debug, PartialEq)]
pub struct SolanaTokenAccountBalance {
address: ksp_core_lib::Pubkey,
amount: crate::SolanaTokenAmount,
}
impl SolanaTokenAccountBalance {
/// Returns the token account address.
#[must_use]
pub const fn address(&self) -> &ksp_core_lib::Pubkey {
return &self.address;
}
/// Returns the token amount fields.
#[must_use]
pub const fn amount(&self) -> &crate::SolanaTokenAmount {
return &self.amount;
}
/// Decodes one token-account balance entry from the Solana JSON wire shape.
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 {
std::result::Result::Ok(wire) => wire,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let address = crate::parse_wire_pubkey(method, "address", wire.address.as_str());
let address = match address {
std::result::Result::Ok(address) => address,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let amount = crate::SolanaTokenAmount {
amount: wire.amount,
decimals: wire.decimals,
ui_amount: wire.ui_amount,
ui_amount_string: wire.ui_amount_string,
};
return std::result::Result::Ok(Self { address, amount });
}
}
#[derive(serde::Deserialize)]
struct WireTokenAmount {
amount: std::string::String,
decimals: u8,
#[serde(rename = "uiAmount")]
ui_amount: std::option::Option<f64>,
#[serde(rename = "uiAmountString")]
ui_amount_string: std::string::String,
}
#[derive(serde::Deserialize)]
struct WireTokenAccountBalance {
address: std::string::String,
amount: std::string::String,
decimals: u8,
#[serde(rename = "uiAmount")]
ui_amount: std::option::Option<f64>,
#[serde(rename = "uiAmountString")]
ui_amount_string: std::string::String,
}
#[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: 5
// version: 6
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
@@ -171,3 +171,31 @@ fn public_typed_canary_contracts_are_available_from_crate_root() {
assert_eq!(config.min_context_slot(), std::option::Option::Some(42));
assert_eq!(ksp_onchain_transport_lib::SolanaNodeHealth::Healthy, ksp_onchain_transport_lib::SolanaNodeHealth::Healthy);
}
#[test]
fn public_pre_002_shared_rpc_types_are_constructible_from_crate_root() {
let context = ksp_onchain_transport_lib::SolanaContextConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::Some(123),
);
assert_eq!(context.commitment(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized));
assert_eq!(context.min_context_slot(), std::option::Option::Some(123));
let account = ksp_onchain_transport_lib::SolanaAccountInfoConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::JsonParsed),
std::option::Option::None,
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed),
std::option::Option::None,
);
assert_eq!(account.encoding(), std::option::Option::Some(ksp_onchain_transport_lib::SolanaAccountEncoding::JsonParsed));
let pubkey = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let selector = ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(pubkey);
assert!(matches!(selector, ksp_onchain_transport_lib::SolanaTokenAccountSelector::Mint(_)));
let vote = ksp_onchain_transport_lib::SolanaVoteAccountsConfig::new(
std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized),
std::option::Option::Some(pubkey),
std::option::Option::Some(true),
std::option::Option::Some(128),
);
assert_eq!(vote.vote_pubkey(), std::option::Option::Some(&pubkey));
}

View File

@@ -0,0 +1,53 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
// version: 1
#[test]
fn account_config_serializes_all_common_fields() {
let config = crate::SolanaAccountInfoConfig::new(
std::option::Option::Some(crate::SolanaAccountEncoding::Base64),
std::option::Option::Some(crate::SolanaDataSliceConfig::new(8, 32)),
std::option::Option::Some(crate::SolanaCommitment::Finalized),
std::option::Option::Some(99),
);
assert_eq!(
config.to_json_value(),
serde_json::json!({"encoding":"base64","dataSlice":{"offset":8,"length":32},"commitment":"finalized","minContextSlot":99})
);
}
#[test]
fn program_accounts_config_preserves_filter_variants_and_flags() {
let filters = std::vec![
crate::SolanaProgramAccountFilter::DataSize(165),
crate::SolanaProgramAccountFilter::Memcmp(crate::SolanaMemcmpFilter::new(4, crate::SolanaMemcmpBytes::Base64("AQID".to_owned()))),
crate::SolanaProgramAccountFilter::TokenAccountState,
];
let config = crate::SolanaProgramAccountsConfig::new(crate::SolanaAccountInfoConfig::default(), filters, std::option::Option::Some(true), std::option::Option::Some(true));
assert_eq!(
config.to_json_value(),
serde_json::json!({
"filters":[{"dataSize":165},{"memcmp":{"offset":4,"bytes":"AQID","encoding":"base64"}},"tokenAccountState"],
"withContext":true,
"sortResults":true
})
);
}
#[test]
fn account_wire_fixture_preserves_legacy_encoded_and_json_parsed_data() {
let values: std::vec::Vec<serde_json::Value> = serde_json::from_str(include_str!("../fixtures/http/account_data.variants.json")).expect("fixture must decode");
let legacy = crate::SolanaAccount::decode_wire("fixture", values[0].clone()).expect("legacy account must decode");
assert!(matches!(legacy.data(), crate::SolanaAccountData::LegacyBinary(_)));
assert_eq!(legacy.space(), std::option::Option::None);
let encoded = crate::SolanaAccount::decode_wire("fixture", values[1].clone()).expect("encoded account must decode");
assert!(matches!(encoded.data(), crate::SolanaAccountData::Encoded { encoding: crate::SolanaAccountEncoding::Base64Zstd, .. }));
let parsed = crate::SolanaAccount::decode_wire("fixture", values[2].clone()).expect("parsed account must decode");
match parsed.data() {
crate::SolanaAccountData::JsonParsed(value) => {
assert_eq!(value.program(), "spl-token");
assert_eq!(value.space(), 165);
assert_eq!(value.parsed()["type"], serde_json::json!("account"));
}
_ => assert!(false, "jsonParsed fixture must retain parsed data"),
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
// version: 1
// version: 2
fn pool_for_url(url: &str) -> crate::HttpTransportPool {
let role = crate::HttpEndpointRoleSettings::new(

View File

@@ -0,0 +1,52 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs
// version: 1
#[test]
fn cluster_node_fixture_preserves_v4_client_id_and_optional_fields() {
let value: serde_json::Value = serde_json::from_str(include_str!("../fixtures/http/cluster_node.v4_2_1.json")).expect("fixture must decode");
let node = crate::SolanaClusterNode::decode_wire("getClusterNodes", value).expect("cluster node must decode");
assert_eq!(node.client_id(), std::option::Option::Some("Agave"));
assert_eq!(node.rpc(), std::option::Option::Some("127.0.0.1:8899"));
assert_eq!(node.pubsub(), std::option::Option::None);
}
#[test]
fn vote_account_fixture_preserves_optional_basis_point_commission_and_epoch_credit_triples() {
let value: serde_json::Value = serde_json::from_str(include_str!("../fixtures/http/vote_account.v4_2_1.json")).expect("fixture must decode");
let info = crate::SolanaVoteAccountInfo::decode_wire("getVoteAccounts", value).expect("vote account must decode");
assert_eq!(info.commission(), 8);
assert_eq!(info.inflation_rewards_commission_bps(), std::option::Option::Some(750));
assert_eq!(info.epoch_credits().len(), 2);
assert_eq!(info.epoch_credits()[0].epoch(), 700);
assert_eq!(info.epoch_credits()[0].previous_credits(), 90);
}
#[test]
fn vote_account_wire_accepts_nodes_predating_basis_point_commission_field() {
let value = serde_json::json!({
"votePubkey":"11111111111111111111111111111111",
"nodePubkey":"11111111111111111111111111111111",
"activatedStake":1,
"commission":5,
"epochVoteAccount":true,
"epochCredits":[],
"lastVote":2,
"rootSlot":1
});
let info = crate::SolanaVoteAccountInfo::decode_wire("getVoteAccounts", value).expect("legacy node shape must decode");
assert_eq!(info.inflation_rewards_commission_bps(), std::option::Option::None);
}
#[test]
fn leader_schedule_request_encodes_current_epoch_and_slot_overloads_without_ambiguous_arrays() {
let identity = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
let config = crate::SolanaLeaderScheduleConfig::new(std::option::Option::Some(identity), std::option::Option::Some(crate::SolanaCommitment::Finalized));
assert_eq!(
crate::SolanaLeaderScheduleRequest::CurrentEpoch(std::option::Option::Some(config.clone())).to_json_params(),
std::vec![serde_json::json!({"identity":"11111111111111111111111111111111","commitment":"finalized"})]
);
assert_eq!(
crate::SolanaLeaderScheduleRequest::Slot { slot: 123, config: std::option::Option::Some(config) }.to_json_params(),
std::vec![serde_json::json!(123), serde_json::json!({"identity":"11111111111111111111111111111111","commitment":"finalized"})]
);
}

View File

@@ -0,0 +1,19 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_common.rs
// version: 1
#[test]
fn shared_context_configs_preserve_commitment_and_min_context_slot() {
let commitment = crate::SolanaCommitmentConfig::new(std::option::Option::Some(crate::SolanaCommitment::Finalized));
assert_eq!(commitment.to_json_value(), serde_json::json!({"commitment":"finalized"}));
let context = crate::SolanaContextConfig::new(std::option::Option::Some(crate::SolanaCommitment::Confirmed), std::option::Option::Some(42));
assert_eq!(context.to_json_value(), serde_json::json!({"commitment":"confirmed","minContextSlot":42}));
}
#[test]
fn rpc_context_preserves_nullable_api_version() {
let with_version = crate::SolanaRpcContext::decode_wire("fixture", serde_json::json!({"slot":10,"apiVersion":"4.2.1"})).expect("context must decode");
assert_eq!(with_version.slot(), 10);
assert_eq!(with_version.api_version(), std::option::Option::Some("4.2.1"));
let without_version = crate::SolanaRpcContext::decode_wire("fixture", serde_json::json!({"slot":11})).expect("context must decode");
assert_eq!(without_version.api_version(), std::option::Option::None);
}

View File

@@ -0,0 +1,19 @@
// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs
// version: 1
#[test]
fn token_selector_is_exclusive_by_construction() {
let mint = "11111111111111111111111111111111".parse::<ksp_core_lib::Pubkey>().expect("fixture pubkey must parse");
assert_eq!(crate::SolanaTokenAccountSelector::Mint(mint).to_json_value(), serde_json::json!({"mint":"11111111111111111111111111111111"}));
assert_eq!(crate::SolanaTokenAccountSelector::ProgramId(mint).to_json_value(), serde_json::json!({"programId":"11111111111111111111111111111111"}));
}
#[test]
fn token_amount_fixture_preserves_nullable_ui_amount() {
let value: serde_json::Value = serde_json::from_str(include_str!("../fixtures/http/token_amount.null_ui.json")).expect("fixture must decode");
let amount = crate::SolanaTokenAmount::decode_wire("fixture", value).expect("token amount must decode");
assert_eq!(amount.amount(), "18446744073709551615");
assert_eq!(amount.decimals(), 9);
assert_eq!(amount.ui_amount(), std::option::Option::None);
assert_eq!(amount.ui_amount_string(), "18446744073.709551615");
}

227
deltas/0.2.2/pre.002.md Normal file
View File

@@ -0,0 +1,227 @@
<!-- file: deltas/0.2.2/pre.002.md -->
<!-- version: 1 -->
# Delta `0.2.2-pre.002` — primitives RPC Accounts/Tokens/Cluster et wire commun
## Base requise
Livraison précédente corrigée :
```text
0.2.2-pre.001-fix.001
workspace.package.version = "0.2.2-pre.1"
```
Le plan canonique attendu est `docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md` version 3, qui inclut le réaudit Agave `v4.2.1`.
## Objectif
Installer les primitives/configurations/résultats partagés nécessaires aux 22 wrappers `0.2.2`, sans encore implémenter les cinq wrappers
Accounts prévus pour `pre.003`. La tranche mutualise les contrats nés avec les canaris `0.2.1`, fixe les formes wire Account/Token/Cluster et
ajoute des fixtures déterministes pour les variantes nullable/versionnées identifiées pendant `pre.001` et son fix.
## Version Cargo
Conformément à `VER-ID-009`, la nouvelle prerelease synchronise le signal technique :
```text
0.2.2-pre.1 -> 0.2.2-pre.2
```
Aucune dépendance ou feature Cargo n'est ajoutée.
## Implémentation
### RPC commun
Un nouveau module commun possède désormais :
```text
SolanaCommitment
SolanaCommitmentConfig
SolanaContextConfig
SolanaRpcContext
SolanaRpcResponse<T>
```
`SolanaCommitment` et `SolanaRpcContext` ont été déplacés hors du module canari sans casser leurs réexports crate-root. `GetBalanceConfig`
conserve son nom, ses champs internes, son constructeur, ses getters et sa forme `Debug` `0.2.1`; seule sa sérialisation réutilise désormais
`SolanaContextConfig`.
Les helpers internes de décodage :
- convertissent les erreurs serde vers `ERROR_CODE_INVALID_RESPONSE` ;
- convertissent explicitement les chaînes de public key vers `ksp_core_lib::Pubkey` ;
- n'incluent pas la valeur de public key invalide dans les diagnostics.
### Accounts
Les primitives suivantes sont ajoutées :
```text
SolanaAccountEncoding
SolanaDataSliceConfig
SolanaAccountInfoConfig
SolanaLargestAccountsFilter
SolanaLargestAccountsConfig
SolanaMemcmpBytes
SolanaMemcmpFilter
SolanaProgramAccountFilter
SolanaProgramAccountsConfig
SolanaParsedAccountData
SolanaAccountData
SolanaAccount
SolanaKeyedAccount
SolanaAccountBalance
SolanaProgramAccountsResult
```
`SolanaAccountData` préserve les trois familles de wire : chaîne legacy, tuple encodé et `jsonParsed`. Le contenu `parsed` reste un
`serde_json::Value`; aucun décodage Program/SPL n'entre dans Transport. `space` reste `Option<u64>`.
`SolanaProgramAccountFilter` conserve `dataSize`, `memcmp` et `tokenAccountState`. Les bytes `memcmp` peuvent rester base58/base64 textuels ou
raw sans ajouter `bs58`/`base64`; les limites de cardinalité et de taille seront appliquées par les wrappers/validation `pre.003` conformément au
plan.
### Tokens
Les primitives suivantes sont ajoutées :
```text
SolanaTokenAccountSelector
SolanaTokenAmount
SolanaTokenAccountBalance
```
Le selector est un enum `Mint | ProgramId`, ce qui interdit par construction un objet contenant simultanément les deux clés. `uiAmount` reste
`Option<f64>` et `amount`/`uiAmountString` restent des chaînes exactes du wire.
### Cluster
Les DTOs/configs partagés suivants sont ajoutés :
```text
SolanaClusterNode
SolanaEpochInfo
SolanaEpochSchedule
SolanaSnapshotSlotInfo
SolanaLeaderScheduleConfig
SolanaLeaderScheduleRequest
SolanaLeaderSchedule
SolanaVoteAccountsConfig
SolanaEpochCredits
SolanaVoteAccountInfo
SolanaVoteAccountStatus
```
Le DTO node conserve tous les endpoints comme chaînes optionnelles et inclut `clientId: Option<String>` confirmé dans Agave `v4.2.1`.
`SolanaVoteAccountInfo` inclut `inflationRewardsCommissionBps: Option<u16>` sans le dériver de `commission`, ainsi que les triples epoch credits
sous un petit DTO nommé. `SolanaLeaderScheduleRequest` encode explicitement les overloads current-epoch/config et slot/config.
## Fixtures déterministes ajoutées
```text
crates/ksp-onchain-transport-lib/fixtures/http/account_data.variants.json
crates/ksp-onchain-transport-lib/fixtures/http/token_amount.null_ui.json
crates/ksp-onchain-transport-lib/fixtures/http/cluster_node.v4_2_1.json
crates/ksp-onchain-transport-lib/fixtures/http/vote_account.v4_2_1.json
```
Elles couvrent notamment legacy/encoded/jsonParsed Account data, `space: null`, `uiAmount: null`, `clientId`, champs Cluster absents et présence
de `inflationRewardsCommissionBps`. Un test supplémentaire vérifie l'absence de ce dernier champ pour une forme de noeud plus ancienne.
## Fichiers ajoutés
```text
crates/ksp-onchain-transport-lib/src/rpc_common.rs
crates/ksp-onchain-transport-lib/src/rpc_accounts.rs
crates/ksp-onchain-transport-lib/src/rpc_tokens.rs
crates/ksp-onchain-transport-lib/src/rpc_cluster.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_common.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs
crates/ksp-onchain-transport-lib/fixtures/http/account_data.variants.json
crates/ksp-onchain-transport-lib/fixtures/http/token_amount.null_ui.json
crates/ksp-onchain-transport-lib/fixtures/http/cluster_node.v4_2_1.json
crates/ksp-onchain-transport-lib/fixtures/http/vote_account.v4_2_1.json
deltas/0.2.2/pre.002.md
```
## Fichiers modifiés
```text
Cargo.toml
crates/ksp-onchain-transport-lib/src/lib.rs
crates/ksp-onchain-transport-lib/src/rpc_canary.rs
crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs
crates/ksp-onchain-transport-lib/tests/public_api.rs
```
## Fichiers supprimés
Aucun.
## Fichiers volontairement inchangés
```text
CHANGELOG.md
ROADMAP.md
docs/plans/009-V0_2_2_HTTP_ACCOUNTS_TOKENS_CLUSTER_PLAN.md
crates/ksp-onchain-transport-lib/Cargo.toml
crates/ksp-onchain-transport-lib/src/executor.rs
crates/ksp-onchain-transport-lib/src/rpc_method.rs
crates/ksp-config-lib/**
config/**
```
Le plan ne change pas : `pre.003` reste consacré aux cinq wrappers Accounts.
## Validations exécutées
- reconstruction locale de l'état `v0.2.1 + pre.001 + pre.001-fix.001` à partir des archives fournies ;
- relecture de `RULES_RUST.md`, `FILE_CONTRACTS.md`, `VERSION_WORKFLOW.md` et du plan `009` version 3 ;
- recoupement des structures account/token Agave `v4.2.1` (`UiAccount`, `UiAccountData`, `UiAccountEncoding`, `UiTokenAmount`) ;
- recoupement des extensions Cluster/Vote Agave `v4.2.1` (`client_id`, `inflation_rewards_commission_bps`) ;
- contrôle statique des réexports crate-root et de l'absence de nouvelle dépendance Cargo ;
- contrôle statique de l'absence de wrapper `0.2.2` ajouté prématurément dans cette tranche ;
- contrôle des fixtures JSON avec un parseur JSON local ;
- contrôle du contenu de l'archive d'échange après génération.
## Validations non exécutées
Le sandbox ne fournit pas `cargo` ni `rustfmt`. Les commandes suivantes n'ont donc pas été déclarées comme réussies et doivent être exécutées
sur le checkout de développement avant commit :
```bash
cargo fmt --all
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-onchain-transport-lib
```
Conformément à `RUST-*`, `cargo test --workspace` reste requis au point de contrôle de session/clôture approprié. Aucun `cargo tree` supplémentaire
n'est requis par ce delta, puisqu'aucune dépendance ni feature n'a changé.
## Décisions prises
- conserver les quatre canaris `0.2.1` et leurs noms publics ;
- centraliser les primitives réellement communes plutôt que créer un mega DTO ;
- utiliser `ksp_core_lib::Pubkey` dans les DTOs publics, avec conversion wire explicite ;
- ne pas activer `serde` sur `solana-pubkey` uniquement pour les réponses RPC ;
- ne pas ajouter `base64`, `bs58`, SPL ou client RPC haut niveau ;
- conserver les champs provider/versionnés optionnels au lieu de les rendre obligatoires ;
- ne pas ajouter de logs par DTO : l'observabilité reste au niveau du transport/executor.
## Questions ouvertes
Aucune question bloquante pour `pre.003`.
Le formatage final exact doit être produit par le `rustfmt` canonique du dépôt lors de l'application locale, puisque le binaire n'est pas disponible
dans le sandbox d'échange.
## Suite
`0.2.2-pre.003` : implémenter les cinq wrappers Accounts (`getAccountInfo`, `getLargestAccounts`, `getMinimumBalanceForRentExemption`,
`getMultipleAccounts`, `getProgramAccounts`) avec validation de cardinalité/filtres, décodage via les DTOs de cette tranche, serveur HTTP local et
fixtures déterministes.