Testnet

The testnet is actually reachable now

Entry #13 · 2026-03-07 · Devlog

The testnet is actually reachable now

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":

  1. CORS. Browsers refuse cross-origin JSON-RPC without Access-Control-Allow-Origin headers. Without this, a dApp at https://foo.app can't fetch from https://rpc.asentum.test. Node says "CORS is easy, just add the headers" and that's true, but you also have to handle the OPTIONS preflight request MetaMask fires before every POST. I added both — a new applyCors() helper that gets called from every response path, and a top-of-dispatcher OPTIONS handler that returns 204 No Content with the headers.
  1. An external bind. The node defaults to 127.0.0.1 which is fine for dev and useless for public. ASENTUM_RPC_HOST=0.0.0.0 was already wired; I just documented it in DEPLOY.md alongside the reverse-proxy recipe.
  1. A /metadata endpoint 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 what wallet_addEthereumChain expects. I added GET /metadata that 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.

  1. 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-For so it works behind nginx/Caddy. When it rejects, it sends a proper 429 Too Many Requests with a Retry-After header, plus a JSON body with retryAfterMs so the landing page can show a countdown.
  1. Health + readiness probes. /health always returns 200 if the process is alive. /ready returns 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, calls window.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 the 429 rate-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

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.