Skip to content

feat(pre-register): pre_register_count pool split (+ devnet-8 client re-pins) — aetheria#176 - #51

Open
iurii-ssv wants to merge 7 commits into
mainfrom
feat/pre-register-count
Open

feat(pre-register): pre_register_count pool split (+ devnet-8 client re-pins) — aetheria#176#51
iurii-ssv wants to merge 7 commits into
mainfrom
feat/pre-register-count

Conversation

@iurii-ssv

@iurii-ssv iurii-ssv commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

What

Adds a pre_register_count knob so pre-registration and a validator-registering aetheria suite can share one enclave. When pre_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).

This PR also folds in an unrelated devnet-6 → devnet-8 client re-pin (the chore(gloas) commit) — see the Client pins section below. Kept in one PR because the aetheria consumer needs both to exercise the split on a working Gloas chain.

How

  • contract/registration/register-validators.cjs — registers a contiguous prefix shares[0, count) (PRE_REGISTER_COUNT env; unset ⇒ the full set). A prefix keeps every registered share's ownerNonce matching 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 fresh sharesData from 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 without pre_register_validators: true, and interpolates the count into the Step-4 plan output.
  • contract/interactions.star — passes PRE_REGISTER_COUNT from args.get("pre_register_count", 0); interpolates the count into the exec description.
  • MakefilePRE_REGISTER_COUNT override hook (mirrors PRE_REGISTER_VALIDATORS), with a numeric guard (rejects non-numeric / blank / leading-zero values).
  • params.yaml / params-gloas.yaml / params-boole.yaml — document + default pre_register_count: 0 on 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 to pre_register_count, but needed for the aetheria consumer to drive the split on a working Gloas chain.

Notes

  • No keygen, no pool widening — the split works on the existing keyshares because both out.json (pubkey-ascending, an SSV bulk-registration requirement, with ownerNonce in 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.
  • Fully backward-compatiblepre_register_count: 0 (the default) registers the full set, exactly as before.
  • Boot-validated end-to-end — the aetheria#176 (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.
  • Merge-gating — the P⊎D correctness lives entirely in the aetheria consumer (aetheria#177); land + green that before any consumer flips pre_register_count > 0 on.

"KEYSHARES_FILE": "/app/keyshares/out.json",
}
pre_register_count = args.get("pre_register_count", 0)
if pre_register_count > 0:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread main.star
# [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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — main.star now fail()s on pre_register_count > 0 combined with pre_register_validators: false.

Comment thread main.star Outdated
# 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)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread params.yaml Outdated
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread params.yaml Outdated
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — 64..64+N is now the half-open [64, 64+N) here and in the main.star Step 4 comment.

Comment thread Makefile
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)"; \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Makefile
GLOAS_FORK_EPOCH?=
BOOLE_FORK_EPOCH?=
PRE_REGISTER_VALIDATORS?=
PRE_REGISTER_COUNT?=

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread params-gloas.yaml
# 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added pre_register_count: 0 to params-boole.yaml so make run-boole PRE_REGISTER_COUNT=N has a key to substitute.

Comment thread params-gloas.yaml
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hunk and the comment block above are commit 7207c31, which is already on main as #50. A rebase would trim the diff down to the four intended changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).
@iurii-ssv
iurii-ssv force-pushed the feat/pre-register-count branch from f64fe15 to 03c7542 Compare September 3, 2026 11:51
…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.
@iurii-ssv iurii-ssv changed the title feat(pre-register): pre_register_count — index-partition the static keyshare pool (aetheria#176) feat(pre-register): pre_register_count pool split (+ devnet-8 client re-pins) — aetheria#176 Sep 3, 2026
…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
ovidiu-ssv-labs previously approved these changes Sep 4, 2026

@ovidiu-ssv-labs ovidiu-ssv-labs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@momosh-ssv
momosh-ssv self-requested a review September 7, 2026 08:38
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.

3 participants