light_instruction_decoder/programs/
ctoken.rs

1//! Compressed Token (CToken) program instruction decoder.
2//!
3//! This module provides a macro-derived decoder for the Light Token (CToken) program,
4//! which uses non-sequential 1-byte discriminators for Pinocchio instructions.
5//!
6//! Note: This decoder only handles Pinocchio (1-byte) instructions.
7//! Anchor (8-byte) instructions are not decoded by this macro-derived decoder.
8//!
9//! ## Instruction Data Formats
10//!
11//! Most CToken instructions have optional max_top_up suffix:
12//! - Transfer, MintTo, Burn: 8 bytes (amount) or 10 bytes (amount + max_top_up)
13//! - TransferChecked, MintToChecked, BurnChecked: 9 bytes (amount + decimals) or 11 bytes (+ max_top_up)
14//! - Approve: 8 bytes (amount) or 10 bytes (amount + max_top_up)
15//! - Revoke: 0 bytes or 2 bytes (max_top_up)
16
17// Allow the macro-generated code to reference types from this crate
18extern crate self as light_instruction_decoder;
19
20use light_instruction_decoder_derive::InstructionDecoder;
21use light_token_interface::instructions::{
22    mint_action::MintActionCompressedInstructionData,
23    transfer2::CompressedTokenInstructionDataTransfer2,
24};
25use solana_instruction::AccountMeta;
26
27/// Calculate the packed accounts start position for Transfer2.
28///
29/// The start position depends on the instruction path and optional accounts:
30/// - Path A (compressions-only): start = 2 (cpi_authority_pda, fee_payer)
31/// - Path B (CPI context write): start = 4 (light_system, fee_payer, cpi_authority, cpi_context)
32/// - Path C (full transfer): start = 7 + optional accounts
33///   - +1 for sol_pool_pda (when lamports imbalance)
34///   - +1 for sol_decompression_recipient (when decompressing SOL)
35///   - +1 for cpi_context_account (when cpi_context present but not writing)
36#[cfg(not(target_os = "solana"))]
37fn calculate_packed_accounts_start(data: &CompressedTokenInstructionDataTransfer2) -> usize {
38    let no_compressed_accounts = data.in_token_data.is_empty() && data.out_token_data.is_empty();
39    let cpi_context_write_required = data
40        .cpi_context
41        .as_ref()
42        .map(|ctx| ctx.set_context || ctx.first_set_context)
43        .unwrap_or(false);
44
45    if no_compressed_accounts {
46        // Path A: compressions-only
47        // [cpi_authority_pda, fee_payer, ...packed_accounts]
48        2
49    } else if cpi_context_write_required {
50        // Path B: CPI context write
51        // [light_system_program, fee_payer, cpi_authority_pda, cpi_context]
52        // No packed accounts in this path (return 4 to indicate end of accounts)
53        4
54    } else {
55        // Path C: Full transfer
56        // Base: [light_system_program, fee_payer, cpi_authority_pda, registered_program_pda,
57        //        account_compression_authority, account_compression_program, system_program]
58        let mut start = 7;
59
60        // Optional: sol_pool_pda (when lamports imbalance exists)
61        let in_lamports: u64 = data
62            .in_lamports
63            .as_ref()
64            .map(|v| v.iter().sum())
65            .unwrap_or(0);
66        let out_lamports: u64 = data
67            .out_lamports
68            .as_ref()
69            .map(|v| v.iter().sum())
70            .unwrap_or(0);
71        if in_lamports != out_lamports {
72            start += 1; // sol_pool_pda
73        }
74
75        // Optional: sol_decompression_recipient (when decompressing SOL)
76        if out_lamports > in_lamports {
77            start += 1; // sol_decompression_recipient
78        }
79
80        // Optional: cpi_context_account (when cpi_context present but not writing)
81        if data.cpi_context.is_some() {
82            start += 1; // cpi_context_account
83        }
84
85        start
86    }
87}
88
89/// Format Transfer2 instruction data with resolved pubkeys.
90///
91/// This formatter provides a human-readable view of the transfer instruction,
92/// resolving account indices to actual pubkeys from the instruction accounts.
93///
94/// Mode detection:
95/// - CPI context mode (cpi_context.set_context || first_set_context): Shows raw indices
96/// - Direct mode: Resolves packed account indices using dynamically calculated start position
97#[cfg(not(target_os = "solana"))]
98pub fn format_transfer2(
99    data: &CompressedTokenInstructionDataTransfer2,
100    accounts: &[AccountMeta],
101) -> String {
102    use std::fmt::Write;
103    let mut output = String::new();
104
105    // Determine if packed accounts are in CPI context write mode
106    let cpi_context_write_mode = data
107        .cpi_context
108        .as_ref()
109        .map(|ctx| ctx.set_context || ctx.first_set_context)
110        .unwrap_or(false);
111
112    // Calculate where packed accounts start based on instruction path
113    let packed_accounts_start = calculate_packed_accounts_start(data);
114
115    // Helper to resolve account index
116    let resolve = |index: u8| -> String {
117        if cpi_context_write_mode {
118            // All accounts are in CPI context
119            format!("packed[{}]", index)
120        } else {
121            accounts
122                .get(packed_accounts_start + index as usize)
123                .map(|a| a.pubkey.to_string())
124                .unwrap_or_else(|| format!("OUT_OF_BOUNDS({})", index))
125        }
126    };
127
128    // Header with mode indicator
129    if cpi_context_write_mode {
130        let _ = writeln!(
131            output,
132            "[CPI Context Write Mode - packed accounts in CPI context]"
133        );
134    }
135
136    // Top-level fields
137    let _ = writeln!(output, "output_queue: {}", resolve(data.output_queue));
138    if data.max_top_up > 0 {
139        let _ = writeln!(output, "max_top_up: {}", data.max_top_up);
140    }
141    if data.with_transaction_hash {
142        let _ = writeln!(output, "with_transaction_hash: true");
143    }
144
145    // Input tokens
146    let _ = writeln!(output, "Input Tokens ({}):", data.in_token_data.len());
147    for (i, token) in data.in_token_data.iter().enumerate() {
148        let _ = writeln!(output, "  [{}]", i);
149        let _ = writeln!(output, "    owner: {}", resolve(token.owner));
150        let _ = writeln!(output, "    mint: {}", resolve(token.mint));
151        let _ = writeln!(output, "    amount: {}", token.amount);
152        if token.has_delegate {
153            let _ = writeln!(output, "    delegate: {}", resolve(token.delegate));
154        }
155        let _ = writeln!(output, "    version: {}", token.version);
156        // Merkle context
157        let _ = writeln!(
158            output,
159            "    merkle_tree: {}",
160            resolve(token.merkle_context.merkle_tree_pubkey_index)
161        );
162        let _ = writeln!(
163            output,
164            "    queue: {}",
165            resolve(token.merkle_context.queue_pubkey_index)
166        );
167        let _ = writeln!(
168            output,
169            "    leaf_index: {}",
170            token.merkle_context.leaf_index
171        );
172        let _ = writeln!(output, "    root_index: {}", token.root_index);
173    }
174
175    // Output tokens
176    let _ = writeln!(output, "Output Tokens ({}):", data.out_token_data.len());
177    for (i, token) in data.out_token_data.iter().enumerate() {
178        let _ = writeln!(output, "  [{}]", i);
179        let _ = writeln!(output, "    owner: {}", resolve(token.owner));
180        let _ = writeln!(output, "    mint: {}", resolve(token.mint));
181        let _ = writeln!(output, "    amount: {}", token.amount);
182        if token.has_delegate {
183            let _ = writeln!(output, "    delegate: {}", resolve(token.delegate));
184        }
185        let _ = writeln!(output, "    version: {}", token.version);
186    }
187
188    // Compressions if present
189    if let Some(compressions) = &data.compressions {
190        let _ = writeln!(output, "Compressions ({}):", compressions.len());
191        for (i, comp) in compressions.iter().enumerate() {
192            let _ = writeln!(output, "  [{}]", i);
193            let _ = writeln!(output, "    mode: {:?}", comp.mode);
194            let _ = writeln!(output, "    amount: {}", comp.amount);
195            let _ = writeln!(output, "    mint: {}", resolve(comp.mint));
196            let _ = writeln!(
197                output,
198                "    source_or_recipient: {}",
199                resolve(comp.source_or_recipient)
200            );
201            let _ = writeln!(output, "    authority: {}", resolve(comp.authority));
202        }
203    }
204
205    output
206}
207
208/// Resolve Transfer2 account names dynamically based on instruction data.
209///
210/// Transfer2 has a dynamic account layout with three mutually exclusive paths:
211///
212/// **Path A: Compressions-only** (`in_token_data.is_empty() && out_token_data.is_empty()`)
213/// - Account 0: `compressions_only_cpi_authority_pda`
214/// - Account 1: `compressions_only_fee_payer`
215/// - Remaining: packed_accounts
216///
217/// **Path B: CPI Context Write** (`cpi_context.set_context || cpi_context.first_set_context`)
218/// - Account 0: `light_system_program`
219/// - Account 1: `fee_payer`
220/// - Account 2: `cpi_authority_pda`
221/// - Account 3: `cpi_context`
222/// - No packed accounts
223///
224/// **Path C: Full Transfer** (default)
225/// - 7 fixed accounts: light_system_program, fee_payer, cpi_authority_pda, registered_program_pda,
226///   account_compression_authority, account_compression_program, system_program
227/// - Optional: sol_pool_pda (when lamports imbalance exists)
228/// - Optional: sol_decompression_recipient (when decompressing SOL)
229/// - Optional: cpi_context_account (when cpi_context is present but not writing)
230/// - Remaining: packed_accounts
231#[cfg(not(target_os = "solana"))]
232pub fn resolve_transfer2_account_names(
233    data: &CompressedTokenInstructionDataTransfer2,
234    accounts: &[AccountMeta],
235) -> Vec<String> {
236    use std::collections::HashMap;
237
238    let mut names = Vec::with_capacity(accounts.len());
239    let mut idx = 0;
240    let mut known_pubkeys: HashMap<[u8; 32], String> = HashMap::new();
241
242    let mut add_name = |name: &str,
243                        accounts: &[AccountMeta],
244                        idx: &mut usize,
245                        known: &mut HashMap<[u8; 32], String>| {
246        if *idx < accounts.len() {
247            names.push(name.to_string());
248            known.insert(accounts[*idx].pubkey.to_bytes(), name.to_string());
249            *idx += 1;
250            true
251        } else {
252            false
253        }
254    };
255
256    // Determine path from instruction data
257    let no_compressed_accounts = data.in_token_data.is_empty() && data.out_token_data.is_empty();
258    let cpi_context_write_required = data
259        .cpi_context
260        .as_ref()
261        .map(|ctx| ctx.set_context || ctx.first_set_context)
262        .unwrap_or(false);
263
264    if no_compressed_accounts {
265        // Path A: Compressions-only
266        add_name(
267            "compressions_only_cpi_authority_pda",
268            accounts,
269            &mut idx,
270            &mut known_pubkeys,
271        );
272        add_name(
273            "compressions_only_fee_payer",
274            accounts,
275            &mut idx,
276            &mut known_pubkeys,
277        );
278    } else if cpi_context_write_required {
279        // Path B: CPI Context Write
280        add_name(
281            "light_system_program",
282            accounts,
283            &mut idx,
284            &mut known_pubkeys,
285        );
286        add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys);
287        add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys);
288        add_name("cpi_context", accounts, &mut idx, &mut known_pubkeys);
289        // No packed accounts in this path
290        return names;
291    } else {
292        // Path C: Full Transfer
293        add_name(
294            "light_system_program",
295            accounts,
296            &mut idx,
297            &mut known_pubkeys,
298        );
299        add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys);
300        add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys);
301        add_name(
302            "registered_program_pda",
303            accounts,
304            &mut idx,
305            &mut known_pubkeys,
306        );
307        add_name(
308            "account_compression_authority",
309            accounts,
310            &mut idx,
311            &mut known_pubkeys,
312        );
313        add_name(
314            "account_compression_program",
315            accounts,
316            &mut idx,
317            &mut known_pubkeys,
318        );
319        add_name("system_program", accounts, &mut idx, &mut known_pubkeys);
320
321        // Optional accounts - determine from instruction data
322        // sol_pool_pda: when lamports imbalance exists
323        let in_lamports: u64 = data
324            .in_lamports
325            .as_ref()
326            .map(|v| v.iter().sum())
327            .unwrap_or(0);
328        let out_lamports: u64 = data
329            .out_lamports
330            .as_ref()
331            .map(|v| v.iter().sum())
332            .unwrap_or(0);
333        let with_sol_pool = in_lamports != out_lamports;
334        if with_sol_pool {
335            add_name("sol_pool_pda", accounts, &mut idx, &mut known_pubkeys);
336        }
337
338        // sol_decompression_recipient: when decompressing SOL (out > in)
339        let with_sol_decompression = out_lamports > in_lamports;
340        if with_sol_decompression {
341            add_name(
342                "sol_decompression_recipient",
343                accounts,
344                &mut idx,
345                &mut known_pubkeys,
346            );
347        }
348
349        // cpi_context_account: add placeholder - formatter will use transaction-level name
350        if data.cpi_context.is_some() {
351            names.push(String::new()); // Empty = use formatter's KNOWN_ACCOUNTS lookup
352            idx += 1;
353        }
354    }
355
356    // Build a map of packed account index -> role name from instruction data
357    let mut packed_roles: HashMap<u8, String> = HashMap::new();
358    let mut owner_count = 0u8;
359    let mut mint_count = 0u8;
360    let mut delegate_count = 0u8;
361    let mut in_merkle_count = 0u8;
362    let mut in_queue_count = 0u8;
363    let mut compress_mint_count = 0u8;
364    let mut compress_source_count = 0u8;
365    let mut compress_auth_count = 0u8;
366
367    // output_queue
368    packed_roles
369        .entry(data.output_queue)
370        .or_insert_with(|| "output_queue".to_string());
371
372    // Input token data
373    for token in data.in_token_data.iter() {
374        packed_roles.entry(token.owner).or_insert_with(|| {
375            let name = if owner_count == 0 {
376                "owner".to_string()
377            } else {
378                format!("owner_{}", owner_count)
379            };
380            owner_count = owner_count.saturating_add(1);
381            name
382        });
383        packed_roles.entry(token.mint).or_insert_with(|| {
384            let name = if mint_count == 0 {
385                "mint".to_string()
386            } else {
387                format!("mint_{}", mint_count)
388            };
389            mint_count = mint_count.saturating_add(1);
390            name
391        });
392        if token.has_delegate {
393            packed_roles.entry(token.delegate).or_insert_with(|| {
394                let name = if delegate_count == 0 {
395                    "delegate".to_string()
396                } else {
397                    format!("delegate_{}", delegate_count)
398                };
399                delegate_count = delegate_count.saturating_add(1);
400                name
401            });
402        }
403        packed_roles
404            .entry(token.merkle_context.merkle_tree_pubkey_index)
405            .or_insert_with(|| {
406                let name = if in_merkle_count == 0 {
407                    "in_merkle_tree".to_string()
408                } else {
409                    format!("in_merkle_tree_{}", in_merkle_count)
410                };
411                in_merkle_count = in_merkle_count.saturating_add(1);
412                name
413            });
414        packed_roles
415            .entry(token.merkle_context.queue_pubkey_index)
416            .or_insert_with(|| {
417                let name = if in_queue_count == 0 {
418                    "in_nullifier_queue".to_string()
419                } else {
420                    format!("in_nullifier_queue_{}", in_queue_count)
421                };
422                in_queue_count = in_queue_count.saturating_add(1);
423                name
424            });
425    }
426
427    // Output token data
428    for token in data.out_token_data.iter() {
429        packed_roles.entry(token.owner).or_insert_with(|| {
430            let name = if owner_count == 0 {
431                "owner".to_string()
432            } else {
433                format!("owner_{}", owner_count)
434            };
435            owner_count = owner_count.saturating_add(1);
436            name
437        });
438        packed_roles.entry(token.mint).or_insert_with(|| {
439            let name = if mint_count == 0 {
440                "mint".to_string()
441            } else {
442                format!("mint_{}", mint_count)
443            };
444            mint_count = mint_count.saturating_add(1);
445            name
446        });
447        if token.has_delegate {
448            packed_roles.entry(token.delegate).or_insert_with(|| {
449                let name = if delegate_count == 0 {
450                    "delegate".to_string()
451                } else {
452                    format!("delegate_{}", delegate_count)
453                };
454                delegate_count = delegate_count.saturating_add(1);
455                name
456            });
457        }
458    }
459
460    // Compressions
461    if let Some(compressions) = &data.compressions {
462        for comp in compressions.iter() {
463            packed_roles.entry(comp.mint).or_insert_with(|| {
464                let name = if compress_mint_count == 0 {
465                    "compress_mint".to_string()
466                } else {
467                    format!("compress_mint_{}", compress_mint_count)
468                };
469                compress_mint_count = compress_mint_count.saturating_add(1);
470                name
471            });
472            packed_roles
473                .entry(comp.source_or_recipient)
474                .or_insert_with(|| {
475                    let name = if compress_source_count == 0 {
476                        "compress_source".to_string()
477                    } else {
478                        format!("compress_source_{}", compress_source_count)
479                    };
480                    compress_source_count = compress_source_count.saturating_add(1);
481                    name
482                });
483            packed_roles.entry(comp.authority).or_insert_with(|| {
484                let name = if compress_auth_count == 0 {
485                    "compress_authority".to_string()
486                } else {
487                    format!("compress_authority_{}", compress_auth_count)
488                };
489                compress_auth_count = compress_auth_count.saturating_add(1);
490                name
491            });
492        }
493    }
494
495    // Remaining accounts are packed - prioritize role names from instruction data
496    let mut packed_idx: u8 = 0;
497    while idx < accounts.len() {
498        let pubkey_bytes = accounts[idx].pubkey.to_bytes();
499
500        // First check if we have a semantic role from instruction data
501        if let Some(role) = packed_roles.get(&packed_idx) {
502            // Use the role name, and note if it matches a known account
503            if let Some(known_name) = known_pubkeys.get(&pubkey_bytes) {
504                names.push(format!("{} (={})", role, known_name));
505            } else {
506                names.push(role.clone());
507                known_pubkeys.insert(pubkey_bytes, role.clone());
508            }
509        } else if let Some(known_name) = known_pubkeys.get(&pubkey_bytes) {
510            // No role, but matches a known account
511            names.push(format!("packed_{} (={})", packed_idx, known_name));
512        } else {
513            // Unknown packed account
514            names.push(format!("packed_account_{}", packed_idx));
515        }
516        idx += 1;
517        packed_idx = packed_idx.saturating_add(1);
518    }
519
520    names
521}
522
523/// Resolve MintAction account names dynamically based on instruction data.
524///
525/// MintAction has a dynamic account layout that depends on:
526/// - `create_mint`: whether creating a new compressed mint
527/// - `cpi_context`: whether using CPI context mode
528/// - `mint` (None = decompressed): whether mint is decompressed to CMint
529/// - `actions`: may contain DecompressMint, CompressAndCloseMint, MintToCompressed
530///
531/// Account layout (see plan for full details):
532/// 1. Fixed: light_system_program, [mint_signer if create_mint], authority
533/// 2. CPI Context Mode: fee_payer, cpi_authority_pda, cpi_context
534/// 3. Executing Mode:
535///    - Optional: compressible_config, cmint, rent_sponsor
536///    - LightSystemAccounts (6 required)
537///    - Optional: cpi_context_account
538///    - Tree accounts
539///    - Packed accounts (identified by pubkey when possible)
540#[cfg(not(target_os = "solana"))]
541pub fn resolve_mint_action_account_names(
542    data: &MintActionCompressedInstructionData,
543    accounts: &[AccountMeta],
544) -> Vec<String> {
545    use std::collections::HashMap;
546
547    use light_token_interface::instructions::mint_action::Action;
548
549    let mut names = Vec::with_capacity(accounts.len());
550    let mut idx = 0;
551    // Track known pubkeys -> name for identifying packed accounts
552    let mut known_pubkeys: HashMap<[u8; 32], String> = HashMap::new();
553
554    // Helper to add name and track pubkey
555    let mut add_name = |name: &str,
556                        accounts: &[AccountMeta],
557                        idx: &mut usize,
558                        known: &mut HashMap<[u8; 32], String>| {
559        if *idx < accounts.len() {
560            names.push(name.to_string());
561            known.insert(accounts[*idx].pubkey.to_bytes(), name.to_string());
562            *idx += 1;
563            true
564        } else {
565            false
566        }
567    };
568
569    // Index 0: light_system_program (always)
570    add_name(
571        "light_system_program",
572        accounts,
573        &mut idx,
574        &mut known_pubkeys,
575    );
576
577    // Index 1: mint_signer (optional - only if creating mint)
578    if data.create_mint.is_some() {
579        add_name("mint_signer", accounts, &mut idx, &mut known_pubkeys);
580    }
581
582    // Next: authority (always)
583    add_name("authority", accounts, &mut idx, &mut known_pubkeys);
584
585    // Determine flags from instruction data
586    let write_to_cpi_context = data
587        .cpi_context
588        .as_ref()
589        .map(|ctx| ctx.first_set_context || ctx.set_context)
590        .unwrap_or(false);
591
592    if write_to_cpi_context {
593        // CPI Context Mode: CpiContextLightSystemAccounts (3 accounts)
594        add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys);
595        add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys);
596        add_name("cpi_context", accounts, &mut idx, &mut known_pubkeys);
597        // No more accounts in this mode
598    } else {
599        // Executing Mode
600        let has_decompress_mint_action = data
601            .actions
602            .iter()
603            .any(|action| matches!(action, Action::DecompressMint(_)));
604
605        let has_compress_and_close_cmint_action = data
606            .actions
607            .iter()
608            .any(|action| matches!(action, Action::CompressAndCloseMint(_)));
609
610        let needs_compressible_accounts =
611            has_decompress_mint_action || has_compress_and_close_cmint_action;
612
613        let cmint_decompressed = data.mint.is_none();
614        let needs_cmint_account =
615            cmint_decompressed || has_decompress_mint_action || has_compress_and_close_cmint_action;
616
617        // Optional: compressible_config
618        if needs_compressible_accounts {
619            add_name(
620                "compressible_config",
621                accounts,
622                &mut idx,
623                &mut known_pubkeys,
624            );
625        }
626
627        // Optional: cmint
628        if needs_cmint_account {
629            add_name("cmint", accounts, &mut idx, &mut known_pubkeys);
630        }
631
632        // Optional: rent_sponsor
633        if needs_compressible_accounts {
634            add_name("rent_sponsor", accounts, &mut idx, &mut known_pubkeys);
635        }
636
637        // LightSystemAccounts (6 required)
638        add_name("fee_payer", accounts, &mut idx, &mut known_pubkeys);
639        add_name("cpi_authority_pda", accounts, &mut idx, &mut known_pubkeys);
640        add_name(
641            "registered_program_pda",
642            accounts,
643            &mut idx,
644            &mut known_pubkeys,
645        );
646        add_name(
647            "account_compression_authority",
648            accounts,
649            &mut idx,
650            &mut known_pubkeys,
651        );
652        add_name(
653            "account_compression_program",
654            accounts,
655            &mut idx,
656            &mut known_pubkeys,
657        );
658        add_name("system_program", accounts, &mut idx, &mut known_pubkeys);
659
660        // Note: cpi_context_account and tree accounts are NOT named here -
661        // let the formatter use the transaction-level account names
662    }
663
664    names
665}
666
667/// Format MintAction instruction data with resolved pubkeys.
668///
669/// Calculate the packed accounts start position for MintAction.
670///
671/// MintAction has a simpler layout than Transfer2:
672/// - 6 fixed LightSystemAccounts: fee_payer, cpi_authority_pda, registered_program_pda,
673///   account_compression_authority, account_compression_program, system_program
674/// - Optional: cpi_context_account (when cpi_context is present but not writing)
675/// - Then: packed accounts
676#[cfg(not(target_os = "solana"))]
677fn calculate_mint_action_packed_accounts_start(
678    data: &MintActionCompressedInstructionData,
679) -> usize {
680    let cpi_context_write_mode = data
681        .cpi_context
682        .as_ref()
683        .map(|ctx| ctx.set_context || ctx.first_set_context)
684        .unwrap_or(false);
685
686    if cpi_context_write_mode {
687        // CPI context write mode: [fee_payer, cpi_authority_pda, cpi_context]
688        3
689    } else {
690        // Normal mode: 6 LightSystemAccounts + optional cpi_context_account
691        let mut start = 6;
692        if data.cpi_context.is_some() {
693            start += 1; // cpi_context_account
694        }
695        start
696    }
697}
698
699/// Format MintAction instruction data with resolved pubkeys.
700///
701/// This formatter provides a human-readable view of the mint action instruction,
702/// resolving account indices to actual pubkeys from the instruction accounts.
703///
704/// Mode detection:
705/// - CPI context write mode (cpi_context.set_context || first_set_context): Shows raw indices
706/// - Direct mode: Resolves packed account indices using dynamically calculated start position
707#[cfg(not(target_os = "solana"))]
708pub fn format_mint_action(
709    data: &MintActionCompressedInstructionData,
710    accounts: &[AccountMeta],
711) -> String {
712    use std::fmt::Write;
713
714    use light_token_interface::instructions::mint_action::Action;
715    let mut output = String::new();
716
717    // CPI context write mode: set_context OR first_set_context means packed accounts in CPI context
718    let cpi_context_write_mode = data
719        .cpi_context
720        .as_ref()
721        .map(|ctx| ctx.set_context || ctx.first_set_context)
722        .unwrap_or(false);
723
724    // Calculate where packed accounts start based on instruction configuration
725    let packed_accounts_start = calculate_mint_action_packed_accounts_start(data);
726
727    // Helper to resolve account index
728    let resolve = |index: u8| -> String {
729        if cpi_context_write_mode {
730            format!("packed[{}]", index)
731        } else {
732            accounts
733                .get(packed_accounts_start + index as usize)
734                .map(|a| a.pubkey.to_string())
735                .unwrap_or_else(|| format!("OUT_OF_BOUNDS({})", index))
736        }
737    };
738
739    // Header with mode indicator
740    if cpi_context_write_mode {
741        let _ = writeln!(
742            output,
743            "[CPI Context Write Mode - packed accounts in CPI context]"
744        );
745    }
746
747    // Top-level fields
748    if data.create_mint.is_some() {
749        let _ = writeln!(output, "create_mint: true");
750    } else {
751        let _ = writeln!(output, "leaf_index: {}", data.leaf_index);
752        if data.prove_by_index {
753            let _ = writeln!(output, "prove_by_index: true");
754        }
755    }
756    let _ = writeln!(output, "root_index: {}", data.root_index);
757    if data.max_top_up > 0 {
758        let _ = writeln!(output, "max_top_up: {}", data.max_top_up);
759    }
760
761    // Mint data summary (if present)
762    if let Some(mint) = &data.mint {
763        let _ = writeln!(output, "Mint:");
764        let _ = writeln!(output, "  supply: {}", mint.supply);
765        let _ = writeln!(output, "  decimals: {}", mint.decimals);
766        if let Some(auth) = &mint.mint_authority {
767            let _ = writeln!(
768                output,
769                "  mint_authority: {}",
770                bs58::encode(auth).into_string()
771            );
772        }
773        if let Some(auth) = &mint.freeze_authority {
774            let _ = writeln!(
775                output,
776                "  freeze_authority: {}",
777                bs58::encode(auth).into_string()
778            );
779        }
780        if let Some(exts) = &mint.extensions {
781            let _ = writeln!(output, "  extensions: {}", exts.len());
782        }
783    }
784
785    // Actions
786    let _ = writeln!(output, "Actions ({}):", data.actions.len());
787    for (i, action) in data.actions.iter().enumerate() {
788        match action {
789            Action::MintToCompressed(a) => {
790                let _ = writeln!(output, "  [{}] MintToCompressed:", i);
791                let _ = writeln!(output, "    version: {}", a.token_account_version);
792                for (j, r) in a.recipients.iter().enumerate() {
793                    let _ = writeln!(
794                        output,
795                        "    recipient[{}]: {} amount: {}",
796                        j,
797                        bs58::encode(&r.recipient).into_string(),
798                        r.amount
799                    );
800                }
801            }
802            Action::UpdateMintAuthority(a) => {
803                let authority_str = a
804                    .new_authority
805                    .as_ref()
806                    .map(|p| bs58::encode(p).into_string())
807                    .unwrap_or_else(|| "None".to_string());
808                let _ = writeln!(output, "  [{}] UpdateMintAuthority: {}", i, authority_str);
809            }
810            Action::UpdateFreezeAuthority(a) => {
811                let authority_str = a
812                    .new_authority
813                    .as_ref()
814                    .map(|p| bs58::encode(p).into_string())
815                    .unwrap_or_else(|| "None".to_string());
816                let _ = writeln!(output, "  [{}] UpdateFreezeAuthority: {}", i, authority_str);
817            }
818            Action::MintTo(a) => {
819                let _ = writeln!(
820                    output,
821                    "  [{}] MintTo: account: {}, amount: {}",
822                    i,
823                    resolve(a.account_index),
824                    a.amount
825                );
826            }
827            Action::UpdateMetadataField(a) => {
828                let field_name = match a.field_type {
829                    0 => "Name",
830                    1 => "Symbol",
831                    2 => "Uri",
832                    _ => "Custom",
833                };
834                let _ = writeln!(
835                    output,
836                    "  [{}] UpdateMetadataField: ext[{}] {} = {:?}",
837                    i,
838                    a.extension_index,
839                    field_name,
840                    String::from_utf8_lossy(&a.value)
841                );
842            }
843            Action::UpdateMetadataAuthority(a) => {
844                let _ = writeln!(
845                    output,
846                    "  [{}] UpdateMetadataAuthority: ext[{}] = {}",
847                    i,
848                    a.extension_index,
849                    bs58::encode(&a.new_authority).into_string()
850                );
851            }
852            Action::RemoveMetadataKey(a) => {
853                let _ = writeln!(
854                    output,
855                    "  [{}] RemoveMetadataKey: ext[{}] key={:?} idempotent={}",
856                    i,
857                    a.extension_index,
858                    String::from_utf8_lossy(&a.key),
859                    a.idempotent != 0
860                );
861            }
862            Action::DecompressMint(a) => {
863                let _ = writeln!(
864                    output,
865                    "  [{}] DecompressMint: rent_payment={} write_top_up={}",
866                    i, a.rent_payment, a.write_top_up
867                );
868            }
869            Action::CompressAndCloseMint(a) => {
870                let _ = writeln!(
871                    output,
872                    "  [{}] CompressAndCloseMint: idempotent={}",
873                    i,
874                    a.idempotent != 0
875                );
876            }
877        }
878    }
879
880    // CPI context details (if present)
881    if let Some(ctx) = &data.cpi_context {
882        let _ = writeln!(output, "CPI Context:");
883        let _ = writeln!(
884            output,
885            "  mode: {}",
886            if ctx.first_set_context {
887                "first_set_context"
888            } else if ctx.set_context {
889                "set_context"
890            } else {
891                "read"
892            }
893        );
894        let _ = writeln!(output, "  in_tree: packed[{}]", ctx.in_tree_index);
895        let _ = writeln!(output, "  in_queue: packed[{}]", ctx.in_queue_index);
896        let _ = writeln!(output, "  out_queue: packed[{}]", ctx.out_queue_index);
897        if ctx.token_out_queue_index > 0 {
898            let _ = writeln!(
899                output,
900                "  token_out_queue: packed[{}]",
901                ctx.token_out_queue_index
902            );
903        }
904        let _ = writeln!(
905            output,
906            "  address_tree: {}",
907            bs58::encode(&ctx.address_tree_pubkey).into_string()
908        );
909    }
910
911    output
912}
913
914/// Compressed Token (CToken) program instructions.
915///
916/// The CToken program uses non-sequential 1-byte discriminators.
917/// Each variant has an explicit #[discriminator = N] attribute.
918///
919/// Field definitions show the base required fields; max_top_up is optional.
920#[derive(InstructionDecoder)]
921#[instruction_decoder(
922    program_id = "cTokenmWW8bLPjZEBAUgYy3zKxQZW6VKi7bqNFEVv3m",
923    program_name = "Light Token",
924    discriminator_size = 1
925)]
926pub enum CTokenInstruction {
927    /// Transfer compressed tokens (discriminator 3)
928    /// Data: amount (u64) [+ max_top_up (u16)]
929    #[discriminator = 3]
930    #[instruction_decoder(account_names = ["source", "destination", "authority"])]
931    Transfer { amount: u64 },
932
933    /// Approve delegate for compressed tokens (discriminator 4)
934    /// Data: amount (u64) [+ max_top_up (u16)]
935    #[discriminator = 4]
936    #[instruction_decoder(account_names = ["source", "delegate", "owner"])]
937    Approve { amount: u64 },
938
939    /// Revoke delegate authority (discriminator 5)
940    /// Data: [max_top_up (u16)]
941    #[discriminator = 5]
942    #[instruction_decoder(account_names = ["source", "owner"])]
943    Revoke,
944
945    /// Mint compressed tokens to an account (discriminator 7)
946    /// Data: amount (u64) [+ max_top_up (u16)]
947    #[discriminator = 7]
948    #[instruction_decoder(account_names = ["cmint", "destination", "authority"])]
949    MintTo { amount: u64 },
950
951    /// Burn compressed tokens (discriminator 8)
952    /// Data: amount (u64) [+ max_top_up (u16)]
953    #[discriminator = 8]
954    #[instruction_decoder(account_names = ["source", "cmint", "authority"])]
955    Burn { amount: u64 },
956
957    /// Close a compressed token account (discriminator 9)
958    #[discriminator = 9]
959    #[instruction_decoder(account_names = ["account", "destination", "authority"])]
960    CloseTokenAccount,
961
962    /// Freeze a compressed token account (discriminator 10)
963    #[discriminator = 10]
964    #[instruction_decoder(account_names = ["account", "mint", "authority"])]
965    FreezeAccount,
966
967    /// Thaw a frozen compressed token account (discriminator 11)
968    #[discriminator = 11]
969    #[instruction_decoder(account_names = ["account", "mint", "authority"])]
970    ThawAccount,
971
972    /// Transfer compressed tokens with decimals check (discriminator 12)
973    /// Data: amount (u64) + decimals (u8) [+ max_top_up (u16)]
974    #[discriminator = 12]
975    #[instruction_decoder(account_names = ["source", "mint", "destination", "authority"])]
976    TransferChecked { amount: u64, decimals: u8 },
977
978    /// Mint compressed tokens with decimals check (discriminator 14)
979    /// Data: amount (u64) + decimals (u8) [+ max_top_up (u16)]
980    #[discriminator = 14]
981    #[instruction_decoder(account_names = ["cmint", "destination", "authority"])]
982    MintToChecked { amount: u64, decimals: u8 },
983
984    /// Burn compressed tokens with decimals check (discriminator 15)
985    /// Data: amount (u64) + decimals (u8) [+ max_top_up (u16)]
986    #[discriminator = 15]
987    #[instruction_decoder(account_names = ["source", "cmint", "authority"])]
988    BurnChecked { amount: u64, decimals: u8 },
989
990    /// Create a new compressed token account (discriminator 18)
991    #[discriminator = 18]
992    #[instruction_decoder(account_names = ["token_account", "mint", "payer", "config", "system_program", "rent_payer"])]
993    CreateTokenAccount,
994
995    /// Create an associated compressed token account (discriminator 100)
996    #[discriminator = 100]
997    #[instruction_decoder(account_names = ["owner", "mint", "fee_payer", "ata", "system_program", "config", "rent_payer"])]
998    CreateAssociatedTokenAccount,
999
1000    /// Transfer v2 with additional options (discriminator 101)
1001    /// Uses dynamic account names resolver because the account layout depends on instruction data.
1002    #[discriminator = 101]
1003    #[instruction_decoder(
1004        params = CompressedTokenInstructionDataTransfer2,
1005        account_names_resolver_from_params = crate::programs::ctoken::resolve_transfer2_account_names,
1006        pretty_formatter = crate::programs::ctoken::format_transfer2
1007    )]
1008    Transfer2,
1009
1010    /// Create associated token account idempotently (discriminator 102)
1011    #[discriminator = 102]
1012    #[instruction_decoder(account_names = ["owner", "mint", "fee_payer", "ata", "system_program", "config", "rent_payer"])]
1013    CreateAssociatedTokenAccountIdempotent,
1014
1015    /// Mint action for compressed tokens (discriminator 103)
1016    /// Uses dynamic account names resolver because the account layout depends on instruction data.
1017    #[discriminator = 103]
1018    #[instruction_decoder(
1019        params = MintActionCompressedInstructionData,
1020        account_names_resolver_from_params = crate::programs::ctoken::resolve_mint_action_account_names,
1021        pretty_formatter = crate::programs::ctoken::format_mint_action
1022    )]
1023    MintAction,
1024
1025    /// Claim compressed tokens (discriminator 104)
1026    #[discriminator = 104]
1027    #[instruction_decoder(account_names = ["forester", "ctoken_account", "rent_recipient", "config"])]
1028    Claim,
1029
1030    /// Withdraw from funding pool (discriminator 105)
1031    #[discriminator = 105]
1032    #[instruction_decoder(account_names = ["authority", "rent_recipient", "config", "destination"])]
1033    WithdrawFundingPool,
1034}