diff --git a/Cargo.toml b/Cargo.toml index ca46a7d..253480b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 120 +# version: 121 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib"] [workspace.package] -version = "0.2.3-pre.1" +version = "0.2.3-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/confirmed_transaction.variants.json b/crates/ksp-onchain-transport-lib/fixtures/http/confirmed_transaction.variants.json new file mode 100644 index 0000000..1ef3981 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/confirmed_transaction.variants.json @@ -0,0 +1,36 @@ +[ + { + "slot": 430000123, + "blockTime": 1787000000, + "transaction": ["AQIDBAUGBwg=", "base64"], + "meta": { + "err": null, + "status": {"Ok": null}, + "fee": 5000, + "preBalances": [10000], + "postBalances": [5000] + }, + "version": 0, + "transactionIndex": 7 + }, + { + "slot": 430000100, + "blockTime": null, + "transaction": "3Bxs4NN8M2Yn4TLb", + "meta": null, + "version": "legacy", + "transactionIndex": null + }, + { + "slot": 430000050, + "blockTime": null, + "transaction": { + "signatures": [], + "message": { + "accountKeys": [], + "recentBlockhash": "11111111111111111111111111111111", + "instructions": [] + } + } + } +] diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/encoded_transaction.variants.json b/crates/ksp-onchain-transport-lib/fixtures/http/encoded_transaction.variants.json new file mode 100644 index 0000000..4bcdd61 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/encoded_transaction.variants.json @@ -0,0 +1,12 @@ +[ + "3Bxs4NN8M2Yn4TLb", + ["AQIDBAUGBwg=", "base64"], + { + "signatures": ["5h6xBEauJ3PK6SWQkgLxHcQpWyRZyG7c4pUpK9fd2B2B7hRQ1S6F9J7BqvQPEmxQxK4DThfYF5f5f5f5f5f5f5f5"], + "message": { + "accountKeys": [], + "recentBlockhash": "11111111111111111111111111111111", + "instructions": [] + } + } +] diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/latest_blockhash.v4_2_1.json b/crates/ksp-onchain-transport-lib/fixtures/http/latest_blockhash.v4_2_1.json new file mode 100644 index 0000000..65da49e --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/latest_blockhash.v4_2_1.json @@ -0,0 +1,4 @@ +{ + "blockhash": "11111111111111111111111111111111", + "lastValidBlockHeight": 430123456 +} diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/prioritization_fee.v4_2_1.json b/crates/ksp-onchain-transport-lib/fixtures/http/prioritization_fee.v4_2_1.json new file mode 100644 index 0000000..653f105 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/prioritization_fee.v4_2_1.json @@ -0,0 +1,4 @@ +[ + {"slot": 430000123, "prioritizationFee": 1000}, + {"slot": 430000122, "prioritizationFee": 0} +] diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/signature_info.v4_2_1.json b/crates/ksp-onchain-transport-lib/fixtures/http/signature_info.v4_2_1.json new file mode 100644 index 0000000..7e8943e --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/signature_info.v4_2_1.json @@ -0,0 +1,19 @@ +[ + { + "signature": "5h6xBEauJ3PK6SWQkgLxHcQpWyRZyG7c4pUpK9fd2B2B7hRQ1S6F9J7BqvQPEmxQxK4DThfYF5f5f5f5f5f5f5f5", + "slot": 430000123, + "err": null, + "memo": "fixture memo", + "blockTime": 1787000000, + "confirmationStatus": "finalized", + "transactionIndex": 7 + }, + { + "signature": "4Nd1mC5QfVhNq8xqP7z8d7K6a5m4n3b2v1c9x8z7y6w5u4t3s2r1q9p8o7n6m5L4K3J2H1G9F8E7D6C5B4A3", + "slot": 430000100, + "err": {"InstructionError": [0, "Custom"]}, + "memo": null, + "blockTime": null, + "confirmationStatus": "confirmed" + } +] diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/signature_status.variants.json b/crates/ksp-onchain-transport-lib/fixtures/http/signature_status.variants.json new file mode 100644 index 0000000..ec68f74 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/signature_status.variants.json @@ -0,0 +1,17 @@ +[ + null, + { + "slot": 430000123, + "confirmations": null, + "status": {"Ok": null}, + "err": null, + "confirmationStatus": "finalized" + }, + { + "slot": 430000120, + "confirmations": 3, + "status": {"Err": {"InstructionError": [0, "Custom"]}}, + "err": {"InstructionError": [0, "Custom"]}, + "confirmationStatus": "confirmed" + } +] diff --git a/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction_result.v4_2_1.json b/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction_result.v4_2_1.json new file mode 100644 index 0000000..4365927 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction_result.v4_2_1.json @@ -0,0 +1,35 @@ +{ + "err": null, + "logs": ["Program 11111111111111111111111111111111 invoke [1]", "Program 11111111111111111111111111111111 success"], + "accounts": [ + { + "lamports": 1000, + "data": ["", "base64"], + "owner": "11111111111111111111111111111111", + "executable": false, + "rentEpoch": 0, + "space": 0 + }, + null + ], + "unitsConsumed": 150, + "loadedAccountsDataSize": 64, + "returnData": { + "programId": "11111111111111111111111111111111", + "data": ["AQI=", "base64"] + }, + "innerInstructions": [], + "replacementBlockhash": { + "blockhash": "ComputeBudget111111111111111111111111111111", + "lastValidBlockHeight": 430123999 + }, + "fee": 5000, + "preBalances": [10000, 0], + "postBalances": [5000, 5000], + "preTokenBalances": [], + "postTokenBalances": [], + "loadedAddresses": { + "writable": [], + "readonly": [] + } +} diff --git a/crates/ksp-onchain-transport-lib/src/lib.rs b/crates/ksp-onchain-transport-lib/src/lib.rs index 79dcfb8..68d16f2 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: 11 +// version: 12 #![warn(missing_docs)] #![deny(unreachable_pub)] #![forbid(unsafe_code)] @@ -10,7 +10,8 @@ //! independent from `ksp-config-lib`, Store and Program layers. `ksp-config-lib` now constructs these public settings through its one-way Config -> //! Transport adapter without creating a reverse dependency. Logical endpoint clients, priority-aware pools, bounded admission limits and retry/no-resend policy //! are available. The four typed Solana HTTP foundation canaries plus all 22 typed `0.2.2` Accounts, Tokens and Cluster wrappers execute real JSON-RPC -//! requests through the shared transport path while the `0.2.3` and `0.2.4` audited families remain staged. +//! requests through the shared transport path. `0.2.3` now exposes its shared Transaction wire/config primitives while its 11 typed wrappers remain staged; +//! the `0.2.4` family remains fully staged. mod client; mod constants; @@ -25,6 +26,7 @@ mod rpc_cluster; mod rpc_common; mod rpc_method; mod rpc_tokens; +mod rpc_transactions; mod settings; pub(crate) use self::constants::TRACING_TARGET; @@ -197,6 +199,44 @@ pub use self::rpc_tokens::SolanaTokenAccountBalance; pub use self::rpc_tokens::SolanaTokenAccountSelector; /// Token amount returned by Solana HTTP token RPC methods. pub use self::rpc_tokens::SolanaTokenAmount; +/// Confirmed transaction result returned by `getTransaction` when the RPC result is non-null. +pub use self::rpc_transactions::SolanaConfirmedTransaction; +/// Wire-preserving transaction payload returned by `getTransaction`. +pub use self::rpc_transactions::SolanaEncodedTransaction; +/// Modern configuration object accepted by `getTransaction`. +pub use self::rpc_transactions::SolanaGetTransactionConfig; +/// Blockhash information returned by `getLatestBlockhash` and optionally by simulation. +pub use self::rpc_transactions::SolanaLatestBlockhash; +/// One recent prioritization-fee sample returned by `getRecentPrioritizationFees`. +pub use self::rpc_transactions::SolanaPrioritizationFee; +/// Configuration accepted by `requestAirdrop`. +pub use self::rpc_transactions::SolanaRequestAirdropConfig; +/// Configuration accepted by `sendTransaction` without changing KSP transport retry semantics. +pub use self::rpc_transactions::SolanaSendTransactionConfig; +/// One ordered signature record returned by `getSignaturesForAddress`. +pub use self::rpc_transactions::SolanaSignatureInfo; +/// One non-null position returned by `getSignatureStatuses`. +pub use self::rpc_transactions::SolanaSignatureStatus; +/// Optional historical-search configuration accepted by `getSignatureStatuses`. +pub use self::rpc_transactions::SolanaSignatureStatusesConfig; +/// Pagination and context configuration accepted by `getSignaturesForAddress`. +pub use self::rpc_transactions::SolanaSignaturesForAddressConfig; +/// Configuration accepted by `simulateTransaction`. +pub use self::rpc_transactions::SolanaSimulateTransactionConfig; +/// Rich result payload returned inside the contextual `simulateTransaction` response. +pub use self::rpc_transactions::SolanaSimulateTransactionResult; +/// Account-return configuration nested under `simulateTransaction`. +pub use self::rpc_transactions::SolanaSimulationAccountsConfig; +/// Binary encoding accepted for serialized transaction input payloads. +pub use self::rpc_transactions::SolanaTransactionBinaryEncoding; +/// Confirmation state reported for a signature or transaction status. +pub use self::rpc_transactions::SolanaTransactionConfirmationStatus; +/// Encoding accepted by `getTransaction`, including its retained legacy `binary` alias. +pub use self::rpc_transactions::SolanaTransactionEncoding; +/// Transaction version reported by `getTransaction` when the version field is present. +pub use self::rpc_transactions::SolanaTransactionVersion; +/// Three-state wire field used when Solana distinguishes omission from an explicit JSON `null`. +pub use self::rpc_transactions::SolanaWireField; /// Open cluster or network descriptor used by HTTP endpoint settings. pub use self::settings::HttpClusterName; /// Runtime settings for one role declared by an HTTP endpoint. diff --git a/crates/ksp-onchain-transport-lib/src/rpc_transactions.rs b/crates/ksp-onchain-transport-lib/src/rpc_transactions.rs new file mode 100644 index 0000000..e1021fe --- /dev/null +++ b/crates/ksp-onchain-transport-lib/src/rpc_transactions.rs @@ -0,0 +1,1296 @@ +// file: crates/ksp-onchain-transport-lib/src/rpc_transactions.rs +// version: 1 + +/// Binary encoding accepted for serialized transaction input payloads. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum SolanaTransactionBinaryEncoding { + /// Base58 text encoding. + Base58, + /// Base64 text encoding. + Base64, +} + +impl SolanaTransactionBinaryEncoding { + /// Returns the Solana JSON-RPC encoding string. + #[must_use] + pub const fn as_str(self) -> &'static str { + return match self { + Self::Base58 => "base58", + Self::Base64 => "base64", + }; + } + + fn from_wire(value: &str) -> std::option::Option { + return match value { + "base58" => std::option::Option::Some(Self::Base58), + "base64" => std::option::Option::Some(Self::Base64), + _ => std::option::Option::None, + }; + } +} + +/// Encoding accepted by `getTransaction`, including its retained legacy `binary` alias. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum SolanaTransactionEncoding { + /// Legacy base58-compatible `binary` alias retained by Solana RPC for backwards compatibility. + Binary, + /// Base58 encoded transaction bytes. + Base58, + /// Base64 encoded transaction bytes. + Base64, + /// Raw JSON transaction representation. + Json, + /// Parsed JSON transaction representation. + JsonParsed, +} + +impl SolanaTransactionEncoding { + /// 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::Json => "json", + Self::JsonParsed => "jsonParsed", + }; + } +} + +/// Three-state wire field used when Solana distinguishes omission from an explicit JSON `null`. +#[derive(Clone, Debug, PartialEq)] +pub enum SolanaWireField { + /// The field was not present in the decoded wire object. + Omitted, + /// The field was explicitly present with JSON `null`. + Null, + /// The field was present with a concrete value. + Value(T), +} + +impl SolanaWireField { + /// Returns whether the field was omitted from the wire object. + #[must_use] + pub const fn is_omitted(&self) -> bool { + return match self { + Self::Omitted => true, + Self::Null | Self::Value(_) => false, + }; + } + + /// Returns whether the field was explicitly JSON `null`. + #[must_use] + pub const fn is_null(&self) -> bool { + return match self { + Self::Null => true, + Self::Omitted | Self::Value(_) => false, + }; + } + + /// Returns the concrete value when the field was present and non-null. + #[must_use] + pub const fn value(&self) -> std::option::Option<&T> { + return match self { + Self::Value(value) => std::option::Option::Some(value), + Self::Omitted | Self::Null => std::option::Option::None, + }; + } +} + +impl std::default::Default for SolanaWireField { + fn default() -> Self { + return Self::Omitted; + } +} + +impl<'de, T> serde::Deserialize<'de> for SolanaWireField +where + T: serde::Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let decoded = as serde::Deserialize>::deserialize(deserializer); + return match decoded { + std::result::Result::Ok(std::option::Option::Some(value)) => std::result::Result::Ok(Self::Value(value)), + std::result::Result::Ok(std::option::Option::None) => std::result::Result::Ok(Self::Null), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } +} + +/// Modern configuration object accepted by `getTransaction`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SolanaGetTransactionConfig { + commitment: std::option::Option, + encoding: std::option::Option, + max_supported_transaction_version: std::option::Option, +} + +impl SolanaGetTransactionConfig { + /// Creates a modern `getTransaction` configuration. + #[must_use] + pub const fn new( + commitment: std::option::Option, + encoding: std::option::Option, + max_supported_transaction_version: std::option::Option, + ) -> Self { + return Self { commitment, encoding, max_supported_transaction_version }; + } + + /// Returns the optional commitment level. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.commitment; + } + + /// Returns the optional transaction response encoding. + #[must_use] + pub const fn encoding(&self) -> std::option::Option { + return self.encoding; + } + + /// Returns the highest transaction version the caller declares it can consume. + #[must_use] + pub const fn max_supported_transaction_version(&self) -> std::option::Option { + return self.max_supported_transaction_version; + } + + /// Returns whether the modern config would serialize to an empty object. + pub(crate) const fn is_empty(&self) -> bool { + return self.commitment.is_none() && self.encoding.is_none() && self.max_supported_transaction_version.is_none(); + } + + /// Serializes the modern config to its 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(encoding) = self.encoding { + object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned())); + } + if let std::option::Option::Some(version) = self.max_supported_transaction_version { + object.insert("maxSupportedTransactionVersion".to_owned(), serde_json::Value::Number(version.into())); + } + return serde_json::Value::Object(object); + } +} + +/// Pagination and context configuration accepted by `getSignaturesForAddress`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SolanaSignaturesForAddressConfig { + before: std::option::Option, + until: std::option::Option, + limit: std::option::Option, + context: crate::SolanaContextConfig, +} + +impl SolanaSignaturesForAddressConfig { + /// Creates an address-signature pagination configuration. + #[must_use] + pub fn new( + before: std::option::Option, + until: std::option::Option, + limit: std::option::Option, + commitment: std::option::Option, + min_context_slot: std::option::Option, + ) -> Self { + return Self { before, until, limit, context: crate::SolanaContextConfig::new(commitment, min_context_slot) }; + } + + /// Returns the exclusive pagination signature preceding the requested page. + #[must_use] + pub fn before(&self) -> std::option::Option<&str> { + return self.before.as_deref(); + } + + /// Returns the exclusive pagination signature terminating the requested range. + #[must_use] + pub fn until(&self) -> std::option::Option<&str> { + return self.until.as_deref(); + } + + /// Returns the optional requested page size. + #[must_use] + pub const fn limit(&self) -> std::option::Option { + return self.limit; + } + + /// 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 the pagination config would serialize to an empty object. + pub(crate) fn is_empty(&self) -> bool { + return self.before.is_none() && self.until.is_none() && self.limit.is_none() && self.commitment().is_none() && self.min_context_slot().is_none(); + } + + /// Serializes this pagination config to the Solana JSON-RPC wire object. + #[must_use] + pub(crate) fn to_json_value(&self) -> serde_json::Value { + let context = self.context.to_json_value(); + let mut object = match context { + serde_json::Value::Object(object) => object, + _ => serde_json::Map::new(), + }; + if let std::option::Option::Some(before) = self.before.as_ref() { + object.insert("before".to_owned(), serde_json::Value::String(before.clone())); + } + if let std::option::Option::Some(until) = self.until.as_ref() { + object.insert("until".to_owned(), serde_json::Value::String(until.clone())); + } + if let std::option::Option::Some(limit) = self.limit { + object.insert("limit".to_owned(), serde_json::json!(limit)); + } + return serde_json::Value::Object(object); + } +} + +/// Optional historical-search configuration accepted by `getSignatureStatuses`. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SolanaSignatureStatusesConfig { + search_transaction_history: std::option::Option, +} + +impl SolanaSignatureStatusesConfig { + /// Creates a signature-status configuration. + #[must_use] + pub const fn new(search_transaction_history: std::option::Option) -> Self { + return Self { search_transaction_history }; + } + + /// Returns whether full transaction history should be searched when explicitly configured. + #[must_use] + pub const fn search_transaction_history(&self) -> std::option::Option { + return self.search_transaction_history; + } + + /// Returns whether the status config would serialize to an empty object. + pub(crate) const fn is_empty(&self) -> bool { + return self.search_transaction_history.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(value) = self.search_transaction_history { + object.insert("searchTransactionHistory".to_owned(), serde_json::Value::Bool(value)); + } + return serde_json::Value::Object(object); + } +} + +/// Configuration accepted by `requestAirdrop`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SolanaRequestAirdropConfig { + recent_blockhash: std::option::Option, + commitment: std::option::Option, +} + +impl SolanaRequestAirdropConfig { + /// Creates an airdrop request configuration. + #[must_use] + pub fn new(recent_blockhash: std::option::Option, commitment: std::option::Option) -> Self { + return Self { recent_blockhash, commitment }; + } + + /// Returns the optional recent blockhash supplied to the faucet RPC. + #[must_use] + pub fn recent_blockhash(&self) -> std::option::Option<&str> { + return self.recent_blockhash.as_deref(); + } + + /// Returns the optional commitment level. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.commitment; + } + + /// Returns whether the airdrop config would serialize to an empty object. + pub(crate) fn is_empty(&self) -> bool { + return self.recent_blockhash.is_none() && self.commitment.is_none(); + } + + /// Serializes this config to the Solana JSON-RPC wire object. + #[must_use] + pub(crate) fn to_json_value(&self) -> serde_json::Value { + let mut object = serde_json::Map::new(); + if let std::option::Option::Some(blockhash) = self.recent_blockhash.as_ref() { + object.insert("recentBlockhash".to_owned(), serde_json::Value::String(blockhash.clone())); + } + 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); + } +} + +/// Configuration accepted by `sendTransaction` without changing KSP transport retry semantics. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SolanaSendTransactionConfig { + skip_preflight: std::option::Option, + preflight_commitment: std::option::Option, + encoding: std::option::Option, + max_retries: std::option::Option, + min_context_slot: std::option::Option, +} + +impl SolanaSendTransactionConfig { + /// Creates a serialized-transaction submission configuration. + #[must_use] + pub const fn new( + skip_preflight: std::option::Option, + preflight_commitment: std::option::Option, + encoding: std::option::Option, + max_retries: std::option::Option, + min_context_slot: std::option::Option, + ) -> Self { + return Self { skip_preflight, preflight_commitment, encoding, max_retries, min_context_slot }; + } + + /// Returns the optional preflight-skip flag. + #[must_use] + pub const fn skip_preflight(&self) -> std::option::Option { + return self.skip_preflight; + } + + /// Returns the optional preflight commitment. + #[must_use] + pub const fn preflight_commitment(&self) -> std::option::Option { + return self.preflight_commitment; + } + + /// Returns the optional serialized transaction encoding. + #[must_use] + pub const fn encoding(&self) -> std::option::Option { + return self.encoding; + } + + /// Returns the optional node-side retransmission limit. + #[must_use] + pub const fn max_retries(&self) -> std::option::Option { + return self.max_retries; + } + + /// 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 the send config would serialize to an empty object. + pub(crate) const fn is_empty(&self) -> bool { + return self.skip_preflight.is_none() + && self.preflight_commitment.is_none() + && self.encoding.is_none() + && self.max_retries.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(value) = self.skip_preflight { + object.insert("skipPreflight".to_owned(), serde_json::Value::Bool(value)); + } + if let std::option::Option::Some(commitment) = self.preflight_commitment { + object.insert("preflightCommitment".to_owned(), serde_json::Value::String(commitment.as_str().to_owned())); + } + 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(max_retries) = self.max_retries { + object.insert("maxRetries".to_owned(), serde_json::json!(max_retries)); + } + 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); + } +} + +/// Account-return configuration nested under `simulateTransaction`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SolanaSimulationAccountsConfig { + encoding: std::option::Option, + addresses: std::vec::Vec, +} + +impl SolanaSimulationAccountsConfig { + /// Creates a simulation account-return configuration. + #[must_use] + pub fn new(encoding: std::option::Option, addresses: std::vec::Vec) -> Self { + return Self { encoding, addresses }; + } + + /// Returns the optional account-data encoding. + #[must_use] + pub const fn encoding(&self) -> std::option::Option { + return self.encoding; + } + + /// Returns the requested account addresses in caller order. + #[must_use] + pub fn addresses(&self) -> &[ksp_core_lib::Pubkey] { + return self.addresses.as_slice(); + } + + /// Serializes the nested account 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(encoding) = self.encoding { + object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned())); + } + let addresses = self.addresses.iter().map(|address| address.to_string()).map(serde_json::Value::String).collect::>(); + object.insert("addresses".to_owned(), serde_json::Value::Array(addresses)); + return serde_json::Value::Object(object); + } +} + +/// Configuration accepted by `simulateTransaction`. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SolanaSimulateTransactionConfig { + commitment: std::option::Option, + encoding: std::option::Option, + replace_recent_blockhash: std::option::Option, + sig_verify: std::option::Option, + min_context_slot: std::option::Option, + inner_instructions: std::option::Option, + accounts: std::option::Option, +} + +impl SolanaSimulateTransactionConfig { + /// Creates a simulation configuration while leaving deterministic validation to the simulation wrapper. + #[must_use] + pub fn new( + commitment: std::option::Option, + encoding: std::option::Option, + replace_recent_blockhash: std::option::Option, + sig_verify: std::option::Option, + min_context_slot: std::option::Option, + inner_instructions: std::option::Option, + accounts: std::option::Option, + ) -> Self { + return Self { + commitment, + encoding, + replace_recent_blockhash, + sig_verify, + min_context_slot, + inner_instructions, + accounts, + }; + } + + /// Returns the optional simulation commitment. + #[must_use] + pub const fn commitment(&self) -> std::option::Option { + return self.commitment; + } + + /// Returns the optional serialized transaction encoding. + #[must_use] + pub const fn encoding(&self) -> std::option::Option { + return self.encoding; + } + + /// Returns the optional recent-blockhash replacement flag. + #[must_use] + pub const fn replace_recent_blockhash(&self) -> std::option::Option { + return self.replace_recent_blockhash; + } + + /// Returns the optional signature-verification flag. + #[must_use] + pub const fn sig_verify(&self) -> std::option::Option { + return self.sig_verify; + } + + /// Returns the optional minimum context slot. + #[must_use] + pub const fn min_context_slot(&self) -> std::option::Option { + return self.min_context_slot; + } + + /// Returns the optional inner-instruction recording flag. + #[must_use] + pub const fn inner_instructions(&self) -> std::option::Option { + return self.inner_instructions; + } + + /// Returns the optional post-simulation account-return configuration. + #[must_use] + pub const fn accounts(&self) -> std::option::Option<&crate::SolanaSimulationAccountsConfig> { + return self.accounts.as_ref(); + } + + /// Returns whether the simulation config would serialize to an empty object. + pub(crate) fn is_empty(&self) -> bool { + return self.commitment.is_none() + && self.encoding.is_none() + && self.replace_recent_blockhash.is_none() + && self.sig_verify.is_none() + && self.min_context_slot.is_none() + && self.inner_instructions.is_none() + && self.accounts.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(encoding) = self.encoding { + object.insert("encoding".to_owned(), serde_json::Value::String(encoding.as_str().to_owned())); + } + if let std::option::Option::Some(value) = self.replace_recent_blockhash { + object.insert("replaceRecentBlockhash".to_owned(), serde_json::Value::Bool(value)); + } + if let std::option::Option::Some(value) = self.sig_verify { + object.insert("sigVerify".to_owned(), serde_json::Value::Bool(value)); + } + 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())); + } + if let std::option::Option::Some(value) = self.inner_instructions { + object.insert("innerInstructions".to_owned(), serde_json::Value::Bool(value)); + } + if let std::option::Option::Some(accounts) = self.accounts.as_ref() { + object.insert("accounts".to_owned(), accounts.to_json_value()); + } + return serde_json::Value::Object(object); + } +} + +/// Blockhash information returned by `getLatestBlockhash` and optionally by simulation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SolanaLatestBlockhash { + blockhash: std::string::String, + last_valid_block_height: u64, +} + +impl SolanaLatestBlockhash { + /// Returns the base58 blockhash string without locally decoding it. + #[must_use] + pub fn blockhash(&self) -> &str { + return self.blockhash.as_str(); + } + + /// Returns the last valid block height for this blockhash. + #[must_use] + pub const fn last_valid_block_height(&self) -> u64 { + return self.last_valid_block_height; + } + + /// Decodes a latest-blockhash object 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), + }; + if wire.blockhash.is_empty() { + return std::result::Result::Err(invalid_transaction_wire(method, "blockhash", "latest blockhash must not be empty")); + } + return std::result::Result::Ok(Self { blockhash: wire.blockhash, last_valid_block_height: wire.last_valid_block_height }); + } +} + +/// One recent prioritization-fee sample returned by `getRecentPrioritizationFees`. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct SolanaPrioritizationFee { + slot: u64, + prioritization_fee: u64, +} + +impl SolanaPrioritizationFee { + /// Returns the sample slot. + #[must_use] + pub const fn slot(&self) -> u64 { + return self.slot; + } + + /// Returns the minimum prioritization fee in micro-lamports reported for the sample. + #[must_use] + pub const fn prioritization_fee(&self) -> u64 { + return self.prioritization_fee; + } + + /// Decodes one prioritization-fee sample 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 { slot: wire.slot, prioritization_fee: wire.prioritization_fee }), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; + } +} + +/// Confirmation state reported for a signature or transaction status. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum SolanaTransactionConfirmationStatus { + /// The transaction has been processed by a node. + Processed, + /// The transaction has reached confirmed commitment. + Confirmed, + /// The transaction has reached finalized commitment. + Finalized, +} + +/// One ordered signature record returned by `getSignaturesForAddress`. +#[derive(Clone, Debug, PartialEq)] +pub struct SolanaSignatureInfo { + signature: std::string::String, + slot: u64, + err: std::option::Option, + memo: std::option::Option, + block_time: std::option::Option, + confirmation_status: std::option::Option, + transaction_index: std::option::Option, +} + +impl SolanaSignatureInfo { + /// Returns the base58 transaction signature string without locally decoding it. + #[must_use] + pub fn signature(&self) -> &str { + return self.signature.as_str(); + } + + /// Returns the transaction slot. + #[must_use] + pub const fn slot(&self) -> u64 { + return self.slot; + } + + /// Returns the nullable transaction error wire value. + #[must_use] + pub const fn err(&self) -> std::option::Option<&serde_json::Value> { + return self.err.as_ref(); + } + + /// Returns the nullable memo text. + #[must_use] + pub fn memo(&self) -> std::option::Option<&str> { + return self.memo.as_deref(); + } + + /// Returns the nullable block time as a Unix timestamp. + #[must_use] + pub const fn block_time(&self) -> std::option::Option { + return self.block_time; + } + + /// Returns the optional confirmation status. + #[must_use] + pub const fn confirmation_status(&self) -> std::option::Option { + return self.confirmation_status; + } + + /// Returns the optional transaction index exposed by current Agave runtimes. + #[must_use] + pub const fn transaction_index(&self) -> std::option::Option { + return self.transaction_index; + } + + /// Decodes one signature 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 status = decode_confirmation_status(method, "confirmationStatus", wire.confirmation_status); + let confirmation_status = match status { + std::result::Result::Ok(status) => status, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(Self { + signature: wire.signature, + slot: wire.slot, + err: wire.err, + memo: wire.memo, + block_time: wire.block_time, + confirmation_status, + transaction_index: wire.transaction_index, + }); + } +} + +/// One non-null position returned by `getSignatureStatuses`. +#[derive(Clone, Debug, PartialEq)] +pub struct SolanaSignatureStatus { + slot: u64, + confirmations: std::option::Option, + status: serde_json::Value, + err: std::option::Option, + confirmation_status: std::option::Option, +} + +impl SolanaSignatureStatus { + /// Returns the slot at which the signature status was observed. + #[must_use] + pub const fn slot(&self) -> u64 { + return self.slot; + } + + /// Returns remaining confirmations, or `None` when the transaction is rooted. + #[must_use] + pub const fn confirmations(&self) -> std::option::Option { + return self.confirmations; + } + + /// Returns the legacy `status` result field losslessly. + #[must_use] + pub const fn status(&self) -> &serde_json::Value { + return &self.status; + } + + /// Returns the nullable transaction error wire value. + #[must_use] + pub const fn err(&self) -> std::option::Option<&serde_json::Value> { + return self.err.as_ref(); + } + + /// Returns the optional confirmation status. + #[must_use] + pub const fn confirmation_status(&self) -> std::option::Option { + return self.confirmation_status; + } + + /// Decodes one present signature status 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 status = decode_confirmation_status(method, "confirmationStatus", wire.confirmation_status); + let confirmation_status = match status { + std::result::Result::Ok(status) => status, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(Self { + slot: wire.slot, + confirmations: wire.confirmations, + status: wire.status, + err: wire.err, + confirmation_status, + }); + } +} + +/// Wire-preserving transaction payload returned by `getTransaction`. +#[derive(Clone, Debug, PartialEq)] +pub enum SolanaEncodedTransaction { + /// Legacy single-string base58 form retained for RPC backwards compatibility. + LegacyBinary(std::string::String), + /// Explicit encoded tuple `[data, "base58" | "base64"]`. + Binary { + /// Encoded transaction bytes. + data: std::string::String, + /// Explicit binary encoding label. + encoding: crate::SolanaTransactionBinaryEncoding, + }, + /// JSON or `jsonParsed` object preserved without Program-specific decoding. + Json(serde_json::Value), +} + +impl SolanaEncodedTransaction { + /// Decodes the untagged transaction wire union without decoding transaction bytes. + pub(crate) fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result { + return match value { + serde_json::Value::String(data) => std::result::Result::Ok(Self::LegacyBinary(data)), + serde_json::Value::Object(object) => std::result::Result::Ok(Self::Json(serde_json::Value::Object(object))), + serde_json::Value::Array(values) => decode_binary_transaction_tuple(method, values), + _ => std::result::Result::Err(invalid_transaction_wire(method, "transaction", "transaction payload has an unsupported JSON shape")), + }; + } +} + +/// Transaction version reported by `getTransaction` when the version field is present. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum SolanaTransactionVersion { + /// Legacy transaction without a versioned message prefix. + Legacy, + /// Numeric versioned-transaction identifier. + Number(u8), +} + +impl SolanaTransactionVersion { + fn decode_wire(method: &str, value: serde_json::Value) -> ksp_core_lib::Result { + return match value { + serde_json::Value::String(value) if value == "legacy" => std::result::Result::Ok(Self::Legacy), + serde_json::Value::Number(value) => { + let number = value.as_u64(); + let number = match number { + std::option::Option::Some(number) => number, + std::option::Option::None => { + return std::result::Result::Err(invalid_transaction_wire( + method, + "version", + "transaction version must be an unsigned integer or legacy", + )); + }, + }; + let converted = u8::try_from(number); + return match converted { + std::result::Result::Ok(number) => std::result::Result::Ok(Self::Number(number)), + std::result::Result::Err(_) => { + std::result::Result::Err(invalid_transaction_wire(method, "version", "transaction version is outside the supported wire integer range")) + }, + }; + }, + _ => std::result::Result::Err(invalid_transaction_wire(method, "version", "transaction version must be an unsigned integer or legacy")), + }; + } +} + +/// Confirmed transaction result returned by `getTransaction` when the RPC result is non-null. +#[derive(Clone, Debug, PartialEq)] +pub struct SolanaConfirmedTransaction { + slot: u64, + block_time: std::option::Option, + transaction: crate::SolanaEncodedTransaction, + meta: crate::SolanaWireField, + version: crate::SolanaWireField, + transaction_index: crate::SolanaWireField, +} + +impl SolanaConfirmedTransaction { + /// Returns the containing slot. + #[must_use] + pub const fn slot(&self) -> u64 { + return self.slot; + } + + /// Returns the nullable block time as a Unix timestamp. + #[must_use] + pub const fn block_time(&self) -> std::option::Option { + return self.block_time; + } + + /// Returns the wire-preserving encoded/JSON transaction payload. + #[must_use] + pub const fn transaction(&self) -> &crate::SolanaEncodedTransaction { + return &self.transaction; + } + + /// Returns the transaction metadata while preserving omitted/null/present states. + #[must_use] + pub const fn meta(&self) -> &crate::SolanaWireField { + return &self.meta; + } + + /// Returns the transaction version while preserving omitted/null/present states. + #[must_use] + pub const fn version(&self) -> &crate::SolanaWireField { + return &self.version; + } + + /// Returns the current optional transaction index while preserving omitted/null/present states. + #[must_use] + pub const fn transaction_index(&self) -> &crate::SolanaWireField { + return &self.transaction_index; + } + + /// Decodes one non-null confirmed transaction without decoding Program-specific transaction internals. + 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 transaction = crate::SolanaEncodedTransaction::decode_wire(method, wire.transaction); + let transaction = match transaction { + std::result::Result::Ok(transaction) => transaction, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let version = decode_transaction_version_field(method, wire.version); + let version = match version { + std::result::Result::Ok(version) => version, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(Self { + slot: wire.slot, + block_time: wire.block_time, + transaction, + meta: wire.meta, + version, + transaction_index: wire.transaction_index, + }); + } +} + +/// Rich result payload returned inside the contextual `simulateTransaction` response. +#[derive(Clone, Debug, PartialEq)] +pub struct SolanaSimulateTransactionResult { + err: crate::SolanaWireField, + logs: crate::SolanaWireField>, + accounts: crate::SolanaWireField>>, + units_consumed: crate::SolanaWireField, + loaded_accounts_data_size: crate::SolanaWireField, + return_data: crate::SolanaWireField, + inner_instructions: crate::SolanaWireField, + replacement_blockhash: crate::SolanaWireField, + fee: crate::SolanaWireField, + pre_balances: crate::SolanaWireField>, + post_balances: crate::SolanaWireField>, + pre_token_balances: crate::SolanaWireField, + post_token_balances: crate::SolanaWireField, + loaded_addresses: crate::SolanaWireField, +} + +impl SolanaSimulateTransactionResult { + /// Returns the simulation error while preserving omission versus explicit `null`. + #[must_use] + pub const fn err(&self) -> &crate::SolanaWireField { + return &self.err; + } + + /// Returns simulation logs while preserving omission versus explicit `null`. + #[must_use] + pub const fn logs(&self) -> &crate::SolanaWireField> { + return &self.logs; + } + + /// Returns requested post-simulation accounts with positional nulls preserved. + #[must_use] + pub const fn accounts(&self) -> &crate::SolanaWireField>> { + return &self.accounts; + } + + /// Returns compute units consumed when the runtime reports them. + #[must_use] + pub const fn units_consumed(&self) -> &crate::SolanaWireField { + return &self.units_consumed; + } + + /// Returns loaded-account data size when the runtime reports it. + #[must_use] + pub const fn loaded_accounts_data_size(&self) -> &crate::SolanaWireField { + return &self.loaded_accounts_data_size; + } + + /// Returns Program return data losslessly at the transport boundary. + #[must_use] + pub const fn return_data(&self) -> &crate::SolanaWireField { + return &self.return_data; + } + + /// Returns inner instructions losslessly without Program-specific decoding. + #[must_use] + pub const fn inner_instructions(&self) -> &crate::SolanaWireField { + return &self.inner_instructions; + } + + /// Returns a replacement blockhash when the runtime produced one. + #[must_use] + pub const fn replacement_blockhash(&self) -> &crate::SolanaWireField { + return &self.replacement_blockhash; + } + + /// Returns the simulated fee when reported. + #[must_use] + pub const fn fee(&self) -> &crate::SolanaWireField { + return &self.fee; + } + + /// Returns pre-simulation lamport balances when reported. + #[must_use] + pub const fn pre_balances(&self) -> &crate::SolanaWireField> { + return &self.pre_balances; + } + + /// Returns post-simulation lamport balances when reported. + #[must_use] + pub const fn post_balances(&self) -> &crate::SolanaWireField> { + return &self.post_balances; + } + + /// Returns pre-simulation token balances losslessly without SPL decoding. + #[must_use] + pub const fn pre_token_balances(&self) -> &crate::SolanaWireField { + return &self.pre_token_balances; + } + + /// Returns post-simulation token balances losslessly without SPL decoding. + #[must_use] + pub const fn post_token_balances(&self) -> &crate::SolanaWireField { + return &self.post_token_balances; + } + + /// Returns loaded-address metadata losslessly without transaction-message decoding. + #[must_use] + pub const fn loaded_addresses(&self) -> &crate::SolanaWireField { + return &self.loaded_addresses; + } + + /// Decodes a simulation result while preserving optional/nullable current Agave fields. + 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 accounts = decode_simulation_accounts_field(method, wire.accounts); + let accounts = match accounts { + std::result::Result::Ok(accounts) => accounts, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let replacement_blockhash = decode_replacement_blockhash_field(method, wire.replacement_blockhash); + let replacement_blockhash = match replacement_blockhash { + std::result::Result::Ok(blockhash) => blockhash, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(Self { + err: wire.err, + logs: wire.logs, + accounts, + units_consumed: wire.units_consumed, + loaded_accounts_data_size: wire.loaded_accounts_data_size, + return_data: wire.return_data, + inner_instructions: wire.inner_instructions, + replacement_blockhash, + fee: wire.fee, + pre_balances: wire.pre_balances, + post_balances: wire.post_balances, + pre_token_balances: wire.pre_token_balances, + post_token_balances: wire.post_token_balances, + loaded_addresses: wire.loaded_addresses, + }); + } +} + +fn invalid_transaction_wire(method: &str, field: &str, message: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, message).with_context("rpc_method", method).with_context("field", field); +} + +fn decode_confirmation_status( + method: &str, + field: &str, + value: std::option::Option, +) -> ksp_core_lib::Result> { + let value = match value { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), + }; + let status = match value.as_str() { + "processed" => crate::SolanaTransactionConfirmationStatus::Processed, + "confirmed" => crate::SolanaTransactionConfirmationStatus::Confirmed, + "finalized" => crate::SolanaTransactionConfirmationStatus::Finalized, + _ => return std::result::Result::Err(invalid_transaction_wire(method, field, "transaction confirmation status is unknown")), + }; + return std::result::Result::Ok(std::option::Option::Some(status)); +} + +fn decode_binary_transaction_tuple(method: &str, values: std::vec::Vec) -> ksp_core_lib::Result { + if values.len() != 2 { + return std::result::Result::Err(invalid_transaction_wire(method, "transaction", "encoded transaction tuple must contain data and encoding")); + } + let mut values = values.into_iter(); + let data_value = match values.next() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(invalid_transaction_wire(method, "transaction", "encoded transaction tuple is missing data")); + }, + }; + let encoding_value = match values.next() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(invalid_transaction_wire(method, "transaction", "encoded transaction tuple is missing encoding")); + }, + }; + let data = match data_value.as_str() { + std::option::Option::Some(value) => value.to_owned(), + std::option::Option::None => { + return std::result::Result::Err(invalid_transaction_wire(method, "transaction", "encoded transaction tuple data must be a string")); + }, + }; + let encoding = match encoding_value.as_str() { + std::option::Option::Some(value) => value, + std::option::Option::None => { + return std::result::Result::Err(invalid_transaction_wire(method, "transaction", "encoded transaction tuple encoding must be a string")); + }, + }; + let encoding = crate::SolanaTransactionBinaryEncoding::from_wire(encoding); + let encoding = match encoding { + std::option::Option::Some(encoding) => encoding, + std::option::Option::None => { + return std::result::Result::Err(invalid_transaction_wire(method, "transaction", "encoded transaction tuple uses an unsupported binary encoding")); + }, + }; + return std::result::Result::Ok(crate::SolanaEncodedTransaction::Binary { data, encoding }); +} + +fn decode_transaction_version_field( + method: &str, + field: crate::SolanaWireField, +) -> ksp_core_lib::Result> { + return match field { + crate::SolanaWireField::Omitted => std::result::Result::Ok(crate::SolanaWireField::Omitted), + crate::SolanaWireField::Null => std::result::Result::Ok(crate::SolanaWireField::Null), + crate::SolanaWireField::Value(value) => { + let version = crate::SolanaTransactionVersion::decode_wire(method, value); + match version { + std::result::Result::Ok(version) => std::result::Result::Ok(crate::SolanaWireField::Value(version)), + std::result::Result::Err(error) => std::result::Result::Err(error), + } + }, + }; +} + +fn decode_simulation_accounts_field( + method: &str, + field: crate::SolanaWireField>>, +) -> ksp_core_lib::Result>>> { + let values = match field { + crate::SolanaWireField::Omitted => return std::result::Result::Ok(crate::SolanaWireField::Omitted), + crate::SolanaWireField::Null => return std::result::Result::Ok(crate::SolanaWireField::Null), + crate::SolanaWireField::Value(values) => values, + }; + let mut accounts = std::vec::Vec::with_capacity(values.len()); + for value in values { + let value = match value { + std::option::Option::Some(value) => value, + std::option::Option::None => { + accounts.push(std::option::Option::None); + continue; + }, + }; + let account = crate::SolanaAccount::decode_wire(method, value); + match account { + std::result::Result::Ok(account) => accounts.push(std::option::Option::Some(account)), + std::result::Result::Err(error) => return std::result::Result::Err(error), + } + } + return std::result::Result::Ok(crate::SolanaWireField::Value(accounts)); +} + +fn decode_replacement_blockhash_field( + method: &str, + field: crate::SolanaWireField, +) -> ksp_core_lib::Result> { + let value = match field { + crate::SolanaWireField::Omitted => return std::result::Result::Ok(crate::SolanaWireField::Omitted), + crate::SolanaWireField::Null => return std::result::Result::Ok(crate::SolanaWireField::Null), + crate::SolanaWireField::Value(value) => value, + }; + let blockhash = crate::SolanaLatestBlockhash::decode_wire(method, value); + return match blockhash { + std::result::Result::Ok(blockhash) => std::result::Result::Ok(crate::SolanaWireField::Value(blockhash)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireLatestBlockhash { + blockhash: std::string::String, + last_valid_block_height: u64, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WirePrioritizationFee { + slot: u64, + prioritization_fee: u64, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireSignatureInfo { + signature: std::string::String, + slot: u64, + #[serde(default)] + err: std::option::Option, + #[serde(default)] + memo: std::option::Option, + #[serde(default)] + block_time: std::option::Option, + #[serde(default)] + confirmation_status: std::option::Option, + #[serde(default)] + transaction_index: std::option::Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireSignatureStatus { + slot: u64, + #[serde(default)] + confirmations: std::option::Option, + status: serde_json::Value, + #[serde(default)] + err: std::option::Option, + #[serde(default)] + confirmation_status: std::option::Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireConfirmedTransaction { + slot: u64, + transaction: serde_json::Value, + #[serde(default)] + meta: crate::SolanaWireField, + #[serde(default)] + block_time: std::option::Option, + #[serde(default)] + version: crate::SolanaWireField, + #[serde(default)] + transaction_index: crate::SolanaWireField, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WireSimulateTransactionResult { + #[serde(default)] + err: crate::SolanaWireField, + #[serde(default)] + logs: crate::SolanaWireField>, + #[serde(default)] + accounts: crate::SolanaWireField>>, + #[serde(default)] + units_consumed: crate::SolanaWireField, + #[serde(default)] + loaded_accounts_data_size: crate::SolanaWireField, + #[serde(default)] + return_data: crate::SolanaWireField, + #[serde(default)] + inner_instructions: crate::SolanaWireField, + #[serde(default)] + replacement_blockhash: crate::SolanaWireField, + #[serde(default)] + fee: crate::SolanaWireField, + #[serde(default)] + pre_balances: crate::SolanaWireField>, + #[serde(default)] + post_balances: crate::SolanaWireField>, + #[serde(default)] + pre_token_balances: crate::SolanaWireField, + #[serde(default)] + post_token_balances: crate::SolanaWireField, + #[serde(default)] + loaded_addresses: crate::SolanaWireField, +} + +#[cfg(test)] +#[path = "../unit_tests/rpc_transactions.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 9bfd69f..896b834 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: 9 +// version: 10 //! Integration tests for the public `ksp-onchain-transport-lib` consumer contract. @@ -243,3 +243,30 @@ fn public_pre_006_remaining_cluster_wrappers_are_available_from_crate_root() { let request = ksp_onchain_transport_lib::SolanaLeaderScheduleRequest::default(); assert!(matches!(request, ksp_onchain_transport_lib::SolanaLeaderScheduleRequest::CurrentEpoch(std::option::Option::None))); } + +#[test] +fn public_transaction_pre_002_shared_types_are_constructible_from_crate_root() { + let get_transaction = ksp_onchain_transport_lib::SolanaGetTransactionConfig::new( + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Confirmed), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionEncoding::JsonParsed), + std::option::Option::Some(0), + ); + assert_eq!(get_transaction.max_supported_transaction_version(), std::option::Option::Some(0)); + let send = ksp_onchain_transport_lib::SolanaSendTransactionConfig::new( + std::option::Option::Some(false), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Processed), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::Some(3), + std::option::Option::None, + ); + assert_eq!(send.max_retries(), std::option::Option::Some(3)); + let pagination = ksp_onchain_transport_lib::SolanaSignaturesForAddressConfig::new( + std::option::Option::Some("before".to_owned()), + std::option::Option::None, + std::option::Option::Some(10), + std::option::Option::Some(ksp_onchain_transport_lib::SolanaCommitment::Finalized), + std::option::Option::None, + ); + assert_eq!(pagination.limit(), std::option::Option::Some(10)); + assert!(ksp_onchain_transport_lib::SolanaWireField::::Omitted.is_omitted()); +} diff --git a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs index bbebde4..33a7cb0 100644 --- a/crates/ksp-onchain-transport-lib/tests/release_completeness.rs +++ b/crates/ksp-onchain-transport-lib/tests/release_completeness.rs @@ -1,5 +1,5 @@ // file: crates/ksp-onchain-transport-lib/tests/release_completeness.rs -// version: 6 +// version: 7 //! Release-level completeness canaries for the `0.2.1` HTTP foundation contract. @@ -194,3 +194,50 @@ fn release_pre_007_v0_2_2_exact_set_is_complete_without_advancing_future_release assert_eq!(future_transactions, 11); assert_eq!(future_blocks_economics, 15); } + +#[test] +fn release_v0_2_3_descriptor_set_and_safety_classes_are_exact_before_wrapper_completion() { + let mut expected = std::vec![ + "getFeeForMessage", + "getLatestBlockhash", + "getRecentPrioritizationFees", + "getSignaturesForAddress", + "getSignatureStatuses", + "getTransaction", + "getTransactionCount", + "isBlockhashValid", + "requestAirdrop", + "sendTransaction", + "simulateTransaction", + ]; + let mut actual = std::vec::Vec::new(); + let mut reads = 0_usize; + let mut writes = 0_usize; + let mut simulations = 0_usize; + for descriptor in ksp_onchain_transport_lib::current_http_rpc_methods() { + if descriptor.coverage_release() != ksp_onchain_transport_lib::HttpRpcCoverageRelease::V0_2_3 { + continue; + } + actual.push(descriptor.method()); + match descriptor.operation_kind() { + ksp_onchain_transport_lib::RpcOperationKind::Read => { + reads += 1; + assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe); + }, + ksp_onchain_transport_lib::RpcOperationKind::WriteSubmission => { + writes += 1; + assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::NeverAfterDispatch); + }, + ksp_onchain_transport_lib::RpcOperationKind::Simulation => { + simulations += 1; + assert_eq!(descriptor.transport_retry_class(), ksp_onchain_transport_lib::TransportRetryClass::RetrySafe); + }, + } + } + actual.sort_unstable(); + expected.sort_unstable(); + assert_eq!(actual, expected); + assert_eq!(reads, 8); + assert_eq!(writes, 2); + assert_eq!(simulations, 1); +} diff --git a/crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs b/crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs new file mode 100644 index 0000000..eabf061 --- /dev/null +++ b/crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs @@ -0,0 +1,286 @@ +// file: crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs +// version: 1 + +#[test] +fn transaction_encoding_strings_match_current_and_legacy_wire_labels() { + assert_eq!(crate::SolanaTransactionBinaryEncoding::Base58.as_str(), "base58"); + assert_eq!(crate::SolanaTransactionBinaryEncoding::Base64.as_str(), "base64"); + assert_eq!(crate::SolanaTransactionEncoding::Binary.as_str(), "binary"); + assert_eq!(crate::SolanaTransactionEncoding::Base58.as_str(), "base58"); + assert_eq!(crate::SolanaTransactionEncoding::Base64.as_str(), "base64"); + assert_eq!(crate::SolanaTransactionEncoding::Json.as_str(), "json"); + assert_eq!(crate::SolanaTransactionEncoding::JsonParsed.as_str(), "jsonParsed"); +} + +#[test] +fn wire_field_preserves_omitted_null_and_value_states() { + #[derive(serde::Deserialize)] + struct Holder { + #[serde(default)] + field: crate::SolanaWireField, + } + + let omitted = serde_json::from_value::(serde_json::json!({})).expect("omitted fixture must decode"); + let null = serde_json::from_value::(serde_json::json!({"field": null})).expect("null fixture must decode"); + let value = serde_json::from_value::(serde_json::json!({"field": 7})).expect("value fixture must decode"); + assert!(omitted.field.is_omitted()); + assert!(!omitted.field.is_null()); + assert_eq!(omitted.field.value(), std::option::Option::None); + assert!(null.field.is_null()); + assert!(!null.field.is_omitted()); + assert_eq!(null.field.value(), std::option::Option::None); + assert_eq!(value.field.value(), std::option::Option::Some(&7)); +} + +#[test] +fn get_transaction_config_serializes_modern_object_without_legacy_overload() { + let config = crate::SolanaGetTransactionConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(crate::SolanaTransactionEncoding::JsonParsed), + std::option::Option::Some(0), + ); + assert!(!config.is_empty()); + assert_eq!(config.to_json_value(), serde_json::json!({"commitment":"confirmed","encoding":"jsonParsed","maxSupportedTransactionVersion":0})); + assert!(crate::SolanaGetTransactionConfig::default().is_empty()); + assert_eq!(crate::SolanaGetTransactionConfig::default().to_json_value(), serde_json::json!({})); +} + +#[test] +fn signatures_for_address_config_preserves_pagination_and_context_order_independently() { + let config = crate::SolanaSignaturesForAddressConfig::new( + std::option::Option::Some("before-signature".to_owned()), + std::option::Option::Some("until-signature".to_owned()), + std::option::Option::Some(25), + std::option::Option::Some(crate::SolanaCommitment::Finalized), + std::option::Option::Some(430_000_000), + ); + assert_eq!(config.before(), std::option::Option::Some("before-signature")); + assert_eq!(config.until(), std::option::Option::Some("until-signature")); + assert_eq!(config.limit(), std::option::Option::Some(25)); + assert_eq!(config.commitment(), std::option::Option::Some(crate::SolanaCommitment::Finalized)); + assert_eq!(config.min_context_slot(), std::option::Option::Some(430_000_000)); + assert_eq!( + config.to_json_value(), + serde_json::json!({ + "before":"before-signature", + "until":"until-signature", + "limit":25, + "commitment":"finalized", + "minContextSlot":430000000 + }) + ); + assert!(crate::SolanaSignaturesForAddressConfig::default().is_empty()); +} + +#[test] +fn signature_status_and_airdrop_configs_preserve_explicit_options() { + let status = crate::SolanaSignatureStatusesConfig::new(std::option::Option::Some(false)); + assert_eq!(status.search_transaction_history(), std::option::Option::Some(false)); + assert_eq!(status.to_json_value(), serde_json::json!({"searchTransactionHistory":false})); + let airdrop = crate::SolanaRequestAirdropConfig::new( + std::option::Option::Some("recent-blockhash".to_owned()), + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + ); + assert_eq!(airdrop.recent_blockhash(), std::option::Option::Some("recent-blockhash")); + assert_eq!(airdrop.commitment(), std::option::Option::Some(crate::SolanaCommitment::Confirmed)); + assert_eq!(airdrop.to_json_value(), serde_json::json!({"recentBlockhash":"recent-blockhash","commitment":"confirmed"})); +} + +#[test] +fn send_transaction_config_keeps_node_retries_distinct_from_transport_policy() { + let config = crate::SolanaSendTransactionConfig::new( + std::option::Option::Some(true), + std::option::Option::Some(crate::SolanaCommitment::Processed), + std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::Some(4), + std::option::Option::Some(430_000_000), + ); + assert_eq!(config.skip_preflight(), std::option::Option::Some(true)); + assert_eq!(config.max_retries(), std::option::Option::Some(4)); + assert_eq!( + config.to_json_value(), + serde_json::json!({ + "skipPreflight":true, + "preflightCommitment":"processed", + "encoding":"base64", + "maxRetries":4, + "minContextSlot":430000000 + }) + ); + assert!(crate::SolanaSendTransactionConfig::default().is_empty()); +} + +#[test] +fn simulate_transaction_config_serializes_accounts_and_flags_without_decoding_transaction() { + let address = "11111111111111111111111111111111".parse::().expect("fixture pubkey must parse"); + let accounts = crate::SolanaSimulationAccountsConfig::new(std::option::Option::Some(crate::SolanaAccountEncoding::Base64), std::vec![address]); + let config = crate::SolanaSimulateTransactionConfig::new( + std::option::Option::Some(crate::SolanaCommitment::Confirmed), + std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64), + std::option::Option::Some(true), + std::option::Option::Some(false), + std::option::Option::Some(430_000_000), + std::option::Option::Some(true), + std::option::Option::Some(accounts), + ); + assert_eq!(config.encoding(), std::option::Option::Some(crate::SolanaTransactionBinaryEncoding::Base64)); + assert_eq!(config.replace_recent_blockhash(), std::option::Option::Some(true)); + assert_eq!(config.sig_verify(), std::option::Option::Some(false)); + assert_eq!(config.accounts().expect("accounts config must be present").addresses(), &[address]); + assert_eq!( + config.to_json_value(), + serde_json::json!({ + "commitment":"confirmed", + "encoding":"base64", + "replaceRecentBlockhash":true, + "sigVerify":false, + "minContextSlot":430000000, + "innerInstructions":true, + "accounts":{"encoding":"base64","addresses":["11111111111111111111111111111111"]} + }) + ); + assert!(crate::SolanaSimulateTransactionConfig::default().is_empty()); +} + +#[test] +fn latest_blockhash_fixture_decodes_shared_result() { + let value = + serde_json::from_str::(include_str!("../fixtures/http/latest_blockhash.v4_2_1.json")).expect("latest blockhash fixture must decode"); + let blockhash = crate::SolanaLatestBlockhash::decode_wire("getLatestBlockhash", value).expect("latest blockhash must decode"); + assert_eq!(blockhash.blockhash(), "11111111111111111111111111111111"); + assert_eq!(blockhash.last_valid_block_height(), 430_123_456); +} + +#[test] +fn latest_blockhash_rejects_empty_wire_value() { + let result = crate::SolanaLatestBlockhash::decode_wire("getLatestBlockhash", serde_json::json!({"blockhash":"","lastValidBlockHeight":430123456})); + assert_eq!(result.expect_err("empty blockhash must fail closed").code(), crate::ERROR_CODE_INVALID_RESPONSE); +} + +#[test] +fn prioritization_fee_fixture_preserves_server_order() { + let values = serde_json::from_str::>(include_str!("../fixtures/http/prioritization_fee.v4_2_1.json")) + .expect("prioritization fee fixture must decode"); + let first = crate::SolanaPrioritizationFee::decode_wire("getRecentPrioritizationFees", values[0].clone()).expect("first fee must decode"); + let second = crate::SolanaPrioritizationFee::decode_wire("getRecentPrioritizationFees", values[1].clone()).expect("second fee must decode"); + assert_eq!(first.slot(), 430_000_123); + assert_eq!(first.prioritization_fee(), 1_000); + assert_eq!(second.slot(), 430_000_122); + assert_eq!(second.prioritization_fee(), 0); +} + +#[test] +fn signature_info_fixture_accepts_current_transaction_index_and_older_omission() { + let values = serde_json::from_str::>(include_str!("../fixtures/http/signature_info.v4_2_1.json")) + .expect("signature info fixture must decode"); + let current = crate::SolanaSignatureInfo::decode_wire("getSignaturesForAddress", values[0].clone()).expect("current signature info must decode"); + let older = crate::SolanaSignatureInfo::decode_wire("getSignaturesForAddress", values[1].clone()).expect("older signature info must decode"); + assert_eq!(current.slot(), 430_000_123); + assert_eq!(current.memo(), std::option::Option::Some("fixture memo")); + assert_eq!(current.transaction_index(), std::option::Option::Some(7)); + assert_eq!(current.confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized)); + assert_eq!(older.transaction_index(), std::option::Option::None); + assert!(older.err().is_some()); + assert_eq!(older.confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Confirmed)); +} + +#[test] +fn signature_status_fixture_preserves_position_null_and_legacy_status_value() { + let values = serde_json::from_str::>(include_str!("../fixtures/http/signature_status.variants.json")) + .expect("signature status fixture must decode"); + assert!(values[0].is_null()); + let rooted = crate::SolanaSignatureStatus::decode_wire("getSignatureStatuses", values[1].clone()).expect("rooted signature status must decode"); + let errored = crate::SolanaSignatureStatus::decode_wire("getSignatureStatuses", values[2].clone()).expect("errored signature status must decode"); + assert_eq!(rooted.confirmations(), std::option::Option::None); + assert_eq!(rooted.status(), &serde_json::json!({"Ok":null})); + assert_eq!(rooted.confirmation_status(), std::option::Option::Some(crate::SolanaTransactionConfirmationStatus::Finalized)); + assert_eq!(errored.confirmations(), std::option::Option::Some(3)); + assert!(errored.err().is_some()); +} + +#[test] +fn unknown_confirmation_status_is_rejected_as_invalid_response() { + let result = crate::SolanaSignatureStatus::decode_wire( + "getSignatureStatuses", + serde_json::json!({"slot":1,"confirmations":0,"status":{"Ok":null},"err":null,"confirmationStatus":"future-status"}), + ); + let error = result.expect_err("unknown confirmation status must fail closed"); + assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE); +} + +#[test] +fn encoded_transaction_fixture_preserves_legacy_binary_tuple_and_json_shapes() { + let values = serde_json::from_str::>(include_str!("../fixtures/http/encoded_transaction.variants.json")) + .expect("encoded transaction fixture must decode"); + let legacy = crate::SolanaEncodedTransaction::decode_wire("getTransaction", values[0].clone()).expect("legacy transaction must decode"); + let binary = crate::SolanaEncodedTransaction::decode_wire("getTransaction", values[1].clone()).expect("binary transaction must decode"); + let json = crate::SolanaEncodedTransaction::decode_wire("getTransaction", values[2].clone()).expect("JSON transaction must decode"); + assert!(matches!(legacy, crate::SolanaEncodedTransaction::LegacyBinary(_))); + assert!(matches!(binary, crate::SolanaEncodedTransaction::Binary { encoding: crate::SolanaTransactionBinaryEncoding::Base64, .. })); + assert!(matches!(json, crate::SolanaEncodedTransaction::Json(_))); + let invalid = crate::SolanaEncodedTransaction::decode_wire("getTransaction", serde_json::json!(["payload", "json"])); + assert_eq!(invalid.expect_err("non-binary tuple encoding must fail").code(), crate::ERROR_CODE_INVALID_RESPONSE); +} + +#[test] +fn confirmed_transaction_fixture_preserves_meta_version_and_transaction_index_states() { + let values = serde_json::from_str::>(include_str!("../fixtures/http/confirmed_transaction.variants.json")) + .expect("confirmed transaction fixture must decode"); + let current = crate::SolanaConfirmedTransaction::decode_wire("getTransaction", values[0].clone()).expect("current transaction must decode"); + let legacy = crate::SolanaConfirmedTransaction::decode_wire("getTransaction", values[1].clone()).expect("legacy transaction must decode"); + let omitted = crate::SolanaConfirmedTransaction::decode_wire("getTransaction", values[2].clone()).expect("omitted-field transaction must decode"); + assert_eq!(current.slot(), 430_000_123); + assert_eq!(current.version().value(), std::option::Option::Some(&crate::SolanaTransactionVersion::Number(0))); + assert_eq!(current.transaction_index().value(), std::option::Option::Some(&7)); + assert!(current.meta().value().is_some()); + assert_eq!(legacy.version().value(), std::option::Option::Some(&crate::SolanaTransactionVersion::Legacy)); + assert!(legacy.meta().is_null()); + assert!(legacy.transaction_index().is_null()); + assert!(omitted.meta().is_omitted()); + assert!(omitted.version().is_omitted()); + assert!(omitted.transaction_index().is_omitted()); +} + +#[test] +fn confirmed_transaction_rejects_unknown_version_shape() { + let result = crate::SolanaConfirmedTransaction::decode_wire( + "getTransaction", + serde_json::json!({"slot":1,"blockTime":null,"transaction":"legacy","meta":null,"version":"v0"}), + ); + assert_eq!(result.expect_err("unknown version label must fail").code(), crate::ERROR_CODE_INVALID_RESPONSE); +} + +#[test] +fn simulation_result_fixture_preserves_rich_optional_fields_and_positional_null_accounts() { + let value = serde_json::from_str::(include_str!("../fixtures/http/simulate_transaction_result.v4_2_1.json")) + .expect("simulation result fixture must decode"); + let result = crate::SolanaSimulateTransactionResult::decode_wire("simulateTransaction", value).expect("simulation result must decode"); + assert!(result.err().is_null()); + assert_eq!(result.logs().value().expect("logs must be present").len(), 2); + let accounts = result.accounts().value().expect("accounts must be present"); + assert_eq!(accounts.len(), 2); + assert_eq!(accounts[0].as_ref().expect("first account must be present").lamports(), 1_000); + assert!(accounts[1].is_none()); + assert_eq!(result.units_consumed().value(), std::option::Option::Some(&150)); + assert_eq!(result.loaded_accounts_data_size().value(), std::option::Option::Some(&64)); + assert_eq!(result.fee().value(), std::option::Option::Some(&5_000)); + let replacement = result.replacement_blockhash().value().expect("replacement blockhash must be present"); + assert_eq!(replacement.blockhash(), "ComputeBudget111111111111111111111111111111"); + assert!(result.return_data().value().is_some()); + assert!(result.inner_instructions().value().is_some()); + assert!(result.pre_token_balances().value().is_some()); + assert!(result.post_token_balances().value().is_some()); + assert!(result.loaded_addresses().value().is_some()); +} + +#[test] +fn simulation_result_distinguishes_omitted_from_explicit_null_fields() { + let result = crate::SolanaSimulateTransactionResult::decode_wire("simulateTransaction", serde_json::json!({"err":null,"logs":null,"unitsConsumed":1})) + .expect("partial simulation result must decode"); + assert!(result.err().is_null()); + assert!(result.logs().is_null()); + assert_eq!(result.units_consumed().value(), std::option::Option::Some(&1)); + assert!(result.accounts().is_omitted()); + assert!(result.replacement_blockhash().is_omitted()); + assert!(result.loaded_addresses().is_omitted()); +} diff --git a/deltas/0.2.3/pre.002.md b/deltas/0.2.3/pre.002.md new file mode 100644 index 0000000..dc6c251 --- /dev/null +++ b/deltas/0.2.3/pre.002.md @@ -0,0 +1,271 @@ + + + +# Delta `0.2.3-pre.002` — primitives Transaction et wire partagé + +## Base requise + +Livraison précédente : + +```text +0.2.3-pre.001 +workspace.package.version = "0.2.3-pre.1" +``` + +Le plan canonique de départ est `docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md` version 2, avec l'alignement Markdown corrigé par +l'opérateur avant cette tranche. + +## Objectif + +Installer les primitives, configurations et résultats partagés nécessaires aux 11 wrappers Transactions `0.2.3`, ainsi que des fixtures wire +locales déterministes, sans encore implémenter les quatre premiers wrappers Read prévus pour `pre.003` et sans déclarer la couverture typed +`0.2.3` complète. + +Cette tranche doit préserver les unions/nullable/omissions du wire Transaction tout en restant une couche Transport : aucun décodage Program, +aucune construction/signature de transaction et aucun client RPC Solana parallèle ne sont introduits. + +## Version Cargo + +Conformément à `VER-ID-009`, la nouvelle prerelease synchronise le signal technique : + +```text +0.2.3-pre.1 -> 0.2.3-pre.2 +``` + +Aucune dépendance ou feature Cargo n'est ajoutée. + +## Implémentation + +### Encodings et champs wire + +Le nouveau module `rpc_transactions` ajoute : + +```text +SolanaTransactionBinaryEncoding +SolanaTransactionEncoding +SolanaWireField +``` + +`SolanaTransactionBinaryEncoding` borne les entrées sérialisées `sendTransaction`/`simulateTransaction` à `base58 | base64` sans décoder les +bytes. `SolanaTransactionEncoding` conserve la surface plus large de `getTransaction` : `binary` legacy, `base58`, `base64`, `json` et +`jsonParsed`. + +`SolanaWireField` matérialise explicitement les trois états qui comptent sur les réponses Transaction évolutives : + +```text +Omitted +Null +Value(T) +``` + +Cette primitive évite de fusionner un champ absent avec un champ explicitement `null`, notamment pour `getTransaction` et +`simulateTransaction`. + +### Configurations partagées + +Les configurations suivantes sont ajoutées : + +```text +SolanaGetTransactionConfig +SolanaSignaturesForAddressConfig +SolanaSignatureStatusesConfig +SolanaRequestAirdropConfig +SolanaSendTransactionConfig +SolanaSimulationAccountsConfig +SolanaSimulateTransactionConfig +``` + +Elles sérialisent uniquement les options explicitement fournies. `SolanaSendTransactionConfig::max_retries` reste nommé et documenté comme une +limite de retransmission **côté noeud RPC** et ne modifie jamais la retry policy HTTP KSP. + +Les validations propres aux wrappers restent volontairement dans leurs tranches d'exécution : limites `128 / 1000 / 256`, encodings Account +interdits pour le retour de simulation et incompatibilité `sigVerify + replaceRecentBlockhash` ne sont pas transformés ici en une seconde policy +parallèle. + +### Résultats et DTOs Transaction + +Les résultats communs suivants sont ajoutés : + +```text +SolanaLatestBlockhash +SolanaPrioritizationFee +SolanaTransactionConfirmationStatus +SolanaSignatureInfo +SolanaSignatureStatus +SolanaEncodedTransaction +SolanaTransactionVersion +SolanaConfirmedTransaction +SolanaSimulateTransactionResult +``` + +`SolanaEncodedTransaction` conserve séparément : + +```text +chaîne binaire legacy +[data, "base58" | "base64"] +objet JSON / jsonParsed +``` + +Aucun `base64`, `bs58` ou `wincode` n'est donc requis. Les objets Transaction/message riches restent lossless via `serde_json::Value` aux +frontières où Transport ne possède pas de modèle métier. + +`SolanaConfirmedTransaction` conserve `meta`, `version` et `transactionIndex` avec la sémantique `Omitted | Null | Value`. Pendant +l'implémentation de cette tranche, le recoupement direct avec Agave `v4.2.1` a confirmé que `EncodedConfirmedTransactionWithStatusMeta` expose +également `transactionIndex: Option` au top-level de `getTransaction`. Le plan `010` passe donc en version 3 pour enregistrer cette précision +wire additive ; il ne s'agit pas d'un changement de périmètre. + +`SolanaSimulateTransactionResult` conserve les champs Agave courants utiles, y compris les comptes positionnels nullable, unités consommées, +loaded account data size, return data, inner instructions, replacement blockhash, fee, balances, token balances et loaded addresses. Les +sous-structures Program/transaction riches restent volontairement opaques lorsqu'un modèle Transport étroit n'apporte pas de valeur. + +### Validation de wire locale + +Les décodeurs partagés : + +- utilisent le domaine d'erreur Transport existant pour les shapes invalides ; +- rejettent un `getLatestBlockhash.blockhash` vide sans tenter de le décoder ; +- rejettent les labels de confirmation inconnus ; +- rejettent les tuples transaction binaires mal formés ou utilisant un encoding non binaire ; +- bornent le champ `version` de `getTransaction` à `legacy | u8` conformément au wire actuel ; +- préservent les `null` positionnels des comptes de simulation. + +Ils ne désérialisent ni message ni transaction Solana. + +## Fixtures déterministes ajoutées + +```text +crates/ksp-onchain-transport-lib/fixtures/http/latest_blockhash.v4_2_1.json +crates/ksp-onchain-transport-lib/fixtures/http/prioritization_fee.v4_2_1.json +crates/ksp-onchain-transport-lib/fixtures/http/signature_info.v4_2_1.json +crates/ksp-onchain-transport-lib/fixtures/http/signature_status.variants.json +crates/ksp-onchain-transport-lib/fixtures/http/encoded_transaction.variants.json +crates/ksp-onchain-transport-lib/fixtures/http/confirmed_transaction.variants.json +crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction_result.v4_2_1.json +``` + +Elles couvrent notamment : + +- latest blockhash ; +- ordre de samples de prioritization fees ; +- `transactionIndex` présent et absent sur les signatures ; +- `null` positionnel pour les statuses ; +- status legacy encore présent ; +- transaction legacy, tuple base64 et JSON ; +- `meta/version/transactionIndex` présents, `null` ou omis ; +- résultat de simulation riche avec compte positionnel `null` et champs optionnels actuels. + +## Tests ajoutés + +`unit_tests/rpc_transactions.rs` ajoute **18 tests déterministes** pour les enums/configs/décodeurs/fixtures de cette tranche. + +Les tests de surface ajoutent aussi : + +- un test public de construction/réexport des primitives Transaction depuis la racine de crate ; +- une canarie de registre qui confirme l'ensemble exact des 11 descriptors `0.2.3` et leur répartition `8 Read / 2 WriteSubmission / 1 Simulation` + sans prétendre que les 11 wrappers typed existent déjà. + +La canarie typed-complete `0.2.3 exact == 11` reste donc volontairement fermée jusqu'aux tranches de wrappers. + +## Fichiers ajoutés + +```text +crates/ksp-onchain-transport-lib/src/rpc_transactions.rs +crates/ksp-onchain-transport-lib/unit_tests/rpc_transactions.rs +crates/ksp-onchain-transport-lib/fixtures/http/latest_blockhash.v4_2_1.json +crates/ksp-onchain-transport-lib/fixtures/http/prioritization_fee.v4_2_1.json +crates/ksp-onchain-transport-lib/fixtures/http/signature_info.v4_2_1.json +crates/ksp-onchain-transport-lib/fixtures/http/signature_status.variants.json +crates/ksp-onchain-transport-lib/fixtures/http/encoded_transaction.variants.json +crates/ksp-onchain-transport-lib/fixtures/http/confirmed_transaction.variants.json +crates/ksp-onchain-transport-lib/fixtures/http/simulate_transaction_result.v4_2_1.json +deltas/0.2.3/pre.002.md +``` + +## Fichiers modifiés + +```text +Cargo.toml +crates/ksp-onchain-transport-lib/src/lib.rs +crates/ksp-onchain-transport-lib/tests/public_api.rs +crates/ksp-onchain-transport-lib/tests/release_completeness.rs +docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md +``` + +## Fichiers supprimés + +Aucun. + +## Fichiers volontairement inchangés + +```text +CHANGELOG.md +ROADMAP.md +crates/ksp-onchain-transport-lib/Cargo.toml +crates/ksp-onchain-transport-lib/README.md +crates/ksp-onchain-transport-lib/USAGE.md +crates/ksp-onchain-transport-lib/src/executor.rs +crates/ksp-onchain-transport-lib/src/rpc_method.rs +crates/ksp-config-lib/** +config/** +``` + +Aucun ancien delta n'est réécrit. Aucun wrapper `0.2.4` n'est avancé. + +## Validations exécutées + +- reconstruction de la base `0.2.3-pre.001` puis remplacement du plan `010` par la version 2 corrigée fournie par l'opérateur ; +- contrôle différentiel de la tranche contre cette base corrigée ; +- recoupement ciblé des unions/configs/résultats avec les sources primaires Agave `v4.2.1` déjà normatives pour le plan ; +- confirmation additive de `getTransaction.transactionIndex: Option` dans `EncodedConfirmedTransactionWithStatusMeta` ; +- parse JSON local des sept nouvelles fixtures ; +- contrôle statique des réexports crate-root des nouveaux types publics ; +- contrôle statique de l'absence de nouveau client HTTP, de `reqwest` direct dans le module Transaction, de boucle de retry locale et de wrapper + Transaction exécuté prématurément ; +- contrôle statique de l'absence de nouvelle dépendance/feature Cargo ; +- contrôle statique de l'absence de `unwrap`, `expect`, `panic!` et `?` dans la nouvelle source de production ; +- contrôle statique des lignes Rust modifiées à `<= 160` colonnes ; +- contrôle que `CHANGELOG.md` reste inchangé. + +## Validations non exécutées + +Le sandbox ne fournit pas `cargo` ni `rustfmt`. Les commandes suivantes ne sont donc pas 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 +``` + +Aucun `cargo tree` supplémentaire n'est requis par cette tranche puisqu'aucune dépendance ni feature n'a changé. Les vues de dépendances du prompt +restent obligatoires à la clôture de `0.2.3`. + +Le commit attendu après application et validations suit `VER-GIT-001` : + +```text +v0.2.3-pre.002 +``` + +## Décisions prises + +- conserver les payloads message/transaction sérialisés comme chaînes opaques tant qu'aucun invariant KSP n'exige leur décodage ; +- distinguer l'encoding binaire d'entrée de la surface d'encoding plus large de `getTransaction` ; +- représenter explicitement `absent / null / présent` lorsque le wire courant le nécessite ; +- conserver `serde_json::Value` seulement aux frontières Transaction/Program riches et évolutives, plutôt que dupliquer tout + `solana-transaction-status-client-types` ; +- réutiliser `SolanaContextConfig`, `SolanaAccountEncoding`, `SolanaAccount` et les erreurs Transport existantes ; +- ne pas ajouter les validations de wrapper avant leur tranche propriétaire ; +- ajouter `getTransaction.transactionIndex` au plan et au DTO après confirmation Agave `v4.2.1`. + +## Questions ouvertes + +Aucune question bloquante pour `pre.003`. + +Le formatage canonique final doit être produit par `cargo fmt --all`/`rustfmt` lors de l'application locale, puisque ces binaires ne sont pas +disponibles dans le sandbox d'échange. + +## Suite + +`0.2.3-pre.003` : implémenter `getFeeForMessage`, `getLatestBlockhash`, `getTransactionCount` et `isBlockhashValid` via le chemin +`descriptor -> execute_standard_rpc`, avec requêtes exactes, résultats contextuels/nullables, erreurs RPC et fixtures HTTP locales. diff --git a/docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md b/docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md index ce93222..b07d7b0 100644 --- a/docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md +++ b/docs/plans/010-V0_2_3_HTTP_TRANSACTIONS_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.2.3` — HTTP Transactions @@ -314,13 +314,18 @@ Cette API legacy doit être nommée/annotée de manière à ne pas sembler être Le résultat confirmé garde un top-level typé : ```text -slot u64 -blockTime Option -transaction EncodedTransaction -meta Option -version legacy | number +slot u64 +blockTime Option +transaction EncodedTransaction +meta absent | null | transaction-meta wire +version absent | null | legacy | number +transactionIndex absent | null | u32 ``` +Le recoupement d'implémentation `pre.002` avec `transaction-status-client-types` Agave `v4.2.1` a confirmé que +`EncodedConfirmedTransactionWithStatusMeta` expose également `transactionIndex: Option` au top-level de `getTransaction`. Cette extension +optionnelle est donc préservée comme celle de `getSignaturesForAddress`, sans être rendue obligatoire pour les providers plus anciens. + `EncodedTransaction` doit préserver les différentes formes réellement sérialisées : ```text @@ -335,6 +340,10 @@ sont riches, évolutives ou dépendantes de `jsonParsed` peuvent rester lossless Le meta doit au minimum respecter la distinction **absent/null/présent** imposée par le wire et ne jamais inventer des listes ou zéros lorsque le provider omet une donnée optionnelle. +`pre.002` matérialise cette distinction avec une primitive générique `SolanaWireField` limitée au wire Transport : `Omitted`, `Null`, `Value(T)`. +Elle est utilisée pour les champs Transaction/Simulation où l'omission et `null` doivent rester distinguables ; elle ne transforme pas ce mécanisme +en modèle métier et ne remplace pas `Option` lorsque les deux états ont le même sens contractuel. + ### `getRecentPrioritizationFees` Le paramètre optionnel est un tableau d'adresses. La documentation actuelle fixe un maximum de **128** et précise que, lorsqu'il est fourni, les @@ -634,3 +643,18 @@ Aucune commande n'est déclarée réussie sans exécution réelle ou preuve opé - découpage `pre.002`–`pre.008` défini. La tranche suivante peut commencer par les primitives wire communes sans réouvrir le scope de release. + +## Résultat de `pre.002` + +`pre.002` installe les primitives/configurations/résultats partagés sans déclarer un wrapper Transaction comme terminé : + +- encodings Transaction séparés entre entrées binaires `base58/base64` et surface `getTransaction` incluant les formes JSON/legacy ; +- configs modernes de `getTransaction`, pagination signatures, statuses, airdrop, send et simulation ; +- DTOs `latestBlockhash`, prioritization fee, signature info/status, transaction encodée/versionnée et résultat de simulation ; +- primitive `SolanaWireField` pour préserver explicitement `omitted/null/value` ; +- `transactionIndex` préservé sur `getSignaturesForAddress` et sur le top-level `getTransaction` courant ; +- transaction/meta/return data/inner instructions/token balances riches conservés losslessly aux frontières prévues sans décodage Program ; +- fixtures communes déterministes pour les variantes legacy/binary/JSON, version/meta/index et simulation riche ; +- aucune nouvelle dépendance Cargo et aucun wrapper `0.2.3` activé prématurément. + +La tranche suivante reste `pre.003` : `getFeeForMessage`, `getLatestBlockhash`, `getTransactionCount` et `isBlockhashValid`.