Native API and delivery protocol
Status: implemented v1 contract baseline, 2026-08-01.
Leani exposes two related interfaces:
- Ethereum JSON-RPC for applications expecting an execution-client-shaped endpoint.
- 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
fetchfor 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:
http://127.0.0.1:8080/v1/... aggregate APIhttp://127.0.0.1:8545 Ethereum JSON-RPCws://127.0.0.1:8546 Ethereum subscriptionsThe 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 is closed with code
1013 and must reconnect.
Public aggregate routes:
GET /health/liveGET /health/readyGET /debug/networkGET /metrics
GET /v1/statusGET /v1/network/statusGET /v1/capabilitiesGET /v1/processorsGET /v1/processors/{processor}/statusGET /v1/processors/{processor}/schemaGET /v1/processors/{processor}/changes/headGET /v1/processors/{processor}/changesGET /v1/processors/{processor}/streamGET /v1/processors/{processor}/collectionsGET /v1/processors/{processor}/collections/{collection}/entitiesGET /v1/processors/{processor}/collections/{collection}/entities/{key}POST /v1/processors/{processor}/collections/{collection}/query-and-followDELETE /v1/processors/{processor}/query-snapshots/{snapshot}GET /v1/processors/{processor}/checkpointsPOST /v1/processors/{processor}/checkpoints/{checkpoint}/restoreGET /v1/processors/{processor}/savepointsPOST /v1/processors/{processor}/savepointsGET /v1/processors/{processor}/savepoints/{savepoint}DELETE /v1/processors/{processor}/savepoints/{savepoint}GET /v1/processors/{processor}/consumersPOST /v1/processors/{processor}/consumersGET /v1/processors/{processor}/consumers/{consumer}DELETE /v1/processors/{processor}/consumers/{consumer}POST /v1/processors/{processor}/consumers/{consumer}/leaseGET /v1/processors/{processor}/consumers/{consumer}/changesPOST /v1/processors/{processor}/consumers/{consumer}/ack
GET /v1/processors/{processor}/query/*
GET /v1/q/blobs/blocksGET /v1/q/blobs/blocks/{number}GET /v1/q/blobs/snapshotGET /v1/q/blobs/transactionsGET /v1/q/blobs/transactions/{hash}GET /v1/q/blobs/scheduleGET /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 are not behind
the optional API bearer token so a protected local supervisor can probe them.
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:
POST /admin/v1/materialization-jobsGET /admin/v1/materialization-jobsGET /admin/v1/materialization-jobs/{id}POST /admin/v1/materialization-jobs/{id}/cancelDELETE /admin/v1/materialization-jobs/{id}POST /admin/v1/backfill-subscriptionsGET /admin/v1/backfill-subscriptionsGET /admin/v1/backfill-subscriptions/{id}POST /admin/v1/backfill-subscriptions/{id}/cancelDELETE /admin/v1/backfill-subscriptions/{id}POST /admin/v1/processors/{processor}/rebuildPOST /admin/v1/sources/{source}/probeThe 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:
POST /admin/v1/materialization-jobscontent-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. After correcting a permanent failure, explicitly
delete the terminal job before recreating the request; deletion is the retry
boundary.
3. Common response model
Every processor query includes coverage:
export interface ProcessorCoverage { chainId: number; requested?: { fromBlock: number; toBlock: number; }; available: Array<{ fromBlock: number; toBlock: number; finality: "preview" | "included" | "finalized"; }>; configuredStartBlock: number; historicalTargetBlock: number | null; liveHeadBlock: number | null; processedThrough: number | null; finalizedThrough: number | null; complete: boolean; state: | "starting" | "backfilling" | "catching_up" | "live" | "degraded" | "failed";}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 only set
when the complete interval begins at the configured start and has no gaps.
For an ordered-state processor, coverage also reports:
export interface OrderedStateCoverage { reduceCursor: BlockCursor | null; pendingThrough: number | null; seededFromCheckpoint: boolean;}All list responses use opaque pagination cursors:
export interface Page<T> { 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:
POST /v1/processors/{processor}/collections/{collection}/query-and-follow;- import every page using its opaque
nextCursor; and - follow
/v1/processors/{processor}/changesor/streamafter the returnedboundaryCursor.
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. Creation always names a start position:
POST /v1/processors/blobs-money/consumerscontent-type: application/json
{ "id": "blobs-api", "role": "required", "start": { "position": "earliest_retained" }, "leaseTtlSeconds": 300, "credential": "<random per-consumer secret>"}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.
Consumer-scoped renew, change-read, and acknowledgement calls send:
x-leani-consumer-credential: <random per-consumer secret>The secret is hashed at rest. A consumer credential cannot inspect or acknowledge another consumer. Administrative bearer authorization remains available when configured.
Read a batch:
GET /v1/processors/blobs-money/consumers/blobs-api/changes?limit=500x-leani-consumer-credential: ...After committing the destination transaction and its cursor atomically:
POST /v1/processors/blobs-money/consumers/blobs-api/ackx-leani-consumer-credential: ...content-type: application/json
{ "cursor": "<highest durably committed cursor>" }Acknowledgement is monotonic and idempotent. A cursor beyond the highest
sequence delivered to that consumer is rejected, preventing a buggy client
from pruning unseen changes. 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,
limit, and an opaque cursor. Entity keys are URL-safe hex. Every result
contains the processor schema, decoded JSON data, block/timestamp/finality
metadata, stable snapshot ID, change boundary, retained bounds, and coverage.
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 snapshot across pagination and node
restart. 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, but 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.
Release a no-longer-needed snapshot early:
DELETE /v1/processors/{processor}/query-snapshots/{snapshotId}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:
GET /v1/processors/{processor}/artifacts/statusGET /v1/processors/{processor}/artifacts/{blockNumber}Portable export is paged by block range:
GET /v1/processors/{processor}/artifacts/export?fromBlock=1&toBlock=1000&limit=1000The 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:
POST /admin/v1/processors/{source}/artifacts/replaycontent-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:
DELETE /admin/v1/processors/{processor}/artifacts?fromBlock=1&toBlock=1000The 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 included-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 and obey configured count
retention. POST .../checkpoints/{checkpoint}/restore atomically repairs
private processor state only when the checkpoint is at the exact current
cursor. 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:
POST /v1/processors/{processor}/savepointscontent-type: application/json
{ "id": "before-v2-upgrade" }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.
4. Change protocol
4.1 Envelope
export interface ChangeEnvelope<T = unknown> { apiVersion: "1"; sequence: string; cursor: string; operation: "apply" | "undo" | "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: "preview" | "included" | "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. 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:
cursor format versionstore epochchain IDprocessor instancechange sequenceblock number/hash when applicablechecksumThe 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 included but not yet finalized.
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;
- need not repeat every entity;
- permits a consumer to mark earlier changes durable.
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:
GET /v1/processors/blobs-money/changes?after=<cursor>&limit=500Streaming:
GET /v1/processors/blobs-money/stream?after=<cursor>Accept: text/event-streamEvery 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:
event: hellodata: {"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:
id: <opaque cursor>event: applydata: {"apiVersion":"1",...}The server sends comments as heartbeats. The SDK reconnects with the last fully yielded cursor, uses exponential backoff with jitter, and treats these differently:
- clean server close: reconnect;
- transient network/5xx: reconnect;
- authentication/validation 4xx: 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:
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<string, unknown>; 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 |
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:
{ "chainId": 1, "ethereumRpc": { "http": "http://127.0.0.1:8545", "webSocket": "ws://127.0.0.1:8546", "methods": { "eth_getBlockByNumber": { "recent": true, "historical": "on_demand_by_number" }, "eth_getTransactionByHash": { "recent": true, "historical": false, "reason": "transaction_locator_disabled" }, "eth_call": { "supported": false, "reason": "evm_state_unavailable" } } }, "processors": [ { "id": "blobs-money", "instance": "blobs-production", "version": "1.4.0", "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.
Also expose an equivalent custom JSON-RPC method,
leani_getCapabilities, 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:
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: "preview" | "included" | "finalized";}
export interface BlobTransaction { network: string; blockNumber: number; blockHash: `0x${string}`; txHash: `0x${string}`; senderAddress: `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";}The API should not use decimal floating point for ETH values. Presentation as ETH belongs in blobs.money.
7.2 Query examples
GET /v1/q/blobs/blocks/21000000GET /v1/q/blobs/blocks?fromBlock=21000000&toBlock=21001000&limit=500GET /v1/q/blobs/transactions?blockNumber=21000000GET /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_incompleteby 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:
blobs.block.putblobs.block.deleteblobs.schedule.putOne 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:
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.
8. TypeScript SDK
Package:
@leani/sdkThe public package name is reserved but not published yet. The pre-release workspace package remains private until its JavaScript/type build and packed Node/Bun smoke tests are part of the release workflow.
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:
import { createLeaniClient } from "@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 contains only
after and signal.
Core client modules:
client.status()client.capabilities()client.request<T>(path, params, options)client.processors.list()client.processors.status(id)client.processors.changeHead(id)client.processors.changes(id, options)client.processors.subscribe<T>(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<T> is the safe primitive for custom query extensions. Relative paths
such as v1/processors/<instance>/query/summary preserve any base-URL path;
root paths such as /v1/q/my-protocol/summary target the configured origin.
Schemes, protocol-relative paths, and cross-origin resolution are rejected
before the bearer token is attached. 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:
import { applyEntityChange, compareSequences, createInMemoryCursorStore, // Tests and development only. isResetRequired,} from "@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:
- update the OpenAPI document;
- validate it in CI;
- generate TypeScript wire types;
- compile the Rust route response types against schema snapshots/tests;
- handwrite the ergonomic SDK around generated wire types;
- 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. Consumer credentials additionally fence one consumer’s lease and
cursor; they do not replace the listener bearer when it is configured.
/health/live, /health/ready, /metrics, /v1/network/status, and
/debug/network remain unauthenticated operational routes. The JSON-RPC
listeners have no built-in authentication.
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:
eth_blockNumbereth_getBlockByNumber(number, true)eth_getBlockReceiptseth_getTransactionReceipteth_subscribe(newHeads)eth_config, from the node's checked/versioned chain scheduleThis 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:
SDK blobs stream -> one database transaction upsert/delete blocks upsert/delete blob_transactions -> acknowledge the Leani-owned durable cursor -> existing application analyticsThe 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
- Publish the shared
@leani/sdkpackage and replace the application-local client once their contracts match. - Start Leani from application-requested ranges derived from PostgreSQL state.
- Compare Leani output with existing
blocksandblob_transactions. - Run the native consumer into shadow PostgreSQL tables.
- Verify reorg, restart, expired-cursor, and backfill-gap behavior.
- Make the SDK consumer primary while retaining Alchemy as a disabled-by- default emergency switch.
- Observe CU usage and parity.
- remove normal Alchemy block, receipt, and head traffic.
- Remove
full_blocksonly 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.