Skip to content

feat(solana-indexer): decoder account-key resolution (PR 6)#4589

Open
squadgazzz wants to merge 6 commits into
mainfrom
solana-indexer/PR6-decoder
Open

feat(solana-indexer): decoder account-key resolution (PR 6)#4589
squadgazzz wants to merge 6 commits into
mainfrom
solana-indexer/PR6-decoder

Conversation

@squadgazzz

Copy link
Copy Markdown
Contributor

Description

The decoder must find, in each streamed transaction, the instructions that call the settlement or SolFlow program, including CPIs made from another program. Solana names an instruction's program and accounts by index into the transaction's account list, and versioned transactions split that list across the static keys plus the address-lookup-table loaded writable and readonly addresses. This PR resolves those indices: rebuild the full account list, walk every instruction (top-level and inner), and keep the ones whose program resolves to a program we track.

Changes

  • build_account_keys: static keys, then ALT-loaded writable, then ALT-loaded readonly, concatenated in that fixed order (§6.3.1.a)
  • walk_instructions: top-level instructions followed by inner/CPI instructions (§6.3.1.b)
  • filter_relevant: resolve each instruction's program and accounts against the account list, keep only settlement/SolFlow, drop instructions with out-of-range index references (§6.3.1.c)
  • RelevantInstruction { program, accounts, data }: the resolved unit PR 7's per-program dispatch will consume
  • re-export InnerInstruction from wire

run() stays unimplemented!(). Instruction-data decoding and the persist path arrive in PR 7/8. No dependency on the settlement interface crate: this step is generic Solana transaction processing.

How to test

New unit tests.

@squadgazzz squadgazzz requested a review from a team as a code owner July 3, 2026 09:25
@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @squadgazzz's task in 3m 21s —— View job


PR Review — decoder account-key resolution

The core logic is correct: account-list reconstruction (static ⊕ ALT-writable ⊕ ALT-readonly) matches Solana's versioned-transaction ordering, the top-level→inner instruction walk covers CPIs, and filter_relevant correctly drops out-of-range indices via ?/get. Including failed transactions (no meta.err check) is consistent with the ingester's intentional failed: None filter. Nicely documented.

Three points posted inline (none blocking):

  1. Duplication with types/tx.rsRelevantInstruction/build_account_keys parallel the unused ResolvedInstruction/TxContext; RelevantInstruction also drops the ix_index/is_inner that ResolvedInstruction deliberately carries. Worth reconciling before PR 7 consumes it.
  2. Zero-pubkey fallback — the "can't match a tracked program ⇒ dropped" safety note only covers the program index; a malformed account key is silently kept as the zero pubkey. Defensive-only in practice.
  3. Test coverage — add negative tests (untracked program dropped, SolFlow matched, out-of-range index dropped) to exercise the new filter branches.

@squadgazzz squadgazzz marked this pull request as draft July 3, 2026 09:25

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements transaction instruction decoding and filtering logic for the Solana indexer. It introduces helper structures and functions to build full account lists (including address lookup tables), walk through both top-level and inner instructions, and filter instructions relevant to specific programs (settlement and SolFlow). Unit tests are also added to verify these decoding behaviors. No critical issues found.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread crates/solana-indexer/src/indexer/decoder.rs Outdated
Comment thread crates/solana-indexer/src/indexer/decoder.rs Outdated
Comment thread crates/solana-indexer/src/indexer/decoder/tests.rs Outdated
@squadgazzz squadgazzz marked this pull request as ready for review July 3, 2026 11:47
@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @squadgazzz's task in 2m 23s —— View job


PR Review — decoder account-key resolution (PR 6)

  • Read changed files and understand the decoder logic
  • Review against Solana versioned-tx semantics
  • Check whether prior review points were addressed
  • Post findings

I re-reviewed against the current branch head (77d7ccc), since the PR evolved after the first pass.

The three earlier review points are now addressed:

  1. Duplication / divergent typesRelevantInstruction was dropped; the decoder now produces the shared ResolvedInstruction from types/tx.rs. One representation, per-program dispatch in PR 7 won't be wired to the wrong one. ✅
  2. Lost position infoResolvedInstruction carries instruction_index + inner_index, and relevant_instructions threads both through (top-level index, None; CPI group.index, Some(offset)). The idempotency/ordering concern is resolved. ✅
  3. Negative testsunresolvable_programs_dropped_account_indices_carried_through now exercises out-of-range program index, wrong-length-key→zero-pubkey→untracked, and an out-of-range account index carried through without dropping the instruction. ✅

