Many Solana workloads need to know what an account looked like at a previous point in time: portfolio analytics, TVL history, vault accounting, oracle audits, treasury reporting, ML feature stores. Standard Solana JSON-RPC does not expose a "read this account as of slot N" primitive. This page covers the two patterns Alchemy supports today for reconstructing historical account state, how to combine them, and how to land at common cadences like hourly snapshots.
getAccountInfo returns the current state of an account. The minContextSlot parameter is a freshness floor (the read must be evaluated at a slot greater than or equal to minContextSlot), not a historical lookup. Solana validators do not retain prior versions of an account's data once the account is updated, so no JSON-RPC method can answer "what did this account hold at slot N" directly.
What Solana does retain is the full transaction and block history. Alchemy's archival infrastructure exposes that history through getTransaction, getBlock, and getSignaturesForAddress, which means you can reconstruct historical account state by:
- Capturing it forward as the chain advances, persisting periodic snapshots to your own store.
- Reconstructing it backward by replaying the transactions that wrote to the account.
Most production indexers combine both: stream forward from "now" while backfilling history for the period before the stream started.
Yellowstone gRPC is the recommended path for ongoing state capture. A single subscription receives every account update for the addresses or programs you care about, with the slot number attached to each update, and persists at minimal latency.
- Open a Yellowstone gRPC subscription with an account filter (
accountsin theSubscribeRequest). - On every
SubscribeUpdateAccountyour handler receives, write a row containing(pubkey, slot, write_version, data, lamports, owner, txn_signature)to your store. - Index the resulting table by
(pubkey, slot, write_version)so you can query the latest version at or before any target slot.
A single Solana slot can contain multiple transactions that write to the same account. The write_version field is a monotonically increasing counter that disambiguates those intra-slot updates: the final state for a slot is the row with the highest write_version at that slot. Always tie-break by (slot, write_version) rather than slot alone.
To produce hourly snapshots, you do not need a separate sampling job. Solana blocks land at roughly 400 ms, so an active account may have many writes per hour and a quiet account may have none. Both cases are handled the same way: a query for "the state of this account at hour H" returns the row with the largest (slot, write_version) pair where slot is less than or equal to the slot corresponding to the timestamp at the end of hour H. In SQL that is ORDER BY slot DESC, write_version DESC LIMIT 1. Use getBlockTime or getBlocks to map wall-clock timestamps to slots.
The Yellowstone account filter supports three styles, in order of selectivity:
- Specific addresses (
account): pass a list of pubkeys to watch. Best when you know the accounts upfront. - Program owner (
owner): receive every account owned by a given program. Use this when the account set is dynamic (for example, all vault accounts under a single program). - Memcmp / data-size / lamports /
token_account_state(filters): narrow further by byte patterns, account discriminator, or balance ranges. Combine withownerto watch a specific subset of a program's accounts.
If you need to enumerate the initial set of accounts before subscribing (for example, all program-derived accounts under a vault program), use getProgramAccounts paginated via Alchemy's AccountsDB Infrastructure. The pageKey and order parameters let you scan large account sets without timing out.
use anyhow::Result;
use futures::{sink::SinkExt, stream::StreamExt};
use std::collections::HashMap;
use yellowstone_grpc_client::{ClientTlsConfig, GeyserGrpcClient};
use yellowstone_grpc_proto::geyser::{
CommitmentLevel, SubscribeRequest, SubscribeRequestFilterAccounts,
subscribe_update::UpdateOneof,
};
#[tokio::main]
async fn main() -> Result<()> {
let endpoint = "https://solana-mainnet.g.alchemy.com";
let x_token = "YOUR_ALCHEMY_API_KEY";
let mut client = GeyserGrpcClient::build_from_shared(endpoint)?
.tls_config(ClientTlsConfig::new().with_native_roots())?
.x_token(Some(x_token))?
.connect()
.await?;
let (mut tx, mut stream) = client.subscribe().await?;
// Subscribe to a specific set of account pubkeys
let mut accounts = HashMap::new();
accounts.insert(
"accounts_to_snapshot".to_string(),
SubscribeRequestFilterAccounts {
account: vec![
"AccountPubkey1...".to_string(),
"AccountPubkey2...".to_string(),
],
owner: vec![],
filters: vec![],
nonempty_txn_signature: Some(true),
},
);
tx.send(SubscribeRequest {
accounts,
commitment: Some(CommitmentLevel::Confirmed as i32),
..Default::default()
})
.await?;
while let Some(Ok(msg)) = stream.next().await {
if let Some(UpdateOneof::Account(update)) = msg.update_oneof {
if let Some(info) = update.account {
// Persist this snapshot. Index by (pubkey, slot, write_version).
println!(
"slot={} pubkey={} lamports={} data_len={}",
update.slot,
bs58::encode(&info.pubkey).into_string(),
info.lamports,
info.data.len()
);
}
}
}
Ok(())
}For client setup, authentication details, and language samples in TypeScript and Go, see the Yellowstone gRPC Quickstart.
Yellowstone supports replaying historical updates by setting from_slot on the SubscribeRequest. The replay window is up to 6000 slots (~40 minutes) of history. Persist the slot of the last update you successfully wrote, and on reconnect resubscribe with from_slot set to that slot. If your downtime exceeds the replay window, fall back to the backfill workflow below for the gap.
Snapshotting forward only captures state from the moment your subscription starts. For periods before that, the available primitive is the transaction history exposed through getTransaction. This workflow is best understood by what each getTransaction response actually contains and what it does not.
For each transaction, getTransaction returns:
meta.preBalancesandmeta.postBalances: SOL lamport balances per account (indexed againsttransaction.message.accountKeys).meta.preTokenBalancesandmeta.postTokenBalances: SPL token balances per account.meta.innerInstructions: CPIs invoked by the top-level instructions.meta.logMessages: program log output.transaction.message.instructionsandtransaction.message.accountKeys: the instructions and the accounts they touched.
What getTransaction does not return: pre or post account data blobs. There is no preAccountData / postAccountData field. That is the central constraint on this workflow.
Transaction replay can reliably reconstruct three things: SOL lamport balances (from pre/post balances), SPL token balances (from pre/post token balances), and events emitted via program logs (Anchor emit! macros, custom msg! lines).
It cannot reconstruct arbitrary program account data blobs from getTransaction alone. To rebuild full account data for historical periods, you need either (a) a starting data snapshot from before the period of interest, plus program-specific decoding of each touching instruction to derive the new state, or (b) the program emitting comprehensive event logs that cover every state mutation. If neither applies, the supported path is to start workflow A as early as possible and accept that history before the subscription start is not recoverable from transactions alone.
- Call
getSignaturesForAddressfor the target account, paging through history withbeforeanduntilcursors. Returns signatures plus block times. - For each signature, call
getTransactionand locate the target account's index intransaction.message.accountKeys. - Read
meta.postBalances[i](andmeta.postTokenBalancesfor token accounts) for the state immediately after this transaction's writes. Persist a row keyed by(pubkey, slot, signature)so you can later answer "balance at hour H" as the row with the largest slot less than or equal toblock_at(H).
meta.preBalances and meta.postBalances are consistent across adjacent transactions: the preBalances[i] of the transaction at slot T+1 equals the postBalances[i] of the most recent earlier transaction that touched account i. Use the post-side of each transaction as the canonical value for its slot.
Alchemy's Solana archival surface is optimized for this kind of historical scan. Per the Built for Solana release, getTransaction is up to 20x faster than other providers on historical calls, and getSignaturesForAddress supports recency-first ordering so you can walk from the present backward without scanning from genesis.
For lending position values, vault share prices, oracle values, market state, and any other field that lives inside a program account's data blob, workflow B is not sufficient on its own (see the warning above). The supported path is:
- Enumerate the account set with paginated
getProgramAccountscalls using Alchemy's AccountsDB Infrastructure (pageKey+order). This gives you every account and its currentdata. - Start a Yellowstone gRPC subscription (workflow A) filtered by program owner. From that point forward, every state change is captured with the full new
databytes. - Treat the moment the subscription starts as your "history begins here" marker. Reads at any slot at or after that marker resolve against the snapshot table; reads before it are not supported by this workflow.
The earlier you start workflow A, the smaller the unsupported window. If a backfill before the subscription start is required, the practical options are to bootstrap from an external historical-snapshot source for the program or to model the program's instructions program-side and replay them against a starting snapshot. Both are out of scope for this guide.
This example walks one account's signature history and persists post-balance per transaction, which is what workflow B reliably supports.
import { Connection, PublicKey } from "@solana/web3.js";
const connection = new Connection(
"https://solana-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_API_KEY",
"confirmed"
);
type BalanceRow = {
pubkey: string;
slot: number;
signature: string;
lamports: number;
};
async function backfillLamportHistory(
address: string,
untilSlot: number
): Promise<BalanceRow[]> {
const pubkey = new PublicKey(address);
const rows: BalanceRow[] = [];
let before: string | undefined = undefined;
while (true) {
const sigs = await connection.getSignaturesForAddress(pubkey, {
before,
limit: 1000,
});
if (sigs.length === 0) break;
for (const sig of sigs) {
if (sig.slot < untilSlot) return rows;
const tx = await connection.getTransaction(sig.signature, {
maxSupportedTransactionVersion: 0,
});
if (!tx || !tx.meta) continue;
// Locate the target account's index in the message account keys.
const keys = tx.transaction.message.getAccountKeys({
accountKeysFromLookups: tx.meta.loadedAddresses,
});
const index = keys
.keySegments()
.flat()
.findIndex((k) => k.equals(pubkey));
if (index === -1) continue;
// Post-balance is the canonical lamport value after this transaction's writes.
rows.push({
pubkey: address,
slot: sig.slot,
signature: sig.signature,
lamports: tx.meta.postBalances[index],
});
}
before = sigs[sigs.length - 1].signature;
}
return rows;
}For SPL token balances, swap meta.postBalances[index] for the matching entry in meta.postTokenBalances (which is indexed by accountIndex and includes mint, owner, and uiTokenAmount). For program log events, parse meta.logMessages against your program's known event format.
The two workflows above give you per-update granularity. Picking a coarser cadence (hourly, daily) is a query-time concern, not an ingest-time one. Two common approaches:
- Store every update, derive snapshots at query time. Recommended when the account set is small or update volume is moderate. Lets you change cadence later without re-ingesting.
- Materialize fixed-cadence rollups. Run a periodic job that, for each tracked account, writes the latest value at or before each cadence boundary into a separate table. Reduces query cost when you only ever read at fixed intervals.
To map wall-clock timestamps to slots for the boundaries, call getBlockTime on a candidate slot, or use getBlocks to find the slot range that bounds a target timestamp.
| Aspect | Yellowstone gRPC (Workflow A) | Transaction replay (Workflow B) |
|---|---|---|
| Time horizon | From subscription start, forward | From any point in history, backward |
| Latency to new data | Real-time (sub-second) | As fast as you can page archival history |
| Filter granularity | Address, owner, memcmp, data size, lamports | Per-address (via getSignaturesForAddress) |
| Data captured | Full account bytes (data, lamports, owner) | SOL balance, SPL token balance, program log events only (no account data blobs) |
| Replay after gap | from_slot, up to 6000 slots (~40 min) | Unbounded, limited only by archival depth |
| Best for | Ongoing capture of a known account or program set | Backfilling SOL/token balance history; not sufficient for arbitrary program state |
| Plan requirement | PAYG or Enterprise | Available on all paid plans |
Combine the two: turn on the gRPC subscription first, persist its slot as your "snapshot start", then run workflow B in parallel to backfill SOL and SPL token balance history before that slot. For full account data history before the subscription start, see the warning under Workflow B: start workflow A as early as possible, or source a starting snapshot from an external historical-data product.
- AccountsDB Infrastructure — paginated
getProgramAccountsandgetTokenLargestAccountsfor enumerating large account sets. - Yellowstone gRPC Overview and Subscribe to Accounts — full filter reference and protobuf definitions.
- Solana API FAQ — Historical Data (Archival) — the full set of archival JSON-RPC methods.
- Built for Solana and How Alchemy Built the Fastest Archival Methods on Solana — background on the archival stack powering the methods above.