Rust SDK for Mina Protocol's Archive Node GraphQL endpoint.
Companion to the daemon-targeting mina-sdk crate. This crate targets the separate archive endpoint defined by o1-labs/Archive-Node-API (events, actions, blocks, network state).
[dependencies]
mina-archive-sdk = "2.0"
tokio = { version = "1", features = ["full"] }Minimum supported Rust version: 1.88, declared as rust-version in
Cargo.toml and built in CI on exactly that toolchain, so a dependency bump
cannot raise the real floor without failing the build.
Requires a Tokio runtime with the time driver enabled — reqwest's
per-request timeout needs it, and without it every query panics with "A Tokio
1.x context was found, but timers are disabled". #[tokio::main] and
#[tokio::test] enable it; a hand-built Builder::new_current_thread() does
not unless you call .enable_time().
use mina_archive_sdk::{ArchiveClient, BlockStatusFilter, EventFilterOptionsInput};
#[tokio::main]
async fn main() -> mina_archive_sdk::Result<()> {
let client = ArchiveClient::new("https://archive.example/");
let events = client.get_events(
EventFilterOptionsInput::for_address("B62q...")
.status(BlockStatusFilter::Canonical)
.from(100)
.to(200),
).await?;
for group in events {
let height = group.block_info.map(|b| b.height).unwrap_or(-1);
let count = group.event_data.map(|d| d.len()).unwrap_or(0);
println!("block {height}: {count} event(s)");
}
Ok(())
}The endpoint is the root path. Archive-Node-API serves GraphQL at
/, not/graphql. Pass the base URL as-is — the SDK never appends a path, so a URL ending in/graphqlreaches a route the server does not serve and returns 404.
Each method on ArchiveClient maps 1:1 to a GraphQL query in the Archive-Node-API schema:
| Method | Returns | Description |
|---|---|---|
get_events(input) |
Vec<EventOutput> |
Events emitted by a zkApp account. |
get_actions(input) |
Vec<ActionOutput> |
Actions dispatched from a zkApp account. |
get_network_state() |
NetworkStateOutput |
Archive's max canonical / pending block heights. |
get_blocks(opts) |
Vec<Block> |
Blocks filtered by height/date range and chain status. Transaction detail needs ENABLE_BLOCK_TRANSACTION_DETAILS on the server — see below. |
get_verification_key_updates(input) |
Vec<VerificationKeyUpdate> |
Applied account updates that set a given verification key, within a required block range. |
query(gql) |
builder | Arbitrary GraphQL through the same retry path. |
execute_query(gql, vars, name) |
serde_json::Value |
Low-level escape hatch returning the raw data field. |
get_blocks returns transaction detail only when the server sets
ENABLE_BLOCK_TRANSACTION_DETAILS=true. It defaults to false, and on a stock
server every block comes back with parent_hash as "" and user_commands,
zkapp_commands and fee_transfer all empty. coinbase is populated either way,
which is what makes the response look healthy rather than obviously truncated.
use std::time::Duration;
use mina_archive_sdk::{ArchiveClient, ClientConfig};
let client = ArchiveClient::with_config(
ClientConfig::new("https://archive.example/")
.attempts(5)
.retry_delay(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.max_retry_after(Duration::from_secs(60)),
);ClientConfig is #[non_exhaustive], so build it through the setters rather
than with a struct literal — that is what lets a new knob be added under a
minor release instead of a major one. The same applies to GetBlocksOptions.
attempts is the total, including the first try. The underlying field is
named retries, but 5 means five requests, not one plus five. attempts(1)
disables retrying.
timeout does not bound a retry-after wait. It bounds one HTTP request.
A 429 carries the server's retry-after, and honouring it means sleeping
between attempts, which no request timeout covers — retry-after: 86400 would
park the call for a day. max_retry_after (default 60s) is the ceiling: when
the server asks for longer, the call returns Error::RateLimited immediately,
carrying the requested delay so you can decide whether to wait, queue or fail.
The schema carries two different time encodings, a few fields apart, and both arrive as strings:
| Field | Encoding | Example |
|---|---|---|
BlockInfo::timestamp |
Unix epoch milliseconds, decimal string | "1692054601000" |
Block::date_time |
ISO-8601 | "2023-08-14T23:10:01.000Z" |
BlockInfo::timestamp is a raw pass-through of the archive DB column. Do not feed
it to an RFC 3339 parser, and do not read it as seconds.
On input, date_time_gte / date_time_lt must be ISO-8601. The server coerces them
with JavaScript's new Date(value).getTime(), and a value it cannot parse becomes
NaN, which reaches SQL as the string "NaN" and matches nothing without
erroring — HTTP 200, empty list, no diagnostic anywhere:
"2023-08-14T00:00:00Z" -> 1691971200000 ok
"2023-08-14" -> 1691971200000 ok
"14/08/2023" -> NaN silently returns zero rows
"Aug 14 2023" -> 1691964000000 parses, but timezone-dependent
Use the typed constructors to make that unrepresentable:
use mina_archive_sdk::BlockQueryInput;
let query = BlockQueryInput::default()
.date_time_gte_from_unix_ms(1_691_971_200_000)
.date_time_lt_from_unix_ms(1_692_054_601_000);Currency wraps nanomina amounts in a u64 for safe parsing of coinbase / fee / user-command values:
use mina_archive_sdk::Currency;
let coinbase = Currency::from_graphql("720000000000").unwrap();
assert_eq!(coinbase.mina(), "720.000000000");
let fee = Currency::from_mina("0.01").unwrap();
// `+` and `*` panic on overflow in EVERY profile — they used to wrap silently
// in release, producing a wrong amount. Use the checked forms for values that
// are not already bounded.
let total = coinbase.checked_add(fee).expect("no overflow");Reaching an overflow needs about 1.8e19 nanomina, roughly sixteen times the
total MINA supply, so it is not reachable from server data — but it is
reachable from Currency::from_nanomina or from accumulation in caller code.
All fallible operations return Result<T> over the Error enum. Match on variants:
use mina_archive_sdk::{ArchiveClient, Error, EventFilterOptionsInput};
# async fn example() -> mina_archive_sdk::Result<()> {
let client = ArchiveClient::new("https://archive.example/");
match client.get_events(EventFilterOptionsInput::for_address("B62q...")).await {
Ok(events) => println!("{} groups", events.len()),
Err(Error::Graphql { messages, .. }) => eprintln!("server rejected: {messages}"),
Err(Error::RateLimited { retry_after, .. }) => eprintln!("slow down: retry after {retry_after:?}"),
Err(Error::UnexpectedStatus { status, .. }) => eprintln!("unexpected HTTP {status}"),
Err(Error::Connection { attempts, .. }) => eprintln!("unreachable after {attempts} tries"),
Err(Error::MissingField { field, .. }) => eprintln!("schema mismatch: {field}"),
Err(e) => eprintln!("error: {e}"),
}
# Ok(())
# }A response can legally carry both data and errors — the root lists and most of
their fields are nullable, so the server nulls the field that failed and reports it
alongside the rows that succeeded.
The methods above are strict: they treat that as a failure. When you would rather keep
what did arrive, use the *_with_errors family, which returns a Response<T>:
# async fn example(client: &mina_archive_sdk::ArchiveClient) -> mina_archive_sdk::Result<()> {
use mina_archive_sdk::EventFilterOptionsInput;
let resp = client
.get_events_with_errors(EventFilterOptionsInput::for_address("B62q..."))
.await?;
if resp.is_partial() {
eprintln!("partial result: {}", resp.messages());
}
for group in resp.data.unwrap_or_default() {
// the rows that did arrive
let _ = group;
}
# Ok(())
# }data: null with errors is a total failure on both paths, not a partial one.
Every GraphQL-level error from this API arrives as HTTP 200 with a populated
errors array — extensions.status is a payload field, not the HTTP status. HTTP 429
is the only non-200 the API emits, which makes it unusually informative: it
unambiguously means "slow down", and it is the one case where retrying the identical
request is correct.
The SDK retries 429 automatically, waiting for the interval the server names in
retry-after. If the retries run out, Error::RateLimited carries retry_after,
limit and remaining so a caller can schedule its own back-off.
Any other non-2xx becomes Error::UnexpectedStatus, which names the status rather than
reporting a decode failure. That is what a URL ending in /graphql produces.
The API attaches extensions.code to every domain error and keeps the message text
deliberately minimal, so the code is the intended discriminator — do not match on
English message strings:
| Code | Meaning |
|---|---|
BLOCK_RANGE_ERROR |
The requested range exceeds BLOCK_RANGE_SIZE. Narrow it; never retry unchanged. |
ACTION_STATE_NOT_FOUND |
The action state is not in the archive. |
ACTION_STATE_OUT_OF_RANGE |
The action state falls outside the requested range. |
RATE_LIMITED |
Too many requests. Back off and retry. |
use mina_archive_sdk::{codes, Error};
# fn example(err: &Error) {
if err.has_graphql_code(codes::BLOCK_RANGE_ERROR) {
// Narrow the range and try again.
}
for code in err.graphql_codes() {
eprintln!("code: {code}");
}
# }An empty result from graphql_codes() means no code was sent, not that nothing went
wrong. The server runs with masked errors, so an unexpected failure arrives as a generic
message with no extensions at all.
ARCHIVE_GRAPHQL_URI=https://archive.example/ cargo run --example network_stateSee examples/:
events.rs— query events for an addressactions.rs— query actions for an addressblocks.rs— get the latest canonical blocks with currency parsingnetwork_state.rs— check archive sync state
Schema 2.0 declares non-null elements in every list the SDK decodes, so these
positions hold plain values, not Options:
| SDL position | SDL type | Rust type |
|---|---|---|
Query.events |
[EventOutput!]! |
Vec<EventOutput> |
Query.actions |
[ActionOutput!]! |
Vec<ActionOutput> |
Query.blocks |
[Block!]! |
Vec<Block> |
EventOutput.eventData |
[EventData!] |
Option<Vec<EventData>> |
ActionOutput.actionData |
[ActionData!] |
Option<Vec<ActionData>> |
EventData.data |
[String!]! |
Vec<String> |
ActionData.data |
[String!]! |
Vec<String> |
TransactionInfo.zkappAccountUpdateIds |
[Int!]! |
Vec<i64> |
eventData and actionData are the only lists that can themselves be null,
so they stay Option. A null element in any of the positions above breaks the
schema, and the query fails with Err(Error::Decode); the SDK does not drop the
element silently.
The crate exports the schema version it speaks:
use mina_archive_sdk::SCHEMA_VERSION; // "2.0" — the Archive-Node-API schema major.minorThe SDK major equals the server major. The Archive-Node-API server, its schema and the SDKs share one major version:
- Same major = compatible.
- A server with a higher minor only adds features that this SDK may not wrap yet. Everything this SDK wraps keeps working.
- A different major is not compatible.
Within that major, the crate version is semver about the SDK's own surface:
| Part | Meaning |
|---|---|
| Major | A breaking change — always together with the server and schema major. |
| Minor | Additive: a new query, a new option, a new helper. |
| Patch | Fixes, docs, dependencies. |
2.0 speaks schema 2.0. The return types of get_events, get_actions and
get_blocks, and the element types of EventData::data, ActionData::data
and TransactionInfo::zkapp_account_update_ids, are the same as in 1.x. The
only change in these positions is EventOutput::event_data and
ActionOutput::action_data, which are now Option<Vec<T>> instead of
Option<Vec<Option<T>>>:
// 1.x
for data in group.event_data.into_iter().flatten().flatten() { /* ... */ }
// 2.0
for data in group.event_data.into_iter().flatten() { /* ... */ }get_network_state and get_verification_key_updates are unchanged.
cargo build
cargo test --lib --tests # unit + wiremock tests, no infra needed
cargo test --doc # rustdoc examples — NOT covered by the line above
cargo fmt --check
cargo clippy --all-targetsIntegration tests are #[ignore]d by default — they require a running Archive-Node-API server. CI provisions one in .github/workflows/integration.yml. To run locally:
ARCHIVE_GRAPHQL_URI=http://localhost:8080/ \
cargo test --test integration_tests -- --ignored --nocaptureschema.graphql is vendored from o1-labs/Archive-Node-API@main. The Schema Drift CI workflow compares them weekly and on PR; on drift, update both schema.graphql and src/types.rs in the same PR.
Apache-2.0 — see LICENSE.