feat(pre-register): pre_register_count pool split (+ devnet-8 client re-pins) — aetheria#176 - #51
feat(pre-register): pre_register_count pool split (+ devnet-8 client re-pins) — aetheria#176#51iurii-ssv wants to merge 7 commits into
Conversation
| "KEYSHARES_FILE": "/app/keyshares/out.json", | ||
| } | ||
| pre_register_count = args.get("pre_register_count", 0) | ||
| if pre_register_count > 0: |
There was a problem hiding this comment.
What happens if pre_register_count is negative, say a typo'd -1? This branch is false, PRE_REGISTER_COUNT is never exported, and the script falls back to registering all 10 shares, which is exactly the ValidatorAlreadyExists collision the split is meant to avoid. Might be worth a plan-time fail() in main.star for non-positive-nonzero values, bounded by constants.SSV_MANAGED_VALIDATOR_COUNT, so bad input fails out of the dangerous mode rather than into it.
There was a problem hiding this comment.
Done — added a plan-time fail() in main.star that rejects pre_register_count outside [0, SSV_MANAGED_VALIDATOR_COUNT] before the enclave is built, so a negative/typo value fails out of the full-register fallback rather than into it.
| // index-partitioned P⊎D split that lets pre-registration and a registering suite share one enclave. | ||
| const all = JSON.parse(fs.readFileSync(KEYSHARES_FILE, "utf8")).shares; | ||
| const count = process.env.PRE_REGISTER_COUNT ? parseInt(process.env.PRE_REGISTER_COUNT, 10) : all.length; | ||
| if (!Number.isInteger(count) || count < 1 || count > all.length) { |
There was a problem hiding this comment.
Should we consider moving the range check up to plan time as well? Here it only fires at Step 4, after ethereum-package bring-up, contract deployment and the keyshare upload, and leaves a half-built enclave to tear down. SSV_MANAGED_VALIDATOR_COUNT is already available in main.star next to the layout guard. Keeping this check as defence-in-depth is fine.
There was a problem hiding this comment.
Done — the range check is now a plan-time guard in main.star (fails before bring-up, no half-built enclave to tear down); the cjs [1, N] check stays as the defence-in-depth you noted.
| # [64+N, 74) for the executor to register as its own cohort (D) — the index-partitioned split that | ||
| # lets pre-registration and a registering suite share one enclave (aetheria#176). register_validators | ||
| # reads pre_register_count from args. | ||
| if args.get("pre_register_validators", False): |
There was a problem hiding this comment.
Do we need to handle pre_register_count > 0 with pre_register_validators: false? make run PRE_REGISTER_COUNT=5 prints the "Params overrides applied" banner and then skips Step 4, so nothing registers while the operator believes cohort P exists. The layout-guard skip flag already fail()s on its incoherent combination a few lines up, so a fail() or at least a WARNING print here would keep the two knobs consistent.
There was a problem hiding this comment.
Done — main.star now fail()s on pre_register_count > 0 combined with pre_register_validators: false.
| # lets pre-registration and a registering suite share one enclave (aetheria#176). register_validators | ||
| # reads pre_register_count from args. | ||
| if args.get("pre_register_validators", False): | ||
| plan.print("Step 4/5: Pre-registering validators on-chain (pre_register_validators=true)") |
There was a problem hiding this comment.
Might be worth interpolating the count into this print and into the exec description in interactions.star. Right now a full run and a 5-of-10 run produce identical plan output, and the registration service is removed at the end of the step, so the only trace of which mode ran is in a removed container's logs.
There was a problem hiding this comment.
Done — the Step 4 plan print and the interactions.star exec description now interpolate the effective count and the P index range, so a 5-of-10 run is distinguishable from a full one.
| # only the first N keyshares on-chain (P = indices 64..64+N), leaving [64+N, 74) for the aetheria | ||
| # executor to register as its own cohort (D). This index-partitioned P⊎D split lets pre-registration and | ||
| # a validator-registering suite share one enclave without the ValidatorAlreadyExists collision above — | ||
| # they register disjoint pubkeys under the same owner, and D's nonces continue from N via the executor's |
There was a problem hiding this comment.
Could this lead to a revert on the executor side? Same owner plus the same operator IDs means P and D land in the same cluster. After P registers N validators the stored cluster state is validatorCount: N with 2.5N ETH balance, so D's bulkRegisterValidator has to pass the live cluster snapshot, not the zero struct this repo's own script uses. Nonce continuity is only half the contract, and the Step 4 comment above still says the executor "expects a clean, empty cluster". Since this isn't boot-validated yet, worth stating the live-snapshot requirement here as an explicit executor precondition, or marking the knob experimental until aetheria#176 confirms the D leg.
There was a problem hiding this comment.
Updated — the split D leg passes the LIVE on-chain cluster snapshot (not just nonce continuity), now stated as an executor precondition and boot-validated by aetheria#176; also corrected the main.star clean-empty-cluster comment.
| pre_register_validators: false | ||
|
|
||
| # pre_register_count partitions the static keyshare pool when pre_register_validators is true: register | ||
| # only the first N keyshares on-chain (P = indices 64..64+N), leaving [64+N, 74) for the aetheria |
There was a problem hiding this comment.
Any reason for the mixed notation? 64..64+N reads inclusive, which is N+1 validators and overlaps D at index 64+N, while [64+N, 74) is half-open. [64, 64+N) in both places (here and in the main.star Step 4 comment) would make the split unambiguous.
There was a problem hiding this comment.
Done — 64..64+N is now the half-open [64, 64+N) here and in the main.star Step 4 comment.
| fi; \ | ||
| if [ -n "$(PRE_REGISTER_COUNT)" ]; then \ | ||
| grep -q '^pre_register_count:' "$(GENERATED_PARAMS)" || { echo "Error: PRE_REGISTER_COUNT set but $(PARAMS_FILE) has no pre_register_count key"; exit 1; }; \ | ||
| sed -E 's|^(pre_register_count:).*|\1 $(PRE_REGISTER_COUNT)|' "$(GENERATED_PARAMS)" > "$(GENERATED_PARAMS).tmp" && mv "$(GENERATED_PARAMS).tmp" "$(GENERATED_PARAMS)"; \ |
There was a problem hiding this comment.
Worth considering a numeric check here. The fork-epoch overrides anchor their sed on [0-9]+ so a bad value simply doesn't substitute, but .* always matches: PRE_REGISTER_COUNT=abc lands a string in the YAML and a blank value produces a YAML null, and both surface as a cryptic Starlark type error at the > 0 comparison instead of a clear Make error. A case guard on ''|*[!0-9]* before the sed would cover it, and would also close the negative-value path from the Make side.
There was a problem hiding this comment.
Done — added a numeric guard (a case on empty-or-non-digit) before the sed, so a non-numeric or blank value fails with a clear Make error instead of landing a YAML null/string.
| GLOAS_FORK_EPOCH?= | ||
| BOOLE_FORK_EPOCH?= | ||
| PRE_REGISTER_VALIDATORS?= | ||
| PRE_REGISTER_COUNT?= |
There was a problem hiding this comment.
Should we update CLAUDE.md alongside this? Line 55 still says the owner-nonce sequence "forbids carving a subset out", which is the opposite of what this knob now does, and the Make-level override list a few lines below omits PRE_REGISTER_COUNT.
There was a problem hiding this comment.
Done — corrected the forbids-carving-a-subset line (it now carves a contiguous prefix), added a pre_register_count bullet, and added PRE_REGISTER_COUNT to the Make-override list.
| # to register as cohort D at keystore offset N — the index-partitioned split that lets pre-registration | ||
| # and a registering suite share one enclave (aetheria#176; the unified glamsterdam-fork suite runs here). | ||
| # 0 (default) registers the FULL set. See params.yaml for the full semantics and the #36 prefix constraint. | ||
| pre_register_count: 0 |
There was a problem hiding this comment.
What do you think about adding pre_register_count: 0 to params-boole.yaml too? The run-boole-interop comment advertises the pre-register overrides as working "like any run-boole run", but the boole file has no count key, so make run-boole PRE_REGISTER_COUNT=N exits at the Makefile key check. Loud failure, so low risk, just an inconsistent surface across the three param files.
There was a problem hiding this comment.
Done — added pre_register_count: 0 to params-boole.yaml so make run-boole PRE_REGISTER_COUNT=N has a key to substitute.
| enabled: true # ENABLED: E2M §2 validated on Gloas (ssvlabs/aetheria#137); removal teardown E2M is skipped on local_testnet by the executor | ||
|
|
||
| boole_epoch: 0 # SSV Boole fork active from genesis (ePBS builds on Boole); default is disabled | ||
| boole_epoch: 1000000000 # dormant far-future epoch — never reached in a run, so ePBS stays pre-Boole (Alan). Not math.MaxUint64/1<<63: the node's Validate() overflows on a scheduled boole epoch > ~5.76e17, only the exact MaxUint64 sentinel counts as unscheduled, and the config pipeline float-rounds it. |
There was a problem hiding this comment.
Done — rebased onto main, dropping the already-merged 7207c31; the diff is now just the intended pre_register_count changes plus a folded devnet-6 to devnet-8 client bump (boot-validated).
…yshare pool Pre-registering the full static keyshare set (indices 64-73) collides with the aetheria executor's own validator-registering suites — both register the same pubkeys, so a combined enclave reverts with ValidatorAlreadyExists. This adds a pre_register_count knob: register only the first N keyshares on-chain (P = indices 64..64+N), leaving [64+N, 74) for the executor to register as its own cohort (D). The index-partitioned P⊎D split lets pre-registration and a registering suite share one enclave. Only a CONTIGUOUS prefix is registered: each share's sharesData signs (owner, nonce) as a strict 0-based sequence, so a prefix keeps every registered nonce matching its position — skipping a middle entry would shift later nonces and trip #36. D's remaining pubkeys are not skipped; the executor regenerates fresh sharesData from the live on-chain nonce (which continues from N). No keygen or pool widening: the split works on the existing keyshares. Default 0 leaves PRE_REGISTER_COUNT unset -> the full set registers as before (backward-compatible). Consumed by aetheria#176's unified fork-transition suite.
…-split key Adds the Makefile hook substituting PRE_REGISTER_COUNT into the generated params' pre_register_count key (mirrors the PRE_REGISTER_VALIDATORS override), and pre_register_count: 0 to params-gloas.yaml so the aetheria#176 pool split works on the gloas net (the unified glamsterdam-fork suite's home). Completes the knob: the aetheria orchestrator passes PRE_REGISTER_COUNT at bring-up, and this lands it in the params main.star reads.
…, consistency - main.star: validate pre_register_count at plan time (before the enclave build) — reject out-of-range [0, SSV_MANAGED_VALIDATOR_COUNT] and a positive count without pre_register_validators, so bad input fails out of the dangerous full-register fallback rather than into it (a half-built enclave). register-validators.cjs keeps its [1, N] check as defence-in-depth. - main.star / interactions.star: interpolate the count into the Step 4 plan print and the registration exec description (a 5-of-10 run is now distinguishable from a full run). - Makefile: reject non-numeric/blank PRE_REGISTER_COUNT before the sed (the .* pattern would otherwise land a YAML null/string and surface as a cryptic Starlark error). - params.yaml / main.star: the split's D leg passes the LIVE cluster snapshot, not just nonce continuity — state it as an executor precondition (boot-validated); fix the 64..64+N notation to the half-open [64, 64+N). - params-boole.yaml: add pre_register_count: 0 so run-boole PRE_REGISTER_COUNT=N has a key. - CLAUDE.md: pre_register_count carves a contiguous prefix (correct the "forbids carving a subset out" line) + add it to the params and Make-override lists.
The node's Gloas block types now follow the devnet-8 spec, so a devnet-6 CL computed a different EIP-7688/7916 progressive-merkleization root and rejected every §4 submit with 500 BLOCK_ERROR_INVALID_SIGNATURE (ssvlabs/ssv#3021); devnet-8 CLs match the node's root. Validated end-to-end by the aetheria#176 boot (§4 published, §6 CONFIRMED, CL head continuity through the fork).
f64fe15 to
03c7542
Compare
…ding-zero count - main.star: tighten pre_register_count to < SSV_MANAGED_VALIDATOR_COUNT so a positive value always leaves a non-empty cohort D; a count equal to the pool size is a no-op split (use 0 for the full set). - Makefile: reject a leading-zero PRE_REGISTER_COUNT (e.g. 010), which YAML 1.1 could read as octal 8 — added a 0?* case alongside the existing digit guard. - params.yaml / CLAUDE.md: document the < pool-size bound.
…t the 63/64 under-count bulkRegisterValidator sent the tx with exactly ethers' auto-estimated gas. geth's eth_estimateGas under-estimates this nested call — its binary search runs the lenient eth_call executor, but real execution forwards only 63/64 of the remaining gas (EIP-150) into the SSVStaking delegatecall, so the subcall is starved into a bare revert when the tx runs with exactly the estimate. It reproduced deterministically on some pre-register counts (e.g. 6) while others (e.g. 8) landed with enough headroom. Estimate the gas explicitly and send with a 2x buffer so the subcall always has enough forwarded gas. Fixes small-count pre-registration, needed by aetheria's glamsterdam-fork churn (which leaves pool headroom for P + D + R + C).
ovidiu-ssv-labs
left a comment
There was a problem hiding this comment.
Filed a separate follow-up improvement for when time allows: #53
… scale Register in <=50-validator batches under Ethereum's 128 KiB tx-size limit, threading the on-chain cluster snapshot from each batch's ValidatorAdded event into the next — pre-registration now scales past the single-tx ~85-validator ceiling to any pool size. - register-validators.cjs: batched bulkRegisterValidator + clusterFromReceipt (named/positional read). - generate-static-keys.sh: SSV_VALIDATOR_COUNT (pool size) and CL_VALIDATOR_START (VC total = seed start index) are env-overridable; Step 4 syncs constants.star + params so the layout mirrors can't drift. - params-gloas.yaml: document why the VC cohort scales with the pool (the genesis-bootstrap deadlock — a pool > 1/3 of the set can't justify without a 2/3-majority VC cohort); set per suite by aetheria's provision-pool.sh. - params/constants/CLAUDE.md: generalize the hardcoded 64-73/74 layout to [64, 64+N). Enables the aetheria#176 glamsterdam-fork sign-off at scale.
What
Adds a
pre_register_countknob so pre-registration and a validator-registering aetheria suite can share one enclave. Whenpre_register_validators: true, register only the first N static keyshares on-chain (cohort P = indices[64, 64+N)), leaving[64+N, 74)for the aetheria executor to register as its own cohort D at keystore offset N.This is the ssv-mini half of the unified
(glamsterdam-fork)sign-off suite (aetheria#176) — the index-partitioned P⊎D split that removes the old constraint where pre-registration and the self-registering suites collided on the single validator pool (ValidatorAlreadyExists).How
contract/registration/register-validators.cjs— registers a contiguous prefixshares[0, count)(PRE_REGISTER_COUNTenv; unset ⇒ the full set). A prefix keeps every registered share'sownerNoncematching its position (0..count-1), so it is feat: fork-epoch knob + opt-in genesis validator pre-registration #36-safe — only skipping a middle entry shifts later nonces. The rest of the pool is left for the executor, which regenerates freshsharesDatafrom the live on-chain nonce (which continues from N).main.star— plan-time validation (fails before the enclave is built): rejects an out-of-range count (valid[0, pool_size)— a positive value must leave a non-empty D) and a positive count withoutpre_register_validators: true, and interpolates the count into the Step-4 plan output.contract/interactions.star— passesPRE_REGISTER_COUNTfromargs.get("pre_register_count", 0); interpolates the count into the exec description.Makefile—PRE_REGISTER_COUNToverride hook (mirrorsPRE_REGISTER_VALIDATORS), with a numeric guard (rejects non-numeric / blank / leading-zero values).params.yaml/params-gloas.yaml/params-boole.yaml— document + defaultpre_register_count: 0on all three param files.Client pins (folded in)
params-gloas.yaml— bumps the geth + lodestar digest pins from glamsterdam-devnet-6 → devnet-8 (chore(gloas)commit). The node's Gloas block types now follow the devnet-8 spec, so a devnet-6 CL computed a different EIP-7688/7916 progressive-merkleization root and 500'd every §4 submit (ssvlabs/ssv#3021); devnet-8 CLs match the node's root. Unrelated topre_register_count, but needed for the aetheria consumer to drive the split on a working Gloas chain.Notes
out.json(pubkey-ascending, an SSV bulk-registration requirement, withownerNoncein that order) and the aetheria seed order the shared set the same way; P (first N) and D (the rest) are complementary slices. The consumer side additionally enforces the ascending order in its allocator SQL (ORDER BY public_key, aetheria#177), so the invariant is real, not incidental.pre_register_count: 0(the default) registers the full set, exactly as before.(glamsterdam-fork)boot registered P + D disjointly on-chain (cluster snapshot + nonce continuity both held) and drove the full ePBS lifecycle on the devnet-8 pins.pre_register_count > 0on.