Skip to content

fix(lib): handle CPI-only accounts in inner instruction reconstruction#384

Open
wellingtonmb88 wants to merge 1 commit intosolana-foundation:mainfrom
wellingtonmb88:fix/cpi-inner-instruction-reconstruction
Open

fix(lib): handle CPI-only accounts in inner instruction reconstruction#384
wellingtonmb88 wants to merge 1 commit intosolana-foundation:mainfrom
wellingtonmb88:fix/cpi-inner-instruction-reconstruction

Conversation

@wellingtonmb88
Copy link

Summary

Fix CPI inner instruction reconstruction to support DeFi protocols (Kamino, Jupiter) where inner instructions reference accounts not present in the outer transaction's account keys.

Changes

  • CPI account key extensionreconstruct_instruction_from_ui now accepts &mut Vec<Pubkey> and dynamically extends the keys vector when it encounters unknown program IDs or PDA accounts from CPI inner instructions, instead of silently dropping them
  • Burn instruction flexibility — Handle burn instructions reconstructed from parsed RPC data that may have only 2 accounts (source, authority) when Solana RPC omits the mint field in non-checked burn JSON
  • versioned_transaction.rs — Clone and extend account keys during inner instruction processing so uncompile_instructions can resolve CPI-only indices
  • kora.toml production defaults — Add DeFi programs (Kamino Lending/Vaults/Farming, Jupiter Aggregator/DCA, Memo), switch to Jupiter price source, set fee payer policy to restrictive (false) defaults, add mainnet/devnet USDC and SOL tokens, block permanent_delegate mint extension

Testing

  • Added test_reconstruct_partially_decoded_with_cpi_pda_accounts — verifies unknown PDA accounts are extended into the keys vector with correct synthetic indices
  • Added test_reconstruct_partially_decoded_with_unknown_program_id — verifies unknown CPI program IDs are also extended correctly
  • Updated existing burn reconstruction tests to expect mint index in accounts when present in parsed data
  • Updated all reconstruct_instruction_from_ui call sites in tests to use &mut signature

Extend account keys dynamically when reconstructing inner instructions
from parsed RPC simulation data. CPI PDA signers and invoked programs
not present in the outer transaction's account keys were silently
dropped, causing validation failures for DeFi protocols like Kamino.

Also fixes burn instruction parsing when Solana RPC omits the mint
field from non-checked burn JSON.
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 12, 2026

Greptile Summary

This PR fixes inner-instruction reconstruction for DeFi protocols (Kamino, Jupiter) where CPI calls reference accounts — program IDs and PDA authorities — that are absent from the outer transaction's account-key list. The approach is sound: reconstruct_instruction_from_ui is widened to accept &mut Vec<Pubkey> and lazily appends unknown keys so uncompile_instructions can always resolve every index; versioned_transaction.rs correctly isolates the mutation to a local clone so self.all_account_keys stays pristine. A companion fix corrects a pre-existing bug where non-checked Burn instruction reconstruction omitted the mint account.

Key changes:

  • reconstruct_instruction_from_ui now extends the account-keys vector on-the-fly for CPI-only program IDs and PDA accounts in PartiallyDecoded inner instructions.
  • versioned_transaction.rs clones all_account_keys before the inner-instruction loop and uses the extended copy for uncompile_instructions, ensuring CPI-only indices resolve correctly.
  • Non-checked Burn reconstruction gains a three-way branch: burnChecked (always 3 accounts), burn with mint present in parsed data (3 accounts), and burn without mint (2-account fallback). This fixes a pre-existing omission where the mint was silently dropped.
  • parse_token_instructions for both spl-token and spl-token-2022 handles the 2-account burn layout without panicking.

