Reference
# Normalized frame archive
> Reference the deterministic, checksummed archive format used to move normalized Leani frame segments.
Status: implemented interchange format v1. The `archive` history source lets the node backfill from local, checksummed objects without retaining raw chain history. It is deliberately source-neutral: an Amazon Parquet, Xatu, EraE, BigQuery, or custom adapter can project its input to normalized `BlockFrame` JSON Lines and then use the same runtime and processors. Example history configuration:
```toml
[[sources.history]]
id = "offline-frames"
kind = "archive"
priority = 5
trust = "trusted_dataset"
manifest = "./archives/mainnet/manifest.json"
```
A relative `manifest` path is relative to the directory of the configuration file, not the working directory the node starts in. Manifest:
```json
{
"format_version": 1,
"id": "mainnet-public-data-2026-08-01",
"chain_id": 1,
"schema_version": "normalized-frame-jsonl.v1",
"capabilities": ["Header", "Transactions", "Receipts", "Logs"],
"complete_capabilities": ["Header", "Transactions", "Receipts", "Logs"],
"finality": "Finalized",
"objects": [
{
"from_block": 19426589,
"to_block": 19427588,
"path": "19426589-19427588.jsonl",
"blake3": "lowercase-64-hex-digits",
"bytes": 1234567
}
]
}
```
Each non-empty object line is one JSON-encoded `BlockFrame`. Objects must: * use simple relative paths (no absolute paths or `..`); * have the exact declared byte length and BLAKE3 digest; * contain exactly one frame per block in their declared range; * be ordered, contiguous, and parent-linked; * match the manifest chain, finality, and declared material capabilities; * be reached without crossing a symbolic link below the manifest’s directory. Reads enforce the normal source byte, frame, and frame-count budgets, and hold one line at a time. The adapter verifies the complete object’s length and BLAKE3 digest before it yields any frame; a length or checksum mismatch fails the read then. It then streams the frames from the same open file, hashing it again as it goes, and adds immutable object identity to their provenance. A structural failure, such as a truncated frame, a block out of order, missing, or repeated, a parent mismatch, a schema mismatch, or a chain or finality mismatch, fails the read as corrupt where it is found, after the frames before it may already have been applied; a later source or a rerun resumes after them. An object edited in place between the two passes fails with the checksum error only after its frames. Frames report only the checks the archive made: the object’s BLAKE3 digest, and the parent link to the previous frame of the same object. A frame’s own verification claims and consensus anchor are dropped, and the trust of its earlier provenance is capped at `trusted_dataset`. The archive is a trusted-dataset source, not automatically a cryptographic raw history source. An EraE adapter must additionally decode raw bodies/receipts, verify their commitment roots, and anchor canonicality before advertising `verified_material`. Backfill any configured processor:
```text
leani backfill \
--processor erc20-balances \
--from-block 12345678 \
--to-block 12346677
```
The runtime records coverage and discards input frames after reduction. Only processor output, undo/change journals, and job metadata remain in SQLite. An ordered processor such as `erc20-balances` applies its history contiguously from `start_block` upward, so `--from` must be the block after the last one it applied, or its `start_block` when it has applied none; the command refuses any other start and names the block to use. When `rpc.historical_mode = "on_demand"`, the same source can answer bounded historical RPC without importing frames into SQLite. Exact block and receipt responses additionally require complete canonical header RLP, EIP-2718 transaction bytes, typed receipt bytes, senders, receipt gas fields, and withdrawals whenever the header commits to a withdrawals root. The manifest must declare those capabilities complete. A blobs-only Xatu projection is correctly rejected for generic RPC because its predicate-filtered transaction material is not a complete block body.
# Brand assets
> Download the Leani mark, wordmark, avatar, and share card, with the rules, colors, and fonts that go with them.
Use these files when you write about Leani, link to it, or list it in a directory or app store. | Asset | Size | Use it for | | ------------------------------------------------- | ------------- | ---------------------------------------------------------------- | | [Wordmark on dark](/leani-wordmark-on-dark.svg) | SVG | Headers, slides, and diagrams on dark backgrounds | | [Wordmark on light](/leani-wordmark-on-light.svg) | SVG | Headers, slides, and diagrams on light backgrounds | | [Mark on dark](/leani-mark-on-dark.svg) | SVG | Small spaces on dark backgrounds, where the wordmark doesn’t fit | | [Mark on light](/leani-mark-on-light.svg) | SVG | Small spaces on light backgrounds | | [App icon](/leani-mark.svg) | SVG | The mark on its dark tile, for app icons and tiles | | [Avatar](/leani-social-avatar.png) | 1024×1024 PNG | Profile pictures, app icons, directory listings | | [Share card](/og-image.png) | 1200×630 PNG | Link previews; the site’s pages already use it |   ## The mark The mark is a lowercase i that leans 12°, one degree for each second of an Ethereum slot. Its dot is a square block: the one block an application keeps. The wordmark spells `leani` in a geometric lowercase and ends in the same i. * Keep the 12° lean, the square dot, and the proportions. Don’t redraw the wordmark in a font. * Use the dark-background files on dark surfaces and the light-background files on light ones. Only the app icon and avatar carry a tile. * Leave clear space around the mark at least as wide as its dot. * Below 24 pixels, use the mark rather than the wordmark. The favicon is a heavier cut of the mark for 16 and 32 pixels. * Write the name as Leani in prose. ## The epoch field The supporting pattern is one epoch’s 32 slots: a 6×6 grid of dots without its corners. One slot can be kept as a green block. The share card and the site use it as background texture and to show data being reduced. * Use the field behind headlines and illustrations, never inside the mark or next to it. * Don’t use it below 48 pixels or behind running text. ## Colors and type | Role | Color | | --------------------------------- | --------- | | Background | `#0b0f0d` | | Text | `#e6f0ea` | | Accent green | `#3ee07f` | | Accent green on light backgrounds | `#12a150` | | Text on light backgrounds | `#0b0f0d` | Body text uses Inter; code and commands use JetBrains Mono. The wordmark is drawn, not typeset. ## Source files The SVGs live in `site/src/assets/brand`. Run `bun run brand:update` in `site/` to publish them and to regenerate the favicons and avatar from the app icon. The share card is rendered from `site/scripts/og-card.html`; its header comment has the command.
# Command-line interface
> Generated Leani command, option, default, and value inventory from the Rust Clap definition.
The tracked CLI fixture is rendered by a Rust test from the actual Clap command tree. The Bun-only site consumes that reviewed fixture without compiling Leani. ## Exit codes | Code | Meaning | | ---- | -------------------------------------------------------------------------------------------------------------------- | | 0 | Success. A `subscribe` stopped by a signal or `--timeout` also exits 0, unless `--once` had not printed a match yet. | | 1 | Runtime failure. | | 2 | Usage error: an unknown command, flag, or value. | | 3 | `doctor` found the configuration unreadable or invalid. | | 124 | `subscribe --once` reached `--timeout` before a match. | | 130 | `subscribe --once` was stopped by SIGINT or SIGTERM before a match, or a second signal cut a shutdown short. | ## Commands
# Configuration fields
> Generated field and constraint inventory for Leani configuration schema version 1.
This inventory is generated from `config/schema-v1.json`. “Required here” means required by the immediate containing object; optional objects can still contain required members when present. ## `config_version` | Field | Type / values | Required here | Constraints | | ---------------- | ------------- | ------------- | ----------- | | `config_version` | `1` | yes | `—` | ## `network` | Field | Type / values | Required here | Constraints | | --------- | -------------------- | ------------- | ----------- | | `network` | `"ethereum-mainnet"` | yes | `—` | ## `data_dir` | Field | Type / values | Required here | Constraints | | ---------- | ----------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------ | | `data_dir` | `string`Writable node state. A relative path is relative to the directory of this configuration file. | yes | `{"default":"./data","minLength":1}` | ## `finality` | Field | Type / values | Required here | Constraints | | ---------------------------- | ----------------------------------------- | ------------- | ----------------------------------- | | `finality` | `starterFinality` | yes | `—` | | `finality.checkpoint` | `string` | yes | `{"pattern":"^0x[0-9a-fA-F]{64}$"}` | | `finality.checkpoint_slot` | `positiveInteger` | yes | `{"minimum":1}` | | `finality.endpoints` | `array` | yes | `{"minItems":1}` | | `finality.kind` | `beacon_api \| consensus_p2p \| disabled` | yes | `—` | | `finality.minimum_agreement` | `positiveInteger` | no | `{"minimum":1}` | | `finality.bootnodes` | `array` | no | `—` | | `finality.minimum_peers` | `nonNegativeInteger` | no | `{"minimum":0}` | | `finality.discovery_port` | `integer` | no | `{"minimum":0,"maximum":65535}` | ## `blocks` | Field | Type / values | Required here | Constraints | | -------- | --------------- | ------------- | ----------- | | `blocks` | `starterBlocks` | no | `—` | ## `uniswap` | Field | Type / values | Required here | Constraints | | ----------------- | ----------------------------------------- | ------------- | ---------------- | | `uniswap` | `starterUniswap` | no | `—` | | `uniswap.markets` | `array` | yes | `{"minItems":1}` | ## `api` | Field | Type / values | Required here | Constraints | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------------------- | | `api` | `starterApi` | yes | `—` | | `api.bind` | `socketAddress`IPv4 or bracketed IPv6 socket address with port | yes | `{"default":"127.0.0.1:18080"}` | | `api.bearer_token_env` | `string` | no | `{"minLength":1}` | | `api.allow_unauthenticated_remote` | `boolean`Serve the API on a non-loopback bind without bearer\_token\_env. Only behind an authenticating proxy. | no | `{"default":false}` | | `api.allowed_hosts` | `array`Host names, besides localhost, that requests may address. Any other Host name gets HTTP 421; IP addresses always pass. | no | `{"default":[]}` | | `api.allowed_origins` | `allowedOrigins`Browser origins, besides loopback ones on any port, that may call the API. Any other Origin gets HTTP 403. | no | `{"default":[]}` | | `api.delivery` | `object` | no | `—` | | `api.delivery.history_batches` | `deliveryBatch` | no | `—` | | `api.delivery.history_batches.target_encoded_bytes` | `humanBytes` | yes | `—` | | `api.delivery.history_batches.maximum_encoded_bytes` | `humanBytes` | yes | `—` | | `api.delivery.history_batches.maximum_events` | `positiveInteger` | yes | `{"minimum":1}` | | `api.delivery.history_batches.maximum_processed_blocks` | `positiveInteger` | yes | `{"minimum":1}` | | `api.delivery.history_batches.maximum_delay` | `humanMilliseconds` | yes | `—` | | `api.delivery.history_batches.maximum_buffered_batches` | `positiveInteger` | yes | `{"minimum":1}` | | `api.delivery.history_batches.maximum_buffered_bytes` | `humanBytes` | yes | `—` | | `api.delivery.history_batches.compression` | `none \| gzip` | no | `—` | | `api.delivery.live_batches` | `deliveryBatch` | no | `—` | | `api.delivery.live_batches.target_encoded_bytes` | `humanBytes` | yes | `—` | | `api.delivery.live_batches.maximum_encoded_bytes` | `humanBytes` | yes | `—` | | `api.delivery.live_batches.maximum_events` | `positiveInteger` | yes | `{"minimum":1}` | | `api.delivery.live_batches.maximum_processed_blocks` | `positiveInteger` | yes | `{"minimum":1}` | | `api.delivery.live_batches.maximum_delay` | `humanMilliseconds` | yes | `—` | | `api.delivery.live_batches.maximum_buffered_batches` | `positiveInteger` | yes | `{"minimum":1}` | | `api.delivery.live_batches.maximum_buffered_bytes` | `humanBytes` | yes | `—` | | `api.delivery.live_batches.compression` | `none \| gzip` | no | `—` | ## `chain` | Field | Type / values | Required here | Constraints | | ------------------- | -------------------- | ------------- | ----------------- | | `chain` | `chain` | yes | `—` | | `chain.name` | `string` | yes | `{"minLength":1}` | | `chain.chain_id` | `positiveInteger` | yes | `{"minimum":1}` | | `chain.merge_block` | `nonNegativeInteger` | no | `{"minimum":0}` | ## `raw_history` | Field | Type / values | Required here | Constraints | | -------------------------------------------- | ----------------- | ------------- | --------------- | | `raw_history` | `rawHistory` | no | `—` | | `raw_history.enabled` | `boolean` | no | `—` | | `raw_history.maximum_logical_bytes` | `humanBytes` | no | `—` | | `raw_history.maximum_physical_bytes` | `humanBytes` | no | `—` | | `raw_history.maximum_frame_logical_bytes` | `humanBytes` | no | `—` | | `raw_history.maximum_segment_logical_bytes` | `humanBytes` | no | `—` | | `raw_history.maximum_segment_physical_bytes` | `humanBytes` | no | `—` | | `raw_history.reader_connections` | `positiveInteger` | no | `{"minimum":1}` | | `raw_history.maximum_source_frames` | `positiveInteger` | no | `{"minimum":1}` | | `raw_history.maximum_buffered_frames` | `positiveInteger` | no | `{"minimum":1}` | ## `artifact_storage` | Field | Type / values | Required here | Constraints | | ------------------------------------------------- | --------------------------- | ------------- | --------------- | | `artifact_storage` | `artifactStorage` | no | `—` | | `artifact_storage.backend` | `sqlite \| tiered_segments` | no | `—` | | `artifact_storage.segment_target_blocks` | `positiveInteger` | no | `{"minimum":1}` | | `artifact_storage.compression` | `none \| snappy \| deflate` | no | `—` | | `artifact_storage.maximum_artifact_logical_bytes` | `humanBytes` | no | `—` | | `artifact_storage.maximum_segment_logical_bytes` | `humanBytes` | no | `—` | | `artifact_storage.maximum_segment_physical_bytes` | `humanBytes` | no | `—` | | `artifact_storage.compaction_interval` | `humanMilliseconds` | no | `—` | | `artifact_storage.maximum_segments_per_cycle` | `positiveInteger` | no | `{"minimum":1}` | ## `budgets` | Field | Type / values | Required here | Constraints | | ------------------------------------------------------------------ | -------------------------------- | ------------- | --------------- | | `budgets` | `budgets` | yes | `—` | | `budgets.memory_bytes` | `positiveInteger` | yes | `{"minimum":1}` | | `budgets.temporary_disk_bytes` | `positiveInteger` | yes | `{"minimum":1}` | | `budgets.pending_delta_bytes` | `positiveInteger` | yes | `{"minimum":1}` | | `budgets.recent_raw_soft_bytes` | `positiveInteger` | yes | `{"minimum":1}` | | `budgets.recent_raw_hard_bytes` | `positiveInteger` | yes | `{"minimum":1}` | | `budgets.source_concurrency` | `positiveInteger` | yes | `{"minimum":1}` | | `budgets.mapper_concurrency` | `positiveInteger` | yes | `{"minimum":1}` | | `budgets.history_material` | `historyMaterialBudget` | no | `—` | | `budgets.history_material.mode` | `disabled \| observe \| enabled` | no | `—` | | `budgets.history_material.memory_bytes` | `humanBytes` | no | `—` | | `budgets.history_material.maximum_buffered_frames_per_acquisition` | `positiveInteger` | no | `{"minimum":1}` | | `budgets.history_material.minimum_physical_chunk_blocks` | `positiveInteger` | no | `{"minimum":1}` | | `budgets.history_material.maximum_overfetch_ratio` | `number` | no | `{"minimum":1}` | | `budgets.history_pipeline` | `historyPipelineBudget` | no | `—` | | `budgets.history_pipeline.maximum_active_chunks` | `positiveInteger` | no | `{"minimum":1}` | | `budgets.history_pipeline.maximum_mapped_bytes` | `humanBytes` | no | `—` | | `budgets.history_pipeline.commit` | `historyCommitBudget` | no | `—` | | `budgets.history_pipeline.commit.maximum_blocks` | `positiveInteger` | no | `{"minimum":1}` | | `budgets.history_pipeline.commit.maximum_changes` | `positiveInteger` | no | `{"minimum":1}` | | `budgets.history_pipeline.commit.maximum_encoded_bytes` | `humanBytes` | no | `—` | | `budgets.history_pipeline.commit.maximum_delay` | `humanMilliseconds` | no | `—` | | `budgets.history_pipeline.commit.target_writer_hold` | `humanMilliseconds` | no | `—` | | `budgets.store` | `storeBudget` | no | `—` | | `budgets.store.maximum_physical_bytes` | `humanBytes` | no | `—` | | `budgets.artifacts` | `artifactBudget` | no | `—` | | `budgets.artifacts.maximum_retained_bytes` | `humanBytes` | no | `—` | | `budgets.artifacts.maximum_pending_bytes` | `humanBytes` | no | `—` | | `budgets.delivery` | `deliveryBudget` | no | `—` | | `budgets.delivery.maximum_retained_bytes` | `humanBytes` | no | `—` | | `budgets.delivery.maximum_history_retained_bytes` | `humanBytes` | no | `—` | ## `sources` | Field | Type / values | Required here | Constraints | | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------- | | `sources` | `sources` | yes | `—` | | `sources.history` | `array` | yes | `{"minItems":1}` | | `sources.history[].id` | `string` | yes | `{"minLength":1}` | | `sources.history[].kind` | `xatu \| era_e \| archive` | yes | `—` | | `sources.history[].priority` | `integer` | yes | `{"minimum":0,"maximum":65535}` | | `sources.history[].trust` | `trusted_dataset \| verified_material` | yes | `—` | | `sources.history[].chunk_blocks` | `positiveInteger`Xatu only: blocks per acquisition chunk for headers, transactions, and logs, a multiple of Xatu's 1,000-block partitions. | no | `{"minimum":1,"default":1000,"multipleOf":1000}` | | `sources.history[].blobs_chunk_blocks` | `positiveInteger`Xatu only: blocks per acquisition chunk for the blobs projection, a multiple of 1,000. | no | `{"minimum":1,"default":8000,"multipleOf":1000}` | | `sources.history[].batch_rows` | `positiveInteger`Xatu only: Parquet rows decoded per bounded Arrow batch. | no | `{"minimum":1,"default":8192}` | | `sources.history[].manifest` | `string`Archive only: the archive manifest. A relative path is relative to the directory of this configuration file. | no | `—` | | `sources.history[].endpoint` | `string` | no | `{"format":"uri"}` | | `sources.history[].allow_insecure_http` | `boolean` | no | `—` | | `sources.live` | `liveSource` | yes | `—` | | `sources.live.kind` | `p2p \| disabled` | yes | `—` | | `sources.live.minimum_peers` | `nonNegativeInteger` | yes | `{"minimum":0}` | | `sources.live.body_serving_peer_target` | `nonNegativeInteger` | no | `{"minimum":0}` | | `sources.live.preferred_peers` | `nonNegativeInteger` | no | `{"minimum":0}` | | `sources.live.max_outbound_peers` | `nonNegativeInteger` | no | `{"minimum":0}` | | `sources.live.max_concurrent_dials` | `nonNegativeInteger` | no | `{"minimum":0}` | | `sources.live.listener_port` | `integer` | no | `{"minimum":0,"maximum":65535}` | | `sources.live.discovery_port` | `integer` | no | `{"minimum":0,"maximum":65535}` | | `sources.live.discv5_port` | `integer` | no | `{"minimum":0,"maximum":65535}` | | `sources.live.enable_discv5` | `boolean` | no | `—` | | `sources.live.nat` | `string` | no | `—` | | `sources.live.trusted_peers` | `array` | no | `—` | | `sources.live.bootstrap_dns_tree` | `string`Optional authenticated EIP-1459 execution peer-hint tree. It affects discovery only; execution material remains protocol-verified locally. | no | `{"pattern":"^enrtree://"}` | | `sources.live.peer_refill_interval_ms` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.peer_recovery_timeout_seconds` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.stall_timeout_seconds` | `integer`Exit the node, so that its process supervisor restarts it, after the live lane stays not ready this long while verified finality is ready. | no | `{"default":600,"minimum":360,"maximum":2592000}` | | `sources.live.material_request_concurrency` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.material_request_blocks` | `integer` | no | `{"minimum":1,"maximum":16}` | | `sources.live.request_timeout_seconds` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.request_retries` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.request_retry_backoff_ms` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.history_header_request_concurrency` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.history_header_request_blocks` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.persistent_retries` | `boolean` | no | `—` | | `sources.live.retry_backoff_max_seconds` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.peer_store_flush_seconds` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.peer_store_max_entries` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.archive_reconciliation_blocks` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.archive_reconciliation_interval_seconds` | `positiveInteger` | no | `{"minimum":1}` | | `sources.live.history_fallback_blocks` | `positiveInteger`Optional maximum finalized suffix eligible for execution-P2P history fallback. When omitted, every gap at or after the processor start block is eligible. | no | `{"minimum":1}` | | `sources.live.handoff_overlap_blocks` | `positiveInteger` | no | `{"minimum":1}` | ## `processors` | Field | Type / values | Required here | Constraints | | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | --------------------------------- | | `processors` | `array` | yes | `{"minItems":1}` | | `processors[].id` | `string` | yes | `{"minLength":1}` | | `processors[].instance` | `string` | yes | `{"minLength":1}` | | `processors[].version` | `string` | yes | `{"minLength":1}` | | `processors[].history_control` | `node_owned \| application_subscriptions` | no | `—` | | `processors[].history_mode` | `automatic \| on_demand` | no | `—` | | `processors[].require_retained_input` | `boolean` | no | `—` | | `processors[].start_block` | `nonNegativeInteger` | yes | `{"minimum":0}` | | `processors[].publish` | `unattested \| attested \| finalized`First chain-confidence level the processor's consumers see. | no | `{"default":"unattested"}` | | `processors[].state` | `statePolicy` | yes | `—` | | `processors[].state.mode` | `durable \| checkpointed` | yes | `—` | | `processors[].artifacts` | `artifactPolicy` | no | `—` | | `processors[].artifacts.mode` | `none \| window \| full`Retention of immutable finalized map results, independent of queryable output and delivery. | no | `{"default":"none"}` | | `processors[].artifacts.window` | `artifactWindow`Required in window mode, and only valid there. | no | `—` | | `processors[].artifacts.window.max_blocks` | `positiveInteger` | no | `{"minimum":1}` | | `processors[].artifacts.window.max_age` | `humanDuration` | no | `—` | | `processors[].artifacts.window.max_bytes` | `humanBytes` | no | `—` | | `processors[].output` | `outputPolicy` | yes | `—` | | `processors[].output.mode` | `none \| latest \| window \| full` | yes | `—` | | `processors[].output.window` | `outputWindow` | no | `—` | | `processors[].output.window.max_blocks` | `positiveInteger` | no | `{"minimum":1}` | | `processors[].output.window.max_age` | `humanDuration` | no | `—` | | `processors[].output.window.max_rows` | `positiveInteger` | no | `{"minimum":1}` | | `processors[].output.window.max_bytes` | `humanBytes` | no | `—` | | `processors[].delivery` | `deliveryPolicy` | yes | `—` | | `processors[].delivery.mode` | `none \| best_effort \| window \| until_acknowledged` | yes | `—` | | `processors[].delivery.emit` | `changes \| job_result` | no | `{"default":"changes"}` | | `processors[].delivery.max_bytes` | `humanBytes` | no | `—` | | `processors[].delivery.max_age` | `humanDuration` | no | `—` | | `processors[].delivery.on_limit` | `pause \| fail \| expire_and_reset` | no | `—` | | `processors[].delivery.pruning` | `deliveryPruning` | no | `—` | | `processors[].delivery.pruning.interval` | `humanDuration` | yes | `—` | | `processors[].delivery.pruning.minimum_batch_blocks` | `positiveInteger` | yes | `{"minimum":1}` | | `processors[].delivery.pruning.minimum_batch_changes` | `positiveInteger` | yes | `{"minimum":1}` | | `processors[].delivery.pruning.maximum_delete_changes` | `positiveInteger` | yes | `{"minimum":1}` | | `processors[].delivery.pruning.retain_finalized_blocks` | `positiveInteger` | yes | `{"minimum":1}` | | `processors[].delivery.pruning.retain_acknowledged_age` | `humanDuration` | yes | `—` | | `processors[].delivery.consumers` | `array` | no | `—` | | `processors[].delivery.consumers[].id` | `string` | yes | `{"minLength":1,"maxLength":128}` | | `processors[].delivery.consumers[].required` | `boolean` | yes | `—` | | `processors[].delivery.consumers[].lease_ttl` | `humanDuration`From one second to 100 years. | yes | `—` | | `processors[].checkpoint` | `checkpointPolicy` | yes | `—` | | `processors[].checkpoint.mode` | `none \| automatic` | yes | `—` | | `processors[].checkpoint.keep` | `nonNegativeInteger` | yes | `{"minimum":0}` | | `processors[].undo` | `undoPolicy` | yes | `—` | | `processors[].undo.mode` | `none \| unfinalized` | yes | `—` | | `processors[].undo.safety_blocks` | `nonNegativeInteger` | yes | `{"minimum":0}` | | `processors[].coverage` | `processorCoverage` | no | `—` | | `processors[].coverage.verification_segment_blocks` | `positiveInteger` | no | `{"minimum":1}` | | `processors[].live_gap_fill` | `processorLiveGapFill`Fill the blocks a block-local live lane skips, such as after a restart at a later finalized anchor, from history before it applies the next block, instead of only announcing them. Requires history\_mode = "on\_demand" and delivery.on\_limit = "pause". Every live lane waits while a fill runs. | no | `—` | | `processors[].live_gap_fill.max_blocks` | `integer`Largest skipped range the lane fills; it announces a longer one, and the rest of a fill that fails or runs longer than five minutes. | yes | `{"minimum":1,"maximum":7200}` | | `processors[].settings` | `object`Strictly decoded by the selected processor factory | no | `—` | ## `rpc` | Field | Type / values | Required here | Constraints | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ------------------------------- | | `rpc` | `rpc` | yes | `—` | | `rpc.http_bind` | `socketAddress`IPv4 or bracketed IPv6 socket address with port | yes | `—` | | `rpc.ws_bind` | `socketAddress`IPv4 or bracketed IPv6 socket address with port | yes | `—` | | `rpc.historical_mode` | `on_demand \| disabled` | yes | `—` | | `rpc.minimum_recent_blocks` | `positiveInteger` | yes | `{"minimum":1}` | | `rpc.allow_unauthenticated_remote` | `boolean`Serve JSON-RPC, which has no authentication, on a non-loopback http\_bind or ws\_bind. Only behind a trusted network or authenticating proxy. | no | `{"default":false}` | | `rpc.allowed_origins` | `allowedOrigins`Browser origins, besides loopback ones on any port, that may call JSON-RPC over HTTP or WebSocket. Any other Origin gets HTTP 403. | no | `{"default":[]}` | | `rpc.max_batch_requests` | `positiveInteger`Most calls in one batch; a longer batch is refused whole with -32600. | no | `{"minimum":1,"default":100}` | | `rpc.max_response_bytes` | `humanBytes`Most encoded bytes of one response or batch response; each call past it is answered with -32005. | no | `{"default":"16MiB"}` | | `rpc.max_log_results` | `positiveInteger`Most logs one eth\_getLogs call returns; more fail with -32005 and a smaller block range to retry. | no | `{"minimum":1,"default":10000}` | | `rpc.max_log_addresses` | `positiveInteger`Most addresses in one log filter; more fail with -32602. | no | `{"minimum":1,"default":1000}` | | `rpc.max_log_topic_alternatives` | `positiveInteger`Most alternatives at one topic position of a log filter; more fail with -32602. | no | `{"minimum":1,"default":1000}` | | `rpc.max_subscriptions_per_connection` | `positiveInteger`Most live subscriptions on one WebSocket connection; more fail with -32005. | no | `{"minimum":1,"default":128}` | | `rpc.max_websocket_connections` | `positiveInteger`Most open WebSocket connections; a further upgrade gets HTTP 503. | no | `{"minimum":1,"default":256}` | | `rpc.max_outbound_bytes` | `humanBytes`Queued subscription notification bytes shared across WebSocket connections and listeners; at least max\_subscription\_event\_bytes. | no | `{"default":"64MiB"}` | | `rpc.max_subscription_event_bytes` | `humanBytes`Most encoded bytes of subscription notifications one chain event may produce for one WebSocket connection, and that a connection may leave unread. A connection past the first is closed with 1008, past the second with 1013. | no | `{"default":"16MiB"}` |
# Ethereum JSON-RPC compatibility
> See exactly which Ethereum JSON-RPC methods Leani implements, refuses, or exposes only through native APIs.
Status: metadata, bounded recent-material HTTP, and live WebSocket profiles are implemented. Canonical header/transaction/receipt bytes are decoded into Alloy RPC types. Bounded on-demand number/range queries are implemented for configured sources that declare every required capability complete. ## 1. Product promise Leani can replace a paid RPC for the common methods its configured sources can answer exactly. It is not a full execution/archive node. Three answer paths exist: * **Recent:** serve from the rolling raw block/receipt cache. * **On-demand:** fetch the relevant historical chunk from Xatu/EraE/another backend, answer slowly, and discard it. * **Materialized:** serve from an operator-enabled durable index. The response should expose a non-standard diagnostic header or companion method indicating source, finality, and whether an answer was recent, on-demand, or materialized. ## 2. Method matrix The currently shipped `leani-evm-free-v1` profile implements `web3_clientVersion`, `net_version`, `eth_chainId`, `eth_blockNumber`, `eth_syncing`, `eth_config`, and `leani_getCapabilities` over HTTP, including JSON-RPC batches and notifications. The WebSocket endpoint also accepts regular calls and implements `newHeads` and filtered `logs` subscriptions from post-commit live events. Methods needing raw blocks, receipts, or logs return the explicit server error `-32004` until that material is present; state/EVM methods remain unsupported. The capability response is the machine-readable authority for a running node. `eth_config` follows final EIP-7910: it accepts no parameters and returns `current`, `next`, and `last` fork configurations with JSON-number blob parameters, chain ID, fork hash, precompiles, and system contracts. Its checked Mainnet schedule uses exact activation timestamps; it does not call an upstream configuration RPC. `current` is the fork active at the timestamp of the newest retained raw canonical block. This timestamp can be unavailable even when `eth_blockNumber` knows a newer canonical anchor. Forks activate by timestamp, so a node that retains no head frame, such as an empty one or one that has only backfilled, fails with `-32004` (`eth_config_head_timestamp_unavailable`) instead of choosing a fork by block number or taking the newest one. A head older than the first scheduled fork fails with `eth_config_head_predates_schedule`, and a node whose chain the schedule is not for fails with `eth_config_chain_schedule_unavailable`. Blob transaction receipts report `blobGasPrice` as the protocol blob base fee of the fork active at the block’s timestamp, from the same schedule and fee function as the `blobs-money` processor. Receipts are built for a whole block, so when that price cannot be computed, every receipt of a block with a blob transaction fails with `-32004`, the receipts of its other transactions included, from `eth_getBlockReceipts` and `eth_getTransactionReceipt` alike, rather than carrying a guessed price. `error.data.reason` says why: * `blob_gas_price_schedule_unavailable`: no scheduled fork covers the block’s timestamp, or the schedule is for another chain. A node on a chain other than Mainnet without a blobs processor uses the checked Mainnet schedule, so the receipts of every block with a blob transaction fail this way. * `header_excess_blob_gas_missing`: the block header has no excess blob gas. * `quantity_exceeds_u128`: the price does not fit in 128 bits. Only an excess blob gas that no valid chain reaches, as a forged header may claim, gets there. `eth_getLogs` can use Xatu’s predicate-complete projection under the trusted-dataset policy. Every requested block and required log field must be present; a partial projection, a narrower address/topic scope, or a broken parent link fails explicitly. Complete-cryptographic policy still requires complete material. Retained raw-log projections can serve the same requests without upstream access when their scope and fields cover the query. Decoded processor entities alone are not a lossless raw-log store: enable on-demand history or retain suitable raw material for historical RPC. `eth_blockNumber`, `latest`, and `eth_syncing.currentBlock` report the canonical tip: the newest delivered block at any level, `unattested` included, as an Ethereum RPC’s `latest` (see [finality levels](/docs/concepts/coverage-finality-and-reorgs/#finality-levels-and-publish-modes)). They report it independently of raw-frame pruning, or the highest block the progress processor has finalized when that is higher or no tip is known, as on a node without live following. A node that knows no block reports `0x0`, as a fresh Ethereum client does; `eth_blockNumber` never fails for want of a head. A reported head can lag the network, while disconnected or on a historical-only node; consult `eth_syncing`, which reports `liveReady: false` then, before treating it as the network tip. For a number/range request outside the recent window, the router selects the lowest-priority viable `HistorySource`, enforces finality/trust and hard byte/frame/concurrency/timeout limits, validates ordered parent-linked frames, and fails over only at the whole-request boundary. Frames are discarded after serialization. A range crossing into the recent window must have an exact parent-hash handoff. Hash-only transaction/receipt history still requires a durable locator and therefore remains recent-only by default. | Method | EVM-free support | Default behavior | Caveat | | ------------------------------- | ----------------------------------- | ----------------------------------- | ---------------------------------------------------------------------- | | `web3_clientVersion` | Exact | Local | — | | `net_version` | Exact | Local | — | | `eth_chainId` | Exact | Local | — | | `eth_blockNumber` | Exact for the known canonical chain | Canonical tip or finalized coverage | `0x0` before any block is known; never an unfinalized processor cursor | | `eth_syncing` | Exact for this node | Canonical head and live readiness | A disconnected or historical-only node does not claim live readiness | | `eth_getBlockByNumber` | Exact with body source | Recent/on-demand | Historical latency depends on backend | | `eth_getBlockByHash` | Exact with locator | Recent; optional index | Hash-to-chunk lookup otherwise expensive | | `eth_getBlockTransactionCount*` | Exact with body | Recent/on-demand | — | | `eth_getTransactionByBlock*` | Exact with body | Recent/on-demand | — | | `eth_getTransactionByHash` | Exact with locator | Recent; optional index | Global tx locator consumes storage | | `eth_getTransactionReceipt` | Exact with receipt locator | Recent; optional index | Same lookup issue | | `eth_getBlockReceipts` | Exact with receipts | Recent/on-demand by block | Excellent fit for P2P/EraE | | `eth_getLogs` | Exact with complete logs | Recent/on-demand/materialized | Filtered datasets are trusted for completeness | | `eth_feeHistory` | Not shipped | Explicit unavailable error | Requires a separately tested client-compatible policy | | `eth_gasPrice` | Planned | Unsupported | Client policy semantics differ | | `eth_maxPriorityFeePerGas` | Planned | Unsupported | Client policy semantics differ | | `eth_subscribe(newHeads)` | Exact | Live | Replacement heads are emitted after branch commit | | `eth_subscribe(logs)` | Exact | Live | Reorged logs emit `removed: true` before replacements | | filter polling methods | Feasible | Later | Stateful server-side filters | | `eth_sendRawTransaction` | Feasible transport | Optional | P2P broadcast or fallback, not indexing core | | `eth_getBalance` | Not general | Unsupported | Requires executed state, including internal effects | | `eth_getTransactionCount` | Not general | Unsupported | Contract nonces/internal creation require execution | | `eth_getCode` | No | Unsupported | Requires state | | `eth_getStorageAt` | No | Unsupported | Requires state | | `eth_call` | No | Unsupported/optional forward | Requires historical code/storage and EVM | | `eth_estimateGas` | No | Unsupported/optional forward | Requires EVM | | debug/trace methods | No from receipts | Unsupported | May be served only by a trace-capable dataset/backend | “On-demand” cannot make every hash-only lookup cheap. Block-number/range queries map naturally to chunked archives. Transaction-hash queries need a locator service or a compact local `tx_hash -> block_number` index. ### Requests, block tags, and missing blocks A call whose `id` is `null` gets a response with a `null` ID; only a call without an `id` member is a notification. An `id` that is an object, an array, or a boolean gets `-32600` with a `null` ID. A call must be a JSON object: an array in its place, such as `["2.0", 1, "eth_chainId"]`, gets `-32600` with a `null` ID too. A notification runs like any other call, side effects included, and gets no response, as JSON-RPC 2.0 requires: an `eth_unsubscribe` notification ends its subscription, and an `eth_subscribe` notification opens one whose ID the client never learns. A batch answers only its calls with IDs, and a batch of only notifications gets no response at all (HTTP 204). Block numbers and transaction indexes are QUANTITY values: `0x` and one or more hexadecimal digits, without leading zeros except in `0x0`, at most 64 bits. Anything else, such as `0x+1`, `0x01`, or `0x`, gets `-32602`. Wherever a block number is accepted, including `fromBlock` and `toBlock` of `eth_getLogs`, these tags are too: | Tag | Block | | ----------- | ------------------------------------------------------------------------------ | | `latest` | the head `eth_blockNumber` reports, at any level | | `finalized` | the newest canonical block the node has verified as finalized | | `safe` | the same block as `finalized` | | `earliest` | block 0 | | `pending` | none: Leani builds no pending block, so `-32004` (`pending_block_unavailable`) | Until the node has verified a finalized block, `finalized` and `safe` fail with `-32004` (`finalized_block_unavailable`). Leani does not track the justified checkpoint that execution clients call safe, so `safe` names the finalized head, which is never newer. A client that waits for `safe` blocks to avoid reorgs therefore stays safe, but it sees an older block than other clients report. Block lookups return `null` only for a number above every block the node knows exists: its canonical tip and the progress processor’s indexed height. Ethereum clients read `null` as “no such block”, and a reorg checker concludes that a block which turned `null` was reorged out. So a block at or below the head that neither the recent window nor on-demand history serves fails with `-32004` instead: `block_not_retained`, or the history source’s reason when history was asked. A block hash that the recent window does not hold fails with `block_not_retained` unless on-demand history offers a block-hash lookup, in which case `null` means that lookup does not know the hash. This applies to `eth_getBlockByNumber`, `eth_getBlockByHash`, `eth_getBlockReceipts`, `eth_getBlockTransactionCountBy*`, and `eth_getTransactionByBlock*AndIndex`, which still returns `null` for an index past the last transaction of a block it serves. `latest` resolves the canonical tip and may fetch its raw material on demand; without a known tip it fails with `block_not_retained`. `earliest` names block 0, so it requires retained or historical material for block 0. `eth_getLogs` with `blockHash` returns only logs of the block with that hash. If a reorg replaces that block between resolving the hash and reading the block, the call fails with `-32004` rather than return the replacement’s logs, as it does once the reorg has happened (`block_hash_not_retained`). An empty alternatives array at a topic position, `[]`, matches any topic there, as `null` does. ### Transport rules and limits HTTP calls need `content-type: application/json`, with any parameters, and get HTTP 415 otherwise. A browser `Origin` other than a loopback one (`http(s)://localhost`, `127.0.0.1`, or `[::1]` on any port) or one listed in `rpc.allowed_origins` gets HTTP 403, on HTTP calls and WebSocket upgrades alike; requests without an `Origin` header pass. A cross-site page therefore cannot post a `text/plain` batch or open a WebSocket to a loopback node. A request body that is not valid UTF-8 JSON gets HTTP 200 with a `-32700` parse error and a `null` ID. A body over 1 MiB gets HTTP 413 with a JSON-RPC error, not plain text: `-32005` with a `null` ID, and `error.data` holding `reason` `request_size_limit_exceeded` and the `limit` in bytes. | Limit | Default | Setting | When exceeded | | ---------------------------------------------------------------------------------------------- | ------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Request body or WebSocket message | 1 MiB | — | HTTP 413 with a `-32005` error; a longer WebSocket message ends the connection | | Calls per batch | 100 | `rpc.max_batch_requests` | one `-32600` error object for the whole batch | | Response bytes | 16 MiB | `rpc.max_response_bytes` | `-32005` for the call that passes the limit, or whose `eth_getLogs` result would, and for every later call in the batch, which do not run; later notifications still run | | `eth_getLogs` results | 10,000 | `rpc.max_log_results` | `-32005` “query returned more than 10000 results. Try with this block range \[0x…, 0x…].” | | Filter addresses | 1,000 | `rpc.max_log_addresses` | `-32602` | | Alternatives per topic position | 1,000 | `rpc.max_log_topic_alternatives` | `-32602` | | Subscriptions per WebSocket connection | 128 | `rpc.max_subscriptions_per_connection` | `-32005` from `eth_subscribe` | | WebSocket connections | 256 | `rpc.max_websocket_connections` | HTTP 503 on the upgrade | | Subscription notifications of one chain event, per connection | 16 MiB | `rpc.max_subscription_event_bytes` | the connection is closed with code `1008` | | Unsent subscription notifications, per connection | 16 MiB | `rpc.max_subscription_event_bytes` | the connection is closed with code `1013` | | Queued or sending notifications, all WebSocket connections | 64 MiB | `rpc.max_outbound_bytes` | the connection holding the most is closed with code `1013` | | Notifications kept queued past a quarter of `rpc.max_subscription_event_bytes`, per connection | 30 s | — | the connection is closed with code `1013` | The `eth_getLogs` hint names the range from `fromBlock` up to the block before the one where the results passed the limit, and `error.data` carries it as `from`, `to`, and `limit`. The hint is missing when the first block alone has more matching logs than the limit; read that block with `eth_getBlockReceipts` or raise the limit. The filter limits also apply to `eth_subscribe("logs")` filters. Closing a WebSocket connection frees its slot and ends its subscriptions. The subscriptions of one WebSocket connection share an output budget per chain event: the notifications that one block, or one reorg with its removed and replacement logs, produces for the connection may take at most `rpc.max_subscription_event_bytes` encoded bytes. The node serializes a log or head once, however many of the connection’s subscriptions it matches, but each notification counts in full. A connection whose subscriptions pass the budget is closed with code `1008` (policy violation), without that event’s notifications, and with a close reason that names the setting. Its subscriptions would pass the budget again on a like block, so reconnect with fewer or narrower subscriptions, or spread them over several connections. The node also holds at most `rpc.max_subscription_event_bytes` of a connection’s notifications unsent. A client that reads more slowly than its subscriptions produce notifications is closed with code `1013` (try again later), as it is when it falls behind the node’s bounded event channel, and may reconnect. Notifications are queued one at a time, so while earlier ones are still unsent, a connection can get `1013` before one event’s notifications pass the budget that would close it with `1008`. Notifications still unsent when a connection is closed are dropped, and a connection that cannot take its close frame within a second is dropped without one. Other connections are unaffected. A call’s response is sent before the connection’s next message is read, so a client that stops reading stops being answered too. ## 3. RPC profiles ### Minimal * chain metadata; * current head; * recent blocks, transactions, receipts, logs; * recent fee history; * new-head and log subscriptions; * custom aggregate queries/subscriptions. Storage is aggregate output plus a small recent window. ### Indexer Minimal plus: * historical `eth_getLogs`; * block-number-based historical blocks/receipts; * configurable log materialization; * longer recent window. Historical answers may be slow unless their data is materialized. ### RPC-heavy Indexer plus: * transaction locator; * transaction/receipt index; * larger raw window; * response cache; * optional forwarding for state methods. This profile intentionally uses more storage. ## 4. ERC-20 balance semantics ### What can be derived For a token where every balance change produces a standard:
```solidity
Transfer(address indexed from, address indexed to, uint256 value)
```
the node can maintain:
```text
balance[token, from] -= value
balance[token, to] += value
```
with zero-address handling for mint and burn. Logs from reverted transactions do not appear in the canonical receipt and therefore do not change the derived balance. ### What can break the model * rebasing or reflection changes balances without per-holder `Transfer` events; * deliberately non-standard tokens; * unusual proxy upgrades that change balance semantics; * tokens whose historical starting state predates the selected start block; * metadata changes or contracts that revert on metadata calls; * migrations that create balances outside normal transfer events. The API should call this an `event-derived token balance`, include the processed block, and expose its assumptions. “Declared” means that the processor manifest and query result declare: * the chain, token/address scope, and starting block; * the derivation method, such as `erc20_transfer_ledger`; * the highest processed and finalized blocks; * whether historical backfill and the live handoff are complete; * the token behavior assumptions under which the value is exact. For a conventional ERC-20 indexed from before its initial mint, this value should be exact. The declaration exists to distinguish that result from generic EVM state for rebasing or non-standard tokens. The node should not transparently intercept arbitrary `eth_call(balanceOf)` and claim universal equivalence. Illustrative response metadata:
```json
{
"token": "0x...",
"address": "0x...",
"balance": "123000000",
"asOfBlock": 25620123,
"finalizedThrough": 25620064,
"method": "erc20_transfer_ledger",
"coverageFrom": 1234567,
"complete": true
}
```
A custom `idx_getTokenBalances` method should be the canonical API. An optional `alchemy_getTokenBalances` compatibility adapter could be added for migrations, but it must return only the declared indexed coverage and completeness metadata. ### Storage modes | Mode | Retained state | New arbitrary address | | --------------------- | -------------------------------- | ------------------------------- | | Address watchlist | Only watched address/token pairs | Requires targeted backfill | | Token allowlist | All holders for selected tokens | Immediate within allowed tokens | | Global current ledger | All non-zero token/address pairs | Immediate, larger database | | Full balance history | Every change/checkpoint | Largest; opt-in | For minimal deployments, watchlist mode is the default. Xatu’s predecoded ERC-20 transfer Parquet table makes a targeted historical backfill possible without querying a paid RPC. The live source still sees all receipt logs but retains only relevant deltas. Adding a watch address should be a first-class job: 1. atomically register address and start cursor; 2. capture new live deltas for it immediately; 3. backfill historical incoming/outgoing transfers; 4. reconcile at an overlapping block; 5. mark the derived balance complete. This is the same cold/hot handoff problem in miniature. ### Historical balance retention Current balance requires one row per retained `(token, address)`. Historical `balance at block N` requires more: * every transfer delta; * periodic snapshots plus later deltas; * or re-running a public-data query on demand. The default should keep current balances and a small undo window. Historical balance queries can be slower and replay public transfer data unless explicitly materialized. ## 5. Native ETH balances Native balances are not equivalent to top-level transaction value transfers. They also change through: * gas charges and priority fees; * internal calls; * contract creation; * withdrawals; * protocol rewards in older history; * other execution-level balance changes. Xatu currently publishes `canonical_execution_balance_diffs` and native transfer tables, so it can act as a trusted historical executed-state source. That does not solve low-latency live native balances through receipt-only P2P: generating complete live balance diffs requires EVM execution or another executed-data provider. Therefore: * `idx_getNativeBalance` may be offered when a `STATE_DIFFS` backend is configured and sufficiently current; * standard `eth_getBalance` remains unsupported until the node has an exact state source; * responses must report backend lag and trust. ## 6. NFT balances * ERC-721 ownership is generally derivable from `Transfer`. * ERC-1155 requires both `TransferSingle` and `TransferBatch`. * The same non-standard/rebase caveats and retention modes apply. These should follow ERC-20 support rather than complicate the first implementation. ## 7. Aggregate subscriptions Native aggregate subscriptions should use durable cursors rather than transient WebSocket connection state:
```text
processor_id
processor_version
block_number
block_hash
change_sequence
finality
```
Event kinds: * `apply` * `undo` * `finalized` * `reset_required` * `processor_complete` Clients resume from a cursor. If the node has pruned the required change history, it returns `reset_required` with the latest queryable checkpoint. The v1 native transport is HTTP query plus SSE, with a TypeScript SDK that handles reconnect and opaque cursors. Standard `eth_subscribe` remains WebSocket JSON-RPC and follows Ethereum’s subscription semantics. See [native API and delivery protocol](https://leani.dev/docs/reference/native-api-and-delivery/).
# Native HTTP API
> Generated inventory of the versioned native HTTP contract from the tracked OpenAPI document.
This inventory is generated from `api/openapi.yaml`. Operation IDs, methods, paths, summaries, authentication policy, and response codes change here in the same pull request as the contract. ## Administration | Method | Path | Purpose | Auth | | -------- | --------------------------------------------------- | --------------------------------------- | --------------- | | `GET` | `/admin/v1/backfill-subscriptions` | List backfill subscriptions | optional bearer | | `POST` | `/admin/v1/backfill-subscriptions` | Create a backfill subscription | optional bearer | | `GET` | `/admin/v1/backfill-subscriptions/{id}` | Inspect a backfill subscription | optional bearer | | `DELETE` | `/admin/v1/backfill-subscriptions/{id}` | Delete a terminal backfill subscription | optional bearer | | `POST` | `/admin/v1/backfill-subscriptions/{id}/cancel` | Cancel a backfill subscription | optional bearer | | `POST` | `/admin/v1/backfill-subscriptions/{id}/retry` | Retry a failed backfill subscription | optional bearer | | `GET` | `/admin/v1/materialization-jobs` | List materialization jobs | optional bearer | | `POST` | `/admin/v1/materialization-jobs` | Create a materialization job | optional bearer | | `GET` | `/admin/v1/materialization-jobs/{id}` | Inspect a materialization job | optional bearer | | `DELETE` | `/admin/v1/materialization-jobs/{id}` | Delete a terminal materialization job | optional bearer | | `POST` | `/admin/v1/materialization-jobs/{id}/cancel` | Cancel a materialization job | optional bearer | | `DELETE` | `/admin/v1/processors/{processor}/artifacts` | Delete retained processor artifacts | optional bearer | | `POST` | `/admin/v1/processors/{processor}/artifacts/replay` | Replay retained processor artifacts | optional bearer | | `POST` | `/admin/v1/processors/{processor}/lanes/live/reset` | Reset a processor live lane for replay | optional bearer | | `GET` | `/admin/v1/raw-history-jobs` | List raw-history jobs | optional bearer | | `POST` | `/admin/v1/raw-history-jobs` | Create a raw-history job | optional bearer | | `GET` | `/admin/v1/raw-history-jobs/{id}` | Inspect a raw-history job | optional bearer | | `DELETE` | `/admin/v1/raw-history-jobs/{id}` | Delete a terminal raw-history job | optional bearer | | `POST` | `/admin/v1/raw-history-jobs/{id}/cancel` | Cancel a raw-history job | optional bearer | ## Other | Method | Path | Purpose | Auth | | ------ | ---------------- | -------------------------------------------- | ------ | | `GET` | `/debug/network` | Render the dependency-free network dashboard | public | ## Health and observability | Method | Path | Purpose | Auth | | ------ | --------------- | ----------------------- | ------ | | `GET` | `/health/live` | Check process liveness | public | | `GET` | `/health/ready` | Check node readiness | public | | `GET` | `/metrics` | Read Prometheus metrics | public | ## Node | Method | Path | Purpose | Auth | | -------- | ------------------------------------------------------------------------ | --------------------------------------------- | --------------- | | `POST` | `/v1/backfill-subscriptions/{subscription}/consumers/{consumer}/ack` | Acknowledge a backfill subscription batch | optional bearer | | `GET` | `/v1/backfill-subscriptions/{subscription}/consumers/{consumer}/changes` | Poll changes from one backfill subscription | optional bearer | | `POST` | `/v1/backfill-subscriptions/{subscription}/consumers/{consumer}/lease` | Renew a backfill consumer lease | optional bearer | | `DELETE` | `/v1/backfill-subscriptions/{subscription}/consumers/{consumer}/lease` | Release a backfill consumer lease | optional bearer | | `GET` | `/v1/backfill-subscriptions/{subscription}/consumers/{consumer}/stream` | Stream changes from one backfill subscription | optional bearer | | `GET` | `/v1/capabilities` | Discover node capabilities | optional bearer | | `GET` | `/v1/network/status` | Read execution network manager status | public | | `GET` | `/v1/processors` | List configured processors | optional bearer | | `GET` | `/v1/status` | Read node and processor status | optional bearer | ## Processor data and delivery | Method | Path | Purpose | Auth | | -------- | ---------------------------------------------------------------------- | ----------------------------------------------------------- | --------------- | | `GET` | `/v1/processors/{processor}/artifacts/{block}` | Read one retained processor artifact | optional bearer | | `GET` | `/v1/processors/{processor}/artifacts/export` | Export a retained processor artifact range | optional bearer | | `GET` | `/v1/processors/{processor}/artifacts/status` | Read retained processor artifact coverage | optional bearer | | `GET` | `/v1/processors/{processor}/changes` | List processor changes | optional bearer | | `GET` | `/v1/processors/{processor}/changes/head` | Read the processor change head | optional bearer | | `GET` | `/v1/processors/{processor}/checkpoints` | List processor recovery checkpoints | optional bearer | | `POST` | `/v1/processors/{processor}/checkpoints/{checkpoint}/restore` | Restore a processor recovery checkpoint | optional bearer | | `GET` | `/v1/processors/{processor}/collections` | List processor collections | optional bearer | | `GET` | `/v1/processors/{processor}/collections/{collection}/entities` | Query processor entities | optional bearer | | `POST` | `/v1/processors/{processor}/collections/{collection}/entities` | Snapshot processor entities | optional bearer | | `GET` | `/v1/processors/{processor}/collections/{collection}/entities/{key}` | Read a processor entity | optional bearer | | `POST` | `/v1/processors/{processor}/collections/{collection}/query-and-follow` | Query a collection and open a race-free follow boundary | optional bearer | | `GET` | `/v1/processors/{processor}/consumers` | List durable consumers | optional bearer | | `POST` | `/v1/processors/{processor}/consumers` | Create a durable consumer | optional bearer | | `GET` | `/v1/processors/{processor}/consumers/{consumer}` | Read a durable consumer | optional bearer | | `DELETE` | `/v1/processors/{processor}/consumers/{consumer}` | Revoke a durable consumer | optional bearer | | `POST` | `/v1/processors/{processor}/consumers/{consumer}/ack` | Acknowledge durable consumer changes | optional bearer | | `GET` | `/v1/processors/{processor}/consumers/{consumer}/changes` | List durable consumer changes | optional bearer | | `POST` | `/v1/processors/{processor}/consumers/{consumer}/lease` | Renew a durable consumer lease | optional bearer | | `DELETE` | `/v1/processors/{processor}/query-snapshots/{snapshot}` | Release a processor query snapshot | optional bearer | | `GET` | `/v1/processors/{processor}/query/blocks/{number}` | Read an indexed Ethereum block summary by block number | optional bearer | | `GET` | `/v1/processors/{processor}/query/latest` | Read the latest indexed Ethereum block summary | optional bearer | | `GET` | `/v1/processors/{processor}/query/pools` | List pools configured for a Uniswap observation processor | optional bearer | | `GET` | `/v1/processors/{processor}/query/pools/{address}/latest` | Read the latest observation for one configured Uniswap pool | optional bearer | | `GET` | `/v1/processors/{processor}/savepoints` | List processor savepoints | optional bearer | | `POST` | `/v1/processors/{processor}/savepoints` | Create a processor savepoint | optional bearer | | `GET` | `/v1/processors/{processor}/savepoints/{savepoint}` | Export a processor savepoint | optional bearer | | `DELETE` | `/v1/processors/{processor}/savepoints/{savepoint}` | Delete a processor savepoint | optional bearer | | `GET` | `/v1/processors/{processor}/schema` | Read a processor schema | optional bearer | | `GET` | `/v1/processors/{processor}/status` | Read processor status | optional bearer | | `GET` | `/v1/processors/{processor}/stream` | Stream processor changes | optional bearer | | `POST` | `/v1/processors/{processor}/streams/live/consumers/{consumer}/ack` | Acknowledge a live-lane consumer batch | optional bearer | | `POST` | `/v1/processors/{processor}/streams/live/consumers/{consumer}/lease` | Renew a live-lane consumer lease | optional bearer | | `DELETE` | `/v1/processors/{processor}/streams/live/consumers/{consumer}/lease` | Release a live-lane consumer lease | optional bearer | | `GET` | `/v1/processors/{processor}/streams/live/consumers/{consumer}/stream` | Stream the dedicated live lane for a durable consumer | optional bearer | ## Typed query extensions | Method | Path | Purpose | Auth | | ------ | ---------------------------------------- | ---------------------------------- | --------------- | | `GET` | `/v1/q/blobs/blocks` | List indexed blob blocks | optional bearer | | `GET` | `/v1/q/blobs/blocks/{number}` | Read an indexed blob block | optional bearer | | `GET` | `/v1/q/blobs/schedule` | Read the active blob schedule | optional bearer | | `GET` | `/v1/q/blobs/snapshot` | Read an atomic blob block snapshot | optional bearer | | `GET` | `/v1/q/blobs/transactions` | List indexed blob transactions | optional bearer | | `GET` | `/v1/q/blobs/transactions/{hash}` | Read an indexed blob transaction | optional bearer | | `GET` | `/v1/q/erc20/balances/{token}/{address}` | Read an indexed ERC-20 balance | optional bearer | | `GET` | `/v1/q/uniswap/pools/{address}` | Read an indexed Uniswap pool | optional bearer |
# Native API and delivery protocol
> Understand snapshots, retained queries, durable consumers, change envelopes, errors, and capability discovery.
Status: implemented v1 contract baseline, 2026-08-01. Leani exposes two related interfaces: 1. Ethereum JSON-RPC for applications expecting an execution-client-shaped endpoint. 2. A native aggregate API for processor status, queries, and durable change streams. The TypeScript SDK targets the native API. Existing Ethereum libraries such as ethers or viem can use the node’s JSON-RPC URL directly. ## 1. Why HTTP plus SSE first blobs.money runs TypeScript on Bun and already uses `fetch`, ethers, and WebSockets. The first aggregate transport should therefore be: * ordinary versioned HTTP/JSON for queries; * server-sent events over streaming `fetch` for a one-way durable change feed; * OpenAPI for machine-readable query/request/response types. SSE fits the required direction, works through common proxies, has a simple sequence/resume model, and does not require the application to implement a bidirectional protocol. The SDK uses streaming `fetch` rather than the browser `EventSource` API so it can send authorization headers and work consistently in Bun, Node, and modern browsers. Standard `eth_subscribe` remains WebSocket JSON-RPC. gRPC can be added for high-throughput non-JavaScript consumers without changing change semantics. ## 2. Interface layout Default listeners:
```text
http://127.0.0.1:8080/v1/... aggregate API
http://127.0.0.1:8545 Ethereum JSON-RPC
ws://127.0.0.1:8546 Ethereum subscriptions
```
The current binary serves all three listeners. The WebSocket endpoint accepts ordinary JSON-RPC calls plus `eth_subscribe`/`eth_unsubscribe` for `newHeads` and filtered `logs`. Subscription events are published only after the live branch commits; reorged logs are sent with `removed: true` before replacement logs. A client that falls behind the bounded event channel, or leaves more than `rpc.max_subscription_event_bytes` of notifications unread, is closed with code 1013 and must reconnect. `eth_subscribe` limits apply per connection: at most `rpc.max_subscriptions_per_connection` subscriptions, whose notifications for one block or reorg may take at most `rpc.max_subscription_event_bytes`. A connection past that is closed with code 1008; reconnect with fewer or narrower subscriptions, or spread them over several connections. When the node shuts down, every connection is closed with code 1001. See [Ethereum JSON-RPC limits](https://leani.dev/docs/reference/ethereum-json-rpc/#transport-rules-and-limits). Aggregate routes:
```text
GET /health/live
GET /health/ready
GET /debug/network
GET /metrics
GET /v1/status
GET /v1/network/status
GET /v1/capabilities
GET /v1/processors
GET /v1/processors/{processor}/status
GET /v1/processors/{processor}/schema
GET /v1/processors/{processor}/changes/head
GET /v1/processors/{processor}/changes
GET /v1/processors/{processor}/stream
GET /v1/processors/{processor}/collections
GET /v1/processors/{processor}/collections/{collection}/entities
POST /v1/processors/{processor}/collections/{collection}/entities
GET /v1/processors/{processor}/collections/{collection}/entities/{key}
POST /v1/processors/{processor}/collections/{collection}/query-and-follow
DELETE /v1/processors/{processor}/query-snapshots/{snapshot}
GET /v1/processors/{processor}/checkpoints
POST /v1/processors/{processor}/checkpoints/{checkpoint}/restore
GET /v1/processors/{processor}/savepoints
POST /v1/processors/{processor}/savepoints
GET /v1/processors/{processor}/savepoints/{savepoint}
DELETE /v1/processors/{processor}/savepoints/{savepoint}
GET /v1/processors/{processor}/consumers
POST /v1/processors/{processor}/consumers
GET /v1/processors/{processor}/consumers/{consumer}
DELETE /v1/processors/{processor}/consumers/{consumer}
POST /v1/processors/{processor}/consumers/{consumer}/lease
GET /v1/processors/{processor}/consumers/{consumer}/changes
POST /v1/processors/{processor}/consumers/{consumer}/ack
GET /v1/processors/{processor}/query/*
GET /v1/q/blobs/blocks
GET /v1/q/blobs/blocks/{number}
GET /v1/q/blobs/snapshot
GET /v1/q/blobs/transactions
GET /v1/q/blobs/transactions/{hash}
GET /v1/q/blobs/schedule
GET /v1/q/erc20/balances/{token}/{address}
GET /v1/q/uniswap/pools/{address}
```
Health distinguishes process/store liveness from required live/finality readiness and processor range coverage. The operational routes, `/health/*`, `/debug/network`, `/metrics`, and `/v1/network/status`, are not behind the optional API bearer token so a protected local supervisor can probe them. When a bearer token is configured, every other route above, reads and writes alike, needs it, as the `/admin/v1` routes below do: `/v1` names the API version, not a public tier. Neither health route scans store tables: liveness runs a trivial SQLite query, and readiness adds in-memory actor state. `/metrics` and `/v1/network/status` reuse store row and byte counts for up to 10 seconds. ERC-20 and Uniswap streams share the generic processor cursor contract; their typed queries and changes encode large quantities as lossless decimal strings. The `/query/*` subtree is supplied by the selected processor’s optional native query extension. It is always scoped to that exact processor instance. The short `/v1/q/blobs`, `/v1/q/erc20`, and `/v1/q/uniswap` forms are aliases installed only when a single configured extension claims the name; canonical paths are reported by `/v1/capabilities`. Local administration, intentionally separate from the public SDK:
```text
POST /admin/v1/materialization-jobs
GET /admin/v1/materialization-jobs
GET /admin/v1/materialization-jobs/{id}
POST /admin/v1/materialization-jobs/{id}/cancel
DELETE /admin/v1/materialization-jobs/{id}
POST /admin/v1/backfill-subscriptions
GET /admin/v1/backfill-subscriptions
GET /admin/v1/backfill-subscriptions/{id}
POST /admin/v1/backfill-subscriptions/{id}/cancel
POST /admin/v1/backfill-subscriptions/{id}/retry
DELETE /admin/v1/backfill-subscriptions/{id}
POST /admin/v1/raw-history-jobs
GET /admin/v1/raw-history-jobs
GET /admin/v1/raw-history-jobs/{id}
POST /admin/v1/raw-history-jobs/{id}/cancel
DELETE /admin/v1/raw-history-jobs/{id}
POST /admin/v1/processors/{processor}/lanes/live/reset
DELETE /admin/v1/processors/{processor}/artifacts
POST /admin/v1/processors/{processor}/artifacts/replay
```
There is no route that rebuilds a processor instance in place. A processor whose coverage contradicts the canonical chain is rebuilt as a replacement instance; its live-lane reset answers `409 live_lane_requires_rebuild` (see the operations runbook). The first administration client can be the Rust CLI over a Unix socket or localhost-only HTTP. Starting or cancelling a backfill changes durable state and should not be mixed with public queries. Processor historical scheduling is independent from RPC history. With `history_mode = "on_demand"`, the canonical live lane and normal JSON-RPC/head subscriptions still start immediately, while that processor waits for an explicit historical job instead of automatically filling from its semantic `start_block`. Create an idempotent node-owned range-to-SQLite job:
```http
POST /admin/v1/materialization-jobs
content-type: application/json
{
"processor": "blobs-production",
"fromBlock": 25600000,
"toBlock": 25610000,
"mode": "fill_missing",
"idempotencyKey": "local-analysis-25600000-25610000-v1"
}
```
`history_control = "node_owned"` plus `history_mode = "on_demand"` permits these jobs. They process missing finalized coverage, retain output according to the processor’s SQLite output policy, and require no consumer or acknowledgement. The request range is not artificially capped; bounded source chunks and runtime budgets keep memory independent from total range length. Application database repair instead uses `POST /admin/v1/backfill-subscriptions` on an `application_subscriptions + on_demand` processor. That operation creates its required consumer and independent history delivery stream atomically. Its `fill_missing` and `recompute` publication semantics are consumer-facing and do not grant the application scheduling authority over a node-owned processor. All control routes share protected API authorization and do not alter JSON-RPC or WebSocket availability. A `fill_missing` subscription owns the creation-time gaps in its requested ranges. If another overlapping job covers one of those blocks first, the subscription still republishes that verified block into its independent stream; shared processor coverage cannot silently satisfy another subscription’s delivery obligation. Completed, failed, and cancelled jobs are terminal and are never retried implicitly; re-submitting a request with the same idempotency key returns the terminal job’s status. A failed subscription is retried explicitly with `POST /admin/v1/backfill-subscriptions/{id}/retry`, for example after a transient outage or after upgrading past the fault that failed it. It resumes from its committed progress with the same history stream, consumer, credential, and acknowledgement, and its error is cleared. Retrying a subscription that is still active returns its status unchanged; a completed or cancelled subscription gets `409 backfill_conflict`. A failed subscription whose processor instance is no longer configured gets `400 invalid_request` and stays as it was. A failed materialization job is not retried: delete it and create it again, which is what `leani backfill` does with a fresh idempotency key. A failed or cancelled subscription’s stream still delivers every record its job committed, then holds its session, with heartbeats, until the consumer has acknowledged what it was delivered, and only then ends with `backfill_failed` or `backfill_cancelled`. Deleting such a subscription is refused with `409 backfill_conflict` while its required consumer has unacknowledged records: read the stream to that end and acknowledge, or delete with `?discardUnacknowledged=true` when the application does not need them. The parameter never applies to a subscription that is still running. Deleting a job right after cancelling it waits up to 10 seconds for its task to stop, and answers `503 backfill_unavailable`, which is retryable, if it has not; a retry waits the same way for a failed job’s task. A subscription whose job has finished is `draining` until its required consumer acknowledges the `backfill_complete` record, which makes it `complete_reclaimable`; its stream ends once that acknowledgement lands. A stream opened after that sends the same `backfill_complete` record again, with the same `cursor`, and ends at once instead of sending heartbeats indefinitely. Acknowledging that cursor again with the stream’s session token succeeds even after the stream has ended, until another stream replaces that session. Should the record no longer be readable, the stream ends with `backfill_completed` instead. A draining subscription can be cancelled as well, for example when its application will never read it to the end. It becomes `cancelled` and keeps its job’s report. Its stream still serves the records committed before the cancellation, `backfill_complete` among them, but acknowledging them leaves the subscription `cancelled`, and a stream opened after that ends with `backfill_cancelled`. Delete it once they are acknowledged, or with `?discardUnacknowledged=true` to abandon them. Cancelling it and deleting it with `?discardUnacknowledged=true` work even after its processor instance was removed from the configuration, when its stream and acknowledgement routes refuse (normally `404 not_found`). A subscription’s status names its required consumer in `consumer`, so an application finds its own subscriptions in `GET /admin/v1/backfill-subscriptions` by that field instead of parsing the opaque `id`; a materialization job’s `consumer` is `null`. `processorConfigured` says whether the node configures the processor instance named in `processor` now: exactly that instance, not another instance of the same kind. While it is `false`, the stream and acknowledgement routes refuse the subscription. That can be temporary, as when a restart’s configuration leaves the instance out by mistake. If the instance will not return, cancel the subscription and delete it with `?discardUnacknowledged=true`, which discards its unacknowledged records; [Processor rebuild or rollback](/docs/operations/runbook/#processor-rebuild-or-rollback) gives the steps around replacing an instance. ## 3. Common response model Every processor query includes coverage:
```ts
export interface ProcessorCoverage {
chainId: number;
chainFinalizedHead: {
number: number;
hash: `0x${string}`;
} | null;
requested?: {
fromBlock: number;
toBlock: number;
} | null;
available: Array<{
fromBlock: number;
toBlock: number;
finality: "unattested" | "attested" | "finalized";
}>;
configuredStartBlock: number;
processedThrough: number | null;
finalizedThrough: number | null;
complete: boolean;
state:
| "starting"
| "backfilling"
| "catching_up"
| "live"
| "degraded"
| "paused"
| "failed";
}
```
`paused` and `failed` report the processor’s live lane; `/v1/network/status` gives its `pauseReason` and first unapplied block (`liveGap`). Otherwise `starting` means nothing is applied yet and `backfilling` that the processor’s range has gaps. `catching_up` means it has none but the processor has not reached the chain’s finalized head (`chainFinalizedHead`), or the node is not ready yet. Only then is it `live`. Every `available` interval is inclusive and has one exact finality for every block it contains. Intervals split at both coverage gaps and finality boundaries, so consumers selecting finalized material must filter these ranges rather than treating `finalizedThrough` as a contiguous watermark. `finalizedThrough` is only the highest covered block recorded as finalized; lower gaps or non-finalized coverage can still exist. For a block-local processor, `available` can contain several intervals while a backfill and head follower operate concurrently. `processedThrough` is the last block of the highest interval, even when earlier ones leave gaps; `complete` says whether the requested range, or the range from the configured start to the processor’s cursor, has none. All list responses use opaque pagination cursors:
```ts
export interface Page {
data: T[];
nextCursor: string | null;
coverage: ProcessorCoverage;
}
```
Clients must not parse a cursor or construct one from a block number. Materialized-view consumers bootstrap without racing the live stream through the generic query-and-follow operation: 1. `POST /v1/processors/{processor}/collections/{collection}/query-and-follow`; 2. import every page, reading the next one with `GET .../entities?cursor=` and the page’s opaque `nextCursor`; and 3. follow `/v1/processors/{processor}/changes` or `/stream` after the returned `boundaryCursor`. Snapshot rows and the change boundary are captured in one SQLite transaction. Concurrent commits therefore fall strictly after the boundary. Pages remain stable for the advertised TTL even while new blocks commit. The response reports actual `rowCount`/`valueBytes`, retained bounds, coverage, and an explicit recovery action. The typed `/v1/q/blobs/snapshot` route remains a built-in convenience wrapper. ### Durable consumer registration Required or best-effort consumers have a server-side cumulative delivered and acknowledged watermark. A `required` consumer also holds back pruning until it acknowledges, so only a stream that retains until acknowledged accepts one: a live stream with `until_acknowledged` delivery, or a backfill stream. A `window` or `best_effort` stream prunes by its own limits and rejects a new `required` consumer with `400`. Re-creating a consumer that already exists returns `409 consumer_exists` whatever its role. Creation always names a start position:
```http
POST /v1/processors/usdc-weth-latest/consumers
content-type: application/json
{
"id": "prices-api",
"role": "best_effort",
"start": { "position": "earliest_retained" },
"leaseTtlSeconds": 300,
"credential": ""
}
```
`position` is `earliest_retained`, `current_head`, or `cursor` with a `cursor` field. An old, wrong-store, wrong-instance, or incompatible cursor returns `reset_required`; the node never substitutes another position. `leaseTtlSeconds` is 1 to 3,153,600,000 (100 years), for a backfill subscription’s consumer too; another value gets `400`. A consumer created with a credential needs it on every consumer-scoped call, in addition to any bearer token: the lease, change, and acknowledgement routes below, and its live and backfill streams with their lease and acknowledgement routes:
```http
x-leani-consumer-credential:
```
A missing or wrong credential gets `403`, whatever the bearer token. The node compares credentials in constant time and stores each as a BLAKE3 hash keyed with a random per-store secret. A backfill subscription’s credential also enters the subscription’s idempotency identity, an unkeyed BLAKE3 hash of the whole request that the store keeps, so use a random credential. A new credential holds 32 to 512 printable ASCII characters without spaces, such as the output of `openssl rand -hex 32`. A consumer or backfill subscription created before that rule keeps its credential: re-creating the consumer still gets `409 consumer_exists`, and re-submitting the subscription’s request unchanged its status. A consumer declared in the node configuration has no credential and takes none. Credentials isolate applications on these consumer-scoped delivery routes only. Listing consumers, inspecting one with `GET .../consumers/{consumer}`, and revoking one with `DELETE` on that path are administrative operations that only the bearer token protects, whether or not the consumer has a credential. The list already shows what an inspection does, an operator must be able to revoke an abandoned consumer whose credential is lost, and without a bearer token every client that reaches the API is trusted with the administrative routes, such as live-lane resets, anyway. Read a batch:
```http
GET /v1/processors/usdc-weth-latest/consumers/prices-api/changes?limit=500
x-leani-consumer-credential: ...
```
After committing the destination transaction and its cursor atomically:
```http
POST /v1/processors/usdc-weth-latest/consumers/prices-api/ack
x-leani-consumer-credential: ...
content-type: application/json
{ "cursor": "" }
```
Acknowledgement is monotonic and idempotent: repeating the acknowledged cursor succeeds and moves no progress. The session token of a stream that has ended may still repeat it, without renewing the lease, until another stream replaces that session. A page read or a stream batch records the last sequence it hands out as the consumer’s delivered sequence (`deliveredSequence`), and a cursor beyond it gets `409 acknowledgement_beyond_delivered`, so a buggy client cannot let retention prune changes it never received. A live stream batch holds one block’s whole commit, and a live acknowledgement must end one, as each batch’s `acknowledgeableCursor` does (`409 acknowledgement_mid_block` otherwise). A backfill acknowledgement must be a batch’s `acknowledgeableCursor` or the completion’s `cursor`. A processor with a split live stream (`block_versioned_idempotent` delivery ordering, such as `blobs-money`) fences renewal and acknowledgement by streaming session. Its consumers renew and acknowledge through `/streams/live/consumers/{consumer}/lease` and `/ack` with the `x-leani-consumer-session` token from the stream’s `hello`, and the generic lease and acknowledgement routes answer `409 consumer_session_required`. Once another stream replaces a session, its token gets `409 consumer_session_lost`; a session that has only ended may still repeat its acknowledged cursor. A session token carries a MAC keyed with the per-store secret; one the node cannot verify, such as a token from before an upgrade, gets `409 consumer_session_lost`, and the client opens a new stream. The live stream’s `hello` also carries the processor’s `coverage` as the session starts, so an application can diff its own copy on every connection without polling the status route. `commit_then_ack` in the Rust API helper does not construct or execute the acknowledgement future until the destination commit succeeds. The TypeScript SDK exposes the equivalent `commitThenAcknowledge(() => destinationCommit(), cursor => client.processors.consumers.acknowledge(...))`; its acknowledgement callback is likewise never invoked after a failed destination commit. ### Generic retained queries Entity queries accept `fromBlock`, `toBlock`, `fromTimestamp`, `toTimestamp`, and `limit`. Entity keys are URL-safe hex. Every row contains the processor schema, decoded JSON data, and block/timestamp/finality metadata. There are two ways to page through a collection: * `GET .../entities` with the query as URL parameters reads current retained output in entity-key order and creates nothing. Its page holds `data`, `nextCursor`, `retainedBounds`, and `coverage`; pass `nextCursor` as `cursor` to read on. Later pages see later commits: each row appears as it was when its page was read, and a row added behind the read position is not returned. * `POST .../entities` with the query as a JSON body copies the matching rows into a stable snapshot that counts against the node-wide snapshot limits, and returns its first page with the snapshot ID, change boundary, row and byte counts, and expiry. `GET .../entities?cursor=` with its `nextCursor` reads the following pages, which stay stable while new blocks commit.
```http
POST /v1/processors/{processor}/collections/{collection}/entities
content-type: application/json
{ "fromBlock": 17000000, "toBlock": 17000999, "limit": 500 }
```
Explicit block bounds require complete processor coverage. Missing blocks return HTTP 409 `range_incomplete`, including the requested interval and coverage. The resolved block range is kept with the cursor, or the snapshot, across pagination and node restart, and each GET page checks coverage again. A block-count window uses its retention policy boundary, rather than the first matching entity, so covered blocks without events remain queryable. `retainedBounds` describes the entities present, not the limits of processor coverage. Timestamp filters select retained rows; include block bounds when you need a completeness check for a specific interval. `output = "none"` or a range outside the retained window returns `output_not_retained` with a rebuild/source-scan action. It never returns a successful empty page for unavailable local history. The TypeScript SDK’s `client.processors.queryAndFollow(processor, collection, query)` returns this snapshot and its `boundaryCursor` in one operation. Continue with `client.processors.subscribe(processor, { after: page.boundaryCursor })`. With delivery disabled, ordinary entity queries still work, and snapshots return `boundaryCursor: null` and `recovery.follow: null`. Query-and-follow, change-head, change-page, and stream requests return non-retryable HTTP 409 `delivery_disabled`; enable a delivery policy before subscribing. The snapshots of a processor that publishes from `attested` or `finalized` also return `boundaryCursor: null` and `recovery.follow: null`, and query-and-follow returns HTTP 409 `deferred_publication_snapshot`: its retained state already holds blocks whose changes are published only once they reach that level, and the stream never undoes a block it never published, so a copy followed from a boundary could keep rows of a reorged block. Release a no-longer-needed snapshot early:
```http
DELETE /v1/processors/{processor}/query-snapshots/{snapshotId}
x-leani-request: 1
```
### Compact processor artifacts When `[processors.artifacts]` is `window` or `full`, the node retains the finalized checksummed map result independently from queryable entities and delivery. Inspect its bounds and logical footprint with:
```http
GET /v1/processors/{processor}/artifacts/status
GET /v1/processors/{processor}/artifacts/{blockNumber}
```
Portable export is paged by block range:
```http
GET /v1/processors/{processor}/artifacts/export?fromBlock=1&toBlock=1000&limit=1000
```
The response uses `application/vnd.leani.processor-artifacts`. Its versioned durable envelope and `x-leani-artifact-digest` cover the immutable map identity, delta schema, canonical ordering, payload checksums, and requested/exported ranges. `x-leani-artifact-complete` is false when another page is needed or the retained range has a gap. Replay reduces artifacts into a distinct configured processor instance with the same immutable map/delta contract:
```http
POST /admin/v1/processors/{source}/artifacts/replay
content-type: application/json
{
"targetProcessor": "analysis-view-v1",
"fromBlock": 1,
"toBlock": 1000,
"limit": 1000
}
```
Artifact deletion is explicit and owner-scoped. Releasing one owner preserves rows protected by another processor job or operator pin:
```http
DELETE /admin/v1/processors/{processor}/artifacts?fromBlock=1&toBlock=1000
```
The default releases the processor-instance owner. `ownerKind` and `ownerId` select `processor_job` or `operator_pin` ownership. Delivery acknowledgement, output pruning, raw-history deletion, and checkpoint pruning never delete artifacts. `[budgets.artifacts]` caps retained finalized bytes and pending unfinalized-block candidates across all processors. Admission is atomic: exceeding either limit rolls back artifacts, processor mutations, coverage, and cursor advancement. The metrics endpoint reports current and maximum artifact bytes separately; window pruning, explicit owner release, or a larger configured budget lets storage-backpressured work resume. ### Checkpoints and portable savepoints Automatic recovery checkpoints are read-only through `GET /v1/processors/{processor}/checkpoints`, are taken at most once per 1,000 finalized blocks and when a historical job completes, and obey configured count retention. `POST .../checkpoints/{checkpoint}/restore` atomically repairs private processor state only when the processor’s cursor sits exactly on the checkpoint; finality reaching that block afterwards does not matter. Between checkpoints no checkpoint can be restored: restore a full-store backup or rebuild the processor. Older checkpoints are rejected because rewinding state without also rewinding independent output, delivery, coverage, and undo storage would be unsafe; use a deterministic rebuild or a full-store backup for that case. Portable savepoints are operator-created exports, which the node cannot restore:
```http
POST /v1/processors/{processor}/savepoints
content-type: application/json
{ "id": "before-v2-upgrade" }
```
Each processor instance keeps at most 16 savepoints; creating another returns `409 savepoint_limit` until one is deleted. A savepoint is also admitted against the physical store budget and gets `507 physical_storage_limit` when it does not fit. `GET .../savepoints/{id}` exports a checksummed, versioned `application/vnd.leani.savepoint` archive. Deletion is explicit with `DELETE`; acknowledgement or checkpoint pruning never deletes a portable savepoint. Checkpoints and savepoints are bound to the processor contract without its lifecycle policies, so raising a limit does not invalidate them; a different processor version does. ## 4. Change protocol ### 4.1 Envelope
```ts
export interface ChangeEnvelope {
apiVersion: "1";
sequence: string;
cursor: string;
operation: "apply" | "undo" | "attested" | "finalized" | "reset_required";
originKind: "live" | "live_recovery" | "historical_backfill" | "recompute";
originId: string;
publicationRevision: string;
chainId: number;
block: {
number: number;
hash: `0x${string}`;
parentHash: `0x${string}`;
timestamp: number;
} | null;
finality: "unattested" | "attested" | "finalized";
kind: string;
schema: string;
key: string | null;
/** When the payload carries its own finality field, it equals the envelope finality. */
data: T | null;
/** Present only on reset_required, as the current coverage hint. */
coverage?: ProcessorCoverage;
emittedAt: string;
}
```
Envelopes carry change-scoped facts, including the processing lane’s origin and publication revision. `originKind: "live_recovery"` marks a finalized block that a live lane applied late, from retained frames or finalized history, as in a gap replay or a live gap fill (section 4.2). Connection-scoped processor identity and node coverage are served by the stream’s `hello` frame (section 4.3) and by `/v1/status`; poll responses continue to carry one top-level `coverage` per page. `sequence` is a decimal string because a long-running stream can exceed JavaScript’s safe integer range. It is monotonically increasing in one store epoch, including across reorgs. `cursor` is an opaque base64url token that includes at least:
```text
cursor format version
store epoch
chain ID
processor instance
change sequence
block number/hash when applicable
checksum
```
The checksum detects accidental corruption, not malicious tampering. A cursor from another store, chain, processor version, or expired epoch is rejected. ### 4.2 Operations `apply` * introduces or updates the keyed domain value; * is emitted only after the state transaction commits; * may be `unattested` or `attested` but not yet finalized (see [finality levels](/docs/concepts/coverage-finality-and-reorgs/#finality-levels-and-publish-modes)). `attested` * marks every block through `throughBlock` attested, as kind `system.finality.put` with `data: { throughBlock }`: the highest block the sync committee has attested; * is emitted only by processors that publish `unattested` blocks; * never repeats an entity. `undo` * reverses a previously committed `apply`; * contains the inverse value/change required by downstream stores; * has its own newer sequence/cursor. `finalized` * advances finality for a processor/chain, as kind `system.finality.put` with `data: { throughBlock }`; * need not repeat every entity; * permits a consumer to mark earlier changes durable. A `finalized` change of kind `system.live_gap.put` instead announces blocks the live lane skipped and did not fill, which the processor never processed, as after a restart at a later finalized anchor: `data: { fromBlock, toBlock, reason: "not_filled" }`. Only block-local processors with `history_mode = "on_demand"` announce them, before the block that follows the range, live or replayed, and in a batch of their own. After a crash between the two, the next block past the cursor announces again, the same range or a longer one; a retry the stream’s last notice already covers adds none. A processor that fills live gaps (below) may instead fill the range then, after a crash or a restart of its live lane, so `live_recovery` batches can follow a notice for the same blocks. Deduplicate batches by cursor: the notice and the following block share a block number. A processor with [`[processors.live_gap_fill]`](/docs/operations/configuration-guide/#live-gap-fill) first fills a range of at most `max_blocks` blocks from history. The live stream delivers those blocks before the following block, one batch each, in block order, as `apply` changes with `originKind: "live_recovery"` and `finality: "finalized"`, and announces nothing for them. It announces a longer range, a range history cannot serve yet, and whatever a fill leaves when it fails or runs out of time, from the first block it did not fill. Otherwise Leani does not fill the range: request it as history, such as with a `fill_missing` backfill subscription (see [Keep your application in sync](/docs/guides/keep-your-application-in-sync/)). `reset_required` * means the requested cursor is no longer resumable or belongs to an incompatible store/processor epoch; * is the only operation whose envelope carries `coverage`, as the current coverage/snapshot hint; * causes the stream to close after the event. The protocol is at-least-once across network reconnects. Consumers deduplicate by cursor/sequence and apply changes transactionally. It is exactly-once in the node’s own state, not magically exactly-once across an HTTP connection. ### 4.3 Poll and stream Polling:
```http
GET /v1/processors/blobs-money/changes?after=&limit=500
```
Streaming:
```http
GET /v1/processors/blobs-money/stream?after=
Accept: text/event-stream
```
Every change includes the processor descriptor’s `schema`. Built-in and custom native processors may render typed JSON in `data`; processors without a public renderer expose schema-labelled `{ "encoding": "hex", "value": "..." }`. Consumers inspect `finality` on each event rather than filtering it through a subscription query parameter. Every stream connection — initial and reconnect — opens with one `hello` frame before any change events:
```text
event: hello
data: {"apiVersion":"1","chainId":1,"processor":{...},"coverage":{...}}
```
The hello is synthesized at accept time from live state; it is never stored, carries **no `id:` line** (it has no position in the change log, so it can never become a resume cursor), and is never acknowledged. The SDK surfaces it through the optional `onHello` subscribe callback. Change events follow as before:
```text
id:
event: apply
data: {"apiVersion":"1",...}
```
A browser `EventSource` reconnects on its own and sends the last event’s `id:`, its cursor, as `Last-Event-ID`; without `after`, the stream resumes after that cursor. An explicit `after` takes precedence. The server sends comments as heartbeats. When the node shuts down, it ends the stream after a whole event, with no error event, and the consumer delivery streams after a whole record. An `error` event carries the error body below and ends the stream; its `retryable` says whether reconnecting can help. A stored change the node cannot render is not retryable. The SDK reconnects with the last fully yielded cursor, uses exponential backoff with jitter, and treats these differently: * clean server close, such as the node’s shutdown: reconnect; * transient network/5xx: reconnect; * no bytes, keep-alive comments included, for `idleTimeoutMs`: reconnect; * `error` event with `retryable: true`: reconnect, and after five in a row without a new change, surface the last one; * `error` event with `retryable: false`: surface error; * authentication/validation 4xx, or a redirect: surface error; * `reset_required`: stop and require a snapshot/resubscription decision; * abort signal: stop without error. Slow consumers are disconnected before they exhaust server memory. Durable resume comes from the change log, not an unbounded per-connection buffer. ## 5. Errors HTTP errors use a stable machine-readable body:
```ts
export interface LeaniErrorBody {
error: {
code:
| "invalid_request"
| "unsupported_capability"
| "range_incomplete"
| "source_unavailable"
| "query_too_expensive"
| "cursor_invalid"
| "cursor_expired"
| "processor_rebuilding"
| "finality_unavailable"
| "internal";
message: string;
retryable: boolean;
details?: Record;
requestId: string;
};
}
```
Relevant status mappings: | Status | Meaning | | ------------- | ---------------------------------------------------------------------- | | `400` | malformed parameters or invalid cursor | | `401` / `403` | authentication/authorization | | `404` | entity or processor does not exist | | `409` | requested processor version/coverage is rebuilding or incomplete | | `410` | stream cursor expired; snapshot reset required | | `413` | requested range/result exceeds configured limit | | `422` | source exists but cannot provide the required capability | | `429` | concurrency/query budget exceeded | | `503` | source/finality temporarily unavailable | | `507` | the physical store budget refuses the write (`physical_storage_limit`) | Standard JSON-RPC uses standard errors where applicable and a documented custom server-error range for unsupported capability, incomplete coverage, and resource limits. Unsupported state methods must not return `null` or zero. ## 6. Capability discovery `GET /v1/capabilities` reports actual configured capability, not everything the software could theoretically implement:
```json
{
"chainId": 1,
"ethereumRpc": {
"methods": {
"eth_call": {
"supported": false,
"reason": "evm_state_unavailable"
}
}
},
"processors": [
{
"id": "blobs-money",
"instance": "blobs-production",
"version": "1.6.0",
"codeHash": "0x...",
"configHash": "0x...",
"genericApi": "processor-v1",
"changeSchema": "blobs-money.change.v1",
"queryExtensions": [
{
"id": "blobs-v1",
"basePath": "/v1/processors/blobs-production/query",
"aliasPath": "/v1/q/blobs"
}
],
"subscriptions": true,
"artifactRetention": "none",
"deliveryOrdering": "block_versioned_idempotent"
}
],
"queryExtensions": [
{
"processor": "blobs-production",
"id": "blobs-v1",
"basePath": "/v1/processors/blobs-production/query",
"aliasPath": "/v1/q/blobs"
}
]
}
```
`genericApi` identifies the common status/query/change contract and `changeSchema` identifies stream payloads. `queryExtensions` separately describes optional typed read wrappers. Applications should persist or select the processor instance and use `basePath`; aliases are conveniences and may be absent when several instances use the same extension. `ethereumRpc` names only `eth_call`, which the node never serves. The JSON-RPC method `leani_getCapabilities` reports the support and coverage of each RPC method on the RPC listener itself, so an Ethereum-library integration can inspect the endpoint without knowing the aggregate API port. ## 7. Blobs API ### 7.1 Types The native API should expose chain-derived values using lossless strings for wei and other 256-bit quantities:
```ts
export interface BlobsBlock {
network: string;
blockNumber: number;
blockHash: `0x${string}`;
parentHash: `0x${string}`;
timestamp: number;
size: string | null;
blobCount: number;
blobGasUsed: string;
excessBlobGas: string;
blobBaseFee: string;
executionBaseFee: string | null;
gasUsed: string | null;
gasLimit: string | null;
executionEthBurnedWei: string | null;
blobEthBurnedWei: string;
reserveFeeWei: string | null;
transactionCount: number;
targetBlobsPerBlock: number;
maxBlobsPerBlock: number;
transformVersion: number;
finality: "unattested" | "attested" | "finalized";
}
export interface BlobTransaction {
network: string;
blockNumber: number;
blockHash: `0x${string}`;
txHash: `0x${string}`;
senderAddress: `0x${string}`;
toAddress: `0x${string}`;
blobVersionedHashes: `0x${string}`[];
blobCount: number;
totalBurnedWei: string;
executionBurnedWei: string;
blobBurnedWei: string;
}
export interface BlobSchedule {
name: string;
activationBlock: number;
activationTimestamp: number;
forkId: `0x${string}`;
targetBlobsPerBlock: number;
maxBlobsPerBlock: number;
baseFeeUpdateFraction: string;
eip7918: boolean;
source: "chain_spec" | "eth_config" | "configured";
}
```
`blobBaseFee` is the protocol blob base fee in wei per blob gas: the EIP-4844 fee for the block’s excess blob gas under the fork active at its timestamp. `blobEthBurnedWei` and each transaction’s `blobBurnedWei` multiply it by the blob gas used. `reserveFeeWei` is the EIP-7918 reserve price from Fusaka on (`null` before); it only changes how excess blob gas evolves and is never applied to `blobBaseFee`. A schedule’s parameters apply from its `activationTimestamp`; `activationBlock` is the block-number label `atBlock` lookups use, and for Dencun it is 19,426,589, the first block the processor covers. The API should not use decimal floating point for ETH values. Presentation as ETH belongs in blobs.money. ### 7.2 Query examples
```http
GET /v1/q/blobs/blocks/21000000
GET /v1/q/blobs/blocks?fromBlock=21000000&toBlock=21001000&limit=500
GET /v1/q/blobs/transactions?blockNumber=21000000
GET /v1/q/blobs/transactions/0x...
GET /v1/q/blobs/schedule?atBlock=...
```
Range bounds are inclusive. Results sort by block number and, for transactions, canonical transaction index. Pagination cursors preserve this order. Querying a block-local range with a gap returns: * `409 range_incomplete` by default; * partial data plus explicit coverage only when `allowPartial=true`. The default prevents a dashboard from silently treating a backfill gap as a zero-blob period. ### 7.3 Change kinds The blobs stream emits:
```text
blobs.block.put
blobs.block.delete
blobs.schedule.put
```
One Ethereum block produces one logical `blobs.block.put` containing its blob transactions, so a consumer can update its database atomically. Blocks and transactions remain independently queryable through their retained entity collections when processor output retention is enabled. Example domain payload:
```ts
export interface BlobsBlockChange {
block: BlobsBlock;
transactions: BlobTransaction[];
}
```
On a reorg, `undo` contains the previous block plus transactions so a consumer can delete or restore them by canonical keys. The replacement branch then arrives as ordinary `apply` changes. A reorg of several blocks, like the revert at a restart after a long outage, undoes them newest first, and the live stream delivers each reverted block’s `undo` as a batch of its own. [Undo runs](/docs/concepts/coverage-finality-and-reorgs/#undo-runs) shows how to rebuild derived aggregates once per such run instead of once per block. ## 8. TypeScript SDK Package:
```text
@smart-byte/leani-sdk
```
The SDK release workflow builds the JavaScript and type declarations and runs packed-package smoke tests in Node and Bun before publication. Prerelease versions use npm’s `latest` tag until the first stable release, and `next` after it. Runtime support: * Bun, first-class and tested in the blobs.money repository; * current Node LTS with native `fetch`; * modern browsers for public read-only endpoints; * no Node-only APIs in the core client. Public shape:
```ts
import { createLeaniClient } from "@smart-byte/leani-sdk";
const node = createLeaniClient({
baseUrl: "http://127.0.0.1:8080",
token: process.env.LEANI_TOKEN,
timeoutMs: 15_000,
});
const status = await node.status();
const capabilities = await node.capabilities();
const page = await node.blobs.listBlocks({
fromBlock: 21_000_000,
toBlock: 21_001_000,
});
for await (const event of node.blobs.subscribe({
after: savedCursor,
signal: abortController.signal,
})) {
await database.transaction(async (tx) => {
await applyBlobChange(tx, event);
await saveCursor(tx, event.cursor);
});
}
```
The change stream currently publishes every retained finality transition. Consumers inspect or filter `event.finality`; `SubscribeOptions` has no finality filter, only `after`, `signal`, `onHello`, and `idleTimeoutMs`. Core client modules:
```ts
client.status()
client.capabilities()
client.request(path, params, options)
client.processors.list()
client.processors.status(id)
client.processors.changeHead(id)
client.processors.changes(id, options)
client.processors.subscribe(id, options)
client.blobs.getBlock(number)
client.blobs.listBlocks(query)
client.blobs.listSnapshot(query)
client.blobs.getTransaction(hash)
client.blobs.listTransactions(query)
client.blobs.getSchedule(query)
client.blobs.subscribe(options)
```
Typed helpers default to the `/v1/q/*` aliases. In a multi-instance setup, select the desired canonical `basePath` from `client.capabilities()` and pass it through `queryBasePaths` when constructing the client. This keeps typed responses while making the processor instance explicit. `request` is the safe primitive for custom query extensions. Every path resolves under the base URL’s path: `v1/processors//query/summary` and a capabilities `basePath` such as `/v1/q/my-protocol/summary` both keep a prefixed base URL’s prefix. Schemes, protocol-relative paths, segments that decode to `.` or `..`, backslashes, encoded slashes, backslashes, and percent signs, and control characters are rejected before the bearer token is attached, and redirects are refused rather than followed. A custom extension whose path parameter can hold an encoded `/` or `%` must take that value in the query string, through `request`’s `params`, instead. Built-in query namespaces use this same primitive. SDK responsibilities: * URL and query encoding; * abortable deadlines; * typed error mapping; * JSON validation at trust boundaries where practical; * SSE parsing across arbitrary byte chunk boundaries; * heartbeat handling; * reconnect/backoff/jitter; * cursor persistence hooks; * duplicate cursor suppression; * exposure of coverage on every query/stream event. SDK non-responsibilities: * automatically discarding `undo`; * silently resetting an expired cursor; * storing application checkpoints; * converting wei to floating point; * wrapping the complete Ethereum JSON-RPC API; * hiding incomplete coverage. The SDK exports pure helpers:
```ts
import {
applyEntityChange,
compareSequences,
createInMemoryCursorStore, // Tests and development only.
isResetRequired,
} from "@smart-byte/leani-sdk";
```
Application database integration remains explicit because domain changes must commit before the application advances Leani’s durable acknowledgement. A crash between those steps can replay a change, so destination writes must be idempotent; Leani remains the resume authority. ## 9. OpenAPI and versioning `api/openapi.yaml` is normative for HTTP queries and error bodies. The workflow: 1. update the OpenAPI document; 2. validate it in CI; 3. generate TypeScript wire types; 4. compile the Rust route response types against schema snapshots/tests; 5. handwrite the ergonomic SDK around generated wire types; 6. run SDK contract tests against the real Rust server. Do not generate the whole SDK as a thin collection of awkward endpoint functions. Generated wire types prevent drift; the handwritten client owns streaming, retries, errors, and domain ergonomics. Compatibility rules within `/v1`: * fields are added only as optional unless a new media/API version is created; * enum expansion must be handled as an unknown value at SDK trust boundaries; * field meaning cannot change; * processor output schemas have their own semantic version; * cursors are always opaque and may change representation; * a processor rebuild that changes semantics receives a new instance/version. ## 10. Authentication and limits Localhost is the default trust boundary. The current optional API bearer token protects all native query, stream, consumer, and `/admin/v1` routes on one listener, and a bind beyond loopback requires it unless `api.allow_unauthenticated_remote` is set. A consumer’s credential, when it has one, is required as well on that consumer’s delivery routes, its lease, change, and acknowledgement routes and its streams, and fences that consumer’s lease and cursor; it does not replace the listener bearer when one is configured. Listing, inspecting, and revoking consumers need only the bearer. `/health/live`, `/health/ready`, `/metrics`, `/v1/network/status`, and `/debug/network` remain unauthenticated operational routes. The JSON-RPC listeners have no built-in authentication. Every route, operational ones included, applies these checks, in this order, to keep web pages from reading or changing a loopback node: | Request | Answer | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- | | `Host` name other than `localhost` or one in `api.allowed_hosts`; IP addresses pass | 421 `host_not_allowed` | | browser `Origin` other than a loopback one or one in `api.allowed_origins` | 403 `origin_not_allowed` | | GET or HEAD without an `Origin`, with `Sec-Fetch-Site: cross-site` or `same-site` and a `Sec-Fetch-Mode` other than `navigate`, such as another page’s image load | 403 `cross_site_request` | | request to a consumer’s live or backfill `stream` route without an allowed `Origin`, with `Sec-Fetch-Site: cross-site` or `same-site` in any mode, navigations included | 403 `cross_site_request` | | POST or DELETE without `content-type: application/json` or `x-leani-request: 1` | 415 `unsupported_media_type` | The SDK, curl, and other non-browser clients send no `Origin` or `Sec-Fetch-*` headers, and their `Host` is the name or address in the URL they use, so their requests pass the first four checks whenever that is an IP address, `localhost`, or a name in `api.allowed_hosts`. So do navigations to other routes, including from another site’s page or into a frame, and requests from browsers that send no Fetch Metadata. A page cannot read those responses, and they create no query snapshot. Opening a consumer’s stream takes its session lease, so the stream routes refuse navigations from other sites too; a browser without Fetch Metadata can still open one, which the bearer token and the consumer’s credential prevent. The SDK sends `x-leani-request: 1` on its requests without a JSON body; other clients must add it, for example `curl -X DELETE -H 'x-leani-request: 1' ...`. When remotely exposed: * use TLS, authentication, and request/connection limits at a reverse proxy; * restrict operational routes to the monitoring network; * keep the API and RPC listeners off the public Internet unless an edge policy explicitly protects them; * use the built-in range, response-byte, concurrent-scan, stream, and storage limits as resource bounds, not as per-tenant authorization; * do not assume separate admin credentials or listeners until that isolation is implemented. The SDK never logs tokens or complete URLs containing secrets. ## 11. blobs.money integration blobs.money now carries the native consumer and application-owned reconciliation described below. The remaining work is production validation, shared-SDK adoption, cutover, and rollback evidence. ### Path A: JSON-RPC compatibility Point the existing ethers/fetch clients at the node and support:
```text
eth_blockNumber
eth_getBlockByNumber(number, true)
eth_getBlockReceipts
eth_getTransactionReceipt
eth_subscribe(newHeads)
eth_config, from the node's checked/versioned chain schedule
```
This minimizes initial application changes and proves RPC parity. It still causes blobs.money to fetch and transform raw blocks, so it is a bridge rather than the final design. ### Path B: native SDK Add a small ingestion adapter in blobs.money:
```text
SDK blobs stream
-> one database transaction
upsert/delete blocks
upsert/delete blob_transactions
-> acknowledge the Leani-owned durable cursor
-> existing application analytics
```
The implemented integration derives required historical ranges from PostgreSQL gaps, creates bounded backfill subscriptions, consumes atomic history batches and live changes, commits application rows, then acknowledges the Leani-owned durable cursor. A crash between those steps causes an idempotent replay rather than data loss; PostgreSQL does not maintain a second resume checkpoint. `full_blocks` remains a legacy/fallback concern rather than a required native-consumer input. The node owns: * Ethereum input acquisition; * fork/reorg/finality handling; * blob schedule and fee formulas; * chain-derived block and blob-transaction projections. blobs.money continues to own: * rollup address attribution unless deliberately moved into a processor; * ETH/USD presentation and staleness policy, while a slim Leani Uniswap processor supplies the indexed WETH/USDC pool observation; * API caching and presentation; * product-specific daily/weekly aggregations; * its public website schema. ### Cutover sequence 1. Replace the application-local client with the published `@smart-byte/leani-sdk` package. 2. Start Leani from application-requested ranges derived from PostgreSQL state. 3. Compare Leani output with existing `blocks` and `blob_transactions`. 4. Run the native consumer into shadow PostgreSQL tables. 5. Verify reorg, restart, expired-cursor, and backfill-gap behavior. 6. Make the SDK consumer primary while retaining Alchemy as a disabled-by- default emergency switch. 7. Observe CU usage and parity. 8. remove normal Alchemy block, receipt, and head traffic. 9. Remove `full_blocks` only after its remaining application readers are gone and a recoverable migration is approved. The application can also keep the local JSON-RPC configured for development. That removes the tendency for ordinary developer startup to consume Alchemy CUs. ## 12. API acceptance criteria * Every response validates against the checked-in OpenAPI v1 schema. * Every processor query exposes coverage and finality. * A resumed stream produces no unreported gap. * Reorg apply/undo events drive a test PostgreSQL projection to the same final state as a clean replay. * A cursor committed with an application transaction resumes safely after a process kill. * An expired cursor produces `reset_required`, never a silently truncated stream. * SDK tests pass on Bun and Node. * blobs.money can ingest a golden range using only the SDK. * JSON-RPC capability discovery accurately reports configured indexes and unsupported state methods.
# Processor contracts
> Reference the configuration, state, output, change, and query contracts of Leani's built-in processors.
These processors ship in the standard binary. Downstream projects can assemble a custom binary and register independent native processors through the public factory/registry API; see [Build a custom processor](https://leani.dev/docs/guides/custom-processor/). ## blobs-money 1.6.0 Block-local Dencun-and-later blob economics and type-3 transaction output. It accepts the filtered Xatu projection or complete normalized execution frames. It declares the `Header`, `Body`, `Senders`, `BlobFields`, `BlockSize` and `ReceiptOutcome` field groups, filtered to type-3 transactions. All wei quantities are lossless integers. `history_mode = "on_demand"` disables only automatic historical processor coverage. It does not disable canonical live processing, normal JSON-RPC, or WebSocket head subscriptions, and it still allows `[processors.live_gap_fill]`, which fills short ranges the live lane skips (see [Live gap fill](/docs/operations/configuration-guide/#live-gap-fill)). The processor emits one atomic block change containing the block and all of its blob transactions. Materialized output stores only the block entity, transaction entities, and transaction-by-block index; snapshot queries reconstruct that same bundle without retaining a second permanent copy. The processor retains the exact post-Fusaka BPO activation schedule and keeps consensus-block size distinct from the exact execution-block RLP size. Each block uses the parameters of the fork active at its timestamp. The blob base fee and every blob burn use the protocol fee, EIP-4844’s `fake_exponential(1, excess_blob_gas, update_fraction)`, under every fork. From Fusaka on, the EIP-7918 reserve price (execution base fee × 2^13 / 2^17) is reported separately as `reserveFeeWei` and is not applied to the fee. Version 1.5 fixes the fee and burn that 1.4 overstated wherever the reserve bound; blobs.money’s own formula still clamps the fee to the reserve. Each transaction reports its `toAddress` besides its `senderAddress`. EIP-4844 forbids contract creation, so every blob transaction has one: usually a rollup’s inbox, which stays put while its batchers rotate. Version 1.6 adds it and reads the `Body` group for it. ## evm-events 1.1.0 A declarative event decoder. Configuration supplies contract addresses, Solidity event ABI fragments, and output mappings. The factory parses and validates the ABI before a source is opened, derives exact topic-zero signatures, pushes address/topic predicates into capable sources, and decodes without EVM execution. It declares the `Logs` field group. An event’s `abi` is a human-readable fragment, one JSON ABI event object as a block explorer shows it, or `log0` (below). Fragments may use every ABI type Solidity events use except `function` and fixed-point: `address`, `bool`, `bytes1..bytes32`, `uint`/`int` widths from 8 through 256, `bytes`, `string`, fixed-size and dynamic arrays, and tuples, nested at most six levels deep. One element of a dynamic array holds at most 64 values, counting each nested array and tuple as one, because the decoder copies that layout for every element. A tuple is written with its component names, as `(uint256 amount, bool exactOutput) params` or `tuple(…) params`. Every parameter and tuple component needs a name (`type name` or `type indexed name`), because decoded values are keyed by name. Unnamed parameters, anonymous events, duplicate signatures, malformed keys, and more than three indexed fields fail deterministically during `doctor`. The order of the configured events is part of the configuration hash, so reordering them creates a new processor instance. Decoded `values` are exact strings: integers in decimal; `address`, `bytesN`, and `bytes` as lowercase `0x` hex; `bool` as `true` or `false`; and `string` as text, with invalid UTF-8 replaced by U+FFFD. A tuple that is not indexed flattens to one key per component, such as `params.amount`, and nested tuples extend the path. An array, also one inside a tuple, is one JSON string whose elements render as above, with tuple elements as objects in ABI order: `["1","2"]` or `[{"maker":"0x…","data":"0x01"}]`. Ethereum stores an indexed `string`, `bytes`, array, or tuple only as the keccak hash of its value, so its value is that 32-byte topic as `0x` hex. `key_fields` may name any value key, including a flattened one. Any contract can emit a log with a configured event’s topic zero. A log whose topics or data do not decode as that event, such as an ERC-721 `Transfer` (four topics, no data) against the ERC-20 `Transfer` ABI or a `bool` word other than 0 or 1, is not that event: it is skipped rather than failing the block, and the block’s mapped delta counts the skipped logs. Only the canonical encoding decodes, the one Solidity’s encoder produces: offsets without gaps or shared tails, zero padding, and no trailing bytes. A log that breaks these rules, as a few old compilers’ event data did, is skipped and counted rather than guessed at. The layout is checked before anything decodes, so decoding takes memory in proportion to the log’s size. `abi = "log0"` stores every topicless log of the configured contracts raw: the entity’s `event` and `signature` are `log0`, and its `values` hold one key, `data`, with the log data as `0x` hex. Contracts such as Ekubo Core emit LOG0 with a packed layout that no ABI describes, so the application decodes those bytes. A `log0` entry requires `addresses`, a processor may have one, and its `key_fields` may name `data`. A topicless log cannot match a topic-zero filter, so a processor with a `log0` entry reads every log of its contracts and selects the configured events itself. An anonymous event with indexed parameters has topics but no signature, and is not decoded. Without `key_fields`, an event’s key is its transaction hash and log index, which no other event shares, so the processor is block-local: its history and live blocks apply independently. A `key_fields` key can repeat across blocks, and its entity holds the key’s newest event, so any keyed output makes the processor ordered, like `erc20-balances` (`ordered_state` reduction and `canonical` delivery ordering). It reduces blocks strictly in chain order: a block emits one change per key, for its last event, and undoing a block restores the key’s event from before it. Its live lane therefore waits for its cold backfill, holding live blocks as pending deltas within `[budgets] pending_delta_bytes` until history reaches them. Consumers read a keyed instance’s stream through the generic consumer routes. Application subscriptions and `POST /admin/v1/materialization-jobs` require a block-local processor, so they refuse a keyed instance. With `history_mode = "on_demand"`, its history comes from the `backfill` command instead, which applies an ordered processor’s history contiguously from `start_block` upward: each range must start at the block after the last one applied, and the command refuses any other start. Finalized-coverage compaction is block-local only, so a keyed instance keeps one coverage row per block it applies, without bound, as other ordered processors do. `publish = "finalized"` holds unfinalized changes in the store until verified finality promotes them. The stream then contains only finalized `apply` records and `system.finality` markers; no `undo` is ever published. Held bytes count against `[processors.delivery] max_bytes` from admission.
```toml
[[processors]]
id = "evm-events"
instance = "weth-transfers"
version = "1.1.0"
start_block = 12965000
publish = "finalized"
[processors.state]
mode = "durable"
[processors.artifacts]
mode = "none"
[processors.output]
mode = "window"
[processors.output.window]
max_blocks = 216000
[processors.delivery]
mode = "none"
[processors.checkpoint]
mode = "automatic"
keep = 3
[processors.undo]
mode = "none"
safety_blocks = 0
[processors.settings]
addresses = ["0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"]
[[processors.settings.events]]
abi = "event Transfer(address indexed from, address indexed to, uint256 value)"
[processors.settings.events.output]
collection = "weth.transfers"
kind = "weth.transfer"
key_fields = []
```
Every entity/change uses `evm.event.entity.v1`/`evm.event.change.v1`, includes block and transaction identity as camelCase `0x`-hex fields, and renders decoded values as exact decimal or hex strings. `bucket_seconds` plus `_bucket_start` in `key_fields` supports deterministic wall-clock buckets from canonical block timestamps. ## transaction-stats 1.0.0 An ordered A-to-B aggregate that retains constant-sized private working state: transaction count, exact total value, cursor, checkpoints, and the bounded unfinalized contribution journal. It declares the `Body` and `Senders` field groups and sender/recipient predicates, and no receipt group, so it cannot tell whether a transaction reverted: `count` and `totalValueWei` cover every submitted transaction from the sender to the recipient. `totalValueWei` is the submitted value, not the value the recipient received.
```toml
[[processors]]
id = "transaction-stats"
instance = "treasury-flow-total"
version = "1.0.0"
start_block = 15537394
publish = "finalized"
[processors.state]
mode = "checkpointed"
[processors.artifacts]
mode = "none"
[processors.output]
mode = "latest"
[processors.delivery]
mode = "none"
[processors.checkpoint]
mode = "automatic"
keep = 3
[processors.undo]
mode = "none"
safety_blocks = 0
[processors.settings]
from = "0x1111111111111111111111111111111111111111"
to = "0x2222222222222222222222222222222222222222"
complete_from_start = true
```
Use `output = "latest"` for a continuing aggregate or `[processors.delivery] emit = "job_result"` for a bounded backfill. Job-result jobs commit state and coverage for every block but spool only the final aggregate; after acknowledgement the job is complete/reclaimable and deletion remains an explicit operator action. ## rollup-txs 1.0.0 A block-local processor for rollups’ L1 transactions other than blobs: calldata batches, state root and output proposals, and proof submissions. It declares the `Header`, `Body`, `Senders`, `Calldata` and `ReceiptOutcome` field groups, filtered to the rules’ recipients. A transaction matches a rule when it is sent to `to`, from `from` when set, calls `selector` when set, carries `chain_id_arg` as its first ABI word when set (shared settlement contracts serving several chains), in a block whose timestamp lies in `[since, until)`. The first matching rule per recipient wins, so list the more specific rules first. A rule also names the `rollup` its transactions belong to and their `purpose` (`data`, `state`, or `proof`), which the reported rows carry. Type-3 transactions never match: `blobs-money` reports them. Only top-level calls match; a call made through a multisig or another contract is not seen. The processor supports Ethereum mainnet only, and only from London (block 12,965,000) on, because `executionBurnedWei` and `tipWei` need the base fee that earlier blocks lack. Startup refuses another chain and a pre-London `start_block`. `since` and `until` are UNIX timestamps in seconds, below 10^10 (the year 2286), not block numbers or milliseconds: a block matches from `since` (inclusive) up to `until` (exclusive). Startup refuses a `since` or `until` below mainnet’s genesis timestamp (1438269973), as any block number is, and one of 10000000000 or more, as any timestamp in milliseconds is: a millisecond `until` would never close its window, and a millisecond `since` would never match. It also refuses a window whose `since` is not below its `until`. A rule with `chain_id_arg` also needs a `selector`: the chain id is the argument after it, and without one the rows would render `selector: null`, so calls to different functions could not be told apart. Every block emits one `rollups.block` change (`rollups.block-bundle.v1`), also when nothing matched, so a consumer can tell a block without rollup activity from one it never received. Materialized output stores each block’s bundle by block number in the `rollups.blocks` collection (`rollups.entity.v1`) and renders it as the same JSON. The bundle is camelCase JSON: * `block`: `network`, `blockNumber`, `blockHash`, `parentHash`, and `timestamp`. The bundle carries no finality of its own: the block’s finality is the `finality` of the change envelope, or of the retained entity, that carries the bundle. * `transactions`, in block order: `network`, `blockNumber`, and `blockHash`, repeated from the block so that each transaction stands alone as a row; then `txHash`, `transactionIndex`, `rollupId` and `purpose` (`data`, `state`, or `proof`), which are the matched rule’s `rollup` and `purpose`, `success`, `senderAddress`, `toAddress`, `selector`, `gasUsed`, `executionBurnedWei` = base fee × gas used, and `tipWei` = (effective price − base fee) × gas used. A reverted transaction is reported like the others, with `success: false`, because its gas was paid. `selector` is the matched rule’s selector, or `null` when that rule has none: the first bytes of a calldata batch are batch data, not a function selector. Hashes, addresses, and selectors are `0x` followed by lowercase hex digits: 64 for a hash, 40 for an address, and 8 for a selector. `gasUsed`, `executionBurnedWei`, and `tipWei` are decimal strings; `blockNumber`, `timestamp`, and `transactionIndex` are JSON numbers. The rules and the `start_block` are the processor’s identity: changing either needs a new `instance`.
```toml
[[processors]]
id = "rollup-txs"
instance = "rollup-txs-1"
version = "1.0.0"
history_control = "application_subscriptions"
history_mode = "on_demand"
start_block = 26000000
[processors.state]
mode = "durable"
[processors.output]
mode = "none"
[processors.delivery]
mode = "until_acknowledged"
[[processors.delivery.consumers]]
id = "app"
required = true
lease_ttl = "5m"
[processors.checkpoint]
mode = "automatic"
keep = 3
[processors.undo]
mode = "unfinalized"
safety_blocks = 256
[[processors.settings.rules]]
rollup = "base"
purpose = "state"
to = "0x43edb88c4b80fdd2adff2412a7bebf9df42cb40e"
selector = "0x82ecf2f6"
```
Xatu serves this processor’s history from its transaction projection, for the rule recipients (see [data sources](/docs/concepts/data-sources/#3-xatu)). ## erc20-balances 1.1.0 An ordered watchlist ledger derived from canonical ERC-20 `Transfer` logs. Configuration declares watched addresses, an optional token allowlist, a start block, and whether the opening coverage is complete. It declares the `Logs` field group. The stored method is explicitly `erc20_transfer_ledger`. Rebasing, reflection, and non-standard tokens are not represented as general EVM balances. Any contract can emit a `Transfer` log, so a log with that signature that is not a canonical ERC-20 transfer (three topics, zero-padded addresses, and a 32-byte value), such as an ERC-721 transfer, is skipped rather than failing the block; the block’s mapped delta counts the skipped logs. A transfer the ledger cannot apply proves it incomplete for that token and holder: an outgoing transfer above the derived balance means transfers before the start block or outside the ERC-20 model, such as a rebasing or forged token, and an incoming one can overflow it. The pair is then marked incomplete: `incompleteFrom` holds that block, `complete` is `false`, `balance` keeps its last derived value, and later transfers for the pair are ignored. Undoing that block restores the derived pair. Only a token in the `tokens` allowlist with `complete_from_start = true` fails the processor instead, because the configuration declares that ledger complete.
```toml
[[processors]]
id = "erc20-balances"
instance = "erc20-watchlist"
version = "1.1.0"
start_block = 1234567
[processors.state]
mode = "durable"
[processors.output]
mode = "latest"
[processors.delivery]
mode = "window"
[processors.checkpoint]
mode = "automatic"
keep = 3
[processors.undo]
mode = "unfinalized"
safety_blocks = 256
[processors.coverage]
verification_segment_blocks = 8192
[processors.settings]
addresses = ["0x1111111111111111111111111111111111111111"]
tokens = ["0x2222222222222222222222222222222222222222"]
complete_from_start = true
```
## block-summary 1.2.0 A receipt-free, block-local view of every Ethereum execution block. It declares the `Header` and `Body` field groups, then publishes timestamp, parent/hash identity, gas limit and usage, decimal base fee, blob gas fields, transaction count, exact execution-block size when available, and finality. Blocks come from verified material or, under the trusted-dataset policy, from Xatu’s dataset-declared header and transaction projections, whose body indices and payload transaction count must agree. Dataset rows carry no execution-block size, so `sizeBytes` is `null` for them. A block a dataset summarized first matches the same block from verified material, as when the live lane overlaps a Xatu backfill, and keeps `null`. The reverse, remapping a block that verified material summarized from a dataset, as a recompute may, is refused as a conflicting apply. Summaries are retained by block hash with a canonical block-number index; the query extension exposes `/latest` and `/blocks/{number}`. It never executes the EVM and does not request receipts. The compact `[blocks]` preset supplies the standard checkpointed lifecycle, full query output, 64 MiB/24 hour delivery window, and undo records kept until 256 blocks past finality. Advanced configurations can override those policies explicitly. ## uniswap-observations 2.1.0 / uniswap-latest 2.0.0 Two configured-pool contracts share the same exact event decoder: * Uniswap V2 `Sync(uint112,uint112)` reserve observations; * Uniswap V3 `Initialize(uint160,int24)` and `Swap(address,address,int256,int256,uint160,uint128,int24)` square-root-price observations. They store exact reserves and `sqrtPriceX96`, never floating-point prices. V3 swap observations also retain `amount0` and `amount1` as signed decimal int256 values, preserving the exact pool deltas needed to derive volume. Applications combine these values with verified token metadata. `uniswap-observations` is block-local and identifies immutable observations by pool, block hash, and log index; it supports independent history/live delivery and full or windowed SQLite retention. Version 2.1 also maintains a query-only latest-observation pointer per pool so new clients can bootstrap immediately before following the immutable stream. Transaction hashes are deliberately not part of this processor’s output, allowing archive and P2P sources to omit transaction bodies when acquiring filtered pool logs. Both processors declare the `Logs` field group. `uniswap-latest` is ordered/canonical and retains only the latest observation per pool for slim price queries.
```toml
[[processors]]
id = "uniswap-observations" # or "uniswap-latest"
instance = "usdc-weth-observations"
version = "2.1.0" # use 2.0.0 with uniswap-latest
start_block = 12369621
history_control = "node_owned"
history_mode = "on_demand"
[processors.state]
mode = "checkpointed"
[processors.artifacts]
mode = "none"
[processors.output]
mode = "full"
[processors.delivery]
mode = "none"
[processors.checkpoint]
mode = "automatic"
keep = 3
[processors.undo]
mode = "unfinalized"
safety_blocks = 256
[processors.settings]
[[processors.settings.pools]]
address = "0x3333333333333333333333333333333333333333"
kind = "v3"
```
Pool factory discovery is intentionally separate from event reduction. A catalog adapter can discover pools and render these immutable processor configs; each processor identity then commits to the exact pool set.
# Built-in processors
> Generated catalog of every processor factory registered by the standard Leani binary.
This catalog comes from `ProcessorRegistry::standard()`. Adding, removing, or renaming a built-in factory changes the tracked fixture in the same Rust commit. Runtime `/v1/capabilities` remains authoritative for one configured node. ## `blobs-money` Mainnet blob blocks, transactions, fees, burn, capacity, and fork-aware schedule ## `block-summary` Ethereum block summaries with transaction counts, from verified or dataset-declared blocks ## `erc20-balances` Watchlist ERC-20 transfer ledger with current balances ## `evm-events` ABI-decoded EVM logs with configurable entity keys and time buckets ## `rollup-txs` Rollups' non-blob L1 transactions (calldata batches, state updates, proofs) matched by configured rules, with execution burn and tips ## `transaction-stats` Directional transaction counts and submitted value totals for one address pair ## `uniswap-latest` Latest materialized Uniswap V2/V3 pool state with typed query routes ## `uniswap-observations` Append-only Uniswap V2/V3 pool observations
# TypeScript SDK
> Typed queries, snapshot lifecycle, resumable streams, and generated public SDK contracts.
The public package is `@smart-byte/leani-sdk`, for Node 20.3+ and Bun 1.2+. In a source checkout, run `bun install --cwd packages/sdk` and `bun run --cwd packages/sdk build`. The [query/follow guide](/docs/guides/query-then-follow/) includes an executable client, a matching node config, and pagination/reset handling. For a node started with `leani init blocks` and `leani serve`:
```ts
import { createLeaniClient } from "@smart-byte/leani-sdk";
const leani = createLeaniClient({ baseUrl: "http://127.0.0.1:18080" });
const stop = new AbortController();
for await (const change of leani.blocks.subscribe({ signal: stop.signal })) {
if (change.operation === "apply" && change.data) {
console.log(change.data.blockNumber);
}
}
```
Pass an `AbortSignal` to cancel an idle subscription; breaking out of the loop also cancels its response body. Transport disconnects reconnect from the last yielded cursor. Protocol errors and expired history require application action. An incomplete final event is discarded before reconnecting. Retryable HTTP errors, including rate limiting, use the configured reconnect backoff, and so do `error` events marked `retryable`; after five of them in a row without a new change, the subscription throws the last one. A stream that sends nothing, not even the node’s 15-second keep-alive comments, for `idleTimeoutMs` (default 60,000) while the loop waits reconnects too. An event over 8 MiB fails the subscription with a non-retryable `invalid_response` error. This print-only example does not maintain state; stateful consumers must apply undo mutations too. A request that fails below HTTP rejects with `TransportError`, a `LeaniError` with `status` 0, `code: "transport"`, `retryable: true`, and the runtime’s error as `cause`: a refused or reset connection, a response body that breaks off, a passed deadline, or a silent stream. Aborting your own signal rejects with its reason instead, and the SDK never retries it. The SDK does not follow redirects: a 3xx answer rejects with the non-retryable `redirect_refused`, with the target in `details.location`, so configure the final URL as `baseUrl`. Every request path resolves under the base URL’s path, one starting with `/` included. A path segment that decodes to `.` or `..`, or holds a backslash, an encoded slash or backslash (`%2F`, `%5C`), or an encoded percent sign (`%25`), and a control character anywhere in the path, are refused before any request. `request()` applies the same rule to a custom query extension’s path, so a path parameter whose value can hold `/` or `%` must move to the query string, `request()`’s `params`. Typed subscriptions accept `processor` to select a configured instance, for example `leani.blobs.subscribe({ processor: "blobs-production" })`. The blobs apply payload is `{ block, transactions }`. Narrow `operation` before reading processor data: `finalized` carries `{ throughBlock }` instead, or, when `isLiveGapChange()` holds, the `{ fromBlock, toBlock }` the live lane skipped. `liveGapBackfill()` in `@smart-byte/leani-sdk/backfill` turns that range into an idempotent `fill_missing` subscription request; see [Keep your application in sync](/docs/guides/keep-your-application-in-sync/). Discover canonical query paths with `capabilities()` and pass `queryBasePaths` when aliases are ambiguous. `queryAndFollow()` returns the **first page** of an immutable snapshot and its stream boundary. Follow every `nextCursor` with `queryEntities()`, release the snapshot in `finally`, then subscribe from `boundaryCursor`. SSE resets throw `ResetRequiredError`; HTTP expiry throws `LeaniError` with `code: "cursor_expired"` or `"query_snapshot_expired"`. Rebuild the seed for either case. `queryEntities()` also works when delivery is disabled; its `boundaryCursor` and `recovery.follow` are then `null`. `queryAndFollow()` requires delivery and returns a `FollowSnapshotPage` with a non-null boundary, or HTTP 409 `delivery_disabled`. The stream and change endpoints return the same error when delivery is disabled. Durable live/history sessions live in `@smart-byte/leani-sdk/backfill`. Construct them with `createBackfillSubscriptionClient({ baseUrl, token, fetch, timeoutMs })`; both entry points use the same `LeaniError` and `TransportError` classes and HTTP error fields (`status`, `code`, `retryable`, `requestId`). The deadline applies to JSON requests, lease renewals, and acknowledgements, and to a stream until its hello arrives. An established stream that sends nothing, heartbeats included, for three `heartbeatIntervalMs` while its iterator waits fails with a `TransportError`; time spent handling a record does not count. A lease renewal that fails with a retryable error is retried with backoff while the lease holds; one that fails otherwise, or keeps failing until the lease would lapse, ends the session at once, and the iterator throws its error. The lanes of `subscribe()` reconnect after either, and after any other retryable error. A stream record over 65 MiB fails the session with a non-retryable `invalid_response` error. A backfill stream can request smaller batches with `batching.maximumEncodedBytes`; `streamLive()` takes no batching option, so a live stream’s batches follow the node’s `api.delivery.live_batches.maximum_encoded_bytes`. Always close delivery sessions in `finally`. Commit idempotent destination writes before acknowledging a batch. Acknowledge a batch’s boundary, never a change’s own cursor: the node refuses one inside a live block or a history progress unit. A backfill session’s `deliveryBatches()` yields every boundary as `ackCursor` with the `events` it covers, including batches without changes and the completion; `events()` flattens the changes without boundaries. A consumer with a credential passes it as `credential` on every call and stream. `retry(subscription)` re-queues a failed subscription to resume from its committed progress, and `delete(subscription, { discardUnacknowledged: true })` deletes a failed or cancelled one whose consumer has not acknowledged every record. See [durable delivery](/docs/concepts/durable-delivery/) for lease and reset semantics. ## Run a history subscription in one call `runBackfill(client, request, options)` in `@smart-byte/leani-sdk/backfill` runs an application-owned subscription to its end; `request` must name its `consumer`. It creates the subscription, applies each history batch with `options.onBatch` and acknowledges it once that resolves, acknowledges the completion, closes the session, and deletes the subscription. It resolves with the `outcome` (`completed`, `cancelled`, or `failed`), the `subscription` as it ended, the `completion` record it received, if it streamed one, and the `deletion`. Creation is idempotent by `idempotencyKey`: running the identical request again, as after a crash, resumes the same subscription after its last acknowledged batch, and the node refuses a different request under the same key. A batch applied but not acknowledged is delivered again, so `onBatch` must be idempotent. A subscription already complete when created is deleted without opening its stream. After a lost connection or consumer session, or while another connection still holds the consumer’s session, it waits a backoff of 1 second, doubling up to 30 seconds, and reads the subscription’s state before it opens the stream again: a subscription that ended meanwhile, as when the node applied the completion’s acknowledgement but its answer was lost, ends the run instead. A failed subscription follows `options.onFailure`. `"retry"`, the default, re-queues it with `retry()`, up to `maxRetries` times (3 by default), then throws a `BackfillStreamError` with `code: "backfill_failed"` and leaves it failed. `"discard"` deletes it with `discardUnacknowledged: true` and resolves with the outcome `failed`, and `"throw"` throws at once. A cancelled subscription is deleted with `discardUnacknowledged: true`. An error from `onBatch`, a `reset_required` stream, any other failure, and an abort of `options.signal` or `request.signal` close the session and reject without deleting the subscription, so the identical request can run again once the cause is fixed. The stream presents `options.credential`, by default the request’s `consumer.credential`. ## Inventory The inventory below is generated from both public entry points and shared error types. The `backfill.*` members belong to the backfill client. ## `baseUrl` | Member | Contract | | --------- | ----------------------- | | `baseUrl` | `readonly baseUrl: URL` | ## `request` | Member | Contract | | --------- | ------------------------------------------------------------------------------------------------------------------ | | `request` | `request( path: string, params?: Record, options?: { signal?: AbortSignal }, ): Promise` | ## `health` | Member | Contract | | -------------- | ------------------------------------------------------------ | | `health.live` | `live(options?: { signal?: AbortSignal }): Promise` | | `health.ready` | `ready(options?: { signal?: AbortSignal }): Promise` | ## `status` | Member | Contract | | -------- | ----------------------------------------------------------------- | | `status` | `status(options?: { signal?: AbortSignal }): Promise` | ## `capabilities` | Member | Contract | | -------------- | ------------------------------------------------------------------------- | | `capabilities` | `capabilities(options?: { signal?: AbortSignal }): Promise` | ## `processors` | Member | Contract | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `processors.list` | `list(options?: { signal?: AbortSignal }): Promise` | | `processors.status` | `status( id: string, options?: { signal?: AbortSignal }, ): Promise` | | `processors.changes` | `changes( id: string, options?: ChangesOptions, ): Promise>>` | | `processors.changeHead` | `changeHead( id: string, options?: { signal?: AbortSignal }, ): Promise` | | `processors.resetLiveLane` | `resetLiveLane( id: string, options?: { signal?: AbortSignal }, ): Promise` | | `processors.listCollections` | `listCollections( id: string, options?: { signal?: AbortSignal }, ): Promise<{ processor: ProcessorSummary; outputMode: "none" \| "latest" \| "window" \| "full"; retained: boolean; data: string[]; }>` | | `processors.queryEntities` | `queryEntities( id: string, collection: string, options?: OutputQueryOptions, ): Promise>` | | `processors.queryAndFollow` | `queryAndFollow( id: string, collection: string, options?: Omit, ): Promise>` | | `processors.getEntity` | `getEntity( id: string, collection: string, key: Hex, options?: { signal?: AbortSignal }, ): Promise>` | | `processors.releaseSnapshot` | `releaseSnapshot( id: string, snapshotId: string, options?: { signal?: AbortSignal }, ): Promise` | | `processors.consumers.list` | `list( id: string, options?: { signal?: AbortSignal }, ): Promise` | | `processors.consumers.create` | `create( id: string, options: CreateConsumerOptions, ): Promise` | | `processors.consumers.inspect` | `inspect( id: string, consumer: string, options?: { signal?: AbortSignal }, ): Promise` | | `processors.consumers.revoke` | `revoke( id: string, consumer: string, options?: { signal?: AbortSignal }, ): Promise` | | `processors.consumers.renew` | `renew( id: string, consumer: string, credential: string, options?: { signal?: AbortSignal }, ): Promise` | | `processors.consumers.changes` | `changes( id: string, consumer: string, credential: string, options?: { limit?: number; signal?: AbortSignal }, ): Promise>>` | | `processors.consumers.acknowledge` | `acknowledge( id: string, consumer: string, credential: string, cursor: string, options?: { signal?: AbortSignal }, ): Promise` | | `processors.subscribe` | `subscribe( id: string, options?: SubscribeOptions, ): AsyncGenerator>` | ## `blobs` | Member | Contract | | ------------------------ | ------------------------------------------------------------------------------------------------------- | | `blobs.getBlock` | `getBlock( number: number, options?: { signal?: AbortSignal }, ): Promise` | | `blobs.listBlocks` | `listBlocks(options: ListBlocksOptions): Promise>` | | `blobs.listSnapshot` | `listSnapshot( options: ListBlocksOptions, ): Promise>` | | `blobs.getTransaction` | `getTransaction( hash: Hex, options?: { signal?: AbortSignal }, ): Promise` | | `blobs.listTransactions` | `listTransactions( options: ListTransactionsOptions, ): Promise>` | | `blobs.getSchedule` | `getSchedule(options?: { atBlock?: number; signal?: AbortSignal; }): Promise` | | `blobs.subscribe` | `subscribe( options?: ProcessorSubscribeOptions, ): AsyncGenerator>` | ## `erc20` | Member | Contract | | ------------------ | ---------------------------------------------------------------------------------------------------- | | `erc20.getBalance` | `getBalance( token: Hex, address: Hex, options?: { signal?: AbortSignal }, ): Promise` | | `erc20.subscribe` | `subscribe( options?: ProcessorSubscribeOptions, ): AsyncGenerator>` | ## `uniswap` | Member | Contract | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | `uniswap.getPool` | `getPool( address: Hex, options?: { signal?: AbortSignal }, ): Promise` | | `uniswap.getLatestObservation` | `getLatestObservation( address: Hex, options?: { processor?: string; signal?: AbortSignal }, ): Promise` | | `uniswap.listConfiguredPools` | `listConfiguredPools(options?: { processor?: string; signal?: AbortSignal; }): Promise` | | `uniswap.subscribe` | `subscribe( options?: ProcessorSubscribeOptions, ): AsyncGenerator>` | ## `blocks` | Member | Contract | | -------------------- | --------------------------------------------------------------------------------------------------------------------- | | `blocks.getLatest` | `getLatest(options?: { processor?: string; signal?: AbortSignal; }): Promise` | | `blocks.getByNumber` | `getByNumber( number: number, options?: { processor?: string; signal?: AbortSignal }, ): Promise` | | `blocks.subscribe` | `subscribe( options?: ProcessorSubscribeOptions, ): AsyncGenerator>` | ## `backfill` | Member | Contract | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `backfill.baseUrl` | `readonly baseUrl: URL` | | `backfill.create` | `create(request: CreateBackfillSubscription): Promise` | | `backfill.list` | `list(options?: { signal?: AbortSignal }): Promise` | | `backfill.inspect` | `inspect( subscription: string, options?: { signal?: AbortSignal }, ): Promise` | | `backfill.cancel` | `cancel( subscription: string, options?: { signal?: AbortSignal }, ): Promise` | | `backfill.retry` | `retry( subscription: string, options?: { signal?: AbortSignal }, ): Promise` | | `backfill.delete` | `delete( subscription: string, options?: { signal?: AbortSignal; discardUnacknowledged?: boolean }, ): Promise` | | `backfill.changes` | `changes( subscription: string, consumer: string, options?: { credential?: string; limit?: number; signal?: AbortSignal; }, ): Promise<{ data: ChangeEnvelope[]; nextCursor: string \| null; coverage: unknown; }>` | | `backfill.stream` | `stream( subscription: string, consumer: string, options?: { credential?: string; batching?: BackfillBatchingRequest; signal?: AbortSignal; }, ): Promise>` | | `backfill.streamLive` | `streamLive( processor: string, consumer: string, options?: { credential?: string; signal?: AbortSignal }, ): Promise>` | | `backfill.subscribe` | `subscribe( request: MultiLaneSubscriptionRequest, ): Promise>` | ## Exported types and helpers