Skip to main content

kora_lib/transaction/
versioned_transaction.rs

1use async_trait::async_trait;
2use base64::{engine::general_purpose::STANDARD, Engine as _};
3use solana_client::{nonblocking::rpc_client::RpcClient, rpc_config::RpcSimulateTransactionConfig};
4use solana_commitment_config::CommitmentConfig;
5use solana_keychain::{Signer, SolanaSigner};
6use solana_message::{
7    compiled_instruction::CompiledInstruction, v0::MessageAddressTableLookup, VersionedMessage,
8};
9use solana_sdk::{instruction::Instruction, pubkey::Pubkey, transaction::VersionedTransaction};
10use std::{collections::HashMap, ops::Deref};
11
12use solana_transaction_status_client_types::UiTransactionEncoding;
13
14use crate::{
15    config::Config,
16    error::KoraError,
17    fee::fee::{FeeConfigUtil, TransactionFeeUtil},
18    lighthouse::LighthouseUtil,
19    plugin::{PluginExecutionContext, TransactionPluginRunner},
20    sanitize_error,
21    transaction::{
22        instruction_util::IxUtils, ParsedSPLInstructionData, ParsedSPLInstructionType,
23        ParsedSystemInstructionData, ParsedSystemInstructionType,
24    },
25    validator::transaction_validator::TransactionValidator,
26    CacheUtil,
27};
28use solana_address_lookup_table_interface::state::AddressLookupTable;
29
30/// A fully resolved transaction with lookup tables and inner instructions resolved
31pub struct VersionedTransactionResolved {
32    pub transaction: VersionedTransaction,
33
34    // Includes lookup table addresses
35    pub all_account_keys: Vec<Pubkey>,
36
37    // Includes all instructions, including inner instructions
38    pub all_instructions: Vec<Instruction>,
39
40    // Parsed instructions by type (None if not parsed yet)
41    parsed_system_instructions:
42        Option<HashMap<ParsedSystemInstructionType, Vec<ParsedSystemInstructionData>>>,
43
44    // Parsed SPL instructions by type (None if not parsed yet)
45    parsed_spl_instructions:
46        Option<HashMap<ParsedSPLInstructionType, Vec<ParsedSPLInstructionData>>>,
47}
48
49impl Deref for VersionedTransactionResolved {
50    type Target = VersionedTransaction;
51
52    fn deref(&self) -> &Self::Target {
53        &self.transaction
54    }
55}
56
57#[async_trait]
58pub trait VersionedTransactionOps {
59    fn encode_b64_transaction(&self) -> Result<String, KoraError>;
60    fn find_signer_position(&self, signer_pubkey: &Pubkey) -> Result<usize, KoraError>;
61
62    async fn sign_transaction(
63        &mut self,
64        config: &Config,
65        signer: &std::sync::Arc<Signer>,
66        rpc_client: &RpcClient,
67        will_send: bool,
68    ) -> Result<(VersionedTransaction, String), KoraError>;
69    async fn sign_and_send_transaction(
70        &mut self,
71        config: &Config,
72        signer: &std::sync::Arc<Signer>,
73        rpc_client: &RpcClient,
74    ) -> Result<(String, String), KoraError>;
75}
76
77impl VersionedTransactionResolved {
78    pub async fn from_transaction(
79        transaction: &VersionedTransaction,
80        config: &Config,
81        rpc_client: &RpcClient,
82        sig_verify: bool,
83    ) -> Result<Self, KoraError> {
84        let mut resolved = Self {
85            transaction: transaction.clone(),
86            all_account_keys: vec![],
87            all_instructions: vec![],
88            parsed_system_instructions: None,
89            parsed_spl_instructions: None,
90        };
91
92        // 1. Resolve lookup table addresses based on transaction type
93        let resolved_addresses = match &transaction.message {
94            VersionedMessage::Legacy(_) => {
95                // Legacy transactions don't have lookup tables
96                vec![]
97            }
98            VersionedMessage::V0(v0_message) => {
99                // V0 transactions may have lookup tables
100                LookupTableUtil::resolve_lookup_table_addresses(
101                    config,
102                    rpc_client,
103                    &v0_message.address_table_lookups,
104                )
105                .await?
106            }
107        };
108
109        // Set all accout keys
110        let mut all_account_keys = transaction.message.static_account_keys().to_vec();
111        all_account_keys.extend(resolved_addresses.clone());
112        resolved.all_account_keys = all_account_keys.clone();
113
114        // 2. Fetch all instructions
115        let outer_instructions =
116            IxUtils::uncompile_instructions(transaction.message.instructions(), &all_account_keys)?;
117
118        let inner_instructions = resolved.fetch_inner_instructions(rpc_client, sig_verify).await?;
119
120        resolved.all_instructions.extend(outer_instructions);
121        resolved.all_instructions.extend(inner_instructions);
122
123        Ok(resolved)
124    }
125
126    /// Only use this is we built the transaction ourselves, because it won't do any checks for resolving LUT, etc.
127    pub fn from_kora_built_transaction(
128        transaction: &VersionedTransaction,
129    ) -> Result<Self, KoraError> {
130        Ok(Self {
131            transaction: transaction.clone(),
132            all_account_keys: transaction.message.static_account_keys().to_vec(),
133            all_instructions: IxUtils::uncompile_instructions(
134                transaction.message.instructions(),
135                transaction.message.static_account_keys(),
136            )?,
137            parsed_system_instructions: None,
138            parsed_spl_instructions: None,
139        })
140    }
141
142    /// Fetch inner instructions via simulation
143    async fn fetch_inner_instructions(
144        &mut self,
145        rpc_client: &RpcClient,
146        sig_verify: bool,
147    ) -> Result<Vec<Instruction>, KoraError> {
148        let simulation_result = rpc_client
149            .simulate_transaction_with_config(
150                &self.transaction,
151                RpcSimulateTransactionConfig {
152                    commitment: Some(rpc_client.commitment()),
153                    sig_verify,
154                    inner_instructions: true,
155                    replace_recent_blockhash: false,
156                    encoding: Some(UiTransactionEncoding::Base64),
157                    accounts: None,
158                    min_context_slot: None,
159                },
160            )
161            .await
162            .map_err(|e| {
163                KoraError::RpcError(format!(
164                    "Failed to simulate transaction: {}",
165                    sanitize_error!(e)
166                ))
167            })?;
168
169        if let Some(err) = simulation_result.value.err {
170            return Err(KoraError::InvalidTransaction(format!(
171                "Transaction simulation failed: {err}"
172            )));
173        }
174
175        if let Some(inner_instructions) = simulation_result.value.inner_instructions {
176            let mut compiled_inner_instructions: Vec<CompiledInstruction> = vec![];
177            // Clone so we can extend with CPI-only PDA accounts discovered
178            // during inner instruction reconstruction.
179            let mut extended_account_keys = self.all_account_keys.clone();
180            let mut account_keys_hashmap =
181                IxUtils::build_account_keys_hashmap(&extended_account_keys);
182
183            for ix in &inner_instructions {
184                for inner_ix in &ix.instructions {
185                    let compiled = IxUtils::reconstruct_instruction_from_ui_with_account_key_cache(
186                        inner_ix,
187                        &mut extended_account_keys,
188                        &mut account_keys_hashmap,
189                    )?;
190                    compiled_inner_instructions.push(compiled);
191                }
192            }
193
194            self.all_account_keys = extended_account_keys;
195            return IxUtils::uncompile_instructions(
196                &compiled_inner_instructions,
197                &self.all_account_keys,
198            );
199        }
200
201        Ok(vec![])
202    }
203
204    pub fn get_or_parse_system_instructions(
205        &mut self,
206    ) -> Result<&HashMap<ParsedSystemInstructionType, Vec<ParsedSystemInstructionData>>, KoraError>
207    {
208        if self.parsed_system_instructions.is_none() {
209            self.parsed_system_instructions = Some(IxUtils::parse_system_instructions(self)?);
210        }
211
212        self.parsed_system_instructions.as_ref().ok_or_else(|| {
213            KoraError::SerializationError("Parsed system instructions not found".to_string())
214        })
215    }
216
217    pub fn get_or_parse_spl_instructions(
218        &mut self,
219    ) -> Result<&HashMap<ParsedSPLInstructionType, Vec<ParsedSPLInstructionData>>, KoraError> {
220        if self.parsed_spl_instructions.is_none() {
221            self.parsed_spl_instructions = Some(IxUtils::parse_token_instructions(self)?);
222        }
223
224        self.parsed_spl_instructions.as_ref().ok_or_else(|| {
225            KoraError::SerializationError("Parsed SPL instructions not found".to_string())
226        })
227    }
228}
229
230// Implementation of the consolidated trait for VersionedTransactionResolved
231#[async_trait]
232impl VersionedTransactionOps for VersionedTransactionResolved {
233    fn encode_b64_transaction(&self) -> Result<String, KoraError> {
234        let serialized = bincode::serialize(&self.transaction).map_err(|e| {
235            KoraError::SerializationError(format!(
236                "Base64 serialization failed: {}",
237                sanitize_error!(e)
238            ))
239        })?;
240        Ok(STANDARD.encode(serialized))
241    }
242
243    fn find_signer_position(&self, signer_pubkey: &Pubkey) -> Result<usize, KoraError> {
244        self.transaction
245            .message
246            .static_account_keys()
247            .iter()
248            .position(|key| key == signer_pubkey)
249            .ok_or_else(|| {
250                KoraError::InvalidTransaction(format!(
251                    "Signer {signer_pubkey} not found in transaction account keys"
252                ))
253            })
254    }
255
256    async fn sign_transaction(
257        &mut self,
258        config: &Config,
259        signer: &std::sync::Arc<Signer>,
260        rpc_client: &RpcClient,
261        will_send: bool,
262    ) -> Result<(VersionedTransaction, String), KoraError> {
263        let fee_payer = signer.pubkey();
264        let validator = TransactionValidator::new(config, fee_payer)?;
265
266        // Validate transaction and accounts (already resolved)
267        validator.validate_transaction(config, self, rpc_client).await?;
268
269        let plugin_context = if will_send {
270            PluginExecutionContext::SignAndSendTransaction
271        } else {
272            PluginExecutionContext::SignTransaction
273        };
274        TransactionPluginRunner::from_config(config)
275            .run(self, config, rpc_client, &fee_payer, plugin_context)
276            .await?;
277
278        // Calculate fee and validate payment if price model requires it
279        let fee_calculation = FeeConfigUtil::estimate_kora_fee(
280            self,
281            &fee_payer,
282            config.validation.is_payment_required(),
283            rpc_client,
284            config,
285        )
286        .await?;
287
288        let required_lamports = fee_calculation.total_fee_lamports;
289
290        // Validate payment if price model is not Free
291        if required_lamports > 0 {
292            log::info!("Payment validation: required_lamports={}", required_lamports);
293            // Get the expected payment destination
294            let payment_destination = config.kora.get_payment_address(&fee_payer)?;
295
296            // Validate token payment using the resolved transaction
297            TransactionValidator::validate_token_payment(
298                config,
299                self,
300                required_lamports,
301                rpc_client,
302                &payment_destination,
303            )
304            .await?;
305
306            // Validate strict pricing if enabled
307            TransactionValidator::validate_strict_pricing_with_fee(config, &fee_calculation)?;
308        }
309
310        // Get latest blockhash and update transaction
311        let mut transaction = self.transaction.clone();
312
313        if transaction.signatures.is_empty() {
314            let blockhash = rpc_client
315                .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed())
316                .await?;
317            transaction.message.set_recent_blockhash(blockhash.0);
318        }
319
320        // Validate transaction fee using resolved transaction
321        let estimated_fee = TransactionFeeUtil::get_estimate_fee_resolved(rpc_client, self).await?;
322        validator.validate_lamport_fee(estimated_fee)?;
323
324        LighthouseUtil::add_fee_payer_assertion(
325            &mut transaction,
326            rpc_client,
327            &fee_payer,
328            estimated_fee,
329            &config.kora.lighthouse,
330            will_send,
331        )
332        .await?;
333
334        // Sign transaction
335        let message_bytes = transaction.message.serialize();
336        let signature = signer
337            .sign_message(&message_bytes)
338            .await
339            .map_err(|e| KoraError::SigningError(sanitize_error!(e)))?;
340
341        // Find the fee payer position - don't assume it's at position 0
342        let fee_payer_position = self.find_signer_position(&fee_payer)?;
343        transaction.signatures[fee_payer_position] = signature;
344
345        // Serialize signed transaction
346        let serialized = bincode::serialize(&transaction)?;
347        let encoded = STANDARD.encode(serialized);
348
349        Ok((transaction, encoded))
350    }
351
352    async fn sign_and_send_transaction(
353        &mut self,
354        config: &Config,
355        signer: &std::sync::Arc<Signer>,
356        rpc_client: &RpcClient,
357    ) -> Result<(String, String), KoraError> {
358        // Payment validation is handled in sign_transaction
359        let (transaction, encoded) =
360            self.sign_transaction(config, signer, rpc_client, true).await?;
361
362        // Send and confirm transaction
363        let signature = rpc_client
364            .send_and_confirm_transaction(&transaction)
365            .await
366            .map_err(|e| KoraError::RpcError(sanitize_error!(e)))?;
367
368        Ok((signature.to_string(), encoded))
369    }
370}
371
372pub struct LookupTableUtil {}
373
374impl LookupTableUtil {
375    /// Resolves addresses from lookup tables for V0 transactions
376    pub async fn resolve_lookup_table_addresses(
377        config: &Config,
378        rpc_client: &RpcClient,
379        lookup_table_lookups: &[MessageAddressTableLookup],
380    ) -> Result<Vec<Pubkey>, KoraError> {
381        let mut resolved_addresses = Vec::new();
382
383        // Maybe we can use caching here, there's a chance the lookup tables get updated though, so tbd
384        for lookup in lookup_table_lookups {
385            let lookup_table_account =
386                CacheUtil::get_account(config, rpc_client, &lookup.account_key, false)
387                    .await
388                    .map_err(|e| {
389                        KoraError::RpcError(format!(
390                            "Failed to fetch lookup table: {}",
391                            sanitize_error!(e)
392                        ))
393                    })?;
394
395            // Parse the lookup table account data to get the actual addresses
396            let address_lookup_table = AddressLookupTable::deserialize(&lookup_table_account.data)
397                .map_err(|e| {
398                    KoraError::InvalidTransaction(format!(
399                        "Failed to deserialize lookup table: {}",
400                        sanitize_error!(e)
401                    ))
402                })?;
403
404            // Resolve writable addresses
405            for &index in &lookup.writable_indexes {
406                if let Some(address) = address_lookup_table.addresses.get(index as usize) {
407                    resolved_addresses.push(*address);
408                } else {
409                    return Err(KoraError::InvalidTransaction(format!(
410                        "Lookup table index {index} out of bounds for writable addresses"
411                    )));
412                }
413            }
414
415            // Resolve readonly addresses
416            for &index in &lookup.readonly_indexes {
417                if let Some(address) = address_lookup_table.addresses.get(index as usize) {
418                    resolved_addresses.push(*address);
419                } else {
420                    return Err(KoraError::InvalidTransaction(format!(
421                        "Lookup table index {index} out of bounds for readonly addresses"
422                    )));
423                }
424            }
425        }
426
427        Ok(resolved_addresses)
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use crate::{
434        config::SplTokenConfig,
435        tests::{
436            common::RpcMockBuilder, config_mock::mock_state::setup_config_mock,
437            toml_mock::ConfigBuilder,
438        },
439        transaction::TransactionUtil,
440        Config,
441    };
442    use serde_json::json;
443    use solana_client::rpc_request::RpcRequest;
444    use std::collections::HashMap;
445
446    use super::*;
447    use solana_address_lookup_table_interface::state::LookupTableMeta;
448    use solana_message::{compiled_instruction::CompiledInstruction, v0, Message};
449    use solana_sdk::{
450        account::Account,
451        hash::Hash,
452        instruction::{AccountMeta, Instruction},
453        signature::Keypair,
454        signer::Signer,
455    };
456
457    fn setup_test_config() -> Config {
458        ConfigBuilder::new()
459            .with_programs(vec![])
460            .with_tokens(vec![])
461            .with_spl_paid_tokens(SplTokenConfig::Allowlist(vec![]))
462            .with_free_price()
463            .with_cache_config(None, false, 60, 30) // Disable cache for tests
464            .build_config()
465            .expect("Failed to build test config")
466    }
467
468    #[test]
469    fn test_encode_transaction_b64() {
470        let keypair = Keypair::new();
471        let instruction = Instruction::new_with_bytes(
472            Pubkey::new_unique(),
473            &[1, 2, 3],
474            vec![AccountMeta::new(keypair.pubkey(), true)],
475        );
476        let message =
477            VersionedMessage::Legacy(Message::new(&[instruction], Some(&keypair.pubkey())));
478        let tx = VersionedTransaction::try_new(message, &[&keypair]).unwrap();
479
480        let resolved = VersionedTransactionResolved::from_kora_built_transaction(&tx).unwrap();
481        let encoded = resolved.encode_b64_transaction().unwrap();
482        assert!(!encoded.is_empty());
483        assert!(encoded
484            .chars()
485            .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='));
486    }
487
488    #[test]
489    fn test_encode_decode_b64_transaction() {
490        let keypair = Keypair::new();
491        let instruction = Instruction::new_with_bytes(
492            Pubkey::new_unique(),
493            &[1, 2, 3],
494            vec![AccountMeta::new(keypair.pubkey(), true)],
495        );
496        let message =
497            VersionedMessage::Legacy(Message::new(&[instruction], Some(&keypair.pubkey())));
498        let tx = VersionedTransaction::try_new(message, &[&keypair]).unwrap();
499
500        let resolved = VersionedTransactionResolved::from_kora_built_transaction(&tx).unwrap();
501        let encoded = resolved.encode_b64_transaction().unwrap();
502        let decoded = TransactionUtil::decode_b64_transaction(&encoded).unwrap();
503
504        assert_eq!(tx, decoded);
505    }
506
507    #[test]
508    fn test_find_signer_position_success() {
509        let keypair = Keypair::new();
510        let program_id = Pubkey::new_unique();
511        let instruction = Instruction::new_with_bytes(
512            program_id,
513            &[1, 2, 3],
514            vec![AccountMeta::new(keypair.pubkey(), true)],
515        );
516        let message =
517            VersionedMessage::Legacy(Message::new(&[instruction], Some(&keypair.pubkey())));
518        let transaction =
519            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
520
521        let position = transaction.find_signer_position(&keypair.pubkey()).unwrap();
522        assert_eq!(position, 0); // Fee payer is typically at position 0
523    }
524
525    #[test]
526    fn test_find_signer_position_success_v0() {
527        let keypair = Keypair::new();
528        let program_id = Pubkey::new_unique();
529        let other_account = Pubkey::new_unique();
530
531        let v0_message = v0::Message {
532            header: solana_message::MessageHeader {
533                num_required_signatures: 1,
534                num_readonly_signed_accounts: 0,
535                num_readonly_unsigned_accounts: 2,
536            },
537            account_keys: vec![keypair.pubkey(), other_account, program_id],
538            recent_blockhash: Hash::default(),
539            instructions: vec![CompiledInstruction {
540                program_id_index: 2,
541                accounts: vec![0, 1],
542                data: vec![1, 2, 3],
543            }],
544            address_table_lookups: vec![],
545        };
546        let message = VersionedMessage::V0(v0_message);
547        let transaction =
548            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
549
550        let position = transaction.find_signer_position(&keypair.pubkey()).unwrap();
551        assert_eq!(position, 0);
552
553        let other_position = transaction.find_signer_position(&other_account).unwrap();
554        assert_eq!(other_position, 1);
555    }
556
557    #[test]
558    fn test_find_signer_position_middle_of_accounts() {
559        let keypair1 = Keypair::new();
560        let keypair2 = Keypair::new();
561        let keypair3 = Keypair::new();
562        let program_id = Pubkey::new_unique();
563
564        let v0_message = v0::Message {
565            header: solana_message::MessageHeader {
566                num_required_signatures: 3,
567                num_readonly_signed_accounts: 0,
568                num_readonly_unsigned_accounts: 1,
569            },
570            account_keys: vec![keypair1.pubkey(), keypair2.pubkey(), keypair3.pubkey(), program_id],
571            recent_blockhash: Hash::default(),
572            instructions: vec![CompiledInstruction {
573                program_id_index: 3,
574                accounts: vec![0, 1, 2],
575                data: vec![1, 2, 3],
576            }],
577            address_table_lookups: vec![],
578        };
579        let message = VersionedMessage::V0(v0_message);
580        let transaction =
581            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
582
583        assert_eq!(transaction.find_signer_position(&keypair1.pubkey()).unwrap(), 0);
584        assert_eq!(transaction.find_signer_position(&keypair2.pubkey()).unwrap(), 1);
585        assert_eq!(transaction.find_signer_position(&keypair3.pubkey()).unwrap(), 2);
586    }
587
588    #[test]
589    fn test_find_signer_position_not_found() {
590        let keypair = Keypair::new();
591        let missing_keypair = Keypair::new();
592        let instruction = Instruction::new_with_bytes(
593            Pubkey::new_unique(),
594            &[1, 2, 3],
595            vec![AccountMeta::new(keypair.pubkey(), true)],
596        );
597        let message =
598            VersionedMessage::Legacy(Message::new(&[instruction], Some(&keypair.pubkey())));
599        let transaction =
600            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
601
602        let result = transaction.find_signer_position(&missing_keypair.pubkey());
603        assert!(matches!(result, Err(KoraError::InvalidTransaction(_))));
604
605        if let Err(KoraError::InvalidTransaction(msg)) = result {
606            assert!(msg.contains(&missing_keypair.pubkey().to_string()));
607            assert!(msg.contains("not found in transaction account keys"));
608        }
609    }
610
611    #[test]
612    fn test_find_signer_position_empty_account_keys() {
613        // Create a transaction with minimal account keys
614        let v0_message = v0::Message {
615            header: solana_message::MessageHeader {
616                num_required_signatures: 0,
617                num_readonly_signed_accounts: 0,
618                num_readonly_unsigned_accounts: 0,
619            },
620            account_keys: vec![], // Empty account keys
621            recent_blockhash: Hash::default(),
622            instructions: vec![],
623            address_table_lookups: vec![],
624        };
625        let message = VersionedMessage::V0(v0_message);
626        let transaction =
627            TransactionUtil::new_unsigned_versioned_transaction_resolved(message).unwrap();
628        let search_key = Pubkey::new_unique();
629
630        let result = transaction.find_signer_position(&search_key);
631        assert!(matches!(result, Err(KoraError::InvalidTransaction(_))));
632    }
633
634    #[test]
635    fn test_from_kora_built_transaction() {
636        let keypair = Keypair::new();
637        let program_id = Pubkey::new_unique();
638        let instruction = Instruction::new_with_bytes(
639            program_id,
640            &[1, 2, 3, 4],
641            vec![
642                AccountMeta::new(keypair.pubkey(), true),
643                AccountMeta::new_readonly(Pubkey::new_unique(), false),
644            ],
645        );
646        let message = VersionedMessage::Legacy(Message::new(
647            std::slice::from_ref(&instruction),
648            Some(&keypair.pubkey()),
649        ));
650        let transaction = VersionedTransaction::try_new(message.clone(), &[&keypair]).unwrap();
651
652        let resolved =
653            VersionedTransactionResolved::from_kora_built_transaction(&transaction).unwrap();
654
655        assert_eq!(resolved.transaction, transaction);
656        assert_eq!(resolved.all_account_keys, transaction.message.static_account_keys());
657        assert_eq!(resolved.all_instructions.len(), 1);
658
659        // Check instruction properties rather than direct equality since IxUtils::uncompile_instructions
660        // properly sets signer status based on the transaction message
661        let resolved_instruction = &resolved.all_instructions[0];
662        assert_eq!(resolved_instruction.program_id, instruction.program_id);
663        assert_eq!(resolved_instruction.data, instruction.data);
664        assert_eq!(resolved_instruction.accounts.len(), instruction.accounts.len());
665
666        assert!(resolved.parsed_system_instructions.is_none());
667        assert!(resolved.parsed_spl_instructions.is_none());
668    }
669
670    #[test]
671    fn test_from_kora_built_transaction_v0() {
672        let keypair = Keypair::new();
673        let program_id = Pubkey::new_unique();
674        let other_account = Pubkey::new_unique();
675
676        let v0_message = v0::Message {
677            header: solana_message::MessageHeader {
678                num_required_signatures: 1,
679                num_readonly_signed_accounts: 0,
680                num_readonly_unsigned_accounts: 2,
681            },
682            account_keys: vec![keypair.pubkey(), other_account, program_id],
683            recent_blockhash: Hash::new_unique(),
684            instructions: vec![CompiledInstruction {
685                program_id_index: 2,
686                accounts: vec![0, 1],
687                data: vec![1, 2, 3],
688            }],
689            address_table_lookups: vec![],
690        };
691        let message = VersionedMessage::V0(v0_message);
692        let transaction = VersionedTransaction::try_new(message.clone(), &[&keypair]).unwrap();
693
694        let resolved =
695            VersionedTransactionResolved::from_kora_built_transaction(&transaction).unwrap();
696
697        assert_eq!(resolved.transaction, transaction);
698        assert_eq!(resolved.all_account_keys, vec![keypair.pubkey(), other_account, program_id]);
699        assert_eq!(resolved.all_instructions.len(), 1);
700        assert_eq!(resolved.all_instructions[0].program_id, program_id);
701        assert_eq!(resolved.all_instructions[0].accounts.len(), 2);
702        assert_eq!(resolved.all_instructions[0].data, vec![1, 2, 3]);
703    }
704
705    #[tokio::test]
706    async fn test_from_transaction_legacy() {
707        let config = setup_test_config();
708        let _m = setup_config_mock(config.clone());
709
710        let keypair = Keypair::new();
711        let instruction = Instruction::new_with_bytes(
712            Pubkey::new_unique(),
713            &[1, 2, 3],
714            vec![AccountMeta::new(keypair.pubkey(), true)],
715        );
716        let message = VersionedMessage::Legacy(Message::new(
717            std::slice::from_ref(&instruction),
718            Some(&keypair.pubkey()),
719        ));
720        let transaction = VersionedTransaction::try_new(message, &[&keypair]).unwrap();
721
722        // Mock RPC client that will be used for inner instructions
723        let mut mocks = HashMap::new();
724        mocks.insert(
725            RpcRequest::SimulateTransaction,
726            json!({
727                "context": { "slot": 1 },
728                "value": {
729                    "err": null,
730                    "logs": [],
731                    "accounts": null,
732                    "unitsConsumed": 1000,
733                    "innerInstructions": []
734                }
735            }),
736        );
737        let rpc_client = RpcMockBuilder::new().with_custom_mocks(mocks).build();
738
739        let resolved = VersionedTransactionResolved::from_transaction(
740            &transaction,
741            &config,
742            &rpc_client,
743            true,
744        )
745        .await
746        .unwrap();
747
748        assert_eq!(resolved.transaction, transaction);
749        assert_eq!(resolved.all_account_keys, transaction.message.static_account_keys());
750        assert_eq!(resolved.all_instructions.len(), 1); // Only outer instruction since no inner instructions in mock
751
752        // Check instruction properties rather than direct equality since IxUtils::uncompile_instructions
753        // properly sets signer status based on the transaction message
754        let resolved_instruction = &resolved.all_instructions[0];
755        assert_eq!(resolved_instruction.program_id, instruction.program_id);
756        assert_eq!(resolved_instruction.data, instruction.data);
757        assert_eq!(resolved_instruction.accounts.len(), instruction.accounts.len());
758        assert_eq!(resolved_instruction.accounts[0].pubkey, instruction.accounts[0].pubkey);
759        assert_eq!(
760            resolved_instruction.accounts[0].is_writable,
761            instruction.accounts[0].is_writable
762        );
763    }
764
765    #[tokio::test]
766    async fn test_from_transaction_v0_with_lookup_tables() {
767        let config = setup_test_config();
768        let _m = setup_config_mock(config.clone());
769
770        let keypair = Keypair::new();
771        let program_id = Pubkey::new_unique();
772        let lookup_table_account = Pubkey::new_unique();
773        let resolved_address = Pubkey::new_unique();
774
775        // Create lookup table
776        let lookup_table = AddressLookupTable {
777            meta: LookupTableMeta {
778                deactivation_slot: u64::MAX,
779                last_extended_slot: 0,
780                last_extended_slot_start_index: 0,
781                authority: Some(Pubkey::new_unique()),
782                _padding: 0,
783            },
784            addresses: vec![resolved_address].into(),
785        };
786
787        let v0_message = v0::Message {
788            header: solana_message::MessageHeader {
789                num_required_signatures: 1,
790                num_readonly_signed_accounts: 0,
791                num_readonly_unsigned_accounts: 1,
792            },
793            account_keys: vec![keypair.pubkey(), program_id],
794            recent_blockhash: Hash::new_unique(),
795            instructions: vec![CompiledInstruction {
796                program_id_index: 1,
797                accounts: vec![0, 2], // Index 2 comes from lookup table
798                data: vec![42],
799            }],
800            address_table_lookups: vec![solana_message::v0::MessageAddressTableLookup {
801                account_key: lookup_table_account,
802                writable_indexes: vec![0],
803                readonly_indexes: vec![],
804            }],
805        };
806
807        let message = VersionedMessage::V0(v0_message);
808        let transaction = VersionedTransaction::try_new(message, &[&keypair]).unwrap();
809
810        // Create mock RPC client with lookup table account and simulation
811        let mut mocks = HashMap::new();
812        let serialized_data = lookup_table.serialize_for_tests().unwrap();
813        let encoded_data = base64::engine::general_purpose::STANDARD.encode(&serialized_data);
814
815        mocks.insert(
816            RpcRequest::GetAccountInfo,
817            json!({
818                "context": { "slot": 1 },
819                "value": {
820                    "data": [encoded_data, "base64"],
821                    "executable": false,
822                    "lamports": 0,
823                    "owner": "AddressLookupTab1e1111111111111111111111111".to_string(),
824                    "rentEpoch": 0
825                }
826            }),
827        );
828
829        mocks.insert(
830            RpcRequest::SimulateTransaction,
831            json!({
832                "context": { "slot": 1 },
833                "value": {
834                    "err": null,
835                    "logs": [],
836                    "accounts": null,
837                    "unitsConsumed": 1000,
838                    "innerInstructions": []
839                }
840            }),
841        );
842
843        let rpc_client = RpcMockBuilder::new().with_custom_mocks(mocks).build();
844
845        let resolved = VersionedTransactionResolved::from_transaction(
846            &transaction,
847            &config,
848            &rpc_client,
849            true,
850        )
851        .await
852        .unwrap();
853
854        assert_eq!(resolved.transaction, transaction);
855
856        // Should include both static accounts and resolved addresses
857        assert_eq!(resolved.all_account_keys.len(), 3); // keypair, program_id, resolved_address
858        assert_eq!(resolved.all_account_keys[0], keypair.pubkey());
859        assert_eq!(resolved.all_account_keys[1], program_id);
860        assert_eq!(resolved.all_account_keys[2], resolved_address);
861    }
862
863    #[tokio::test]
864    async fn test_from_transaction_simulation_failure() {
865        let config = setup_test_config();
866        let _m = setup_config_mock(config.clone());
867
868        let keypair = Keypair::new();
869        let instruction = Instruction::new_with_bytes(
870            Pubkey::new_unique(),
871            &[1, 2, 3],
872            vec![AccountMeta::new(keypair.pubkey(), true)],
873        );
874        let message =
875            VersionedMessage::Legacy(Message::new(&[instruction], Some(&keypair.pubkey())));
876        let transaction = VersionedTransaction::try_new(message, &[&keypair]).unwrap();
877
878        // Mock RPC client with simulation error
879        let mut mocks = HashMap::new();
880        mocks.insert(
881            RpcRequest::SimulateTransaction,
882            json!({
883                "context": { "slot": 1 },
884                "value": {
885                    "err": "InstructionError",
886                    "logs": ["Some error log"],
887                    "accounts": null,
888                    "unitsConsumed": 0
889                }
890            }),
891        );
892        let rpc_client = RpcMockBuilder::new().with_custom_mocks(mocks).build();
893
894        let result = VersionedTransactionResolved::from_transaction(
895            &transaction,
896            &config,
897            &rpc_client,
898            true,
899        )
900        .await;
901
902        // The simulation should fail, but the exact error type depends on mock implementation
903        // We expect either an RpcError (from mock deserialization) or InvalidTransaction (from simulation logic)
904        assert!(result.is_err());
905
906        match result {
907            Err(KoraError::RpcError(msg)) => {
908                assert!(msg.contains("Failed to simulate transaction"));
909            }
910            Err(KoraError::InvalidTransaction(msg)) => {
911                assert!(msg.contains("inner instructions fetching failed"));
912            }
913            _ => panic!("Expected RpcError or InvalidTransaction"),
914        }
915    }
916
917    #[tokio::test]
918    async fn test_fetch_inner_instructions_with_inner_instructions() {
919        let config = setup_test_config();
920        let _m = setup_config_mock(config);
921
922        let keypair = Keypair::new();
923        let instruction = Instruction::new_with_bytes(
924            Pubkey::new_unique(),
925            &[1, 2, 3],
926            vec![AccountMeta::new(keypair.pubkey(), true)],
927        );
928        let message =
929            VersionedMessage::Legacy(Message::new(&[instruction], Some(&keypair.pubkey())));
930        let transaction = VersionedTransaction::try_new(message, &[&keypair]).unwrap();
931
932        // Mock RPC client with inner instructions
933        let inner_instruction_data = bs58::encode(&[10, 20, 30]).into_string();
934        let mut mocks = HashMap::new();
935        mocks.insert(
936            RpcRequest::SimulateTransaction,
937            json!({
938                "context": { "slot": 1 },
939                "value": {
940                    "err": null,
941                    "logs": [],
942                    "accounts": null,
943                    "unitsConsumed": 1000,
944                    "innerInstructions": [
945                        {
946                            "index": 0,
947                            "instructions": [
948                                {
949                                    "programIdIndex": 1,
950                                    "accounts": [0],
951                                    "data": inner_instruction_data
952                                }
953                            ]
954                        }
955                    ]
956                }
957            }),
958        );
959        let rpc_client = RpcMockBuilder::new().with_custom_mocks(mocks).build();
960
961        let mut resolved =
962            VersionedTransactionResolved::from_kora_built_transaction(&transaction).unwrap();
963        let inner_instructions =
964            resolved.fetch_inner_instructions(&rpc_client, true).await.unwrap();
965
966        assert_eq!(inner_instructions.len(), 1);
967        assert_eq!(inner_instructions[0].data, vec![10, 20, 30]);
968    }
969
970    #[tokio::test]
971    async fn test_fetch_inner_instructions_with_sig_verify_false() {
972        let config = setup_test_config();
973        let _m = setup_config_mock(config);
974
975        let keypair = Keypair::new();
976        let instruction = Instruction::new_with_bytes(
977            Pubkey::new_unique(),
978            &[1, 2, 3],
979            vec![AccountMeta::new(keypair.pubkey(), true)],
980        );
981        let message =
982            VersionedMessage::Legacy(Message::new(&[instruction], Some(&keypair.pubkey())));
983        let transaction = VersionedTransaction::try_new(message, &[&keypair]).unwrap();
984
985        // Mock RPC client with inner instructions
986        let inner_instruction_data = bs58::encode(&[10, 20, 30]).into_string();
987        let mut mocks = HashMap::new();
988        mocks.insert(
989            RpcRequest::SimulateTransaction,
990            json!({
991                "context": { "slot": 1 },
992                "value": {
993                    "err": null,
994                    "logs": [],
995                    "accounts": null,
996                    "unitsConsumed": 1000,
997                    "innerInstructions": [
998                        {
999                            "index": 0,
1000                            "instructions": [
1001                                {
1002                                    "programIdIndex": 1,
1003                                    "accounts": [0],
1004                                    "data": inner_instruction_data
1005                                }
1006                            ]
1007                        }
1008                    ]
1009                }
1010            }),
1011        );
1012        let rpc_client = RpcMockBuilder::new().with_custom_mocks(mocks).build();
1013
1014        let mut resolved =
1015            VersionedTransactionResolved::from_kora_built_transaction(&transaction).unwrap();
1016        let inner_instructions =
1017            resolved.fetch_inner_instructions(&rpc_client, false).await.unwrap();
1018
1019        assert_eq!(inner_instructions.len(), 1);
1020        assert_eq!(inner_instructions[0].data, vec![10, 20, 30]);
1021    }
1022
1023    #[tokio::test]
1024    async fn test_get_or_parse_system_instructions() {
1025        let config = setup_test_config();
1026        let _m = setup_config_mock(config);
1027
1028        let keypair = Keypair::new();
1029        let recipient = Pubkey::new_unique();
1030
1031        // Create a system transfer instruction
1032        let instruction =
1033            solana_system_interface::instruction::transfer(&keypair.pubkey(), &recipient, 1000000);
1034        let message =
1035            VersionedMessage::Legacy(Message::new(&[instruction], Some(&keypair.pubkey())));
1036        let transaction = VersionedTransaction::try_new(message, &[&keypair]).unwrap();
1037
1038        let mut resolved =
1039            VersionedTransactionResolved::from_kora_built_transaction(&transaction).unwrap();
1040
1041        // First call should parse and cache
1042        let parsed1_len = {
1043            let parsed1 = resolved.get_or_parse_system_instructions().unwrap();
1044            assert!(!parsed1.is_empty());
1045            parsed1.len()
1046        };
1047
1048        // Second call should return cached result
1049        let parsed2 = resolved.get_or_parse_system_instructions().unwrap();
1050        assert_eq!(parsed1_len, parsed2.len());
1051
1052        // Should contain transfer instruction
1053        assert!(
1054            parsed2.contains_key(&crate::transaction::ParsedSystemInstructionType::SystemTransfer)
1055        );
1056    }
1057
1058    #[tokio::test]
1059    async fn test_resolve_lookup_table_addresses() {
1060        let config = setup_test_config();
1061        let _m = setup_config_mock(config.clone());
1062
1063        let lookup_account_key = Pubkey::new_unique();
1064        let address1 = Pubkey::new_unique();
1065        let address2 = Pubkey::new_unique();
1066        let address3 = Pubkey::new_unique();
1067
1068        let lookup_table = AddressLookupTable {
1069            meta: LookupTableMeta {
1070                deactivation_slot: u64::MAX,
1071                last_extended_slot: 0,
1072                last_extended_slot_start_index: 0,
1073                authority: Some(Pubkey::new_unique()),
1074                _padding: 0,
1075            },
1076            addresses: vec![address1, address2, address3].into(),
1077        };
1078
1079        let serialized_data = lookup_table.serialize_for_tests().unwrap();
1080
1081        let rpc_client = RpcMockBuilder::new()
1082            .with_account_info(&Account {
1083                data: serialized_data,
1084                executable: false,
1085                lamports: 0,
1086                owner: Pubkey::new_unique(),
1087                rent_epoch: 0,
1088            })
1089            .build();
1090
1091        let lookups = vec![solana_message::v0::MessageAddressTableLookup {
1092            account_key: lookup_account_key,
1093            writable_indexes: vec![0, 2], // address1, address3
1094            readonly_indexes: vec![1],    // address2
1095        }];
1096
1097        let resolved_addresses =
1098            LookupTableUtil::resolve_lookup_table_addresses(&config, &rpc_client, &lookups)
1099                .await
1100                .unwrap();
1101
1102        assert_eq!(resolved_addresses.len(), 3);
1103        assert_eq!(resolved_addresses[0], address1);
1104        assert_eq!(resolved_addresses[1], address3);
1105        assert_eq!(resolved_addresses[2], address2);
1106    }
1107
1108    #[tokio::test]
1109    async fn test_resolve_lookup_table_addresses_empty() {
1110        let config = setup_test_config();
1111        let _m = setup_config_mock(config.clone());
1112
1113        let rpc_client = RpcMockBuilder::new().with_account_not_found().build();
1114        let lookups = vec![];
1115
1116        let resolved_addresses =
1117            LookupTableUtil::resolve_lookup_table_addresses(&config, &rpc_client, &lookups)
1118                .await
1119                .unwrap();
1120
1121        assert_eq!(resolved_addresses.len(), 0);
1122    }
1123
1124    #[tokio::test]
1125    async fn test_resolve_lookup_table_addresses_account_not_found() {
1126        let config = setup_test_config();
1127        let _m = setup_config_mock(config.clone());
1128
1129        let rpc_client = RpcMockBuilder::new().with_account_not_found().build();
1130        let lookups = vec![solana_message::v0::MessageAddressTableLookup {
1131            account_key: Pubkey::new_unique(),
1132            writable_indexes: vec![0],
1133            readonly_indexes: vec![],
1134        }];
1135
1136        let result =
1137            LookupTableUtil::resolve_lookup_table_addresses(&config, &rpc_client, &lookups).await;
1138        assert!(matches!(result, Err(KoraError::RpcError(_))));
1139
1140        if let Err(KoraError::RpcError(msg)) = result {
1141            assert!(msg.contains("Failed to fetch lookup table"));
1142        }
1143    }
1144
1145    #[tokio::test]
1146    async fn test_resolve_lookup_table_addresses_invalid_index() {
1147        let config = setup_test_config();
1148        let _m = setup_config_mock(config.clone());
1149
1150        let lookup_account_key = Pubkey::new_unique();
1151        let address1 = Pubkey::new_unique();
1152
1153        let lookup_table = AddressLookupTable {
1154            meta: LookupTableMeta {
1155                deactivation_slot: u64::MAX,
1156                last_extended_slot: 0,
1157                last_extended_slot_start_index: 0,
1158                authority: Some(Pubkey::new_unique()),
1159                _padding: 0,
1160            },
1161            addresses: vec![address1].into(), // Only 1 address, index 0
1162        };
1163
1164        let serialized_data = lookup_table.serialize_for_tests().unwrap();
1165        let rpc_client = RpcMockBuilder::new()
1166            .with_account_info(&Account {
1167                data: serialized_data,
1168                executable: false,
1169                lamports: 0,
1170                owner: Pubkey::new_unique(),
1171                rent_epoch: 0,
1172            })
1173            .build();
1174
1175        // Try to access index 1 which doesn't exist
1176        let lookups = vec![solana_message::v0::MessageAddressTableLookup {
1177            account_key: lookup_account_key,
1178            writable_indexes: vec![1], // Invalid index
1179            readonly_indexes: vec![],
1180        }];
1181
1182        let result =
1183            LookupTableUtil::resolve_lookup_table_addresses(&config, &rpc_client, &lookups).await;
1184        assert!(matches!(result, Err(KoraError::InvalidTransaction(_))));
1185
1186        if let Err(KoraError::InvalidTransaction(msg)) = result {
1187            assert!(msg.contains("index 1 out of bounds"));
1188            assert!(msg.contains("writable addresses"));
1189        }
1190    }
1191
1192    #[tokio::test]
1193    async fn test_resolve_lookup_table_addresses_invalid_readonly_index() {
1194        let config = setup_test_config();
1195        let _m = setup_config_mock(config.clone());
1196
1197        let lookup_account_key = Pubkey::new_unique();
1198        let address1 = Pubkey::new_unique();
1199
1200        let lookup_table = AddressLookupTable {
1201            meta: LookupTableMeta {
1202                deactivation_slot: u64::MAX,
1203                last_extended_slot: 0,
1204                last_extended_slot_start_index: 0,
1205                authority: Some(Pubkey::new_unique()),
1206                _padding: 0,
1207            },
1208            addresses: vec![address1].into(),
1209        };
1210
1211        let serialized_data = lookup_table.serialize_for_tests().unwrap();
1212        let rpc_client = RpcMockBuilder::new()
1213            .with_account_info(&Account {
1214                data: serialized_data,
1215                executable: false,
1216                lamports: 0,
1217                owner: Pubkey::new_unique(),
1218                rent_epoch: 0,
1219            })
1220            .build();
1221
1222        let lookups = vec![solana_message::v0::MessageAddressTableLookup {
1223            account_key: lookup_account_key,
1224            writable_indexes: vec![],
1225            readonly_indexes: vec![5], // Invalid index
1226        }];
1227
1228        let result =
1229            LookupTableUtil::resolve_lookup_table_addresses(&config, &rpc_client, &lookups).await;
1230        assert!(matches!(result, Err(KoraError::InvalidTransaction(_))));
1231
1232        if let Err(KoraError::InvalidTransaction(msg)) = result {
1233            assert!(msg.contains("index 5 out of bounds"));
1234            assert!(msg.contains("readonly addresses"));
1235        }
1236    }
1237}