From d00a4c724528b8f7163c83844e102623b63a7b0b Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 25 Jul 2026 08:29:46 +0200 Subject: [PATCH] v0.1.0-pre.026 --- .../src/get_signatures_for_address.rs | 234 ++++++ kb-onchain-transport/src/get_transaction.rs | 730 ++++++++++++++++++ kb-onchain-transport/src/lib.rs | 20 +- .../get_transaction_equivalent_a.json | 39 + .../get_transaction_equivalent_b.json | 30 + .../get_transaction_legacy_failure.json | 37 + .../get_transaction_legacy_success.json | 80 ++ .../get_transaction_optional_absent.json | 28 + ...get_transaction_v0_alt_cpi_token_2022.json | 88 +++ 9 files changed, 1285 insertions(+), 1 deletion(-) create mode 100644 kb-onchain-transport/src/get_signatures_for_address.rs create mode 100644 kb-onchain-transport/src/get_transaction.rs create mode 100644 kb-onchain-transport/tests/fixtures/get_transaction_equivalent_a.json create mode 100644 kb-onchain-transport/tests/fixtures/get_transaction_equivalent_b.json create mode 100644 kb-onchain-transport/tests/fixtures/get_transaction_legacy_failure.json create mode 100644 kb-onchain-transport/tests/fixtures/get_transaction_legacy_success.json create mode 100644 kb-onchain-transport/tests/fixtures/get_transaction_optional_absent.json create mode 100644 kb-onchain-transport/tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json diff --git a/kb-onchain-transport/src/get_signatures_for_address.rs b/kb-onchain-transport/src/get_signatures_for_address.rs new file mode 100644 index 0000000..9dc20a9 --- /dev/null +++ b/kb-onchain-transport/src/get_signatures_for_address.rs @@ -0,0 +1,234 @@ +// file: kb-onchain-transport/src/get_signatures_for_address.rs +// version: 2 + +//! Standard Solana `getSignaturesForAddress` request and response contracts. + +/// One transaction signature summary returned by `getSignaturesForAddress`. +#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AddressSignatureInfo { + /// Transaction signature. + pub signature: std::string::String, + /// Slot containing the transaction. + pub slot: u64, + /// Optional transaction error returned by the RPC node. + pub err: std::option::Option, + /// Optional memo associated with the transaction. + pub memo: std::option::Option, + /// Optional block time as a Unix timestamp. + pub block_time: std::option::Option, + /// Optional confirmation status reported by the RPC node. + pub confirmation_status: std::option::Option, +} + +/// Configuration for one standard Solana `getSignaturesForAddress` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetSignaturesForAddressConfig { + /// Requested commitment, restricted to `confirmed` or `finalized`. + pub commitment: std::string::String, + /// Maximum number of signatures requested from the node. + pub limit: u16, + /// Optional exclusive cursor toward older signatures. + pub before: std::option::Option, + /// Optional exclusive lower boundary toward newer signatures. + pub until: std::option::Option, + /// Optional minimum context slot. + pub min_context_slot: std::option::Option, +} + +impl GetSignaturesForAddressConfig { + /// Creates and validates one signature history request configuration. + pub fn new( + commitment: impl std::convert::Into, + limit: u16, + before: std::option::Option, + until: std::option::Option, + min_context_slot: std::option::Option, + ) -> kb_core::Result { + let commitment_value = commitment.into(); + if commitment_value != "confirmed" && commitment_value != "finalized" { + return std::result::Result::Err(kb_core::Error::config( + "getSignaturesForAddress commitment must be 'confirmed' or 'finalized'", + )); + } + if limit == 0 || limit > 1000 { + return std::result::Result::Err(kb_core::Error::config( + "getSignaturesForAddress limit must be between 1 and 1000", + )); + } + let before_result = validate_optional_signature( + before.as_ref(), + "getSignaturesForAddress before signature", + ); + if let std::result::Result::Err(error) = before_result { + return std::result::Result::Err(error); + } + let until_result = + validate_optional_signature(until.as_ref(), "getSignaturesForAddress until signature"); + if let std::result::Result::Err(error) = until_result { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(Self { + commitment: commitment_value, + limit, + before, + until, + min_context_slot, + }); + } + + fn request_params(&self, address: &str) -> kb_core::Result> { + let address_result = + crate::validate_solana_pubkey_text(address, "getSignaturesForAddress address"); + if let std::result::Result::Err(error) = address_result { + return std::result::Result::Err(error); + } + let mut config = serde_json::Map::::new(); + config.insert("commitment".to_string(), serde_json::Value::String(self.commitment.clone())); + config.insert( + "limit".to_string(), + serde_json::Value::Number(serde_json::Number::from(self.limit)), + ); + if let std::option::Option::Some(before) = &self.before { + config.insert("before".to_string(), serde_json::Value::String(before.clone())); + } + if let std::option::Option::Some(until) = &self.until { + config.insert("until".to_string(), serde_json::Value::String(until.clone())); + } + if let std::option::Option::Some(min_context_slot) = self.min_context_slot { + config.insert( + "minContextSlot".to_string(), + serde_json::Value::Number(serde_json::Number::from(min_context_slot)), + ); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::String(address.to_string()), + serde_json::Value::Object(config), + ]); + } +} + +impl crate::HttpClient { + /// Fetches one page of signatures associated with an account or program address. + pub async fn get_signatures_for_address( + &self, + address: &str, + config: &crate::GetSignaturesForAddressConfig, + ) -> kb_core::Result> { + let params_result = config.request_params(address); + let params = match params_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = self + .execute_json_rpc_result_raw("getSignaturesForAddress".to_string(), params) + .await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return crate::adapt_get_signatures_for_address_result(&value); + } +} + +/// Adapts a raw standard Solana `getSignaturesForAddress` result value. +pub fn adapt_get_signatures_for_address_result( + source: &serde_json::Value, +) -> kb_core::Result> { + let parse_result = + serde_json::from_value::>(source.clone()); + let parsed = match parse_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse getSignaturesForAddress result: {error}" + ))); + }, + }; + for item in &parsed { + let validation_result = crate::validate_transaction_signature_text( + item.signature.as_str(), + "getSignaturesForAddress result signature", + ); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + } + return std::result::Result::Ok(parsed); +} + +fn validate_optional_signature( + value: std::option::Option<&std::string::String>, + field_name: &str, +) -> kb_core::Result<()> { + if let std::option::Option::Some(signature) = value { + return crate::validate_transaction_signature_text(signature.as_str(), field_name); + } + return std::result::Result::Ok(()); +} + +#[cfg(test)] +mod tests { + fn signature(byte: u8) -> std::string::String { + return bs58::encode([byte; 64]).into_string(); + } + + fn pubkey(byte: u8) -> std::string::String { + return bs58::encode([byte; 32]).into_string(); + } + + #[test] + fn config_supports_before_and_until_for_bounded_pagination() { + let config_result = crate::GetSignaturesForAddressConfig::new( + "confirmed", + 1000, + std::option::Option::Some(signature(1)), + std::option::Option::Some(signature(2)), + std::option::Option::Some(44), + ); + let config = match config_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected config error: {error}"), + }; + let params_result = config.request_params(pubkey(3).as_str()); + let params = match params_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected params error: {error}"), + }; + assert_eq!(params.len(), 2); + assert_eq!(params[1]["limit"], serde_json::json!(1000)); + assert_eq!(params[1]["minContextSlot"], serde_json::json!(44)); + } + + #[test] + fn config_rejects_limit_above_rpc_maximum() { + let result = crate::GetSignaturesForAddressConfig::new( + "confirmed", + 1001, + std::option::Option::None, + std::option::Option::None, + std::option::Option::None, + ); + assert!(result.is_err()); + } + + #[test] + fn result_adapter_preserves_signature_metadata() { + let source = serde_json::json!([{ + "signature": signature(4), + "slot": 123, + "err": null, + "memo": "hello", + "blockTime": 456, + "confirmationStatus": "finalized" + }]); + let result = crate::adapt_get_signatures_for_address_result(&source); + let values = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("unexpected adapter error: {error}"), + }; + assert_eq!(values.len(), 1); + assert_eq!(values[0].slot, 123); + assert_eq!(values[0].memo.as_deref(), std::option::Option::Some("hello")); + } +} diff --git a/kb-onchain-transport/src/get_transaction.rs b/kb-onchain-transport/src/get_transaction.rs new file mode 100644 index 0000000..ecee400 --- /dev/null +++ b/kb-onchain-transport/src/get_transaction.rs @@ -0,0 +1,730 @@ +// file: kb-onchain-transport/src/get_transaction.rs +// version: 8 + +//! Standard Solana `getTransaction` adapter for the canonical transaction contract. + +use base64::Engine; // rust-rules: trait-import + +/// Common adapter contract for transport-specific transaction payloads. +pub trait CanonicalTransactionAdapter { + /// Source payload type accepted by the adapter. + type Source; + + /// Adapts one source payload into an optional canonical transaction. + fn adapt( + &self, + source: &Self::Source, + ) -> kb_core::Result>; +} + +/// Configuration for a standard Solana `getTransaction` request. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GetTransactionConfig { + /// Requested commitment, restricted to `confirmed` or `finalized`. + pub commitment: std::string::String, + /// Maximum supported numbered transaction version. + pub max_supported_transaction_version: u8, +} + +impl GetTransactionConfig { + /// Creates and validates a `getTransaction` request configuration. + pub fn new( + commitment: impl std::convert::Into, + max_supported_transaction_version: u8, + ) -> kb_core::Result { + let commitment_value = commitment.into(); + if commitment_value != "confirmed" && commitment_value != "finalized" { + return std::result::Result::Err(kb_core::Error::config( + "getTransaction commitment must be 'confirmed' or 'finalized'", + )); + } + return std::result::Result::Ok(Self { + commitment: commitment_value, + max_supported_transaction_version, + }); + } + + /// Creates the default confirmed configuration supporting transaction version zero. + pub fn confirmed_v0() -> Self { + return Self { + commitment: "confirmed".to_string(), + max_supported_transaction_version: 0, + }; + } + + fn request_params(&self, signature: &str) -> kb_core::Result> { + let signature_result = + crate::validate_transaction_signature_text(signature, "getTransaction signature"); + if let std::result::Result::Err(error) = signature_result { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(std::vec![ + serde_json::Value::String(signature.to_string()), + serde_json::json!({ + "commitment": self.commitment.clone(), + "encoding": "json", + "maxSupportedTransactionVersion": self.max_supported_transaction_version + }), + ]); + } +} + +/// Source payload and canonical result returned by one `getTransaction` request. +#[derive(Clone, Debug, PartialEq)] +pub struct GetTransactionAcquisition { + /// Source-specific JSON result retained only for immediate metrics and hashing. + pub source_json: serde_json::Value, + /// Canonical source-independent transaction when the RPC result is not null. + pub canonical_transaction: std::option::Option, +} + +/// Adapter from the standard Solana `getTransaction` JSON result to the canonical model. +#[derive(Clone, Copy, Debug, Default)] +pub struct GetTransactionAdapter; + +impl crate::CanonicalTransactionAdapter for crate::GetTransactionAdapter { + type Source = serde_json::Value; + + fn adapt( + &self, + source: &Self::Source, + ) -> kb_core::Result> { + return adapt_get_transaction_result(source); + } +} + +impl crate::HttpClient { + /// Fetches one source payload and its canonical transaction adaptation. + pub async fn get_transaction_acquisition( + &self, + signature: &str, + config: &crate::GetTransactionConfig, + ) -> kb_core::Result { + let params_result = config.request_params(signature); + let params = match params_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let result = self.execute_json_rpc_result_raw("getTransaction".to_string(), params).await; + let value = match result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let adapter = crate::GetTransactionAdapter; + let canonical_result = crate::CanonicalTransactionAdapter::adapt(&adapter, &value); + let canonical_transaction = match canonical_result { + std::result::Result::Ok(canonical) => canonical, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::GetTransactionAcquisition { + source_json: value, + canonical_transaction, + }); + } + + /// Fetches and adapts one standard Solana transaction into the canonical model. + pub async fn get_transaction_canonical( + &self, + signature: &str, + config: &crate::GetTransactionConfig, + ) -> kb_core::Result> { + let acquisition_result = self.get_transaction_acquisition(signature, config).await; + let acquisition = match acquisition_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(acquisition.canonical_transaction); + } +} + +/// Adapts a raw standard Solana `getTransaction` result value. +pub fn adapt_get_transaction_result( + source: &serde_json::Value, +) -> kb_core::Result> { + if source.is_null() { + return std::result::Result::Ok(std::option::Option::None); + } + let parse_result = serde_json::from_value::(source.clone()); + let parsed = match parse_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "cannot parse getTransaction result: {error}" + ))); + }, + }; + let canonical_result = adapt_parsed_transaction(parsed); + return match canonical_result { + std::result::Result::Ok(transaction) => { + std::result::Result::Ok(std::option::Option::Some(transaction)) + }, + std::result::Result::Err(error) => std::result::Result::Err(error), + }; +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcGetTransactionResult { + slot: u64, + #[serde(default)] + block_time: std::option::Option, + #[serde(default)] + meta: std::option::Option, + transaction: RpcJsonTransaction, + #[serde(default)] + version: std::option::Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(untagged)] +enum RpcTransactionVersion { + Legacy(std::string::String), + Number(u8), +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct RpcJsonTransaction { + signatures: std::vec::Vec, + message: RpcRawMessage, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcRawMessage { + header: RpcMessageHeader, + account_keys: std::vec::Vec, + recent_blockhash: std::string::String, + instructions: std::vec::Vec, + #[serde(default)] + address_table_lookups: std::vec::Vec, +} + +#[derive(Clone, Copy, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcMessageHeader { + num_required_signatures: u8, + num_readonly_signed_accounts: u8, + num_readonly_unsigned_accounts: u8, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcCompiledInstruction { + program_id_index: u16, + accounts: std::vec::Vec, + data: std::string::String, + #[serde(default)] + stack_height: std::option::Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcAddressTableLookup { + account_key: std::string::String, + writable_indexes: std::vec::Vec, + readonly_indexes: std::vec::Vec, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcTransactionMeta { + #[serde(default)] + err: std::option::Option, + fee: u64, + pre_balances: std::vec::Vec, + post_balances: std::vec::Vec, + #[serde(default)] + inner_instructions: std::option::Option>, + #[serde(default)] + log_messages: std::option::Option>, + #[serde(default)] + pre_token_balances: std::option::Option>, + #[serde(default)] + post_token_balances: std::option::Option>, + #[serde(default)] + rewards: std::option::Option>, + #[serde(default)] + loaded_addresses: std::option::Option, + #[serde(default)] + return_data: std::option::Option, + #[serde(default)] + compute_units_consumed: std::option::Option, + #[serde(default)] + cost_units: std::option::Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct RpcInnerInstructionGroup { + index: u16, + instructions: std::vec::Vec, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcTokenBalance { + account_index: u16, + mint: std::string::String, + #[serde(default)] + owner: std::option::Option, + #[serde(default)] + program_id: std::option::Option, + ui_token_amount: RpcUiTokenAmount, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcUiTokenAmount { + amount: std::string::String, + decimals: u8, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcReward { + pubkey: std::string::String, + lamports: i64, + post_balance: u64, + #[serde(default)] + reward_type: std::option::Option, + #[serde(default)] + commission: std::option::Option, +} + +#[derive(Clone, Debug, Default, serde::Deserialize)] +struct RpcLoadedAddresses { + #[serde(default)] + writable: std::vec::Vec, + #[serde(default)] + readonly: std::vec::Vec, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RpcReturnData { + program_id: std::string::String, + data: std::vec::Vec, +} + +fn adapt_parsed_transaction( + parsed: RpcGetTransactionResult, +) -> kb_core::Result { + let primary_signature = match parsed.transaction.signatures.first() { + std::option::Option::Some(value) => value.clone(), + std::option::Option::None => { + return std::result::Result::Err(kb_core::Error::json( + "getTransaction result contains no signatures", + )); + }, + }; + let version_result = adapt_version(parsed.version); + let version = match version_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let loaded_addresses = match &parsed.meta { + std::option::Option::Some(metadata) => { + adapt_loaded_addresses(metadata.loaded_addresses.clone()) + }, + std::option::Option::None => kb_lib::MdCanonicalLoadedAddresses::default(), + }; + let instructions_result = adapt_instructions(parsed.transaction.message.instructions); + let instructions = match instructions_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let metadata_result = adapt_metadata(parsed.meta); + let metadata = match metadata_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let transaction = kb_lib::MdCanonicalTransaction { + format_version: kb_lib::MD_CANONICAL_TRANSACTION_FORMAT_VERSION, + primary_signature, + slot: parsed.slot, + block_time: parsed.block_time, + version, + signatures: parsed.transaction.signatures, + message: kb_lib::MdCanonicalTransactionMessage { + header: kb_lib::MdCanonicalMessageHeader { + num_required_signatures: parsed.transaction.message.header.num_required_signatures, + num_readonly_signed_accounts: parsed + .transaction + .message + .header + .num_readonly_signed_accounts, + num_readonly_unsigned_accounts: parsed + .transaction + .message + .header + .num_readonly_unsigned_accounts, + }, + static_account_keys: parsed.transaction.message.account_keys, + recent_blockhash: parsed.transaction.message.recent_blockhash, + instructions, + address_table_lookups: parsed + .transaction + .message + .address_table_lookups + .into_iter() + .map(|lookup| { + return kb_lib::MdCanonicalAddressTableLookup { + account_key: lookup.account_key, + writable_indexes: lookup.writable_indexes, + readonly_indexes: lookup.readonly_indexes, + }; + }) + .collect(), + loaded_addresses, + }, + metadata, + }; + let validation_result = transaction.validate(); + if let std::result::Result::Err(error) = validation_result { + return std::result::Result::Err(error); + } + return std::result::Result::Ok(transaction); +} + +fn adapt_version( + version: std::option::Option, +) -> kb_core::Result { + return match version { + std::option::Option::Some(RpcTransactionVersion::Legacy(value)) => { + if value != "legacy" { + return std::result::Result::Err(kb_core::Error::json(format!( + "unsupported textual transaction version: {value}" + ))); + } + std::result::Result::Ok(kb_lib::MdCanonicalTransactionVersion::Legacy) + }, + std::option::Option::Some(RpcTransactionVersion::Number(value)) => { + std::result::Result::Ok(kb_lib::MdCanonicalTransactionVersion::Number(value)) + }, + std::option::Option::None => { + std::result::Result::Ok(kb_lib::MdCanonicalTransactionVersion::Legacy) + }, + }; +} + +fn adapt_instructions( + instructions: std::vec::Vec, +) -> kb_core::Result> { + let mut adapted = std::vec::Vec::with_capacity(instructions.len()); + for instruction in instructions { + let instruction_result = adapt_instruction(instruction); + let canonical = match instruction_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + adapted.push(canonical); + } + return std::result::Result::Ok(adapted); +} + +fn adapt_instruction( + instruction: RpcCompiledInstruction, +) -> kb_core::Result { + let decode_result = bs58::decode(instruction.data.as_str()).into_vec(); + let bytes = match decode_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err(kb_core::Error::json(format!( + "getTransaction instruction data is not valid base58: {error}" + ))); + }, + }; + let data_base64 = base64::engine::general_purpose::STANDARD.encode(bytes); + return std::result::Result::Ok(kb_lib::MdCanonicalCompiledInstruction { + program_id_index: instruction.program_id_index, + account_indexes: instruction.accounts, + data_base64, + stack_height: instruction.stack_height, + }); +} + +fn adapt_loaded_addresses( + loaded_addresses: std::option::Option, +) -> kb_lib::MdCanonicalLoadedAddresses { + return match loaded_addresses { + std::option::Option::Some(value) => kb_lib::MdCanonicalLoadedAddresses { + writable: value.writable, + readonly: value.readonly, + }, + std::option::Option::None => kb_lib::MdCanonicalLoadedAddresses::default(), + }; +} + +fn adapt_metadata( + metadata: std::option::Option, +) -> kb_core::Result> { + let source = match metadata { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), + }; + let inner_result = adapt_inner_instructions(source.inner_instructions.unwrap_or_default()); + let inner_instructions = match inner_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let return_result = adapt_return_data(source.return_data); + let return_data = match return_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let pre_token_result = adapt_token_balances(source.pre_token_balances.unwrap_or_default()); + let pre_token_balances = match pre_token_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let post_token_result = adapt_token_balances(source.post_token_balances.unwrap_or_default()); + let post_token_balances = match post_token_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let status = if source.err.is_some() { + kb_lib::MdCanonicalTransactionStatus::Failed + } else { + kb_lib::MdCanonicalTransactionStatus::Success + }; + return std::result::Result::Ok(std::option::Option::Some( + kb_lib::MdCanonicalTransactionMetadata { + status, + error: source.err, + fee: source.fee, + pre_balances: source.pre_balances, + post_balances: source.post_balances, + inner_instructions, + log_messages: source.log_messages.unwrap_or_default(), + pre_token_balances, + post_token_balances, + rewards: source + .rewards + .unwrap_or_default() + .into_iter() + .map(|reward| { + return kb_lib::MdCanonicalReward { + pubkey: reward.pubkey, + lamports: reward.lamports, + post_balance: reward.post_balance, + reward_type: reward.reward_type, + commission: reward.commission, + }; + }) + .collect(), + return_data, + compute_units_consumed: source.compute_units_consumed, + cost_units: source.cost_units, + }, + )); +} + +fn adapt_inner_instructions( + groups: std::vec::Vec, +) -> kb_core::Result> { + let mut adapted = std::vec::Vec::with_capacity(groups.len()); + for group in groups { + let instructions_result = adapt_instructions(group.instructions); + let instructions = match instructions_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + adapted.push(kb_lib::MdCanonicalInnerInstructionGroup { + parent_instruction_index: group.index, + instructions, + }); + } + return std::result::Result::Ok(adapted); +} + +fn adapt_token_balances( + balances: std::vec::Vec, +) -> kb_core::Result> { + let mut adapted = std::vec::Vec::with_capacity(balances.len()); + for balance in balances { + let balance_result = kb_lib::MdCanonicalTokenBalance::new( + balance.account_index, + balance.mint, + balance.owner, + balance.program_id, + balance.ui_token_amount.amount, + balance.ui_token_amount.decimals, + ); + let canonical = match balance_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + adapted.push(canonical); + } + return std::result::Result::Ok(adapted); +} + +fn adapt_return_data( + return_data: std::option::Option, +) -> kb_core::Result> { + let source = match return_data { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), + }; + if source.data.len() != 2 { + return std::result::Result::Err(kb_core::Error::json( + "getTransaction return data must contain payload and encoding", + )); + } + if source.data[1] != "base64" { + return std::result::Result::Err(kb_core::Error::json(format!( + "unsupported getTransaction return data encoding: {}", + source.data[1] + ))); + } + return std::result::Result::Ok(std::option::Option::Some(kb_lib::MdCanonicalReturnData { + program_id: source.program_id, + data_base64: source.data[0].clone(), + })); +} + +#[cfg(test)] +mod tests { + fn fixture(name: &str) -> serde_json::Value { + let text = match name { + "legacy_success" => { + include_str!("../tests/fixtures/get_transaction_legacy_success.json") + }, + "legacy_failure" => { + include_str!("../tests/fixtures/get_transaction_legacy_failure.json") + }, + "v0_alt" => { + include_str!("../tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json") + }, + "optional_absent" => { + include_str!("../tests/fixtures/get_transaction_optional_absent.json") + }, + "equivalent_a" => include_str!("../tests/fixtures/get_transaction_equivalent_a.json"), + "equivalent_b" => include_str!("../tests/fixtures/get_transaction_equivalent_b.json"), + _ => panic!("unknown fixture: {name}"), + }; + let parse_result = serde_json::from_str(text); + match parse_result { + std::result::Result::Ok(value) => return value, + std::result::Result::Err(error) => panic!("fixture parse failed: {error}"), + } + } + + fn adapt_fixture(name: &str) -> kb_lib::MdCanonicalTransaction { + let value = fixture(name); + let result = crate::adapt_get_transaction_result(&value); + match result { + std::result::Result::Ok(std::option::Option::Some(transaction)) => return transaction, + std::result::Result::Ok(std::option::Option::None) => { + panic!("fixture unexpectedly adapted to none"); + }, + std::result::Result::Err(error) => panic!("fixture adaptation failed: {error}"), + } + } + + #[test] + fn null_result_adapts_to_none() { + let result = crate::adapt_get_transaction_result(&serde_json::Value::Null); + assert_eq!(result, std::result::Result::Ok(std::option::Option::None)); + } + + #[test] + fn legacy_success_preserves_token_balances_and_return_data() { + let transaction = adapt_fixture("legacy_success"); + assert_eq!(transaction.version, kb_lib::MdCanonicalTransactionVersion::Legacy); + assert_eq!(transaction.block_time, std::option::Option::Some(1_700_000_001)); + let metadata = match transaction.metadata { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("metadata missing"), + }; + assert_eq!(metadata.status, kb_lib::MdCanonicalTransactionStatus::Success); + assert_eq!(transaction.message.instructions[0].data_base64, "AQ=="); + assert_eq!(metadata.post_token_balances.len(), 1); + assert_eq!(metadata.post_token_balances[0].amount, "12345678901234567890"); + assert_eq!(metadata.post_token_balances[0].decimal_amount, "12345678901.234567890"); + assert!(metadata.return_data.is_some()); + assert_eq!(metadata.inner_instructions.len(), 1); + } + + #[test] + fn legacy_failure_preserves_error() { + let transaction = adapt_fixture("legacy_failure"); + let metadata = match transaction.metadata { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("metadata missing"), + }; + assert_eq!(metadata.status, kb_lib::MdCanonicalTransactionStatus::Failed); + assert!(metadata.error.is_some()); + } + + #[test] + fn version_zero_preserves_alt_cpi_and_token_2022() { + let transaction = adapt_fixture("v0_alt"); + assert_eq!(transaction.version, kb_lib::MdCanonicalTransactionVersion::Number(0)); + assert_eq!(transaction.message.address_table_lookups.len(), 1); + assert_eq!(transaction.message.loaded_addresses.writable.len(), 1); + let metadata = match transaction.metadata { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("metadata missing"), + }; + assert_eq!(metadata.inner_instructions.len(), 1); + assert_eq!( + metadata.post_token_balances[0].program_id.as_deref(), + std::option::Option::Some("TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb") + ); + } + + #[test] + fn optional_fields_and_block_time_can_be_absent() { + let transaction = adapt_fixture("optional_absent"); + assert_eq!(transaction.block_time, std::option::Option::None); + let metadata = match transaction.metadata { + std::option::Option::Some(value) => value, + std::option::Option::None => panic!("metadata missing"), + }; + assert!(metadata.log_messages.is_empty()); + assert!(metadata.rewards.is_empty()); + assert!(metadata.return_data.is_none()); + } + + #[test] + fn equivalent_provider_shapes_produce_identical_hashes() { + let first = adapt_fixture("equivalent_a"); + let second = adapt_fixture("equivalent_b"); + let first_hash = match first.canonical_json_hash() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("first hash failed: {error}"), + }; + let second_hash = match second.canonical_json_hash() { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("second hash failed: {error}"), + }; + assert_eq!(first_hash, second_hash); + } + + #[test] + fn request_config_rejects_public_key_length_signature() { + let config = crate::GetTransactionConfig::confirmed_v0(); + let result = config.request_params("3Bxs4NN8M2Yn4TLb7gR6Xy7n2D1Q8VjQqWcnpX8C6pQw"); + assert!(result.is_err()); + } + + #[test] + fn request_config_rejects_processed_commitment() { + let result = crate::GetTransactionConfig::new("processed", 0); + assert!(result.is_err()); + } + + #[test] + fn request_config_uses_json_encoding_and_version_zero() { + let config = crate::GetTransactionConfig::confirmed_v0(); + let params_result = config.request_params( + "2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T", + ); + let params = match params_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("request params failed: {error}"), + }; + assert_eq!(params[1]["encoding"], serde_json::Value::String("json".to_string())); + assert_eq!(params[1]["maxSupportedTransactionVersion"], serde_json::Value::from(0)); + } +} diff --git a/kb-onchain-transport/src/lib.rs b/kb-onchain-transport/src/lib.rs index 2211327..dd4d2bf 100644 --- a/kb-onchain-transport/src/lib.rs +++ b/kb-onchain-transport/src/lib.rs @@ -1,5 +1,5 @@ // file: kb-onchain-transport/src/lib.rs -// version: 6 +// version: 7 #![forbid(unsafe_code)] #![deny(unreachable_pub)] @@ -11,6 +11,8 @@ mod client; mod constants; mod endpoint_role; mod execution_rpc; +mod get_signatures_for_address; +mod get_transaction; mod http_client; mod http_pool; mod json_rpc; @@ -124,6 +126,22 @@ pub use self::execution_rpc::adapt_send_transaction_result; pub use self::execution_rpc::adapt_simulate_transaction_result; /// Classifies a genesis hash as an official public cluster. pub use self::execution_rpc::classify_genesis_hash; +/// One signature summary returned by `getSignaturesForAddress`. +pub use self::get_signatures_for_address::AddressSignatureInfo; +/// Configuration for `getSignaturesForAddress`. +pub use self::get_signatures_for_address::GetSignaturesForAddressConfig; +/// Adapts a raw `getSignaturesForAddress` result. +pub use self::get_signatures_for_address::adapt_get_signatures_for_address_result; +/// Common adapter contract for transport-specific transaction payloads. +pub use self::get_transaction::CanonicalTransactionAdapter; +/// Source payload and canonical result returned by `getTransaction`. +pub use self::get_transaction::GetTransactionAcquisition; +/// Standard Solana `getTransaction` adapter. +pub use self::get_transaction::GetTransactionAdapter; +/// Configuration for `getTransaction`. +pub use self::get_transaction::GetTransactionConfig; +/// Adapts a raw standard Solana `getTransaction` result. +pub use self::get_transaction::adapt_get_transaction_result; /// HTTP JSON-RPC client bound to one endpoint. pub use self::http_client::HttpClient; /// HTTP method class used for routing diagnostics. diff --git a/kb-onchain-transport/tests/fixtures/get_transaction_equivalent_a.json b/kb-onchain-transport/tests/fixtures/get_transaction_equivalent_a.json new file mode 100644 index 0000000..f9d1be8 --- /dev/null +++ b/kb-onchain-transport/tests/fixtures/get_transaction_equivalent_a.json @@ -0,0 +1,39 @@ +{ + "slot": 104, + "blockTime": null, + "version": "legacy", + "transaction": { + "signatures": ["6psR5zDjAeh7HyLceXFQkWTJeRxjor8asYrG1AeUrrKVNXGE7DkvDqvVo98R9rPPQKHGm6TW3PBMSLrw2fHfxaX"], + "message": { + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 1 + }, + "accountKeys": [ + "11111111111111111111111111111111", + "ComputeBudget111111111111111111111111111111" + ], + "recentBlockhash": "11111111111111111111111111111111", + "instructions": [ + {"programIdIndex": 1, "accounts": [0], "data": "8", "stackHeight": null} + ], + "addressTableLookups": [] + } + }, + "meta": { + "err": null, + "fee": 5000, + "preBalances": [10000, 1], + "postBalances": [5000, 1], + "innerInstructions": null, + "logMessages": null, + "preTokenBalances": null, + "postTokenBalances": null, + "rewards": null, + "loadedAddresses": null, + "returnData": null, + "computeUnitsConsumed": null, + "costUnits": null + } +} \ No newline at end of file diff --git a/kb-onchain-transport/tests/fixtures/get_transaction_equivalent_b.json b/kb-onchain-transport/tests/fixtures/get_transaction_equivalent_b.json new file mode 100644 index 0000000..00728a1 --- /dev/null +++ b/kb-onchain-transport/tests/fixtures/get_transaction_equivalent_b.json @@ -0,0 +1,30 @@ +{ + "meta": { + "postBalances": [5000, 1], + "fee": 5000, + "status": {"Ok": null}, + "err": null, + "preBalances": [10000, 1] + }, + "transaction": { + "message": { + "instructions": [ + {"data": "8", "accounts": [0], "programIdIndex": 1} + ], + "recentBlockhash": "11111111111111111111111111111111", + "accountKeys": [ + "11111111111111111111111111111111", + "ComputeBudget111111111111111111111111111111" + ], + "header": { + "numReadonlyUnsignedAccounts": 1, + "numReadonlySignedAccounts": 0, + "numRequiredSignatures": 1 + } + }, + "signatures": ["6psR5zDjAeh7HyLceXFQkWTJeRxjor8asYrG1AeUrrKVNXGE7DkvDqvVo98R9rPPQKHGm6TW3PBMSLrw2fHfxaX"] + }, + "blockTime": null, + "slot": 104, + "providerSpecificField": "ignored" +} \ No newline at end of file diff --git a/kb-onchain-transport/tests/fixtures/get_transaction_legacy_failure.json b/kb-onchain-transport/tests/fixtures/get_transaction_legacy_failure.json new file mode 100644 index 0000000..7caeb1c --- /dev/null +++ b/kb-onchain-transport/tests/fixtures/get_transaction_legacy_failure.json @@ -0,0 +1,37 @@ +{ + "blockTime": 1700000002, + "meta": { + "err": {"InstructionError": [0, {"Custom": 6001}]}, + "fee": 5000, + "innerInstructions": null, + "loadedAddresses": {"readonly": [], "writable": []}, + "logMessages": ["Program 11111111111111111111111111111111 failed: custom program error: 0x1771"], + "postBalances": [5000, 1], + "postTokenBalances": [], + "preBalances": [10000, 1], + "preTokenBalances": [], + "rewards": null, + "status": {"Err": {"InstructionError": [0, {"Custom": 6001}]}} + }, + "slot": 101, + "transaction": { + "message": { + "accountKeys": [ + "11111111111111111111111111111111", + "ComputeBudget111111111111111111111111111111" + ], + "addressTableLookups": [], + "header": { + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 1, + "numRequiredSignatures": 1 + }, + "instructions": [ + {"accounts": [0], "data": "4", "programIdIndex": 1} + ], + "recentBlockhash": "11111111111111111111111111111111" + }, + "signatures": ["3LJnHMv3ygbULcerbSFVsk2Jo33Qv8DoyUJQh2E9UZAGYXcv7Y7KGZ3hPw5F5j9C9tBhVcYqipRnNEugKV1rnSU"] + }, + "version": "legacy" +} \ No newline at end of file diff --git a/kb-onchain-transport/tests/fixtures/get_transaction_legacy_success.json b/kb-onchain-transport/tests/fixtures/get_transaction_legacy_success.json new file mode 100644 index 0000000..5c2821d --- /dev/null +++ b/kb-onchain-transport/tests/fixtures/get_transaction_legacy_success.json @@ -0,0 +1,80 @@ +{ + "slot": 100, + "blockTime": 1700000001, + "version": "legacy", + "transaction": { + "signatures": ["2Ana1pUpv2ZbMVkwF5FXapYeBEjdxDatLn7nvJkhgTSXbs59SyZSx866bXirPgj8QQVB57uxHJBG1YFvkRbFj4T"], + "message": { + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 2 + }, + "accountKeys": [ + "11111111111111111111111111111111", + "ComputeBudget111111111111111111111111111111", + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + ], + "recentBlockhash": "11111111111111111111111111111111", + "instructions": [ + { + "programIdIndex": 1, + "accounts": [0], + "data": "2", + "stackHeight": 1 + } + ], + "addressTableLookups": [] + } + }, + "meta": { + "err": null, + "status": {"Ok": null}, + "fee": 5000, + "preBalances": [10000, 1, 1], + "postBalances": [5000, 1, 1], + "innerInstructions": [ + { + "index": 0, + "instructions": [ + { + "programIdIndex": 2, + "accounts": [0], + "data": "3", + "stackHeight": 2 + } + ] + } + ], + "logMessages": [ + "Program ComputeBudget111111111111111111111111111111 invoke [1]", + "Program ComputeBudget111111111111111111111111111111 success" + ], + "preTokenBalances": [], + "postTokenBalances": [ + { + "accountIndex": 0, + "mint": "So11111111111111111111111111111111111111112", + "owner": "11111111111111111111111111111111", + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "uiTokenAmount": { + "amount": "12345678901234567890", + "decimals": 9, + "uiAmount": null, + "uiAmountString": "12345678901.23456789" + } + } + ], + "rewards": [], + "loadedAddresses": { + "writable": [], + "readonly": [] + }, + "returnData": { + "programId": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "data": ["AQI=", "base64"] + }, + "computeUnitsConsumed": 1200, + "costUnits": 1300 + } +} \ No newline at end of file diff --git a/kb-onchain-transport/tests/fixtures/get_transaction_optional_absent.json b/kb-onchain-transport/tests/fixtures/get_transaction_optional_absent.json new file mode 100644 index 0000000..5470640 --- /dev/null +++ b/kb-onchain-transport/tests/fixtures/get_transaction_optional_absent.json @@ -0,0 +1,28 @@ +{ + "slot": 103, + "blockTime": null, + "transaction": { + "signatures": ["5fMCpSnW6zfEJrShJAFSTaye2dexqwVfErfeETB34kbkRriTSfD3uQxtzjn2ToyKeqakLbpcbrvq5eDBTbs4uCW"], + "message": { + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 1 + }, + "accountKeys": [ + "11111111111111111111111111111111", + "ComputeBudget111111111111111111111111111111" + ], + "recentBlockhash": "11111111111111111111111111111111", + "instructions": [ + {"programIdIndex": 1, "accounts": [0], "data": "7"} + ] + } + }, + "meta": { + "err": null, + "fee": 5000, + "preBalances": [10000, 1], + "postBalances": [5000, 1] + } +} \ No newline at end of file diff --git a/kb-onchain-transport/tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json b/kb-onchain-transport/tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json new file mode 100644 index 0000000..10b0aff --- /dev/null +++ b/kb-onchain-transport/tests/fixtures/get_transaction_v0_alt_cpi_token_2022.json @@ -0,0 +1,88 @@ +{ + "slot": 102, + "blockTime": 1700000003, + "version": 0, + "transaction": { + "signatures": ["4VpzYuMH3LdMKjYmwoFUAfVyQqMBt2rjcAV2TjhbGet1VCAgn6fBaz1JCLRdmmZFuMtDv7BjALgJiwZRtYSTqpV"], + "message": { + "header": { + "numRequiredSignatures": 1, + "numReadonlySignedAccounts": 0, + "numReadonlyUnsignedAccounts": 2 + }, + "accountKeys": [ + "11111111111111111111111111111111", + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "ComputeBudget111111111111111111111111111111" + ], + "recentBlockhash": "11111111111111111111111111111111", + "instructions": [ + { + "programIdIndex": 3, + "accounts": [0, 4], + "data": "5", + "stackHeight": 1 + } + ], + "addressTableLookups": [ + { + "accountKey": "AddressLookupTab1e1111111111111111111111111", + "writableIndexes": [0], + "readonlyIndexes": [1] + } + ] + } + }, + "meta": { + "err": null, + "fee": 7000, + "preBalances": [100000, 1, 1, 1, 1], + "postBalances": [93000, 1, 1, 1, 1], + "innerInstructions": [ + { + "index": 0, + "instructions": [ + { + "programIdIndex": 4, + "accounts": [0, 1], + "data": "6", + "stackHeight": 2 + } + ] + } + ], + "logMessages": ["Program log: Token-2022 CPI"], + "preTokenBalances": [ + { + "accountIndex": 1, + "mint": "So11111111111111111111111111111111111111112", + "owner": "11111111111111111111111111111111", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "uiTokenAmount": { + "amount": "10", + "decimals": 0, + "uiAmountString": "10" + } + } + ], + "postTokenBalances": [ + { + "accountIndex": 1, + "mint": "So11111111111111111111111111111111111111112", + "owner": "11111111111111111111111111111111", + "programId": "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb", + "uiTokenAmount": { + "amount": "9", + "decimals": 0, + "uiAmountString": "9" + } + } + ], + "rewards": [], + "loadedAddresses": { + "writable": ["SysvarRent111111111111111111111111111111111"], + "readonly": ["SysvarC1ock11111111111111111111111111111111"] + }, + "computeUnitsConsumed": 9999 + } +} \ No newline at end of file