Module 18 — Crossing the Boundary: Rust, N-API, and WASI for Cryptographic Throughput
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.
When V8 Hits Its Ceiling
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.
javascript
The 25× throughput difference is not marginal — it changes whether you need 1 server or 5.
N-API: The Stable Node.js Native Addon Interface
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 vs Neon: Two Ways to Bridge Rust to N-API
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.
napi-rs: Rust Bindings for N-API
toml
rust
bash
Using the Addon in Node.js
javascript
Zero-Copy: The Key Principle
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.
Async N-API: Don't Block the Event Loop
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:
rust
javascript
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:
rust
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.
Handling Untrusted Input: catch_unwind
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.
rust
javascript
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."
Cross-Platform Distribution: Prebuilding the .node Binary
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:
bash
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.
WASI: Sandboxed Native Computation
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.
javascript
N-API vs WASI: When to Choose
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)
// 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)
# 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.rsusenapi::bindgen_prelude::*;usenapi_derive::napi;usesecp256k1::{Secp256k1,Message,PublicKey,ecdsa::Signature};#[napi]// Zero-copy: receives a Buffer reference, does not clone the datapubfnverify_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)]pubstructTransactionData{pub tx_hash:Buffer,// 32-byte transaction hash, used for correlating results back to callerspub message_hash:Buffer,// 32-byte hash of the signed messagepub signature:Buffer,// 64-byte compact ECDSA signaturepub public_key:Buffer,// 33 or 65-byte public key}#[napi]// Batch verification: verify many signatures in one call// Amortizes the JNI crossing overheadpubfnverify_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 addonnpminstall-g @napi-rs/cli
napi build --platform--release# Produces: blockchain-crypto.node (platform-specific binary)
// Zero-copy Buffer pass-throughimport{ verifySecp256k1, verifySecp256k1Batch }from'./blockchain-crypto.node';// Single verification — passes Buffer references, no copyfunctionverifyTransaction(tx){const messageHash =computeHash(tx.data);// BufferreturnverifySecp256k1( messageHash,// passed as reference — zero copy tx.signature,// passed as reference — zero copy tx.senderPublicKey// passed as reference — zero copy);}// Batch verification — most efficient pattern// Amortizes the JS→native boundary crossing cost across many verificationsasyncfunctionverifyTransactionBatch(transactions){const batchData = transactions.map(tx=>({message_hash:computeHash(tx.data),signature: tx.signature,public_key: tx.senderPublicKey,}));returnverifySecp256k1Batch(batchData);// one crossing for N verifications}
// 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]pubasyncfnverify_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 =awaitverifySecp256k1BatchAsync(batchData);
usenapi::threadsafe_function::{ThreadsafeFunction,ThreadsafeFunctionCallMode};usenapi::JsFunction;usestd::thread;#[napi]pubfnverify_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(())}
usestd::panic::catch_unwind;#[napi]pubfnverify_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()))?;Ok::<bool,Error>(secp.verify_ecdsa(&msg,&sig,&pk).is_ok())});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 processtry{verifySecp256k1Safe(corruptHash, signature, publicKey);}catch(err){ logger.warn({ err },'rejected malformed transaction — process stayed up');}
# 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
import{WASI}from'node:wasi';import{ readFileSync }from'node:fs';// Load a WASI moduleconst wasi =newWASI({version:'preview1',// No filesystem access granted — fully sandboxedpreopens:{},});const importObject ={wasi_snapshot_preview1: wasi.wasiImport,};const wasmModule =newWebAssembly.Module(readFileSync('./crypto-verifier.wasm'));const instance =newWebAssembly.Instance(wasmModule, importObject);wasi.initialize(instance);// Call WASI functionconst{ verify_ed25519, malloc, free }= instance.exports;// 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;const messageOffset =malloc(32);const signatureOffset =malloc(64);const publicKeyOffset =malloc(32);try{// Now these offsets are addresses the allocator has actually reserved —// safe to write into.newUint8Array(memory.buffer, messageOffset,32).set(messageHash);newUint8Array(memory.buffer, signatureOffset,64).set(signature);newUint8Array(memory.buffer, publicKeyOffset,32).set(publicKey);// Callconst 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);}