Build a custom node in Rust
Use Leani as a Rust library when your application needs a transformation beyond
its built-in processors. You supply a Processor and register its factory;
Leani supplies acquisition, the CLI, storage, checkpoints, and delivery.
The result is your own executable with the usual doctor, backfill, and
serve commands.
This example stores each block’s number and timestamp. It requests headers
only and indexes three Mainnet blocks from public EraE archives. No API key or
live peer connection is needed. Start with the
source installation and keep LEANI_SOURCE
pointing to the checkout.
Run the complete example
From the checkout, build the custom binary and test its processor:
cargo build --locked -p leani-custom-examplecargo test --locked -p leani-custom-exampleUse a fresh working directory so the example owns its own database:
export LEANI_CUSTOM_BIN="$LEANI_SOURCE/target/debug/leani-custom-example"LEANI_CUSTOM_DIR="$(mktemp -d)"cp "$LEANI_SOURCE/examples/custom-node/node.toml" "$LEANI_CUSTOM_DIR/leani.toml"cd "$LEANI_CUSTOM_DIR""$LEANI_CUSTOM_BIN" doctor --json"$LEANI_CUSTOM_BIN" backfill \ --processor example-block-summary-local --from 19426589 --to 19426591"$LEANI_CUSTOM_BIN" serveExpect valid: true from doctor and continuous coverage of
19426589..19426591 after backfill. serve stays in the foreground. In a
second terminal, query the custom route:
curl -s http://127.0.0.1:9080/v1/processors/example-block-summary-local/query/19426589The response has the shape { "blockNumber": 19426589, "timestamp": 1710338159 }.
The timestamp comes from the block header. Query an unindexed block and the
extension returns HTTP 404. Discover the extension through /v1/capabilities
when your application does not know the configured instance name.
Use the Rust library
The package leani exports the node runtime and processor registry. The
example’s Cargo dependency is a local path during the alpha:
[dependencies]leani = { path = "../../crates/node" }leani-primitives = { path = "../../crates/primitives" }leani-processor-api = { path = "../../crates/processor-api" }Those paths are relative to examples/custom-node/Cargo.toml. Use paths to the
same checkout in your own project; the alpha’s crates are not yet published.
The complete manifest also includes the serialization, Tokio, and Axum
dependencies used below.
The entry point registers the factory and hands command-line execution to
leani::run_with_registry:
#[tokio::main]async fn main() -> std::process::ExitCode { let mut registry = ProcessorRegistry::new(); if let Err(error) = registry.register(BlockSummaryFactory) { eprintln!("error: {error}"); return Exit::Failure.into(); } match run_with_registry(registry).await { Ok(exit) => exit.into(), Err(error) => { eprintln!("error: {error:#}"); Exit::Failure.into() } }}ProcessorRegistry::new() starts empty: this binary contains the custom
processor registered here. The stock leani executable will not recognize
example-block-summary. Run the custom binary for this configuration.
Transform a block
The complete implementation is in examples/custom-node/src/main.rs.
Its descriptor declares Capability::Header, the processor’s ID/version,
its starting block, and its entity/change schemas.
These methods implement the transformation:
async fn map(&self, block: &BlockFrame) -> Result<EncodedDelta, ProcessorError> { self.descriptor.requirements[0] .validate_frame(block) .map_err(|error| ProcessorError::Input(error.to_owned()))?; let payload = serde_json::to_vec(&BlockSummary { block_number: block.block.number.0, timestamp: block.block.timestamp, }) .map_err(|error| ProcessorError::DeltaPayload(error.to_string()))?; Ok(EncodedDelta::new( &self.descriptor, block.chain_id, block.block, payload, )) }
async fn reduce( &self, transaction: &mut dyn ReducerTransaction, cursor: &ProcessorCursor, delta: &EncodedDelta, ) -> Result<DomainChanges, ProcessorError> { delta.validate(&self.descriptor)?; validate_cursor(cursor, delta)?; let key = delta.block.number.0.to_be_bytes().to_vec(); transaction .put(COLLECTION, key.clone(), delta.payload.clone()) .await?; let change = DomainChange { kind: self.change_kind.clone(), key, operation: ChangeOperation::Upsert, payload: delta.payload.clone(), }; transaction.emit(change.clone()).await?; Ok(DomainChanges { changes: vec![change], }) }map validates its input and encodes a deterministic block summary. It must
not depend on wall-clock time or external requests. reduce stores that summary
under the block number and emits its change in the same transaction. Leani
manages checkpoints and publication around that transaction.
The example implements change_json and entity_json to expose typed JSON
through delivery and the generic collection API.
For your own transformation, change the summary type, mapping, and rendering
together. Change the schema/version when the durable representation changes.
Configure the processor
The full node.toml supplies the archive source, budgets, and listeners.
Its processor section is:
[[processors]]id = "example-block-summary"instance = "example-block-summary-local"version = "1.0.0"history_mode = "on_demand"start_block = 19426589publish = "included_and_finalized"
[processors.state]mode = "durable"
[processors.output]mode = "full"
[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]change_kind = "example.block_summary"history_mode = "on_demand" lets the explicit bounded backfill command own
this run. output.mode = "full" retains all three summaries, and
delivery.mode = "window" makes their changes available to subscribers.
For a new experiment, use a new data directory.
Add a typed query route
The example’s optional query extension mounts /{number} underneath its
instance’s query path. Its factory connects both the processor and extension:
#[derive(Clone, Copy, Debug)]struct BlockSummaryFactory;
#[derive(Clone, Copy, Debug)]struct BlockSummaryQueryExtension;
#[allow(clippy::unnecessary_literal_bound)]impl QueryExtension for BlockSummaryQueryExtension { fn id(&self) -> &str { "block-summary-v1" }
fn alias(&self) -> Option<&str> { Some("block-summaries") }
fn routes(&self) -> Router<QueryContext> { Router::new().route("/{number}", get(get_block_summary)) }}
async fn get_block_summary( State(context): State<QueryContext>, Path(number): Path<u64>,) -> Result<Json<BlockSummary>, ApiError> { let value = context .entity(COLLECTION, &number.to_be_bytes()) .await? .ok_or_else(|| ApiError::not_found("block summary is not indexed"))?; let summary = serde_json::from_slice(&value).map_err(|error| { ApiError::internal(&format!("stored block summary is invalid: {error}")) })?; Ok(Json(summary))}
#[allow(clippy::unnecessary_literal_bound)]impl ProcessorFactory for BlockSummaryFactory { fn id(&self) -> &str { "example-block-summary" }
fn description(&self) -> &str { "Example block number and timestamp summaries" }
fn create( &self, configured: &ProcessorConfig, _context: ProcessorFactoryContext, ) -> Result<ProcessorComponents, ProcessorFactoryError> { let settings = configured .decode_settings::<BlockSummarySettings>() .map_err(|error| ProcessorFactoryError::configuration(error.to_string()))?; Ok( ProcessorComponents::new(Arc::new(BlockSummaryProcessor::new(configured, settings)?)) .with_query_extension(Arc::new(BlockSummaryQueryExtension)), ) }}With the example node still running, prepare the SDK using the install page, then run this from the checkout:
bun examples/custom-node/client.tsimport { createLeaniClient } from "@leani/sdk";
interface BlockSummary { blockNumber: number; timestamp: number;}
const leani = createLeaniClient({ baseUrl: "http://127.0.0.1:9080" });
const capabilities = await leani.capabilities();const extension = capabilities.queryExtensions.find( (candidate) => candidate.processor === "example-block-summary-local",);if (!extension) throw new Error("block summary query extension is unavailable");
const summary = await leani.request<BlockSummary>( `${extension.basePath}/19426589`,);console.log(summary);Move it into your application
The example includes a standalone manifest with explicit dependencies. With your
source checkout named leani, create a sibling application:
cd "$LEANI_SOURCE/.."cargo new --bin my-leani-nodecp "$LEANI_SOURCE/examples/custom-node/standalone.toml" my-leani-node/Cargo.tomlcp "$LEANI_SOURCE/examples/custom-node/src/main.rs" my-leani-node/src/main.rscp "$LEANI_SOURCE/examples/custom-node/node.toml" my-leani-node/node.tomlcp "$LEANI_SOURCE/rust-toolchain.toml" my-leani-node/rust-toolchain.tomlcd my-leani-nodecargo buildcargo test --locked./target/debug/my-leani-node --config node.toml doctor --jsonThis application is outside Leani’s workspace. Its dependencies point to
../leani/crates/...; adjust those paths if your checkout has another name.
The compatibility patch in the standalone manifest matches the workspace’s
upstream dependency patch. Commit the generated Cargo.lock, then use
cargo build --locked for subsequent builds. Use your new binary with the same
bounded backfill and serve commands from the beginning of this guide.
Complete files: standalone Cargo manifest, Rust implementation and tests, and node configuration.
The implementation is compiled into your binary. For contract events expressible with a static ABI, the event decoder may be sufficient without Rust code. For custom aggregation or additional queries, continue with the processor contract.
Native processors run in the node process. Review their code and test their restart and reorg behavior before using them with production data.