Issues found:

  • Both new index-assignment sites (all_account_keys.len() as u8) perform a truncating cast with no overflow guard. If the accumulated vec somehow reaches 256 entries the cast wraps to 0, silently mapping the new account to the same index as the first transaction account. A u8::try_from(...).ok()? guard (with a logged error) would be safer.
  • The inline comments on the burn reconstruction branches invert the actual observed behaviour: the standard Solana parser (parse_instruction::parse) does include mint for non-checked Burn, making the else if branch the primary path and the else the edge-case fallback.

Confidence Score: 4/5

  • Safe to merge with minor follow-up recommended for overflow hardening.
  • The architectural approach is correct and well-tested: mutation is isolated to a local clone, deduplication across instruction calls is handled implicitly by the shared mutable vec, and the burn three-way branch is logically sound. The only substantive concern is the two len() as u8 truncating casts introduced in this PR — they are unlikely to trigger in practice given Solana's account limits, but they represent a latent correctness risk worth addressing before the code reaches high-traffic production paths.
  • Pay close attention to crates/lib/src/transaction/instruction_util.rs lines 422 and 442 (the u8 cast sites).

Important Files Changed

Filename Overview
crates/lib/src/transaction/instruction_util.rs Core logic change: reconstruct_instruction_from_ui now takes &mut Vec<Pubkey> and dynamically appends CPI-only program IDs and PDA accounts instead of silently dropping them. Burn instruction reconstruction gains a three-way branch (burnChecked / burn-with-mint / burn-without-mint) fixing a pre-existing bug that omitted the mint from non-checked burns. Two instances of all_account_keys.len() as u8 lack overflow guards and will silently corrupt the index mapping if the vec reaches 256 entries.
crates/lib/src/transaction/versioned_transaction.rs Minimal, targeted change: clones self.all_account_keys into a local extended_account_keys before the inner-instruction loop so CPI-only accounts discovered during reconstruction are accumulated and passed to uncompile_instructions, without mutating the transaction's own key list.

Sequence Diagram

sequenceDiagram
    participant Caller as get_inner_instructions()
    participant VTR as VersionedTransactionResolved
    participant IxUtils
    participant RPC

    Caller->>VTR: get_inner_instructions(rpc_client)
    VTR->>RPC: simulate_transaction (inner_instructions=true)
    RPC-->>VTR: UiInnerInstructions[]

    VTR->>VTR: extended_account_keys = self.all_account_keys.clone()

    loop for each inner instruction
        alt UiInstruction::Compiled
            VTR->>VTR: push CompiledInstruction directly
        else UiInstruction::Parsed (PartiallyDecoded)
            VTR->>IxUtils: reconstruct_instruction_from_ui(&mut extended_account_keys)
            alt program_id in extended_account_keys
                IxUtils->>IxUtils: use existing index
            else program_id is CPI-only
                IxUtils->>IxUtils: append program_id → new index
            end
            loop for each account in partial.accounts
                alt account in extended_account_keys
                    IxUtils->>IxUtils: use existing index
                else CPI PDA not in outer keys
                    IxUtils->>IxUtils: append PDA → new index
                end
            end
            IxUtils-->>VTR: CompiledInstruction
        end
    end

    VTR->>IxUtils: uncompile_instructions(&extended_account_keys)
    IxUtils-->>Caller: Vec<Instruction>
Loading

Last reviewed commit: 3385321

