Milestone

The dev log that built v1

Entry #09 · 2026-02-20 · Devlog

The dev log that built v1

This is the changelog for AsentumChain v1. Every meaningful step from "blank repo" to "4 validators surviving a kill-one test" in one page. One-liners. Some commentary. Mostly facts. If you've been following along, this is the recap; if you're new, this is the whole story compressed.

I'm calling what exists today "v1" because every box on my Phase 3 roadmap is checked. There's more to do. There's always more to do. But v1 is the version where I'd actually let someone clone the repo and pnpm test on a Pi.


phase 0 — picking a stack

  • decided to build a JS-native L1. yes, JavaScript. yes, it'll be fast enough.
  • typescript, node 20+, pnpm workspaces, vitest, tsup. boring stack on purpose.
  • post-quantum: dilithium3 (ml-dsa-65) via @noble/post-quantum. 1952-byte pubkey, 3309-byte sig. fits in a tx.
  • hash: blake3 via @noble/hashes.
  • serialization: SSZ via @chainsafe/ssz. deterministic, merkleized, schemas as code.
  • storage: leveldb via classic-level. (it's ClassicLevel, not Level. burned 20 minutes on this.)
  • contract VM: SES + hardened javascript. real code, no DSL.

phase 1 — single-node chain

  • @asentum/types: SSZ schemas for Account, TransactionBody, SignedTransaction, BlockHeader, Block
  • @asentum/crypto: dilithium3 wrapper, blake3, address derivation (EIP-55-style checksum but blake3 not keccak)
  • @asentum/state: kv state store. accounts at a:, code at k:, contract storage at s:
  • chain layer: applyTx (transfer / deploy / call), buildBlock, genesis with pre-funded accounts
  • node layer: AsentumNode with mempool, block timer, state/block stores
  • asentum chain and asentum balance CLI work end-to-end
  • 87 tests, all green

phase 1.5 — federation

  • started with libp2p gossipsub. should be fine, said the docs.
  • it was not fine. peers connected and disconnected in a loop. blocks didn't propagate.
  • spent an evening tuning the connection manager, mesh sizes, retry logic. nothing helped.
  • killed libp2p pubsub at 2am. switched to HTTP polling. (article 01)
  • HttpSyncLoop: replicas POLL the producer's /block-raw/:n endpoint on a timer. dead simple, works.
  • POST /tx for replica → producer tx forwarding. mempool bridges the gap.
  • 5–10 node testnet target. plain HTTP is fine for that. phase 3 will revisit.

phase 2 — smart contracts

  • @asentum/vm: contract VM on top of SES Compartment. (article 02)
  • gas metering per storage op + per event byte + per tx kind. gas limit per call.
  • contract source = real javascript. host APIs: storage.get/set, emit, assert, msg, chain
  • reference ERC-20 contract (~80 lines). deploy + transfer + balance check works end-to-end.
  • block receipts: full SSZ schema, blake3 over concat for receiptsRoot
  • block headers commit to receiptsRoot. cross-node determinism: replica re-runs txs, computes its own receiptsRoot, refuses block if mismatch.
  • caught a race in produceBlockSafe — overlapping timer ticks during slow contract deploys. fixed with an in-flight lock.
  • multi-node federation contract test passed: 2 nodes, deploy contract, call contract, byte-identical state on both.
  • browser explorer at http://127.0.0.1:9045/ — phosphor-green terminal aesthetic, renders blocks, txs with kind badges, decoded call args, receipt events. zero new code paths to power it.

phase 3.1 — validator registry

  • staking system contract at 0x0000000000000000000000000000000000000001
  • reserved-address pattern: genesis deploys it, the chain reads from it via runReadOnlyCall
  • methods: bond(pubKey, amount), unbond(amount), getValidator(addr), getValidators()
  • ~250 LOC total. event emission, gas metering, RPC visibility, deterministic across replicas — all FREE because it's a regular contract.
  • realized: governance, treasury, slashing, every "module" — they're all just contracts. small extensible core, everything else is policy. (article 03)

phase 3.2 — round-based voting protocol

  • ConsensusEngine class. owns (height, round, phase), two Map<blockHash, Set<validator>> for prevotes + precommits, validator set snapshot. (article 04)
  • Vote SSZ schemas in @asentum/types: VoteBody, SignedVote
  • vote signing: blake3(SSZ(body)) → dilithium3 sign
  • proposer selection: lex-sort validators, idx = (height + round) % n
  • 2f+1 quorum: floor(2n/3) + 1
  • engine.ingestVote: verify sig, check height/round/chainId, dedupe, bucket
  • producer signs its own prevote+precommit, ingests, federation mode trivially self-certifies (threshold=1)
  • 33 new tests

phase 3.3 — vote gossip + BFT commit

  • ConsensusEngine.waitForQuorum: Promise + listener pair. resolves when threshold crossed, rejects on timeout. (article 04)
  • producer's BFT cycle is now: sign prevote → ingest → AWAIT prevote quorum → sign precommit → ingest → AWAIT precommit quorum → persist
  • five lines of "real" logic for the entire BFT round.
  • per-node validator key persisted as a 32-byte seed at <dataDir>/validator.key. faucet keypair as fallback.
  • VoteBroadcaster: HTTP POST /consensus/vote to peers. one-hop re-gossip via dedup.
  • broadcastProposal: pushes the freshly built block to peers via /consensus/proposal BEFORE the producer enters waitForQuorum (otherwise: deadlock — caught it on the first compile because async deadlocks have a smell)
  • replicas vote on applied blocks (between persist and engine reset). this is what closes the BFT loop in multi-validator mode.
  • in-process 4-engine simulation test: 4 engines + a for loop = perfect gossip simulator. byzantine survival test asserts the silent validator still reaches commit by passively observing peer votes. 384ms. (article 05)
  • 13 new tests

phase 3.4 — slashing for double-signing

  • engine.ingestVote now detects double-signs: same validator + same (height, round, kind) + DIFFERENT blockHash = byzantine. (article 06)
  • new per-validator-per-kind tracking maps. O(1) detection on every ingestion.
  • conflicting vote NOT counted toward quorum. (THE safety property — without it, a double-signer can fork the chain. there's a test for the exact 2-validator scenario that would break.)
  • DoubleSignEvidence type: both signed votes ARE the proof.
  • onDoubleSign listener fires synchronously inside ingestVote.
  • slashing system contract at 0x0000000000000000000000000000000000000002. recordSlash, getTombstones, isTombstoned. ~50 lines.
  • producer auto-submits a recordSlash tx when its engine catches a double-sign. signs with the faucet, idempotent at the contract layer.
  • getActiveValidators reads BOTH staking AND slashing, returns staking minus tombstoned. tombstoned validators are gone from the BFT view of the world even though their bond record still exists.
  • recordSlash currently caller-trusted. honest caveat — full crypto verification of evidence is a follow-on. either chain-layer pre-verification or adding dilithium3 verify as a host VM API.
  • 11 new tests

phase 3.5 — 4-validator local devnet + kill-one

  • proposer-rotation skip: produceBlockInner returns null when not the scheduled proposer. (was throwing, broke as soon as a second validator existed.) (articles 07, 08)
  • deterministic dev validator seeds: 0x76 ('v') + 0x30+i ('0..3') + 0xde + spread. every node derives the same 4 pubkeys for genesis. same model as the dev faucet.
  • node options: devnet=true bonds the dev validator set at genesis. validatorIndex=N picks which dev validator key this node uses for its own consensus signing.
  • bootDevnet() test helper: 4 AsentumNode instances, 4 tempdirs, 4 RpcServers on OS-assigned ports, 4 VoteBroadcasters wired peer-to-peer.
  • six ticks → all 4 nodes at height 6. lockstep BFT across real HTTP. passed on the first compile.
  • kill-one survival test: stop one node mid-run, the surviving 3 still reach 2f+1 quorum and commit at least one more block. 5 > 4. ✓
  • 4 new tests, total 77.

phase 3.6 — round skip via proposal timeout

  • each non-proposer arms a proposalTimeoutMs wall-clock timer when it enters a new height. on fire: locally advance the round, re-evaluate proposer, kick produceBlockSafe if we're now the new proposer.
  • naive clock-based advancement DESYNCED the surviving nodes — saw heights 5, 6, 7 in the kill-one test. each node advanced independently, their rounds drifted, replica votes went out at the wrong round and the proposer rejected them.
  • fix: include the proposer's BFT round in the /consensus/proposal payload. receivers bump their engine round to match BEFORE casting their replica vote. round state is now consensus-driven via the proposal itself, not local-clock.
  • kill-one result went from 5, 5, 5 (was 4) (advance 1, then stall) to 16, 16, 16 (was 4)12 blocks of progress in lockstep over a 3-second test window, through repeated dead-proposer round skips.
  • 1 new test, total 78.

phase 3.7 — stake-weighted proposer rotation

  • replaced uniform (height + round) % n rotation with BLAKE3-hashed cumulative-stake ranges.
  • naive (h + r) % total failed on small totals because consecutive rounds produced tiny strides — 3 validators × 100 stake = 300 total, rounds r=0..2 at height 5 all landed in the same bucket.
  • fix: hash (height || round) with BLAKE3 → take first 16 bytes as uint128 → mod totalStake. well-distributed across any total.
  • new test: a validator with 2× the stake of its peers wins ~50% of slots over 1000 samples (expected, proportional to stake share).
  • equal-stake case still produces uniform coverage over the long run (back-compat for the dev devnet).
  • 2 new tests, total 80.

phase 3.8 — cryptographic verification of slashing evidence

  • phase 3.4 had caller-trusted recordSlash — the slashing contract accepted any evidence string. phase 3.8 closes the loop.
  • new chain/slashing-evidence.ts: pure verifier that decodes two SSZ-encoded SignedVotes from the tx args, verifies both Dilithium3 signatures, checks (chainId, height, round, kind) match, checks blockHashes differ, checks offender arg matches the votes' validator address.
  • wired into applyCallTransaction: calls targeting SLASHING_CONTRACT_ADDRESS with method recordSlash run through the verifier BEFORE the VM executes. malformed/fake evidence is rejected at apply time and never touches contract state.
  • auto-submission in handleDoubleSignEvidence now encodes full SignedVoteSchema.serialize(vote) hex for both votes as args [offender, voteAHex, voteBHex].
  • slashing contract source updated to accept the 3-arg form, with backward-compat for the legacy 2-arg form (storage doesn't care, the apply layer is the gatekeeper).
  • 10 new tests covering: valid evidence accepted, wrong arg count rejected, garbage hex rejected, different validators rejected, offender mismatch rejected, height/round/chainId/kind mismatch rejected, same-hash (fake conflict) rejected, plus end-to-end integration where an AsentumNode + real VM + real state store runs the full flow. total 92.

phase 4.1 — ethereum JSON-RPC read layer

  • new rpc/eth-rpc.ts + rpc/eth-encoding.ts + POST / route in the RpcServer. (article 10)
  • 16 eth_* methods: eth_chainId, eth_blockNumber, eth_getBalance, eth_getTransactionCount, eth_getBlockByNumber, eth_getTransactionByHash, eth_getTransactionReceipt, eth_getCode, eth_call, eth_gasPrice, eth_feeHistory, eth_estimateGas, eth_syncing, eth_accounts, net_version, web3_clientVersion.
  • pure translation layer. no crypto. no dual-signature acceptance. the chain stays 100% Dilithium3; the RPC just reformats native state on the way out for Ethereum tools.
  • unused Ethereum fields (sha3Uncles, logsBloom, difficulty, totalDifficulty, mixHash, nonce) return stable sentinels that Ethereum clients recognize. real fields (stateRoot, receiptsRoot, miner, baseFeePerGas, timestamp, gasLimit, gasUsed) are genuine.
  • eth_sendRawTransaction returns "method not found" by design — the write path is the Snap (phase 4.2), NOT eth_*.
  • JSON-RPC batch requests work (array body → array response). GET / still serves the explorer HTML alongside the JSON-RPC POST /.
  • 33 new tests: 15 unit tests on the encoding helpers + 18 HTTP tests that boot a real RpcServer and hit it with real fetch() calls. live smoke-tested against a running node binary via curl.
  • total 125.

phase 4.2 — metamask snap (the write path)

  • new package @asentum/metamask-snap. (article 10)
  • the hard problem: MetaMask is secp256k1-only and you can't configure it to sign with Dilithium3 without forking the whole extension. the answer is MetaMask Snaps — MetaMask's plugin system — which lets a published Snap manage its own keys in a sandbox inside MetaMask.
  • src/wallet-core.ts: pure testable primitives. deriveKeypairFromEntropy (32 bytes from snap_getEntropy → Dilithium3 keypair), buildTransferBody, buildCallBody, buildDeployBody, signTxBody, encodeSignedTx. ~300 LOC of zero-dependency-on-MetaMask logic.
  • src/index.ts: the Snap onRpcRequest handler. exposes asentum_getAddress, asentum_getPublicKey, asentum_buildTransfer, asentum_signTransfer, asentum_signCall, asentum_signDeploy. every signing method shows a snap_dialog confirmation with the full tx details (to, value, chain, nonce, gas, method, args, origin) before touching the key.
  • src/snap-api.ts: minimal local type surface mirroring @metamask/snaps-sdk. lets the package typecheck in our monorepo without installing the real SDK. documented swap path for publishing.
  • entropy source is snap_getEntropy which derives deterministically from the user's MetaMask seed phrase. backing up and restoring MetaMask also backs up and restores the AsentumChain account — no separate seed to remember.
  • Dilithium3 private key never leaves the Snap sandbox. Snap has NO endowment:network-access permission — it signs and returns bytes, the dApp broadcasts.
  • 29 new tests: 19 for wallet-core (deterministic derivation, cross-verify with @asentum/crypto, SSZ round-trip, gas defaults), 10 for the Snap handler with a mocked snap global on globalThis (dialog recording, accept/reject paths, param validation, unknown method).
  • total 154 (node-side) → 190 across all packages when you add the Snap tests.

phase 4.3 — eth_getLogs + stateful filters

  • new rpc/log-filter.ts: pure matching logic. parseLogFilter handles the full Ethereum filter object spec (fromBlock/toBlock with tags + hex, single/array address filter, position-sensitive topic filters with null/string/array forms). matchesAddress, matchesTopics, matchesFilter are pure predicates. No node dependencies. (article 11)
  • new rpc/filter-store.ts: in-memory FilterStore with TTL-based eviction (default 5 min no-poll → drop), max 1024 concurrent filters, injectable clock for deterministic tests. Tracks lastSeenBlock watermark per filter.
  • eth_getLogs in eth-rpc.ts: scans blocks in [fromBlock, toBlock], flattens event data from receipts via the shared receiptEventsToLogs helper, filters by address + topics, returns the flat array in block/tx/logIndex order. Hard cap MAX_LOG_RANGE = 10_000n matches Infura/Alchemy, protects against DoS from misconfigured dApps.
  • refactored eth-encoding.ts to extract receiptEventsToLogs as a shared helper — receiptToEthJson and eth_getLogs now use the same code path, so log shape is byte-identical across both endpoints.
  • stateful filter methods: eth_newFilter, eth_newBlockFilter, eth_getFilterLogs, eth_getFilterChanges (watermark-based incremental poll), eth_uninstallFilter.
  • 36 new tests: 20 unit tests for filter matching (every corner of the topic spec), 7 unit tests for the filter store (eviction, max cap, watermark), 9 HTTP integration tests that deploy a REAL test contract emitting Hello + Ping events, poll via fetch, and verify the filter machinery end-to-end.
  • total 226 across all packages.

phase 4.4 — polish (real getCode, getBlockByHash, getStorageAt)

  • real eth_getCode: new node.getContractCode(address) walks account → codeHash → code bytes from the state store. EOAs and unknown addresses return '0x'. Verified live against the staking system contract at 0x0000...01 — it returns the actual Dilithium3 JavaScript source bytes. (article 12)
  • eth_getBlockByHash: added a BH: prefix to the block store mapping blake3(header) → block number. Written on every putBlock so hash lookups are O(1). New node.getBlockByHash(hash) accessor wired through. Round-tripped via eth_getBlockByNumber → take hash → eth_getBlockByHash → same block bytes back.
  • eth_getBlockTransactionCountByHash: same hash index, returns tx count.
  • eth_getStorageAt: reads via the state store's native string-key storage. Documented as NOT Solidity-ABI-slot-compatible — our contracts use arbitrary string keys like storage.set('greeting', 'hello world'), not 32-byte keccak-derived slots. dApps that know a contract's native key layout get real values back.
  • 9 new tests: real contract bytecode returned after a deploy, EOAs return '0x', round-trip genesis via hash, block-by-hash for a contract-deploy block with full tx detail, tx count via hash, storage value read after init() writes greeting.
  • Phase 4 is closed. Total 235 tests across all packages.

phase 5.1 — testnet readiness (CORS, metadata, rate limiting, health)

  • pivot: phase 5 was originally planned as light clients, but the actual blocker for "share this with friends" is all the unglamorous infrastructure to expose an RPC to the internet safely. light clients moved to phase 7. (article 13)
  • CORS + OPTIONS preflight in rpc/server.ts: new applyCors() helper called from every response path, top-of-dispatcher OPTIONS handler returning 204 No Content with Access-Control-Allow-Origin/-Methods/-Headers/-Max-Age/Vary headers. configurable via ASENTUM_CORS_ORIGIN (default *).
  • /metadata endpoint returning EIP-3085 shape for MetaMask's wallet_addEthereumChain: chainId, chainName, nativeCurrency, rpcUrls, blockExplorerUrls, plus extras like chainIdDecimal, currentBlock, faucetUrl for the landing page to display. public URLs configurable via env vars; falls back to request-inferred URLs for local dev.
  • Health + ready probes: /health always 200, /ready returns 200 if the chain has advanced in the last 30s (or is still at height 0), 503 if stalled. used by systemd, load balancers, uptime monitors.
  • Faucet rate limiting: new rpc/rate-limit.ts with a classic token-bucket RateLimiter class keyed by client IP (respects X-Forwarded-For so it works behind Caddy/nginx). default 1 drip per minute per IP. rejects with HTTP 429 + Retry-After header + retryAfterMs in the body. FIFO eviction at maxKeys, injectable clock for deterministic tests.
  • bin/run.ts wiring: new env vars ASENTUM_PUBLIC_TESTNET, ASENTUM_PUBLIC_RPC_URL, ASENTUM_PUBLIC_EXPLORER_URL, ASENTUM_CHAIN_NAME, ASENTUM_FAUCET_RATE_LIMIT, ASENTUM_FAUCET_CAPACITY, ASENTUM_FAUCET_REFILL_PER_SEC. startup banner shows the active public-testnet config.
  • 16 new tests (CORS happy path, OPTIONS preflight, custom origin, /metadata shape with + without configured URLs, /health, /ready at genesis and post-block, faucet rate limiting happy path + 429, RateLimiter unit tests with injected clock). total 251.

phase 5.2 — shareable block explorer landing page

  • upgraded explorer/html.ts with a hero banner at the top of the page — phosphor-green aesthetic preserved, hero banner is 200 new lines of HTML/CSS/JS added above the existing block feed. (article 13)
  • network info panel showing chain id, current block, native token, and RPC URL (fetched from /metadata on page load and mirrored into the hero on every poll tick)
  • "+ Add to MetaMask" button that fetches /metadata, calls window.ethereum.request({method: 'wallet_addEthereumChain', ...}), handles "MetaMask not installed" and user-rejection cases with friendly toast messages
  • "Copy RPC URL" button via navigator.clipboard
  • Faucet form that takes a 20-byte address, validates it client-side (/^0x[0-9a-fA-F]{40}$/), POSTs to /dev/faucet, handles the 429 rate-limited case with a "try again in Xs" countdown, logs the tx hash on success
  • "TESTNET" badge in warning-orange so visitors know it's not mainnet
  • existing block feed + chain state + CLI quick-ref panels untouched — zero regressions
  • DEPLOY.md shipped alongside: 9 sections from cold Ubuntu VPS to public URL, including a full systemd unit, a Caddyfile with automatic Let's Encrypt TLS, DNS instructions, and a paste-ready "hey friend" message
  • 12 new tests: 9 static HTML assertions (hero banner present, MetaMask button wired to wallet_addEthereumChain, faucet form POSTs to /dev/faucet, 429 handling, regex validation, no regression on existing block panels) + 3 end-to-end HTTP tests. total 263.

phase 5.3 — noob-friendly CLI wallet

  • new packages/cli/src/config.ts: persistent config at ~/.asentum/cli/config.json (cross-platform via os.homedir(), Windows-safe). tracks rpcUrl + currentAccount. handles corrupted files gracefully, effectiveRpcUrl() resolves env var → config file → default. (article 14)
  • asentum quickstart — four-step fully-automated first-run. connects → creates-or-reuses default account → requests 100 ASE from faucet → waits for next block → shows balance. handles each failure mode (unreachable RPC, rate-limited faucet, already-exists account) with a specific friendly message. ends with green ✓ + four suggested next commands.
  • smart welcome banner when asentum runs with no args. checks if the user has any accounts — if not, points at quickstart; if yes, shows the current account name + address + the three commands they'll actually want next.
  • asentum config / asentum config set rpc <url> / asentum config reset / asentum use <name> — persistent config mutation commands. no more env vars for the common case.
  • balanceCmd fallback: asentum balance with no args uses the current account automatically. error message for "no account specified" points at three alternative commands.
  • ANSI colors everywhere, respecting NO_COLOR env var and non-TTY output. no new dependencies.
  • bin.ts friendly error handler: inspects the error and prints actionable next commands for the six most common noob failures (connection refused, unknown account, missing account default, unknown command, quickstart chain unreachable, fallback). stack traces are gone.
  • INSTALL.md: 200-line install guide with separate Windows PowerShell + macOS Terminal sections, step-by-step from "no Node.js" to "funded account", expected output paste-verbatim, troubleshooting section for the six most common noob issues.
  • 10 new CLI tests (config round-trip, corrupted file fallback, missing fields, env var precedence, empty env var edge case, effectiveRpcUrl precedence). total 273.

phase 5.4 — lightweight validator join

  • the Phase 0 unlock: "anyone can validate" was a claim I couldn't actually back until now. the missing piece was a way for a new node to JOIN an existing chain from just a URL, without having to independently guess the primary's genesis parameters. (articles 15, 16)
  • new chain/genesis-spec.ts: GenesisSpec JSON-safe type (chainId, gasLimit, baseFeePerGas, initialAccounts, initialValidators, genesisHash, genesisStateRoot), plus saveGenesisSpec / loadGenesisSpec / toGenesisSpec / fromGenesisSpec helpers. bigints as decimal strings, byte arrays as 0x hex, fully portable as an HTTP response body.
  • AsentumNode.init() refactor: three genesis modes converging on one code path. (1) existing chain → load spec from disk + resume. (2) fresh local boot → derive spec from env/dev constants, save to disk, call createGenesis. (3) fresh boot with joinPeerRpcUrl set → fetch /genesis from peer, parse the spec, call createGenesis locally with those params, verify the resulting block hash matches the peer's reported hash (if not, throw before committing anything). all three paths end with the same persisted genesis-spec.json.
  • new GET /genesis RPC endpoint returning the persisted spec. passes through the Phase 5.1 CORS layer so browsers and joining validators alike can hit it.
  • new ASENTUM_JOIN_PEER_RPC env var wired into bin/run.ts. on first boot of a fresh data dir, sets joinPeerRpcUrl and bootstraps from the peer. ignored on subsequent boots.
  • hash verification is the load-bearing safety property: a joining node doesn't trust the primary beyond "these are the params you used." if the primary lied, or if the joiner's reconstruction code has drifted from the primary's, the local hash won't match the reported hash and init throws. no corrupted chain state lingering.
  • VALIDATOR.md shipped: one-page guide for running your own validator on a VPS or home hardware. data dir, env vars, validator key generation, first boot, sync via replica mode, bond via the existing CLI command, confirm you're in the active set, operational notes on key backup + double-signing risk + offline handling + genesis hash mismatch. documents the remaining gaps (no single-binary distributable yet, no auto-bond env var, no validator dashboard, no bootstrap node list).
  • 5 new integration tests in test/consensus/validator-join.test.ts: real in-process test booting two AsentumNodes on two OS-assigned ports, the joiner fetches /genesis from the primary and reconstructs an identical genesis, joiner persists its own spec file, initialSync catches up 3 blocks, HttpSyncLoop tracks the primary in real time at 100ms poll intervals.
  • total 278 tests across all five packages.

phase 6 — the testnet is actually public

  • https://testnet.asentum.com is live on a Hetzner CX22 (€4.15/month, 2 vCPU, 3.7 GB RAM, Ubuntu 24.04 LTS). systemd + Caddy + Let's Encrypt + ufw (22/80/443). End-to-end HTTPS JSON-RPC, explorer landing page, rate-limited faucet, the works. (article 17)
  • cold-Ubuntu to public URL in 90 minutes, following DEPLOY.md verbatim, including three fuckups-and-recoveries. (1) Promise.withResolvers is not a function — a libp2p transitive dep (mortice, it-queue, @libp2p/utils, @libp2p/ping) silently requires Node 22. My .nvmrc said 20 and my engines.node said >=20.0.0, both lies. Bumped the VPS from Node 20.20.2 to Node 22.22.2. Rebuilt. Fixed. (2) fatal: {} — SES lockdown() makes Error message/stack non-enumerable, so the default console.error('fatal:', err) prints the empty object. Patched bin/run.ts to read err.name / err.message / err.stack explicitly. (3) Caddy reload failure — first-run raced on /var/log/caddy/asentum.log ownership because an initial attempt as root created the file root-owned before the caddy user could. rm + chown caddy:caddy + full restart instead of reload.
  • the morning-after numbers (8 hours unattended): 14,244 blocks produced between baseline #740 @ 00:09 UTC and #14,984 @ 08:09 UTC. Target block interval 2.000s, measured average 2.000s, drift -3.3 blocks = 0.023% behind a theoretical perfect clock. systemctl is-active asentum: active. NRestarts: 0. Memory: 85 MB (from 57 MB at boot). CPU burned: 14.5 minutes over 8 hours (~3% avg). Chain state on disk: 3.5 MB. journalctl -p err --since "8 hours ago": zero entries for both asentum.service and caddy.service.
  • cleanup landed in the repo (for next deploy, not yet rolled out to the running node): .nvmrc22, package.json engines.node → >=22.0.0, bin/run.ts fatal handler now prints err.name/message/stack explicitly instead of relying on default Error inspection, pnpm-lock.yaml regenerated against the current @asentum/metamask-snap package deps (was drifted since the Phase 4.2 add).
  • 278/278 tests still green after the cleanup. No test changes needed.

phase 7 — the single-binary wallet ("the everybody chain")

  • goal: let a non-dev on a brand new Mac or Windows laptop install the AsentumChain CLI wallet with one command, zero prerequisites, no Node install, no npm, no git, no sudo, no admin. (articles 18, 19)
  • approach: Node.js Single Executable Applications (SEA) — bundle the CLI into one CJS file with esbuild, generate a SEA blob, inject it into a copy of the Node binary via postject, re-sign on macOS with codesign --sign -. Result: one 80-110 MB self-contained executable per platform containing the Node runtime + the CLI + all transitive deps, ready to run with no environment.
  • new packages/cli/scripts/build-sea.sh: full build pipeline. --target <platform-arch> flag for cross-compilation. Downloads target-specific Node distributions from nodejs.org on demand (cached in sea-build/.node-cache/), handles macOS universal-binary thinning via lipo -thin, sets useCodeCache: false for cross-builds (V8 code cache is arch-specific), invokes postject with the right --macho-segment-name NODE_SEA for darwin. One bash script, one flag per target.
  • the universal-binary trap: postject refused to inject the SEA blob into the macOS Node binary because the sentinel string NODE_SEA_FUSE_fce680... appeared twice — the nodejs.org macOS installer ships a universal (fat) binary containing both x86_64 and arm64 slices, and every string in the file is duplicated across slices. lipo -thin arm64 before injection fixes it. 237 MB → 116 MB → 104 MB final.
  • cross-compiled three targets from one Apple Silicon Mac: asentum-darwin-arm64 (104 MB, native), asentum-darwin-x64 (109 MB, cross-built, verified via Rosetta 2), asentum-win-x64.exe (80 MB, cross-built PE32+, postject warns about broken Authenticode but the binary still runs — SmartScreen "More info → Run anyway" on first launch). Zero Docker, zero VMs, zero CI required for the first cut.
  • new packages/cli/scripts/install.sh: macOS/Linux installer. Detects platform + arch via uname, downloads the right binary via curl or wget with a progress bar, strips com.apple.quarantine xattr on darwin, installs user-scoped at ~/.asentum/bin/asentum (no sudo ever), pre-configures the RPC URL to the testnet it was served from via asentum config set rpc $RPC_URL, patches the user's .zshrc / .bashrc to add the install dir to PATH, prints next-step hints, interactively offers to run asentum quickstart. Respects NO_COLOR, ASENTUM_INSTALL_DIR, ASENTUM_SKIP_QUICKSTART, ASENTUM_INSTALL_BASE env vars.
  • new packages/cli/scripts/install.ps1: Windows PowerShell installer. Same four-phase flow (download → prepare → install → PATH), installs to %LOCALAPPDATA%\Asentum\bin\asentum.exe, updates user PATH via [Environment]::SetEnvironmentVariable, calls Unblock-File to strip the zone identifier, pre-configures the RPC URL, prints a clear heads-up about SmartScreen's "Windows protected your PC" dialog on first run.
  • VPS hosting: new /opt/asentum/install/ directory with all three binaries and both install scripts, served by Caddy via two new handle blocks: (1) bare /install → rewrites to install.sh with Content-Type: text/x-shellscript so curl | sh works, (2) /install/* → static file server with browse for a directory listing. The existing reverse_proxy 127.0.0.1:8545 became the catchall handle { } at the bottom. Caddy systemctl reload was graceful — the running asentum-testnet node never flinched, the chain stayed on its 2.000s metronome through the entire deployment.
  • the RPC pre-config bug: first end-to-end test of the full curl | sh → quickstart flow failed because the freshly installed binary was pointing at the hardcoded default http://127.0.0.1:8545 instead of the testnet it was downloaded from. Fixed by having the installer run asentum config set rpc <url> after installing but before handing off to the user. One-line fix, only catchable by end-to-end testing, would have been invisible to every unit test and every component test.
  • the end-to-end proof: env -i HOME=/tmp/fake PATH=/usr/bin:/bin TERM=xterm bash -c 'curl -fsSL https://testnet.asentum.com/install | sh' — completely stripped environment, no Node on PATH, fake HOME. Install completes in ~45 seconds. Then $FAKE_HOME/.asentum/bin/asentum quickstart runs against the live testnet, generates a Dilithium3 keypair, hits the faucet, waits for the next block, prints a funded balance. Zero-prereq → funded wallet in 60 seconds total. Cold start of the SEA binary: 0.80 seconds.
  • public URLs:
  • macOS/Linux: curl -fsSL https://testnet.asentum.com/install | sh
  • Windows PowerShell: iwr -useb https://testnet.asentum.com/install/install.ps1 | iex
  • direct binaries: https://testnet.asentum.com/install/asentum-{darwin-arm64,darwin-x64,win-x64.exe}
  • directory browser: https://testnet.asentum.com/install/
  • what Phase 7 still needs: (a) verify the Windows binary on an actual Windows machine (can't test locally), (b) verify darwin-x64 on an actual Intel Mac (we tested via Rosetta 2 which is close enough but not identical), (c) CI pipeline (GitHub Actions matrix) to auto-build all three targets on every tag instead of manual local cross-builds, (d) linux-x64 binary for Linux testers, (e) Authenticode EV cert to kill the Windows SmartScreen warning (~$200-400/yr, defer until we have users who care).

the numbers

$ vitest run

 Test Files  23 passed (23)
      Tests  278 passed (278)
   Duration  ~18s

278 tests across five packages. 15 in types, 21 in crypto, 203 in node, 29 in the MetaMask Snap, 10 in the CLI. Sequential run finishes in ~18s, most of which is the 4-validator BFT kill-one test's wall-clock waits.

LOC, hand-counted:

| package | lines | | ---------------------- | -----: | | @asentum/types | ~300 | | @asentum/crypto | ~250 | | @asentum/state | ~420 | | @asentum/vm | ~400 | | @asentum/node | ~6,800 | | @asentum/cli | ~1,100 | | @asentum/metamask-snap | ~650 | | total | ~9,900 |

Plus the test suite (~3,500 lines), DEPLOY.md (operators), INSTALL.md (users), and VALIDATOR.md (people running their own validators). Roughly ~13,500 lines of TypeScript + docs for an entire post-quantum L1 with smart contracts, BFT consensus, slashing with cryptographically-verified evidence, vote gossip with round skip, rotating stake-weighted proposers, HTTP RPC, a CLI with noob-friendly quickstart, a browser explorer with MetaMask integration, a full Ethereum JSON-RPC read layer, a MetaMask Snap, CORS-ready public-testnet mode, rate-limited faucet, systemd + Caddy deployment recipe, AND a documented validator-join flow with genesis hash verification.

phase 9 — the native wallet extension

  • MetaMask couldn't do what we needed: wallet_addEthereumChain only adds AsentumChain as a network with a secp256k1 address. The Phase 4.2 Snap exists but it's a request-handling Snap (not Keyring), so its Dilithium3 addresses never appear in MetaMask's accounts list. The UX was a dead end. Decision: build a native Chrome extension. (article 21)
  • new package @asentum/extension: Manifest V3, zero-framework, 8 source files, 5 bundled scripts (popup 430 KB, background 395 KB, confirm 8 KB, content-script 1.4 KB, provider 2.8 KB). Reuses wallet-core.ts from the Phase 4.2 Snap + rpc-client.ts from the CLI — both 100% browser-compatible, zero MetaMask coupling.
  • password-encrypted keys: scrypt (N=131072, r=8, p=1) + AES-GCM-256. Private key never on disk as plaintext. Decrypted keypair in chrome.storage.session (wiped on browser restart or explicit lock). Wrong-password detection via AES-GCM auth tag.
  • multiple accounts: create, rename, switch, delete. Adding a new account re-prompts for password (verified against active account before keygen).
  • transaction history: scans last 200 blocks for txs involving the active address, renders IN/OUT cards with click-through to explorer tx detail.
  • window.asentum provider injected into every page via MAIN-world content script. API: connect(), disconnect(), getAddress(), isConnected(), isUnlocked(), sendTransfer({to, amount}), deployContract({source}), callContract({to, method, args}), viewContract({to, method, args}).
  • per-origin permissions: first-time origins get a connect-confirmation popup. Granted origins persist in chrome.storage.local, revocable from Settings → Connected Sites.
  • per-transaction approval: every sign request opens a separate confirm window showing origin, action type (Send ASE / Deploy contract / Call method()), amount/method, estimated gas fee. No silent signing.
  • <all_urls> host permission required for content-script injection — Chrome 145 separates content_scripts match patterns from host_permissions, and without broad host permissions the extension silently doesn't inject the provider. Same pattern as MetaMask/Phantom/Coinbase/etc. Triggers in-depth Chrome Web Store review (~5-7 days).
  • icons: brand glyph cropped from the repo logo.png (leftmost 49×49 square), box-filter downscaled to 16/48/128 PNG, composited onto the #0a0a0d extension background.
  • distribution: sideload zip at testnet.asentum.com/install/asentum-extension.zip. Chrome Web Store submission pending review (v0.2.1). Provider test page at testnet.asentum.com/install/provider-test.html.
  • Buffer polyfill: src/buffer-shim.ts installs a minimal Buffer.alloc + writeUIntLE global so @chainsafe/persistent-merkle-tree's single Buffer.alloc(32) call inside mixInLength doesn't crash with ReferenceError: Buffer is not defined in the browser context. 15 lines, zero risk.

phase 10 — contract playground + explorer upgrades

  • testnet.asentum.com/playground: a browser-based JavaScript smart contract IDE. (article 22) Dark textarea editor, three template buttons (Token, Guestbook, Tip Jar) loaded from <script type="text/plain"> blocks (avoids template-literal escaping corruption). Connect wallet → Deploy → post-deploy function introspection (view vs write classification based on storage.set / emit( presence in the function body) → auto-generated parameter inputs + call/send buttons per function.
  • explorer: fees everywhere: all tx cards in block detail + tx detail now show Gas used: X / Y and Tx fee: Z ASE computed as gasUsed × baseFeePerGas. (article 23)
  • explorer: addresses are clickable: every From/To field links to /address/<addr>. Search bar accepts block numbers, tx hashes (64 hex), AND addresses (40 hex).
  • explorer: /address/<addr> pages: balance, nonce, type indicator (Contract vs EOA). Content-negotiated via Sec-Fetch-Dest like /block/<n> and /tx/<hash>.
  • explorer: contract pages with source + Read/Write: if an address has code deployed, the page shows the full JavaScript source (decoded from eth_getCode hex), plus auto-generated Read contract (view functions, free, instant) and Write contract (state-changing functions, requires wallet connect + per-tx approval via the extension) sections.
  • explorer: deploy tx → contract link: deploy transactions show a gold-colored "Contract: 0x..." label instead of "To", linking directly to the contract's address page with source + Read/Write.
  • explorer: wallet connect: Write contract sections have a Connect/Disconnect wallet button that talks to window.asentum from the Asentum extension.
  • branding: all remaining "AsentumChain" → "Asentum" in the explorer (hero, footer, toast messages, aria labels).
  • confirm window polish: context-aware approval UI — transfers show amount, deploys show "Deploy contract (N bytes of JavaScript source)", calls show the method name like init() or transfer(). All three show estimated gas fee.

the numbers

| package | lines | | ---------------------- | -----: | | @asentum/types | ~300 | | @asentum/crypto | ~250 | | @asentum/state | ~420 | | @asentum/vm | ~400 | | @asentum/node | ~9,200 | | @asentum/cli | ~1,100 | | @asentum/metamask-snap | ~650 | | @asentum/extension | ~2,400 | | total | ~14,700 |

Plus the test suite (~3,500 lines), the SDK, deployment guides (DEPLOY.md, INSTALL.md, VALIDATOR.md), and 24 dev-log articles in content/.

phase 11 — four validators, three continents

  • 4-validator testnet LIVE across Germany (primary + Nuremberg) and USA (Ashburn VA + Hillsboro OR). Each box runs the same binary, joins via ASENTUM_JOIN_PEER_RPC, generates its own Dilithium3 validator key, bonds into the staking contract, and participates in BFT consensus. (article 24)
  • 3 bugs found and fixed during deployment:
  1. Retry-induced self-tombstoning: when quorum timed out, the proposer rebuilt the block with a new timestamp → new hash → conflicting prevote → self-slashed. Fix: cache the proposal block per height — retries reuse the same block (same hash = same prevote = no conflict). 6 lines, all 81 consensus tests still pass.
  2. Producer mode had no sync loop: validators that restarted were stuck at their pre-restart height forever. Fix: run the HttpSyncLoop alongside production when ASENTUM_PEER_RPC is set. Changed one if condition. Validators now automatically catch up when they fall behind.
  3. No peer gossip configuration: validators bound to 127.0.0.1 couldn't exchange votes. Fix: bind to 0.0.0.0, open port 8545, set ASENTUM_PEERS with all peer IPs.
  • @asentum/sdk shipped (built during validator sync wait time): AsentumClient (chain reads), AsentumWallet (signing + sending), AsentumContract (deploy + view + send). 3 source files, 14 KB built. Full deploy-and-interact loop in 8 lines.
  • KILL-ONE SURVIVAL TEST PASSED: killed the primary genesis validator. Remaining 3 validators (EU + US East + US West) kept producing blocks across the Atlantic. Primary restarted, synced to the head via the new sync loop, rejoined consensus. Zero tombstones. Zero manual intervention. Two-second blocks throughout.
BASELINE:    primary 960, EU 961, US-E 962, US-W 964
PRIMARY KILLED
20 SEC LATER: EU 966, US-E 966, US-W 966  ← chain survived
PRIMARY BACK: primary 965, EU 966, US-E 966, US-W 966  ← synced back
TOMBSTONES:  0

phase 12 — governance + governance-controlled VM libraries

  • governance contract deployed at 0x0000...0003 — on-chain proposal/vote/execute system with ASE-weighted voting (vote weight = msg.value locked for the voting period). Supports proposals, amendments (nested sub-proposals), and four categories: aip, parameter, library, general. (article 25)
  • governance-controlled VM libraries — the killer feature. Library proposals include the full JavaScript source. When a library proposal passes governance vote + execution delay, the source is stored on-chain at lib:<name> in the governance contract's storage. Every time a contract runs, the VM reads the approved-library list, evaluates each library in a sandboxed sub-Compartment (no host APIs — pure functions only), freezes the result, and injects it as a global in the contract's execution environment. The programming language evolves by governance vote. No restart, no config, no human intervention. Deterministic across all nodes.
  • live proof: proposed @std/math library (PI, E, abs, floor, ceil, round, min, max, pow, sqrt, clamp). Voted FOR with 1,000 ASE. Voting period ended. Executed. Deployed a contract using math.PI and math.sqrt(144) — globals that didn't exist 60 seconds earlier. getPI() → 3.141592653589793 ✓, getArea(5) → 78.53981633974483 ✓, getSqrt(144) → 12 ✓.
  • chain-level governance rules enforced by assert() — no human override, no admin key:
  • minimum voting period: 2,160 blocks (~72 minutes). No flash governance.
  • proposal bond: 100 ASE sent with the proposal. Anti-spam.
  • minimum quorum: 1,000 ASE total vote weight. Proposals that don't reach quorum get no_quorum status, not passed.
  • execution delay: 100 blocks after voting ends before effects take hold. Community reaction time.
  • init replay protection: governance contract can only be initialized once.
  • getApprovedLibraries() endpoint lets the VM enumerate all approved libraries at any block height.
  • graceful quorum transition for validators — newly bonded validators enter pending status for 10 blocks before being included in the active set. Prevents the self-tombstoning bug we hit when the quorum threshold changed mid-block-production.

what's next

  • governance.asentum.com — dedicated governance UI. Browse proposals, vote with connected wallet, see results. Also embedded as a tab in the validator GUI.
  • validator GUI (Electron app) — install → create wallet → fund → sync (circular progress bar) → stake → validate. Governance tab for voting with staked tokens. The "everybody chain" made literal.
  • AIPs + docs.asentum.com — formal standards process. AIP-1: Standard Token Interface. Developer tutorials. Proposed and voted on through governance.
  • contract IDE — evolve the playground into a full browser IDE. Editor, deploy dashboard, contract manager, AIP compliance checker.
  • DeFi / AMM — simplified liquidity pools for AIP-1 tokens with chain-native mechanics.
  • deferred: dynamic peer discovery, Authenticode cert, per-opcode gas metering, bond burning, historical balance queries.

v1 was "77 tests, 4-validator BFT, kill-one survival." v1.5 was "190 tests, MetaMask connects." v1.8 was "278 tests, validator join flow." v1.9 was "testnet is live." v1.10 was "curl | sh." v2.0 was "wallet + playground + explorer." v2.1 was "4 validators, 3 continents, kill-one proven." v3.0 is "the chain governs its own programming language — a math library was voted into existence by token holders and automatically injected into every contract's VM across 4 validator nodes on 3 continents. There is no 'but' in Asentum. There is only code and truth."

The everybody chain.

— milkie

Don't miss the next entry.

Join the launch list and we'll send you a note whenever there's a new devlog entry, a research drop, or a real milestone.