Correctness spot-checks (all good):

  • build_account_keys: static ⊕ ALT-writable ⊕ ALT-readonly matches Solana versioned-tx account resolution ordering; wrong-length keys collapse to the zero pubkey while preserving slot alignment (no off-by-one), and the zero pubkey can't equal a tracked program.
  • relevant_instructions walks inner instructions independently of whether their parent top-level instruction survived — so a settlement CPI under a dropped router call is still kept. Test 1 exercises exactly this, which is the important property.
  • Accounts are carried as raw indices (not resolved here), so the prior "malformed account key silently resolves to System Program" concern no longer applies within this PR — it's deferred to whoever resolves account indices in PR 7/8, and remains defensive-only given geyser always emits 32-byte keys.

Remaining notes (non-blocking, already acknowledged in-code):

  • The top-level-before-inner walk order isn't on-chain execution order — flagged by the existing TODO at decoder.rs:191. Fine to defer.
  • Failed transactions aren't filtered (no meta.err check), consistent with the ingester's intentional failed: None.

No blocking issues — LGTM. Clean, well-documented, and the tests target the branches that would regress silently.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements instruction decoding and filtering logic for the Solana indexer, adding helper functions to reconstruct account keys and extract relevant instructions for the settlement and SolFlow programs, along with corresponding unit tests. The review feedback correctly identifies that chaining top-level instructions before inner instructions violates the actual on-chain execution order, which could lead to state inconsistencies for downstream consumers. The reviewer provides actionable code suggestions to interleave these instructions in their exact execution sequence and update the unit tests accordingly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +151 to +197
fn relevant_instructions(
tx: &SubscribeUpdateTransactionInfo,
settlement_program: &Pubkey,
solflow_program: &Pubkey,
) -> Vec<ResolvedInstruction> {
let account_keys = build_account_keys(tx);
let top_level = tx
.transaction
.as_ref()
.and_then(|transaction| transaction.message.as_ref())
.map(|message| message.instructions.as_slice())
.unwrap_or_default()
.iter()
.enumerate()
.map(|(index, ix)| RawInstruction {
instruction_index: index as u32,
inner_index: None,
program_id_index: ix.program_id_index,
account_indices: &ix.accounts,
data: &ix.data,
});
let inner = tx
.meta
.as_ref()
.map(|meta| meta.inner_instructions.as_slice())
.unwrap_or_default()
.iter()
.flat_map(|group| {
group
.instructions
.iter()
.enumerate()
.map(move |(offset, ix)| RawInstruction {
instruction_index: group.index,
inner_index: Some(offset as u32),
program_id_index: ix.program_id_index,
account_indices: &ix.accounts,
data: &ix.data,
})
});
// TODO: top-level instructions come before inner ones here, which is not the
// on-chain execution order. Revisit if ordering across the two matters.
top_level
.chain(inner)
.filter_map(|raw| raw.resolve(&account_keys, settlement_program, solflow_program))
.collect()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The current implementation chains all top-level instructions before all inner instructions, which violates the actual on-chain execution order. For downstream consumers (like the dispatch and persist steps) that rely on causal ordering, this can lead to state inconsistencies or logic bugs. Interleave the top-level and inner instructions so they are returned in their exact execution sequence.

fn relevant_instructions(
    tx: &SubscribeUpdateTransactionInfo,
    settlement_program: &Pubkey,
    solflow_program: &Pubkey,
) -> Vec<ResolvedInstruction> {
    let account_keys = build_account_keys(tx);
    let instructions = tx
        .transaction
        .as_ref()
        .and_then(|transaction| transaction.message.as_ref())
        .map(|message| message.instructions.as_slice())
        .unwrap_or_default();
    let inner_groups = tx
        .meta
        .as_ref()
        .map(|meta| meta.inner_instructions.as_slice())
        .unwrap_or_default();

    let mut result = Vec::new();
    for (index, ix) in instructions.iter().enumerate() {
        let index_u32 = index as u32;
        let raw_top = RawInstruction {
            instruction_index: index_u32,
            inner_index: None,
            program_id_index: ix.program_id_index,
            account_indices: &ix.accounts,
            data: &ix.data,
        };
        if let Some(resolved) = raw_top.resolve(&account_keys, settlement_program, solflow_program) {
            result.push(resolved);
        }

        if let Some(group) = inner_groups.iter().find(|g| g.index == index_u32) {
            for (offset, inner_ix) in group.instructions.iter().enumerate() {
                let raw_inner = RawInstruction {
                    instruction_index: index_u32,
                    inner_index: Some(offset as u32),
                    program_id_index: inner_ix.program_id_index,
                    account_indices: &inner_ix.accounts,
                    data: &inner_ix.data,
                };
                if let Some(resolved) = raw_inner.resolve(&account_keys, settlement_program, solflow_program) {
                    result.push(resolved);
                }
            }
        }
    }
    result
}

Comment on lines +102 to +118
let relevant = relevant_instructions(&tx, &settlement, &solflow);

// router dropped, solflow (top-level) and settlement (CPI) kept, in order.
assert_eq!(relevant.len(), 2);

assert_eq!(relevant[0].program_id, solflow);
assert_eq!(relevant[0].instruction_index, 1);
assert_eq!(relevant[0].inner_index, None);
assert_eq!(relevant[0].accounts, vec![1, 4]);
assert_eq!(relevant[0].data, Bytes::from(vec![1, 2, 3]));

assert_eq!(relevant[1].program_id, settlement);
assert_eq!(relevant[1].instruction_index, 0);
assert_eq!(relevant[1].inner_index, Some(0));
assert_eq!(relevant[1].accounts, vec![1]);
assert_eq!(relevant[1].data, Bytes::from(vec![7]));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Update the test assertions to reflect the correct interleaved execution order of instructions (CPI under top-level instruction 0 should be resolved before top-level instruction 1).

Suggested change
let relevant = relevant_instructions(&tx, &settlement, &solflow);
// router dropped, solflow (top-level) and settlement (CPI) kept, in order.
assert_eq!(relevant.len(), 2);
assert_eq!(relevant[0].program_id, solflow);
assert_eq!(relevant[0].instruction_index, 1);
assert_eq!(relevant[0].inner_index, None);
assert_eq!(relevant[0].accounts, vec![1, 4]);
assert_eq!(relevant[0].data, Bytes::from(vec![1, 2, 3]));
assert_eq!(relevant[1].program_id, settlement);
assert_eq!(relevant[1].instruction_index, 0);
assert_eq!(relevant[1].inner_index, Some(0));
assert_eq!(relevant[1].accounts, vec![1]);
assert_eq!(relevant[1].data, Bytes::from(vec![7]));
}
let relevant = relevant_instructions(&tx, &settlement, &solflow);
// router dropped, settlement (CPI) and solflow (top-level) kept, in actual execution order.
assert_eq!(relevant.len(), 2);
assert_eq!(relevant[0].program_id, settlement);
assert_eq!(relevant[0].instruction_index, 0);
assert_eq!(relevant[0].inner_index, Some(0));
assert_eq!(relevant[0].accounts, vec![1]);
assert_eq!(relevant[0].data, Bytes::from(vec![7]));
assert_eq!(relevant[1].program_id, solflow);
assert_eq!(relevant[1].instruction_index, 1);
assert_eq!(relevant[1].inner_index, None);
assert_eq!(relevant[1].accounts, vec![1, 4]);
assert_eq!(relevant[1].data, Bytes::from(vec![1, 2, 3]));
}
References
  1. When writing tests, if a specific trade is performed under controlled conditions, it is acceptable to directly access its elements (e.g., trades[0]) without additional checks, as the test setup guarantees its existence and properties.

