diff --git a/playbooks/gloas-dev/builder-prefork-onboard.yaml b/playbooks/gloas-dev/builder-prefork-onboard.yaml new file mode 100644 index 00000000..0c390fae --- /dev/null +++ b/playbooks/gloas-dev/builder-prefork-onboard.yaml @@ -0,0 +1,232 @@ +id: builder-prefork-onboard +name: "GLOAS: Pre-fork builder onboarding (0x03 -> builder via fork dequeue)" +description: | + Focused regression test for the GLOAS pre-fork builder onboarding path + (`onboard_builders_from_pending_deposits`). + + This test ONLY exercises the case where a builder is created from a regular + validator deposit that is still sitting in the pending-deposit queue when the + fork happens: + + 1. Inside the UNFINALIZED window just before GLOAS activates (so the deposit is + still in state.pending_deposits at the fork), deposit `builderCount` keys via + the STANDARD validator deposit contract using 0x03 (builder) withdrawal + credentials. The deposit signatures are verified under the regular + DOMAIN_DEPOSIT, so the ordinary `generate_deposits` task is used unchanged. + 2. Because these deposits reach the fork still pending (unprocessed), the GLOAS + fork transition dequeues them and converts them into builders via + `onboard_builders_from_pending_deposits`. + 3. Verify, right after the first GLOAS block, that each pubkey now exists as a + builder in the beacon state (i.e. it was dequeued/onboarded at the fork). + 4. Verify the onboarded builders go active. + + TIMING IS LOAD-BEARING. `process_pending_deposits` only drains deposits whose + slot is already finalized (`if deposit.slot > finalized_slot: break`). If the + deposit is made too early it finalizes and is applied to the validator registry + as a regular validator BEFORE the fork, and `onboard_builders_from_pending_deposits` + never sees it (it also explicitly keeps deposits for existing validators in the + queue). So the deposit must land late enough to still be UNfinalized at the fork. + There is no minimum deposit amount to onboard a builder (per gloas spec + v1.7.0-alpha.11: add_builder_to_registry sets builder.balance = deposit.amount + with no MIN_ACTIVATION_BALANCE check); 32 ETH is only needed for regular + validator activation, not builder onboarding. + + This test REQUIRES being started before the pre-fork deposit window. If that + window has already passed, the test fails fast (the path under test is no longer + reachable) rather than silently passing. +version: 1.0.0 +tags: [gloas, builder, deposit, onboarding, prefork, eip-8282] +timeout: 1h +config: + walletPrivkey: '' + # Regular validator deposit contract (pre-fork builder onboarding via 0x03 creds). + depositContract: '0x00000000219ab540356cBB839Cbe05303d7705Fa' + builderCount: 5 + depositAmount: 5 + # Optional: deposit for a SPECIFIC builder key (e.g. buildoor's) instead of a + # random one, so an external builder using the same mnemonic+index can bid on + # the onboarded builder. Leave empty to use a freshly generated mnemonic. + builderMnemonic: '' + builderKeyIndex: 0 + +tasks: +- name: check_clients_are_healthy + title: "Check if at least one client is ready" + timeout: 5m + config: + minClientCount: 1 + +# get consensus specs and current slot, then calculate fork-relative timing +- name: get_consensus_specs + id: get_specs + title: "Get consensus chain specs" + +- name: check_consensus_slot_range + id: current_slot + title: "Get current slot" + +- name: run_shell + id: calc_slots + title: "Calculate pre-fork deposit window (must stay unfinalized at the fork)" + config: + envVars: + GLOAS_FORK_EPOCH: "tasks.get_specs.outputs.specs.GLOAS_FORK_EPOCH" + SLOTS_PER_EPOCH: "tasks.get_specs.outputs.specs.SLOTS_PER_EPOCH" + CURRENT_SLOT: "tasks.current_slot.outputs.currentSlot" + command: | + set -e + GLOAS_FORK_EPOCH=$(echo "$GLOAS_FORK_EPOCH" | jq -r .) + SLOTS_PER_EPOCH=$(echo "$SLOTS_PER_EPOCH" | jq -r .) + CURRENT_SLOT=$(echo "$CURRENT_SLOT" | jq -r .) + + ACTIVATION_SLOT=$((GLOAS_FORK_EPOCH * SLOTS_PER_EPOCH)) + + # The deposit must still be in state.pending_deposits at the fork to be + # onboarded. process_pending_deposits only drains FINALIZED deposits + # (`if deposit.slot > finalized_slot: break`), and finality lags >= 2 + # epochs, so depositing ~1 epoch before the fork keeps it unfinalized + # (won't be drained into the validator registry) while still leaving + # slots for it to be included as a pending deposit. + PREFORK_SLOT=$((ACTIVATION_SLOT - SLOTS_PER_EPOCH)) + if [ "$PREFORK_SLOT" -lt 0 ]; then PREFORK_SLOT=0; fi + + # Need a couple slots of headroom before the fork to actually land the + # deposit as pending. If we're already inside that headroom, it's too late. + LATEST_DEPOSIT_SLOT=$((ACTIVATION_SLOT - 2)) + if [ "$CURRENT_SLOT" -ge "$LATEST_DEPOSIT_SLOT" ]; then + echo "ERROR: too late to land a pre-fork pending deposit." + echo " current slot: $CURRENT_SLOT" + echo " latest deposit slot: $LATEST_DEPOSIT_SLOT" + echo " GLOAS activation: slot $ACTIVATION_SLOT (epoch $GLOAS_FORK_EPOCH)" + echo "Start the test earlier so the 0x03 deposit is still pending" + echo "(and unfinalized) when GLOAS activates." + exit 1 + fi + + # First GLOAS block (+1 slot of margin) is where onboarding is observable. + ONBOARD_SLOT=$((ACTIVATION_SLOT + 1)) + ACTIVE_EPOCH=$((GLOAS_FORK_EPOCH + 4)) + + echo "Current slot: $CURRENT_SLOT" + echo "GLOAS activation: epoch $GLOAS_FORK_EPOCH (slot $ACTIVATION_SLOT)" + echo "Pre-fork deposit: slot $PREFORK_SLOT (latest safe: $LATEST_DEPOSIT_SLOT)" + echo "Onboard check: slot $ONBOARD_SLOT" + echo "Active wait epoch: $ACTIVE_EPOCH" + + echo "::set-output-json preforkSlot $PREFORK_SLOT" + echo "::set-output-json onboardSlot $ONBOARD_SLOT" + echo "::set-output-json activeEpoch $ACTIVE_EPOCH" + +# prepare wallet and mnemonic +- name: generate_child_wallet + id: test_wallet + title: "Generate wallet for builder deposits" + config: + walletSeed: builder-prefork-onboard-test + prefundMinBalance: 100000000000000000000 # 100 ETH + configVars: + privateKey: walletPrivkey + +- name: get_random_mnemonic + id: test_mnemonic + title: "Generate fallback mnemonic for builders" + +- name: get_pubkeys_from_mnemonic + id: builder_pubkeys + title: "Get pubkeys for all builder keys" + configVars: + # Use the configured builderMnemonic if set, else the random fallback. + mnemonic: '| if .builderMnemonic != "" then .builderMnemonic else .tasks.test_mnemonic.outputs.mnemonic end' + # Derive enough keys to index from builderKeyIndex. + count: "| .builderKeyIndex + .builderCount" + +## +## PHASE 1: PRE-FORK deposit via the STANDARD validator deposit contract +## Wait into the unfinalized window (~1 epoch before the fork), then deposit +## builderCount keys with 0x03 credentials so they are still pending (and +## unfinalized, hence not yet drained into the validator registry) at the fork. +## + +- name: check_consensus_slot_range + title: "Wait for pre-fork deposit slot (${{ tasks.calc_slots.outputs.preforkSlot }})" + timeout: 1h + configVars: + minSlotNumber: "tasks.calc_slots.outputs.preforkSlot" + +- name: generate_deposits + id: prefork_deposits + title: "Deposit ${builderCount} builders via STANDARD contract (0x03 creds)" + config: + awaitReceipt: true + failOnReject: true + configVars: + limitTotal: "builderCount" + indexCount: "builderCount" + startIndex: "builderKeyIndex" + depositAmount: "depositAmount" + walletPrivkey: "tasks.test_wallet.outputs.childWallet.privkey" + mnemonic: '| if .builderMnemonic != "" then .builderMnemonic else .tasks.test_mnemonic.outputs.mnemonic end' + depositContract: "depositContract" + # 0x03 builder credentials: version byte + 11 zero bytes + 20-byte address + withdrawalCredentials: '| "0x03" + ("00" * 11) + (.tasks.test_wallet.outputs.childWallet.address | ltrimstr("0x"))' + +## +## PHASE 2: Verify the fork DEQUEUED the pending deposits into builders +## Right after the first GLOAS block, each pubkey must now exist as a builder. +## + +- name: check_consensus_slot_range + title: "Wait for first GLOAS block (slot ${{ tasks.calc_slots.outputs.onboardSlot }})" + timeout: 1h + configVars: + minSlotNumber: "tasks.calc_slots.outputs.onboardSlot" + +- name: run_task_matrix + title: "Verify all ${builderCount} pending deposits were onboarded as builders" + timeout: 30m + configVars: + matrixValues: "| .tasks.builder_pubkeys.outputs.pubkeys[(.builderKeyIndex):(.builderKeyIndex + .builderCount)]" + config: + runConcurrent: true + matrixVar: "builderPubkey" + task: + name: check_consensus_builder_status + title: "Verify ${builderPubkey} was onboarded as a builder" + # Existence-only check. Do NOT use expectActive here: a deposit onboarded + # AT the fork has deposit_epoch == GLOAS_FORK_EPOCH, which cannot be + # finalized yet, and expectActive requires deposit_epoch < finalized_epoch. + # Poll (no failOnCheckMiss) so we wait for the builder to appear in the + # builder set instead of failing on a single too-early read. + timeout: 10m + configVars: + builderPubKey: "builderPubkey" + +## +## PHASE 3: Verify the onboarded builders go active +## + +- name: check_consensus_slot_range + title: "Wait until builders should be active (epoch ${{ tasks.calc_slots.outputs.activeEpoch }})" + timeout: 1h + configVars: + minEpochNumber: "tasks.calc_slots.outputs.activeEpoch" + +- name: run_task_matrix + title: "Verify all ${builderCount} onboarded builders are active" + timeout: 30m + configVars: + matrixValues: "| .tasks.builder_pubkeys.outputs.pubkeys[(.builderKeyIndex):(.builderKeyIndex + .builderCount)]" + config: + runConcurrent: true + matrixVar: "builderPubkey" + task: + name: check_consensus_builder_status + title: "Verify builder ${builderPubkey} is active" + # Poll until the builder is active (deposit finalized + withdrawable_epoch + # == FAR_FUTURE) rather than asserting once: finalization timing varies, so + # let it converge within the timeout instead of failing on an early read. + timeout: 20m + config: + expectActive: true + configVars: + builderPubKey: "builderPubkey" diff --git a/playbooks/gloas-dev/builder-prefork-queuefill.yaml b/playbooks/gloas-dev/builder-prefork-queuefill.yaml new file mode 100644 index 00000000..fb12d11c --- /dev/null +++ b/playbooks/gloas-dev/builder-prefork-queuefill.yaml @@ -0,0 +1,251 @@ +id: builder-prefork-queuefill +name: "GLOAS: Pre-fork builder active AT the fork (queue-fill trick)" +description: | + Tests whether a builder can be ACTIVE in the very first gloas epoch (epoch F, + slot 0) instead of the usual F+1/F+2 floor, using the deposit-queue-position + trick. + + Why F slot-0 is normally impossible (per gloas spec v1.7.0-alpha.11): + - is_active_builder requires `builder.deposit_epoch < finalized_checkpoint.epoch`. + - onboard_builders_from_pending_deposits only onboards deposits that are still + in state.pending_deposits at the fork boundary. + - process_pending_deposits drains only FINALIZED deposits, in order, capped at + MAX_PENDING_DEPOSITS_PER_EPOCH (= 16) per epoch. + So to be active at slot 0 of epoch F, the builder's deposit must be BOTH + finalized (deposit_epoch <= F-3) AND still in the pending queue at the fork. A + finalized deposit on an empty queue gets drained into the validator registry, so + the only way to keep it pending is to put >= 16 finalized deposits AHEAD of it so + the per-epoch drain (16) can't reach it at the F-1 -> F boundary. + + Construction: + 1. In epoch F-3, deposit `junkCount` (>= 16) regular 0x01 deposits of 1 ETH as + a "wall" at the front of the pending-deposit queue. + 2. Right after (later slot, same epoch F-3), deposit the 0x03 builder deposit, + so it sits BEHIND the wall, at queue index >= junkCount. + 3. At the F-1 -> F boundary, process_pending_deposits finalizes everything, + drains the first 16 (wall), hits the count cap, and stops BEFORE the builder + deposit. upgrade_to_gloas then onboards the still-pending builder deposit + with deposit_epoch = F-3. + 4. In epoch F, finalized ~ F-2 > F-3 = deposit_epoch, so is_active_builder is + already true -> the builder can bid for a payload in the first gloas epoch. + + PASS => the queue-fill trick works: builder active in epoch F. + FAIL on the active check => trick did not achieve slot-0 activation (logs show + deposit_epoch vs finalized_epoch; usually means it slipped to F+1). + FAIL on the onboard check => builder deposit was drained into a validator + (wall too small / mistimed) and is now a stuck 0x03 validator. + + Requires being started before epoch F-3 and GLOAS_FORK_EPOCH >= 3. +version: 1.0.0 +tags: [gloas, builder, deposit, onboarding, prefork, queue, eip-8282] +timeout: 1h +config: + walletPrivkey: '' + # Regular validator deposit contract (pre-fork onboarding path). + depositContract: '0x00000000219ab540356cBB839Cbe05303d7705Fa' + builderCount: 1 + # Wall size. Must exceed MAX_PENDING_DEPOSITS_PER_EPOCH (16) so the boundary + # drain can't reach the builder deposit. 20 gives margin. + junkCount: 20 + builderDepositAmount: 5 + junkDepositAmount: 1 + # Optional: deposit for a SPECIFIC builder key (e.g. buildoor's) instead of a + # random one, so an external builder using the same mnemonic+index can bid on + # the onboarded builder. Leave empty to use a freshly generated mnemonic. + builderMnemonic: '' + builderKeyIndex: 0 + +tasks: +- name: check_clients_are_healthy + title: "Check if at least one client is ready" + timeout: 5m + config: + minClientCount: 1 + +- name: get_consensus_specs + id: get_specs + title: "Get consensus chain specs" + +- name: check_consensus_slot_range + id: current_slot + title: "Get current slot" + +- name: run_shell + id: calc_slots + title: "Calculate F-3 deposit window for queue-fill" + config: + envVars: + GLOAS_FORK_EPOCH: "tasks.get_specs.outputs.specs.GLOAS_FORK_EPOCH" + SLOTS_PER_EPOCH: "tasks.get_specs.outputs.specs.SLOTS_PER_EPOCH" + CURRENT_SLOT: "tasks.current_slot.outputs.currentSlot" + command: | + set -e + GLOAS_FORK_EPOCH=$(echo "$GLOAS_FORK_EPOCH" | jq -r .) + SLOTS_PER_EPOCH=$(echo "$SLOTS_PER_EPOCH" | jq -r .) + CURRENT_SLOT=$(echo "$CURRENT_SLOT" | jq -r .) + + if [ "$GLOAS_FORK_EPOCH" -lt 3 ]; then + echo "ERROR: need GLOAS_FORK_EPOCH >= 3 to deposit at epoch F-3 (got $GLOAS_FORK_EPOCH)." + exit 1 + fi + + ACTIVATION_SLOT=$((GLOAS_FORK_EPOCH * SLOTS_PER_EPOCH)) + + # Deposit in epoch F-3 so deposit_epoch = F-3 < finalized@fork (~F-2), the + # condition is_active_builder needs. Land a slot into F-3 (not the very + # first slot) so it isn't finalized at the F-2 boundary prematurely. + STUFF_EPOCH=$((GLOAS_FORK_EPOCH - 3)) + STUFF_SLOT=$((STUFF_EPOCH * SLOTS_PER_EPOCH + 1)) + + # All deposits must land while still in epoch F-3; keep ~2 slots headroom. + LATEST_DEPOSIT_SLOT=$(((GLOAS_FORK_EPOCH - 2) * SLOTS_PER_EPOCH - 2)) + if [ "$CURRENT_SLOT" -ge "$LATEST_DEPOSIT_SLOT" ]; then + echo "ERROR: too late for the F-3 queue-fill window." + echo " current slot: $CURRENT_SLOT" + echo " latest deposit slot: $LATEST_DEPOSIT_SLOT (epoch F-3)" + echo " GLOAS activation: slot $ACTIVATION_SLOT (epoch $GLOAS_FORK_EPOCH)" + exit 1 + fi + + # A couple slots into epoch F: builder should already be active here. + FORK_CHECK_SLOT=$((ACTIVATION_SLOT + 2)) + + echo "Current slot: $CURRENT_SLOT" + echo "GLOAS activation: epoch $GLOAS_FORK_EPOCH (slot $ACTIVATION_SLOT)" + echo "Queue-fill deposits: slot $STUFF_SLOT (epoch $STUFF_EPOCH = F-3)" + echo "Active-at-fork check: slot $FORK_CHECK_SLOT (epoch $GLOAS_FORK_EPOCH = F)" + + echo "::set-output-json stuffSlot $STUFF_SLOT" + echo "::set-output-json forkCheckSlot $FORK_CHECK_SLOT" + echo "::set-output-json forkEpoch $GLOAS_FORK_EPOCH" + +# prepare wallet and a single mnemonic (junk = first junkCount indices, +# builder = the next builderCount indices) +- name: generate_child_wallet + id: test_wallet + title: "Generate wallet for deposits" + config: + walletSeed: builder-prefork-queuefill-test + prefundMinBalance: 100000000000000000000 # 100 ETH + configVars: + privateKey: walletPrivkey + +- name: get_random_mnemonic + id: test_mnemonic + title: "Generate junk-wall mnemonic" + +- name: get_random_mnemonic + id: builder_mnemonic_rand + title: "Generate fallback builder mnemonic" + +- name: get_pubkeys_from_mnemonic + id: builder_pubkeys + title: "Get builder pubkey(s)" + configVars: + # Builder uses a SEPARATE mnemonic from the junk wall (no index collision). + # Use the configured builderMnemonic (e.g. buildoor's) if set, else a random one. + mnemonic: '| if .builderMnemonic != "" then .builderMnemonic else .tasks.builder_mnemonic_rand.outputs.mnemonic end' + count: "| .builderKeyIndex + .builderCount" + +## +## PHASE 1: at epoch F-3, fill the front of the pending-deposit queue with junk +## + +- name: check_consensus_slot_range + title: "Wait for F-3 deposit slot (${{ tasks.calc_slots.outputs.stuffSlot }})" + timeout: 1h + configVars: + minSlotNumber: "tasks.calc_slots.outputs.stuffSlot" + +- name: generate_deposits + id: junk_wall + title: "Queue-fill: ${junkCount}x 1 ETH regular (0x01) deposits" + config: + awaitReceipt: true + awaitInclusion: true + failOnReject: true + configVars: + limitTotal: "junkCount" + limitPerSlot: "junkCount" + indexCount: "junkCount" + startIndex: "| 0" + depositAmount: "junkDepositAmount" + walletPrivkey: "tasks.test_wallet.outputs.childWallet.privkey" + mnemonic: "tasks.test_mnemonic.outputs.mnemonic" + depositContract: "depositContract" + # 0x01 eth1 withdrawal credentials -> regular validators. + withdrawalCredentials: '| "0x01" + ("00" * 11) + (.tasks.test_wallet.outputs.childWallet.address | ltrimstr("0x"))' + +## +## PHASE 2: behind the wall (later slot, still epoch F-3), the builder deposit +## + +- name: generate_deposits + id: builder_deposit + title: "Builder deposit (0x03) BEHIND the wall (index >= ${junkCount})" + config: + awaitReceipt: true + awaitInclusion: true + failOnReject: true + configVars: + limitTotal: "builderCount" + limitPerSlot: "builderCount" + indexCount: "builderCount" + startIndex: "builderKeyIndex" + depositAmount: "builderDepositAmount" + walletPrivkey: "tasks.test_wallet.outputs.childWallet.privkey" + mnemonic: '| if .builderMnemonic != "" then .builderMnemonic else .tasks.builder_mnemonic_rand.outputs.mnemonic end' + depositContract: "depositContract" + # 0x03 builder credentials. + withdrawalCredentials: '| "0x03" + ("00" * 11) + (.tasks.test_wallet.outputs.childWallet.address | ltrimstr("0x"))' + +## +## PHASE 3: at the first gloas block, the builder deposit must have been onboarded +## (i.e. it survived the boundary drain instead of becoming a stuck validator) +## + +- name: check_consensus_slot_range + title: "Wait for first GLOAS block (slot ${{ tasks.calc_slots.outputs.forkCheckSlot }})" + timeout: 1h + configVars: + minSlotNumber: "tasks.calc_slots.outputs.forkCheckSlot" + +- name: run_task_matrix + title: "Verify builder survived the wall and was onboarded" + timeout: 20m + configVars: + matrixValues: "| .tasks.builder_pubkeys.outputs.pubkeys[(.builderKeyIndex):(.builderKeyIndex + .builderCount)]" + config: + runConcurrent: true + matrixVar: "builderPubkey" + task: + name: check_consensus_builder_status + title: "Verify ${builderPubkey} onboarded as a builder" + timeout: 10m + configVars: + builderPubKey: "builderPubkey" + +## +## PHASE 4: THE EXPERIMENT — builder must be ACTIVE during the first gloas epoch +## expectActive requires deposit_epoch < finalized; with the F-3 deposit that +## should already hold at epoch F. failOnCheckMiss => hard pass/fail of the +## "active at the fork" hypothesis. On miss, the task log prints deposit_epoch +## vs finalized_epoch. +## + +- name: run_task_matrix + title: "EXPERIMENT: builder active IN epoch F (${{ tasks.calc_slots.outputs.forkEpoch }})?" + timeout: 10m + configVars: + matrixValues: "| .tasks.builder_pubkeys.outputs.pubkeys[(.builderKeyIndex):(.builderKeyIndex + .builderCount)]" + config: + runConcurrent: true + matrixVar: "builderPubkey" + task: + name: check_consensus_builder_status + title: "Verify builder ${builderPubkey} is active in the first gloas epoch" + config: + expectActive: true + failOnCheckMiss: true + configVars: + builderPubKey: "builderPubkey"