Skip to content

About

Rust SDK for the Mina Archive Node GraphQL API — events, actions, blocks, network state.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

mina-archive-sdk

CI crates.io docs.rs license

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

[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().

Quick start

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 /graphql reaches 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 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.

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 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

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.

Errors

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(())
# }

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 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.

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 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.

Examples

ARCHIVE_GRAPHQL_URI=https://archive.example/ cargo run --example network_state

See examples/:

  • events.rs — query events for an address
  • actions.rs — query actions for an address
  • blocks.rs — get the latest canonical blocks with currency parsing
  • network_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 mina_archive_sdk::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

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-targets

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/ \
  cargo test --test integration_tests -- --ignored --nocapture

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.

About

Rust SDK for the Mina Archive Node GraphQL API — events, actions, blocks, network state.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages