feat(wraith): every tier gets its own coordinator, drawn evenly from every opted-in node - #885
Merged
Merged
Conversation
…every opted-in node At zero session demand — mainnet — `seats_for_demand` returned one seat, so one node carried all four denominations for a whole epoch while every other opted-in node sat idle and earned nothing. And the seat count was one more thing nodes had to agree on, sized from each node's own snapshot of gossiped session counters. Each tier now has its own leader. The chain beacon ranks every roster node per tier (`coordinator_order_for_tier`, which existed with no callers), and tiers are filled in turn by the best-ranked node not already leading one — so with eight nodes, every epoch has four different leaders. Ranks are independent and uniform and the rule never looks at who a node is, so every node is equally likely to lead every tier; 16,000 epochs hold every (node, tier) count within 10% of its share. Random each epoch, deliberately not a fixed rotation: a published schedule lets a malicious operator prepare for the round it will run. Leaders are known one anchor block before the epoch starts. Every opted-in node runs a coordinator. The ones not leading are every tier's failover path — the leader, then the tier's own ranking — walked in the same order by every wallet, so a dead leader's cohort moves together. That also retires #883's drain rule: a node never leaves its own roster, so a lead that moves mid-epoch leaves the old leader serving what it holds. The seat scheme is deleted rather than left beside the new one: `elect_coordinators`, `verify_election`, `shard_for`, `shard_key_for_tier_epoch`, `seat_try_order`, `pick_seat_endpoints`, `seats_for_demand`, `COORDINATOR_SEATS`. The published document carries `tiers`, `coordinators` (with `leads`), `my_tiers` and `my_endpoint`; `election_is_honest` recomputes the draw and refuses leaders, failover orders or `leads` that do not follow from the inputs, including the old seat-shaped document. The attestation swaps `my_seat` for `serving` + `my_tiers`; the challenger checks `attested_serving`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #883 (the roster converges, merged). Same release, one roll.
The problem
At zero session demand, which is what mainnet has,
seats_for_demandreturned one seat. So one node carried all four denominations (100k / 1m / 10m / 100m) for a whole epoch (~a day) while every other opted-in node sat idle and earned nothing. Measured on the fleet: 8 nodes opted in,electedhad one entry.The seat count was also one more thing nodes had to agree on. It was sized from each node's own snapshot of gossiped session counters, so two nodes with identical rosters could still send the same tier to different coordinators.
What changes
Each tier gets its own leader, and they're different nodes. Every epoch the chain beacon ranks every roster node separately for each tier (
coordinator_order_for_tier, which already existed and had no callers). Tiers are filled in turn, each by the best-ranked node not already leading one. With 8 nodes, every epoch has four different leaders.Every opted-in node runs a coordinator. Nodes that aren't leading are every tier's failover path. The order is the leader, then the rest of that tier's own ranking. Every wallet walks the same order, so a dead leader's cohort moves as one body to the same next node rather than scattering.
The seat scheme is deleted, not left beside the new one. That covers
elect_coordinators,verify_election,shard_for,shard_key_for_tier_epoch,seat_try_order,pick_seat_endpoints,seats_for_demandandCOORDINATOR_SEATS. The codebase records several times what a dead second scheme invites.#883's drain rule goes too. It kept a node that lost its seat mid-epoch serving until the epoch turned. Now every opted-in node serves, and a node never leaves its own roster, so when a lead moves the old leader is still serving and finishes what it holds. The rule could no longer trigger.
The published document
GET /api/v1/pool/coordinatornow carriestiers(each tier'sleaderandorder),coordinators(every roster node withendpointandleads), andmy_tiers/my_endpoint.seats,my_seatandelectedare gone.beacon,anchor_height,rosterandroster_commitmentare unchanged.election_doc::election_is_honestrecomputes the draw from the published inputs. It refuses a document whose leaders, failover orders orleadsdon't follow from them. That includes the old seat-shaped document, so an old node's answer makes a new wallet fall back rather than guess. The wallet takes its endpoints fromelection_doc::endpoints_for_tier, which recomputes them and never reads the claims.The coordinator attestation (#881) swaps
my_seatforserving+my_tiers, and the challenger (#882) checksattested_serving. Neither is deployed.Verification
wraith-protocol: tests for distinct leaders (2,000 epochs), evenness per node and per tier (16,000 epochs), no self-nomination, rotation, fewer nodes than tiers, the failover order, and duplicate ids.ghost-pool: the document a node publishes passes the exact check a wallet runs (election_is_honest,endpoints_for_tier); four tiers go to four nodes and every roster node serves; each node publishes its ownmy_tiers/my_endpoint. fix(wraith): the coordinator roster converges — no local verdicts, no first-look freeze (#710) #883's convergence tests are kept.leads, trimmed roster, substituted beacon and a seat-shaped document are all refused; four tiers resolve to four different endpoints; nodes with no endpoint are skipped.scripts/mutate-wraith.shgains one mutation, independent picks in place of distinct leaders: 30/30 caught.wraith-protocol426,ghost-pool501,ghost-verification324 and the wallet resolver 20/20 pass.cargo fmt --check, the CI clippy invocation (all targets) andcargo doc -D warningsare clean for all four crates.⛔ Before rolling
Port 9100 must be open on every node. #884 makes the fleet check fail until it is. Every opted-in node now runs a coordinator, and wallets have to be able to reach it. Measured 2026-09-11: ufw is
default deny (incoming)everywhere and only vm4 and vm8 have a 9100 rule. Today's elected coordinator (vm3) answers127.0.0.1:9100/healthwith 200, but from outside it times out.No height gate: the election isn't consensus and doesn't feed payouts.