diff --git a/Cargo.toml b/Cargo.toml index dde83bb..f9e8269 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/account_data.variants.json b/crates/ksp-onchain-transport-lib/fixtures/http/account_data.variants.json new file mode 100644 index 0000000..7d979b7 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/account_data.variants.json @@ -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} +] diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/cluster_node.v4_2_1.json b/crates/ksp-onchain-transport-lib/fixtures/http/cluster_node.v4_2_1.json new file mode 100644 index 0000000..bda08c4 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/cluster_node.v4_2_1.json @@ -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"} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/token_amount.null_ui.json b/crates/ksp-onchain-transport-lib/fixtures/http/token_amount.null_ui.json new file mode 100644 index 0000000..28275e4 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/token_amount.null_ui.json @@ -0,0 +1 @@ +{"amount":"18446744073709551615","decimals":9,"uiAmount":null,"uiAmountString":"18446744073.709551615"} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/vote_account.v4_2_1.json b/crates/ksp-onchain-transport-lib/fixtures/http/vote_account.v4_2_1.json new file mode 100644 index 0000000..b405f5c --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/vote_account.v4_2_1.json @@ -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} diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index bbd926c..44e0d72 100644 --- a/crates/ksp-onchain-transport-lib/src/lib.rs +++ b/crates/ksp-onchain-transport-lib/src/lib.rs @@ -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. diff --git a/crates/ksp-onchain-transport-lib/src/rpc_accounts.rs b/crates/ksp-onchain-transport-lib/src/rpc_accounts.rs new file mode 100644 index 0000000..7d00421 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/rpc_accounts.rs @@ -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 { + 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, + data_slice: std::option::Option, + context: crate::SolanaContextConfig, +} + +impl SolanaAccountInfoConfig { + /// Creates an explicit account-info configuration. + #[must_use] + pub const fn new( + encoding: std::option::Option, + data_slice: std::option::Option, + commitment: std::option::Option, + min_context_slot: std::option::Option, + ) -> 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 { + return self.encoding; + } + + /// Returns the optional account-data slice. + #[must_use] + pub const fn data_slice(&self) -> std::option::Option { + return self.data_slice; + } + + /// Returns the optional commitment level. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.context.commitment(); + } + + /// Returns the optional minimum context slot. + #[must_use] + pub const fn min_context_slot(&self) -> std::option::Option { + 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, + filter: std::option::Option, + sort_results: std::option::Option, +} + +impl SolanaLargestAccountsConfig { + /// Creates a largest-accounts configuration. + #[must_use] + pub const fn new( + commitment: std::option::Option, + filter: std::option::Option, + sort_results: std::option::Option, + ) -> Self { + return Self { commitment, filter, sort_results }; + } + + /// Returns the optional commitment. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.commitment; + } + + /// Returns the optional circulating-account filter. + #[must_use] + pub const fn filter(&self) -> std::option::Option { + return self.filter; + } + + /// Returns the optional server-side result-sorting request. + #[must_use] + pub const fn sort_results(&self) -> std::option::Option { + 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), +} + +/// 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, + with_context: std::option::Option, + sort_results: std::option::Option, +} + +impl SolanaProgramAccountsConfig { + /// Creates a program-accounts configuration. + #[must_use] + pub fn new( + account_config: crate::SolanaAccountInfoConfig, + filters: std::vec::Vec, + with_context: std::option::Option, + sort_results: std::option::Option, + ) -> 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 { + return self.with_context; + } + + /// Returns the optional server-side sorting request. + #[must_use] + pub const fn sort_results(&self) -> std::option::Option { + 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::>(); + 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 { + let decoded = crate::decode_wire_json::(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, +} + +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 { + 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 { + let decoded = crate::decode_wire_json::(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 { + let decoded = crate::decode_wire_json::(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 { + let decoded = crate::decode_wire_json::(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), + /// Contextual account list returned when `withContext` is true. + Context(crate::SolanaRpcResponse>), +} + +#[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, +} + +#[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; diff --git a/crates/ksp-onchain-transport-lib/src/rpc_canary.rs b/crates/ksp-onchain-transport-lib/src/rpc_canary.rs index 2dd8c13..12d60d8 100644 --- a/crates/ksp-onchain-transport-lib/src/rpc_canary.rs +++ b/crates/ksp-onchain-transport-lib/src/rpc_canary.rs @@ -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, @@ -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, -} - -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, } -#[derive(serde::Deserialize)] -struct WireRpcContext { - slot: u64, - #[serde(rename = "apiVersion", default)] - api_version: std::option::Option, -} - #[derive(serde::Deserialize)] struct WireBalanceResult { - context: WireRpcContext, + context: serde_json::Value, value: u64, } diff --git a/crates/ksp-onchain-transport-lib/src/rpc_cluster.rs b/crates/ksp-onchain-transport-lib/src/rpc_cluster.rs new file mode 100644 index 0000000..253ac0d --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/rpc_cluster.rs @@ -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, + gossip: std::option::Option, + pubsub: std::option::Option, + rpc: std::option::Option, + serve_repair: std::option::Option, + shred_version: std::option::Option, + tpu: std::option::Option, + tpu_forwards: std::option::Option, + tpu_forwards_quic: std::option::Option, + tpu_quic: std::option::Option, + tpu_vote: std::option::Option, + tvu: std::option::Option, + version: std::option::Option, + client_id: std::option::Option, +} + +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 { 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 { 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 { + let decoded = crate::decode_wire_json::(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, +} + +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 { 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 { + let decoded = crate::decode_wire_json::(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 { + let decoded = crate::decode_wire_json::(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, +} + +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 { 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 { + let decoded = crate::decode_wire_json::(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, + commitment: std::option::Option, +} + +impl SolanaLeaderScheduleConfig { + /// Creates a leader-schedule configuration. + #[must_use] + pub const fn new(identity: std::option::Option, commitment: std::option::Option) -> 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 { 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), + /// 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, + }, +} + +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 { + 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>, +} + +impl SolanaLeaderSchedule { + /// Returns the complete leader schedule map. + #[must_use] + pub const fn entries(&self) -> &std::collections::BTreeMap> { 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 { + let decoded = crate::decode_wire_json::>>(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, + vote_pubkey: std::option::Option, + keep_unstaked_delinquents: std::option::Option, + delinquent_slot_distance: std::option::Option, +} + +impl SolanaVoteAccountsConfig { + /// Creates a vote-accounts configuration. + #[must_use] + pub const fn new( + commitment: std::option::Option, + vote_pubkey: std::option::Option, + keep_unstaked_delinquents: std::option::Option, + delinquent_slot_distance: std::option::Option, + ) -> 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 { 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 { return self.keep_unstaked_delinquents; } + /// Returns the optional delinquent slot distance. + #[must_use] + pub const fn delinquent_slot_distance(&self) -> std::option::Option { 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, + epoch_vote_account: bool, + epoch_credits: std::vec::Vec, + 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 { 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 { + let decoded = crate::decode_wire_json::(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, + delinquent: std::vec::Vec, +} + +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 { + let decoded = crate::decode_wire_json::(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, + #[serde(default)] gossip: std::option::Option, + #[serde(default)] pubsub: std::option::Option, + #[serde(default)] rpc: std::option::Option, + #[serde(default)] serve_repair: std::option::Option, + #[serde(default)] shred_version: std::option::Option, + #[serde(default)] tpu: std::option::Option, + #[serde(default)] tpu_forwards: std::option::Option, + #[serde(default)] tpu_forwards_quic: std::option::Option, + #[serde(default)] tpu_quic: std::option::Option, + #[serde(default)] tpu_vote: std::option::Option, + #[serde(default)] tvu: std::option::Option, + #[serde(default)] version: std::option::Option, + #[serde(default)] client_id: std::option::Option, +} + +#[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, +} + +#[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, +} + +#[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, + 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, + delinquent: std::vec::Vec, +} + +#[cfg(test)] +#[path = "../unit_tests/rpc_cluster.rs"] +mod tests; diff --git a/crates/ksp-onchain-transport-lib/src/rpc_common.rs b/crates/ksp-onchain-transport-lib/src/rpc_common.rs new file mode 100644 index 0000000..6bfc146 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/rpc_common.rs @@ -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, +} + +impl SolanaCommitmentConfig { + /// Creates an explicit commitment-only configuration. + #[must_use] + pub const fn new(commitment: std::option::Option) -> Self { + return Self { commitment }; + } + + /// Returns the optional commitment level. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + 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, + min_context_slot: std::option::Option, +} + +impl SolanaContextConfig { + /// Creates an explicit context-aware RPC configuration. + #[must_use] + pub const fn new(commitment: std::option::Option, min_context_slot: std::option::Option) -> Self { + return Self { commitment, min_context_slot }; + } + + /// Returns the optional commitment level. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.commitment; + } + + /// Returns the optional minimum context slot. + #[must_use] + pub const fn min_context_slot(&self) -> std::option::Option { + 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, +} + +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 { + let decoded = crate::decode_wire_json::(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 { + context: crate::SolanaRpcContext, + value: T, +} + +impl SolanaRpcResponse { + /// 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(method: &str, value: serde_json::Value) -> ksp_core_lib::Result { + let decoded = serde_json::from_value::(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 { + let parsed = value.parse::(); + 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, +} + +#[cfg(test)] +#[path = "../unit_tests/rpc_common.rs"] +mod tests; diff --git a/crates/ksp-onchain-transport-lib/src/rpc_tokens.rs b/crates/ksp-onchain-transport-lib/src/rpc_tokens.rs new file mode 100644 index 0000000..ff980cc --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/rpc_tokens.rs @@ -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, + 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 { + 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 { + let decoded = crate::decode_wire_json::(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 { + let decoded = crate::decode_wire_json::(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, + #[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, + #[serde(rename = "uiAmountString")] + ui_amount_string: std::string::String, +} + +#[cfg(test)] +#[path = "../unit_tests/rpc_tokens.rs"] +mod tests; diff --git a/crates/ksp-onchain-transport-lib/tests/public_api.rs b/crates/ksp-onchain-transport-lib/tests/public_api.rs index 72f16c7..bd11fa6 100644 --- a/crates/ksp-onchain-transport-lib/tests/public_api.rs +++ b/crates/ksp-onchain-transport-lib/tests/public_api.rs @@ -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::().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)); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs new file mode 100644 index 0000000..c36083a --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_accounts.rs @@ -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::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"), + } +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs index 3be90bc..df790fe 100644 --- a/crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_canary.rs @@ -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( diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs new file mode 100644 index 0000000..091d388 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_cluster.rs @@ -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::().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"})] + ); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_common.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_common.rs new file mode 100644 index 0000000..a585153 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_common.rs @@ -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); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs new file mode 100644 index 0000000..8e4790a --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_tokens.rs @@ -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::().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"); +} diff --git a/deltas/0.2.2/pre.002.md b/deltas/0.2.2/pre.002.md new file mode 100644 index 0000000..3208860 --- /dev/null +++ b/deltas/0.2.2/pre.002.md @@ -0,0 +1,227 @@ + + + +# 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 +``` + +`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`. + +`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` 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` confirmé dans Agave `v4.2.1`. +`SolanaVoteAccountInfo` inclut `inflationRewardsCommissionBps: Option` 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.