Skip to content

feat(cluster-tool): per-producer finalizer keys, genesis producer registration, and the producer-registration flow (WIRE-367) - #94

Open
heifner wants to merge 8 commits into
masterfrom
feature/wire-367-producer-registration
Open

feat(cluster-tool): per-producer finalizer keys, genesis producer registration, and the producer-registration flow (WIRE-367)#94
heifner wants to merge 8 commits into
masterfrom
feature/wire-367-producer-registration

Conversation

@heifner

@heifner heifner commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Part of WIRE-367. Gives the harness what it needs to stand up a real block producer, and adds the flow that drives one from a fresh account to producing blocks and back.

Per-producer BLS finalizer keys

The harness minted one K1+BLS set per producer node and every producer account on that node shared it. regfinkey enforces a global duplicate-key check, so N accounts sharing one BLS key cannot all register — which meant update_ranked_producers could never publish, and no harness cluster had ever run on a ranked schedule. Every flow to date ran on whatever setprodkeys stamped at bootstrap.

BLS keys are now per producer account (OperatorAccount.wireFinalizer); the K1 block-signing key stays node-shared, since it carries no uniqueness constraint. NodeopProcess takes operators: OperatorAccount[] and emits one --producer-name plus its BLS --signature-provider per hosted producer, and the genesis finalizer policy is built from producer keys rather than node keys. Because that K1 is shared by construction, NodeopProcess.create now refuses a node whose hosted producer accounts do not all carry the same one.

Bootstrap ordering

Three constraints, each of which cost a failed flow run to find, now pinned by tests in ClusterBuildDefaultsGenesisProducers.test.ts:

  1. ProducerIdentities must precede ProducerNodes. A producing node renders one --signature-provider per hosted account at launch, so the keys must exist by then. Key material is a separate phase from the newaccount write, which cannot run until sysio.system is deployed. Symptom: ClusterKeyStore: operator "defproducera" has not been provisioned.
  2. Finality must stay before SystemContract. bios::setfinalizer is a BIOS-ABI action and stops existing once the system contract replaces bios code. Symptom: Unknown action setfinalizer in contract sysio.
  3. regoperator for genesis producers waits for OPPConfigsysio.opreg isn't deployed until OPPContracts.

Plus one non-ordering trap: regfinkey bills its rows to the producer, and neither route to a producer account leaves room. Steps.consensus.planGrantProducerRam grants it, keyed by handle because a sponsored producer's on-chain name is generated at run time. Symptom: Account using more than allotted RAM usage.

New surface

  • SystemContractStepsplanRegproducer, planRegfinkey, planActfinkey, planUnregprod, planSetscorecfg, planSetacctram, all on the generated action types — planSetscorecfg carries the rolling-window gate (missed_round_window_ms, max_pct_missed_rounds_in_window) alongside the consecutive threshold.
  • ProducerNodeTool — spawns an ad-hoc producing node outside NodeConfig.plan, ports from BindConfigProvider.findAvailable.
  • WireOperatorProvisioningTool — a PRODUCER without producerNodeIndex now routes to the collateral-backed OPP-operator path (unique K1/EM/ED plus a BLS finalizer key of its own, sponsored account, ETH+SOL funding, authex links on both chains, regoperator). A spec naming both producerNodeIndex and ethereumHdIndex is refused, since genesis and collateral-backed are mutually exclusive routes.
  • ProtocolTiming.ScheduleRebuildIntervalMs — the ranked schedule is rebuilt at most once per 120 slots, so the flow's schedule and demotion deadlines derive from it rather than from producer rotations alone.

SSM key custody

A producer NODE now publishes nothing under its own name. Finality moved to per-account
finalizer keys, so buildArgs renders every --signature-provider spec from an ACCOUNT's
publicationLabel — the block-signing K1 from the first hosted account, one finalizer BLS
per hosted account — and the producer-account rows already republish that K1 under each
account's own label. The node_NN parameters were fetched by nobody, so publishing them wrote
live signing keys into every replication region for no reader. Removing the row left
SignatureKeySource single-valued and nodeIndex write-only, so both go and the key resolver
collapses to the one lookup it always was. The bios node is not an exception: its identity is an
operator row whose label IS the node name its daemon renders.

The cross-check test asserted [K1, BLS] per producing node — an assumption, and the wrong one.
It now DERIVES the expected ids from what buildArgs renders and names the bios label divergence
explicitly, so it fails when a rendered spec has no publication rather than when a guess about
node keys stops holding. Worth knowing: these steps are composed only under
--signature-provider-type SSM, which the e2e gate does not exercise, so the unit tests are the
whole safety net until a cluster workflow boots an SSM cluster against it.