Comment on lines +418 to +426
let program_idx = if let Some(&idx) = account_keys_hashmap.get(&program_id)
{
idx
} else {
let idx = all_account_keys.len() as u8;
all_account_keys.push(program_id);
account_keys_hashmap.insert(program_id, idx);
idx
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential u8 index overflow when extending account keys

all_account_keys.len() as u8 performs a silent truncating cast. If the accumulated extended_account_keys vec ever reaches 256 entries (the Solana account-index hard limit), this cast wraps to 0, assigning the new CPI account the same index as the first account in the transaction. Any subsequent uncompile_instructions call would then resolve this index to the wrong Pubkey.

While practically unlikely — Solana transactions rarely have anywhere near 256 unique accounts — the correct pattern is to fail loudly rather than produce a silently corrupted index:

Suggested change
let program_idx = if let Some(&idx) = account_keys_hashmap.get(&program_id)
{
idx
} else {
let idx = all_account_keys.len() as u8;
all_account_keys.push(program_id);
account_keys_hashmap.insert(program_id, idx);
idx
};
let idx = match u8::try_from(all_account_keys.len()) {
Ok(i) => i,
Err(_) => {
log::error!("Account key index overflow: too many CPI accounts");
return None;
}
};

The same issue exists on line 442 inside the per-account loop and should be fixed with the same pattern.

Comment on lines +438 to +445
// CPI PDA signers (e.g. Kamino lending authority)
// are not in the outer transaction's account keys.
// Extend the keys vec so uncompile_instructions
// can resolve the index back to the pubkey.
let idx = all_account_keys.len() as u8;
all_account_keys.push(pubkey);
account_keys_hashmap.insert(pubkey, idx);
idx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same u8 overflow risk inside per-account loop

Same truncating cast pattern as the program-ID block above (line ~422). For completeness, this path also needs the u8::try_from guard so an overflowing CPI account early-exits instead of silently mapping to index 0.

Suggested change
// CPI PDA signers (e.g. Kamino lending authority)
// are not in the outer transaction's account keys.
// Extend the keys vec so uncompile_instructions
// can resolve the index back to the pubkey.
let idx = all_account_keys.len() as u8;
all_account_keys.push(pubkey);
account_keys_hashmap.insert(pubkey, idx);
idx
let idx = match u8::try_from(all_account_keys.len()) {
Ok(i) => i,
Err(_) => {
log::error!("Account key index overflow: too many CPI accounts");
return None;
}
};

Comment on lines +886 to 893
} else if let Ok(mint) = Self::get_field_as_pubkey(info, PARSED_DATA_FIELD_MINT) {
// Some RPC providers may include mint for non-checked burn
let mint_idx = Self::get_account_index(account_keys_hashmap, &mint)?;
vec![account_idx, mint_idx, authority_idx]
} else {
// Standard Solana RPC doesn't include mint in parsed "burn"
// (non-checked) JSON, so we reconstruct with 2 accounts
vec![account_idx, authority_idx]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading comment — standard Solana parser includes mint for non-checked burn

The comment says "Standard Solana RPC doesn't include mint in parsed 'burn' (non-checked) JSON", but the updated tests (test_reconstruct_spl_token_burn_instruction, test_reconstruct_token2022_burn_instruction) prove the opposite: parse_instruction::parse() for a non-checked Burn produces a parsed JSON that does include the mint field, so the else if branch is the primary path, not the fallback.

The comment on the else branch is also misleading — the no-mint case is an edge case (e.g. an older or unusual RPC), not the standard behaviour. Consider swapping the framing:

Suggested change
} else if let Ok(mint) = Self::get_field_as_pubkey(info, PARSED_DATA_FIELD_MINT) {
// Some RPC providers may include mint for non-checked burn
let mint_idx = Self::get_account_index(account_keys_hashmap, &mint)?;
vec![account_idx, mint_idx, authority_idx]
} else {
// Standard Solana RPC doesn't include mint in parsed "burn"
// (non-checked) JSON, so we reconstruct with 2 accounts
vec![account_idx, authority_idx]
} else if let Ok(mint) = Self::get_field_as_pubkey(info, PARSED_DATA_FIELD_MINT) {
// Solana's parser includes the mint for non-checked burn; reconstruct
// with the standard 3-account layout [source, mint, authority].
let mint_idx = Self::get_account_index(account_keys_hashmap, &mint)?;
vec![account_idx, mint_idx, authority_idx]
} else {
// Defensive fallback: some RPC providers may omit mint in the
// parsed "burn" JSON; reconstruct with 2 accounts [source, authority].
vec![account_idx, authority_idx]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant