A Client With Nothing to Call

Both phones had shipped the node-participation client. The server it talked to had been deleted three commits earlier, and nothing said so.

Tapping Connect with wallet on the node settings screen produced an error. Not a friendly one — the raw body of a 404, printed where a status message goes. The route it called, /api/auth/challenge, had been served by the control plane we retired. The client half was complete, shipped, and running on real phones. The server half no longer existed.

So had every step after it: the coverage market that tells a node what to work on, the shard endpoint it downloads its slice from, and the relays that let a NAT-bound phone be reached at all. A whole feature, half present, failing at the first call with a message nobody could act on.

Recovering the contract

The clients cannot be asked to change

A reimplementation here has an unusual constraint: the callers are already in people's pockets. Field names, error shapes and call order are fixed by what shipped. We read the deleted implementation out of git history rather than guessing, and the reading turned up things guessing would have missed — most of all the ordering: the coverage POST is what creates the relay target the worker then dials. Reverse those and the socket connects, carries bytes, and credits nobody.

Built

Three files, no dependencies

  • Node tokens. A wallet signs a single-use nonce; the bridge verifies ed25519 and returns a bearer token scoped to participation and nothing else. node:crypto verifies a raw 32-byte Solana address as a key once you prepend the DER header for Ed25519 — no library needed.
  • The market. Which expert windows are under-covered, what a volunteering node should take, what it reports having taken, and who is owed for the bytes its relay carried.
  • The relay. A WebSocket spliced to a TCP endpoint, handshake and frame codec written out. Carrier NAT and a 443-only edge mean neither side can dial the other, so both dial here.

The one-byte preamble. A ring peer writes a single ASCII byte before its first frame — P for "I am your predecessor", N for "I am your successor" — which is how a stage that dials both neighbours tells each one which descriptor it is. To the relay it is payload, not protocol: it must not be buffered, inspected or reordered. There is a test that fails if it is.

Two deliberate departures

Where we did not copy the original

The old hub emitted {"detail": ...} from its handlers and {"error": ...} from its middleware. Both mobile clients read only error. Every explained refusal therefore reached the user as a bare "HTTP 403" — the server said why and the phone could not hear it. Every error body is now error.

More consequentially: the old hub gated the challenge on _operator_authorized — an admin wallet, or one holding a minimum KVR balance. Operating a bridge and contributing compute to one are different things, and with the shipped defaults that gate refused every phone that ever asked. Participation is open here unless an operator sets a floor, and that floor is a separate setting from operator eligibility.

Verified

A wallet that did not exist a minute earlier

against gate.kvasir-ai.net, freshly generated keypair

challenge    200
node-token   200   ttl 30 days
volunteer    200   assigned layer 0, experts [0,32), n_embd 4096
coverage     200   wired, session expert-probe, listen_port 52970

n_embd is not decoration in that response. Both clients abort an assignment when it is missing rather than guess a hidden size and produce silent garbage, so a model is only offered as work when its dimensions are recorded. They travel in the load plan, because the loader regenerates the catalog and anything not in the plan does not survive a reload — and they are recorded rather than read from the file on the request path, because a coverage poll must not wait seconds on a 122 GB GGUF's metadata.

What this cost to find. Nothing in the build, the tests, or the type checker knows that a client calls a route that no longer exists. The deletion was correct; the thing that made it expensive is that the two halves of the feature live in different languages, different repositories and different release cadences, and only a person tapping a button connects them.