diff --git a/sdk-libs/instruction-decoder-derive/src/attribute_impl.rs b/sdk-libs/instruction-decoder-derive/src/attribute_impl.rs index 43b07951ef..e249a27e45 100644 --- a/sdk-libs/instruction-decoder-derive/src/attribute_impl.rs +++ b/sdk-libs/instruction-decoder-derive/src/attribute_impl.rs @@ -139,12 +139,13 @@ fn generate_match_arms(instructions: &[InstructionInfo]) -> Vec { quote! { Vec::new() } } else { let params_struct_name = format_ident!("{}DecoderParams", pascal_name); - // Generate field accessors for each parameter - use empty name to print value directly + // Generate field accessors for each parameter with their field names let field_pushes: Vec = info.params.iter().map(|param| { let field_name = ¶m.name; + let field_name_str = field_name.to_string(); quote! { fields.push(light_instruction_decoder::DecodedField::new( - "", + #field_name_str, format!("{:#?}", params.#field_name), )); } diff --git a/sdk-libs/instruction-decoder-derive/src/builder.rs b/sdk-libs/instruction-decoder-derive/src/builder.rs index 20eaf2ac53..8490bf9a2f 100644 --- a/sdk-libs/instruction-decoder-derive/src/builder.rs +++ b/sdk-libs/instruction-decoder-derive/src/builder.rs @@ -133,9 +133,9 @@ impl<'a> InstructionDecoderBuilder<'a> { variant_args: &VariantDecoderArgs, ) -> syn::Result { let instruction_name = variant.ident.to_string(); - // Pass crate_ctx to resolve struct field names at compile time - let account_names_code = variant_args.account_names_code(self.crate_ctx.as_ref()); - let fields_code = self.generate_fields_code(variant, variant_args)?; + + // Generate the body code based on whether we have a dynamic resolver + let body_code = self.generate_match_arm_body(variant, variant_args)?; match self.args.discriminator_size { 1 => { @@ -173,8 +173,7 @@ impl<'a> InstructionDecoderBuilder<'a> { }; Ok(quote! { #disc => { - let account_names: Vec = #account_names_code; - let fields = { #fields_code }; + #body_code Some(light_instruction_decoder::DecodedInstruction::with_fields_and_accounts( #instruction_name, fields, @@ -207,8 +206,7 @@ impl<'a> InstructionDecoderBuilder<'a> { }; Ok(quote! { #disc => { - let account_names: Vec = #account_names_code; - let fields = { #fields_code }; + #body_code Some(light_instruction_decoder::DecodedInstruction::with_fields_and_accounts( #instruction_name, fields, @@ -237,8 +235,7 @@ impl<'a> InstructionDecoderBuilder<'a> { let disc_array = discriminator.iter(); Ok(quote! { [#(#disc_array),*] => { - let account_names: Vec = #account_names_code; - let fields = { #fields_code }; + #body_code Some(light_instruction_decoder::DecodedInstruction::with_fields_and_accounts( #instruction_name, fields, @@ -254,6 +251,77 @@ impl<'a> InstructionDecoderBuilder<'a> { } } + /// Generate the body code for a match arm. + /// + /// When `account_names_resolver_from_params` is specified, this generates code that: + /// 1. Parses params first + /// 2. Calls the resolver function with params and accounts to get dynamic account names + /// 3. Calls the formatter if specified + /// + /// Otherwise, it uses static account names from `accounts` or `account_names`. + fn generate_match_arm_body( + &self, + variant: &syn::Variant, + variant_args: &VariantDecoderArgs, + ) -> syn::Result { + // Check if we have a dynamic account names resolver + if let (Some(resolver_path), Some(params_ty)) = ( + &variant_args.account_names_resolver_from_params, + variant_args.params_type(), + ) { + // Dynamic resolver mode: parse params first, then call resolver + let fields_code = if let Some(formatter_path) = &variant_args.pretty_formatter { + // Use custom formatter + quote! { + let mut fields = Vec::new(); + let formatted = #formatter_path(¶ms, accounts); + fields.push(light_instruction_decoder::DecodedField::new( + "", + formatted, + )); + fields + } + } else { + // Use Debug formatting + quote! { + let mut fields = Vec::new(); + fields.push(light_instruction_decoder::DecodedField::new( + "", + format!("{:#?}", params), + )); + fields + } + }; + + Ok(quote! { + let (account_names, fields) = if let Ok(params) = <#params_ty as borsh::BorshDeserialize>::try_from_slice(remaining) { + let account_names = #resolver_path(¶ms, accounts); + let fields = { #fields_code }; + (account_names, fields) + } else { + let account_names: Vec = Vec::new(); + let mut fields = Vec::new(); + if !remaining.is_empty() { + fields.push(light_instruction_decoder::DecodedField::new( + "data_len", + remaining.len().to_string(), + )); + } + (account_names, fields) + }; + }) + } else { + // Static account names mode + let account_names_code = variant_args.account_names_code(self.crate_ctx.as_ref()); + let fields_code = self.generate_fields_code(variant, variant_args)?; + + Ok(quote! { + let account_names: Vec = #account_names_code; + let fields = { #fields_code }; + }) + } + } + /// Generate field parsing code for a variant. fn generate_fields_code( &self, diff --git a/sdk-libs/instruction-decoder-derive/src/parsing.rs b/sdk-libs/instruction-decoder-derive/src/parsing.rs index b166d427e4..57e2e3c36c 100644 --- a/sdk-libs/instruction-decoder-derive/src/parsing.rs +++ b/sdk-libs/instruction-decoder-derive/src/parsing.rs @@ -157,6 +157,12 @@ pub struct VariantDecoderArgs { /// The function must have signature `fn(&ParamsType, &[AccountMeta]) -> String`. #[darling(default)] pub pretty_formatter: Option, + + /// Optional function to resolve account names dynamically from parsed params. + /// The function must have signature `fn(&ParamsType, &[AccountMeta]) -> Vec`. + /// When specified, this takes precedence over `accounts` and `account_names`. + #[darling(default)] + pub account_names_resolver_from_params: Option, } impl VariantDecoderArgs { diff --git a/sdk-libs/instruction-decoder/src/formatter.rs b/sdk-libs/instruction-decoder/src/formatter.rs index 3a7c95dfa4..2e409d7e75 100644 --- a/sdk-libs/instruction-decoder/src/formatter.rs +++ b/sdk-libs/instruction-decoder/src/formatter.rs @@ -1,6 +1,9 @@ //! Transaction formatting utilities for explorer-style output -use std::fmt::{self, Write}; +use std::{ + collections::HashMap, + fmt::{self, Write}, +}; use solana_pubkey::Pubkey; use tabled::{Table, Tabled}; @@ -8,11 +11,33 @@ use tabled::{Table, Tabled}; use crate::{ config::{EnhancedLoggingConfig, LogVerbosity}, types::{ - AccountAccess, AccountChange, EnhancedInstructionLog, EnhancedTransactionLog, - TransactionStatus, + AccountAccess, AccountChange, AccountStateSnapshot, EnhancedInstructionLog, + EnhancedTransactionLog, TransactionStatus, }, }; +/// Format a number with thousands separators (e.g., 1000000 -> "1,000,000") +fn format_with_thousands_separator(n: u64) -> String { + let s = n.to_string(); + let mut result = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i).is_multiple_of(3) { + result.push(','); + } + result.push(c); + } + result +} + +/// Format a signed number with thousands separators, preserving the sign +fn format_signed_with_thousands_separator(n: i64) -> String { + if n >= 0 { + format_with_thousands_separator(n as u64) + } else { + format!("-{}", format_with_thousands_separator(n.unsigned_abs())) + } +} + /// Known test accounts and programs mapped to human-readable names static KNOWN_ACCOUNTS: &[(&str, &str)] = &[ // Test program @@ -107,10 +132,54 @@ static KNOWN_ACCOUNTS: &[(&str, &str)] = &[ "amt2kaJA14v3urZbZvnc5v2np8jqvc4Z8zDep5wbtzx", "v2 address merkle tree", ), - // CPI authority + // CPI authorities ( "HZH7qSLcpAeDqCopVU4e5XkhT9j3JFsQiq8CmruY3aru", - "cpi authority pda", + "light system cpi authority", + ), + ( + "GXtd2izAiMJPwMEjfgTRH3d7k9mjn4Jq3JrWFv9gySYy", + "light token cpi authority", + ), + // Rent sponsor + ( + "r18WwUxfG8kQ69bQPAB2jV6zGNKy3GosFGctjQoV4ti", + "rent sponsor", + ), + // Compressible config PDA + ( + "ACXg8a7VaqecBWrSbdu73W4Pg9gsqXJ3EXAqkHyhvVXg", + "compressible config", + ), + // Registered program PDA + ( + "35hkDgaAKwMCaxRz2ocSZ6NaUrtKkyNqU6c4RV3tYJRh", + "registered program pda", + ), + // Config counter PDA + ( + "8gH9tmziWsS8Wc4fnoN5ax3jsSumNYoRDuSBvmH2GMH8", + "config counter pda", + ), + // Registered registry program PDA + ( + "DumMsyvkaGJG4QnQ1BhTgvoRMXsgGxfpKDUCr22Xqu4w", + "registered registry program pda", + ), + // Account compression authority PDA + ( + "HwXnGK3tPkkVY6P439H2p68AxpeuWXd5PcrAxFpbmfbA", + "account compression authority pda", + ), + // Sol pool PDA + ( + "CHK57ywWSDncAoRu1F8QgwYJeXuAJyyBYT4LixLXvMZ1", + "sol pool pda", + ), + // SPL Noop program + ( + "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV", + "noop program", ), // Solana native programs ("11111111111111111111111111111111", "system program"), @@ -128,7 +197,7 @@ static KNOWN_ACCOUNTS: &[(&str, &str)] = &[ ), ]; -/// Row for account table display +/// Row for account table display (4 columns - used for inner instructions) #[derive(Tabled)] struct AccountRow { #[tabled(rename = "#")] @@ -141,6 +210,25 @@ struct AccountRow { name: String, } +/// Row for outer instruction account table display (7 columns - includes account state) +#[derive(Tabled)] +struct OuterAccountRow { + #[tabled(rename = "#")] + symbol: String, + #[tabled(rename = "Account")] + pubkey: String, + #[tabled(rename = "Type")] + access: String, + #[tabled(rename = "Name")] + name: String, + #[tabled(rename = "Data Len")] + data_len: String, + #[tabled(rename = "Lamports")] + lamports: String, + #[tabled(rename = "Change")] + lamports_change: String, +} + /// Colors for terminal output #[derive(Debug, Clone, Default)] pub struct Colors { @@ -450,19 +538,24 @@ impl TransactionFormatter { writeln!(output, "{}│{}", self.colors.gray, self.colors.reset)?; for (i, instruction) in log.instructions.iter().enumerate() { - self.write_instruction(output, instruction, 0, i + 1)?; + self.write_instruction(output, instruction, 0, i + 1, log.account_states.as_ref())?; } Ok(()) } /// Write single instruction with proper indentation and hierarchy + /// + /// For outer instructions (depth=0), if account_states is provided, displays + /// a 7-column table with Data Len, Lamports, and Change columns. + /// For inner instructions, displays a 4-column table. fn write_instruction( &self, output: &mut String, instruction: &EnhancedInstructionLog, depth: usize, number: usize, + account_states: Option<&HashMap>, ) -> fmt::Result { let indent = self.get_tree_indent(depth); let prefix = if depth == 0 { "├─" } else { "└─" }; @@ -575,49 +668,121 @@ impl TransactionFormatter { self.colors.reset )?; - // Create a table for better account formatting - let mut account_rows: Vec = Vec::new(); + // For outer instructions (depth=0) with account states, use 7-column table + // For inner instructions, use 4-column table + if let (0, Some(states)) = (depth, account_states) { + let mut outer_rows: Vec = Vec::new(); + + for (idx, account) in instruction.accounts.iter().enumerate() { + let access = if account.is_signer && account.is_writable { + AccountAccess::SignerWritable + } else if account.is_signer { + AccountAccess::Signer + } else if account.is_writable { + AccountAccess::Writable + } else { + AccountAccess::Readonly + }; - for (idx, account) in instruction.accounts.iter().enumerate() { - let access = if account.is_signer && account.is_writable { - AccountAccess::SignerWritable - } else if account.is_signer { - AccountAccess::Signer - } else if account.is_writable { - AccountAccess::Writable - } else { - AccountAccess::Readonly - }; + // Try to get account name from decoded instruction first, then fall back to lookup + // Empty names from resolver indicate "use KNOWN_ACCOUNTS lookup" + let account_name = instruction + .decoded_instruction + .as_ref() + .and_then(|decoded| decoded.account_names.get(idx).cloned()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| self.get_account_name(&account.pubkey)); + + // Get account state if available + let (data_len, lamports, lamports_change) = if let Some(state) = + states.get(&account.pubkey) + { + let change = (state.lamports_after as i128 - state.lamports_before as i128) + .clamp(i64::MIN as i128, i64::MAX as i128) + as i64; + let change_str = if change > 0 { + format!("+{}", format_signed_with_thousands_separator(change)) + } else if change < 0 { + format_signed_with_thousands_separator(change) + } else { + "0".to_string() + }; + ( + format_with_thousands_separator(state.data_len_before as u64), + format_with_thousands_separator(state.lamports_before), + change_str, + ) + } else { + ("-".to_string(), "-".to_string(), "-".to_string()) + }; - // Try to get account name from decoded instruction first, then fall back to lookup - let account_name = instruction - .decoded_instruction - .as_ref() - .and_then(|decoded| decoded.account_names.get(idx).cloned()) - .unwrap_or_else(|| self.get_account_name(&account.pubkey)); - account_rows.push(AccountRow { - symbol: access.symbol(idx + 1), - pubkey: account.pubkey.to_string(), - access: access.text().to_string(), - name: account_name, - }); - } + outer_rows.push(OuterAccountRow { + symbol: access.symbol(idx + 1), + pubkey: account.pubkey.to_string(), + access: access.text().to_string(), + name: account_name, + data_len, + lamports, + lamports_change, + }); + } - if !account_rows.is_empty() { - let table = Table::new(account_rows) - .to_string() - .lines() - .map(|line| format!("{}{}", accounts_indent, line)) - .collect::>() - .join("\n"); - writeln!(output, "{}", table)?; + if !outer_rows.is_empty() { + let table = Table::new(outer_rows) + .to_string() + .lines() + .map(|line| format!("{}{}", accounts_indent, line)) + .collect::>() + .join("\n"); + writeln!(output, "{}", table)?; + } + } else { + // Inner instructions or no account states - use 4-column table + let mut account_rows: Vec = Vec::new(); + + for (idx, account) in instruction.accounts.iter().enumerate() { + let access = if account.is_signer && account.is_writable { + AccountAccess::SignerWritable + } else if account.is_signer { + AccountAccess::Signer + } else if account.is_writable { + AccountAccess::Writable + } else { + AccountAccess::Readonly + }; + + // Try to get account name from decoded instruction first, then fall back to lookup + // Empty names from resolver indicate "use KNOWN_ACCOUNTS lookup" + let account_name = instruction + .decoded_instruction + .as_ref() + .and_then(|decoded| decoded.account_names.get(idx).cloned()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| self.get_account_name(&account.pubkey)); + account_rows.push(AccountRow { + symbol: access.symbol(idx + 1), + pubkey: account.pubkey.to_string(), + access: access.text().to_string(), + name: account_name, + }); + } + + if !account_rows.is_empty() { + let table = Table::new(account_rows) + .to_string() + .lines() + .map(|line| format!("{}{}", accounts_indent, line)) + .collect::>() + .join("\n"); + writeln!(output, "{}", table)?; + } } } - // Write inner instructions recursively + // Write inner instructions recursively (inner instructions don't get account states) for (i, inner) in instruction.inner_instructions.iter().enumerate() { if depth < self.config.max_cpi_depth { - self.write_instruction(output, inner, depth + 1, i + 1)?; + self.write_instruction(output, inner, depth + 1, i + 1, None)?; } } @@ -1023,10 +1188,7 @@ impl TransactionFormatter { "account compression authority", ), (constants::NOOP_PROGRAM_ID, "noop program"), - ( - constants::LIGHT_TOKEN_PROGRAM_ID, - "compressed token program", - ), + (constants::LIGHT_TOKEN_PROGRAM_ID, "light token program"), (constants::ADDRESS_TREE_V1, "address tree v1"), (constants::ADDRESS_QUEUE_V1, "address queue v1"), (constants::SOL_POOL_PDA, "sol pool pda"), @@ -1055,3 +1217,32 @@ impl TransactionFormatter { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_with_thousands_separator() { + assert_eq!(format_with_thousands_separator(0), "0"); + assert_eq!(format_with_thousands_separator(1), "1"); + assert_eq!(format_with_thousands_separator(12), "12"); + assert_eq!(format_with_thousands_separator(123), "123"); + assert_eq!(format_with_thousands_separator(1234), "1,234"); + assert_eq!(format_with_thousands_separator(12345), "12,345"); + assert_eq!(format_with_thousands_separator(123456), "123,456"); + assert_eq!(format_with_thousands_separator(1234567), "1,234,567"); + assert_eq!(format_with_thousands_separator(1000000000), "1,000,000,000"); + } + + #[test] + fn test_format_signed_with_thousands_separator() { + assert_eq!(format_signed_with_thousands_separator(0), "0"); + assert_eq!(format_signed_with_thousands_separator(1234), "1,234"); + assert_eq!(format_signed_with_thousands_separator(-1234), "-1,234"); + assert_eq!( + format_signed_with_thousands_separator(-1000000), + "-1,000,000" + ); + } +} diff --git a/sdk-libs/instruction-decoder/src/lib.rs b/sdk-libs/instruction-decoder/src/lib.rs index 2ccea7d1cf..507322cdfc 100644 --- a/sdk-libs/instruction-decoder/src/lib.rs +++ b/sdk-libs/instruction-decoder/src/lib.rs @@ -58,6 +58,7 @@ pub use programs::{ pub use registry::DecoderRegistry; #[cfg(not(target_os = "solana"))] pub use types::{ - AccountAccess, AccountChange, CompressedAccountInfo, EnhancedInstructionLog, - EnhancedTransactionLog, LightProtocolEvent, MerkleTreeChange, TransactionStatus, + AccountAccess, AccountChange, AccountStateSnapshot, CompressedAccountInfo, + EnhancedInstructionLog, EnhancedTransactionLog, LightProtocolEvent, MerkleTreeChange, + TransactionStatus, }; diff --git a/sdk-libs/instruction-decoder/src/programs/ctoken.rs b/sdk-libs/instruction-decoder/src/programs/ctoken.rs index 3faa163c24..34200ad30e 100644 --- a/sdk-libs/instruction-decoder/src/programs/ctoken.rs +++ b/sdk-libs/instruction-decoder/src/programs/ctoken.rs @@ -24,9 +24,67 @@ use light_token_interface::instructions::{ }; use solana_instruction::AccountMeta; -/// Standard token accounts (before packed_accounts). -/// Transfer2 has 10 fixed accounts at indices 0-9. -const PACKED_ACCOUNTS_START: usize = 10; +/// Calculate the packed accounts start position for Transfer2. +/// +/// The start position depends on the instruction path and optional accounts: +/// - Path A (compressions-only): start = 2 (cpi_authority_pda, fee_payer) +/// - Path B (CPI context write): start = 4 (light_system, fee_payer, cpi_authority, cpi_context) +/// - Path C (full transfer): start = 7 + optional accounts +/// - +1 for sol_pool_pda (when lamports imbalance) +/// - +1 for sol_decompression_recipient (when decompressing SOL) +/// - +1 for cpi_context_account (when cpi_context present but not writing) +#[cfg(not(target_os = "solana"))] +fn calculate_packed_accounts_start(data: &CompressedTokenInstructionDataTransfer2) -> usize { + let no_compressed_accounts = data.in_token_data.is_empty() && data.out_token_data.is_empty(); + let cpi_context_write_required = data + .cpi_context + .as_ref() + .map(|ctx| ctx.set_context || ctx.first_set_context) + .unwrap_or(false); + + if no_compressed_accounts { + // Path A: compressions-only + // [cpi_authority_pda, fee_payer, ...packed_accounts] + 2 + } else if cpi_context_write_required { + // Path B: CPI context write + // [light_system_program, fee_payer, cpi_authority_pda, cpi_context] + // No packed accounts in this path (return 4 to indicate end of accounts) + 4 + } else { + // Path C: Full transfer + // Base: [light_system_program, fee_payer, cpi_authority_pda, registered_program_pda, + // account_compression_authority, account_compression_program, system_program] + let mut start = 7; + + // Optional: sol_pool_pda (when lamports imbalance exists) + let in_lamports: u64 = data + .in_lamports + .as_ref() + .map(|v| v.iter().sum()) + .unwrap_or(0); + let out_lamports: u64 = data + .out_lamports + .as_ref() + .map(|v| v.iter().sum()) + .unwrap_or(0); + if in_lamports != out_lamports { + start += 1; // sol_pool_pda + } + + // Optional: sol_decompression_recipient (when decompressing SOL) + if out_lamports > in_lamports { + start += 1; // sol_decompression_recipient + } + + // Optional: cpi_context_account (when cpi_context present but not writing) + if data.cpi_context.is_some() { + start += 1; // cpi_context_account + } + + start + } +} /// Format Transfer2 instruction data with resolved pubkeys. /// @@ -34,16 +92,8 @@ const PACKED_ACCOUNTS_START: usize = 10; /// resolving account indices to actual pubkeys from the instruction accounts. /// /// Mode detection: -/// - CPI context mode (cpi_context is Some): Packed accounts are passed via CPI context account, -/// not in the instruction's accounts array. Shows raw indices only. -/// - Direct mode (cpi_context is None): Packed accounts are in the accounts array at -/// PACKED_ACCOUNTS_START offset. Resolves indices to actual pubkeys. -/// -/// Index resolution: -/// - In CPI context mode: all indices shown as packed[N] (stored in CPI context account) -/// - In direct mode: all indices (owner, mint, delegate, merkle_tree, queue) are resolved -/// using PACKED_ACCOUNTS_START offset. Note: this assumes a specific account layout and -/// may show OUT_OF_BOUNDS if the actual layout differs. +/// - CPI context mode (cpi_context.set_context || first_set_context): Shows raw indices +/// - Direct mode: Resolves packed account indices using dynamically calculated start position #[cfg(not(target_os = "solana"))] pub fn format_transfer2( data: &CompressedTokenInstructionDataTransfer2, @@ -52,32 +102,34 @@ pub fn format_transfer2( use std::fmt::Write; let mut output = String::new(); - // Determine if packed accounts are in CPI context (not directly in accounts array) - // When cpi_context is Some, packed accounts are stored in/read from a CPI context account - let uses_cpi_context = data.cpi_context.is_some(); + // Determine if packed accounts are in CPI context write mode + let cpi_context_write_mode = data + .cpi_context + .as_ref() + .map(|ctx| ctx.set_context || ctx.first_set_context) + .unwrap_or(false); + + // Calculate where packed accounts start based on instruction path + let packed_accounts_start = calculate_packed_accounts_start(data); // Helper to resolve account index - // In CPI context mode: all indices are packed indices stored in CPI context - // In direct mode: packed indices are offset by PACKED_ACCOUNTS_START let resolve = |index: u8| -> String { - if uses_cpi_context { - // All accounts (including trees/queues) are in CPI context + if cpi_context_write_mode { + // All accounts are in CPI context format!("packed[{}]", index) } else { accounts - .get(PACKED_ACCOUNTS_START + index as usize) + .get(packed_accounts_start + index as usize) .map(|a| a.pubkey.to_string()) - .unwrap_or_else(|| { - format!("OUT_OF_BOUNDS({})", PACKED_ACCOUNTS_START + index as usize) - }) + .unwrap_or_else(|| format!("OUT_OF_BOUNDS({})", index)) } }; // Header with mode indicator - if uses_cpi_context { + if cpi_context_write_mode { let _ = writeln!( output, - "[CPI Context Mode - packed accounts in CPI context]" + "[CPI Context Write Mode - packed accounts in CPI context]" ); } @@ -153,16 +205,505 @@ pub fn format_transfer2( output } +/// Resolve Transfer2 account names dynamically based on instruction data. +/// +/// Transfer2 has a dynamic account layout with three mutually exclusive paths: +/// +/// **Path A: Compressions-only** (`in_token_data.is_empty() && out_token_data.is_empty()`) +/// - Account 0: `compressions_only_cpi_authority_pda` +/// - Account 1: `compressions_only_fee_payer` +/// - Remaining: packed_accounts +/// +/// **Path B: CPI Context Write** (`cpi_context.set_context || cpi_context.first_set_context`) +/// - Account 0: `light_system_program` +/// - Account 1: `fee_payer` +/// - Account 2: `cpi_authority_pda` +/// - Account 3: `cpi_context` +/// - No packed accounts +/// +/// **Path C: Full Transfer** (default) +/// - 7 fixed accounts: light_system_program, fee_payer, cpi_authority_pda, registered_program_pda, +/// account_compression_authority, account_compression_program, system_program +/// - Optional: sol_pool_pda (when lamports imbalance exists) +/// - Optional: sol_decompression_recipient (when decompressing SOL) +/// - Optional: cpi_context_account (when cpi_context is present but not writing) +/// - Remaining: packed_accounts +#[cfg(not(target_os = "solana"))] +pub fn resolve_transfer2_account_names( + data: &CompressedTokenInstructionDataTransfer2, + accounts: &[AccountMeta], +) -> Vec { + use std::collections::HashMap; + + let mut names = Vec::with_capacity(accounts.len()); + let mut idx = 0; + let mut known_pubkeys: HashMap<[u8; 32], String> = HashMap::new(); + + let mut add_name = |name: &str, + accounts: &[AccountMeta], + idx: &mut usize, + known: &mut HashMap<[u8; 32], String>| { + if *idx < accounts.len() { + names.push(name.to_string()); + known.insert(accounts[*idx].pubkey.to_bytes(), name.to_string()); + *idx += 1; + true + } else { + false + } + }; + + // Determine path from instruction data + let no_compressed_accounts = data.in_token_data.is_empty() && data.out_token_data.is_empty(); + let cpi_context_write_required = data + .cpi_context + .as_ref() + .map(|ctx| ctx.set_context || ctx.first_set_context) + .unwrap_or(false); + + if no_compressed_accounts { + // Path A: Compressions-only + add_name( + "compressions_only_cpi_authority_pda", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name( + "compressions_only_fee_payer", + accounts, + &mut idx, + &mut known_pubkeys, + ); + } else if cpi_context_write_required { + // Path B: CPI Context Write + add_name( + "light_system_program", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_context", accounts, &mut idx, &mut known_pubkeys); + // No packed accounts in this path + return names; + } else { + // Path C: Full Transfer + add_name( + "light_system_program", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys); + add_name( + "registered_program_pda", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name( + "account_compression_authority", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name( + "account_compression_program", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name("system_program", accounts, &mut idx, &mut known_pubkeys); + + // Optional accounts - determine from instruction data + // sol_pool_pda: when lamports imbalance exists + let in_lamports: u64 = data + .in_lamports + .as_ref() + .map(|v| v.iter().sum()) + .unwrap_or(0); + let out_lamports: u64 = data + .out_lamports + .as_ref() + .map(|v| v.iter().sum()) + .unwrap_or(0); + let with_sol_pool = in_lamports != out_lamports; + if with_sol_pool { + add_name("sol_pool_pda", accounts, &mut idx, &mut known_pubkeys); + } + + // sol_decompression_recipient: when decompressing SOL (out > in) + let with_sol_decompression = out_lamports > in_lamports; + if with_sol_decompression { + add_name( + "sol_decompression_recipient", + accounts, + &mut idx, + &mut known_pubkeys, + ); + } + + // cpi_context_account: add placeholder - formatter will use transaction-level name + if data.cpi_context.is_some() { + names.push(String::new()); // Empty = use formatter's KNOWN_ACCOUNTS lookup + idx += 1; + } + } + + // Build a map of packed account index -> role name from instruction data + let mut packed_roles: HashMap = HashMap::new(); + let mut owner_count = 0u8; + let mut mint_count = 0u8; + let mut delegate_count = 0u8; + let mut in_merkle_count = 0u8; + let mut in_queue_count = 0u8; + let mut compress_mint_count = 0u8; + let mut compress_source_count = 0u8; + let mut compress_auth_count = 0u8; + + // output_queue + packed_roles + .entry(data.output_queue) + .or_insert_with(|| "output_queue".to_string()); + + // Input token data + for token in data.in_token_data.iter() { + packed_roles.entry(token.owner).or_insert_with(|| { + let name = if owner_count == 0 { + "owner".to_string() + } else { + format!("owner_{}", owner_count) + }; + owner_count = owner_count.saturating_add(1); + name + }); + packed_roles.entry(token.mint).or_insert_with(|| { + let name = if mint_count == 0 { + "mint".to_string() + } else { + format!("mint_{}", mint_count) + }; + mint_count = mint_count.saturating_add(1); + name + }); + if token.has_delegate { + packed_roles.entry(token.delegate).or_insert_with(|| { + let name = if delegate_count == 0 { + "delegate".to_string() + } else { + format!("delegate_{}", delegate_count) + }; + delegate_count = delegate_count.saturating_add(1); + name + }); + } + packed_roles + .entry(token.merkle_context.merkle_tree_pubkey_index) + .or_insert_with(|| { + let name = if in_merkle_count == 0 { + "in_merkle_tree".to_string() + } else { + format!("in_merkle_tree_{}", in_merkle_count) + }; + in_merkle_count = in_merkle_count.saturating_add(1); + name + }); + packed_roles + .entry(token.merkle_context.queue_pubkey_index) + .or_insert_with(|| { + let name = if in_queue_count == 0 { + "in_nullifier_queue".to_string() + } else { + format!("in_nullifier_queue_{}", in_queue_count) + }; + in_queue_count = in_queue_count.saturating_add(1); + name + }); + } + + // Output token data + for token in data.out_token_data.iter() { + packed_roles.entry(token.owner).or_insert_with(|| { + let name = if owner_count == 0 { + "owner".to_string() + } else { + format!("owner_{}", owner_count) + }; + owner_count = owner_count.saturating_add(1); + name + }); + packed_roles.entry(token.mint).or_insert_with(|| { + let name = if mint_count == 0 { + "mint".to_string() + } else { + format!("mint_{}", mint_count) + }; + mint_count = mint_count.saturating_add(1); + name + }); + if token.has_delegate { + packed_roles.entry(token.delegate).or_insert_with(|| { + let name = if delegate_count == 0 { + "delegate".to_string() + } else { + format!("delegate_{}", delegate_count) + }; + delegate_count = delegate_count.saturating_add(1); + name + }); + } + } + + // Compressions + if let Some(compressions) = &data.compressions { + for comp in compressions.iter() { + packed_roles.entry(comp.mint).or_insert_with(|| { + let name = if compress_mint_count == 0 { + "compress_mint".to_string() + } else { + format!("compress_mint_{}", compress_mint_count) + }; + compress_mint_count = compress_mint_count.saturating_add(1); + name + }); + packed_roles + .entry(comp.source_or_recipient) + .or_insert_with(|| { + let name = if compress_source_count == 0 { + "compress_source".to_string() + } else { + format!("compress_source_{}", compress_source_count) + }; + compress_source_count = compress_source_count.saturating_add(1); + name + }); + packed_roles.entry(comp.authority).or_insert_with(|| { + let name = if compress_auth_count == 0 { + "compress_authority".to_string() + } else { + format!("compress_authority_{}", compress_auth_count) + }; + compress_auth_count = compress_auth_count.saturating_add(1); + name + }); + } + } + + // Remaining accounts are packed - prioritize role names from instruction data + let mut packed_idx: u8 = 0; + while idx < accounts.len() { + let pubkey_bytes = accounts[idx].pubkey.to_bytes(); + + // First check if we have a semantic role from instruction data + if let Some(role) = packed_roles.get(&packed_idx) { + // Use the role name, and note if it matches a known account + if let Some(known_name) = known_pubkeys.get(&pubkey_bytes) { + names.push(format!("{} (={})", role, known_name)); + } else { + names.push(role.clone()); + known_pubkeys.insert(pubkey_bytes, role.clone()); + } + } else if let Some(known_name) = known_pubkeys.get(&pubkey_bytes) { + // No role, but matches a known account + names.push(format!("packed_{} (={})", packed_idx, known_name)); + } else { + // Unknown packed account + names.push(format!("packed_account_{}", packed_idx)); + } + idx += 1; + packed_idx = packed_idx.saturating_add(1); + } + + names +} + +/// Resolve MintAction account names dynamically based on instruction data. +/// +/// MintAction has a dynamic account layout that depends on: +/// - `create_mint`: whether creating a new compressed mint +/// - `cpi_context`: whether using CPI context mode +/// - `mint` (None = decompressed): whether mint is decompressed to CMint +/// - `actions`: may contain DecompressMint, CompressAndCloseMint, MintToCompressed +/// +/// Account layout (see plan for full details): +/// 1. Fixed: light_system_program, [mint_signer if create_mint], authority +/// 2. CPI Context Mode: fee_payer, cpi_authority_pda, cpi_context +/// 3. Executing Mode: +/// - Optional: compressible_config, cmint, rent_sponsor +/// - LightSystemAccounts (6 required) +/// - Optional: cpi_context_account +/// - Tree accounts +/// - Packed accounts (identified by pubkey when possible) +#[cfg(not(target_os = "solana"))] +pub fn resolve_mint_action_account_names( + data: &MintActionCompressedInstructionData, + accounts: &[AccountMeta], +) -> Vec { + use std::collections::HashMap; + + use light_token_interface::instructions::mint_action::Action; + + let mut names = Vec::with_capacity(accounts.len()); + let mut idx = 0; + // Track known pubkeys -> name for identifying packed accounts + let mut known_pubkeys: HashMap<[u8; 32], String> = HashMap::new(); + + // Helper to add name and track pubkey + let mut add_name = |name: &str, + accounts: &[AccountMeta], + idx: &mut usize, + known: &mut HashMap<[u8; 32], String>| { + if *idx < accounts.len() { + names.push(name.to_string()); + known.insert(accounts[*idx].pubkey.to_bytes(), name.to_string()); + *idx += 1; + true + } else { + false + } + }; + + // Index 0: light_system_program (always) + add_name( + "light_system_program", + accounts, + &mut idx, + &mut known_pubkeys, + ); + + // Index 1: mint_signer (optional - only if creating mint) + if data.create_mint.is_some() { + add_name("mint_signer", accounts, &mut idx, &mut known_pubkeys); + } + + // Next: authority (always) + add_name("authority", accounts, &mut idx, &mut known_pubkeys); + + // Determine flags from instruction data + let write_to_cpi_context = data + .cpi_context + .as_ref() + .map(|ctx| ctx.first_set_context || ctx.set_context) + .unwrap_or(false); + + if write_to_cpi_context { + // CPI Context Mode: CpiContextLightSystemAccounts (3 accounts) + add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_context", accounts, &mut idx, &mut known_pubkeys); + // No more accounts in this mode + } else { + // Executing Mode + let has_decompress_mint_action = data + .actions + .iter() + .any(|action| matches!(action, Action::DecompressMint(_))); + + let has_compress_and_close_cmint_action = data + .actions + .iter() + .any(|action| matches!(action, Action::CompressAndCloseMint(_))); + + let needs_compressible_accounts = + has_decompress_mint_action || has_compress_and_close_cmint_action; + + let cmint_decompressed = data.mint.is_none(); + let needs_cmint_account = + cmint_decompressed || has_decompress_mint_action || has_compress_and_close_cmint_action; + + // Optional: compressible_config + if needs_compressible_accounts { + add_name( + "compressible_config", + accounts, + &mut idx, + &mut known_pubkeys, + ); + } + + // Optional: cmint + if needs_cmint_account { + add_name("cmint", accounts, &mut idx, &mut known_pubkeys); + } + + // Optional: rent_sponsor + if needs_compressible_accounts { + add_name("rent_sponsor", accounts, &mut idx, &mut known_pubkeys); + } + + // LightSystemAccounts (6 required) + add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys); + add_name( + "registered_program_pda", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name( + "account_compression_authority", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name( + "account_compression_program", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name("system_program", accounts, &mut idx, &mut known_pubkeys); + + // Note: cpi_context_account and tree accounts are NOT named here - + // let the formatter use the transaction-level account names + } + + names +} + +/// Format MintAction instruction data with resolved pubkeys. +/// +/// Calculate the packed accounts start position for MintAction. +/// +/// MintAction has a simpler layout than Transfer2: +/// - 6 fixed LightSystemAccounts: fee_payer, cpi_authority_pda, registered_program_pda, +/// account_compression_authority, account_compression_program, system_program +/// - Optional: cpi_context_account (when cpi_context is present but not writing) +/// - Then: packed accounts +#[cfg(not(target_os = "solana"))] +fn calculate_mint_action_packed_accounts_start( + data: &MintActionCompressedInstructionData, +) -> usize { + let cpi_context_write_mode = data + .cpi_context + .as_ref() + .map(|ctx| ctx.set_context || ctx.first_set_context) + .unwrap_or(false); + + if cpi_context_write_mode { + // CPI context write mode: [fee_payer, cpi_authority_pda, cpi_context] + 3 + } else { + // Normal mode: 6 LightSystemAccounts + optional cpi_context_account + let mut start = 6; + if data.cpi_context.is_some() { + start += 1; // cpi_context_account + } + start + } +} + /// Format MintAction instruction data with resolved pubkeys. /// /// This formatter provides a human-readable view of the mint action instruction, /// resolving account indices to actual pubkeys from the instruction accounts. /// /// Mode detection: -/// - CPI context mode (cpi_context.set_context || first_set_context): Packed accounts are passed -/// via CPI context account, not in the instruction's accounts array. Shows raw indices only. -/// - Direct mode: Packed accounts are in the accounts array at PACKED_ACCOUNTS_START offset. -/// Resolves indices to actual pubkeys. +/// - CPI context write mode (cpi_context.set_context || first_set_context): Shows raw indices +/// - Direct mode: Resolves packed account indices using dynamically calculated start position #[cfg(not(target_os = "solana"))] pub fn format_mint_action( data: &MintActionCompressedInstructionData, @@ -173,32 +714,33 @@ pub fn format_mint_action( use light_token_interface::instructions::mint_action::Action; let mut output = String::new(); - // CPI context mode: set_context OR first_set_context means packed accounts in CPI context - let uses_cpi_context = data + // CPI context write mode: set_context OR first_set_context means packed accounts in CPI context + let cpi_context_write_mode = data .cpi_context .as_ref() .map(|ctx| ctx.set_context || ctx.first_set_context) .unwrap_or(false); + // Calculate where packed accounts start based on instruction configuration + let packed_accounts_start = calculate_mint_action_packed_accounts_start(data); + // Helper to resolve account index let resolve = |index: u8| -> String { - if uses_cpi_context { + if cpi_context_write_mode { format!("packed[{}]", index) } else { accounts - .get(PACKED_ACCOUNTS_START + index as usize) + .get(packed_accounts_start + index as usize) .map(|a| a.pubkey.to_string()) - .unwrap_or_else(|| { - format!("OUT_OF_BOUNDS({})", PACKED_ACCOUNTS_START + index as usize) - }) + .unwrap_or_else(|| format!("OUT_OF_BOUNDS({})", index)) } }; // Header with mode indicator - if uses_cpi_context { + if cpi_context_write_mode { let _ = writeln!( output, - "[CPI Context Mode - packed accounts in CPI context]" + "[CPI Context Write Mode - packed accounts in CPI context]" ); } @@ -456,10 +998,11 @@ pub enum CTokenInstruction { CreateAssociatedTokenAccount, /// Transfer v2 with additional options (discriminator 101) + /// Uses dynamic account names resolver because the account layout depends on instruction data. #[discriminator = 101] #[instruction_decoder( - account_names = ["fee_payer", "authority", "registered_program_pda", "noop_program", "account_compression_authority", "account_compression_program", "self_program", "cpi_signer", "light_system_program", "system_program"], params = CompressedTokenInstructionDataTransfer2, + account_names_resolver_from_params = crate::programs::ctoken::resolve_transfer2_account_names, pretty_formatter = crate::programs::ctoken::format_transfer2 )] Transfer2, @@ -470,10 +1013,11 @@ pub enum CTokenInstruction { CreateAssociatedTokenAccountIdempotent, /// Mint action for compressed tokens (discriminator 103) + /// Uses dynamic account names resolver because the account layout depends on instruction data. #[discriminator = 103] #[instruction_decoder( - account_names = ["fee_payer", "authority", "registered_program_pda", "noop_program", "account_compression_authority", "account_compression_program", "self_program", "cpi_signer", "light_system_program", "system_program"], params = MintActionCompressedInstructionData, + account_names_resolver_from_params = crate::programs::ctoken::resolve_mint_action_account_names, pretty_formatter = crate::programs::ctoken::format_mint_action )] MintAction, diff --git a/sdk-libs/instruction-decoder/src/programs/light_system.rs b/sdk-libs/instruction-decoder/src/programs/light_system.rs index d65b7316c8..ec79fbf33f 100644 --- a/sdk-libs/instruction-decoder/src/programs/light_system.rs +++ b/sdk-libs/instruction-decoder/src/programs/light_system.rs @@ -546,6 +546,82 @@ pub fn format_invoke_cpi_readonly( output } +/// Resolve account names dynamically for InvokeCpiWithReadOnly. +/// +/// Account layout depends on CPI context mode: +/// +/// **CPI Context Write Mode** (`set_context || first_set_context`): +/// - fee_payer, cpi_authority_pda, cpi_context +/// +/// **Normal Mode**: +/// 1. Fixed: fee_payer, authority, registered_program_pda, account_compression_authority, +/// account_compression_program, system_program +/// 2. Optional: cpi_context_account (if cpi_context is present) +/// 3. Tree accounts: named based on usage in instruction data +#[cfg(not(target_os = "solana"))] +pub fn resolve_invoke_cpi_readonly_account_names( + data: &InstructionDataInvokeCpiWithReadOnly, + accounts: &[AccountMeta], +) -> Vec { + use std::collections::HashMap; + + let mut names = Vec::with_capacity(accounts.len()); + let mut idx = 0; + let mut known_pubkeys: HashMap<[u8; 32], String> = HashMap::new(); + + let mut add_name = |name: &str, + accounts: &[AccountMeta], + idx: &mut usize, + known: &mut HashMap<[u8; 32], String>| { + if *idx < accounts.len() { + names.push(name.to_string()); + known.insert(accounts[*idx].pubkey.to_bytes(), name.to_string()); + *idx += 1; + true + } else { + false + } + }; + + // Check if we're in CPI context write mode + let cpi_context_write_mode = data.cpi_context.set_context || data.cpi_context.first_set_context; + + if cpi_context_write_mode { + // CPI Context Write Mode: only 3 accounts + add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_context", accounts, &mut idx, &mut known_pubkeys); + return names; + } + + // Normal Mode: Fixed LightSystemAccounts (6 accounts) + add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys); + add_name("authority", accounts, &mut idx, &mut known_pubkeys); + add_name( + "registered_program_pda", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name( + "account_compression_authority", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name( + "account_compression_program", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name("system_program", accounts, &mut idx, &mut known_pubkeys); + + // Don't provide names for remaining accounts (cpi_context_account, tree/queue accounts) + // - let the formatter use the transaction-level account names + names +} + /// Format InvokeCpiWithAccountInfo instruction data. /// /// Note: This instruction does NOT have the 4-byte Anchor prefix - it uses pure borsh. @@ -589,6 +665,82 @@ pub fn format_invoke_cpi_account_info( output } +/// Resolve account names dynamically for InvokeCpiWithAccountInfo. +/// +/// Account layout depends on CPI context mode: +/// +/// **CPI Context Write Mode** (`set_context || first_set_context`): +/// - fee_payer, cpi_authority_pda, cpi_context +/// +/// **Normal Mode**: +/// 1. Fixed: fee_payer, authority, registered_program_pda, account_compression_authority, +/// account_compression_program, system_program +/// 2. Optional: cpi_context_account (if cpi_context is present) +/// 3. Tree accounts: named based on usage in instruction data +#[cfg(not(target_os = "solana"))] +pub fn resolve_invoke_cpi_account_info_account_names( + data: &InstructionDataInvokeCpiWithAccountInfo, + accounts: &[AccountMeta], +) -> Vec { + use std::collections::HashMap; + + let mut names = Vec::with_capacity(accounts.len()); + let mut idx = 0; + let mut known_pubkeys: HashMap<[u8; 32], String> = HashMap::new(); + + let mut add_name = |name: &str, + accounts: &[AccountMeta], + idx: &mut usize, + known: &mut HashMap<[u8; 32], String>| { + if *idx < accounts.len() { + names.push(name.to_string()); + known.insert(accounts[*idx].pubkey.to_bytes(), name.to_string()); + *idx += 1; + true + } else { + false + } + }; + + // Check if we're in CPI context write mode + let cpi_context_write_mode = data.cpi_context.set_context || data.cpi_context.first_set_context; + + if cpi_context_write_mode { + // CPI Context Write Mode: only 3 accounts + add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys); + add_name("cpi_context", accounts, &mut idx, &mut known_pubkeys); + return names; + } + + // Normal Mode: Fixed LightSystemAccounts (6 accounts) + add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys); + add_name("authority", accounts, &mut idx, &mut known_pubkeys); + add_name( + "registered_program_pda", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name( + "account_compression_authority", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name( + "account_compression_program", + accounts, + &mut idx, + &mut known_pubkeys, + ); + add_name("system_program", accounts, &mut idx, &mut known_pubkeys); + + // Don't provide names for remaining accounts (cpi_context_account, tree/queue accounts) + // - let the formatter use the transaction-level account names + names +} + /// Wrapper type for Invoke instruction that handles the 4-byte Anchor prefix. /// /// The derive macro's borsh deserialization expects the data immediately after @@ -720,7 +872,7 @@ pub enum LightSystemInstruction { /// Has 4-byte Anchor vec length prefix after discriminator. #[discriminator(26, 16, 169, 7, 21, 202, 242, 25)] #[instruction_decoder( - account_names = ["fee_payer", "authority", "registered_program_pda", "noop_program", "account_compression_authority", "account_compression_program", "self_program"], + account_names = ["fee_payer", "authority", "registered_program_pda", "log_program", "account_compression_authority", "account_compression_program", "self_program"], params = InvokeWrapper, pretty_formatter = crate::programs::light_system::format_invoke_wrapper )] @@ -730,28 +882,30 @@ pub enum LightSystemInstruction { /// Has 4-byte Anchor vec length prefix after discriminator. #[discriminator(49, 212, 191, 129, 39, 194, 43, 196)] #[instruction_decoder( - account_names = ["fee_payer", "authority", "registered_program_pda", "noop_program", "account_compression_authority", "account_compression_program", "invoking_program", "cpi_signer"], + account_names = ["fee_payer", "authority", "registered_program_pda", "log_program", "account_compression_authority", "account_compression_program", "invoking_program", "cpi_signer"], params = InvokeCpiWrapper, pretty_formatter = crate::programs::light_system::format_invoke_cpi_wrapper )] InvokeCpi, - /// CPI with read-only compressed accounts. + /// CPI with read-only compressed accounts (V2 account layout). /// Uses pure borsh serialization (no 4-byte prefix). + /// Note: V2 instructions have no log_program account. #[discriminator(86, 47, 163, 166, 21, 223, 92, 8)] #[instruction_decoder( - account_names = ["fee_payer", "authority", "registered_program_pda", "noop_program", "account_compression_authority", "account_compression_program", "invoking_program", "cpi_signer"], params = InstructionDataInvokeCpiWithReadOnly, + account_names_resolver_from_params = crate::programs::light_system::resolve_invoke_cpi_readonly_account_names, pretty_formatter = crate::programs::light_system::format_invoke_cpi_readonly )] InvokeCpiWithReadOnly, - /// CPI with full account info for each compressed account. + /// CPI with full account info for each compressed account (V2 account layout). /// Uses pure borsh serialization (no 4-byte prefix). + /// Note: V2 instructions have no log_program account. #[discriminator(228, 34, 128, 84, 47, 139, 86, 240)] #[instruction_decoder( - account_names = ["fee_payer", "authority", "registered_program_pda", "noop_program", "account_compression_authority", "account_compression_program", "invoking_program", "cpi_signer"], params = InstructionDataInvokeCpiWithAccountInfo, + account_names_resolver_from_params = crate::programs::light_system::resolve_invoke_cpi_account_info_account_names, pretty_formatter = crate::programs::light_system::format_invoke_cpi_account_info )] InvokeCpiWithAccountInfo, diff --git a/sdk-libs/instruction-decoder/src/types.rs b/sdk-libs/instruction-decoder/src/types.rs index 423535c8d8..1072e0eaa7 100644 --- a/sdk-libs/instruction-decoder/src/types.rs +++ b/sdk-libs/instruction-decoder/src/types.rs @@ -4,12 +4,23 @@ //! and transaction logging. These types are independent of any test framework //! (LiteSVM, etc.) and can be used in standalone tools. +use std::collections::HashMap; + use solana_instruction::AccountMeta; use solana_pubkey::Pubkey; use solana_signature::Signature; use crate::{DecodedInstruction, DecoderRegistry, EnhancedLoggingConfig}; +/// Pre and post transaction account state snapshot +#[derive(Debug, Clone, Default)] +pub struct AccountStateSnapshot { + pub lamports_before: u64, + pub lamports_after: u64, + pub data_len_before: usize, + pub data_len_after: usize, +} + /// Enhanced transaction log containing all formatting information #[derive(Debug, Clone)] pub struct EnhancedTransactionLog { @@ -23,6 +34,8 @@ pub struct EnhancedTransactionLog { pub account_changes: Vec, pub program_logs_pretty: String, pub light_events: Vec, + /// Pre and post transaction account state snapshots (keyed by pubkey) + pub account_states: Option>, } impl EnhancedTransactionLog { @@ -39,6 +52,7 @@ impl EnhancedTransactionLog { account_changes: Vec::new(), program_logs_pretty: String::new(), light_events: Vec::new(), + account_states: None, } } } @@ -218,7 +232,7 @@ pub fn get_program_name(program_id: &Pubkey, registry: Option<&DecoderRegistry>) "ComputeBudget111111111111111111111111111111" => "Compute Budget".to_string(), "SySTEM1eSU2p4BGQfQpimFEWWSC1XDFeun3Nqzz3rT7" => "Light System Program".to_string(), "compr6CUsB5m2jS4Y3831ztGSTnDpnKJTKS95d64XVq" => "Account Compression".to_string(), - "cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m" => "Compressed Token Program".to_string(), + "cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m" => "Light Token Program".to_string(), _ => format!("Unknown Program ({})", program_id), } } diff --git a/sdk-libs/program-test/src/logging/mod.rs b/sdk-libs/program-test/src/logging/mod.rs index cfda331b47..84db0f0a7c 100644 --- a/sdk-libs/program-test/src/logging/mod.rs +++ b/sdk-libs/program-test/src/logging/mod.rs @@ -9,6 +9,7 @@ //! - Re-exports from instruction-decoder use std::{ + collections::HashMap, fs::OpenOptions, io::Write, path::PathBuf, @@ -18,19 +19,38 @@ use std::{ use chrono; // Re-export everything from instruction-decoder pub use light_instruction_decoder::{ - AccountAccess, AccountChange, AccountCompressionInstructionDecoder, CTokenInstructionDecoder, - Colors, CompressedAccountInfo, ComputeBudgetInstructionDecoder, DecodedField, - DecodedInstruction, DecoderRegistry, EnhancedInstructionLog, EnhancedLoggingConfig, - EnhancedTransactionLog, InstructionDecoder, LightProtocolEvent, LightSystemInstructionDecoder, - LogVerbosity, MerkleTreeChange, RegistryInstructionDecoder, SplTokenInstructionDecoder, - SystemInstructionDecoder, Token2022InstructionDecoder, TransactionFormatter, TransactionStatus, + AccountAccess, AccountChange, AccountCompressionInstructionDecoder, AccountStateSnapshot, + CTokenInstructionDecoder, Colors, CompressedAccountInfo, ComputeBudgetInstructionDecoder, + DecodedField, DecodedInstruction, DecoderRegistry, EnhancedInstructionLog, + EnhancedLoggingConfig, EnhancedTransactionLog, InstructionDecoder, LightProtocolEvent, + LightSystemInstructionDecoder, LogVerbosity, MerkleTreeChange, RegistryInstructionDecoder, + SplTokenInstructionDecoder, SystemInstructionDecoder, Token2022InstructionDecoder, + TransactionFormatter, TransactionStatus, }; -use litesvm::types::TransactionResult; +use litesvm::{types::TransactionResult, LiteSVM}; use solana_sdk::{ inner_instruction::InnerInstruction, pubkey::Pubkey, signature::Signature, transaction::Transaction, }; +/// Lightweight pre-transaction account state capture. +/// Maps pubkey -> (lamports, data_len) for accounts in a transaction. +pub type AccountStates = HashMap; + +/// Capture account states from LiteSVM context. +/// Call this before and after sending the transaction. +pub fn capture_account_states(context: &LiteSVM, transaction: &Transaction) -> AccountStates { + let mut states = HashMap::new(); + for pubkey in &transaction.message.account_keys { + if let Some(account) = context.get_account(pubkey) { + states.insert(*pubkey, (account.lamports, account.data.len())); + } else { + states.insert(*pubkey, (0, 0)); + } + } + states +} + use crate::program_test::config::ProgramTestConfig; static SESSION_STARTED: std::sync::Once = std::sync::Once::new(); @@ -128,6 +148,7 @@ fn write_to_log_file(content: &str) { } /// Main entry point for enhanced transaction logging +#[allow(clippy::too_many_arguments)] pub fn log_transaction_enhanced( config: &ProgramTestConfig, transaction: &Transaction, @@ -135,6 +156,8 @@ pub fn log_transaction_enhanced( signature: &Signature, slot: u64, transaction_counter: usize, + pre_states: Option<&AccountStates>, + post_states: Option<&AccountStates>, ) { log_transaction_enhanced_with_console( config, @@ -144,10 +167,13 @@ pub fn log_transaction_enhanced( slot, transaction_counter, false, + pre_states, + post_states, ) } /// Enhanced transaction logging with console output control +#[allow(clippy::too_many_arguments)] pub fn log_transaction_enhanced_with_console( config: &ProgramTestConfig, transaction: &Transaction, @@ -156,6 +182,8 @@ pub fn log_transaction_enhanced_with_console( slot: u64, transaction_counter: usize, print_to_console: bool, + pre_states: Option<&AccountStates>, + post_states: Option<&AccountStates>, ) { if !config.enhanced_logging.enabled { return; @@ -167,6 +195,8 @@ pub fn log_transaction_enhanced_with_console( signature, slot, &config.enhanced_logging, + pre_states, + post_states, ); let formatter = TransactionFormatter::new(&config.enhanced_logging); @@ -205,12 +235,19 @@ fn get_pretty_logs_string(result: &TransactionResult) -> String { } /// Create EnhancedTransactionLog from LiteSVM transaction result +/// +/// If pre_states and post_states are provided, captures account state snapshots +/// for all accounts in the transaction. +/// +/// Use `capture_pre_account_states` before and after sending the transaction. pub fn from_transaction_result( transaction: &Transaction, result: &TransactionResult, signature: &Signature, slot: u64, config: &EnhancedLoggingConfig, + pre_states: Option<&AccountStates>, + post_states: Option<&AccountStates>, ) -> EnhancedTransactionLog { let (status, compute_consumed) = match result { Ok(meta) => (TransactionStatus::Success, meta.compute_units_consumed), @@ -222,6 +259,28 @@ pub fn from_transaction_result( let estimated_fee = (transaction.signatures.len() as u64) * 5000; + // Capture account states if both pre and post states are provided + let account_states = if let (Some(pre), Some(post)) = (pre_states, post_states) { + let mut states = HashMap::new(); + for pubkey in &transaction.message.account_keys { + let (lamports_before, data_len_before) = pre.get(pubkey).copied().unwrap_or((0, 0)); + let (lamports_after, data_len_after) = post.get(pubkey).copied().unwrap_or((0, 0)); + + states.insert( + solana_pubkey::Pubkey::new_from_array(pubkey.to_bytes()), + AccountStateSnapshot { + lamports_before, + lamports_after, + data_len_before, + data_len_after, + }, + ); + } + Some(states) + } else { + None + }; + // Build full instructions with accounts and data let mut instructions: Vec = transaction .message @@ -290,6 +349,7 @@ pub fn from_transaction_result( log.compute_used = compute_consumed; log.instructions = instructions; log.program_logs_pretty = pretty_logs_string; + log.account_states = account_states; log } diff --git a/sdk-libs/program-test/src/program_test/rpc.rs b/sdk-libs/program-test/src/program_test/rpc.rs index f87e89703f..a2f5d6981d 100644 --- a/sdk-libs/program-test/src/program_test/rpc.rs +++ b/sdk-libs/program-test/src/program_test/rpc.rs @@ -392,7 +392,9 @@ impl LightProgramTest { let signature = transaction.signatures[0]; let transaction_for_logging = transaction.clone(); // Clone for logging - // Cache the current context before transaction execution + // Capture lightweight pre-transaction account states for logging + let pre_states = crate::logging::capture_account_states(&self.context, &transaction); + // Clone context for test assertions (get_pre_transaction_account needs full account data) let pre_context_snapshot = self.context.clone(); // Simulate the transaction. Currently, in banks-client/server, only @@ -405,6 +407,10 @@ impl LightProgramTest { let transaction_result = self.context.send_transaction(transaction.clone()); let slot = self.context.get_sysvar::().slot; + // Capture post-transaction account states for logging + let post_states = + crate::logging::capture_account_states(&self.context, &transaction_for_logging); + // Always try enhanced logging for file output (both success and failure) if crate::logging::should_use_enhanced_logging(&self.config) { crate::logging::log_transaction_enhanced( @@ -414,6 +420,8 @@ impl LightProgramTest { &signature, slot, self.transaction_counter, + Some(&pre_states), + Some(&post_states), ); } @@ -429,6 +437,8 @@ impl LightProgramTest { slot, self.transaction_counter, true, // Enable console output + Some(&pre_states), + Some(&post_states), ); } RpcError::TransactionError(x.err.clone()) @@ -446,6 +456,8 @@ impl LightProgramTest { slot, self.transaction_counter, true, // Enable console output + Some(&pre_states), + Some(&post_states), ); // if self.config.log_light_protocol_events { diff --git a/sdk-tests/csdk-anchor-full-derived-test/tests/instruction_decoder_test.rs b/sdk-tests/csdk-anchor-full-derived-test/tests/instruction_decoder_test.rs index 2d4de9ffc1..ec7e04d6b6 100644 --- a/sdk-tests/csdk-anchor-full-derived-test/tests/instruction_decoder_test.rs +++ b/sdk-tests/csdk-anchor-full-derived-test/tests/instruction_decoder_test.rs @@ -225,7 +225,7 @@ fn test_enhanced_decoder_params_decoding() { "Fields should contain decoded params" ); - // The params field has empty name for inline display + // The params field contains the decoded parameter data let params_field = decoded.fields.first(); assert!(params_field.is_some(), "Should have a params field"); @@ -440,8 +440,8 @@ fn test_attribute_macro_decoder_with_instruction_data() { // The attribute macro decodes params - requires Debug impl (compile error if missing) assert_eq!(decoded.fields.len(), 1, "Should have 1 field (params)"); - // Field name is empty for inline params display - assert_eq!(decoded.fields[0].name, ""); + // Field name is now the actual parameter name + assert_eq!(decoded.fields[0].name, "params"); // Verify params contain expected values let params_value = &decoded.fields[0].value;