feat(solana-indexer) PR 6: decoder account-key resolution#4589
Conversation
|
Claude finished @squadgazzz's task in 3m 21s —— View job PR Review — decoder account-key resolutionThe 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 Three points posted inline (none blocking):
|
There was a problem hiding this comment.
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.
|
Claude finished @squadgazzz's task in 2m 23s —— View job PR Review — decoder account-key resolution (PR 6)
I re-reviewed against the current branch head ( The three earlier review points are now addressed:
Correctness spot-checks (all good):
Remaining notes (non-blocking, already acknowledged in-code):
No blocking issues — LGTM. Clean, well-documented, and the tests target the branches that would regress silently. |
There was a problem hiding this comment.
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.
| settlement_program: &Pubkey, | ||
| solflow_program: &Pubkey, | ||
| ) -> Option<ResolvedInstruction> { | ||
| let program_id = *account_keys.get(self.program_id_index as usize)?; |
There was a problem hiding this comment.
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>?
There was a problem hiding this comment.
In this PR the flat list only serves the program lookup, that is true. But the upcoming decode PR would resolve every instruction's account indices against it (many lookups), and it's exactly the account_keys TxContext holds. A get_account(index) method skips the allocation but re-derives which range each index falls in on every call, and we'd build the list in the decode PR anyway.
| Some(ResolvedInstruction { | ||
| program_id, | ||
| data: Bytes::copy_from_slice(self.data), | ||
| accounts: self.account_indices.to_vec(), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Nothing clones them yet. Probably, worth revisiting if the decode path shows real clone pressure.
| settlement_program: &Pubkey, | ||
| solflow_program: &Pubkey, | ||
| ) -> Option<ResolvedInstruction> { | ||
| let program_id = *account_keys.get(self.program_id_index as usize)?; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Right, it can't happen for a real tx. I assume, an out-of-range index fails account loading before execution, so the tx is never included in a block and never reaches the indexer, same as the reverting-tx case you mentioned. Keeping ? only because stream data is untrusted and a panic here is worse than a silent drop.
jmg-duarte
left a comment
There was a problem hiding this comment.
LGTM
One comment for discussion, not blocking
| /// field keeps the raw account-list indices, resolving them to pubkeys is | ||
| /// left to the decode step. Returns `None` if the program is untracked or | ||
| /// its index is out of range. | ||
| fn resolve_protocol_instruction( |
There was a problem hiding this comment.
With this in mind, should we (not now) make ResolvedInstruction carry typestate-like semantics?
ResolvedInstruction -> FullyResolvedInstruction
Under that decode step?
There was a problem hiding this comment.
Makes sense, but I'd keep it out of this PR. ResolvedInstruction lives only in decoder.rs and account indices resolve on demand during decode, so nothing reads a pubkey before it's resolved. The typestate would guard a mistake the current flow can't make, at the cost of a second struct and an eager conversion. The doc note already mentions the two phases. Worth revisiting if it starts crossing module boundaries or decode grows multiple stages.
| /// Top-level instruction index. For a CPI, the top-level instruction it | ||
| /// runs under. | ||
| instruction_index: u32, | ||
| /// Position within the top-level instruction's inner list, or `None` for a | ||
| /// top-level instruction. | ||
| inner_index: Option<u32>, |
There was a problem hiding this comment.
I’m not sure how this representation captures the full depth of a Solana instruction. Solana instructions can nest up to 4 levels deep, so instruction_index + inner_index only covers the top level and the first CPI.
I was thinking we could use a Vec<u8> where the length is the instruction depth and each element is the position at that level. That would replace the two fields and handle all nesting layers uniformly.
I get that this probably carries over from the earlier types I authored in crates/solana-indexer/src/types/tx.rs#L21-L36, but it feels worth revisiting.
Also, the smart contracts team is leaning toward blocking CPI at the program level, but I don’t think that affects the indexer. It still needs to capture whatever it sees, so just mentioning it for context.
There was a problem hiding this comment.
Good call, done in ce8e40a. Switched to Vec<u8>. Depth is clamped to 4, since that's Solana's cap (stack height 5) and stack_height is untrusted stream data, so a corrupt value can't force a huge allocation.
| .flat_map(|group| group.instructions.iter().enumerate()) | ||
| .map(move |(offset, ix)| RawInstruction { | ||
| instruction_index: index, | ||
| inner_index: Some(offset as u32), | ||
| program_id_index: ix.program_id_index, | ||
| account_indices: &ix.accounts, | ||
| data: &ix.data, | ||
| }); |
There was a problem hiding this comment.
We need to consider the InnerInstruction.stack_height field here:
TransactionStatusMeta
└─ inner_instructions: Vec<InnerInstructions> ← one group per top-level ix
└─ InnerInstructions { index: u32, ... } ← which top-level ix this group belongs to
instructions: Vec<InnerInstruction> } ← flat list of CPIs at all depths under it
└─ InnerInstruction { ..., stack_height } ← the only depth signal
We can consider fixing this in future PRs (before launch, naturally).
There was a problem hiding this comment.
Done in ce8e40a. relevant_instructions now reads stack_height off each InnerInstruction and rebuilds the per-level path from it (2 is a direct CPI, 3 a CPI that one made, and so on).
tilacog
left a comment
There was a problem hiding this comment.
Forgot to approve, since the inner ix issue can be fixed later.
MartinquaXD
left a comment
There was a problem hiding this comment.
my comments got addressed
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 consumeInnerInstructionfromwirerun()staysunimplemented!(). Instruction-data decoding and the persist path arrive in PR 7/8. No dependency on the settlementinterfacecrate: this step is generic Solana transaction processing.How to test
New unit tests.