Skip to content

v0.7.0

Choose a tag to compare

@github-actions github-actions released this 16 Sep 10:15
· 24 commits to main since this release

Added

  • A control room. cupel up and cupel network up now 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, since forkchoiceUpdated appears 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-rolled eth_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 newHeads over 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/mode carries 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's newHeads subscription; 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 next up; 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 got 405 Method Not Allowed and the real call was never sent. Every
    browser client had been unable to reach the gateway since it was written,
    while cast and forge worked perfectly, because neither of them asks
    permission. The README had been recommending viem against it the whole time.

    Separately, the Access-Control-Allow-Origin header was set by the JSON-RPC
    handler alone, so /health and /metrics returned 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-allowlist is a Host
    header allowlist and not the same idea; without --rest-api-cors-origins Teku
    answers curl perfectly 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/dist is compiled into the
    binary, and a bare dist/ 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 a ui job 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 for SECONDS_PER_SLOT and
    SLOTS_PER_EPOCH and 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=6 was
    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:8545 whatever --bind was given. That
    is true for 0.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
    through host.docker.internal, which gives a container a route to the host
    and nothing more: the ports are published on 127.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 observe attaches
    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.0 now makes it reachable. The signer is deliberately
    not covered by that flag.

  • The dashboard check waited for the wrong thing. up appears as soon as
    Prometheus has attempted a scrape and is 0 when 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 is GET /api/mode and the page asks it first. Under npm run dev the 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 /api now. "Gateway not running" appeared when
    every upstream was down, because /health answers 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 stop cupel-el2 and 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 --noEmit in ui/ type-checked zero files,
    because the tsconfig is a solution file; CI runs tsc -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 and select! drops the losers — on Windows the
    process ended and left the container running with nothing driving it. It is
    built once and pinned. --bind localhost and --block-time 0 panicked
    after the container was up; clap refuses both before anything starts.
    --bind now reaches the metrics line and network mode, --detach says it
    skips the control room too, cupel status exits 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 beside cupel 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 the getPayloadV3 answer, 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.