What this module covers: At 50,000 transactions/second, every transaction requires elliptic curve signature verification (secp256k1 for Ethereum/Bitcoin, ed25519 for Supra/Solana). V8's JavaScript implementation of these algorithms cannot reach the throughput that a Rust native addon can achieve. This module covers N-API — the stable C ABI that connects Node.js to native code — and how to use napi-rs to write Rust addons that pass multi-megabyte Buffer payloads across the JavaScript/native boundary without copying, maintaining the zero-allocation principles from Module 5. WASI provides a sandboxed alternative for deterministic computation that does not require compilation per platform.
JavaScript's secp256k1 implementation (via elliptic or @noble/secp256k1) processes ~10,000–25,000 verifications/second on a single thread. A Rust implementation of the same algorithm (via the secp256k1 crate) processes ~200,000–500,000 verifications/second. The difference: Rust compiles to optimized machine code with SIMD instructions, no GC pauses, and no JIT compilation overhead.
// Benchmark: JS vs Rust secp256k1 verification // Node.js (noble/secp256k1): ~15,000 verifications/sec per thread // Rust N-API addon: ~380,000 verifications/sec per thread
// At 50,000 tx/sec requiring verification: // JS: needs 50,000/15,000 = 3.3 threads minimum (4 workers) // Rust: needs 50,000/380,000 = 0.13 threads (fits in single thread pool)
The 25× throughput difference is not marginal — it changes whether you need 1 server or 5.
N-API (Node-API) is a stable C ABI for creating native addons that work across Node.js versions without recompilation. It replaced the older NAN (Native Abstractions for Node.js) which broke on every major Node.js version.
N-API guarantees: an addon compiled for Node.js 18 runs on Node.js 22 without recompilation. The ABI is stable.
napi-rs (used throughout this module) is one of two mainstream frameworks for writing N-API addons in Rust. The other is Neon. Both sit on top of the same underlying N-API C ABI and solve the same core problem — safely converting Rust types (structs, Vec<T>, String, integers) into JavaScript values and back without hand-rolling raw N-API pointer calls. The practical differences are mostly about ergonomics and defaults: napi-rs leans on procedural macros (#[napi], #[napi(object)]) to generate TypeScript type definitions alongside the compiled binary and has first-class ThreadsafeFunction support for async callbacks; Neon has historically emphasized a more manual, explicit binding style and its own async task abstraction. For a new blockchain indexer addon, either is a reasonable choice — this module standardizes on napi-rs for its TypeScript generation and batch-call ergonomics, which matter once you're verifying thousands of transactions per call.
# Cargo.toml [package] name = "blockchain-crypto" version = "0.1.0" edition = "2021"
[lib] crate-type = ["cdylib"] # dynamic library for N-API
[dependencies] napi = { version = "2", features = ["napi9"] } napi-derive = "2" secp256k1 = { version = "0.28", features = ["recovery"] }
// src/lib.rs use napi::bindgen_prelude::*; use napi_derive::napi; use secp256k1::{Secp256k1, Message, PublicKey, ecdsa::Signature};
#[napi] // Zero-copy: receives a Buffer reference, does not clone the data pub fn verify_secp256k1( message_hash: Buffer, // 32-byte hash — direct reference to JS Buffer memory signature: Buffer, // 64-byte signature — direct reference public_key: Buffer, // 33 or 65-byte public key — direct reference ) -> Result<bool> { let secp = Secp256k1::verification_only();
let msg = Message::from_digest_slice(&message_hash) .map_err(|e| Error::from_reason(e.to_string()))?;
let sig = Signature::from_compact(&signature) .map_err(|e| Error::from_reason(e.to_string()))?;
let pk = PublicKey::from_slice(&public_key) .map_err(|e| Error::from_reason(e.to_string()))?;
Ok(secp.verify_ecdsa(&msg, &sig, &pk).is_ok()) }
// A single incoming transaction's verification material — mirrors the shape // the JS side builds up before calling into the batch function. #[napi(object)] pub struct TransactionData { pub tx_hash: Buffer, // 32-byte transaction hash, used for correlating results back to callers pub message_hash: Buffer, // 32-byte hash of the signed message pub signature: Buffer, // 64-byte compact ECDSA signature pub public_key: Buffer, // 33 or 65-byte public key }
#[napi] // Batch verification: verify many signatures in one call // Amortizes the JNI crossing overhead pub fn verify_secp256k1_batch( transactions: Vec<TransactionData>, ) -> Result<Vec<bool>> { let secp = Secp256k1::verification_only();
Ok(transactions.iter().map(|tx| { let msg = Message::from_digest_slice(&tx.message_hash).ok()?; let sig = Signature::from_compact(&tx.signature).ok()?; let pk = PublicKey::from_slice(&tx.public_key).ok()?; Some(secp.verify_ecdsa(&msg, &sig, &pk).is_ok()) }).map(|r| r.unwrap_or(false)).collect()) }
# Build the Rust addon npm install -g @napi-rs/cli napi build --platform --release # Produces: blockchain-crypto.node (platform-specific binary)
When Node.js passes a Buffer to a native function, napi-rs passes a reference to the underlying memory — no copy. The Rust function reads directly from the V8/off-heap memory that the Buffer points to.
This maintains the zero-allocation principle from Module 5: the signature verification workload processes data that was allocated once (when the transaction was received) and never copied again — from socket → Buffer → Rust secp256k1 → result.
Every crossing of the JS/native boundary is a toll booth — the crypto math itself is nearly free, but paying that toll once per transaction instead of once per batch is what actually costs you at 50K TPS. Zero-copy Buffers eliminate the cost of moving the data; batching (below) eliminates the cost of the crossing itself, and both matter independently.
The quiz at the end of this module asks what happens when a native N-API function runs synchronously on the main thread — it blocks the event loop, exactly like any other long-running synchronous JavaScript call. verify_secp256k1 and verify_secp256k1_batch above are both synchronous: while Rust is verifying, Node.js cannot process any other request. For a single ~200ns verification that's irrelevant, but a batch of 500 transactions still takes real wall-clock time, and a burst of concurrent batch calls will queue behind each other on the main thread.
napi-rs gives you two ways to move this work off the main thread without giving up the Rust performance:
// Option 1: an async #[napi] fn — napi-rs runs the function body on the // libuv thread pool automatically and resolves a JS Promise when it's done. #[napi] pub async fn verify_secp256k1_batch_async( transactions: Vec<TransactionData>, ) -> Result<Vec<bool>> { // Runs off the main thread — the event loop stays free to handle // other transactions, HTTP requests, etc. while this executes. let secp = Secp256k1::verification_only();
Ok(transactions.iter().map(|tx| { let msg = Message::from_digest_slice(&tx.message_hash).ok()?; let sig = Signature::from_compact(&tx.signature).ok()?; let pk = PublicKey::from_slice(&tx.public_key).ok()?; Some(secp.verify_ecdsa(&msg, &sig, &pk).is_ok()) }).map(|r| r.unwrap_or(false)).collect()) }
// From Node.js — looks identical to the sync version, but no longer // blocks the event loop while 500 signatures verify. const results = await verifySecp256k1BatchAsync(batchData);
For callback-shaped native code (e.g. wrapping a C library that reports progress incrementally rather than returning a single value), napi-rs exposes ThreadsafeFunction — a handle that lets a background thread safely call back into JavaScript:
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}; use napi::JsFunction; use std::thread;
#[napi] pub fn verify_batch_streaming( transactions: Vec<TransactionData>, // JS callback invoked once per verified transaction, from a native thread callback: JsFunction, ) -> Result<()> { let tsfn: ThreadsafeFunction<bool> = callback.create_threadsafe_function(0, |ctx| Ok(vec![ctx.value]))?;
// Spawn a real OS thread — NOT the main thread, NOT the libuv pool. // ThreadsafeFunction is what makes it safe to call back into JS from here. thread::spawn(move || { let secp = Secp256k1::verification_only(); for tx in transactions { let is_valid = (|| -> Option<bool> { let msg = Message::from_digest_slice(&tx.message_hash).ok()?; let sig = Signature::from_compact(&tx.signature).ok()?; let pk = PublicKey::from_slice(&tx.public_key).ok()?; Some(secp.verify_ecdsa(&msg, &sig, &pk).is_ok()) })().unwrap_or(false);
// Safely marshal the result back onto the JS thread tsfn.call(is_valid, ThreadsafeFunctionCallMode::NonBlocking); } });
Ok(()) }
If neither is available for a given native library, the fallback is the same one you'd use for any blocking JS call: offload to a worker_thread (Module 6) and message the result back, rather than calling the synchronous native function directly from the main thread.
The indexer verifies transaction bytes coming off the network — data an attacker fully controls. If a malformed or adversarially crafted transaction causes a Rust panic! (an out-of-bounds slice index, an unwrap() on None, an integer overflow in debug builds), the default behavior is not a catchable JavaScript error. A Rust panic unwinding across the N-API boundary aborts the entire Node.js process — every in-flight transaction, every open connection, gone, with no chance for graceful shutdown.
use std::panic::catch_unwind;
#[napi] pub fn verify_secp256k1_safe( message_hash: Buffer, signature: Buffer, public_key: Buffer, ) -> Result<bool> { // catch_unwind traps a panic before it can cross the FFI boundary and // converts it into a Rust Result — which napi-rs then turns into a // normal, catchable JavaScript exception instead of a process crash. let result = catch_unwind(|| { let secp = Secp256k1::verification_only();
let msg = Message::from_digest_slice(&message_hash) .map_err(|e| Error::from_reason(e.to_string()))?; let sig = Signature::from_compact(&signature) .map_err(|e| Error::from_reason(e.to_string()))?; let pk = PublicKey::from_slice(&public_key) .map_err(|e| Error::from_reason(e.to_string()))?;
match result { Ok(inner_result) => inner_result, // The panic was caught here — surface it as a normal JS-catchable error // instead of letting it unwind into an aborted process. Err(_) => Err(Error::from_reason( "native verification panicked on malformed transaction bytes", )), } }
// From Node.js — a panic on malformed bytes now throws, it doesn't crash the process try { verifySecp256k1Safe(corruptHash, signature, publicKey); } catch (err) { logger.warn({ err }, 'rejected malformed transaction — process stayed up'); }
Any #[napi] function that touches attacker-supplied byte layouts (transaction hashes, signatures, public keys parsed from raw network bytes) should be wrapped in catch_unwind. It is the difference between "one bad transaction gets rejected" and "one bad transaction takes down the entire indexer."
A compiled blockchain-crypto.node is platform- and architecture-specific — a binary built on darwin-arm64 will not load on linux-x64. This is the same distribution problem Module 17 solved for Single Executable Applications: you cannot ship one artifact and expect it to run everywhere; you have to build and ship one artifact per target.
napi-rs's CLI cross-compiles for every platform you need to support:
# Build for each deployment target explicitly, rather than relying on the # host machine's own platform (CI runners are typically linux-x64) napi build --platform --release --target x86_64-unknown-linux-gnu napi build --platform --release --target aarch64-unknown-linux-gnu napi build --platform --release --target x86_64-apple-darwin napi build --platform --release --target aarch64-apple-darwin
Each target produces its own .node file, named so napi-rs's loader can pick the right one at require() time (blockchain-crypto.linux-x64-gnu.node, blockchain-crypto.darwin-arm64.node, etc.). For distribution, napi-rs publishes these as optional per-platform npm packages that install alongside the main package — npm install only pulls the one binary matching the installing machine's platform, rather than shipping every architecture to every consumer.
The older, framework-agnostic alternative is prebuildify: it bundles all prebuilt binaries directly inside the published package (under a prebuilds/ directory) rather than as separate optional dependencies, trading a larger package size for zero-network-dependency installs — useful in the same air-gapped or CI-restricted environments where Module 17's SEA bundling matters. Either way, the goal is identical to the SEA story: consumers of the indexer should never need a Rust toolchain or node-gyp installed just to run npm install.
WebAssembly System Interface (WASI) allows running compiled C/Rust code inside a sandboxed WebAssembly runtime. Unlike N-API (which runs with full native permissions), WASI modules are sandboxed — they cannot access the filesystem or network unless explicitly granted.
import { WASI } from 'node:wasi'; import { readFileSync } from 'node:fs';
// Load a WASI module const wasi = new WASI({ version: 'preview1', // No filesystem access granted — fully sandboxed preopens: {}, });
// WRONG: writing to hardcoded offsets (0, 32, 96) assumes those addresses are // unused WASM linear memory. They might not be — the module's own allocator // (its internal heap, stack, or globals) could already own that region, and // nothing has reserved it on your behalf. This silently corrupts memory or // gets overwritten by the module itself. // // const memory = instance.exports.memory; // new Uint8Array(memory.buffer, 0, 32).set(messageHash); // unsafe // new Uint8Array(memory.buffer, 32, 64).set(signature); // unsafe // new Uint8Array(memory.buffer, 96, 32).set(publicKey); // unsafe
// CORRECT: request memory from the module's own exported allocator first. // Most WASI-compiled Rust/C modules export malloc/free (or an equivalent // alloc function) precisely so host code can safely claim a region. const memory = instance.exports.memory;
try { // Now these offsets are addresses the allocator has actually reserved — // safe to write into. new Uint8Array(memory.buffer, messageOffset, 32).set(messageHash); new Uint8Array(memory.buffer, signatureOffset, 64).set(signature); new Uint8Array(memory.buffer, publicKeyOffset, 32).set(publicKey);
// Call const isValid = verify_ed25519(messageOffset, signatureOffset, publicKeyOffset); } finally { // Release the allocated memory back to the module once done — // otherwise every verification call leaks WASM linear memory. free(messageOffset, 32); free(signatureOffset, 64); free(publicKeyOffset, 32); }
A production indexer team initially called verify_secp256k1 once per transaction, exactly as it arrived off the wire. Each individual call was fast — but marshalling arguments across the JS/native boundary costs roughly 2µs per crossing regardless of how little work the native side does. At sustained 50,000 TPS, 2µs × 50,000 is an extra 100ms of pure crossing overhead per second — effectively an entire CPU core spent on nothing but marshalling, before a single signature was verified. Switching to verify_secp256k1_batch with 500-transaction batches cut the number of crossings by 500x, and that phantom CPU core's worth of overhead disappeared from the flame graphs entirely.
N-API (Rust)
WASI
Performance
Near-native
~1.5–2x slower than native (JIT compilation overhead)