Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1211549
Add support to the simulateTransaction method
ArthurPaivaT Jul 3, 2026
c4e1a9e
Merge branch 'main' of github.com:solana-rpc/cloudbreak into feat/sim…
ArthurPaivaT Jul 3, 2026
4f544d0
Enable feature sets on transaction simulation
ArthurPaivaT Jul 7, 2026
cf3f85c
Fix compute units on transaction simulation on feature gates
ArthurPaivaT Jul 7, 2026
4378787
Multiple minor fixes on simulateTransaction and integration tests
ArthurPaivaT Jul 10, 2026
a14454a
Cleanup and document simulateTransaction changes
ArthurPaivaT Jul 15, 2026
00193b7
Merge remote-tracking branch 'origin/main' into feat/get-supply
ArthurPaivaT Jul 16, 2026
f23dce0
Implement getSupply rpc method
ArthurPaivaT Jul 20, 2026
f781e6f
Merge branch 'main' of github.com:solana-rpc/cloudbreak into feat/get…
ArthurPaivaT Jul 20, 2026
ede109a
Implement getSupply supply tracker
ArthurPaivaT Jul 22, 2026
a582efa
Merge branch 'main' of github.com:solana-rpc/cloudbreak into feat/get…
ArthurPaivaT Jul 22, 2026
59f950e
cleanup getSupply code
ArthurPaivaT Jul 22, 2026
edb2af5
add getTokenSupply integration tests
ArthurPaivaT Jul 23, 2026
09f84f3
Fix getSupply mismatch on startup window
ArthurPaivaT Jul 23, 2026
83d6bbc
fix getSupply over count on gap fixes
ArthurPaivaT Jul 27, 2026
9044d3e
Merge branch 'main' of github.com:solana-rpc/cloudbreak into feat/get…
ArthurPaivaT Jul 27, 2026
804b3b7
add integration tests for get token largest accounts
ArthurPaivaT Jul 27, 2026
fb661d9
add getTokenSupply and getTokenLargestAccounts to the readme
ArthurPaivaT Jul 27, 2026
e3b71ad
Merge remote-tracking branch 'origin/main' into feat/get-supply
ArthurPaivaT Aug 24, 2026
6f7a963
Merge branch 'feat/get-supply' into test-gtla
ArthurPaivaT Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ The API server exposes the following JSON-RPC methods:
| `getMultipleAccounts` | Batched `getAccountInfo` for up to `[server].max-multiple-accounts` pubkeys per request (default `100`). Returns `null` per position for missing or indexer-filter-excluded accounts. |
| `getBalance` | Returns the lamport balance of an account. Returns `0` for missing or closed accounts (Agave-compatible). |
| `getTokenAccountBalance` | Returns the `UiTokenAmount` of an SPL Token / Token-2022 account, including mint-aware decimals and UI amounts. WSOL native mint is recognised explicitly. |
| `getTokenSupply` | Returns the total supply of an SPL Token / Token-2022 mint as a `UiTokenAmount`, with mint-aware decimals and UI amounts. |
| `getTokenLargestAccounts` | Returns the 20 largest accounts holding a given mint, sorted by amount descending, each with its address and `UiTokenAmount`. |
| `getSlot` | Returns the current slot at the requested commitment level. |
| `getHealth` | Returns the health status of the service. |
| `getVersion` | Returns the cluster version, Agave-compatible (`{"solana-core": "<string>"}`). See note below for the composite-string format Cloudbreak uses. |
| `getGenesisHash` | Returns the cluster genesis hash as a base58 string. |
| `getVoteAccounts` | Returns the cluster's `current` and `delinquent` vote accounts with per-voter activated stake, commission, last vote, and recent epoch credits. Optional; only available when the Vote and Stake programs are indexed. See [Vote Accounts](#vote-accounts-getvoteaccounts). |
| `simulateTransaction` | Simulates a transaction against indexed account state at the requested slot and returns logs, compute units, return data, requested account state, and balance changes. Supports `sigVerify`, `replaceRecentBlockhash`, `accounts`, `innerInstructions`, and `minContextSlot`. Optional; only available on a full unfiltered index. See [Simulate Transaction](#simulate-transaction-simulatetransaction). |
| `getSupply` | Returns the total and circulating supply in lamports plus the non-circulating account list. Optional; only available on a full unfiltered index with `supply-tracker-enabled = true` on the indexer. See [Supply](#supply-getsupply). |

Only **confirmed** and **finalized** commitment levels are fully supported. By default, requests with `processed` commitment return an error. This can be overridden via the `processed-commitment` configuration option (see [API Configuration](#api-server-cloudbreakapitoml)).

Expand Down Expand Up @@ -703,6 +707,18 @@ A snapshot captures stake at a single point in time, but activated stake drifts

The recomputer detects drift by comparing the total activated stake across runs. It recomputes every 60 s and treats an epoch as converged once the total is unchanged for three consecutive polls and the indexed `EpochRewards` sysvar reports that reward distribution has finished. If that sysvar is not indexed, it converges on stability alone after roughly 30 minutes. After converging it keeps recomputing on a slower 600 s heartbeat, returning to the 60 s cadence if a late reward write or a healed ingestion gap moves the total again.

### Simulate Transaction (`simulateTransaction`)

`simulateTransaction` is optional and only served on a **full, unfiltered index** (empty `[programs]` include and exclude lists). Simulation must be able to load any account a transaction touches — including program, lookup-table, sysvar, and feature-gate accounts — so a filtered index cannot serve it. Cloudbreak checks this at startup; if the index is filtered, the method returns a `simulateTransaction is not supported on this node` error.

The transaction is executed read-only against the indexed account state at the requested slot; nothing is committed. Cloudbreak reconstructs the cluster's actually-activated feature set at that slot from the on-chain feature accounts (rather than enabling all features), so compute-unit accounting and execution behaviour match mainnet. `replaceRecentBlockhash` substitutes the latest recorded blockhash before execution and reports it with its `lastValidBlockHeight`; `sigVerify` verifies signatures; `accounts` returns post-simulation state for the requested addresses; `innerInstructions` includes decoded inner instructions.

### Supply (`getSupply`)

`getSupply` is optional and only served on a **full, unfiltered index** (empty `[programs]` include and exclude lists), with `supply-tracker-enabled = true` set on the indexer. The indexer seeds the total supply from the snapshot bank's `capitalization` on every (re)start. While the snapshot is ingesting, accounts touched by live blocks are recorded in a small in-memory touched-account map; once the snapshot pass completes, those touches are reconciled against the startup-slot balances read back from the database and the tracker goes live. From then on it advances the total per confirmed block by reading each updated account's previous lamports from the database (owner-routed via the account owner map) and summing the resulting deltas — no full per-account lamports map is kept in memory. One `supply` row carrying the total and the non-circulating lamports is persisted per confirmed block (pruned to the last 128 slots), and a background recomputer derives the non-circulating set from Agave's pinned non-circulating account and withdraw-authority lists plus locked stake accounts, refreshing on the same converge-then-heartbeat cadence as the epoch-stakes recomputer.

The API serves from a polled cache. Requests return a node-unhealthy error until the bootstrap pass has completed and the non-circulating set has been computed. On a detected slot gap the tracker keeps applying block deltas but stops publishing totals until every gap slot has been repaired (requests fail as node-unhealthy in the meantime); a failed account write or a failed delta query marks it stale, and it stays fail-closed until the indexer restarts. For `finalized` commitment the response picks the newest persisted total at or below the finalized slot; `confirmed` returns the newest total. `context.slot` is the slot of the served row; the non-circulating account list may lag it by up to the recomputer heartbeat.

### Snapshot on Indexer Startup

When the `[snapshot]` section (with `[snapshot.tracker_endpoint]`) is present in the indexer config, the indexer queries the cluster tracker, downloads the latest covering snapshot pair from the source the tracker reports, and processes the archives before beginning gRPC streaming. This provides fast bootstrapping of account state. Omit the entire `[snapshot]` section to skip this.
Expand Down Expand Up @@ -903,13 +919,14 @@ cp example.cloudbreak.integration_tests.toml cloudbreak.integration_tests.toml
cargo run --bin integration_tests -- benchmark gpa
cargo run --bin integration_tests -- benchmark gtabo
cargo run --bin integration_tests -- benchmark gtabd
cargo run --bin integration_tests -- benchmark simulate-transaction
```

### Commands

| Command | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `benchmark <type>` | Main command. Load test with optional dual-endpoint comparison. Types: `gpa`, `gtabo`, `gtabd`, `gpa-token-owner`, `gpa-token-mint`. |
| `benchmark <type>` | Main command. Load test with optional dual-endpoint comparison. Types: `gpa`, `gtabo`, `gtabd`, `gpa-token-owner`, `gpa-token-mint`, `simulate-transaction`. |
| `compare` | (Legacy) Full pubkey set comparison between two endpoints with transaction history checks. |
| `get-slot` | (Legacy) Polls `getSlot` on rpc1 every 100ms. |

Expand Down
7 changes: 7 additions & 0 deletions crates/api/src/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::http::server::HttpHandlerResponse;
use crate::http::server::ResponseBody;
use crate::modules::bandwidth;
use crate::modules::cache::GpaProcessor;
use crate::modules::supply_cache::SharedSupplySnapshot;
use crate::modules::vote_accounts_cache::SharedStakesSnapshot;
use crate::error::RpcError;
use crate::query_tracker_client::QueryTrackerClient;
Expand Down Expand Up @@ -108,6 +109,8 @@ pub struct CloudbreakRpcState {
pub stakes_cache: SharedStakesSnapshot,
pub max_multiple_accounts: usize,
pub simulation_supported: bool,
pub supply_supported: bool,
pub supply_cache: SharedSupplySnapshot,
pub feature_set_cache: Arc<RwLock<Option<CachedFeatureSet>>>,
}

Expand All @@ -130,6 +133,8 @@ impl CloudbreakRpcState {
stakes_cache: SharedStakesSnapshot,
max_multiple_accounts: usize,
simulation_supported: bool,
supply_supported: bool,
supply_cache: SharedSupplySnapshot,
) -> Self {
Self {
database,
Expand All @@ -148,6 +153,8 @@ impl CloudbreakRpcState {
stakes_cache,
max_multiple_accounts,
simulation_supported,
supply_supported,
supply_cache,
feature_set_cache: Arc::new(RwLock::new(None)),
}
}
Expand Down
10 changes: 9 additions & 1 deletion crates/api/src/http/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ use hyper::body::Incoming;
use hyper::{Request, StatusCode};
use serde::Serialize;
use solana_commitment_config::CommitmentConfig;
use solana_rpc_client_api::config::{RpcAccountInfoConfig, RpcContextConfig, RpcSimulateTransactionConfig};
use solana_rpc_client_api::config::{
RpcAccountInfoConfig, RpcContextConfig, RpcSimulateTransactionConfig, RpcSupplyConfig,
};
use std::convert::Infallible;
use std::sync::Arc;
use tokio::time::Instant;
Expand Down Expand Up @@ -169,6 +171,12 @@ async fn process_single_request(
.await;
json_serialize_response(id, result, ctx).await
}
"getSupply" => {
let config: Option<RpcSupplyConfig> =
extract_param(&rpc_request.params, 0).ok().flatten();
let result = methods::get_supply::get_supply(state, config).await;
json_serialize_response(id, result, ctx).await
}
"getAccountInfo" => {
let start_time = Instant::now();

Expand Down
31 changes: 30 additions & 1 deletion crates/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use cloudbreak_core::{ApiConfig, EnvironmentInfo, TryLoadConfig};
use crate::{
http::{CloudbreakRpcState, HeaderKeys},
metrics::setup_metrics,
modules::{cache::GpaProcessor, vote_accounts_cache},
modules::{cache::GpaProcessor, supply_cache, vote_accounts_cache},
query_tracker_client::QueryTrackerClient,
};
use std::sync::RwLock;
Expand Down Expand Up @@ -127,6 +127,33 @@ pub async fn run(config: &str) -> cloudbreak_core::Result<()> {
);
}

let supply_supported = indexer_filter.supports_simulation();
let supply_cache: supply_cache::SharedSupplySnapshot = Arc::default();
if supply_supported {
match supply_cache::load_latest_supply(&database).await {
Ok(Some(snapshot)) => {
info!(
"Loaded initial supply snapshot ({} rows)",
snapshot.rows.len()
);
*supply_cache.write().unwrap() = Arc::new(snapshot);
}
Ok(None) => {
tracing::warn!(
"supply table is empty at startup; getSupply will fail until the \
indexer processes a snapshot"
);
}
Err(e) => {
tracing::error!("Failed to load initial supply snapshot: {:?}", e);
}
}
supply_cache::spawn_poll_task(database.clone(), supply_cache.clone());
info!("getSupply: supported=true (full unfiltered index)");
} else {
info!("getSupply: supported=false (indexer filter is not a full unfiltered index)");
}

let state = CloudbreakRpcState::new(
database,
queries_timeout,
Expand All @@ -144,6 +171,8 @@ pub async fn run(config: &str) -> cloudbreak_core::Result<()> {
stakes_cache,
max_multiple_accounts,
simulation_supported,
supply_supported,
supply_cache,
);

info!("Server is starting...");
Expand Down
104 changes: 104 additions & 0 deletions crates/api/src/methods/get_supply.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use solana_commitment_config::CommitmentLevel;
use solana_rpc_client_api::{
config::RpcSupplyConfig,
response::{Response as RpcResponse, RpcResponseContext, RpcSupply},
};

use crate::{error::RpcError, http::CloudbreakRpcState, methods::resolve_commitment};

const MAX_SUPPLY_STALENESS_SLOTS: u64 = 150;

pub async fn get_supply(
state: &CloudbreakRpcState,
config: Option<RpcSupplyConfig>,
) -> Result<RpcResponse<RpcSupply>, RpcError> {
if !state.supply_supported {
return Err(RpcError::InvalidParamsWithMessage(
"getSupply is not supported on this node".to_string(),
));
}

let config = config.unwrap_or_default();
let commitment = resolve_commitment(
config
.commitment
.map(|c| c.commitment)
.unwrap_or(CommitmentLevel::Finalized),
state.processed_commitment,
)?;

let snapshot = state.supply_cache.read().unwrap().clone();
let Some(accounts_list) = snapshot.non_circulating_accounts.as_ref() else {
tracing::warn!(
target: "get_supply",
"non-circulating accounts not computed yet; returning node unhealthy"
);
return Err(state.node_unhealthy());
};

let finalized_slot = match state
.slot_syncronizer_data
.as_ref()
.map(|d| d.read().unwrap().finalized_slot.slot)
{
Some(finalized) if finalized > 0 => finalized,
_ => crate::db_query::get_slot_data(&state.database)
.await
.map(|d| d.finalized_slot.slot)
.unwrap_or(0),
};

let row = match commitment {
CommitmentLevel::Finalized => snapshot
.rows
.iter()
.rev()
.find(|row| row.slot <= finalized_slot),
_ => snapshot.rows.last(),
};
let Some(row) = row.copied() else {
tracing::warn!(
target: "get_supply",
"no cached supply row for {:?} commitment; returning node unhealthy",
commitment
);
return Err(state.node_unhealthy());
};
let Some(non_circulating) = row.non_circulating else {
tracing::warn!(
target: "get_supply",
"supply row at slot {} has no non-circulating lamports yet; returning node unhealthy",
row.slot
);
return Err(state.node_unhealthy());
};
if finalized_slot.saturating_sub(row.slot) > MAX_SUPPLY_STALENESS_SLOTS {
tracing::warn!(
target: "get_supply",
"supply slot {} is more than {} slots behind finalized slot {}; returning node unhealthy",
row.slot,
MAX_SUPPLY_STALENESS_SLOTS,
finalized_slot
);
return Err(state.node_unhealthy());
}

let non_circulating_accounts = if config.exclude_non_circulating_accounts_list {
Vec::new()
} else {
accounts_list.clone()
};

Ok(RpcResponse {
context: RpcResponseContext {
slot: row.slot,
api_version: None,
},
value: RpcSupply {
total: row.total,
circulating: row.total.saturating_sub(non_circulating),
non_circulating,
non_circulating_accounts,
},
})
}
1 change: 1 addition & 0 deletions crates/api/src/methods/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod genesis;
pub mod get_account_info;
pub mod get_balance;
pub mod get_multiple_accounts;
pub mod get_supply;
pub mod get_token_account_balance;
pub mod get_token_largest_accounts;
pub mod get_token_supply;
Expand Down
2 changes: 1 addition & 1 deletion crates/api/src/methods/simulate_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -921,7 +921,7 @@ fn sysvar_account_ids() -> [Pubkey; 9] {
fn programdata_addresses(accounts: &HashMap<Pubkey, (AccountSharedData, Slot)>) -> Vec<Pubkey> {
let loader = solana_sdk_ids::bpf_loader_upgradeable::id();
let mut out = Vec::new();
for (_key, (account, _)) in accounts {
for (account, _) in accounts.values() {
if account.owner() != &loader {
continue;
}
Expand Down
1 change: 1 addition & 0 deletions crates/api/src/modules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@

pub mod bandwidth;
pub mod cache;
pub mod supply_cache;
pub mod vote_accounts_cache;
Loading