// file: kb-onchain-transport/src/get_transaction.rs // version: 10 //! 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)); } }