mina-archive-sdk
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).
Install
[]
= "2.0"
= { = "1", = ["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().
Quick start
use ;
async
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.
API
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. |
Block transaction detail
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.
Configuration
use Duration;
use ;
let client = with_config;
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.
Dates and times
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 BlockQueryInput;
let query = default
.date_time_gte_from_unix_ms
.date_time_lt_from_unix_ms;
Currency
Currency wraps nanomina amounts in a u64 for safe parsing of coinbase / fee / user-command values:
use Currency;
let coinbase = from_graphql.unwrap;
assert_eq!;
let fee = from_mina.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.expect;
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.
Errors
All fallible operations return Result<T> over the Error enum. Match on variants:
use ;
# async
Partial results
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
data: null with errors is a total failure on both paths, not a partial one.
Rate limiting and HTTP status
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.
Contract error codes
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 ;
#
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.
Examples
ARCHIVE_GRAPHQL_URI=https://archive.example/
See 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
Nullable elements
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.
Version compatibility
The crate exports the schema version it speaks:
use SCHEMA_VERSION; // "2.0" — the Archive-Node-API schema major.minor
The 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. |
Migrating from 1.x to 2.0
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.
Development
Integration 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/ \
Schema sync
schema.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.
License
Apache-2.0 — see LICENSE.