Phase 5 was supposed to be light clients. It's not anymore. I pivoted.
Here's what changed my mind: the user I care about most right now isn't a "mobile light-client user" — it's a friend I can send a link to and have them poke at the chain in their browser. Light clients are a Pi-and-mobile optimization for later. The thing blocking "pass it around to a couple of people" today is all the infrastructure you need to actually expose an RPC to the internet safely. None of that is glamorous. All of it is load-bearing.
So Phase 5 became testnet readiness, and the light-client work got pushed to Phase 7 or wherever. Here's what shipped.
the critical path
There were exactly five things blocking "open the URL in a browser and it works":
- CORS. Browsers refuse cross-origin JSON-RPC without
Access-Control-Allow-Originheaders. Without this, a dApp athttps://foo.appcan't fetch fromhttps://rpc.asentum.test. Node says "CORS is easy, just add the headers" and that's true, but you also have to handle theOPTIONSpreflight request MetaMask fires before every POST. I added both — a newapplyCors()helper that gets called from every response path, and a top-of-dispatcherOPTIONShandler that returns204 No Contentwith the headers.
- An external bind. The node defaults to
127.0.0.1which is fine for dev and useless for public.ASENTUM_RPC_HOST=0.0.0.0was already wired; I just documented it in DEPLOY.md alongside the reverse-proxy recipe.
- A
/metadataendpoint in EIP-3085 shape. When a user clicks "Add AsentumChain to MetaMask", the website needs to pass MetaMask a specific JSON object:{chainId, chainName, nativeCurrency, rpcUrls, blockExplorerUrls}. That's the EIP-3085 schema, and it's whatwallet_addEthereumChainexpects. I addedGET /metadatathat returns exactly this shape, driven by env vars so the operator can set the public URLs:
``json { "chainId": "0x539", "chainName": "AsentumChain Testnet", "nativeCurrency": { "name": "Asentum", "symbol": "ASE", "decimals": 18 }, "rpcUrls": ["https://rpc.asentum.test"], "blockExplorerUrls": ["https://rpc.asentum.test"] } ``
It also returns chainIdDecimal + currentBlock + faucetUrl as extras for the landing page to display.
- Faucet rate limiting. A public, unauthenticated faucet is a giant "please drain me" sign. I added a classic token-bucket limiter keyed by client IP with configurable capacity + refill rate. Default is 1 drip per minute per IP, matches what most dev faucets use. The limiter correctly handles
X-Forwarded-Forso it works behind nginx/Caddy. When it rejects, it sends a proper429 Too Many Requestswith aRetry-Afterheader, plus a JSON body withretryAfterMsso the landing page can show a countdown.
- Health + readiness probes.
/healthalways returns 200 if the process is alive./readyreturns 200 if the chain has advanced in the last 30 seconds, 503 if it's stalled. Systemd, load balancers, uptime monitors — everyone wants these. Three extra lines each, but skipping them makes your monitoring story bad.
and then the landing page
With the server-side primitives in place, I upgraded the explorer HTML. It was already there (phosphor-green dev aesthetic), but it didn't have anything a visitor could do. I added a hero banner at the top of the page with:
- Network info panel (chain ID, current block, native token, RPC URL)
- A big "+ Add to MetaMask" button that fetches
/metadata, callswindow.ethereum.request({method: 'wallet_addEthereumChain', ...}), and handles both the "MetaMask not installed" case and user rejection with a friendly toast message - A "Copy RPC URL" button via
navigator.clipboard - A faucet form that takes a 20-byte address, POSTs to
/dev/faucet, handles the429rate-limited case with a "try again in Xs" message, and logs the tx hash on success - A "TESTNET" badge in warning-orange so nobody thinks this is mainnet
The existing block feed below is untouched. The hero is a literal banner — 200 lines of HTML/CSS/JS added above the working explorer, not a rewrite.
the live receipt
Live smoke test against a real running binary on 0.0.0.0:8549 with ASENTUM_PUBLIC_TESTNET=1:
--- GET / (landing page) ---
<div class="hero-banner">
<span id="hero-chain-name">AsentumChain</span><span class="testnet-badge">TESTNET</span>
<button type="button" class="btn btn-primary" id="btn-add-metamask">+ Add to MetaMask</button>
--- CORS preflight ---
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Accept, X-Requested-With
--- /dev/faucet first call ---
{"accepted":true,"txHash":"0x047843ae..."}
--- /dev/faucet second call (expected: rate limited) ---
HTTP/1.1 429 Too Many Requests
Retry-After: 60
The rate limiter fired exactly as designed. The CORS headers are on every response. MetaMask-friendly /metadata returns the right shape. The node is bound to 0.0.0.0, reachable from the outside, ready for a Caddy reverse proxy in front of it.
47 new tests across testnet-readiness (16), explorer-landing (12), and the existing Phase 4.4 polish suite. Full node suite is 198 tests green, 273 total across all packages.
DEPLOY.md shipped alongside the code. Nine sections from "cold Ubuntu VPS" to "friends curl your endpoint." Includes a full systemd unit, a Caddyfile with automatic Let's Encrypt TLS, DNS instructions, smoke-test commands, and a paste-ready "hey friend, here's the link" message.
The chain can physically be on the internet now. I just have to actually deploy it.
Next I want the CLI to stop scaring Windows noobs. One command, no env vars, clear progress. That's the next article.
— milkie
