Skip to content

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:

Terminal window
cargo build --locked -p leani-custom-example
cargo test --locked -p leani-custom-example

Use a fresh working directory so the example owns its own database:

Terminal window
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" serve

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

Terminal window
curl -s http://127.0.0.1:9080/v1/processors/example-block-summary-local/query/19426589

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

Run Leani with your processor 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()
}
}
}
The leani Rust library supplies the CLI and node runtime.Verified source

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:

Map a block and commit its summary
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],
})
}
Processor methods from the complete custom-node example.Verified source

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:

Custom processor configuration
[[processors]]
id = "example-block-summary"
instance = "example-block-summary-local"
version = "1.0.0"
history_mode = "on_demand"
start_block = 19426589
publish = "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"
The complete lifecycle policy for the block-summary processor instance.Verified source

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:

Custom processor query 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)),
)
}
}
A native processor factory attaching a typed, processor-owned query route.Verified source

With the example node still running, prepare the SDK using the install page, then run this from the checkout:

Terminal window
bun examples/custom-node/client.ts
Discover and query a custom extension
import { 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);
Capability-driven TypeScript lookup of a processor-owned query route.Verified source

Move it into your application

The example includes a standalone manifest with explicit dependencies. With your source checkout named leani, create a sibling application:

Terminal window
cd "$LEANI_SOURCE/.."
cargo new --bin my-leani-node
cp "$LEANI_SOURCE/examples/custom-node/standalone.toml" my-leani-node/Cargo.toml
cp "$LEANI_SOURCE/examples/custom-node/src/main.rs" my-leani-node/src/main.rs
cp "$LEANI_SOURCE/examples/custom-node/node.toml" my-leani-node/node.toml
cp "$LEANI_SOURCE/rust-toolchain.toml" my-leani-node/rust-toolchain.toml
cd my-leani-node
cargo build
cargo test --locked
./target/debug/my-leani-node --config node.toml doctor --json

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