Repository navigation
v0.7.0
Added
-
A control room.
cupel upandcupel network upnow print a second
address alongside the RPC: a page showing the head, the last twelve blocks as
they arrive, what the gateway's upstreams are doing, and — in network mode —
what each of the three consensus clients calls the head, the justified epoch
and the finalised one.The agreement panel reports four outcomes rather than two, because a client
that is unreachable, one on a chain that has not finalised yet, one that is
merely behind by some epochs, and one that genuinely disagrees at the same
epoch are different situations and only the last is alarming.It also produces blocks. The button runs the same four Engine API calls the
producer runs once a second, with a copy kept of every request and response,
and shows them in order with what each is for — keyed by method and
occurrence, sinceforkchoiceUpdatedappears twice and the two calls do
different jobs.Everything else on the page is read straight from the clients by the browser.
Producing a block is the exception: it needs an authenticated call to a port
bound to localhost, so there is one endpoint,POST /api/produce, holding the
head under a lock for the whole sequence — each call names the parent, so two
producers sharing a head would have the second build on a block the first had
already replaced.The page is compiled into the binary with
rust-embed, so a release carries
its own front end and running Cupel never needs a JavaScript toolchain. -
The control room, second pass. A strip across the top with the vitals —
mode, chain id, the head as it ticks, the clock in network mode, the gateway,
the binary's version, and how old the oldest answer on the page is — and
pages down the side: Overview, Blocks, Accounts, Gateway, Consensus in
network mode, the walkthrough in lab mode. Routes live in the hash, because
the bundle refers to its assets relatively so the binary can mount it
anywhere, and a path with a second segment would resolve them under it. A
page the running mode does not have says so; a hash nothing lives at gets a
page too; a page that throws is the only thing that goes.Every block number opens the block — the header as the client sent it, the
transactions in full — and every transaction opens what was sent, what it
cost and what happened, with the genesis contracts' events decoded by name
and everything else shown as the topics and data it is. Bounded on purpose:
fifty blocks, fifty more on request, five hundred at most. The recent
window, not an explorer; the explorer is phase G. The accounts page shows
the four development accounts with live balances and nonces, their keys
behind a click, and the three contracts with names, symbols and supplies
read by hand-rolledeth_call. The addresses are mirrored from the Rust
side and a Rust test reads the TypeScript to check the two agree.Nothing pretends to be current. Every source remembers when it last
succeeded; a panel keeps its numbers across a failed request and, three
intervals later, greys them and says when they were last true. Stopping geth
no longer leaves a page that looks like a chain making blocks, and a node
whose last answer is too old counts as not answering in the verdict — never
as a silent vote for whichever root it last reported.Push instead of poll. The page subscribes to
newHeadsover geth's
WebSocket and to each beacon node's event stream, and fetches the moment a
block is announced — over HTTP, through the gateway in lab mode, so the
socket is a doorbell and not a data path. Polling stretches while a socket
is open and never stops, so a dead-but-open socket cannot freeze the page.
Each panel says "live" or "polling"; a tab brought back from the background
asks everything at once.Tests, for the first time: Vitest over the arithmetic — verdicts, slot
maths, ABI and log decoding, block and transaction parsing, formatting,
routes, freshness, the two wire formats — run in CI beside the lint and type
checks. A theme switch that remembers itself, and a tab title that mirrors
the head.
Changed
GET /api/modecarries the binary's version beside the mode, and the strip
shows it: the page is compiled into the binary, so it is exactly as old as
the process serving it.- Network-mode geth publishes a WebSocket per node, on 8558–8560, for the
control room'snewHeadssubscription; lab mode's has been on 8547 all
along. A test reads the compose file to check the flags and the ports are
there. The three execution containers are recreated on the nextup; their
chains persist.
Fixed
-
No browser could call the gateway. A JSON-RPC request carries
content-type: application/json, which is never a simple request, so a
browser asks permission first — and the route accepted only POST, so the
preflight got405 Method Not Allowedand the real call was never sent. Every
browser client had been unable to reach the gateway since it was written,
whilecastandforgeworked perfectly, because neither of them asks
permission. The README had been recommendingviemagainst it the whole time.Separately, the
Access-Control-Allow-Originheader was set by the JSON-RPC
handler alone, so/healthand/metricsreturned correct bodies that
browsers dropped unread. It is a response layer now — one place, every route,
nothing for a third handler to forget. The test for it needed the router
extracted first: the test module had been building its own copy of the routes,
which is exactly how a layer goes missing from the real one while the suite
stays green. -
Teku sent no CORS headers either.
--rest-api-host-allowlistis a Host
header allowlist and not the same idea; without--rest-api-cors-originsTeku
answerscurlperfectly and a page throws the answer away. Three clients,
three spellings of one concept: Lighthouse has--http-allow-origin, Prysm
--http-cors-domain, Teku this. -
The committed bundle was never committed.
ui/distis compiled into the
binary, and a baredist/in the root ignore file — plus Vite's scaffolded
ui/.gitignore, either alone sufficient — kept it out of the repository while
a comment directly above the rule explained why it was in. Nothing failed
locally, because the working tree had the files; the first machine to find out
would have been CI, on all four jobs at once, with#[derive(RustEmbed)] folder '.../ui/dist' does not exist. There is auijob now that lints, typechecks,
rebuilds the bundle from source and fails if it differs from what is
committed — the same check genesis and the diagrams already get. -
The walkthrough about slots stated the slot time from memory. Walkthrough
3 reads the head slot, the epoch and the proposer duties off the running
chain, and then printed "6 seconds, so an epoch is 3 minutes 12" from a string
in the source — wrong on both counts, in the one lesson whose entire subject
is how a chain divides time. It now asks the chain forSECONDS_PER_SLOTand
SLOTS_PER_EPOCHand derives every duration from them, so the lesson cannot
disagree with the thing it is describing. -
The devnet's slot time was never six seconds.
SECONDS_PER_SLOT=6was
passed to the genesis generator from the first day of network mode and the
generator does not template that value, so the key was simply absent from the
config it produced and all three clients used the mainnet preset's twelve. The
chain was correct throughout. Nothing errored. The only symptom was that
finality arrived twice as late as the banner, the design document and CI's
arithmetic all said it would — and CI's finality window was set from the wrong
number, so the job failed with "no finalised epoch" on a chain that was going
to finalise nine minutes later.Writing the key in by hand is not a fix: Lighthouse validates the config
against the preset compiled into it and refuses to start with YAML
configuration incompatible with spec constants for mainnet. Six-second slots
need minimal-preset binaries. So the number is twelve everywhere now,init
checks the generated config against what the code assumes rather than trusting
it, and CI asks the running chain what it is using and fails if it disagrees —
because the failure being guarded against was never a wrong value, it was a
value nobody wrote and nobody missed. -
The banner printed
http://127.0.0.1:8545whatever--bindwas given. That
is true for0.0.0.0, where localhost still reaches the socket, and a lie for
any other address. -
Metrics never worked on plain Linux Docker. Prometheus scraped the clients
throughhost.docker.internal, which gives a container a route to the host
and nothing more: the ports are published on127.0.0.1, so the scrape
arrived on the bridge address where nothing was listening. Every target was
refused and every panel drew an empty box — indistinguishable, on a lab chain,
from a quiet one. Docker Desktop's port proxy made it work anyway, which is
why it went unnoticed for three phases.The clients are now scraped by container name over the network Cupel already
created, which is both correct and one hop shorter;cupel observeattaches
Prometheus to whichever of the two networks exists, because compose treats an
external network it cannot find as an error and which one exists depends on
the mode. The gateway is the one target that genuinely runs on the host, and
cupel up --bind 0.0.0.0now makes it reachable. The signer is deliberately
not covered by that flag. -
The dashboard check waited for the wrong thing.
upappears as soon as
Prometheus has attempted a scrape and is0when the target refused, so
waiting for the series to exist was waiting for the first failure. Every panel
was then empty for a reason that had nothing to do with the panels, and the
output was twenty identical failures naming the wrong culprit. It now waits
for targets that are up, prints which are up and which are down before judging
anything, and sends a request through the gateway first — per-method counters
do not exist until something has been through it, and block production talks
to the node directly. -
The control room asked the wrong questions. Which mode was running was
guessed from which fixed port answered, which misread a devnet started beside
a lab and could not work from another machine; the process serving the page
knows, so there isGET /api/modeand the page asks it first. Undernpm run devthe produce button reported failure after making a real block, because
the page called the control room cross-origin and the control room sends no
CORS headers; Vite proxies/apinow. "Gateway not running" appeared when
every upstream was down, because/healthanswers 503 with a full body and
any non-2xx read as a refusal. The agreement panel could call clients that
agreed a disagreement, because the finalised epoch and root came from two
requests made at different moments; both come from one response now. The
clock read Lighthouse alone, so stopping node1 — the lab's own demonstration
— stopped the clock; a failed spec request produced mainnet's slot numbers
labelled as this chain's; and a chain that never finalised said "first one
about now away" for ever. Missing numbers are undefined, the clock says it
was not told rather than inventing one, and finality that has stalled is
called stalled. A page opened from another machine now says why the client
panels are empty: the clients are published on 127.0.0.1 only. -
Stopping the node the demo names did not stop finality. Sixty-four
validators across three nodes is 22 / 21 / 21, and justification needs more
than two thirds — 43. Losing node1 leaves 42 and finality stops; losing node2
or node3 leaves 43 and it carries on, a tenth of a percent above the line.
Five places told the reader to stopcupel-el2and watch finality stop,
and it did not. The split, the threshold and the node whose absence matters
are computed now, the banner and walkthrough 3 read them, and a test derives
the answer from the split and checks the named container against it. The
uneven split is the better lesson: the threshold counts validators, not
nodes. -
Four checks that could not fail. The slot-time guard read a key the
pinned generator never writes, took its fallback of twelve, compared twelve
with twelve and passed; it reads both spellings now, in milliseconds. The
walkthrough guard test searched a file for a string the test itself
contained; the guard moved to where it cannot be forgotten and the test
checks behaviour instead.tsc --noEmitinui/type-checked zero files,
because the tsconfig is a solution file; CI runstsc -b. The dashboard
checker counted skipped panels as resolved and could report "all resolved"
having asked Prometheus nothing; it fails when nothing was verified. -
Ctrl-C during a block could be dropped, because the shutdown future was
rebuilt inside the loop andselect!drops the losers — on Windows the
process ended and left the container running with nothing driving it. It is
built once and pinned.--bind localhostand--block-time 0panicked
after the container was up; clap refuses both before anything starts.
--bindnow reaches the metrics line and network mode,--detachsays it
skips the control room too,cupel statusexits non-zero when nothing
answers, and the port-taken message suggests Ctrl-C rather than a command
that cannot free the port. -
The walkthroughs were a second producer on a chain that already had one.
Walkthroughs 1 and 2 drove the Engine API themselves besidecupel up's own
producer, two producers naming the same parent — a reorg at best, stalled
production at worst, and four successful-looking calls either way. They ask
the process that holds the lock, through/api/produce, so the walkthrough
narrates the block that actually happened. Walkthrough 4 no longer calls a
node that did not answer "a slot behind", or two clients at different epochs
a disagreement; walkthrough 2 no longer blames a null receipt on a fee that
could never be too low. The network walkthroughs ask every client whether
the devnet is up, not only node1. -
One blob transaction stopped lab production for good.
newPayloadV3
takes the versioned hashes of the payload's blobs and the producer always
sent none, so a payload with a blob transaction was rejected, the transaction
stayed in the pool, and every later attempt failed the same way. The hashes
are computed from the commitments in thegetPayloadV3answer, as EIP-4844
says, and an end-to-end test against a stand-in Engine API fails with the
old empty list. A 401 from the Engine API now says what it most likely means
instead of "error decoding response body", and the producer's default RPC is
the node, not the gateway. -
The gateway cached answers that change. A pending transaction looked up
by hash, a receipt before finality, a block by number — all cacheable only on
a chain that cannot reorganise, which network mode can. What is cached now is
what is immutable without assuming finality: properties of the chain, and
blocks by hash. Filters were created on one node and polled on another, so
every filter-based subscription failed with "filter not found" on a healthy
devnet; node-local methods go to one node now. And a null result — "not
mined yet" — no longer counts as a failed request. -
The signer signed transactions other than the ones it was asked for. The
spending budget counted value alone, so any number of zero-value
transactions at any fee fitted inside a budget of one wei; it counts value
plus the whole gas limit at the maximum fee. Quantities were guessed at — a
decimal gas limit parsed as hex, unparseable values silently defaulted, a
missing nonce became zero — and each signed something other than what was
requested. A quantity is 0x-prefixed hex that fits its field or the request
is refused naming the field; nonce and gas limit are required; a tip above
the fee cap is refused here. Audit entries record the maximum fee and the
worst-case cost the decision was made on.
Phase F: a Chainlink oracle.