Two gaps under --signature-provider-type SSM, both invisible under the default KEY mode:

  • A flow-provisioned operator's keys were never published. The bootstrap walker enumerates identities from config, and a flow's label is not in the config, so the daemon later started for it rendered SSM: specs pointing at parameters nothing had written. Its provisioning phase now publishes them itself, through the same renderer the walker uses, and only for labels the walker does not already cover.
  • An existing parameter was retained on sight. If it held a different key than the one this run materialized, the daemon reading it would sign with a key the chain never saw. The publish step now compares the stored value in canonical native form and fails, naming the parameter and region, rather than adopting a stale copy. A parameter is still never overwritten.

The flow

flow-producer-registration, 9 phases: provision → negative case (registered but unbonded never enters the schedule) → ETH deposit → SOL deposit → start node → enters schedule and produces a block → missed rounds drive demotion → regproducer clears the demotion while the miss streak survives → producing a block clears the streak → withdraw below minimum removes it. The two recovery assertions are separate on purpose: regproducer is a free signature, so it re-opens the schedule without pardoning the record, and only producing retires the streak. Assertions read the active schedule via get_producer_schedule rather than head_block_producer, and the exit check requires the demoted producer to be absent while the schedule still holds only genesis producers at or above MIN_SCHEDULE_SIZE. Picked up by dynamic discovery, so no workflow edit.

Validation

  • pnpm build, pnpm run lint, and 2070 tests across 229 suites.
  • e2e gate 33992129110 on the current heads: 15/15 flows green, including this one (1240s) and emissions-soak, where genesis producers newly drawing producer pay would have surfaced.

CI

Build and test is red here until Wire-Network/wire-libraries-ts#80 merges and publishes. This repo resolves @wireio/sdk-core from the registry, and the published version predates the WIRE-367 ABI: every error is a missing setscorecfg, rank_score or standby_bps. No code change here will fix it, and none is needed — the sibling-linked build used by the e2e gate above is green.

Merge order

Last. After Wire-Network/wire-sysio#599 and Wire-Network/wire-libraries-ts#80.

…istration, and the producer-registration flow (WIRE-367)

A cluster could never publish a ranked schedule. `update_ranked_producers`
schedules only producers with an active finalizer key, and every producer on a
node shared one — `regfinkey` enforces global key uniqueness, so only the first
could ever register — while genesis producers got an account and nothing else:
no `regproducer`, no `regfinkey`, no opreg row. Every flow ran on whatever
`setprodkeys` stamped at bootstrap.

Each producer ACCOUNT now owns a BLS finalizer key (the block-signing K1 stays
node-shared; `setprodkeys` maps every producer on a node to it). That turns
`NodeopOptions.operator` into `operators: OperatorAccount[]` — one
`--producer-name` and one finalizer provider per hosted account — and moves the
genesis finalizer policy onto those accounts, because
`update_ranked_producers` rebuilds it from exactly those keys and a node-keyed
policy would be replaced by one the node holds no key for: LIB freezes and the
pending schedule never activates.

Bootstrap ordering follows from that. Identities are materialized in a new
`ProducerIdentities` phase before any node starts, since a producing node
renders its accounts' signature providers at launch (and, under SSM, fetches
them — so their publication moves to the beforeNodes phase). `Finality` stays
ahead of `SystemContract`, because `bios::setfinalizer` stops existing once the
system contract replaces the bios code. `GenesisProducerRegistration` grants RAM
then registers each producer and its key; the opreg half waits for
`GenesisProducerOperators`, after sysio.opreg is deployed and configured.

New: `Steps.contracts.sysio.system.plan{Regproducer,Regfinkey,Actfinkey,
Unregprod,Setscorecfg,Setacctram}` (each producer action signed by the producer
its own data names, derived rather than carried beside it);
`Steps.consensus.plan{RegisterProducer,RegisterFinalizerKey,GrantProducerRam}`
for the keys-from-the-store paths; and `ProducerNodeTool`, which starts and
stops an ad-hoc producing node the way `OperatorDaemonTool` starts a daemon.
`WireOperatorProvisioningTool` routes a producer with no `producerNodeIndex`
through the collateral-backed path — sponsored account, authex links,
`regoperator`, plus its own finalizer key.

