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, notLevel. 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 atk:, contract storage ats: - chain layer: applyTx (transfer / deploy / call), buildBlock, genesis with pre-funded accounts
- node layer: AsentumNode with mempool, block timer, state/block stores
asentum chainandasentum balanceCLI 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/:nendpoint 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 ownreceiptsRoot, 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), twoMap<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/voteto peers. one-hop re-gossip via dedup. - broadcastProposal: pushes the freshly built block to peers via
/consensus/proposalBEFORE 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
forloop = 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=truebonds the dev validator set at genesis.validatorIndex=Npicks 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
proposalTimeoutMswall-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, 7in 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/proposalpayload. 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) to16, 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) % nrotation with BLAKE3-hashed cumulative-stake ranges. - naive
(h + r) % totalfailed 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 methodrecordSlashrun through the verifier BEFORE the VM executes. malformed/fake evidence is rejected at apply time and never touches contract state. - auto-submission in
handleDoubleSignEvidencenow encodes fullSignedVoteSchema.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_sendRawTransactionreturns "method not found" by design — the write path is the Snap (phase 4.2), NOTeth_*.- JSON-RPC batch requests work (array body → array response).
GET /still serves the explorer HTML alongside the JSON-RPCPOST /. - 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 fromsnap_getEntropy→ Dilithium3 keypair),buildTransferBody,buildCallBody,buildDeployBody,signTxBody,encodeSignedTx. ~300 LOC of zero-dependency-on-MetaMask logic.src/index.ts: the SnaponRpcRequesthandler. exposesasentum_getAddress,asentum_getPublicKey,asentum_buildTransfer,asentum_signTransfer,asentum_signCall,asentum_signDeploy. every signing method shows asnap_dialogconfirmation 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_getEntropywhich 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-accesspermission — 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 mockedsnapglobal onglobalThis(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.parseLogFilterhandles 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,matchesFilterare pure predicates. No node dependencies. (article 11) - new
rpc/filter-store.ts: in-memoryFilterStorewith TTL-based eviction (default 5 min no-poll → drop), max 1024 concurrent filters, injectable clock for deterministic tests. TrackslastSeenBlockwatermark per filter. eth_getLogsineth-rpc.ts: scans blocks in [fromBlock, toBlock], flattens event data from receipts via the sharedreceiptEventsToLogshelper, filters by address + topics, returns the flat array in block/tx/logIndex order. Hard capMAX_LOG_RANGE = 10_000nmatches Infura/Alchemy, protects against DoS from misconfigured dApps.- refactored
eth-encoding.tsto extractreceiptEventsToLogsas a shared helper —receiptToEthJsonandeth_getLogsnow 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: newnode.getContractCode(address)walks account → codeHash → code bytes from the state store. EOAs and unknown addresses return'0x'. Verified live against the staking system contract at0x0000...01— it returns the actual Dilithium3 JavaScript source bytes. (article 12) eth_getBlockByHash: added aBH:prefix to the block store mappingblake3(header)→ block number. Written on everyputBlockso hash lookups are O(1). Newnode.getBlockByHash(hash)accessor wired through. Round-tripped viaeth_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 likestorage.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() writesgreeting. - 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: newapplyCors()helper called from every response path, top-of-dispatcher OPTIONS handler returning204 No ContentwithAccess-Control-Allow-Origin/-Methods/-Headers/-Max-Age/Varyheaders. configurable viaASENTUM_CORS_ORIGIN(default*). /metadataendpoint returning EIP-3085 shape for MetaMask'swallet_addEthereumChain:chainId,chainName,nativeCurrency,rpcUrls,blockExplorerUrls, plus extras likechainIdDecimal,currentBlock,faucetUrlfor the landing page to display. public URLs configurable via env vars; falls back to request-inferred URLs for local dev.- Health + ready probes:
/healthalways 200,/readyreturns 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.tswith a classic token-bucketRateLimiterclass keyed by client IP (respectsX-Forwarded-Forso it works behind Caddy/nginx). default 1 drip per minute per IP. rejects with HTTP 429 +Retry-Afterheader +retryAfterMsin the body. FIFO eviction atmaxKeys, injectable clock for deterministic tests. bin/run.tswiring: new env varsASENTUM_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,
/metadatashape with + without configured URLs,/health,/readyat genesis and post-block, faucet rate limiting happy path + 429,RateLimiterunit tests with injected clock). total 251.
phase 5.2 — shareable block explorer landing page
- upgraded
explorer/html.tswith 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
/metadataon page load and mirrored into the hero on every poll tick) - "+ Add to MetaMask" button that fetches
/metadata, callswindow.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.mdshipped 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 viaos.homedir(), Windows-safe). tracksrpcUrl+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-reusesdefaultaccount → 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
asentumruns with no args. checks if the user has any accounts — if not, points atquickstart; 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.balanceCmdfallback:asentum balancewith no args uses the current account automatically. error message for "no account specified" points at three alternative commands.- ANSI colors everywhere, respecting
NO_COLORenv var and non-TTY output. no new dependencies. bin.tsfriendly 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,
effectiveRpcUrlprecedence). 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:GenesisSpecJSON-safe type (chainId, gasLimit, baseFeePerGas, initialAccounts, initialValidators, genesisHash, genesisStateRoot), plussaveGenesisSpec/loadGenesisSpec/toGenesisSpec/fromGenesisSpechelpers. 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, callcreateGenesis. (3) fresh boot withjoinPeerRpcUrlset → fetch/genesisfrom peer, parse the spec, callcreateGenesislocally 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 persistedgenesis-spec.json.- new
GET /genesisRPC 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_RPCenv var wired intobin/run.ts. on first boot of a fresh data dir, setsjoinPeerRpcUrland 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.mdshipped: 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 twoAsentumNodes on two OS-assigned ports, the joiner fetches/genesisfrom the primary and reconstructs an identical genesis, joiner persists its own spec file,initialSynccatches up 3 blocks,HttpSyncLooptracks 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.mdverbatim, 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.nvmrcsaid20and myengines.nodesaid>=20.0.0, both lies. Bumped the VPS from Node 20.20.2 to Node 22.22.2. Rebuilt. Fixed. (2)fatal: {}— SESlockdown()makes Errormessage/stacknon-enumerable, so the defaultconsole.error('fatal:', err)prints the empty object. Patchedbin/run.tsto readerr.name/err.message/err.stackexplicitly. (3) Caddy reload failure — first-run raced on/var/log/caddy/asentum.logownership 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 UTCand#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 bothasentum.serviceandcaddy.service. - cleanup landed in the repo (for next deploy, not yet rolled out to the running node):
.nvmrc→22,package.jsonengines.node →>=22.0.0,bin/run.tsfatal handler now printserr.name/message/stackexplicitly instead of relying on default Error inspection,pnpm-lock.yamlregenerated against the current@asentum/metamask-snappackage 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 withcodesign --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 insea-build/.node-cache/), handles macOS universal-binary thinning vialipo -thin, setsuseCodeCache: falsefor cross-builds (V8 code cache is arch-specific), invokespostjectwith the right--macho-segment-name NODE_SEAfor darwin. One bash script, one flag per target. - the universal-binary trap:
postjectrefused to inject the SEA blob into the macOS Node binary because the sentinel stringNODE_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 arm64before 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 viauname, downloads the right binary viacurlorwgetwith a progress bar, stripscom.apple.quarantinexattr on darwin, installs user-scoped at~/.asentum/bin/asentum(no sudo ever), pre-configures the RPC URL to the testnet it was served from viaasentum config set rpc $RPC_URL, patches the user's.zshrc/.bashrcto add the install dir toPATH, prints next-step hints, interactively offers to runasentum quickstart. RespectsNO_COLOR,ASENTUM_INSTALL_DIR,ASENTUM_SKIP_QUICKSTART,ASENTUM_INSTALL_BASEenv 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, callsUnblock-Fileto 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 newhandleblocks: (1) bare/install→ rewrites toinstall.shwithContent-Type: text/x-shellscriptsocurl | shworks, (2)/install/*→ static file server withbrowsefor a directory listing. The existingreverse_proxy 127.0.0.1:8545became the catchallhandle { }at the bottom. Caddysystemctl reloadwas 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 → quickstartflow failed because the freshly installed binary was pointing at the hardcoded defaulthttp://127.0.0.1:8545instead of the testnet it was downloaded from. Fixed by having the installer runasentum 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, fakeHOME. Install completes in ~45 seconds. Then$FAKE_HOME/.asentum/bin/asentum quickstartruns 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_addEthereumChainonly 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). Reuseswallet-core.tsfrom the Phase 4.2 Snap +rpc-client.tsfrom 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.asentumprovider 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 separatescontent_scriptsmatch patterns fromhost_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#0a0a0dextension background. - distribution: sideload zip at
testnet.asentum.com/install/asentum-extension.zip. Chrome Web Store submission pending review (v0.2.1). Provider test page attestnet.asentum.com/install/provider-test.html. - Buffer polyfill:
src/buffer-shim.tsinstalls a minimalBuffer.alloc+writeUIntLEglobal so@chainsafe/persistent-merkle-tree's singleBuffer.alloc(32)call insidemixInLengthdoesn't crash withReferenceError: Buffer is not definedin 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 onstorage.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 / YandTx fee: Z ASEcomputed asgasUsed × 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 viaSec-Fetch-Destlike/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_getCodehex), 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.asentumfrom 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()ortransfer(). 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:
- 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.
- 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_RPCis set. Changed oneifcondition. Validators now automatically catch up when they fall behind. - No peer gossip configuration: validators bound to
127.0.0.1couldn't exchange votes. Fix: bind to0.0.0.0, open port 8545, setASENTUM_PEERSwith all peer IPs.
@asentum/sdkshipped (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/mathlibrary (PI, E, abs, floor, ceil, round, min, max, pow, sqrt, clamp). Voted FOR with 1,000 ASE. Voting period ended. Executed. Deployed a contract usingmath.PIandmath.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_quorumstatus, notpassed. - 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
pendingstatus 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
