Run an ENC Node
An ENC node hosts enclaves: it sequences signed events, folds them into verifiable state (a Sparse Merkle Tree) and an append-only history (a Certificate Transparency log), and serves both to clients over HTTP + WebSocket. The node never has to be trusted — every byte it emits is independently verifiable against the protocol's signed events. See the Node API spec for the full wire surface.
The node logic is a single kernel compiled from the formally-verified Lean specification. That kernel ships in four interchangeable runtimes, all of which answer the same wire request byte-identically.
The four runtimes
| Runtime | What it is | Best for |
|---|---|---|
| cf-worker | TypeScript host loading the WASM kernel on Cloudflare Workers + Durable Objects | Production at scale — billions of per-user enclaves, geo-routed, zero-ops |
rs-server (encd) | Pure-Rust axum + wasmtime host; one static binary | Self-hosted / air-gapped / sovereign VM + bare-metal deploys |
| pure-JS Worker | The kernel as pure JavaScript, emitted from the Lean spec | The cross-implementation parity reference |
| lean-host | Runs the Lean program directly via a small Node byte-pump | Zero translation gap — the wire bytes are interpreted by the program that was proven correct; also the correctness referee |
All three WASM-based runtimes load a byte-identical enc-core.wasm. The Lean runtime
doesn't go through WASM at all — it is the Lean program. Cross-runtime tests assert all
four produce identical output for the same input.
Which should I run? Pick cf-worker for anything that needs to scale past one machine or be geo-distributed — it's the default for new deploys. Pick rs-server for self-hosted, compliance, or air-gapped deploys where one VM is enough. lean-host and pure-JS are correctness references rather than production targets.
Install and build
The runtimes live in the impl-node repository.
The SDK packages, the JS node handlers, and most of the cf-worker host are generated from
the Lean specification — yarn build:js runs that codegen.
git clone https://github.com/enc-protocol/impl-node
cd impl-node
yarn install
yarn build:js # regenerate SDK + node + worker from the Lean spec
yarn test:pure # smoke test — 62 assertions, ~800ms, no env neededFor the Rust + WASM artifacts:
yarn build:rs # build the Rust VM library (rs/)
yarn build:wasm # wasm-pack build of the kernel
yarn build # build:js + build:rs + build:wasm
# The standalone Rust HTTP server is an independent crate:
cd rs-server && cargo build --releaseThe generated files (sdk/, js/node/, and the codegen'd parts of cf-worker/src/) are
byte-pinned to the spec — don't hand-edit them; changes are overwritten on the next
yarn build:js, and the codegen idempotence gate will flag the drift.
Run locally
cf-worker (the production variant, run locally):
cd cf-worker
yarn install
yarn wrangler dev # → http://127.0.0.1:8787Same kernel as production, backed by WASM + per-enclave SQLite Durable Objects.
rs-server (encd):
cd rs-server
cargo build --release
./target/release/encd --port 8080 # → http://0.0.0.0:8080Loads the embedded WASM kernel once at startup; in-memory state by default.
pure-JS Worker (the codegen reference):
npx wrangler dev --config test/wrangler.toml --local # → http://127.0.0.1:8787Smoke-test a running node — bootstrap an enclave with a fixed throwaway key and read back the derived sequencer public key:
ENC=$(python3 -c "print('ee'*32)")
PRIV=$(python3 -c "print('33'*32)")
curl -X POST http://localhost:8080/enclaves/$ENC/init-with-priv \
-H "Content-Type: application/json" \
-d "{\"enclave_id\":\"$ENC\",\"sequencer_priv\":\"$PRIV\"}"
# → {"ok":true,"enclave_id":"ee…","sequencer_pubkey":"3c72addb…"}The returned sequencer_pubkey is the deterministic BIP-340 derivation from the private
key — identical across every runtime for the same input, which is how kernel parity is
checked by eye.
Test
yarn test:pure # 62 assertions, ~800ms, no env
yarn test # full JS suite (property + node + verifier + fuzz + persistence + …)
yarn test:cross # cross-implementation parity vectors (JS ↔ Rust ↔ WASM ↔ Schnorr ↔ RBAC)
yarn test:rs # cargo test for the Rust VM library
yarn test:all # test + test:cross + test:rs
cd rs-server && cargo test --release # rs-server (encd) unit testsMost JS suites spawn wrangler dev and exercise the pure-JS Worker; raise
WRANGLER_TIMEOUT on slower machines.
Deploy
Cloudflare (cf-worker) — the scale path
Prerequisites: a Cloudflare account with Workers + Durable Objects enabled, an
authenticated wrangler (npx wrangler login), and an R2 bucket for closed-bundle
archival.
cd cf-worker
yarn install
yarn wrangler deploy --dry-run # preflight: type-check + bundle size
yarn wrangler deployThe first deploy runs migrations that create the EnclaveDO and RegistryDO Durable
Object classes. Per-enclave isolation is automatic: each enclave_id routes to its own
Durable Object — idFromName(enclave_id) — with independent SQLite storage. Cloudflare
spawns and evicts isolates globally on demand, so scaling from one enclave to billions is
the same code path.
VM / bare-metal (rs-server / encd) — the self-hosted path
Build a static musl binary (no glibc dependency) and run it under systemd:
rustup target add x86_64-unknown-linux-musl
cd rs-server
cargo build --release --target x86_64-unknown-linux-musl
# → target/x86_64-unknown-linux-musl/release/encd (one static binary)Copy it to the host and register a systemd unit:
[Unit]
Description=encd — ENC protocol node
After=network.target
[Service]
ExecStart=/usr/local/bin/encd --port 8080
Restart=on-failure
DynamicUser=yes
[Install]
WantedBy=multi-user.targetThe MVP ships with in-memory state; wire an external store (sled / rocksdb / Postgres) for persistence across restarts.
Verify a deploy
The enc CLI ships an end-to-end test harness that drives a full workflow against any node
URL — enclave creation, signed commits, push, close-bundle, and signed STH retrieval:
# from a checkout of the enc CLI:
cargo build --release --bin enc
# Personal-enclave workflow against any node:
./scripts/enc-test.sh --workflow personal --remote https://your-node.example.com
# Expected: 16/16 ok
# Group RBAC workflow:
./scripts/enc-test.sh --workflow group
# Expected: 14/14 okA green run confirms the full host-sign-seq round-trip works against your deploy.
How a node protects the sequencer key
A node signs every sequencing event, but the sequencer private key never lives in the
kernel's WASM memory. The kernel declares a host_sign_seq import; the host answers it:
- The kernel computes the 32-byte message hash and calls
host_sign_seq. - The host retrieves the private key from off-WASM storage — Durable Object SQLite on cf-worker, the Rust process heap on rs-server.
- The host signs with BIP-340 Schnorr (
@noble/curvesin the JS hosts, thek256crate in rs-server) and writes the 64-byte signature back into WASM memory. - The kernel embeds the signature in the event record.
Why it matters: portable snapshots (GET /<id>/snapshot) are keyless. An attacker who
dumps a node's memory finds the public key but never the private one — so backups can be
archived or shared without ever leaking the signing identity.
Admission hooks — deployment policy, not protocol
A node admits commits through a configurable hook chain. Each hook either passes a request or rejects it — for example, requiring a valid Cloudflare Access JWT before any state-changing action:
const cfAccessJwtHook: AdmissionHook = async (request, action, env) => {
if (PUBLIC_ACTIONS.has(`${request.method} ${action}`)) return { pass: true };
const jwt = request.headers.get('Cf-Access-Jwt-Assertion');
if (!jwt) return { pass: false, reject: { code: 'unauthorized', message: 'missing JWT', status: 401 } };
const claims = await verifyCfAccessJwt(jwt, env);
return claims
? { pass: true }
: { pass: false, reject: { code: 'unauthorized', message: 'invalid JWT', status: 401 } };
};
admissionChain.push(cfAccessJwtHook);Hooks are orthogonal to the protocol: a Lean theorem
(replay_invariant_under_hook_swap) proves that adding or removing hooks never changes
protocol-layer determinism. They are pure deployment policy — gate access, add auth,
throttle — laid over the verifiable event stream without altering a single emitted byte.
Choosing a runtime for scale
A single-VM benchmark says rs-server is several times faster per core — but that's the wrong question for production. At Telegram scale (~900M users, ~50K peak msgs/sec) what matters is hosting billions of enclaves with per-user isolation, geo-routed and crash-surviving:
- cf-worker gets that for free. Each enclave is its own Durable Object with its own
storage, routed by
idFromName(enclave_id), spawned and evicted by Cloudflare globally. Scaling 1 → 1B enclaves is the same code path, zero-ops. - rs-server can do it too — but you run the VM fleet, sticky-route by
enclave_id, replicate each enclave's state, and operate that infrastructure yourself.
This is safe because distinct enclaves have disjoint commit histories — proved in the
Lean spec as the theorem enclave_isolation. When a request lands on an enclave's Durable
Object, that DO's storage holds only that enclave's commits, so no cross-enclave
coordination is ever needed.
For a hot enclave (a single busy chat at 25–50K msgs/sec), cf-worker can split one logical enclave across N physical Durable Objects via jump-hash routing, with every routing decision anchored by Lean theorems.
Both runtimes load the same kernel and produce byte-identical events — the choice is operational, not protocol-level.
Performance
All four runtimes are fast, and the formally-verified ones are not a tax:
| Runtime | GET / ops/sec (N=1000, single laptop, loopback) |
|---|---|
| lean-host (Lean) | 6,083 |
| encd (Rust) | 5,497 |
| cf-worker (workerd, local) | 660 |
Choosing the Lean runtime — the program that was literally proven correct — costs nothing at
the framing layer; at N=1000 it actually crosses ahead of native Rust. (Local wrangler dev
understates cf-worker badly; the production Cloudflare deploy measures roughly 1,755 req/s.)