`flow-producer-registration` drives a fresh account from provisioning to
producing blocks and back out: registered-but-unbonded stays UNKNOWN and
produces nothing, both bonds flip it ACTIVE, it enters the ranked schedule and
produces, its node is stopped until demotion fires at exactly the threshold,
`regproducer` recovers it, and withdrawing the bond takes it out again.

Change-Id: I5a92bc26a7483b0f1f1b00dde56248bf1536de13
…eads, schedule-based flow asserts (WIRE-367)

Review fixes on #94:
- NodeConfig.createAdHoc / adHocNodeName / peerEndpointFor /
  producerPeerEndpoints / AdHocIndex / NodeNamePrefix: the one composition
  OperatorDaemonTool and ProducerNodeTool share. BindConfigProvider.
  findAvailableAdHocPorts with DefaultAdHocHttp/P2p in the daemon layout (the
  old 8988/9976 sat inside the reserved agave band, so the preference never
  applied).
- WireClient.activeAuthorization is the one spelling of <account>@Active;
  getProducers and getProducerSchedule are typed reads.
- ConsensusSteps runners resolve from the key store and delegate to the
  SystemContractSteps runners; Steps.consensus.ProducerRamBytes is the one
  RAM grant; KeySteps.keyGeneratorContext replaces four hand-built contexts.
- Constants.MIN_SCHEDULE_SIZE; ProtocolTiming.BlockIntervalMs /
  ProducerRepetitions / producerRotationMs / OutpostWriteBudgetMs;
  utils/producerRankUtils decodes the tier off rank_score.
- flow-producer-registration installs setscorecfg, asserts on the ACTIVE
  schedule via get_producer_schedule instead of head_block_producer, and
  checks the healthy tier off rank_score.
- EMISSION_CONFIG_DEFAULTS carries standby_bps: 800.
- Tests for every new or changed symbol, the two-producer NodeopProcess argv,
  the producer node stop runner, the identity phase, and the collateral-route
  producer; the ConsensusSteps runner tests live in ConsensusSteps.test.ts.

Change-Id: I9b98755b5e348f8c88e1d75b9ead9206d271e8c7
… deadlines, SSM publication, hosted-producer key guard (WIRE-367)

- ProtocolTiming.ScheduleRebuildIntervalMs (60 s) joins the flow's schedule
  and demotion deadlines: the ranked schedule is rebuilt at most once per 120
  slots, so a deadline sized on rotations alone undershot by one rebuild tick.
- verifyProducerLeavesSchedule asserts the demoted producer is gone AND that
  the schedule holds only genesis producers with at least MIN_SCHEDULE_SIZE
  entries; the row read after demotion is null-guarded.
- WireOperatorProvisioningTool: a collateral-backed PRODUCER's phase reports
  under the Producer actor, defaults to a non-bootstrapped registration, and a
  spec naming both producerNodeIndex and ethereumHdIndex is refused.
- NodeopProcess.create refuses a node whose hosted producer accounts do not
  share one block-signing K1.
- SSM: a flow-provisioned operator's freshly materialized keys are published
  by its own provisioning phase (K1/EM/ED, plus BLS for a producer) through
  the same renderer the bootstrap walker uses, for labels the bootstrap does
  not publish itself; runPublishSignatureProviderKey refuses an existing
  parameter that holds a different key instead of retaining a stale copy.
- Stale comments in ClusterState, ClusterBuild, and the external-config test.

Change-Id: I548be9406604c0ad4877f6f26fdc606a0924a875
…tscorecfg (WIRE-367)

`prodscorecfg` gained the rolling window a producer's miss RATE is measured
over and the percentage limit that goes with it, mirroring the batch-operator
availability gates. Both are required fields on the generated action type, so
every payload that installs a score config carries them.

The flow's snapshot weight now matches what the contract ships, a tenth of
collateral rather than parity, so the config it installs stays representative
of production. It changes none of the flow's assertions: no producer in it
holds an attestation, so the factor is zero either way.

Worth knowing for the next ABI change here: the first `pnpm build` after
regenerating passed while this config was still missing both new fields,
because `tsc -b` kept a stale output for the dependent package. `pnpm clean`
first, or the error arrives as a runtime serialization failure in the gate
instead of a compile error.

Change-Id: Id9290f06f512a0bdb6150e41c538bdea3b47b180
…t the old one (WIRE-367)

The flow asserted that `regproducer` clears both the demotion AND the miss
streak. That was the old behaviour; the streak now deliberately survives
re-registration, because re-registering costs nothing but a signature and can
be repeated, so clearing it there would let an absent operator call it on a
timer and never produce a block. The gate caught this as a failed
`demotion-cleared` step -- the assertion was encoding a contract the contract
no longer offers.

