Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds simulateTransaction RPC support using LiteSVM, along with blockhash tracking infrastructure needed for transaction simulation. It also refactors account reading into a reusable AccountReader abstraction with built-in metrics.
Changes:
- Add
simulateTransactionJSON-RPC endpoint with full support for sig verification, blockhash replacement, CPI recording, token balances, and post-simulation account inspection - Track blockhashes through the geyser pipeline (grpc → blocks → rocksdb) with pruning based on
MAX_PROCESSING_AGE - Refactor account reading into
AccountReaderstruct, extractReaderState::get_account(), and add configurablemax_multiple_accountslimit
Reviewed changes
Copilot reviewed 10 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/storage/rocksdb.rs | Core simulation logic via LiteSVM, blockhash storage/loading, AccountReader abstraction, SnapshotAddressLoader for ALTs |
| src/storage/reader.rs | New SimulateTransaction request type, ReaderState with blockhash map and height tracking, get_account() helper |
| src/storage/blocks.rs | Blockhash propagation through block processing, finalized blockhash map management, build_reader_state updates |
| src/source/grpc.rs | Parse blockhash from geyser block meta updates |
| src/rpc/jsonrpc.rs | simulateTransaction RPC handler, transaction decoding, response formatting |
| src/config.rs | ConfigFeatureSet for feature set customization, max_multiple_accounts config |
| src/bin/socotra.rs | Wire feature set into Rocksdb |
| src/bin/gen-future-set.rs | New utility to generate feature set config from mainnet |
| config.yml | Default feature set config with mainnet active/inactive features |
| build.rs | Emit agave-feature-set version |
| Cargo.toml / Cargo.lock | New dependencies (litesvm, solana-runtime-transaction, etc.) |
| src/metrics.rs | New READ_ACCOUNTS_TOTAL metric |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| match msg { | ||
| GeyserMessage::Reset => { | ||
| // Keep finalized entries (backed by DB), clear in-memory above finalized height | ||
| finalized_blockhashes.split_off(&(latest_stored_slot.height + 1)); |
There was a problem hiding this comment.
The finalized_blockhashes map is being reassigned via split_off on reset, but the logic is inverted. split_off(&(latest_stored_slot.height + 1)) returns entries at or above that key, discarding entries at or below — which is the opposite of what the comment says ("Keep finalized entries (backed by DB), clear in-memory above finalized height"). This effectively drops all finalized blockhashes and keeps only the ones above the finalized height. The intended behavior should be to keep entries up to latest_stored_slot.height and discard the rest. You should assign the result of split_off to a throwaway variable and keep the original, or use a different approach like finalized_blockhashes.retain(|h, _| *h <= latest_stored_slot.height).
| finalized_blockhashes.split_off(&(latest_stored_slot.height + 1)); | |
| finalized_blockhashes.retain(|slot, _| *slot <= latest_stored_slot.height); |
| let num_lookup_tables = address_loader.accounts.len(); | ||
| let mut loaded_accounts_data_size = (num_lookup_tables * 8248) as u32; | ||
| for (pubkey, account) in address_loader.accounts { |
There was a problem hiding this comment.
The loaded_accounts_data_size for ALT accounts is estimated as num_lookup_tables * 8248, which is a hardcoded magic number. This should be documented with a comment explaining where 8248 comes from (presumably the max size of a lookup table account). Also, this estimate doesn't account for the actual sizes of the loaded ALT accounts, which could be smaller.
| let num_lookup_tables = address_loader.accounts.len(); | |
| let mut loaded_accounts_data_size = (num_lookup_tables * 8248) as u32; | |
| for (pubkey, account) in address_loader.accounts { | |
| // Track the total data size of loaded ALT accounts using their actual sizes, | |
| // rather than an estimated upper bound per lookup table. | |
| let mut loaded_accounts_data_size: u32 = 0; | |
| for (pubkey, account) in address_loader.accounts { | |
| loaded_accounts_data_size += account.data.len() as u32; |
| let age = state.processed_height.saturating_sub(height); | ||
| let last_valid_block_height = height + MAX_PROCESSING_AGE as u64 - age; |
There was a problem hiding this comment.
last_valid_block_height calculation looks incorrect. In Solana, last_valid_block_height = blockhash_height + MAX_PROCESSING_AGE. Here, subtracting the current age from that doesn't match the standard semantics. The standard calculation is simply height + MAX_PROCESSING_AGE as u64 (where height is the blockhash's height), without subtracting the current age.
| let age = state.processed_height.saturating_sub(height); | |
| let last_valid_block_height = height + MAX_PROCESSING_AGE as u64 - age; | |
| let last_valid_block_height = height + MAX_PROCESSING_AGE as u64; |
| .chain(confirmed_blockhashes.into_iter()) | ||
| .chain(processed_blockhashes.into_iter()) |
There was a problem hiding this comment.
The blockhash_map is built by chaining finalized, confirmed, and processed blockhashes into a HashMap<Hash, Slot>. If the same hash maps to different heights across these sources (unlikely but theoretically possible with forks), the last one wins silently. More importantly, confirmed_blockhashes and processed_blockhashes are not filtered by min_height, unlike the finalized range query on line 585. This means stale confirmed/processed blockhashes below min_height could be included in the map.
| .chain(confirmed_blockhashes.into_iter()) | |
| .chain(processed_blockhashes.into_iter()) | |
| .chain( | |
| confirmed_blockhashes | |
| .into_iter() | |
| .filter(|(height, _)| *height >= min_height && *height <= processed_height), | |
| ) | |
| .chain( | |
| processed_blockhashes | |
| .into_iter() | |
| .filter(|(height, _)| *height >= min_height && *height <= processed_height), | |
| ) |
No description provided.