settlement_program: &Pubkey,
solflow_program: &Pubkey,
) -> Option<ResolvedInstruction> {
let program_id = *account_keys.get(self.program_id_index as usize)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AFAICS accessing an account by index is the only reason why the accounts got flattened into a separate vector in the first place. Wouldn't it make more sense to just implement SubscribeUpdateTransactionInfo::get_account(u32) -> Option<&T>?

Some(ResolvedInstruction {
program_id,
data: Bytes::copy_from_slice(self.data),
accounts: self.account_indices.to_vec(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't spot this earlier but it might make sense to already store those accounts as Arc<[T]>. This assumes that this data profits from cheap clones and doesn't get modified often.

settlement_program: &Pubkey,
solflow_program: &Pubkey,
) -> Option<ResolvedInstruction> {
let program_id = *account_keys.get(self.program_id_index as usize)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it possible that we don't find the account of a given index? I imagine you can craft a transaction which just does not contain all the accounts it needs but would such a transaction actually reach the indexer here or would this be similar to a reverting ethereum transaction where reasoning about it doesn't make sense because it didn't even alter the chain state?

/// if that program is the settlement or SolFlow program. Account indices
/// are carried through unresolved. Returns `None` if the program is
/// untracked or its index is out of range.
fn resolve(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should the name perhaps convey that it drops particular instructions (e.g. resolve_protocol_instruction())?

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.

2 participants