It now checks both halves of the rule, which is stronger than what it replaced:
re-registration must clear the demotion and must NOT clear the streak, and the
block the producer subsequently makes is what clears it. That second half is a
new step, so the flow now proves the recovery path end to end rather than
assuming it.

Change-Id: I7d6ef7550a2e8e5ee36c83e4f6f4d31ab1e2ea79
…identity (WIRE-367)

`min_blocks_per_round` rides `setscorecfg`. The producer-registration flow sets it
to 0 -- the disabled spelling -- because it demotes by stopping a node outright, so
every round it charges is a whole round with no blocks at all; leaving the check
armed would make the assertions depend on how many blocks the node happened to land
before it died.

`NodeopProcess.create` now asserts the hosted operators ARE the node's producers,
not merely that there are the same number of them. `buildArgs` renders
`--producer-name` from the operators while the key store, the SSM secret ids and
start.sh all read `node.producers`, so matching counts alone let a caller hand over
the right number of the wrong accounts -- or the right accounts in a different
order -- and the node would sign for producers it holds no slot for, silently.
`NodeConfig.createAdHoc` now stores the key-store LABEL there, as `plan` does; an
on-chain account in that field reads as a label that resolves to nothing.

`producerTier` refuses a tiered `rank_score` rendered as a number rather than
decoding a rounded one. Every non-healthy tier sets bit 62 or 63, so such a value is
far past the safe-integer range: the demoted tier's `unscored()` rounds UP across
the tier boundary and reads as a tier the contract never packs -- on a correctly
demoted producer, which is exactly what the flow asserts on.

Change-Id: Ice0bdfb4a39f1eb6823e5a5d99fbb63c3030bfc7
…abel alone (WIRE-367)

The identity assertion added with the node/operator pinning rejected the BIOS
node and took every e2e flow down with it: `node.producers` is not uniformly one
identifier. A planned producer node carries key-store labels -- and a producer
never calls `roa::newuser`, so its label IS its account -- while the bios node
carries the ON-CHAIN genesis producer name `sysio` under an operator labelled
`node_bios`. Demanding labels failed at `start-bios`, which aborted every
downstream phase, so all fifteen flows died in about two seconds without
reaching any of their own assertions.

Each producer name is now matched by an operator on EITHER its label or its
account, which still catches the defect the assertion exists for -- the right
NUMBER of the wrong accounts, or the right accounts in a different order.

The unit tests missed this because the `producerOperator` fixture sets
`label = account`, so every shape exercised had the two identifiers identical --
precisely the condition under which the bug is invisible. Two tests now pin the
shapes that differ: the bios node is accepted with `label != account` and still
renders `--producer-name sysio`, and an operator matching by neither is rejected.

Change-Id: Ia77daf62129812fc400d8ce530ed2f3b0ec88972
… (WIRE-367)

Under SSM the bootstrap wrote a live BLS private key into every replication
region for each producer node, at `/wire/<cluster>/node_NN/BLS`, that no daemon
ever read. Finality moved to per-ACCOUNT finalizer keys in this work -- a shared
node key cannot pass `regfinkey`'s global uniqueness check -- and both former
readers moved with it, but the publishing side did not.

Tracing what a node actually resolves showed the whole node identity was dead,
not just its BLS half. `buildArgs` renders every `--signature-provider` spec from
an ACCOUNT's `publicationLabel`: the block-signing K1 from the first hosted
account, one finalizer BLS per hosted account. A `node_NN` parameter is fetched
by nobody, and the producer-ACCOUNT rows already republish that same K1 under
each account's own label -- which is the point of publishing per identity. The
bios node is not an exception: its identity is an operator row whose label IS the
node name its daemon renders.

Removing the row left `SignatureKeySource` single-valued and `nodeIndex`
write-only, so both go, and the key resolver collapses to the one lookup it
always was. Every published identity is an operator account keyed by its own
label.

The cross-check test asserted [K1, BLS] per producing node -- an assumption, and
the wrong one. It now DERIVES the expected ids from what `buildArgs` renders, and
names the bios label divergence explicitly, so it fails when a rendered spec has
no publication rather than when a guess about node keys stops holding.

Note the coverage limit: these steps are composed only when
`signatureProvider.type === SSM`, and the e2e gate runs KEY mode, so the gate
exercises none of this. The unit tests are the whole safety net until a cluster
workflow boots an SSM cluster against it.

Change-Id: I392a8ab918b6f79ec99a2cb7c7b0b957ef532faf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant