From d2c50b48d054ab315e77df7d69600ee8eeda7097 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 26 May 2026 12:05:58 -0400 Subject: [PATCH 01/12] Add EIP: Builder Deposit Contract Draft EIP for a builder-specific deposit predeploy that verifies BLS proof-of-possession signatures on chain via the EIP-2537 precompiles, serving the EIP-7732 builder population. A separate top_up entrypoint adds unverified stake to an already-registered builder. --- EIPS/eip-draft_builder_deposit.md | 139 +++++ assets/eip-draft_builder_deposit/.gitignore | 5 + assets/eip-draft_builder_deposit/README.md | 67 +++ .../builder_deposit_contract.sol | 552 ++++++++++++++++++ assets/eip-draft_builder_deposit/foundry.toml | 21 + .../eip-draft_builder_deposit/gen_vectors.py | 168 ++++++ .../test/BuilderDeposit.t.sol | 298 ++++++++++ .../test/TestHarness.sol | 28 + .../test/Vectors.sol | 46 ++ 9 files changed, 1324 insertions(+) create mode 100644 EIPS/eip-draft_builder_deposit.md create mode 100644 assets/eip-draft_builder_deposit/.gitignore create mode 100644 assets/eip-draft_builder_deposit/README.md create mode 100644 assets/eip-draft_builder_deposit/builder_deposit_contract.sol create mode 100644 assets/eip-draft_builder_deposit/foundry.toml create mode 100644 assets/eip-draft_builder_deposit/gen_vectors.py create mode 100644 assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol create mode 100644 assets/eip-draft_builder_deposit/test/TestHarness.sol create mode 100644 assets/eip-draft_builder_deposit/test/Vectors.sol diff --git a/EIPS/eip-draft_builder_deposit.md b/EIPS/eip-draft_builder_deposit.md new file mode 100644 index 00000000000000..29eb1ddd1d23f0 --- /dev/null +++ b/EIPS/eip-draft_builder_deposit.md @@ -0,0 +1,139 @@ +--- +title: Builder Deposit Contract +description: Predeploy a BLS-verifying builder deposit contract using EIP-2537 precompiles, for EIP-7732 builders +author: Cayman (@wemeetagain) +discussions-to: +status: Draft +type: Standards Track +category: Core +created: 2026-05-22 +requires: 2537, 7732 +--- + +## Abstract + +Predeploy a builder deposit contract at a fixed address. The contract exposes two entrypoints: + +- `deposit(...)`, which verifies a BLS proof-of-possession signature against the supplied `DepositMessage` using the [EIP-2537](./eip-2537.md) precompiles before emitting a deposit log; and +- `top_up(...)`, which accepts an additional deposit for an existing builder without on-chain signature verification. + +This contract is independent of the existing validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa` and serves the [EIP-7732](./eip-7732.md) builder population only. + +## Motivation + +The deployed validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa` does not verify BLS signatures on chain. The consensus layer instead verifies the proof-of-possession of a `pubkey` on its **first** appearance — subsequent top-ups to the same `pubkey` are accepted without any further signature check, and in practice top-ups are submitted with all-zero signatures. Two consequences follow: + +1. The existing contract is an immutable two-mode API: a "first deposit" must carry a valid signature, while every "top-up" intentionally omits one. Replacing its runtime with a signature-checking variant would break the top-up path and reject all-zero signatures that are in use today. +2. The signature-verification cost for new validators is borne entirely by the consensus layer. An adversary that can submit arbitrarily many invalid deposits forces every beacon node to pay the verification cost for each one. Mainnet absorbs this today only because the 32-ETH minimum validator deposit makes the per-attempt cost expensive. + +[EIP-7732](./eip-7732.md) introduces builders as a separate consensus-layer class with a substantially lower deposit threshold (as little as 1 ETH per builder). Naively reusing the existing deposit contract for builders would amplify the consensus-side DoS surface in proportion to how much cheaper a builder deposit is, while preserving the existing top-up loophole. + +This EIP introduces a **separate** deposit contract dedicated to the EIP-7732 builder population. It: + +- Verifies the BLS proof-of-possession on chain using the [EIP-2537](./eip-2537.md) precompiles, so the consensus layer can skip the per-deposit pairing cost; and +- Gas-meters the verification, so the cost of presenting a candidate (valid or invalid) is charged to the depositor's transaction. DoS resistance falls out of the existing gas-pricing rules instead of needing dedicated consensus-side throttling. + +The existing validator deposit contract is untouched, preserving its first-deposit-plus-unsigned-top-up semantics for the existing 32-ETH validator population. + +## Specification + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). + +### Constants + +| Name | Value | Comment | +| --- | --- | --- | +| `BUILDER_DEPOSIT_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007732` | Predeploy address of the builder deposit contract (placeholder; final value assigned alongside the EIP number) | +| `DOMAIN_BUILDER_DEPOSIT` | `0x0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9` | Signing domain for builder deposit messages. The `0x0b000000` domain type is a placeholder pending consensus-specs allocation; it MUST differ from the validator `DOMAIN_DEPOSIT` (`0x03000000…`) so signatures are not interchangeable between the two contracts | +| `BLS12_G2ADD` | `0x0d` | [EIP-2537](./eip-2537.md) precompile address | +| `BLS12_PAIRING_CHECK` | `0x0f` | [EIP-2537](./eip-2537.md) precompile address | +| `BLS12_MAP_FP2_TO_G2` | `0x11` | [EIP-2537](./eip-2537.md) precompile address | +| `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder deposit contract | + +### Fork transition + +At the start of processing the first block where this EIP is active, before processing transactions, execution clients MUST install `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` at `BUILDER_DEPOSIT_CONTRACT_ADDRESS` if the account at that address is empty (zero `nonce`, empty `code`, empty `storage`, zero `balance`). The installation MUST set `code = BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE`, `nonce = 1`, `balance = 0`, and leave `storage` empty. + +If the account at `BUILDER_DEPOSIT_CONTRACT_ADDRESS` is not empty at fork time, clients MUST abort initialisation. This matches the predeploy pattern used by [EIP-2935](./eip-2935.md), [EIP-4788](./eip-4788.md), [EIP-7002](./eip-7002.md), and [EIP-7251](./eip-7251.md). + +### Builder deposit contract behavior + +The contract exposes two external entrypoints. Both MUST emit a log record at `BUILDER_DEPOSIT_CONTRACT_ADDRESS` that the consensus layer can extract and process as a builder deposit operation. + +#### Verified entrypoint + +``` +deposit( + bytes pubkey, // 48-byte compressed G1 (X with sign+infinity flags) + bytes withdrawal_credentials, // 32-byte commitment + bytes signature, // 96-byte compressed G2 (X with sign+infinity flags) + Fp pubkey_y, // affine Y of pubkey, in EIP-2537 encoding + Fp2 signature_y // affine Y of signature, in EIP-2537 encoding +) payable +``` + +`deposit(...)` MUST perform the following, in order, before emitting any log: + +1. Validate input lengths and the deposit amount. +2. Reject `pubkey` or `signature` whose infinity flag is set. +3. Verify that the supplied `pubkey_y` and `signature_y` agree with the sign flag of the corresponding compressed encoding (i.e. `sign(pubkey_y)` equals the sign bit of `pubkey`, and likewise for the signature). This binds the point used in the pairing check to the encoding that is emitted and that the consensus layer decompresses; without it the verified point could be the negation of the registered point. +4. Compute the signing root + `compute_signing_root(DepositMessage(pubkey, withdrawal_credentials, amount), DOMAIN_BUILDER_DEPOSIT)`. +5. Verify the BLS proof-of-possession via the [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile, using the supplied affine `Y` coordinates to construct the G1 and G2 points. +6. Revert the entire call if the pairing check fails. + +On success, `deposit(...)` MUST emit a `BuilderDepositEvent` carrying `pubkey`, `withdrawal_credentials`, `amount`, `signature`, and the contract's monotonic deposit index. + +#### Unverified top-up entrypoint + +``` +top_up( + bytes pubkey // 48-byte compressed G1 of an existing builder +) payable +``` + +`top_up(...)` MUST perform the length and amount checks but MUST NOT perform any signature verification. On success it MUST emit a `BuilderTopUpEvent` carrying `pubkey`, `amount`, and the contract's monotonic deposit index. + +`top_up(...)` deliberately takes no `withdrawal_credentials`. A top-up only adds stake to an already-registered builder; the credentials are fixed by that builder's verified `BuilderDepositEvent`. Omitting the field denies an unauthenticated caller any influence over a builder's withdrawal target. The consensus layer is responsible for rejecting `BuilderTopUpEvent` records that target a `pubkey` not already registered as an EIP-7732 builder. + +## Rationale + +- **A separate contract, not a replacement.** The deployed validator contract has an immutable two-mode API. Replacing its runtime would either break the all-zero-signature top-up flow that mainnet uses today, or would require keeping an unverified entrypoint in the spec — bringing the same DoS surface forward. A separate contract lets the existing validator semantics stay fixed. + +- **Y coordinates supplied by the caller.** On-chain decompression of a compressed G1 or G2 point requires an Fp or Fp2 square root, which in turn requires several thousand bytes of runtime code and an order-of-magnitude more gas than the pairing check itself. Because builders already work with affine BLS points in their off-chain infrastructure, requiring the Y coordinates as call data shrinks the canonical bytecode considerably and removes the Fp-arithmetic and Sarkar/Adj sqrt code from the audit surface. + +- **Caller-supplied Y is bound to the compressed sign bit.** The contract requires `sign(pubkey_y)` to equal the sign flag of the compressed `pubkey` (and likewise for the signature). The pairing check alone does NOT make this redundant: because a depositor jointly chooses the key, the emitted sign bit, and the signature, they can verify a point `(X, +Y)` while the emitted bytes decompress to `(X, −Y)`, keeping the pairing self-consistent but causing the consensus layer to register a point whose proof-of-possession was never actually verified. Binding the sign bit closes this gap with a single field comparison and short-circuits before any pairing work. + +- **Two entrypoints rather than a single overloaded one.** Keeping `top_up(...)` separate makes the consensus-layer log handling unambiguous: a `BuilderDepositEvent` is the first sighting of a builder pubkey and is accompanied by an execution-layer-verified signature; a `BuilderTopUpEvent` is an increment against an already-registered builder and carries no signature. + +- **Gas-metered verification as the DoS gate.** Verification cost (`BLS12_PAIRING_CHECK` + `BLS12_MAP_FP2_TO_G2` + supporting work) is paid by the depositor's transaction. Submitting an invalid signature therefore costs the same as submitting a valid one; there is no asymmetric drain on the consensus layer. + +- **Distinct signing domain (`DOMAIN_BUILDER_DEPOSIT`).** Builder deposit signatures use a domain type distinct from the validator deposit domain. This is a deliberate departure from "reuse existing signing tooling unchanged": sharing `DOMAIN_DEPOSIT` and the identical `DepositMessage` structure would make a proof-of-possession byte-for-byte interchangeable between this contract and the validator deposit contract, letting a public validator-deposit signature be replayed here to force-enrol a validator pubkey as a builder (and vice versa). Domain separation removes that cross-context replay in both directions; signing tooling needs only a one-constant domain change. + +## Backwards Compatibility + +This EIP is additive at the execution layer: it introduces a new contract at a previously empty address. It does not modify the validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`, does not change the `DepositEvent` layout that contract emits, and does not affect any existing validator's ability to make first deposits or top-ups. + +At the consensus layer, EIP-7732 builders MUST be sourced from `BuilderDepositEvent` and `BuilderTopUpEvent` logs at `BUILDER_DEPOSIT_CONTRACT_ADDRESS`; the validator deposit contract continues to be the sole source of validator deposits. + +## Test Cases + +A Foundry test suite under `../assets/eip-draft_builder_deposit/test/` cross-verifies the contract against `py_ecc` (the canonical Eth2 Python reference). Coverage includes the SSZ signing-root computation, an end-to-end `deposit(...)` round-trip against a `py_ecc.bls.G2ProofOfPossession.Sign`-produced signature, the `top_up(...)` happy path, the monotonic deposit-index invariant, and the input-shape and tampering rejection paths. + +## Reference Implementation + +Solidity source for the proposed runtime is published at [`../assets/eip-draft_builder_deposit/builder_deposit_contract.sol`](../assets/eip-draft_builder_deposit/builder_deposit_contract.sol), with the test harness, fixture generator, and Foundry configuration alongside it. The compiled, optimised runtime bytecode of the current draft is approximately 7.6 KiB — well within the [EIP-170](./eip-170.md) 24 KiB limit, with no on-chain field-arithmetic kernel or decompression path. The final `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` and `BUILDER_DEPOSIT_CONTRACT_ADDRESS` will be locked in once the contract has been independently audited. + +## Security Considerations + +- **Signing-domain separation.** `DOMAIN_BUILDER_DEPOSIT` MUST differ from the validator `DOMAIN_DEPOSIT`. Because both contracts use the identical `DepositMessage` SSZ structure, a shared domain would make proof-of-possession signatures byte-for-byte interchangeable, allowing a public validator-deposit signature to be replayed into this contract (force-enrolling a validator pubkey as a builder) and vice versa. The distinct domain type closes this in both directions. +- **Sign-bit binding.** The supplied affine `Y` MUST agree with the sign flag of the compressed `pubkey`/`signature`. Without this binding, a depositor controlling the key could pass the pairing check on a point `(X, +Y)` while emitting bytes that the consensus layer decompresses to `(X, −Y)`, so the registered key's proof-of-possession would never have been verified — and any CL client that defensively re-verified the emitted `(pubkey, signature)` would reject a deposit other clients accept, risking a builder-set split. +- **Top-up validity at CL.** The contract emits `BuilderTopUpEvent` without checking that the target `pubkey` exists. The consensus layer MUST reject top-ups against unregistered builders so that all-zero or junk top-ups cannot register new builders without a verified deposit. `top_up(...)` carries no `withdrawal_credentials`, so an unauthenticated caller cannot rewrite an existing builder's withdrawal target. +- **DoS surface.** Verification cost is gas-metered and paid by the depositor; an adversary cannot force consensus-layer pairing work without first paying the corresponding execution-layer gas. Per [EIP-2537](./eip-2537.md) §"Gas burning on error", a precompile that rejects a malformed (off-curve or out-of-subgroup) point burns all gas forwarded to it, so the contract MUST NOT forward `gas()` to the precompiles. Because EIP-2537 pricing is deterministic (a pure function of input length), the contract forwards a fixed gas ceiling to each precompile `staticcall` — set per call at roughly 2.5x the documented cost — which bounds the worst-case burn on a malformed input to that ceiling instead of the whole transaction, while leaving ample headroom for a future reprice. The ceilings MUST be revisited if [EIP-2537](./eip-2537.md) pricing changes. +- **Subgroup membership.** The [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile performs G1 and G2 subgroup checks; the contract does not need to re-implement them. +- **Compressed-point flags.** The contract must reject infinity-flagged inputs to prevent acceptance of the identity element as a `pubkey` or `signature`. +- **Validator-contract co-existence.** The validator deposit contract is unmodified; nothing in this EIP changes the existing 32-ETH validator deposit semantics. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/assets/eip-draft_builder_deposit/.gitignore b/assets/eip-draft_builder_deposit/.gitignore new file mode 100644 index 00000000000000..e21f82cc0be8ba --- /dev/null +++ b/assets/eip-draft_builder_deposit/.gitignore @@ -0,0 +1,5 @@ +out/ +cache/ +venv/ +*.pyc +__pycache__/ diff --git a/assets/eip-draft_builder_deposit/README.md b/assets/eip-draft_builder_deposit/README.md new file mode 100644 index 00000000000000..eec7e177b12e09 --- /dev/null +++ b/assets/eip-draft_builder_deposit/README.md @@ -0,0 +1,67 @@ +# EIP-XXXX: Builder Deposit Contract — Assets + +Reference Solidity for the proposal, plus cross-verification tests. + +## Files + +| File | Purpose | +| --- | --- | +| `builder_deposit_contract.sol` | The proposed predeploy. Two entrypoints: `deposit(...)` (BLS-verified, requires affine Y coordinates) and `top_up(...)` (unverified, CL re-validates). | +| `gen_vectors.py` | Python script that uses `py_ecc` (the canonical Eth2 reference) to produce cross-verification test vectors. | +| `test/Vectors.sol` | Auto-generated Solidity library of test vectors. Regenerate by running `gen_vectors.py`. | +| `test/TestHarness.sol` | Thin wrapper that inherits `BuilderDepositContract` and exposes its `internal` SSZ signing-root helper + the `deposit_count` storage slot, for use by the tests. | +| `test/BuilderDeposit.t.sol` | Foundry tests. | +| `foundry.toml` | Foundry configuration (solc `0.6.11`, EVM `prague` by default). | + +## Running the tests + +Prerequisites: + +```bash +# Foundry (forge / cast / anvil) +curl -L https://foundry.paradigm.xyz | bash && foundryup + +# Python with py_ecc for regenerating vectors +python3 -m venv venv && ./venv/bin/pip install py_ecc +``` + +Run the test suite: + +```bash +forge test -vv +``` + +`evm_version = "prague"` in `foundry.toml` enables the EIP-2537 BLS precompiles, required for the deposit-verification path. To run only the input-shape and signing-root tests on an older EVM (no EIP-2537 needed): + +```bash +forge test -vv --evm-version cancun --no-match-test 'Deposit(Valid|Rejects(Tampered|Infinity))' +``` + +## Regenerating vectors + +```bash +./venv/bin/python gen_vectors.py > test/Vectors.sol +``` + +The script is deterministic: the secret key is hard-coded so the output is byte-stable across runs. `py_ecc.bls.G2ProofOfPossession.Sign` (the Eth2 ciphersuite) produces the deposit signature; `py_ecc.optimized_bls12_381.normalize` extracts the canonical affine (X, Y) coordinates. + +## Test coverage + +| Test | What it cross-verifies | EIP-2537 required? | +| --- | --- | --- | +| `testComputeSigningRoot` | `_computeDepositSigningRoot` matches `py_ecc`-derived SSZ `compute_signing_root` | no | +| `testDepositValid` | A `py_ecc.G2ProofOfPossession.Sign`-produced signature is accepted; `deposit_count` increments | **yes** | +| `testTopUpValid` | `top_up(...)` accepts a non-signed call; `deposit_count` increments | no | +| `testMonotonicIndex` | Deposit + top_up + top_up increment the counter by 1 each | **yes** (for the deposit step) | +| `testDepositRejectsTamperedAmount` | Sending a different `msg.value` than was signed fails the pairing check | **yes** | +| `testDepositRejectsTamperedSignature` | Flipping a bit in the signature is rejected (subgroup or pairing failure) | **yes** | +| `testDepositRejectsPubkeySignBitFlip` | Flipping only the pubkey sign flag (keeping Y) is rejected by the sign-bit binding — regression for audit Finding 2 | no | +| `testDepositRejectsSignatureSignBitFlip` | Flipping only the signature sign flag (keeping Y) is rejected by the sign-bit binding | no | +| `testDepositRejectsInfinityPubkey` | `pubkey` with infinity flag is rejected before BLS work | no | +| `testDepositRejectsInfinitySignature` | `signature` with infinity flag is rejected before BLS work | no | +| `testDepositRejectsTooSmallAmount` | `msg.value < 1 ether` is rejected | no | +| `testDepositRejectsNonGweiAmount` | `msg.value` not aligned to 1 gwei is rejected | no | +| `testDepositRejectsWrongPubkeyLength` | `pubkey.length != 48` is rejected | no | +| `testDepositRejectsWrongSignatureLength` | `signature.length != 96` is rejected | no | +| `testTopUpRejectsTooSmallAmount` | `top_up` with `msg.value < 1 ether` is rejected | no | +| `testTopUpRejectsWrongPubkeyLength` | `top_up` with `pubkey.length != 48` is rejected | no | diff --git a/assets/eip-draft_builder_deposit/builder_deposit_contract.sol b/assets/eip-draft_builder_deposit/builder_deposit_contract.sol new file mode 100644 index 00000000000000..2c472c0c7eab36 --- /dev/null +++ b/assets/eip-draft_builder_deposit/builder_deposit_contract.sol @@ -0,0 +1,552 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.6.11; +pragma experimental ABIEncoderV2; // for `Fp` / `Fp2` struct calldata in `deposit` + +// ─────────────────────────────────────────────────────────────────────────────── +// EIP-XXXX: Builder Deposit Contract +// +// Predeploy installed at BUILDER_DEPOSIT_CONTRACT_ADDRESS. Exposes: +// +// * deposit(pubkey, wc, signature, pubkey_y, signature_y) — BLS proof-of- +// possession verified on chain via the EIP-2537 precompiles. Emits +// `BuilderDepositEvent`. The consensus layer trusts the EL pairing check +// and does not re-verify. +// +// * top_up(pubkey, wc) — unverified additional deposit for an already- +// registered builder. Emits `BuilderTopUpEvent`. The consensus layer +// rejects top-ups whose `pubkey` is not in the builder set. +// +// Storage is intentionally minimal — a single monotonic `deposit_count`. The +// consensus layer extracts builder deposits from the event log, not from a +// Merkle tree on chain (so no `get_deposit_root` / `get_deposit_count` view +// functions, unlike the validator deposit contract). +// +// Algorithms used: +// * Signing root — SSZ `hash_tree_root` of `DepositMessage` mixed with +// `DOMAIN_BUILDER_DEPOSIT` per `compute_signing_root`. +// * Hash-to-curve — `expand_message_xmd` + SSWU/3-isogeny via EIP-2537 +// `MAP_FP2_TO_G2`, per IETF RFC 9380. +// * Pairing check — Negation trick: verify e(-G1, σ) · e(pk, H(m)) == 1 +// via EIP-2537 `PAIRING_CHECK` (subgroup-checked). +// * Fp reduction — `MODEXP` precompile (0x05) with exponent 1. +// +// Design notes (vs. the validator deposit contract): +// * No on-chain G1 / G2 decompression. Callers MUST supply affine Y +// coordinates. This removes the Fp / Fp2 arithmetic kernel and the +// Sarkar/Adj Fp2-sqrt routine from the canonical bytecode. +// * No "Y matches sign bit" check. A caller supplying ±Y substitutes ±pk +// (or ±σ) into the pairing equation, which then fails; the pairing check +// is the sole signature-correctness oracle. +// * No `IDepositContract` / ERC-165 inheritance — this is not a drop-in +// replacement for the validator deposit contract; the ABI is fresh. +// ─────────────────────────────────────────────────────────────────────────────── + +contract BuilderDepositContract { + + // ── Constants ────────────────────────────────────────────────────────── + + uint constant PUBLIC_KEY_LENGTH = 48; + uint constant SIGNATURE_LENGTH = 96; + + // EIP-7732 sets the builder minimum stake at 1 ETH. The contract enforces + // the same lower bound at the EL boundary so junk-amount transactions are + // rejected before they reach the consensus layer. + uint constant BUILDER_MIN_DEPOSIT = 1 ether; + + // EIP-2537 precompile addresses. + uint8 constant BLS12_G2ADD = 0x0d; + uint8 constant BLS12_PAIRING_CHECK = 0x0f; + uint8 constant BLS12_MAP_FP2_TO_G2 = 0x11; + // Pre-existing modexp precompile (used for hash_to_field's modular reduction). + uint8 constant MOD_EXP_PRECOMPILE = 0x05; + + // Gas forwarded to each precompile staticcall. Per EIP-2537 §"Gas burning + // on error", an EIP-2537 precompile that rejects its input (malformed + // encoding, off-curve, or wrong-subgroup point) burns ALL gas forwarded to + // the call. Forwarding `gas()` would therefore let a single malformed point + // drain a whole transaction. Because EIP-2537 pricing is deterministic + // (a pure function of input length, no data-dependent loops), we instead + // forward a fixed ceiling per precompile, bounding the worst-case burn on a + // bad input to that ceiling. Each ceiling is ~2.5x the documented cost, a + // margin chosen to tolerate a moderate future reprice while still capping + // the loss far below a full transaction. + // + // precompile documented cost ceiling + // MAP_FP2_TO_G2 23800 60000 + // G2ADD 600 2000 + // PAIRING_CHECK 32600*k + 37700 256000 (k = 2 -> 102900) + // MODEXP (0x05) ~200 for our inputs 5000 (does not burn-all, + // capped for uniformity) + uint constant MAP_FP2_TO_G2_GAS = 60000; + uint constant G2ADD_GAS = 2000; + uint constant PAIRING_CHECK_GAS = 256000; + uint constant MODEXP_GAS = 5000; + + // Canonical Eth2 BLS ciphersuite, used directly as the DST for + // `expand_message_xmd` per IETF draft-irtf-cfrg-bls-signature-04. + string constant BLS_SIG_DST = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; + + // Mask used to clear the three flag bits from the top byte of a compressed + // BLS12-381 point's X coordinate. + bytes1 constant BLS_BYTE_WITHOUT_FLAGS_MASK = bytes1(0x1f); + + // (q - 1) / 2 in the (hi:128, lo:256) Fp packing — the IETF sign-bit + // threshold for a field element: sign(y) == 1 iff y > (q-1)/2. Used to + // bind the caller-supplied affine Y to the compressed sign flag. + uint constant Q_MINUS_1_OVER_2_HI = 0x0d0088f51cbff34d258dd3db21a5d66b; + uint constant Q_MINUS_1_OVER_2_LO = 0xb23ba5c279c2895fb39869507b587b120f55ffff58a9ffffdcff7fffffffd555; + + // Builder-deposit signing domain. Distinct from the validator deposit + // domain (0x03000000…) so a proof-of-possession signature is NOT + // interchangeable between this contract and the validator deposit contract + // at 0x00000000219ab540356cbb839cbe05303d7705fa — without this separation a + // public validator-deposit signature could be replayed here to force-enrol + // a validator pubkey as a builder (and vice versa). + // + // Constructed as compute_domain(DOMAIN_BUILDER_DEPOSIT_TYPE, + // fork_version=GENESIS_FORK_VERSION=0x00000000, genesis_validators_root=0) + // = DOMAIN_BUILDER_DEPOSIT_TYPE || sha256(64 zero bytes)[:28]. + // + // DRAFT NOTE [EIP-XXXX]: the 4-byte domain type 0x0b000000 is a PLACEHOLDER. + // The final value MUST be allocated in consensus-specs and MUST differ from + // DOMAIN_DEPOSIT (0x03000000). + bytes32 constant DOMAIN_BUILDER_DEPOSIT = 0x0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9; + + // ── Structs (EIP-2537 encoding) ──────────────────────────────────────── + + // Fp: a base-field element in the EIP-2537 64-byte encoding, with the 16 + // zero pad-bytes folded into the top of `a`. + struct Fp { uint a; uint b; } + + // Fp2 = a + b·u with u² = -1. + struct Fp2 { Fp a; Fp b; } + + // Point on BLS12-381 over Fp. + struct G1Point { Fp X; Fp Y; } + + // Point on BLS12-381 over Fp2. + struct G2Point { Fp2 X; Fp2 Y; } + + // ── Events ───────────────────────────────────────────────────────────── + + /// @notice Emitted on a verified builder deposit. The CL trusts the EL's + /// pairing check; no second pairing on the consensus side. + event BuilderDepositEvent( + bytes pubkey, // 48 bytes, compressed G1 + bytes32 withdrawal_credentials, + uint64 amount_gwei, + bytes signature, // 96 bytes, compressed G2 + uint64 index + ); + + /// @notice Emitted on an unverified top-up. The CL MUST reject if the + /// pubkey is not already a registered builder. No `withdrawal_credentials` + /// field: a top-up only adds stake to an existing builder, and the CL uses + /// the credentials established by that builder's verified `BuilderDepositEvent`. + /// Omitting the field removes any ability for an unauthenticated caller to + /// influence a builder's withdrawal target. + event BuilderTopUpEvent( + bytes pubkey, + uint64 amount_gwei, + uint64 index + ); + + // ── Storage ──────────────────────────────────────────────────────────── + + // Monotonic deposit index, incremented on every successful `deposit` or + // `top_up`. Used only as the `index` field of the emitted events; no + // on-chain Merkle tree depends on it. + uint64 internal deposit_count; + + // ── External entrypoints ─────────────────────────────────────────────── + + /// @notice BLS-verified builder deposit. + function deposit( + bytes calldata pubkey, + bytes32 withdrawal_credentials, + bytes calldata signature, + Fp calldata pubkey_y, + Fp2 calldata signature_y + ) external payable { + require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderDeposit: invalid pubkey length"); + require(signature.length == SIGNATURE_LENGTH, "BuilderDeposit: invalid signature length"); + require(msg.value >= BUILDER_MIN_DEPOSIT, "BuilderDeposit: deposit value too low"); + require(msg.value % 1 gwei == 0, "BuilderDeposit: deposit value not multiple of gwei"); + uint deposit_amount = msg.value / 1 gwei; + require(deposit_amount <= type(uint64).max, "BuilderDeposit: deposit value too high"); + require(!_isInfinityFlagSet(pubkey[0]), "BuilderDeposit: infinity pubkey"); + require(!_isInfinityFlagSet(signature[0]), "BuilderDeposit: infinity signature"); + + // BLS proof-of-possession check. Performed before any state change or + // log emission so an invalid signature reverts the whole call. + bytes32 signingRoot = _computeDepositSigningRoot( + pubkey, withdrawal_credentials, uint64(deposit_amount) + ); + G1Point memory pk = _constructG1(pubkey, pubkey_y); + G2Point memory sig = _constructG2(signature, signature_y); + G2Point memory msgPoint = _hashToCurve(signingRoot); + require( + _blsPairingCheck(pk, msgPoint, sig), + "BuilderDeposit: invalid BLS signature" + ); + + uint64 idx = deposit_count; + deposit_count = idx + 1; + emit BuilderDepositEvent( + pubkey, withdrawal_credentials, uint64(deposit_amount), signature, idx + ); + } + + /// @notice Unverified top-up for an existing builder. The CL rejects + /// top-ups against unregistered pubkeys. Takes only the `pubkey`: the + /// top-up adds stake to whatever builder is already registered under that + /// key, and cannot set or change its withdrawal credentials. + function top_up( + bytes calldata pubkey + ) external payable { + require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderDeposit: invalid pubkey length"); + require(msg.value >= BUILDER_MIN_DEPOSIT, "BuilderDeposit: deposit value too low"); + require(msg.value % 1 gwei == 0, "BuilderDeposit: deposit value not multiple of gwei"); + uint deposit_amount = msg.value / 1 gwei; + require(deposit_amount <= type(uint64).max, "BuilderDeposit: deposit value too high"); + + uint64 idx = deposit_count; + deposit_count = idx + 1; + emit BuilderTopUpEvent(pubkey, uint64(deposit_amount), idx); + } + + // ── Signing-root computation ─────────────────────────────────────────── + + // Algorithm: SSZ `hash_tree_root` (consensus-specs §SSZ Merkleization) + + // `compute_signing_root` (consensus-specs §Beacon-chain helpers). + // Returns `sha256(hash_tree_root(DepositMessage(...)) || DOMAIN_BUILDER_DEPOSIT)`. + function _computeDepositSigningRoot( + bytes memory pubkey, + bytes32 withdrawal_credentials, + uint64 amount_gwei + ) internal pure returns (bytes32) { + // `pubkey` is 48 bytes; pad to 64 bytes and sha256 to get its SSZ root. + bytes memory paddedPubkey = new bytes(64); + for (uint i = 0; i < PUBLIC_KEY_LENGTH; i++) { + paddedPubkey[i] = pubkey[i]; + } + bytes32 pubkeyRoot = sha256(paddedPubkey); + + // Left subtree of the DepositMessage SSZ Merkle tree: + // sha256(pubkey_root || withdrawal_credentials). + bytes32 leftNode = sha256(abi.encodePacked(pubkeyRoot, withdrawal_credentials)); + + // Right subtree: 64-byte buffer of [amount_gwei_LE(8) || zero(56)], + // hashed under sha256. + bytes memory amountAndZero = new bytes(64); + for (uint i = 0; i < 8; i++) { + amountAndZero[i] = bytes1(uint8(amount_gwei >> (8 * i))); + } + bytes32 rightNode = sha256(amountAndZero); + + bytes32 depositMessageRoot = sha256(abi.encodePacked(leftNode, rightNode)); + return sha256(abi.encodePacked(depositMessageRoot, DOMAIN_BUILDER_DEPOSIT)); + } + + // ── hash_to_curve (BLS12-381 G2) ─────────────────────────────────────── + + // Algorithm: `expand_message_xmd` (RFC 9380 §5.3.1) with SHA-256, producing + // 256 output bytes. Layout matches draft-irtf-cfrg-hash-to-curve-16. + function _expandMessage(bytes32 message) internal pure returns (bytes memory) { + // b0 = sha256(Z_pad(64) || message(32) || lib_str(2)=0x0100 || + // I2OSP(0,1) || DST || DST_len(1)). + // Lengths: 64 + 32 + 2 + 1 + 43 + 1 = 143 bytes. + bytes memory b0Input = new bytes(143); + for (uint i = 0; i < 32; i++) { + b0Input[i + 64] = message[i]; + } + b0Input[96] = 0x01; + for (uint i = 0; i < 43; i++) { + b0Input[i + 99] = bytes(BLS_SIG_DST)[i]; + } + b0Input[142] = bytes1(uint8(43)); + bytes32 b0 = sha256(b0Input); + + // b1..b8: 8 chained sha256 invocations yielding 256 output bytes. + bytes memory output = new bytes(256); + bytes32 chunk = sha256(abi.encodePacked(b0, bytes1(uint8(1)), bytes(BLS_SIG_DST), bytes1(uint8(43)))); + assembly { + mstore(add(output, 0x20), chunk) + } + for (uint i = 2; i < 9; i++) { + bytes32 input; + assembly { + input := xor(b0, mload(add(output, add(0x20, mul(0x20, sub(i, 2)))))) + } + chunk = sha256(abi.encodePacked(input, bytes1(uint8(i)), bytes(BLS_SIG_DST), bytes1(uint8(43)))); + assembly { + mstore(add(output, add(0x20, mul(0x20, sub(i, 1)))), chunk) + } + } + return output; + } + + // Algorithm: `hash_to_field` (RFC 9380 §5.2) producing 2 Fp2 elements by + // reducing 64-byte slices of `expand_message_xmd` output mod q. + function _hashToField(bytes32 message) internal view returns (Fp2[2] memory result) { + bytes memory expanded = _expandMessage(message); + result[0] = Fp2( + _convertSliceToFp(expanded, 0, 64), + _convertSliceToFp(expanded, 64, 128) + ); + result[1] = Fp2( + _convertSliceToFp(expanded, 128, 192), + _convertSliceToFp(expanded, 192, 256) + ); + } + + // Algorithm: `hash_to_curve` (RFC 9380 §3, encode_to_curve for G2): two + // `hash_to_field` outputs each mapped to G2 via SSWU + 3-isogeny (via + // EIP-2537 `MAP_FP2_TO_G2`), summed in G2. + function _hashToCurve(bytes32 message) internal view returns (G2Point memory) { + Fp2[2] memory uvals = _hashToField(message); + G2Point memory p0 = _mapToCurveG2(uvals[0]); + G2Point memory p1 = _mapToCurveG2(uvals[1]); + return _addG2(p0, p1); + } + + // ── Field-arithmetic helper (modexp-based reduction) ─────────────────── + + // Reduce data[start:end] mod q via the MODEXP precompile (exponent 1). + // Returns a 48-byte big-endian result. + function _reduceModulo(bytes memory data, uint start, uint end) internal view returns (bytes memory) { + uint length = end - start; + require(length <= data.length, "BuilderDeposit: slice out of range"); + bytes memory result = new bytes(48); + bool success; + assembly { + let p := mload(0x40) + mstore(p, length) // length of base + mstore(add(p, 0x20), 0x20) // length of exponent + mstore(add(p, 0x40), 48) // length of modulus + // base + let ctr := length + let src := add(add(data, 0x20), start) + let dst := add(p, 0x60) + for { } or(gt(ctr, 0x20), eq(ctr, 0x20)) { ctr := sub(ctr, 0x20) } { + mstore(dst, mload(src)) + dst := add(dst, 0x20) + src := add(src, 0x20) + } + let mask := sub(exp(256, sub(0x20, ctr)), 1) + let srcpart := and(mload(src), not(mask)) + let dstpart := and(mload(dst), mask) + mstore(dst, or(dstpart, srcpart)) + // exponent: 1 (identity exponent — we only need a mod reduction) + mstore(add(p, add(0x60, length)), 1) + // modulus q (high 16 bytes ORed in, low 32 bytes as a full word) + let modulusAddr := add(p, add(0x60, add(0x10, length))) + mstore(modulusAddr, or(mload(modulusAddr), 0x1a0111ea397fe69a4b1ba7b6434bacd7)) + mstore(add(p, add(0x90, length)), 0x64774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab) + success := staticcall(MODEXP_GAS, MOD_EXP_PRECOMPILE, p, add(0xB0, length), add(result, 0x20), 48) + switch success case 0 { revert(0, 0) } + } + require(success, "BuilderDeposit: modexp failed"); + return result; + } + + function _convertSliceToFp(bytes memory data, uint start, uint end) internal view returns (Fp memory) { + bytes memory fe = _reduceModulo(data, start, end); + return Fp(_sliceToUint(fe, 0, 16), _sliceToUint(fe, 16, 48)); + } + + function _sliceToUint(bytes memory data, uint start, uint end) internal pure returns (uint result) { + uint length = end - start; + require(length <= 32, "BuilderDeposit: bad slice"); + for (uint i = 0; i < length; i++) { + result = result + (uint8(data[start + i]) * (2 ** (8 * (length - i - 1)))); + } + } + + // ── EIP-2537 precompile wrappers ─────────────────────────────────────── + + // Algorithm: simplified SWU map for BLS12-381 G2 (Wahby–Boneh 2019) + // composed with the 3-isogeny back to G2, delegated to the EIP-2537 + // `MAP_FP2_TO_G2` precompile (address 0x11). + function _mapToCurveG2(Fp2 memory fe) internal view returns (G2Point memory) { + uint[4] memory input = [fe.a.a, fe.a.b, fe.b.a, fe.b.b]; + uint[8] memory output; + bool success; + assembly { + success := staticcall(MAP_FP2_TO_G2_GAS, BLS12_MAP_FP2_TO_G2, input, 128, output, 256) + switch success case 0 { revert(0, 0) } + } + require(success, "BuilderDeposit: map_fp2_to_g2 failed"); + return G2Point( + Fp2(Fp(output[0], output[1]), Fp(output[2], output[3])), + Fp2(Fp(output[4], output[5]), Fp(output[6], output[7])) + ); + } + + // Algorithm: BLS12-381 G2 point addition, delegated to EIP-2537 `G2ADD` + // (0x0d). + function _addG2(G2Point memory a, G2Point memory b) internal view returns (G2Point memory) { + uint[16] memory input = [ + a.X.a.a, a.X.a.b, a.X.b.a, a.X.b.b, a.Y.a.a, a.Y.a.b, a.Y.b.a, a.Y.b.b, + b.X.a.a, b.X.a.b, b.X.b.a, b.X.b.b, b.Y.a.a, b.Y.a.b, b.Y.b.a, b.Y.b.b + ]; + uint[8] memory output; + bool success; + assembly { + success := staticcall(G2ADD_GAS, BLS12_G2ADD, input, 512, output, 256) + switch success case 0 { revert(0, 0) } + } + require(success, "BuilderDeposit: g2_add failed"); + return G2Point( + Fp2(Fp(output[0], output[1]), Fp(output[2], output[3])), + Fp2(Fp(output[4], output[5]), Fp(output[6], output[7])) + ); + } + + // Algorithm: BLS verification via the "fixed-(-G1)" pairing identity + // (Boneh–Lynn–Shacham 2001, §3): instead of testing + // e(pk, H(m)) == e(G1, σ), + // we test + // e(-G1, σ) · e(pk, H(m)) == 1, + // which is a single multi-pairing call. Delegated to EIP-2537 + // `PAIRING_CHECK` (0x0f), which internally performs G1 and G2 subgroup + // checks and returns 0/1. + function _blsPairingCheck(G1Point memory pk, G2Point memory msgPoint, G2Point memory sig) + internal + view + returns (bool) + { + uint[24] memory input; + + input[0] = pk.X.a; + input[1] = pk.X.b; + input[2] = pk.Y.a; + input[3] = pk.Y.b; + + input[4] = msgPoint.X.a.a; + input[5] = msgPoint.X.a.b; + input[6] = msgPoint.X.b.a; + input[7] = msgPoint.X.b.b; + input[8] = msgPoint.Y.a.a; + input[9] = msgPoint.Y.a.b; + input[10] = msgPoint.Y.b.a; + input[11] = msgPoint.Y.b.b; + + // -G1 = negation of the BLS12-381 G1 generator, in EIP-2537 encoding. + input[12] = 31827880280837800241567138048534752271; + input[13] = 88385725958748408079899006800036250932223001591707578097800747617502997169851; + input[14] = 22997279242622214937712647648895181298; + input[15] = 46816884707101390882112958134453447585552332943769894357249934112654335001290; + + input[16] = sig.X.a.a; + input[17] = sig.X.a.b; + input[18] = sig.X.b.a; + input[19] = sig.X.b.b; + input[20] = sig.Y.a.a; + input[21] = sig.Y.a.b; + input[22] = sig.Y.b.a; + input[23] = sig.Y.b.b; + + uint[1] memory output; + bool success; + assembly { + success := staticcall(PAIRING_CHECK_GAS, BLS12_PAIRING_CHECK, input, 768, output, 32) + switch success case 0 { revert(0, 0) } + } + require(success, "BuilderDeposit: pairing_check failed"); + return output[0] == 1; + } + + // ── Compressed-point construction (caller-supplied Y) ────────────────── + + // Parse a 48-byte compressed G1 X coordinate and pair it with the caller- + // supplied Y. + // + // The supplied Y MUST agree with the compressed sign flag. This binds the + // point used in the pairing check to the encoding that is emitted (and that + // the consensus layer decompresses): without it, a caller controlling its + // own key could verify (X, +Y) while the emitted bytes decompress to + // (X, -Y), so the consensus layer would register a key whose proof-of- + // possession was never actually verified. The pairing check alone does NOT + // catch this, because the depositor jointly chooses the key, the emitted + // sign bit, and the signature, keeping the pairing self-consistent. + function _constructG1(bytes memory compressed, Fp memory y) internal pure returns (G1Point memory) { + require( + _fpSignBit(y) == _isSignFlagSet(compressed[0]), + "BuilderDeposit: pubkey Y sign mismatch" + ); + bytes memory rawX = _stripFlagBits(compressed); + Fp memory X = Fp(_sliceToUint(rawX, 0, 16), _sliceToUint(rawX, 16, 48)); + return G1Point(X, y); + } + + // Parse a 96-byte compressed G2 X coordinate and pair it with the caller- + // supplied Y. BLS12-381 compressed G2 places the imaginary Fp coefficient + // first (bytes [0..48]) and the real Fp coefficient second (bytes [48..96]). + // As in `_constructG1`, the supplied Y MUST agree with the compressed sign + // flag so the verified point binds to the emitted encoding. + function _constructG2(bytes memory compressed, Fp2 memory y) internal pure returns (G2Point memory) { + require( + _fp2SignBit(y) == _isSignFlagSet(compressed[0]), + "BuilderDeposit: signature Y sign mismatch" + ); + bytes memory rawX = _stripFlagBits(compressed); + uint bA = _sliceToUint(rawX, 0, 16); + uint bB = _sliceToUint(rawX, 16, 48); + uint aA = _sliceToUint(rawX, 48, 64); + uint aB = _sliceToUint(rawX, 64, 96); + Fp2 memory X = Fp2(Fp(aA, aB), Fp(bA, bB)); + return G2Point(X, y); + } + + // ── Compressed-encoding flag handling ────────────────────────────────── + + // Algorithm: ZCash-style BLS12-381 serialization flag bits (also adopted + // by IETF draft-irtf-cfrg-bls-signature Appendix A). The first byte + // carries three flags in its top three bits — [compressed (0x80)] + // [infinity (0x40)][sign (0x20)] — and five bits of X-coordinate payload. + // We reject infinity-flagged inputs (the identity element is never a valid + // pubkey or signature) and bind the sign flag to the caller-supplied Y + // (see `_constructG1` / `_constructG2`). + function _isInfinityFlagSet(bytes1 b) internal pure returns (bool) { + return (uint8(b) & 0x40) != 0; + } + + function _isSignFlagSet(bytes1 b) internal pure returns (bool) { + return (uint8(b) & 0x20) != 0; + } + + function _stripFlagBits(bytes memory enc) internal pure returns (bytes memory) { + bytes memory copyOf = new bytes(enc.length); + for (uint i = 0; i < enc.length; i++) { + copyOf[i] = enc[i]; + } + copyOf[0] = copyOf[0] & BLS_BYTE_WITHOUT_FLAGS_MASK; + return copyOf; + } + + // ── Sign bit of an affine Y coordinate ───────────────────────────────── + // + // The IETF "sign" of a field element y is 1 iff y > (q-1)/2. For Fp2, the + // sign is that of the imaginary coefficient if non-zero, else that of the + // real coefficient. These match the conventions used by the BLS12-381 + // (de)compression routines the consensus layer applies to the emitted + // encoding. + + function _fpIsZero(Fp memory x) internal pure returns (bool) { + return x.a == 0 && x.b == 0; + } + + function _fpSignBit(Fp memory y) internal pure returns (bool) { + if (y.a > Q_MINUS_1_OVER_2_HI) return true; + if (y.a < Q_MINUS_1_OVER_2_HI) return false; + return y.b > Q_MINUS_1_OVER_2_LO; + } + + function _fp2SignBit(Fp2 memory y) internal pure returns (bool) { + // Fp2 is `a + b·u`; `.a` is the real coefficient, `.b` the imaginary. + if (!_fpIsZero(y.b)) return _fpSignBit(y.b); + return _fpSignBit(y.a); + } +} diff --git a/assets/eip-draft_builder_deposit/foundry.toml b/assets/eip-draft_builder_deposit/foundry.toml new file mode 100644 index 00000000000000..3f5aadf687da0f --- /dev/null +++ b/assets/eip-draft_builder_deposit/foundry.toml @@ -0,0 +1,21 @@ +[profile.default] +# The verifier and harness target Solidity 0.6.11 (the same compiler that +# produced the deployed deposit contract). Tests target the same pragma so +# the harness can use plain inheritance instead of low-level calls. +solc = "0.6.11" +src = "." +test = "test" +out = "out" +cache_path = "cache" + +# Modexp-only tests work on any post-Byzantium EVM. The full BLS verification +# tests (testVerifyDeposit*) require EIP-2537 precompiles, which were +# activated in Prague. Override on the command line for older targets: +# forge test --evm-version cancun --no-match-test VerifyDeposit +evm_version = "prague" + +# `bytecode_hash = "none"` makes the runtime byte string deterministic +# across rebuilds — useful when comparing against the canonical bytecode. +bytecode_hash = "none" +optimizer = true +optimizer_runs = 200 diff --git a/assets/eip-draft_builder_deposit/gen_vectors.py b/assets/eip-draft_builder_deposit/gen_vectors.py new file mode 100644 index 00000000000000..29666aed9b2557 --- /dev/null +++ b/assets/eip-draft_builder_deposit/gen_vectors.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +Generate cross-verification vectors for the BuilderDepositContract Foundry tests. + +Uses py_ecc as the reference implementation: + * `depositCase()` — a deterministic deposit signature produced by + `py_ecc.bls.G2ProofOfPossession.Sign` (the Eth2 ciphersuite), together + with the (X, Y) affine decomposition of the resulting BLS points. + * `depositSigningRoot()` — the canonical SSZ signing root that the + `BuilderDepositContract._computeDepositSigningRoot` function must match. + +Output: a self-contained Solidity file `test/Vectors.sol` that the Foundry +test imports as constants. No JSON parsing in Solidity needed. + +Run from this directory: + /tmp/eipenv/bin/python gen_vectors.py > test/Vectors.sol +""" + +import hashlib +import sys +from py_ecc.optimized_bls12_381 import ( + G1, G2, field_modulus as Q, multiply, normalize, FQ, FQ2, +) +from py_ecc.bls.g2_primitives import ( + G1_to_pubkey, G2_to_signature, pubkey_to_G1, signature_to_G2, +) +from py_ecc.bls import G2ProofOfPossession as bls_pop + +# ── helpers ──────────────────────────────────────────────────────────────── + +def sha256(b: bytes) -> bytes: + return hashlib.sha256(b).digest() + +# Builder-deposit signing domain — distinct 4-byte domain type (0x0b000000, +# a placeholder pending consensus-specs allocation) with the same GENESIS +# fork-data suffix as the validator deposit domain. Must match +# DOMAIN_BUILDER_DEPOSIT in builder_deposit_contract.sol. Domain separation +# from the validator deposit domain (0x03000000…) prevents cross-context +# signature replay. +DOMAIN_BUILDER_DEPOSIT = bytes.fromhex( + "0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9" +) + +def deposit_signing_root(pubkey: bytes, wc: bytes, amount_gwei: int) -> bytes: + """SSZ compute_signing_root(DepositMessage(pubkey, wc, amount), DOMAIN_BUILDER_DEPOSIT).""" + assert len(pubkey) == 48 and len(wc) == 32 and 0 <= amount_gwei < (1 << 64) + pubkey_root = sha256(pubkey + b"\x00" * 16) + left = sha256(pubkey_root + wc) + right = sha256(amount_gwei.to_bytes(8, "little") + b"\x00" * 56) + msg_root = sha256(left + right) + return sha256(msg_root + DOMAIN_BUILDER_DEPOSIT) + +def split_fp(x: int) -> tuple: + """Pack a 384-bit Fp value into the (hi:128, lo:256) form used by the verifier.""" + x %= Q + return (x >> 256, x & ((1 << 256) - 1)) + +def sol_fp(x: int) -> str: + hi, lo = split_fp(x) + return f"BuilderDepositContract.Fp({hi:#034x}, {lo:#066x})" + +def sol_fp2(a: int, b: int) -> str: + return f"BuilderDepositContract.Fp2({sol_fp(a)}, {sol_fp(b)})" + +def sol_hex_bytes(b: bytes) -> str: + return 'hex"' + b.hex() + '"' + +# ── end-to-end deposit signature ─────────────────────────────────────────── + +def deposit_test(): + # Deterministic private key for reproducibility (NOT a real key — purely + # for round-tripping the verifier). + sk = 0x4242424242424242424242424242424242424242424242424242424242424242 + pubkey = bls_pop.SkToPk(sk) + wc = b"\x00" * 32 + amount = 32_000_000_000 # 32 ETH in gwei + sr = deposit_signing_root(pubkey, wc, amount) + signature = bls_pop.Sign(sk, sr) + assert bls_pop.Verify(pubkey, sr, signature), "py_ecc self-verify failed" + + pk_g1 = pubkey_to_G1(pubkey) + sig_g2 = signature_to_G2(signature) + pk_x, pk_y = normalize(pk_g1) + sig_x, sig_y = normalize(sig_g2) + + # deposit_data_root: not consumed by BuilderDepositContract (it does not + # take a deposit_data_root parameter), but included so that consensus-layer + # log consumers can cross-check against the same SSZ structure used by + # the validator deposit contract. + pubkey_root = sha256(pubkey + b"\x00" * 16) + sig_root = sha256( + sha256(signature[:64]) + sha256(signature[64:] + b"\x00" * 32) + ) + amount_bytes = amount.to_bytes(8, "little") + node = sha256( + sha256(pubkey_root + wc) + sha256(amount_bytes + b"\x00" * 24 + sig_root) + ) + return { + "pubkey": pubkey, + "wc": wc, + "amount_gwei": amount, + "signature": signature, + "deposit_data_root": node, + "signing_root": sr, + "pubkey_y_a": pk_y.n, + "signature_y_a_a": sig_y.coeffs[0], + "signature_y_a_b": sig_y.coeffs[1], + } + +# ── emit Solidity ────────────────────────────────────────────────────────── + +def emit(): + out = [] + p = out.append + + p("// SPDX-License-Identifier: CC0-1.0") + p("// AUTOGENERATED by gen_vectors.py — do not edit by hand.") + p("//") + p("// Cross-verification fixtures produced from py_ecc (the canonical Eth2") + p("// Python reference implementation). Regenerate with:") + p("// /tmp/eipenv/bin/python gen_vectors.py > test/Vectors.sol") + p("pragma solidity 0.6.11;") + p("pragma experimental ABIEncoderV2;") + p("") + p('import "../builder_deposit_contract.sol";') + p("") + p("library Vectors {") + p("") + + d = deposit_test() + p(" // ── End-to-end deposit signature ──────────────────────────────────") + p(" //") + p(" // Generated from a deterministic secret key via") + p(" // py_ecc.bls.G2ProofOfPossession (the Eth2 ciphersuite).") + p("") + p(" function depositCase() internal pure") + p(" returns (") + p(" bytes memory pubkey,") + p(" bytes32 withdrawal_credentials,") + p(" bytes memory signature,") + p(" bytes32 deposit_data_root,") + p(" uint64 amount_gwei,") + p(" BuilderDepositContract.Fp memory pubkey_y,") + p(" BuilderDepositContract.Fp2 memory signature_y") + p(" )") + p(" {") + p(f" pubkey = {sol_hex_bytes(d['pubkey'])};") + p(f" withdrawal_credentials = {'0x' + d['wc'].hex()};") + p(f" signature = {sol_hex_bytes(d['signature'])};") + p(f" deposit_data_root = {'0x' + d['deposit_data_root'].hex()};") + p(f" amount_gwei = {d['amount_gwei']};") + p(f" pubkey_y = {sol_fp(d['pubkey_y_a'])};") + p(f" signature_y = {sol_fp2(d['signature_y_a_a'], d['signature_y_a_b'])};") + p(" }") + p("") + + p(" /// @notice Expected `compute_signing_root` for `depositCase()`,") + p(" /// computed in Python and baked in so the on-chain SSZ helper can be") + p(" /// cross-checked without a second BLS pairing.") + p(" function depositSigningRoot() internal pure returns (bytes32) {") + p(f" return {'0x' + d['signing_root'].hex()};") + p(" }") + p("") + p("}") + return "\n".join(out) + "\n" + +if __name__ == "__main__": + sys.stdout.write(emit()) diff --git a/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol b/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol new file mode 100644 index 00000000000000..b78a247ac1b8c2 --- /dev/null +++ b/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity 0.6.11; +pragma experimental ABIEncoderV2; + +import "../builder_deposit_contract.sol"; +import "./TestHarness.sol"; +import "./Vectors.sol"; + +/// @notice Cross-verification tests for BuilderDepositContract. +/// +/// Expected values come from py_ecc (see ../gen_vectors.py) and are baked +/// into ./Vectors.sol as Solidity literals. +/// +/// The full deposit-verification tests require the EIP-2537 BLS precompiles +/// (foundry's default Prague EVM). The signing-root cross-check and the +/// length/amount/flag rejection tests do not — they exercise SHA-256 and +/// the EVM only. +contract BuilderDepositTest { + + BuilderDepositHarness internal harness; + + function setUp() public { + harness = new BuilderDepositHarness(); + } + + // ── Cross-check: SSZ signing root ────────────────────────────────────── + + function testComputeSigningRoot() public { + ( + bytes memory pubkey, + bytes32 wc, + , + , + uint64 amount_gwei, + , + ) = Vectors.depositCase(); + bytes32 expected = Vectors.depositSigningRoot(); + bytes32 got = harness.computeDepositSigningRoot(pubkey, wc, amount_gwei); + require(got == expected, "signing root mismatch vs py_ecc"); + } + + // ── Happy path: verified deposit + top-up ────────────────────────────── + + function testDepositValid() public { + ( + bytes memory pubkey, + bytes32 wc, + bytes memory signature, + , + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) = Vectors.depositCase(); + + uint64 before = harness.getDepositCount(); + harness.deposit{value: uint(amount_gwei) * 1 gwei}( + pubkey, wc, signature, pubkey_y, signature_y + ); + require(harness.getDepositCount() == before + 1, "deposit count did not increment"); + } + + function testTopUpValid() public { + (bytes memory pubkey, , , , , , ) = Vectors.depositCase(); + uint64 before = harness.getDepositCount(); + harness.top_up{value: 2 ether}(pubkey); + require(harness.getDepositCount() == before + 1, "top_up did not increment count"); + } + + function testMonotonicIndex() public { + ( + bytes memory pubkey, + bytes32 wc, + bytes memory signature, + , + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) = Vectors.depositCase(); + + require(harness.getDepositCount() == 0, "expected initial count == 0"); + harness.deposit{value: uint(amount_gwei) * 1 gwei}( + pubkey, wc, signature, pubkey_y, signature_y + ); + require(harness.getDepositCount() == 1, "after first deposit count == 1"); + harness.top_up{value: 1 ether}(pubkey); + require(harness.getDepositCount() == 2, "after top_up count == 2"); + harness.top_up{value: 1 ether}(pubkey); + require(harness.getDepositCount() == 3, "after second top_up count == 3"); + } + + // ── Negative paths: BLS check ────────────────────────────────────────── + + function testDepositRejectsTamperedAmount() public { + ( + bytes memory pubkey, + bytes32 wc, + bytes memory signature, + , + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) = Vectors.depositCase(); + // Sending a different msg.value puts a different `amount_gwei` into + // the signing root, so the pairing check must reject. + uint tamperedValue = (uint(amount_gwei) + 1) * 1 gwei; + try harness.deposit{value: tamperedValue}( + pubkey, wc, signature, pubkey_y, signature_y + ) { + require(false, "tampered amount should revert"); + } catch {} + } + + function testDepositRejectsTamperedSignature() public { + ( + bytes memory pubkey, + bytes32 wc, + bytes memory signature, + , + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) = Vectors.depositCase(); + bytes memory tampered = _copy(signature); + tampered[10] = tampered[10] ^ bytes1(uint8(1)); + try harness.deposit{value: uint(amount_gwei) * 1 gwei}( + pubkey, wc, tampered, pubkey_y, signature_y + ) { + require(false, "tampered signature should revert"); + } catch {} + } + + // Regression test for the sign-bit binding (audit Finding 2). The valid + // vector has pubkey sign flag == sign(pubkey_y). Flipping ONLY the pubkey's + // sign flag (leaving X and the supplied pubkey_y unchanged) models an + // attacker who verifies (X, +Y) but emits bytes that decompress to (X, -Y). + // With the sign-bit consistency check in `_constructG1`, this must revert + // BEFORE any pairing work. Without the check it would have passed. + function testDepositRejectsPubkeySignBitFlip() public { + ( + bytes memory pubkey, + bytes32 wc, + bytes memory signature, + , + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) = Vectors.depositCase(); + bytes memory flipped = _copy(pubkey); + flipped[0] = flipped[0] ^ bytes1(uint8(0x20)); // flip sign flag only + try harness.deposit{value: uint(amount_gwei) * 1 gwei}( + flipped, wc, signature, pubkey_y, signature_y + ) { + require(false, "pubkey sign-bit flip should revert"); + } catch {} + } + + // Same regression, signature side: flip the signature's sign flag while + // keeping signature_y, exercising `_constructG2`'s sign-bit check. + function testDepositRejectsSignatureSignBitFlip() public { + ( + bytes memory pubkey, + bytes32 wc, + bytes memory signature, + , + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) = Vectors.depositCase(); + bytes memory flipped = _copy(signature); + flipped[0] = flipped[0] ^ bytes1(uint8(0x20)); + try harness.deposit{value: uint(amount_gwei) * 1 gwei}( + pubkey, wc, flipped, pubkey_y, signature_y + ) { + require(false, "signature sign-bit flip should revert"); + } catch {} + } + + // ── Negative paths: compressed-encoding flags ────────────────────────── + + function testDepositRejectsInfinityPubkey() public { + ( + bytes memory pubkey, + bytes32 wc, + bytes memory signature, + , + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) = Vectors.depositCase(); + bytes memory infPubkey = _copy(pubkey); + infPubkey[0] = infPubkey[0] | bytes1(uint8(0x40)); // set infinity flag + try harness.deposit{value: uint(amount_gwei) * 1 gwei}( + infPubkey, wc, signature, pubkey_y, signature_y + ) { + require(false, "infinity pubkey should revert"); + } catch {} + } + + function testDepositRejectsInfinitySignature() public { + ( + bytes memory pubkey, + bytes32 wc, + bytes memory signature, + , + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) = Vectors.depositCase(); + bytes memory infSig = _copy(signature); + infSig[0] = infSig[0] | bytes1(uint8(0x40)); + try harness.deposit{value: uint(amount_gwei) * 1 gwei}( + pubkey, wc, infSig, pubkey_y, signature_y + ) { + require(false, "infinity signature should revert"); + } catch {} + } + + // ── Negative paths: input-shape validation ───────────────────────────── + + function testDepositRejectsTooSmallAmount() public { + // BLS data doesn't matter — the amount check fires first. + bytes memory pubkey = new bytes(48); + bytes memory signature = new bytes(96); + BuilderDepositContract.Fp memory zero_fp = + BuilderDepositContract.Fp(0, 0); + BuilderDepositContract.Fp2 memory zero_fp2 = + BuilderDepositContract.Fp2(zero_fp, zero_fp); + try harness.deposit{value: 0.5 ether}( + pubkey, bytes32(0), signature, zero_fp, zero_fp2 + ) { + require(false, "deposit < 1 ether should revert"); + } catch {} + } + + function testDepositRejectsNonGweiAmount() public { + bytes memory pubkey = new bytes(48); + bytes memory signature = new bytes(96); + BuilderDepositContract.Fp memory zero_fp = + BuilderDepositContract.Fp(0, 0); + BuilderDepositContract.Fp2 memory zero_fp2 = + BuilderDepositContract.Fp2(zero_fp, zero_fp); + // 1 ether + 1 wei is not a multiple of 1 gwei. + try harness.deposit{value: 1 ether + 1}( + pubkey, bytes32(0), signature, zero_fp, zero_fp2 + ) { + require(false, "non-gwei value should revert"); + } catch {} + } + + function testDepositRejectsWrongPubkeyLength() public { + bytes memory pubkey = new bytes(47); // one short + bytes memory signature = new bytes(96); + BuilderDepositContract.Fp memory zero_fp = + BuilderDepositContract.Fp(0, 0); + BuilderDepositContract.Fp2 memory zero_fp2 = + BuilderDepositContract.Fp2(zero_fp, zero_fp); + try harness.deposit{value: 1 ether}( + pubkey, bytes32(0), signature, zero_fp, zero_fp2 + ) { + require(false, "47-byte pubkey should revert"); + } catch {} + } + + function testDepositRejectsWrongSignatureLength() public { + bytes memory pubkey = new bytes(48); + bytes memory signature = new bytes(95); // one short + BuilderDepositContract.Fp memory zero_fp = + BuilderDepositContract.Fp(0, 0); + BuilderDepositContract.Fp2 memory zero_fp2 = + BuilderDepositContract.Fp2(zero_fp, zero_fp); + try harness.deposit{value: 1 ether}( + pubkey, bytes32(0), signature, zero_fp, zero_fp2 + ) { + require(false, "95-byte signature should revert"); + } catch {} + } + + function testTopUpRejectsTooSmallAmount() public { + bytes memory pubkey = new bytes(48); + try harness.top_up{value: 0.5 ether}(pubkey) { + require(false, "top_up < 1 ether should revert"); + } catch {} + } + + function testTopUpRejectsWrongPubkeyLength() public { + bytes memory pubkey = new bytes(47); + try harness.top_up{value: 1 ether}(pubkey) { + require(false, "47-byte pubkey should revert"); + } catch {} + } + + // ── helpers ──────────────────────────────────────────────────────────── + + function _copy(bytes memory src) internal pure returns (bytes memory dst) { + dst = new bytes(src.length); + for (uint i = 0; i < src.length; i++) dst[i] = src[i]; + } +} diff --git a/assets/eip-draft_builder_deposit/test/TestHarness.sol b/assets/eip-draft_builder_deposit/test/TestHarness.sol new file mode 100644 index 00000000000000..7f37713faedd27 --- /dev/null +++ b/assets/eip-draft_builder_deposit/test/TestHarness.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: CC0-1.0 +pragma solidity 0.6.11; +pragma experimental ABIEncoderV2; + +import "../builder_deposit_contract.sol"; + +/// @notice Test harness that inherits BuilderDepositContract and exposes +/// the internal helpers and storage slots needed for unit tests. +/// `deposit(...)` and `top_up(...)` are inherited as-is — the harness does +/// not override them — so tests exercise the same external entrypoints +/// that the real predeploy exposes. +contract BuilderDepositHarness is BuilderDepositContract { + /// @notice Read access to the monotonic deposit counter, used by tests + /// to check that successful entrypoints increment it by exactly one. + function getDepositCount() external view returns (uint64) { + return deposit_count; + } + + /// @notice Exposes the SSZ signing-root computation so the test suite + /// can cross-check it against the canonical Python (py_ecc) reference. + function computeDepositSigningRoot( + bytes calldata pubkey, + bytes32 withdrawal_credentials, + uint64 amount_gwei + ) external pure returns (bytes32) { + return _computeDepositSigningRoot(pubkey, withdrawal_credentials, amount_gwei); + } +} diff --git a/assets/eip-draft_builder_deposit/test/Vectors.sol b/assets/eip-draft_builder_deposit/test/Vectors.sol new file mode 100644 index 00000000000000..29dadfcb3ef098 --- /dev/null +++ b/assets/eip-draft_builder_deposit/test/Vectors.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: CC0-1.0 +// AUTOGENERATED by gen_vectors.py — do not edit by hand. +// +// Cross-verification fixtures produced from py_ecc (the canonical Eth2 +// Python reference implementation). Regenerate with: +// /tmp/eipenv/bin/python gen_vectors.py > test/Vectors.sol +pragma solidity 0.6.11; +pragma experimental ABIEncoderV2; + +import "../builder_deposit_contract.sol"; + +library Vectors { + + // ── End-to-end deposit signature ────────────────────────────────── + // + // Generated from a deterministic secret key via + // py_ecc.bls.G2ProofOfPossession (the Eth2 ciphersuite). + + function depositCase() internal pure + returns ( + bytes memory pubkey, + bytes32 withdrawal_credentials, + bytes memory signature, + bytes32 deposit_data_root, + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) + { + pubkey = hex"b5b99c967e4c69822f427db1f6871dd119afb95ab9646ba2e707990a3db31777a59b66f69e89c2055699b0ade7357eae"; + withdrawal_credentials = 0x0000000000000000000000000000000000000000000000000000000000000000; + signature = hex"ab275ac905d68eb121c85caff35043de7e7bf478ec46b28a09eec700a4d2123572f18202f701d6a2974daddf7afba23b11199fa446bae3e67c630d45f62928dc3e3244317b485ff002e126e6b917ddcf4426f0903d68eb6743ab1a33fdaf56ed"; + deposit_data_root = 0xcf743a69594fad43bccf7056e08b2a3b12a6696d0a7ccf0527c48d6cabd98f01; + amount_gwei = 32000000000; + pubkey_y = BuilderDepositContract.Fp(0x1201d584a96bc82775861b0611171d2f, 0xfeaa2431c48856e23d3b747f7392fad645e0cdd5aa01b6a5b24b73ab584db4ad); + signature_y = BuilderDepositContract.Fp2(BuilderDepositContract.Fp(0x12ca72b79300a13c051ed9170061f030, 0xf53694950c73fdb4f8130911d0dac6b13b423450cb333b467102fd22666d6593), BuilderDepositContract.Fp(0x14454e59e30d8480bc2bf6f3d08196f9, 0x285f86d269689bc843fcf8006d8b1448bfd6d71010aca7cba6ebde8b1154aa90)); + } + + /// @notice Expected `compute_signing_root` for `depositCase()`, + /// computed in Python and baked in so the on-chain SSZ helper can be + /// cross-checked without a second BLS pairing. + function depositSigningRoot() internal pure returns (bytes32) { + return 0xf764d63dcac69648e80ba943a88eaf62ce01a4c53998971787c3dc78b6f006c2; + } + +} From ee7205397f64e2d5289de71f5a807c66e6b30346 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 26 May 2026 13:26:41 -0400 Subject: [PATCH 02/12] Rework builder deposit onto the EIP-7685 request bus Replace event-log delivery with the EIP-7685 request mechanism used by EIP-7002 (withdrawals) and EIP-7251 (consolidations): two single-type predeploys sharing a RequestQueue base, drained by a SYSTEM_ADDRESS end-of-block system call and committed via the block requests_hash. - BuilderDepositContract (request type 0x03): deposit() verifies the BLS proof-of-possession, then appends a record to its queue; no logs. - BuilderTopUpContract (request type 0x04): unverified top_up() appends a record to its queue. - No request fee: the staked value is the anti-spam gate. BLS verification and the prior audit fixes (domain separation, sign-bit binding, precompile gas caps) are unchanged. Dequeued records carry no signature, since the consensus layer trusts the execution-layer check. Tests rewritten for the queue / system-read model (14 passing). Adds requires 7685; request-type bytes and predeploy addresses are placeholders. --- EIPS/eip-draft_builder_deposit.md | 71 +++-- assets/eip-draft_builder_deposit/README.md | 38 ++- .../builder_deposit_contract.sol | 224 +++++++++------ .../test/BuilderDeposit.t.sol | 256 ++++++++---------- .../test/TestHarness.sol | 25 +- 5 files changed, 335 insertions(+), 279 deletions(-) diff --git a/EIPS/eip-draft_builder_deposit.md b/EIPS/eip-draft_builder_deposit.md index 29eb1ddd1d23f0..91b6165cb8623e 100644 --- a/EIPS/eip-draft_builder_deposit.md +++ b/EIPS/eip-draft_builder_deposit.md @@ -1,23 +1,23 @@ --- title: Builder Deposit Contract -description: Predeploy a BLS-verifying builder deposit contract using EIP-2537 precompiles, for EIP-7732 builders +description: Predeploy BLS-verifying builder deposit and top-up contracts as EIP-7685 requests, using EIP-2537 precompiles, for EIP-7732 builders author: Cayman (@wemeetagain) discussions-to: status: Draft type: Standards Track category: Core created: 2026-05-22 -requires: 2537, 7732 +requires: 2537, 7685, 7732 --- ## Abstract -Predeploy a builder deposit contract at a fixed address. The contract exposes two entrypoints: +Predeploy two [EIP-7685](./eip-7685.md) request contracts for the [EIP-7732](./eip-7732.md) builder population, modelled on the request bus that [EIP-7002](./eip-7002.md) (withdrawals) and [EIP-7251](./eip-7251.md) (consolidations) use: -- `deposit(...)`, which verifies a BLS proof-of-possession signature against the supplied `DepositMessage` using the [EIP-2537](./eip-2537.md) precompiles before emitting a deposit log; and -- `top_up(...)`, which accepts an additional deposit for an existing builder without on-chain signature verification. +- a builder deposit contract whose `deposit(...)` verifies a BLS proof-of-possession against the supplied `DepositMessage` using the [EIP-2537](./eip-2537.md) precompiles, then appends a deposit request to its queue; and +- a builder top-up contract whose `top_up(...)` appends an additional-stake request for an existing builder without on-chain signature verification. -This contract is independent of the existing validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa` and serves the [EIP-7732](./eip-7732.md) builder population only. +Each contract maintains an in-state request queue drained by an end-of-block `SYSTEM_ADDRESS` system call; the dequeued records become the contract's [EIP-7685](./eip-7685.md) `request_data`, committed in the block `requests_hash`. Neither contract emits logs. Both are independent of the existing validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`. ## Motivation @@ -41,50 +41,60 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ### Constants +All address and request-type values below are placeholders pending allocation in consensus-specs and the execution-layer client configuration; the `0x03`/`0x04` request types in particular MUST be distinct from the existing deposit (`0x00`), withdrawal (`0x01`), and consolidation (`0x02`) types. + | Name | Value | Comment | | --- | --- | --- | -| `BUILDER_DEPOSIT_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007732` | Predeploy address of the builder deposit contract (placeholder; final value assigned alongside the EIP number) | +| `BUILDER_DEPOSIT_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007732` | Predeploy address of the builder deposit contract (placeholder) | +| `BUILDER_TOPUP_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007733` | Predeploy address of the builder top-up contract (placeholder) | +| `BUILDER_DEPOSIT_REQUEST_TYPE` | `0x03` | [EIP-7685](./eip-7685.md) request-type byte for builder deposits (placeholder) | +| `BUILDER_TOPUP_REQUEST_TYPE` | `0x04` | [EIP-7685](./eip-7685.md) request-type byte for builder top-ups (placeholder) | +| `SYSTEM_ADDRESS` | `0xfffffffffffffffffffffffffffffffffffffffe` | Address that invokes the end-of-block system call (as in [EIP-7002](./eip-7002.md)) | +| `MAX_REQUESTS_PER_BLOCK` | `16` | Maximum records each contract drains into one block | | `DOMAIN_BUILDER_DEPOSIT` | `0x0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9` | Signing domain for builder deposit messages. The `0x0b000000` domain type is a placeholder pending consensus-specs allocation; it MUST differ from the validator `DOMAIN_DEPOSIT` (`0x03000000…`) so signatures are not interchangeable between the two contracts | | `BLS12_G2ADD` | `0x0d` | [EIP-2537](./eip-2537.md) precompile address | | `BLS12_PAIRING_CHECK` | `0x0f` | [EIP-2537](./eip-2537.md) precompile address | | `BLS12_MAP_FP2_TO_G2` | `0x11` | [EIP-2537](./eip-2537.md) precompile address | | `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder deposit contract | +| `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder top-up contract | ### Fork transition -At the start of processing the first block where this EIP is active, before processing transactions, execution clients MUST install `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` at `BUILDER_DEPOSIT_CONTRACT_ADDRESS` if the account at that address is empty (zero `nonce`, empty `code`, empty `storage`, zero `balance`). The installation MUST set `code = BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE`, `nonce = 1`, `balance = 0`, and leave `storage` empty. +At the start of processing the first block where this EIP is active, before processing transactions, execution clients MUST install each predeploy — `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` at `BUILDER_DEPOSIT_CONTRACT_ADDRESS` and `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE` at `BUILDER_TOPUP_CONTRACT_ADDRESS` — if the account at the respective address is empty (zero `nonce`, empty `code`, empty `storage`, zero `balance`). Each installation MUST set `code` to the runtime code, `nonce = 1`, `balance = 0`, and leave `storage` empty. + +If either account is not empty at fork time, clients MUST abort initialisation. This matches the predeploy pattern used by [EIP-2935](./eip-2935.md), [EIP-4788](./eip-4788.md), [EIP-7002](./eip-7002.md), and [EIP-7251](./eip-7251.md). -If the account at `BUILDER_DEPOSIT_CONTRACT_ADDRESS` is not empty at fork time, clients MUST abort initialisation. This matches the predeploy pattern used by [EIP-2935](./eip-2935.md), [EIP-4788](./eip-4788.md), [EIP-7002](./eip-7002.md), and [EIP-7251](./eip-7251.md). +### Request queue and system call -### Builder deposit contract behavior +Both predeploys follow the [EIP-7002](./eip-7002.md) / [EIP-7251](./eip-7251.md) request-bus pattern. Each maintains a FIFO queue of request records in its own storage. A user-facing entrypoint validates a request and appends one record. At the end of the block, the execution layer MUST invoke each predeploy with a call from `SYSTEM_ADDRESS` and empty calldata; the predeploy MUST dequeue up to `MAX_REQUESTS_PER_BLOCK` records (oldest first), return their concatenation as that contract's `request_data`, and advance its queue head past the returned records. Records beyond the per-block cap remain queued for subsequent blocks. A call with empty calldata from any address other than `SYSTEM_ADDRESS` MUST revert. -The contract exposes two external entrypoints. Both MUST emit a log record at `BUILDER_DEPOSIT_CONTRACT_ADDRESS` that the consensus layer can extract and process as a builder deposit operation. +The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). Neither contract emits logs, and there is no request fee: the staked value (and, for deposits, gas-metered verification) is the anti-spam gate. -#### Verified entrypoint +### Verified deposit entrypoint ``` deposit( bytes pubkey, // 48-byte compressed G1 (X with sign+infinity flags) - bytes withdrawal_credentials, // 32-byte commitment + bytes32 withdrawal_credentials, // 32-byte commitment bytes signature, // 96-byte compressed G2 (X with sign+infinity flags) Fp pubkey_y, // affine Y of pubkey, in EIP-2537 encoding Fp2 signature_y // affine Y of signature, in EIP-2537 encoding ) payable ``` -`deposit(...)` MUST perform the following, in order, before emitting any log: +`deposit(...)` MUST perform the following, in order, before appending any record: 1. Validate input lengths and the deposit amount. 2. Reject `pubkey` or `signature` whose infinity flag is set. -3. Verify that the supplied `pubkey_y` and `signature_y` agree with the sign flag of the corresponding compressed encoding (i.e. `sign(pubkey_y)` equals the sign bit of `pubkey`, and likewise for the signature). This binds the point used in the pairing check to the encoding that is emitted and that the consensus layer decompresses; without it the verified point could be the negation of the registered point. +3. Verify that the supplied `pubkey_y` and `signature_y` agree with the sign flag of the corresponding compressed encoding (i.e. `sign(pubkey_y)` equals the sign bit of `pubkey`, and likewise for the signature). This binds the point used in the pairing check to the encoding the consensus layer will register; without it the verified point could be the negation of the registered point. 4. Compute the signing root `compute_signing_root(DepositMessage(pubkey, withdrawal_credentials, amount), DOMAIN_BUILDER_DEPOSIT)`. 5. Verify the BLS proof-of-possession via the [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile, using the supplied affine `Y` coordinates to construct the G1 and G2 points. 6. Revert the entire call if the pairing check fails. -On success, `deposit(...)` MUST emit a `BuilderDepositEvent` carrying `pubkey`, `withdrawal_credentials`, `amount`, `signature`, and the contract's monotonic deposit index. +On success, `deposit(...)` MUST append a `BUILDER_DEPOSIT_REQUEST_TYPE` record of `pubkey (48) ++ withdrawal_credentials (32) ++ amount_gwei (8, little-endian)` to its queue. The signature is intentionally absent: it was verified at submission, so the consensus layer trusts the dequeued record without re-pairing. -#### Unverified top-up entrypoint +### Unverified top-up entrypoint ``` top_up( @@ -92,19 +102,25 @@ top_up( ) payable ``` -`top_up(...)` MUST perform the length and amount checks but MUST NOT perform any signature verification. On success it MUST emit a `BuilderTopUpEvent` carrying `pubkey`, `amount`, and the contract's monotonic deposit index. +`top_up(...)` MUST perform the length and amount checks but MUST NOT perform any signature verification. On success it MUST append a `BUILDER_TOPUP_REQUEST_TYPE` record of `pubkey (48) ++ amount_gwei (8, little-endian)` to its queue. -`top_up(...)` deliberately takes no `withdrawal_credentials`. A top-up only adds stake to an already-registered builder; the credentials are fixed by that builder's verified `BuilderDepositEvent`. Omitting the field denies an unauthenticated caller any influence over a builder's withdrawal target. The consensus layer is responsible for rejecting `BuilderTopUpEvent` records that target a `pubkey` not already registered as an EIP-7732 builder. +`top_up(...)` deliberately takes no `withdrawal_credentials`. A top-up only adds stake to an already-registered builder; the credentials are fixed by that builder's verified deposit. Omitting the field denies an unauthenticated caller any influence over a builder's withdrawal target. The consensus layer is responsible for rejecting top-up records that target a `pubkey` not already registered as an EIP-7732 builder. + +The deposited ETH for both entrypoints is locked in the respective contract; the consensus layer credits the builder from the dequeued request. ## Rationale - **A separate contract, not a replacement.** The deployed validator contract has an immutable two-mode API. Replacing its runtime would either break the all-zero-signature top-up flow that mainnet uses today, or would require keeping an unverified entrypoint in the spec — bringing the same DoS surface forward. A separate contract lets the existing validator semantics stay fixed. -- **Y coordinates supplied by the caller.** On-chain decompression of a compressed G1 or G2 point requires an Fp or Fp2 square root, which in turn requires several thousand bytes of runtime code and an order-of-magnitude more gas than the pairing check itself. Because builders already work with affine BLS points in their off-chain infrastructure, requiring the Y coordinates as call data shrinks the canonical bytecode considerably and removes the Fp-arithmetic and Sarkar/Adj sqrt code from the audit surface. +- **Reuse the EIP-7685 request bus.** Builder deposits and top-ups are delivered through the same execution-to-consensus request mechanism as [EIP-7002](./eip-7002.md) withdrawals and [EIP-7251](./eip-7251.md) consolidations: an in-state queue drained by an end-of-block `SYSTEM_ADDRESS` system call, committed in `requests_hash`. This is preferred over the log-scraping path that [EIP-6110](./eip-6110.md) uses for validator deposits, which was a backwards-compatibility accommodation for the immutable validator contract. A fresh contract has no such constraint, so it uses the modern request bus and the consensus layer needs no log-parsing for builders. -- **Caller-supplied Y is bound to the compressed sign bit.** The contract requires `sign(pubkey_y)` to equal the sign flag of the compressed `pubkey` (and likewise for the signature). The pairing check alone does NOT make this redundant: because a depositor jointly chooses the key, the emitted sign bit, and the signature, they can verify a point `(X, +Y)` while the emitted bytes decompress to `(X, −Y)`, keeping the pairing self-consistent but causing the consensus layer to register a point whose proof-of-possession was never actually verified. Binding the sign bit closes this gap with a single field comparison and short-circuits before any pairing work. +- **Two predeploys, two request types.** Mirroring withdrawals (`0x01`) and consolidations (`0x02`) — each a single-type request predeploy — builder deposits (`0x03`) and top-ups (`0x04`) are two separate predeploys sharing a common queue implementation. Each is a standard single-type request contract: an empty-calldata `SYSTEM_ADDRESS` call returns a flat `request_data`. The execution layer therefore needs no new read semantics, and the consensus layer distinguishes a first-sighting deposit (with an execution-layer-verified signature) from a stake-only top-up by request type rather than by inspecting record contents. + +- **No request fee.** Unlike EIP-7002/7251, whose requests would otherwise be free and so charge a dynamic fee, every builder request locks at least the minimum stake and (for deposits) pays for gas-metered BLS verification. That staked value is the anti-spam gate, so no separate fee is levied; flooding the queue costs at least the stake per entry. The per-block cap plus the queue still bound how many records enter a single block. + +- **Y coordinates supplied by the caller.** On-chain decompression of a compressed G1 or G2 point requires an Fp or Fp2 square root, which in turn requires several thousand bytes of runtime code and an order-of-magnitude more gas than the pairing check itself. Because builders already work with affine BLS points in their off-chain infrastructure, requiring the Y coordinates as call data shrinks the canonical bytecode considerably and removes the Fp-arithmetic and Sarkar/Adj sqrt code from the audit surface. -- **Two entrypoints rather than a single overloaded one.** Keeping `top_up(...)` separate makes the consensus-layer log handling unambiguous: a `BuilderDepositEvent` is the first sighting of a builder pubkey and is accompanied by an execution-layer-verified signature; a `BuilderTopUpEvent` is an increment against an already-registered builder and carries no signature. +- **Caller-supplied Y is bound to the compressed sign bit.** The contract requires `sign(pubkey_y)` to equal the sign flag of the compressed `pubkey` (and likewise for the signature). The pairing check alone does NOT make this redundant: because a depositor jointly chooses the key, the queued sign bit, and the signature, they can verify a point `(X, +Y)` while the record's `pubkey` bytes decompress to `(X, −Y)`, keeping the pairing self-consistent but causing the consensus layer to register a point whose proof-of-possession was never actually verified. Binding the sign bit closes this gap with a single field comparison and short-circuits before any pairing work. - **Gas-metered verification as the DoS gate.** Verification cost (`BLS12_PAIRING_CHECK` + `BLS12_MAP_FP2_TO_G2` + supporting work) is paid by the depositor's transaction. Submitting an invalid signature therefore costs the same as submitting a valid one; there is no asymmetric drain on the consensus layer. @@ -114,21 +130,22 @@ top_up( This EIP is additive at the execution layer: it introduces a new contract at a previously empty address. It does not modify the validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`, does not change the `DepositEvent` layout that contract emits, and does not affect any existing validator's ability to make first deposits or top-ups. -At the consensus layer, EIP-7732 builders MUST be sourced from `BuilderDepositEvent` and `BuilderTopUpEvent` logs at `BUILDER_DEPOSIT_CONTRACT_ADDRESS`; the validator deposit contract continues to be the sole source of validator deposits. +At the consensus layer, EIP-7732 builders MUST be sourced from the builder deposit (`BUILDER_DEPOSIT_REQUEST_TYPE`) and top-up (`BUILDER_TOPUP_REQUEST_TYPE`) requests committed in the block `requests_hash`; the validator deposit contract continues to be the sole source of validator deposits. The new request types are additive — blocks that contain no builder requests produce empty `request_data` for these types, which [EIP-7685](./eip-7685.md) excludes from the `requests_hash`. ## Test Cases -A Foundry test suite under `../assets/eip-draft_builder_deposit/test/` cross-verifies the contract against `py_ecc` (the canonical Eth2 Python reference). Coverage includes the SSZ signing-root computation, an end-to-end `deposit(...)` round-trip against a `py_ecc.bls.G2ProofOfPossession.Sign`-produced signature, the `top_up(...)` happy path, the monotonic deposit-index invariant, and the input-shape and tampering rejection paths. +A Foundry test suite under `../assets/eip-draft_builder_deposit/test/` cross-verifies the contracts against `py_ecc` (the canonical Eth2 Python reference). Coverage includes the SSZ signing-root computation; an end-to-end `deposit(...)` that enqueues a record matching a `py_ecc.bls.G2ProofOfPossession.Sign`-produced signature; the `top_up(...)` happy path; the `SYSTEM_ADDRESS` system read returning the exact `request_data` records; the per-block cap and FIFO drain order; rejection of a non-`SYSTEM_ADDRESS` system read; and the input-shape and tampering rejection paths (each asserting nothing is enqueued). ## Reference Implementation -Solidity source for the proposed runtime is published at [`../assets/eip-draft_builder_deposit/builder_deposit_contract.sol`](../assets/eip-draft_builder_deposit/builder_deposit_contract.sol), with the test harness, fixture generator, and Foundry configuration alongside it. The compiled, optimised runtime bytecode of the current draft is approximately 7.6 KiB — well within the [EIP-170](./eip-170.md) 24 KiB limit, with no on-chain field-arithmetic kernel or decompression path. The final `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` and `BUILDER_DEPOSIT_CONTRACT_ADDRESS` will be locked in once the contract has been independently audited. +Solidity source for both predeploys is published at [`../assets/eip-draft_builder_deposit/builder_deposit_contract.sol`](../assets/eip-draft_builder_deposit/builder_deposit_contract.sol), with the test harness, fixture generator, and Foundry configuration alongside it. The file defines a shared `RequestQueue` base plus `BuilderDepositContract` and `BuilderTopUpContract`. The optimised runtime bytecode of the current draft is approximately 7.8 KiB for the deposit contract and 1.5 KiB for the top-up contract — both well within the [EIP-170](./eip-170.md) 24 KiB limit, with no on-chain field-arithmetic kernel or decompression path. The final `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE`, `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE`, the predeploy addresses, and the request-type bytes will be locked in once the contracts have been independently audited. ## Security Considerations - **Signing-domain separation.** `DOMAIN_BUILDER_DEPOSIT` MUST differ from the validator `DOMAIN_DEPOSIT`. Because both contracts use the identical `DepositMessage` SSZ structure, a shared domain would make proof-of-possession signatures byte-for-byte interchangeable, allowing a public validator-deposit signature to be replayed into this contract (force-enrolling a validator pubkey as a builder) and vice versa. The distinct domain type closes this in both directions. -- **Sign-bit binding.** The supplied affine `Y` MUST agree with the sign flag of the compressed `pubkey`/`signature`. Without this binding, a depositor controlling the key could pass the pairing check on a point `(X, +Y)` while emitting bytes that the consensus layer decompresses to `(X, −Y)`, so the registered key's proof-of-possession would never have been verified — and any CL client that defensively re-verified the emitted `(pubkey, signature)` would reject a deposit other clients accept, risking a builder-set split. -- **Top-up validity at CL.** The contract emits `BuilderTopUpEvent` without checking that the target `pubkey` exists. The consensus layer MUST reject top-ups against unregistered builders so that all-zero or junk top-ups cannot register new builders without a verified deposit. `top_up(...)` carries no `withdrawal_credentials`, so an unauthenticated caller cannot rewrite an existing builder's withdrawal target. +- **Sign-bit binding.** The supplied affine `Y` MUST agree with the sign flag of the compressed `pubkey`/`signature`. Without this binding, a depositor controlling the key could pass the pairing check on a point `(X, +Y)` while the queued record's `pubkey` bytes decompress to `(X, −Y)`, so the consensus layer registers a key whose proof-of-possession the execution layer never verified (it verified the negation). The deposit record carries no signature, so the consensus layer cannot detect this by re-verification — it trusts the execution-layer check — which is exactly why the binding must be enforced on chain. +- **System-read access control and per-block cap.** Only `SYSTEM_ADDRESS` may invoke the end-of-block dequeue; any other empty-calldata call reverts, so a non-system caller cannot drain or replay the queue. Each contract returns at most `MAX_REQUESTS_PER_BLOCK` records per block, bounding the size each predeploy contributes to the block requests; excess records remain queued for later blocks. +- **Top-up validity at CL.** A top-up appends a request without checking that the target `pubkey` exists. The consensus layer MUST reject top-ups against unregistered builders so that all-zero or junk top-ups cannot register new builders without a verified deposit. `top_up(...)` carries no `withdrawal_credentials`, so an unauthenticated caller cannot rewrite an existing builder's withdrawal target. - **DoS surface.** Verification cost is gas-metered and paid by the depositor; an adversary cannot force consensus-layer pairing work without first paying the corresponding execution-layer gas. Per [EIP-2537](./eip-2537.md) §"Gas burning on error", a precompile that rejects a malformed (off-curve or out-of-subgroup) point burns all gas forwarded to it, so the contract MUST NOT forward `gas()` to the precompiles. Because EIP-2537 pricing is deterministic (a pure function of input length), the contract forwards a fixed gas ceiling to each precompile `staticcall` — set per call at roughly 2.5x the documented cost — which bounds the worst-case burn on a malformed input to that ceiling instead of the whole transaction, while leaving ample headroom for a future reprice. The ceilings MUST be revisited if [EIP-2537](./eip-2537.md) pricing changes. - **Subgroup membership.** The [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile performs G1 and G2 subgroup checks; the contract does not need to re-implement them. - **Compressed-point flags.** The contract must reject infinity-flagged inputs to prevent acceptance of the identity element as a `pubkey` or `signature`. diff --git a/assets/eip-draft_builder_deposit/README.md b/assets/eip-draft_builder_deposit/README.md index eec7e177b12e09..6a36c722d3ae55 100644 --- a/assets/eip-draft_builder_deposit/README.md +++ b/assets/eip-draft_builder_deposit/README.md @@ -6,10 +6,10 @@ Reference Solidity for the proposal, plus cross-verification tests. | File | Purpose | | --- | --- | -| `builder_deposit_contract.sol` | The proposed predeploy. Two entrypoints: `deposit(...)` (BLS-verified, requires affine Y coordinates) and `top_up(...)` (unverified, CL re-validates). | +| `builder_deposit_contract.sol` | The two proposed predeploys plus a shared base: `RequestQueue` (EIP-7002-style queue + `SYSTEM_ADDRESS` end-of-block read), `BuilderDepositContract` (`deposit(...)`, BLS-verified, request type `0x03`), and `BuilderTopUpContract` (`top_up(...)`, unverified, request type `0x04`). | | `gen_vectors.py` | Python script that uses `py_ecc` (the canonical Eth2 reference) to produce cross-verification test vectors. | | `test/Vectors.sol` | Auto-generated Solidity library of test vectors. Regenerate by running `gen_vectors.py`. | -| `test/TestHarness.sol` | Thin wrapper that inherits `BuilderDepositContract` and exposes its `internal` SSZ signing-root helper + the `deposit_count` storage slot, for use by the tests. | +| `test/TestHarness.sol` | `BuilderDepositHarness` / `BuilderTopUpHarness` — inherit the predeploys and expose the pending-queue depth (and the SSZ signing-root helper) for the tests. | | `test/BuilderDeposit.t.sol` | Foundry tests. | | `foundry.toml` | Foundry configuration (solc `0.6.11`, EVM `prague` by default). | @@ -31,10 +31,10 @@ Run the test suite: forge test -vv ``` -`evm_version = "prague"` in `foundry.toml` enables the EIP-2537 BLS precompiles, required for the deposit-verification path. To run only the input-shape and signing-root tests on an older EVM (no EIP-2537 needed): +`evm_version = "prague"` in `foundry.toml` enables the EIP-2537 BLS precompiles, required for the three tests that exercise the pairing path. To run only the queue, system-read, and input-validation tests on an older EVM (no EIP-2537 needed): ```bash -forge test -vv --evm-version cancun --no-match-test 'Deposit(Valid|Rejects(Tampered|Infinity))' +forge test -vv --evm-version cancun --no-match-test '(DepositEnqueuesAndReads|Tampered)' ``` ## Regenerating vectors @@ -47,21 +47,19 @@ The script is deterministic: the secret key is hard-coded so the output is byte- ## Test coverage -| Test | What it cross-verifies | EIP-2537 required? | +| Test | What it covers | EIP-2537 required? | | --- | --- | --- | | `testComputeSigningRoot` | `_computeDepositSigningRoot` matches `py_ecc`-derived SSZ `compute_signing_root` | no | -| `testDepositValid` | A `py_ecc.G2ProofOfPossession.Sign`-produced signature is accepted; `deposit_count` increments | **yes** | -| `testTopUpValid` | `top_up(...)` accepts a non-signed call; `deposit_count` increments | no | -| `testMonotonicIndex` | Deposit + top_up + top_up increment the counter by 1 each | **yes** (for the deposit step) | -| `testDepositRejectsTamperedAmount` | Sending a different `msg.value` than was signed fails the pairing check | **yes** | -| `testDepositRejectsTamperedSignature` | Flipping a bit in the signature is rejected (subgroup or pairing failure) | **yes** | -| `testDepositRejectsPubkeySignBitFlip` | Flipping only the pubkey sign flag (keeping Y) is rejected by the sign-bit binding — regression for audit Finding 2 | no | -| `testDepositRejectsSignatureSignBitFlip` | Flipping only the signature sign flag (keeping Y) is rejected by the sign-bit binding | no | -| `testDepositRejectsInfinityPubkey` | `pubkey` with infinity flag is rejected before BLS work | no | -| `testDepositRejectsInfinitySignature` | `signature` with infinity flag is rejected before BLS work | no | -| `testDepositRejectsTooSmallAmount` | `msg.value < 1 ether` is rejected | no | -| `testDepositRejectsNonGweiAmount` | `msg.value` not aligned to 1 gwei is rejected | no | -| `testDepositRejectsWrongPubkeyLength` | `pubkey.length != 48` is rejected | no | -| `testDepositRejectsWrongSignatureLength` | `signature.length != 96` is rejected | no | -| `testTopUpRejectsTooSmallAmount` | `top_up` with `msg.value < 1 ether` is rejected | no | -| `testTopUpRejectsWrongPubkeyLength` | `top_up` with `pubkey.length != 48` is rejected | no | +| `testDepositEnqueuesAndReads` | A `py_ecc`-produced deposit is accepted, enqueued, and the `SYSTEM_ADDRESS` read returns the exact 88-byte record | **yes** | +| `testTopUpEnqueuesAndReads` | `top_up(...)` enqueues; the system read returns the exact 56-byte record | no | +| `testSystemReadRequiresSystemAddress` | An empty-calldata read from a non-`SYSTEM_ADDRESS` caller reverts | no | +| `testPerBlockCapAndFifo` | 17 queued → first read drains the 16-record cap, second drains the remainder (FIFO) | no | +| `testDepositRejectsTamperedAmount` | A different `msg.value` than was signed fails the pairing check; nothing enqueued | **yes** | +| `testDepositRejectsTamperedSignature` | Flipping a signature bit is rejected (subgroup/pairing failure); nothing enqueued | **yes** | +| `testDepositRejectsPubkeySignBitFlip` | Flipping only the pubkey sign flag is rejected by the sign-bit binding (audit Finding 2 regression); nothing enqueued | no | +| `testDepositRejectsSignatureSignBitFlip` | Flipping only the signature sign flag is rejected by the sign-bit binding; nothing enqueued | no | +| `testDepositRejectsInfinityPubkey` | `pubkey` with infinity flag is rejected before BLS work; nothing enqueued | no | +| `testDepositRejectsTooSmallAmount` | `msg.value < 1 ether` is rejected; nothing enqueued | no | +| `testDepositRejectsWrongPubkeyLength` | `pubkey.length != 48` is rejected; nothing enqueued | no | +| `testTopUpRejectsTooSmallAmount` | `top_up` with `msg.value < 1 ether` is rejected; nothing enqueued | no | +| `testTopUpRejectsWrongPubkeyLength` | `top_up` with `pubkey.length != 48` is rejected; nothing enqueued | no | diff --git a/assets/eip-draft_builder_deposit/builder_deposit_contract.sol b/assets/eip-draft_builder_deposit/builder_deposit_contract.sol index 2c472c0c7eab36..361eb62333d053 100644 --- a/assets/eip-draft_builder_deposit/builder_deposit_contract.sol +++ b/assets/eip-draft_builder_deposit/builder_deposit_contract.sol @@ -6,23 +6,32 @@ pragma experimental ABIEncoderV2; // for `Fp` / `Fp2` struct calldata in `depos // ─────────────────────────────────────────────────────────────────────────────── // EIP-XXXX: Builder Deposit Contract // -// Predeploy installed at BUILDER_DEPOSIT_CONTRACT_ADDRESS. Exposes: +// Two EIP-7685 request predeploys for the EIP-7732 builder population, modelled +// on the EIP-7002 (withdrawals) / EIP-7251 (consolidations) "request bus": // -// * deposit(pubkey, wc, signature, pubkey_y, signature_y) — BLS proof-of- -// possession verified on chain via the EIP-2537 precompiles. Emits -// `BuilderDepositEvent`. The consensus layer trusts the EL pairing check -// and does not re-verify. +// * BuilderDepositContract @ BUILDER_DEPOSIT_CONTRACT_ADDRESS (request type 0x03) +// deposit(pubkey, wc, signature, pubkey_y, signature_y) — verifies the BLS +// proof-of-possession on chain via the EIP-2537 precompiles, then appends +// a deposit record to the in-state request queue. // -// * top_up(pubkey, wc) — unverified additional deposit for an already- -// registered builder. Emits `BuilderTopUpEvent`. The consensus layer -// rejects top-ups whose `pubkey` is not in the builder set. +// * BuilderTopUpContract @ BUILDER_TOPUP_CONTRACT_ADDRESS (request type 0x04) +// top_up(pubkey) — unverified additional stake for an already-registered +// builder; appends a top-up record to its queue. The consensus layer +// rejects top-ups whose `pubkey` is not in the builder set. // -// Storage is intentionally minimal — a single monotonic `deposit_count`. The -// consensus layer extracts builder deposits from the event log, not from a -// Merkle tree on chain (so no `get_deposit_root` / `get_deposit_count` view -// functions, unlike the validator deposit contract). +// Neither contract emits logs. Both share the `RequestQueue` base: a user call +// appends a record; at the end of the block a `SYSTEM_ADDRESS` call with empty +// calldata pops up to MAX_REQUESTS_PER_BLOCK records and returns them as the +// flat `request_data` for that predeploy's request type. The execution layer +// prepends the type byte and commits the result in the block `requests_hash` +// (EIP-7685). Each contract is a standard single-type request predeploy, so the +// EL needs no new read semantics — exactly the withdrawals/consolidations model. // -// Algorithms used: +// Anti-spam is the staked value itself: every deposit/top-up locks >= 1 ETH and +// (for deposits) pays for gas-metered BLS verification, so there is no EIP-1559 +// request fee (unlike EIP-7002/7251, whose requests would otherwise be free). +// +// Algorithms used (BuilderDepositContract): // * Signing root — SSZ `hash_tree_root` of `DepositMessage` mixed with // `DOMAIN_BUILDER_DEPOSIT` per `compute_signing_root`. // * Hash-to-curve — `expand_message_xmd` + SSWU/3-isogeny via EIP-2537 @@ -31,18 +40,66 @@ pragma experimental ABIEncoderV2; // for `Fp` / `Fp2` struct calldata in `depos // via EIP-2537 `PAIRING_CHECK` (subgroup-checked). // * Fp reduction — `MODEXP` precompile (0x05) with exponent 1. // -// Design notes (vs. the validator deposit contract): -// * No on-chain G1 / G2 decompression. Callers MUST supply affine Y -// coordinates. This removes the Fp / Fp2 arithmetic kernel and the -// Sarkar/Adj Fp2-sqrt routine from the canonical bytecode. -// * No "Y matches sign bit" check. A caller supplying ±Y substitutes ±pk -// (or ±σ) into the pairing equation, which then fails; the pairing check -// is the sole signature-correctness oracle. -// * No `IDepositContract` / ERC-165 inheritance — this is not a drop-in -// replacement for the validator deposit contract; the ABI is fresh. +// Design notes: +// * Callers supply affine Y coordinates; there is no on-chain decompression +// or Fp/Fp2 arithmetic kernel. The supplied Y is bound to the compressed +// sign bit (see `_constructG1`/`_constructG2`), and a builder-specific +// signing domain prevents cross-context replay with validator deposits. +// * BLS verification gates entry into the deposit queue, so dequeued records +// are pre-verified and carry no signature (the CL trusts the EL check). // ─────────────────────────────────────────────────────────────────────────────── -contract BuilderDepositContract { +// EIP-7002-style request queue shared by both builder predeploys. A user call +// appends an opaque record; the end-of-block `SYSTEM_ADDRESS` system call drains +// up to MAX_REQUESTS_PER_BLOCK records (FIFO) and returns their concatenation as +// the predeploy's flat `request_data`. No fee: callers of the derived contracts +// already lock staked value, which is the anti-spam gate. +contract RequestQueue { + // Address used to invoke the end-of-block system operation (EIP-7002/7251). + address constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + + // Maximum records drained into a single block. Mirrors EIP-7002's + // MAX_WITHDRAWAL_REQUESTS_PER_BLOCK; excess records wait for later blocks. + uint constant MAX_REQUESTS_PER_BLOCK = 16; + + // FIFO queue of opaque request records, drained from `queueHead`. + bytes[] internal queue; + uint internal queueHead; + + // Append a request record. Called by the derived contract's entrypoint + // after it has validated (and, for deposits, BLS-verified) the request. + function _enqueue(bytes memory record) internal { + queue.push(record); + } + + // End-of-block read-out. Only `SYSTEM_ADDRESS` may call it (with empty + // calldata). Pops up to MAX_REQUESTS_PER_BLOCK records FIFO, returns their + // concatenation as the flat `request_data`, and advances the head. The EL + // prepends this predeploy's EIP-7685 request-type byte. + fallback() external { + require(msg.sender == SYSTEM_ADDRESS, "RequestQueue: only system"); + + uint head = queueHead; + uint tail = queue.length; + uint n = tail - head; + if (n > MAX_REQUESTS_PER_BLOCK) { + n = MAX_REQUESTS_PER_BLOCK; + } + + // Concatenate the next `n` records into a single flat byte string. + bytes memory out; + for (uint i = 0; i < n; i++) { + out = abi.encodePacked(out, queue[head + i]); + } + queueHead = head + n; + + assembly { + return(add(out, 0x20), mload(out)) + } + } +} + +contract BuilderDepositContract is RequestQueue { // ── Constants ────────────────────────────────────────────────────────── @@ -128,40 +185,22 @@ contract BuilderDepositContract { // Point on BLS12-381 over Fp2. struct G2Point { Fp2 X; Fp2 Y; } - // ── Events ───────────────────────────────────────────────────────────── + // ── Request record ───────────────────────────────────────────────────── + // + // EIP-7685 `request_data` for a builder deposit (request type 0x03) is the + // concatenation of one record per dequeued deposit: + // + // pubkey (48) ++ withdrawal_credentials (32) ++ amount_gwei (8, LE) = 88 bytes + // + // The signature is intentionally absent: it was verified at submission, so + // the consensus layer trusts the record without re-pairing. - /// @notice Emitted on a verified builder deposit. The CL trusts the EL's - /// pairing check; no second pairing on the consensus side. - event BuilderDepositEvent( - bytes pubkey, // 48 bytes, compressed G1 - bytes32 withdrawal_credentials, - uint64 amount_gwei, - bytes signature, // 96 bytes, compressed G2 - uint64 index - ); - - /// @notice Emitted on an unverified top-up. The CL MUST reject if the - /// pubkey is not already a registered builder. No `withdrawal_credentials` - /// field: a top-up only adds stake to an existing builder, and the CL uses - /// the credentials established by that builder's verified `BuilderDepositEvent`. - /// Omitting the field removes any ability for an unauthenticated caller to - /// influence a builder's withdrawal target. - event BuilderTopUpEvent( - bytes pubkey, - uint64 amount_gwei, - uint64 index - ); - - // ── Storage ──────────────────────────────────────────────────────────── - - // Monotonic deposit index, incremented on every successful `deposit` or - // `top_up`. Used only as the `index` field of the emitted events; no - // on-chain Merkle tree depends on it. - uint64 internal deposit_count; - - // ── External entrypoints ─────────────────────────────────────────────── - - /// @notice BLS-verified builder deposit. + // ── External entrypoint ──────────────────────────────────────────────── + + /// @notice BLS-verified builder deposit. On success, appends a deposit + /// record to the request queue (no log). The deposited ETH is locked in the + /// contract; the consensus layer credits the builder from the dequeued + /// request at the end of the block. function deposit( bytes calldata pubkey, bytes32 withdrawal_credentials, @@ -178,8 +217,8 @@ contract BuilderDepositContract { require(!_isInfinityFlagSet(pubkey[0]), "BuilderDeposit: infinity pubkey"); require(!_isInfinityFlagSet(signature[0]), "BuilderDeposit: infinity signature"); - // BLS proof-of-possession check. Performed before any state change or - // log emission so an invalid signature reverts the whole call. + // BLS proof-of-possession check. Performed before the record is queued + // so an invalid signature reverts the whole call and never enqueues. bytes32 signingRoot = _computeDepositSigningRoot( pubkey, withdrawal_credentials, uint64(deposit_amount) ); @@ -191,29 +230,17 @@ contract BuilderDepositContract { "BuilderDeposit: invalid BLS signature" ); - uint64 idx = deposit_count; - deposit_count = idx + 1; - emit BuilderDepositEvent( - pubkey, withdrawal_credentials, uint64(deposit_amount), signature, idx - ); + _enqueue(abi.encodePacked( + pubkey, withdrawal_credentials, _le64(uint64(deposit_amount)) + )); } - /// @notice Unverified top-up for an existing builder. The CL rejects - /// top-ups against unregistered pubkeys. Takes only the `pubkey`: the - /// top-up adds stake to whatever builder is already registered under that - /// key, and cannot set or change its withdrawal credentials. - function top_up( - bytes calldata pubkey - ) external payable { - require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderDeposit: invalid pubkey length"); - require(msg.value >= BUILDER_MIN_DEPOSIT, "BuilderDeposit: deposit value too low"); - require(msg.value % 1 gwei == 0, "BuilderDeposit: deposit value not multiple of gwei"); - uint deposit_amount = msg.value / 1 gwei; - require(deposit_amount <= type(uint64).max, "BuilderDeposit: deposit value too high"); - - uint64 idx = deposit_count; - deposit_count = idx + 1; - emit BuilderTopUpEvent(pubkey, uint64(deposit_amount), idx); + // 8-byte little-endian encoding of a uint64 (SSZ amount encoding). + function _le64(uint64 v) internal pure returns (bytes memory r) { + r = new bytes(8); + for (uint i = 0; i < 8; i++) { + r[i] = bytes1(uint8(v >> (8 * i))); + } } // ── Signing-root computation ─────────────────────────────────────────── @@ -550,3 +577,42 @@ contract BuilderDepositContract { return _fpSignBit(y.a); } } + +// ─────────────────────────────────────────────────────────────────────────────── +// Builder top-up predeploy — EIP-7685 request type 0x04, installed at +// BUILDER_TOPUP_CONTRACT_ADDRESS. +// +// Unverified: adds stake to an already-registered builder. There is no BLS +// check (a top-up does not register a new key), so this contract carries none +// of the cryptographic machinery — just the shared request queue. The consensus +// layer MUST reject top-ups whose pubkey is not already in the builder set. +// +// No `withdrawal_credentials`: a top-up only adds stake to an existing builder, +// whose credentials are fixed by its verified deposit. Omitting the field +// denies an unauthenticated caller any influence over a builder's withdrawal +// target. +// +// EIP-7685 `request_data` is the concatenation of one record per dequeued +// top-up: pubkey (48) ++ amount_gwei (8, LE) = 56 bytes. +// ─────────────────────────────────────────────────────────────────────────────── +contract BuilderTopUpContract is RequestQueue { + uint constant PUBLIC_KEY_LENGTH = 48; + uint constant BUILDER_MIN_DEPOSIT = 1 ether; + + /// @notice Unverified top-up. On success, appends a top-up record to the + /// request queue (no log). The ETH is locked in the contract; the consensus + /// layer credits the existing builder from the dequeued request. + function top_up(bytes calldata pubkey) external payable { + require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderTopUp: invalid pubkey length"); + require(msg.value >= BUILDER_MIN_DEPOSIT, "BuilderTopUp: deposit value too low"); + require(msg.value % 1 gwei == 0, "BuilderTopUp: deposit value not multiple of gwei"); + uint amount = msg.value / 1 gwei; + require(amount <= type(uint64).max, "BuilderTopUp: deposit value too high"); + + bytes memory amountLE = new bytes(8); + for (uint i = 0; i < 8; i++) { + amountLE[i] = bytes1(uint8(uint64(amount) >> (8 * i))); + } + _enqueue(abi.encodePacked(pubkey, amountLE)); + } +} diff --git a/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol b/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol index b78a247ac1b8c2..9cb9b1fede18e4 100644 --- a/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol +++ b/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol @@ -6,42 +6,59 @@ import "../builder_deposit_contract.sol"; import "./TestHarness.sol"; import "./Vectors.sol"; -/// @notice Cross-verification tests for BuilderDepositContract. -/// -/// Expected values come from py_ecc (see ../gen_vectors.py) and are baked -/// into ./Vectors.sol as Solidity literals. +/// @dev Minimal subset of the Foundry cheatcode interface (avoids a forge-std +/// dependency on this 0.6.11 project). +interface Vm { + function prank(address) external; +} + +/// @notice Tests for the EIP-7685 request-bus builder predeploys. /// -/// The full deposit-verification tests require the EIP-2537 BLS precompiles -/// (foundry's default Prague EVM). The signing-root cross-check and the -/// length/amount/flag rejection tests do not — they exercise SHA-256 and -/// the EVM only. +/// Expected values come from py_ecc (see ../gen_vectors.py) baked into +/// ./Vectors.sol. The full deposit-verification tests require the EIP-2537 BLS +/// precompiles (foundry's default Prague EVM); the queue / system-read / input +/// tests do not. contract BuilderDepositTest { - BuilderDepositHarness internal harness; + Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + address constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + + uint constant DEPOSIT_RECORD_LEN = 88; // pubkey 48 + wc 32 + amount 8 + uint constant TOPUP_RECORD_LEN = 56; // pubkey 48 + amount 8 + + BuilderDepositHarness internal dep; + BuilderTopUpHarness internal top; function setUp() public { - harness = new BuilderDepositHarness(); + dep = new BuilderDepositHarness(); + top = new BuilderTopUpHarness(); + } + + // Drive the end-of-block system read: call the predeploy as SYSTEM_ADDRESS + // with empty calldata; the fallback returns the flat request_data. + function _systemRead(address target) internal returns (bytes memory) { + vm.prank(SYSTEM_ADDRESS); + (bool ok, bytes memory ret) = target.call(""); + require(ok, "system read reverted"); + return ret; + } + + function _le64(uint64 v) internal pure returns (bytes memory r) { + r = new bytes(8); + for (uint i = 0; i < 8; i++) r[i] = bytes1(uint8(v >> (8 * i))); } // ── Cross-check: SSZ signing root ────────────────────────────────────── function testComputeSigningRoot() public { - ( - bytes memory pubkey, - bytes32 wc, - , - , - uint64 amount_gwei, - , - ) = Vectors.depositCase(); - bytes32 expected = Vectors.depositSigningRoot(); - bytes32 got = harness.computeDepositSigningRoot(pubkey, wc, amount_gwei); - require(got == expected, "signing root mismatch vs py_ecc"); + (bytes memory pubkey, bytes32 wc, , , uint64 amount_gwei, , ) = Vectors.depositCase(); + bytes32 got = dep.computeDepositSigningRoot(pubkey, wc, amount_gwei); + require(got == Vectors.depositSigningRoot(), "signing root mismatch vs py_ecc"); } - // ── Happy path: verified deposit + top-up ────────────────────────────── + // ── Happy path: verified deposit enqueues, system read emits the record ── - function testDepositValid() public { + function testDepositEnqueuesAndReads() public { ( bytes memory pubkey, bytes32 wc, @@ -52,43 +69,58 @@ contract BuilderDepositTest { BuilderDepositContract.Fp2 memory signature_y ) = Vectors.depositCase(); - uint64 before = harness.getDepositCount(); - harness.deposit{value: uint(amount_gwei) * 1 gwei}( - pubkey, wc, signature, pubkey_y, signature_y - ); - require(harness.getDepositCount() == before + 1, "deposit count did not increment"); + require(dep.pendingCount() == 0, "starts empty"); + dep.deposit{value: uint(amount_gwei) * 1 gwei}(pubkey, wc, signature, pubkey_y, signature_y); + require(dep.pendingCount() == 1, "one record queued"); + + bytes memory data = _systemRead(address(dep)); + bytes memory expected = abi.encodePacked(pubkey, wc, _le64(amount_gwei)); + require(data.length == DEPOSIT_RECORD_LEN, "deposit record length"); + require(keccak256(data) == keccak256(expected), "deposit record bytes mismatch"); + require(dep.pendingCount() == 0, "queue drained"); } - function testTopUpValid() public { - (bytes memory pubkey, , , , , , ) = Vectors.depositCase(); - uint64 before = harness.getDepositCount(); - harness.top_up{value: 2 ether}(pubkey); - require(harness.getDepositCount() == before + 1, "top_up did not increment count"); + function testTopUpEnqueuesAndReads() public { + bytes memory pubkey = new bytes(48); + for (uint i = 0; i < 48; i++) pubkey[i] = bytes1(uint8(i + 1)); + + top.top_up{value: 3 ether}(pubkey); + require(top.pendingCount() == 1, "one top-up queued"); + + bytes memory data = _systemRead(address(top)); + bytes memory expected = abi.encodePacked(pubkey, _le64(3_000_000_000)); + require(data.length == TOPUP_RECORD_LEN, "top-up record length"); + require(keccak256(data) == keccak256(expected), "top-up record bytes mismatch"); + require(top.pendingCount() == 0, "queue drained"); } - function testMonotonicIndex() public { - ( - bytes memory pubkey, - bytes32 wc, - bytes memory signature, - , - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) = Vectors.depositCase(); + // ── System read access control + FIFO / per-block cap ────────────────── - require(harness.getDepositCount() == 0, "expected initial count == 0"); - harness.deposit{value: uint(amount_gwei) * 1 gwei}( - pubkey, wc, signature, pubkey_y, signature_y - ); - require(harness.getDepositCount() == 1, "after first deposit count == 1"); - harness.top_up{value: 1 ether}(pubkey); - require(harness.getDepositCount() == 2, "after top_up count == 2"); - harness.top_up{value: 1 ether}(pubkey); - require(harness.getDepositCount() == 3, "after second top_up count == 3"); + function testSystemReadRequiresSystemAddress() public { + // Without the SYSTEM_ADDRESS prank, the fallback must revert. + (bool ok, ) = address(dep).call(""); + require(!ok, "non-system system-read must revert"); + } + + function testPerBlockCapAndFifo() public { + bytes memory pubkey = new bytes(48); + // Enqueue MAX_REQUESTS_PER_BLOCK + 1 = 17 top-ups (unverified, so easy + // to queue many without distinct signatures). + for (uint i = 0; i < 17; i++) { + top.top_up{value: 1 ether}(pubkey); + } + require(top.pendingCount() == 17, "17 queued"); + + bytes memory first = _systemRead(address(top)); + require(first.length == 16 * TOPUP_RECORD_LEN, "first read drains the 16-record cap"); + require(top.pendingCount() == 1, "one remains after cap"); + + bytes memory second = _systemRead(address(top)); + require(second.length == 1 * TOPUP_RECORD_LEN, "second read drains the remainder"); + require(top.pendingCount() == 0, "queue empty"); } - // ── Negative paths: BLS check ────────────────────────────────────────── + // ── Negative paths: BLS check (nothing should enqueue) ───────────────── function testDepositRejectsTamperedAmount() public { ( @@ -100,14 +132,12 @@ contract BuilderDepositTest { BuilderDepositContract.Fp memory pubkey_y, BuilderDepositContract.Fp2 memory signature_y ) = Vectors.depositCase(); - // Sending a different msg.value puts a different `amount_gwei` into - // the signing root, so the pairing check must reject. - uint tamperedValue = (uint(amount_gwei) + 1) * 1 gwei; - try harness.deposit{value: tamperedValue}( + try dep.deposit{value: (uint(amount_gwei) + 1) * 1 gwei}( pubkey, wc, signature, pubkey_y, signature_y ) { require(false, "tampered amount should revert"); } catch {} + require(dep.pendingCount() == 0, "nothing enqueued on reject"); } function testDepositRejectsTamperedSignature() public { @@ -122,19 +152,15 @@ contract BuilderDepositTest { ) = Vectors.depositCase(); bytes memory tampered = _copy(signature); tampered[10] = tampered[10] ^ bytes1(uint8(1)); - try harness.deposit{value: uint(amount_gwei) * 1 gwei}( + try dep.deposit{value: uint(amount_gwei) * 1 gwei}( pubkey, wc, tampered, pubkey_y, signature_y ) { require(false, "tampered signature should revert"); } catch {} + require(dep.pendingCount() == 0, "nothing enqueued on reject"); } - // Regression test for the sign-bit binding (audit Finding 2). The valid - // vector has pubkey sign flag == sign(pubkey_y). Flipping ONLY the pubkey's - // sign flag (leaving X and the supplied pubkey_y unchanged) models an - // attacker who verifies (X, +Y) but emits bytes that decompress to (X, -Y). - // With the sign-bit consistency check in `_constructG1`, this must revert - // BEFORE any pairing work. Without the check it would have passed. + // Regression for audit Finding 2: flip only the pubkey sign flag (keep Y). function testDepositRejectsPubkeySignBitFlip() public { ( bytes memory pubkey, @@ -146,16 +172,15 @@ contract BuilderDepositTest { BuilderDepositContract.Fp2 memory signature_y ) = Vectors.depositCase(); bytes memory flipped = _copy(pubkey); - flipped[0] = flipped[0] ^ bytes1(uint8(0x20)); // flip sign flag only - try harness.deposit{value: uint(amount_gwei) * 1 gwei}( + flipped[0] = flipped[0] ^ bytes1(uint8(0x20)); + try dep.deposit{value: uint(amount_gwei) * 1 gwei}( flipped, wc, signature, pubkey_y, signature_y ) { require(false, "pubkey sign-bit flip should revert"); } catch {} + require(dep.pendingCount() == 0, "nothing enqueued on reject"); } - // Same regression, signature side: flip the signature's sign flag while - // keeping signature_y, exercising `_constructG2`'s sign-bit check. function testDepositRejectsSignatureSignBitFlip() public { ( bytes memory pubkey, @@ -168,15 +193,14 @@ contract BuilderDepositTest { ) = Vectors.depositCase(); bytes memory flipped = _copy(signature); flipped[0] = flipped[0] ^ bytes1(uint8(0x20)); - try harness.deposit{value: uint(amount_gwei) * 1 gwei}( + try dep.deposit{value: uint(amount_gwei) * 1 gwei}( pubkey, wc, flipped, pubkey_y, signature_y ) { require(false, "signature sign-bit flip should revert"); } catch {} + require(dep.pendingCount() == 0, "nothing enqueued on reject"); } - // ── Negative paths: compressed-encoding flags ────────────────────────── - function testDepositRejectsInfinityPubkey() public { ( bytes memory pubkey, @@ -187,109 +211,57 @@ contract BuilderDepositTest { BuilderDepositContract.Fp memory pubkey_y, BuilderDepositContract.Fp2 memory signature_y ) = Vectors.depositCase(); - bytes memory infPubkey = _copy(pubkey); - infPubkey[0] = infPubkey[0] | bytes1(uint8(0x40)); // set infinity flag - try harness.deposit{value: uint(amount_gwei) * 1 gwei}( - infPubkey, wc, signature, pubkey_y, signature_y + bytes memory inf = _copy(pubkey); + inf[0] = inf[0] | bytes1(uint8(0x40)); + try dep.deposit{value: uint(amount_gwei) * 1 gwei}( + inf, wc, signature, pubkey_y, signature_y ) { require(false, "infinity pubkey should revert"); } catch {} - } - - function testDepositRejectsInfinitySignature() public { - ( - bytes memory pubkey, - bytes32 wc, - bytes memory signature, - , - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) = Vectors.depositCase(); - bytes memory infSig = _copy(signature); - infSig[0] = infSig[0] | bytes1(uint8(0x40)); - try harness.deposit{value: uint(amount_gwei) * 1 gwei}( - pubkey, wc, infSig, pubkey_y, signature_y - ) { - require(false, "infinity signature should revert"); - } catch {} + require(dep.pendingCount() == 0, "nothing enqueued on reject"); } // ── Negative paths: input-shape validation ───────────────────────────── function testDepositRejectsTooSmallAmount() public { - // BLS data doesn't matter — the amount check fires first. bytes memory pubkey = new bytes(48); bytes memory signature = new bytes(96); - BuilderDepositContract.Fp memory zero_fp = - BuilderDepositContract.Fp(0, 0); - BuilderDepositContract.Fp2 memory zero_fp2 = - BuilderDepositContract.Fp2(zero_fp, zero_fp); - try harness.deposit{value: 0.5 ether}( - pubkey, bytes32(0), signature, zero_fp, zero_fp2 - ) { + BuilderDepositContract.Fp memory z = BuilderDepositContract.Fp(0, 0); + BuilderDepositContract.Fp2 memory z2 = BuilderDepositContract.Fp2(z, z); + try dep.deposit{value: 0.5 ether}(pubkey, bytes32(0), signature, z, z2) { require(false, "deposit < 1 ether should revert"); } catch {} - } - - function testDepositRejectsNonGweiAmount() public { - bytes memory pubkey = new bytes(48); - bytes memory signature = new bytes(96); - BuilderDepositContract.Fp memory zero_fp = - BuilderDepositContract.Fp(0, 0); - BuilderDepositContract.Fp2 memory zero_fp2 = - BuilderDepositContract.Fp2(zero_fp, zero_fp); - // 1 ether + 1 wei is not a multiple of 1 gwei. - try harness.deposit{value: 1 ether + 1}( - pubkey, bytes32(0), signature, zero_fp, zero_fp2 - ) { - require(false, "non-gwei value should revert"); - } catch {} + require(dep.pendingCount() == 0, "nothing enqueued on reject"); } function testDepositRejectsWrongPubkeyLength() public { - bytes memory pubkey = new bytes(47); // one short + bytes memory pubkey = new bytes(47); bytes memory signature = new bytes(96); - BuilderDepositContract.Fp memory zero_fp = - BuilderDepositContract.Fp(0, 0); - BuilderDepositContract.Fp2 memory zero_fp2 = - BuilderDepositContract.Fp2(zero_fp, zero_fp); - try harness.deposit{value: 1 ether}( - pubkey, bytes32(0), signature, zero_fp, zero_fp2 - ) { + BuilderDepositContract.Fp memory z = BuilderDepositContract.Fp(0, 0); + BuilderDepositContract.Fp2 memory z2 = BuilderDepositContract.Fp2(z, z); + try dep.deposit{value: 1 ether}(pubkey, bytes32(0), signature, z, z2) { require(false, "47-byte pubkey should revert"); } catch {} - } - - function testDepositRejectsWrongSignatureLength() public { - bytes memory pubkey = new bytes(48); - bytes memory signature = new bytes(95); // one short - BuilderDepositContract.Fp memory zero_fp = - BuilderDepositContract.Fp(0, 0); - BuilderDepositContract.Fp2 memory zero_fp2 = - BuilderDepositContract.Fp2(zero_fp, zero_fp); - try harness.deposit{value: 1 ether}( - pubkey, bytes32(0), signature, zero_fp, zero_fp2 - ) { - require(false, "95-byte signature should revert"); - } catch {} + require(dep.pendingCount() == 0, "nothing enqueued on reject"); } function testTopUpRejectsTooSmallAmount() public { bytes memory pubkey = new bytes(48); - try harness.top_up{value: 0.5 ether}(pubkey) { + try top.top_up{value: 0.5 ether}(pubkey) { require(false, "top_up < 1 ether should revert"); } catch {} + require(top.pendingCount() == 0, "nothing enqueued on reject"); } function testTopUpRejectsWrongPubkeyLength() public { bytes memory pubkey = new bytes(47); - try harness.top_up{value: 1 ether}(pubkey) { + try top.top_up{value: 1 ether}(pubkey) { require(false, "47-byte pubkey should revert"); } catch {} + require(top.pendingCount() == 0, "nothing enqueued on reject"); } - // ── helpers ──────────────────────────────────────────────────────────── + // ── helper ───────────────────────────────────────────────────────────── function _copy(bytes memory src) internal pure returns (bytes memory dst) { dst = new bytes(src.length); diff --git a/assets/eip-draft_builder_deposit/test/TestHarness.sol b/assets/eip-draft_builder_deposit/test/TestHarness.sol index 7f37713faedd27..ada3f65d6d5e83 100644 --- a/assets/eip-draft_builder_deposit/test/TestHarness.sol +++ b/assets/eip-draft_builder_deposit/test/TestHarness.sol @@ -4,20 +4,16 @@ pragma experimental ABIEncoderV2; import "../builder_deposit_contract.sol"; -/// @notice Test harness that inherits BuilderDepositContract and exposes -/// the internal helpers and storage slots needed for unit tests. -/// `deposit(...)` and `top_up(...)` are inherited as-is — the harness does -/// not override them — so tests exercise the same external entrypoints -/// that the real predeploy exposes. +/// @notice Test harness for the deposit predeploy. Inherits BuilderDepositContract +/// (so `deposit(...)` and the inherited `SYSTEM_ADDRESS` system-read `fallback` +/// are exercised as-is) and exposes the internal queue depth plus the SSZ +/// signing-root helper for cross-checking against py_ecc. contract BuilderDepositHarness is BuilderDepositContract { - /// @notice Read access to the monotonic deposit counter, used by tests - /// to check that successful entrypoints increment it by exactly one. - function getDepositCount() external view returns (uint64) { - return deposit_count; + /// @notice Number of queued-but-not-yet-dequeued records. + function pendingCount() external view returns (uint) { + return queue.length - queueHead; } - /// @notice Exposes the SSZ signing-root computation so the test suite - /// can cross-check it against the canonical Python (py_ecc) reference. function computeDepositSigningRoot( bytes calldata pubkey, bytes32 withdrawal_credentials, @@ -26,3 +22,10 @@ contract BuilderDepositHarness is BuilderDepositContract { return _computeDepositSigningRoot(pubkey, withdrawal_credentials, amount_gwei); } } + +/// @notice Test harness for the top-up predeploy. +contract BuilderTopUpHarness is BuilderTopUpContract { + function pendingCount() external view returns (uint) { + return queue.length - queueHead; + } +} From bf46f27d69ab319265bb45188f4d74afc62f451b Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 26 May 2026 16:22:24 -0400 Subject: [PATCH 03/12] Add EIP-1559 request fee, unsigned deposit amount, and round-2 audit fixes Request fee (like EIP-7002/7251): RequestQueue gains an excess/count fee market with fake_exponential; deposit/top_up require msg.value >= stake + fee. No EXCESS_INHIBITOR (predeploys install with empty storage, so excess starts at the minimum fee). Unsigned amount: the BLS proof-of-possession now commits only to the 2-field message (pubkey, withdrawal_credentials); amount_gwei is an explicit, unsigned parameter. Signing the amount added no security (the unverified top_up already adds unsigned stake) and would otherwise force a signed value derived from a fee unknown at signing time. The distinct 2-field message also reinforces cross-context replay protection. Round-2 audit fixes: - Queue storage is now a head/tail ring over a mapping that resets both pointers to 0 when emptied (EIP-7002 dequeue behavior), bounding storage to peak in-flight depth instead of leaking a slot per request forever. - fallback requires empty calldata, so only the system read-out / fee getter reach it. - Spec: a 0x03 record for an already-registered pubkey MUST be treated as a top-up (credit stake, never change withdrawal credentials), making the replayable deposit signature harmless. - Fixed stale entrypoint signatures in the contract header comment. Tests: 20 passing (17 without EIP-2537), incl. fee dynamics, queue reset, and fallback-calldata regressions. Vectors regenerated for the 2-field signing message. --- EIPS/eip-draft_builder_deposit.md | 66 +++-- assets/eip-draft_builder_deposit/README.md | 17 +- .../builder_deposit_contract.sol | 245 ++++++++++++------ .../eip-draft_builder_deposit/gen_vectors.py | 17 +- .../test/BuilderDeposit.t.sol | 189 ++++++++++---- .../test/TestHarness.sol | 22 +- .../test/Vectors.sol | 8 +- 7 files changed, 398 insertions(+), 166 deletions(-) diff --git a/EIPS/eip-draft_builder_deposit.md b/EIPS/eip-draft_builder_deposit.md index 91b6165cb8623e..bdb10c16eb018b 100644 --- a/EIPS/eip-draft_builder_deposit.md +++ b/EIPS/eip-draft_builder_deposit.md @@ -14,7 +14,7 @@ requires: 2537, 7685, 7732 Predeploy two [EIP-7685](./eip-7685.md) request contracts for the [EIP-7732](./eip-7732.md) builder population, modelled on the request bus that [EIP-7002](./eip-7002.md) (withdrawals) and [EIP-7251](./eip-7251.md) (consolidations) use: -- a builder deposit contract whose `deposit(...)` verifies a BLS proof-of-possession against the supplied `DepositMessage` using the [EIP-2537](./eip-2537.md) precompiles, then appends a deposit request to its queue; and +- a builder deposit contract whose `deposit(...)` verifies a BLS proof-of-possession over the `pubkey` and `withdrawal_credentials` using the [EIP-2537](./eip-2537.md) precompiles, then appends a deposit request to its queue; and - a builder top-up contract whose `top_up(...)` appends an additional-stake request for an existing builder without on-chain signature verification. Each contract maintains an in-state request queue drained by an end-of-block `SYSTEM_ADDRESS` system call; the dequeued records become the contract's [EIP-7685](./eip-7685.md) `request_data`, committed in the block `requests_hash`. Neither contract emits logs. Both are independent of the existing validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`. @@ -51,6 +51,9 @@ All address and request-type values below are placeholders pending allocation in | `BUILDER_TOPUP_REQUEST_TYPE` | `0x04` | [EIP-7685](./eip-7685.md) request-type byte for builder top-ups (placeholder) | | `SYSTEM_ADDRESS` | `0xfffffffffffffffffffffffffffffffffffffffe` | Address that invokes the end-of-block system call (as in [EIP-7002](./eip-7002.md)) | | `MAX_REQUESTS_PER_BLOCK` | `16` | Maximum records each contract drains into one block | +| `TARGET_REQUESTS_PER_BLOCK` | `2` | Per-block request count above which the fee rises | +| `MIN_REQUEST_FEE` | `1` | Minimum request fee, in wei | +| `REQUEST_FEE_UPDATE_FRACTION` | `17` | Controls the fee's rate of change | | `DOMAIN_BUILDER_DEPOSIT` | `0x0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9` | Signing domain for builder deposit messages. The `0x0b000000` domain type is a placeholder pending consensus-specs allocation; it MUST differ from the validator `DOMAIN_DEPOSIT` (`0x03000000…`) so signatures are not interchangeable between the two contracts | | `BLS12_G2ADD` | `0x0d` | [EIP-2537](./eip-2537.md) precompile address | | `BLS12_PAIRING_CHECK` | `0x0f` | [EIP-2537](./eip-2537.md) precompile address | @@ -66,9 +69,26 @@ If either account is not empty at fork time, clients MUST abort initialisation. ### Request queue and system call -Both predeploys follow the [EIP-7002](./eip-7002.md) / [EIP-7251](./eip-7251.md) request-bus pattern. Each maintains a FIFO queue of request records in its own storage. A user-facing entrypoint validates a request and appends one record. At the end of the block, the execution layer MUST invoke each predeploy with a call from `SYSTEM_ADDRESS` and empty calldata; the predeploy MUST dequeue up to `MAX_REQUESTS_PER_BLOCK` records (oldest first), return their concatenation as that contract's `request_data`, and advance its queue head past the returned records. Records beyond the per-block cap remain queued for subsequent blocks. A call with empty calldata from any address other than `SYSTEM_ADDRESS` MUST revert. +Both predeploys follow the [EIP-7002](./eip-7002.md) / [EIP-7251](./eip-7251.md) request-bus pattern. Each maintains a FIFO queue of request records in its own storage and an EIP-1559-style `excess` counter. A user-facing entrypoint validates a request, charges the current fee, and appends one record. -The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). Neither contract emits logs, and there is no request fee: the staked value (and, for deposits, gas-metered verification) is the anti-spam gate. +A call with empty calldata dispatches on the caller: + +- From `SYSTEM_ADDRESS` (the end-of-block system call): the predeploy MUST dequeue up to `MAX_REQUESTS_PER_BLOCK` records (oldest first), return their concatenation as that contract's `request_data`, advance its queue head past the returned records, then update `excess` from the number of requests added in the block (`excess = max(0, excess + count - TARGET_REQUESTS_PER_BLOCK)`) and reset that count. Records beyond the per-block cap remain queued for subsequent blocks. +- From any other caller: the predeploy MUST return the current fee (the fee getter), without modifying state. + +The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). Neither contract emits logs. + +### Request fee + +Each request carries a fee, computed exactly as in [EIP-7002](./eip-7002.md): + +``` +fee = fake_exponential(MIN_REQUEST_FEE, excess, REQUEST_FEE_UPDATE_FRACTION) +``` + +where `fake_exponential` is the integer approximation of `MIN_REQUEST_FEE · e^(excess / REQUEST_FEE_UPDATE_FRACTION)` used by [EIP-1559](./eip-1559.md). Because `excess` grows whenever a block contains more than `TARGET_REQUESTS_PER_BLOCK` requests and decays otherwise, the fee rises super-linearly under sustained demand and returns to `MIN_REQUEST_FEE` when demand subsides. The fee is charged on top of any staked value (see the entrypoints below) and is left locked in the contract. + +Unlike EIP-7002/7251, these predeploys carry no `EXCESS_INHIBITOR`: those contracts are deployed before their activating fork and use the inhibitor to reject requests until the first system call, whereas these are installed at the fork with empty storage (`excess = 0`, the minimum fee), so there are no pre-activation requests to inhibit. ### Verified deposit entrypoint @@ -76,21 +96,25 @@ The execution layer prepends the contract's request-type byte and includes `requ deposit( bytes pubkey, // 48-byte compressed G1 (X with sign+infinity flags) bytes32 withdrawal_credentials, // 32-byte commitment + uint64 amount_gwei, // stake to credit, in gwei (NOT signed) bytes signature, // 96-byte compressed G2 (X with sign+infinity flags) Fp pubkey_y, // affine Y of pubkey, in EIP-2537 encoding Fp2 signature_y // affine Y of signature, in EIP-2537 encoding ) payable ``` +`amount_gwei` is the stake to credit. It is an explicit parameter — and is **not** part of the signed message — because `msg.value` must cover both the stake and the dynamic fee, so the credited stake cannot be derived from `msg.value` alone. The signature commits only to `(pubkey, withdrawal_credentials)`; see [Rationale](#rationale) for why the amount is not signed. + `deposit(...)` MUST perform the following, in order, before appending any record: -1. Validate input lengths and the deposit amount. -2. Reject `pubkey` or `signature` whose infinity flag is set. -3. Verify that the supplied `pubkey_y` and `signature_y` agree with the sign flag of the corresponding compressed encoding (i.e. `sign(pubkey_y)` equals the sign bit of `pubkey`, and likewise for the signature). This binds the point used in the pairing check to the encoding the consensus layer will register; without it the verified point could be the negation of the registered point. -4. Compute the signing root - `compute_signing_root(DepositMessage(pubkey, withdrawal_credentials, amount), DOMAIN_BUILDER_DEPOSIT)`. -5. Verify the BLS proof-of-possession via the [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile, using the supplied affine `Y` coordinates to construct the G1 and G2 points. -6. Revert the entire call if the pairing check fails. +1. Validate input lengths, and that `amount_gwei * 1 gwei` is at least the minimum stake. +2. Require `msg.value >= amount_gwei * 1 gwei + fee`, where `fee` is the current request fee. Any value beyond `amount_gwei * 1 gwei` is retained by the contract (the fee, plus any overpayment, is not credited to the builder). +3. Reject `pubkey` or `signature` whose infinity flag is set. +4. Verify that the supplied `pubkey_y` and `signature_y` agree with the sign flag of the corresponding compressed encoding (i.e. `sign(pubkey_y)` equals the sign bit of `pubkey`, and likewise for the signature). This binds the point used in the pairing check to the encoding the consensus layer will register; without it the verified point could be the negation of the registered point. +5. Compute the signing root over the 2-field builder message: + `signing_root = sha256(hash_tree_root(pubkey, withdrawal_credentials) || DOMAIN_BUILDER_DEPOSIT)`. +6. Verify the BLS proof-of-possession via the [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile, using the supplied affine `Y` coordinates to construct the G1 and G2 points. +7. Revert the entire call if the pairing check fails. On success, `deposit(...)` MUST append a `BUILDER_DEPOSIT_REQUEST_TYPE` record of `pubkey (48) ++ withdrawal_credentials (32) ++ amount_gwei (8, little-endian)` to its queue. The signature is intentionally absent: it was verified at submission, so the consensus layer trusts the dequeued record without re-pairing. @@ -98,16 +122,25 @@ On success, `deposit(...)` MUST append a `BUILDER_DEPOSIT_REQUEST_TYPE` record o ``` top_up( - bytes pubkey // 48-byte compressed G1 of an existing builder + bytes pubkey, // 48-byte compressed G1 of an existing builder + uint64 amount_gwei // stake to add, in gwei ) payable ``` -`top_up(...)` MUST perform the length and amount checks but MUST NOT perform any signature verification. On success it MUST append a `BUILDER_TOPUP_REQUEST_TYPE` record of `pubkey (48) ++ amount_gwei (8, little-endian)` to its queue. +`top_up(...)` MUST validate the pubkey length, require `amount_gwei * 1 gwei` to be at least the minimum stake, and require `msg.value >= amount_gwei * 1 gwei + fee` (same fee as `deposit`), but MUST NOT perform any signature verification. On success it MUST append a `BUILDER_TOPUP_REQUEST_TYPE` record of `pubkey (48) ++ amount_gwei (8, little-endian)` to its queue. `top_up(...)` deliberately takes no `withdrawal_credentials`. A top-up only adds stake to an already-registered builder; the credentials are fixed by that builder's verified deposit. Omitting the field denies an unauthenticated caller any influence over a builder's withdrawal target. The consensus layer is responsible for rejecting top-up records that target a `pubkey` not already registered as an EIP-7732 builder. The deposited ETH for both entrypoints is locked in the respective contract; the consensus layer credits the builder from the dequeued request. +### Consensus-layer processing of records + +The consensus layer processes the two request types as follows: + +- A `BUILDER_DEPOSIT_REQUEST_TYPE` (`0x03`) record for a `pubkey` **not** yet in the builder set is a first deposit: it registers the builder with the record's `withdrawal_credentials` and credits its `amount_gwei`. The execution layer has already verified the proof-of-possession, so the consensus layer does not re-verify. +- A `BUILDER_DEPOSIT_REQUEST_TYPE` (`0x03`) record for a `pubkey` **already** in the builder set MUST be treated as a top-up: it credits `amount_gwei` and MUST NOT change the existing `withdrawal_credentials`. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. The rule is required because the `0x03` entrypoint's signature is replayable: it commits only to `(pubkey, withdrawal_credentials)` and is public in calldata once any deposit lands, so a third party can submit a further `0x03` record for an already-registered builder (funding it themselves). Treating it as a top-up makes that replay equivalent to a `BUILDER_TOPUP_REQUEST_TYPE` record — a permitted stake addition — and prevents it from re-registering or altering the builder. +- A `BUILDER_TOPUP_REQUEST_TYPE` (`0x04`) record MUST be rejected if its `pubkey` is not already a registered builder, and otherwise credits `amount_gwei` without touching the withdrawal credentials. + ## Rationale - **A separate contract, not a replacement.** The deployed validator contract has an immutable two-mode API. Replacing its runtime would either break the all-zero-signature top-up flow that mainnet uses today, or would require keeping an unverified entrypoint in the spec — bringing the same DoS surface forward. A separate contract lets the existing validator semantics stay fixed. @@ -116,7 +149,9 @@ The deposited ETH for both entrypoints is locked in the respective contract; the - **Two predeploys, two request types.** Mirroring withdrawals (`0x01`) and consolidations (`0x02`) — each a single-type request predeploy — builder deposits (`0x03`) and top-ups (`0x04`) are two separate predeploys sharing a common queue implementation. Each is a standard single-type request contract: an empty-calldata `SYSTEM_ADDRESS` call returns a flat `request_data`. The execution layer therefore needs no new read semantics, and the consensus layer distinguishes a first-sighting deposit (with an execution-layer-verified signature) from a stake-only top-up by request type rather than by inspecting record contents. -- **No request fee.** Unlike EIP-7002/7251, whose requests would otherwise be free and so charge a dynamic fee, every builder request locks at least the minimum stake and (for deposits) pays for gas-metered BLS verification. That staked value is the anti-spam gate, so no separate fee is levied; flooding the queue costs at least the stake per entry. The per-block cap plus the queue still bound how many records enter a single block. +- **EIP-1559-style request fee.** Each request carries the same dynamic, demand-responsive fee as EIP-7002/7251, rather than relying on the staked value alone as the anti-spam gate. This keeps the builder predeploys uniform with the existing request bus and smooths bursts: when a block exceeds `TARGET_REQUESTS_PER_BLOCK`, the `excess` counter grows and the fee rises super-linearly, throttling demand independently of the deposit minimum; it decays back to `MIN_REQUEST_FEE` when demand subsides. Because a deposit also carries stake, the fee is charged on top of the staked value; the fee is retained by the contract (effectively burned), and the per-block cap plus the queue still bound how many records enter a single block. + +- **The amount is not signed.** The builder deposit signature commits only to `(pubkey, withdrawal_credentials)`, not the amount. Signing the amount would add no security here: the unverified `top_up` already lets anyone add stake to a `pubkey` with no signature, so the staked amount is not a signature-bound quantity by design, and even on the first deposit the depositor controls both the signature and `msg.value`, so a mismatch benefits no one. Leaving the amount unsigned also removes a circularity: with the fee drawn from `msg.value`, a *signed* amount could not be derived from `msg.value` (the fee is unknown at signing time), so it would otherwise have to be both signed and passed explicitly. `amount_gwei` is therefore an explicit but unsigned parameter — the credited stake — which keeps it symmetric with `top_up`'s amount and makes the credited value deterministic regardless of the fee at inclusion time. - **Y coordinates supplied by the caller.** On-chain decompression of a compressed G1 or G2 point requires an Fp or Fp2 square root, which in turn requires several thousand bytes of runtime code and an order-of-magnitude more gas than the pairing check itself. Because builders already work with affine BLS points in their off-chain infrastructure, requiring the Y coordinates as call data shrinks the canonical bytecode considerably and removes the Fp-arithmetic and Sarkar/Adj sqrt code from the audit surface. @@ -124,7 +159,7 @@ The deposited ETH for both entrypoints is locked in the respective contract; the - **Gas-metered verification as the DoS gate.** Verification cost (`BLS12_PAIRING_CHECK` + `BLS12_MAP_FP2_TO_G2` + supporting work) is paid by the depositor's transaction. Submitting an invalid signature therefore costs the same as submitting a valid one; there is no asymmetric drain on the consensus layer. -- **Distinct signing domain (`DOMAIN_BUILDER_DEPOSIT`).** Builder deposit signatures use a domain type distinct from the validator deposit domain. This is a deliberate departure from "reuse existing signing tooling unchanged": sharing `DOMAIN_DEPOSIT` and the identical `DepositMessage` structure would make a proof-of-possession byte-for-byte interchangeable between this contract and the validator deposit contract, letting a public validator-deposit signature be replayed here to force-enrol a validator pubkey as a builder (and vice versa). Domain separation removes that cross-context replay in both directions; signing tooling needs only a one-constant domain change. +- **Distinct signing domain (`DOMAIN_BUILDER_DEPOSIT`).** Builder deposit signatures use a domain type distinct from the validator deposit domain. Were the builder message identical to the validator `DepositMessage` and signed under the same domain, a public validator-deposit signature could be replayed here to force-enrol a validator pubkey as a builder (and vice versa). Two independent differences now prevent that: the builder message is a 2-field `(pubkey, withdrawal_credentials)` container (the validator message is 3-field, with the amount, so the message roots differ for the same key), and the signing domain differs. The distinct domain is retained as the explicit guarantee rather than relying on the structural difference alone — an explicit domain tag is more robust than the assumption that no other scheme ever signs a 2-field `(pubkey, withdrawal_credentials)` message under the validator domain. ## Backwards Compatibility @@ -142,10 +177,11 @@ Solidity source for both predeploys is published at [`../assets/eip-draft_builde ## Security Considerations -- **Signing-domain separation.** `DOMAIN_BUILDER_DEPOSIT` MUST differ from the validator `DOMAIN_DEPOSIT`. Because both contracts use the identical `DepositMessage` SSZ structure, a shared domain would make proof-of-possession signatures byte-for-byte interchangeable, allowing a public validator-deposit signature to be replayed into this contract (force-enrolling a validator pubkey as a builder) and vice versa. The distinct domain type closes this in both directions. +- **Signing-domain separation.** `DOMAIN_BUILDER_DEPOSIT` MUST differ from the validator `DOMAIN_DEPOSIT`, so a proof-of-possession signature is never interchangeable between this contract and the validator deposit contract (which would otherwise allow a public validator-deposit signature to be replayed here to force-enrol a validator pubkey as a builder, and vice versa). The builder message also differs structurally — a 2-field `(pubkey, withdrawal_credentials)` container versus the validator's 3-field message — which independently prevents the replay; the distinct domain is kept as the explicit guarantee rather than relying on that structural difference alone. - **Sign-bit binding.** The supplied affine `Y` MUST agree with the sign flag of the compressed `pubkey`/`signature`. Without this binding, a depositor controlling the key could pass the pairing check on a point `(X, +Y)` while the queued record's `pubkey` bytes decompress to `(X, −Y)`, so the consensus layer registers a key whose proof-of-possession the execution layer never verified (it verified the negation). The deposit record carries no signature, so the consensus layer cannot detect this by re-verification — it trusts the execution-layer check — which is exactly why the binding must be enforced on chain. - **System-read access control and per-block cap.** Only `SYSTEM_ADDRESS` may invoke the end-of-block dequeue; any other empty-calldata call reverts, so a non-system caller cannot drain or replay the queue. Each contract returns at most `MAX_REQUESTS_PER_BLOCK` records per block, bounding the size each predeploy contributes to the block requests; excess records remain queued for later blocks. - **Top-up validity at CL.** A top-up appends a request without checking that the target `pubkey` exists. The consensus layer MUST reject top-ups against unregistered builders so that all-zero or junk top-ups cannot register new builders without a verified deposit. `top_up(...)` carries no `withdrawal_credentials`, so an unauthenticated caller cannot rewrite an existing builder's withdrawal target. +- **Replayable deposit records.** A deposit's `(pubkey, withdrawal_credentials, signature, …)` is public in calldata, and the signature commits only to `(pubkey, withdrawal_credentials)`, so a third party can submit a further `0x03` record for an already-registered builder at an arbitrary amount (funding it themselves). The consensus layer MUST treat a `0x03` record for an already-registered `pubkey` as a top-up — crediting stake but never changing the withdrawal credentials or re-registering — so the replay cannot redirect a builder's withdrawals or reset its state (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)). This is harmless beyond a funded stake addition, exactly like a `0x04` top-up. - **DoS surface.** Verification cost is gas-metered and paid by the depositor; an adversary cannot force consensus-layer pairing work without first paying the corresponding execution-layer gas. Per [EIP-2537](./eip-2537.md) §"Gas burning on error", a precompile that rejects a malformed (off-curve or out-of-subgroup) point burns all gas forwarded to it, so the contract MUST NOT forward `gas()` to the precompiles. Because EIP-2537 pricing is deterministic (a pure function of input length), the contract forwards a fixed gas ceiling to each precompile `staticcall` — set per call at roughly 2.5x the documented cost — which bounds the worst-case burn on a malformed input to that ceiling instead of the whole transaction, while leaving ample headroom for a future reprice. The ceilings MUST be revisited if [EIP-2537](./eip-2537.md) pricing changes. - **Subgroup membership.** The [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile performs G1 and G2 subgroup checks; the contract does not need to re-implement them. - **Compressed-point flags.** The contract must reject infinity-flagged inputs to prevent acceptance of the identity element as a `pubkey` or `signature`. diff --git a/assets/eip-draft_builder_deposit/README.md b/assets/eip-draft_builder_deposit/README.md index 6a36c722d3ae55..e09ad6a61655af 100644 --- a/assets/eip-draft_builder_deposit/README.md +++ b/assets/eip-draft_builder_deposit/README.md @@ -6,7 +6,7 @@ Reference Solidity for the proposal, plus cross-verification tests. | File | Purpose | | --- | --- | -| `builder_deposit_contract.sol` | The two proposed predeploys plus a shared base: `RequestQueue` (EIP-7002-style queue + `SYSTEM_ADDRESS` end-of-block read), `BuilderDepositContract` (`deposit(...)`, BLS-verified, request type `0x03`), and `BuilderTopUpContract` (`top_up(...)`, unverified, request type `0x04`). | +| `builder_deposit_contract.sol` | The two proposed predeploys plus a shared base: `RequestQueue` (EIP-7002-style queue + EIP-1559 fee + `SYSTEM_ADDRESS` end-of-block read), `BuilderDepositContract` (`deposit(...)`, BLS-verified, request type `0x03`), and `BuilderTopUpContract` (`top_up(...)`, unverified, request type `0x04`). | | `gen_vectors.py` | Python script that uses `py_ecc` (the canonical Eth2 reference) to produce cross-verification test vectors. | | `test/Vectors.sol` | Auto-generated Solidity library of test vectors. Regenerate by running `gen_vectors.py`. | | `test/TestHarness.sol` | `BuilderDepositHarness` / `BuilderTopUpHarness` — inherit the predeploys and expose the pending-queue depth (and the SSZ signing-root helper) for the tests. | @@ -34,7 +34,7 @@ forge test -vv `evm_version = "prague"` in `foundry.toml` enables the EIP-2537 BLS precompiles, required for the three tests that exercise the pairing path. To run only the queue, system-read, and input-validation tests on an older EVM (no EIP-2537 needed): ```bash -forge test -vv --evm-version cancun --no-match-test '(DepositEnqueuesAndReads|Tampered)' +forge test -vv --evm-version cancun --no-match-test 'Deposit(EnqueuesAndReads|AmountNotBound|RejectsTamperedSignature)' ``` ## Regenerating vectors @@ -52,14 +52,19 @@ The script is deterministic: the secret key is hard-coded so the output is byte- | `testComputeSigningRoot` | `_computeDepositSigningRoot` matches `py_ecc`-derived SSZ `compute_signing_root` | no | | `testDepositEnqueuesAndReads` | A `py_ecc`-produced deposit is accepted, enqueued, and the `SYSTEM_ADDRESS` read returns the exact 88-byte record | **yes** | | `testTopUpEnqueuesAndReads` | `top_up(...)` enqueues; the system read returns the exact 56-byte record | no | -| `testSystemReadRequiresSystemAddress` | An empty-calldata read from a non-`SYSTEM_ADDRESS` caller reverts | no | +| `testFeeStartsAtMinimum` | The fee is `MIN_REQUEST_FEE` (1 wei) at `excess == 0` | no | +| `testFeeRisesWithExcess` | A block of 18 requests then a system call sets `excess = 16`, so `fake_exponential(1, 16, 17) == 2` | no | +| `testFeeGetterFallbackMatches` | A non-system empty-calldata call returns the current fee | no | +| `testDepositAmountNotBoundToSignature` | The same signature is accepted with a different `amount_gwei` (the amount is unsigned); the record reflects the passed amount | **yes** | +| `testDepositRejectsInsufficientValue` | `msg.value == stake` (no room for the fee) reverts; nothing enqueued | no | +| `testSystemReadRequiresSystemAddress` | A non-`SYSTEM_ADDRESS` empty-calldata call is the fee getter and does NOT drain the queue | no | | `testPerBlockCapAndFifo` | 17 queued → first read drains the 16-record cap, second drains the remainder (FIFO) | no | -| `testDepositRejectsTamperedAmount` | A different `msg.value` than was signed fails the pairing check; nothing enqueued | **yes** | +| `testDepositRejectsTamperedAmount` | An `amount_gwei` different from the signed one fails the pairing check; nothing enqueued | **yes** | | `testDepositRejectsTamperedSignature` | Flipping a signature bit is rejected (subgroup/pairing failure); nothing enqueued | **yes** | | `testDepositRejectsPubkeySignBitFlip` | Flipping only the pubkey sign flag is rejected by the sign-bit binding (audit Finding 2 regression); nothing enqueued | no | | `testDepositRejectsSignatureSignBitFlip` | Flipping only the signature sign flag is rejected by the sign-bit binding; nothing enqueued | no | | `testDepositRejectsInfinityPubkey` | `pubkey` with infinity flag is rejected before BLS work; nothing enqueued | no | -| `testDepositRejectsTooSmallAmount` | `msg.value < 1 ether` is rejected; nothing enqueued | no | +| `testDepositRejectsTooSmallStake` | `amount_gwei * 1 gwei < 1 ether` is rejected; nothing enqueued | no | | `testDepositRejectsWrongPubkeyLength` | `pubkey.length != 48` is rejected; nothing enqueued | no | -| `testTopUpRejectsTooSmallAmount` | `top_up` with `msg.value < 1 ether` is rejected; nothing enqueued | no | +| `testTopUpRejectsTooSmallStake` | `top_up` stake `< 1 ether` is rejected; nothing enqueued | no | | `testTopUpRejectsWrongPubkeyLength` | `top_up` with `pubkey.length != 48` is rejected; nothing enqueued | no | diff --git a/assets/eip-draft_builder_deposit/builder_deposit_contract.sol b/assets/eip-draft_builder_deposit/builder_deposit_contract.sol index 361eb62333d053..79e46c72e34b57 100644 --- a/assets/eip-draft_builder_deposit/builder_deposit_contract.sol +++ b/assets/eip-draft_builder_deposit/builder_deposit_contract.sol @@ -10,14 +10,14 @@ pragma experimental ABIEncoderV2; // for `Fp` / `Fp2` struct calldata in `depos // on the EIP-7002 (withdrawals) / EIP-7251 (consolidations) "request bus": // // * BuilderDepositContract @ BUILDER_DEPOSIT_CONTRACT_ADDRESS (request type 0x03) -// deposit(pubkey, wc, signature, pubkey_y, signature_y) — verifies the BLS -// proof-of-possession on chain via the EIP-2537 precompiles, then appends -// a deposit record to the in-state request queue. +// deposit(pubkey, wc, amount_gwei, signature, pubkey_y, signature_y) — +// verifies the BLS proof-of-possession on chain via the EIP-2537 +// precompiles, then appends a deposit record to the in-state request queue. // // * BuilderTopUpContract @ BUILDER_TOPUP_CONTRACT_ADDRESS (request type 0x04) -// top_up(pubkey) — unverified additional stake for an already-registered -// builder; appends a top-up record to its queue. The consensus layer -// rejects top-ups whose `pubkey` is not in the builder set. +// top_up(pubkey, amount_gwei) — unverified additional stake for an +// already-registered builder; appends a top-up record to its queue. The +// consensus layer rejects top-ups whose `pubkey` is not in the builder set. // // Neither contract emits logs. Both share the `RequestQueue` base: a user call // appends a record; at the end of the block a `SYSTEM_ADDRESS` call with empty @@ -49,38 +49,134 @@ pragma experimental ABIEncoderV2; // for `Fp` / `Fp2` struct calldata in `depos // are pre-verified and carry no signature (the CL trusts the EL check). // ─────────────────────────────────────────────────────────────────────────────── -// EIP-7002-style request queue shared by both builder predeploys. A user call -// appends an opaque record; the end-of-block `SYSTEM_ADDRESS` system call drains -// up to MAX_REQUESTS_PER_BLOCK records (FIFO) and returns their concatenation as -// the predeploy's flat `request_data`. No fee: callers of the derived contracts -// already lock staked value, which is the anti-spam gate. +// EIP-7002 / EIP-7251 style request bus shared by both builder predeploys. +// +// A user call appends an opaque record (and increments the per-block request +// count). The end-of-block `SYSTEM_ADDRESS` system call drains up to +// MAX_REQUESTS_PER_BLOCK records (FIFO) and returns their concatenation as the +// predeploy's flat `request_data`, then updates the EIP-1559-style `excess` +// counter from the per-block count and resets the count. +// +// The queue is a head/tail ring over a `mapping(uint => bytes)`, matching +// EIP-7002's `dequeue_withdrawal_requests`: records are written at `queueTail` +// and read from `queueHead`, and BOTH pointers are reset to 0 once the queue +// empties (`new_queue_head_index == queue_tail_index` in EIP-7002), so the +// mapping slots are reused by later requests. Storage is therefore bounded by +// the peak in-flight queue depth, not by lifetime request volume — a plain +// growable array would leak a slot per request forever, since draining only +// advances the head. +// +// Each request carries a dynamic fee, computed exactly as in EIP-7002: +// `fee = fake_exponential(MIN_REQUEST_FEE, excess, REQUEST_FEE_UPDATE_FRACTION)`. +// When more than TARGET_REQUESTS_PER_BLOCK requests are submitted per block the +// excess grows and the fee rises super-linearly, throttling demand. The fee is +// charged on top of any staked value by the derived contract and is left locked +// in the contract (effectively burned). +// +// Unlike EIP-7002/7251 there is no EXCESS_INHIBITOR: those contracts are +// deployed before their activating fork and use the inhibitor to reject +// requests until the first system call. These predeploys are installed at the +// fork with empty storage (`excess == 0`, i.e. the minimum fee), so there are +// no pre-activation requests to inhibit. contract RequestQueue { // Address used to invoke the end-of-block system operation (EIP-7002/7251). address constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; - // Maximum records drained into a single block. Mirrors EIP-7002's - // MAX_WITHDRAWAL_REQUESTS_PER_BLOCK; excess records wait for later blocks. + // Maximum records drained into a single block (mirrors EIP-7002); excess + // records wait for later blocks. uint constant MAX_REQUESTS_PER_BLOCK = 16; - - // FIFO queue of opaque request records, drained from `queueHead`. - bytes[] internal queue; + // Per-block request count above which the fee starts to rise (mirrors EIP-7002). + uint constant TARGET_REQUESTS_PER_BLOCK = 2; + // Minimum request fee in wei, and the fee's update fraction (mirror EIP-7002). + uint constant MIN_REQUEST_FEE = 1; + uint constant REQUEST_FEE_UPDATE_FRACTION = 17; + + // FIFO queue of opaque request records: a head/tail ring over a mapping + // (see the note above). `queueTail` is the next write index, `queueHead` + // the next read index; both reset to 0 when the queue empties. + mapping(uint => bytes) internal queue; uint internal queueHead; + uint internal queueTail; + + // EIP-1559-style fee state: `excess` accumulates per-block demand above + // TARGET; `count` is the number of requests added in the current block. + uint internal excess; + uint internal count; + + // Current per-request fee (wei). Constant within a block: `excess` is only + // updated by the end-of-block system call. + function _getFee() internal view returns (uint) { + return _fakeExponential(MIN_REQUEST_FEE, excess, REQUEST_FEE_UPDATE_FRACTION); + } + + // EIP-7002 fee curve: factor * e^(numerator / denominator), via the same + // integer Taylor-series approximation used by EIP-1559 / EIP-4844. + function _fakeExponential(uint factor, uint numerator, uint denominator) + internal + pure + returns (uint) + { + uint i = 1; + uint output = 0; + uint numeratorAccum = factor * denominator; + while (numeratorAccum > 0) { + output += numeratorAccum; + numeratorAccum = (numeratorAccum * numerator) / (denominator * i); + i += 1; + } + return output / denominator; + } - // Append a request record. Called by the derived contract's entrypoint - // after it has validated (and, for deposits, BLS-verified) the request. - function _enqueue(bytes memory record) internal { - queue.push(record); + // Append a request record and count it toward this block's demand. Called + // by the derived entrypoint after it has validated (and, for deposits, + // BLS-verified) the request and confirmed the fee was paid. + function _recordRequest(bytes memory record) internal { + queue[queueTail] = record; + queueTail += 1; + count += 1; } - // End-of-block read-out. Only `SYSTEM_ADDRESS` may call it (with empty - // calldata). Pops up to MAX_REQUESTS_PER_BLOCK records FIFO, returns their - // concatenation as the flat `request_data`, and advances the head. The EL - // prepends this predeploy's EIP-7685 request-type byte. + // 8-byte little-endian encoding of a uint64 (SSZ amount encoding). + function _le64(uint64 v) internal pure returns (bytes memory r) { + r = new bytes(8); + for (uint i = 0; i < 8; i++) { + r[i] = bytes1(uint8(v >> (8 * i))); + } + } + + // Empty-calldata entry point. Two modes, dispatched on caller (as EIP-7002): + // * SYSTEM_ADDRESS: end-of-block read-out — drain up to + // MAX_REQUESTS_PER_BLOCK records FIFO, update `excess` from `count`, + // reset `count`, and return the records as flat `request_data` (the EL + // prepends this predeploy's request-type byte). + // * any other caller: fee getter — return the current `_getFee()`. fallback() external { - require(msg.sender == SYSTEM_ADDRESS, "RequestQueue: only system"); + // Only the canonical empty-calldata call reaches the fallback meaningfully + // (the system read-out, or a fee query) — `deposit`/`top_up` have their own + // selectors. Reject any other calldata, as EIP-7002 does (it only treats + // zero-length input as the fee getter). + require(msg.data.length == 0, "RequestQueue: unexpected calldata"); + + if (msg.sender != SYSTEM_ADDRESS) { + // Fee getter. + uint fee = _getFee(); + assembly { + let p := mload(0x40) + mstore(p, fee) + return(p, 0x20) + } + } + + // Update the EIP-1559-style excess from this block's demand, then reset. + uint c = count; + excess = (excess + c > TARGET_REQUESTS_PER_BLOCK) + ? excess + c - TARGET_REQUESTS_PER_BLOCK + : 0; + count = 0; + // Drain up to MAX_REQUESTS_PER_BLOCK records (FIFO) from the head. uint head = queueHead; - uint tail = queue.length; + uint tail = queueTail; uint n = tail - head; if (n > MAX_REQUESTS_PER_BLOCK) { n = MAX_REQUESTS_PER_BLOCK; @@ -91,7 +187,17 @@ contract RequestQueue { for (uint i = 0; i < n; i++) { out = abi.encodePacked(out, queue[head + i]); } - queueHead = head + n; + + // EIP-7002 `dequeue_withdrawal_requests`: once the queue empties, reset + // BOTH pointers to 0 so the mapping slots are reused by later requests; + // otherwise just advance the head. + uint newHead = head + n; + if (newHead == tail) { + queueHead = 0; + queueTail = 0; + } else { + queueHead = newHead; + } assembly { return(add(out, 0x20), mload(out)) @@ -198,30 +304,35 @@ contract BuilderDepositContract is RequestQueue { // ── External entrypoint ──────────────────────────────────────────────── /// @notice BLS-verified builder deposit. On success, appends a deposit - /// record to the request queue (no log). The deposited ETH is locked in the - /// contract; the consensus layer credits the builder from the dequeued - /// request at the end of the block. + /// record to the request queue (no log). `amount_gwei` is the stake to + /// credit and is the amount bound into the signed `DepositMessage`; the + /// caller MUST send `msg.value >= amount_gwei * 1 gwei + fee`, where `fee` + /// is the current request fee (call this contract with empty calldata to + /// read it). The staked ETH is locked in the contract; the consensus layer + /// credits the builder `amount_gwei` from the dequeued request. function deposit( bytes calldata pubkey, bytes32 withdrawal_credentials, + uint64 amount_gwei, bytes calldata signature, Fp calldata pubkey_y, Fp2 calldata signature_y ) external payable { require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderDeposit: invalid pubkey length"); require(signature.length == SIGNATURE_LENGTH, "BuilderDeposit: invalid signature length"); - require(msg.value >= BUILDER_MIN_DEPOSIT, "BuilderDeposit: deposit value too low"); - require(msg.value % 1 gwei == 0, "BuilderDeposit: deposit value not multiple of gwei"); - uint deposit_amount = msg.value / 1 gwei; - require(deposit_amount <= type(uint64).max, "BuilderDeposit: deposit value too high"); + uint stake = uint(amount_gwei) * 1 gwei; + require(stake >= BUILDER_MIN_DEPOSIT, "BuilderDeposit: deposit value too low"); + // Fee is charged on top of the stake; overpayment of the fee is + // forfeited, as in EIP-7002. + require(msg.value >= stake + _getFee(), "BuilderDeposit: insufficient value for stake + fee"); require(!_isInfinityFlagSet(pubkey[0]), "BuilderDeposit: infinity pubkey"); require(!_isInfinityFlagSet(signature[0]), "BuilderDeposit: infinity signature"); // BLS proof-of-possession check. Performed before the record is queued // so an invalid signature reverts the whole call and never enqueues. - bytes32 signingRoot = _computeDepositSigningRoot( - pubkey, withdrawal_credentials, uint64(deposit_amount) - ); + // The amount is not part of the signed message (see + // `_computeDepositSigningRoot`); it is recorded below as the credited stake. + bytes32 signingRoot = _computeDepositSigningRoot(pubkey, withdrawal_credentials); G1Point memory pk = _constructG1(pubkey, pubkey_y); G2Point memory sig = _constructG2(signature, signature_y); G2Point memory msgPoint = _hashToCurve(signingRoot); @@ -230,28 +341,25 @@ contract BuilderDepositContract is RequestQueue { "BuilderDeposit: invalid BLS signature" ); - _enqueue(abi.encodePacked( - pubkey, withdrawal_credentials, _le64(uint64(deposit_amount)) + _recordRequest(abi.encodePacked( + pubkey, withdrawal_credentials, _le64(amount_gwei) )); } - // 8-byte little-endian encoding of a uint64 (SSZ amount encoding). - function _le64(uint64 v) internal pure returns (bytes memory r) { - r = new bytes(8); - for (uint i = 0; i < 8; i++) { - r[i] = bytes1(uint8(v >> (8 * i))); - } - } - // ── Signing-root computation ─────────────────────────────────────────── // Algorithm: SSZ `hash_tree_root` (consensus-specs §SSZ Merkleization) + // `compute_signing_root` (consensus-specs §Beacon-chain helpers). - // Returns `sha256(hash_tree_root(DepositMessage(...)) || DOMAIN_BUILDER_DEPOSIT)`. + // + // The builder deposit message is the 2-field container + // `(pubkey, withdrawal_credentials)` — the amount is deliberately NOT + // signed (the unverified `top_up` already lets stake be added without a + // signature, so binding it here would protect nothing). The signature is a + // proof of possession that binds only the key and the withdrawal target. + // Returns `sha256(hash_tree_root(pubkey, withdrawal_credentials) || DOMAIN_BUILDER_DEPOSIT)`. function _computeDepositSigningRoot( bytes memory pubkey, - bytes32 withdrawal_credentials, - uint64 amount_gwei + bytes32 withdrawal_credentials ) internal pure returns (bytes32) { // `pubkey` is 48 bytes; pad to 64 bytes and sha256 to get its SSZ root. bytes memory paddedPubkey = new bytes(64); @@ -260,20 +368,11 @@ contract BuilderDepositContract is RequestQueue { } bytes32 pubkeyRoot = sha256(paddedPubkey); - // Left subtree of the DepositMessage SSZ Merkle tree: + // hash_tree_root of the 2-field container = sha256(field0 || field1): // sha256(pubkey_root || withdrawal_credentials). - bytes32 leftNode = sha256(abi.encodePacked(pubkeyRoot, withdrawal_credentials)); + bytes32 messageRoot = sha256(abi.encodePacked(pubkeyRoot, withdrawal_credentials)); - // Right subtree: 64-byte buffer of [amount_gwei_LE(8) || zero(56)], - // hashed under sha256. - bytes memory amountAndZero = new bytes(64); - for (uint i = 0; i < 8; i++) { - amountAndZero[i] = bytes1(uint8(amount_gwei >> (8 * i))); - } - bytes32 rightNode = sha256(amountAndZero); - - bytes32 depositMessageRoot = sha256(abi.encodePacked(leftNode, rightNode)); - return sha256(abi.encodePacked(depositMessageRoot, DOMAIN_BUILDER_DEPOSIT)); + return sha256(abi.encodePacked(messageRoot, DOMAIN_BUILDER_DEPOSIT)); } // ── hash_to_curve (BLS12-381 G2) ─────────────────────────────────────── @@ -600,19 +699,17 @@ contract BuilderTopUpContract is RequestQueue { uint constant BUILDER_MIN_DEPOSIT = 1 ether; /// @notice Unverified top-up. On success, appends a top-up record to the - /// request queue (no log). The ETH is locked in the contract; the consensus - /// layer credits the existing builder from the dequeued request. - function top_up(bytes calldata pubkey) external payable { + /// request queue (no log). `amount_gwei` is the stake to add; the caller + /// MUST send `msg.value >= amount_gwei * 1 gwei + fee`, where `fee` is the + /// current request fee (read it by calling this contract with empty + /// calldata). The ETH is locked in the contract; the consensus layer + /// credits the existing builder from the dequeued request. + function top_up(bytes calldata pubkey, uint64 amount_gwei) external payable { require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderTopUp: invalid pubkey length"); - require(msg.value >= BUILDER_MIN_DEPOSIT, "BuilderTopUp: deposit value too low"); - require(msg.value % 1 gwei == 0, "BuilderTopUp: deposit value not multiple of gwei"); - uint amount = msg.value / 1 gwei; - require(amount <= type(uint64).max, "BuilderTopUp: deposit value too high"); + uint stake = uint(amount_gwei) * 1 gwei; + require(stake >= BUILDER_MIN_DEPOSIT, "BuilderTopUp: deposit value too low"); + require(msg.value >= stake + _getFee(), "BuilderTopUp: insufficient value for stake + fee"); - bytes memory amountLE = new bytes(8); - for (uint i = 0; i < 8; i++) { - amountLE[i] = bytes1(uint8(uint64(amount) >> (8 * i))); - } - _enqueue(abi.encodePacked(pubkey, amountLE)); + _recordRequest(abi.encodePacked(pubkey, _le64(amount_gwei))); } } diff --git a/assets/eip-draft_builder_deposit/gen_vectors.py b/assets/eip-draft_builder_deposit/gen_vectors.py index 29666aed9b2557..6116b6f2c04cc1 100644 --- a/assets/eip-draft_builder_deposit/gen_vectors.py +++ b/assets/eip-draft_builder_deposit/gen_vectors.py @@ -41,13 +41,14 @@ def sha256(b: bytes) -> bytes: "0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9" ) -def deposit_signing_root(pubkey: bytes, wc: bytes, amount_gwei: int) -> bytes: - """SSZ compute_signing_root(DepositMessage(pubkey, wc, amount), DOMAIN_BUILDER_DEPOSIT).""" - assert len(pubkey) == 48 and len(wc) == 32 and 0 <= amount_gwei < (1 << 64) +def deposit_signing_root(pubkey: bytes, wc: bytes) -> bytes: + """compute_signing_root for the 2-field builder message (pubkey, wc). + + The amount is intentionally NOT signed (see builder_deposit_contract.sol): + htr = sha256(pubkey_root || wc); signing_root = sha256(htr || DOMAIN).""" + assert len(pubkey) == 48 and len(wc) == 32 pubkey_root = sha256(pubkey + b"\x00" * 16) - left = sha256(pubkey_root + wc) - right = sha256(amount_gwei.to_bytes(8, "little") + b"\x00" * 56) - msg_root = sha256(left + right) + msg_root = sha256(pubkey_root + wc) return sha256(msg_root + DOMAIN_BUILDER_DEPOSIT) def split_fp(x: int) -> tuple: @@ -73,8 +74,8 @@ def deposit_test(): sk = 0x4242424242424242424242424242424242424242424242424242424242424242 pubkey = bls_pop.SkToPk(sk) wc = b"\x00" * 32 - amount = 32_000_000_000 # 32 ETH in gwei - sr = deposit_signing_root(pubkey, wc, amount) + amount = 32_000_000_000 # 32 ETH in gwei (credited stake; NOT signed) + sr = deposit_signing_root(pubkey, wc) signature = bls_pop.Sign(sk, sr) assert bls_pop.Verify(pubkey, sr, signature), "py_ecc self-verify failed" diff --git a/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol b/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol index 9cb9b1fede18e4..c66c4331bb2c9f 100644 --- a/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol +++ b/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol @@ -12,12 +12,13 @@ interface Vm { function prank(address) external; } -/// @notice Tests for the EIP-7685 request-bus builder predeploys. +/// @notice Tests for the EIP-7685 request-bus builder predeploys, including the +/// EIP-1559-style request fee. /// -/// Expected values come from py_ecc (see ../gen_vectors.py) baked into -/// ./Vectors.sol. The full deposit-verification tests require the EIP-2537 BLS -/// precompiles (foundry's default Prague EVM); the queue / system-read / input -/// tests do not. +/// Expected BLS values come from py_ecc (see ../gen_vectors.py) baked into +/// ./Vectors.sol. The deposit-verification tests require the EIP-2537 BLS +/// precompiles (foundry's default Prague EVM); the queue / fee / system-read / +/// input tests do not. contract BuilderDepositTest { Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); @@ -34,8 +35,6 @@ contract BuilderDepositTest { top = new BuilderTopUpHarness(); } - // Drive the end-of-block system read: call the predeploy as SYSTEM_ADDRESS - // with empty calldata; the fallback returns the flat request_data. function _systemRead(address target) internal returns (bytes memory) { vm.prank(SYSTEM_ADDRESS); (bool ok, bytes memory ret) = target.call(""); @@ -48,15 +47,20 @@ contract BuilderDepositTest { for (uint i = 0; i < 8; i++) r[i] = bytes1(uint8(v >> (8 * i))); } + function _copy(bytes memory src) internal pure returns (bytes memory dst) { + dst = new bytes(src.length); + for (uint i = 0; i < src.length; i++) dst[i] = src[i]; + } + // ── Cross-check: SSZ signing root ────────────────────────────────────── function testComputeSigningRoot() public { - (bytes memory pubkey, bytes32 wc, , , uint64 amount_gwei, , ) = Vectors.depositCase(); - bytes32 got = dep.computeDepositSigningRoot(pubkey, wc, amount_gwei); + (bytes memory pubkey, bytes32 wc, , , , , ) = Vectors.depositCase(); + bytes32 got = dep.computeDepositSigningRoot(pubkey, wc); require(got == Vectors.depositSigningRoot(), "signing root mismatch vs py_ecc"); } - // ── Happy path: verified deposit enqueues, system read emits the record ── + // ── Happy path: deposit / top-up enqueue, system read emits the record ── function testDepositEnqueuesAndReads() public { ( @@ -69,8 +73,8 @@ contract BuilderDepositTest { BuilderDepositContract.Fp2 memory signature_y ) = Vectors.depositCase(); - require(dep.pendingCount() == 0, "starts empty"); - dep.deposit{value: uint(amount_gwei) * 1 gwei}(pubkey, wc, signature, pubkey_y, signature_y); + uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); + dep.deposit{value: value}(pubkey, wc, amount_gwei, signature, pubkey_y, signature_y); require(dep.pendingCount() == 1, "one record queued"); bytes memory data = _systemRead(address(dep)); @@ -84,30 +88,82 @@ contract BuilderDepositTest { bytes memory pubkey = new bytes(48); for (uint i = 0; i < 48; i++) pubkey[i] = bytes1(uint8(i + 1)); - top.top_up{value: 3 ether}(pubkey); + uint64 amount_gwei = 3_000_000_000; // 3 ETH + top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); require(top.pendingCount() == 1, "one top-up queued"); bytes memory data = _systemRead(address(top)); - bytes memory expected = abi.encodePacked(pubkey, _le64(3_000_000_000)); + bytes memory expected = abi.encodePacked(pubkey, _le64(amount_gwei)); require(data.length == TOPUP_RECORD_LEN, "top-up record length"); require(keccak256(data) == keccak256(expected), "top-up record bytes mismatch"); require(top.pendingCount() == 0, "queue drained"); } + // ── EIP-1559-style request fee ───────────────────────────────────────── + + function testFeeStartsAtMinimum() public { + require(dep.feeWei() == 1, "min fee is 1 wei at excess 0"); + require(top.feeWei() == 1, "min fee is 1 wei at excess 0"); + } + + function testFeeRisesWithExcess() public { + // 18 top-ups in one block → count 18. The next system call sets + // excess = 18 - TARGET(2) = 16, and fake_exponential(1, 16, 17) == 2. + bytes memory pubkey = new bytes(48); + uint64 amount_gwei = 1_000_000_000; // 1 ETH + for (uint i = 0; i < 18; i++) { + top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); + } + require(top.feeWei() == 1, "fee unchanged until the system call updates excess"); + _systemRead(address(top)); + require(top.feeWei() == 2, "fee rises after a block above target"); + } + + function testFeeGetterFallbackMatches() public { + // A non-system empty-calldata call returns the current fee. + (bool ok, bytes memory ret) = address(top).call(""); + require(ok, "fee getter call failed"); + require(ret.length == 32, "fee getter returns a word"); + require(abi.decode(ret, (uint)) == top.feeWei(), "fee getter mismatch"); + } + + function testDepositRejectsInsufficientValue() public { + ( + bytes memory pubkey, + bytes32 wc, + bytes memory signature, + , + uint64 amount_gwei, + BuilderDepositContract.Fp memory pubkey_y, + BuilderDepositContract.Fp2 memory signature_y + ) = Vectors.depositCase(); + // Exactly the stake, with nothing left for the fee: must revert. + try dep.deposit{value: uint(amount_gwei) * 1 gwei}( + pubkey, wc, amount_gwei, signature, pubkey_y, signature_y + ) { + require(false, "stake without fee should revert"); + } catch {} + require(dep.pendingCount() == 0, "nothing enqueued on reject"); + } + // ── System read access control + FIFO / per-block cap ────────────────── function testSystemReadRequiresSystemAddress() public { - // Without the SYSTEM_ADDRESS prank, the fallback must revert. - (bool ok, ) = address(dep).call(""); - require(!ok, "non-system system-read must revert"); + // A non-system empty-calldata call is the fee getter, not a drain: it + // returns the fee and must NOT advance the queue. + bytes memory pubkey = new bytes(48); + uint64 amount_gwei = 1_000_000_000; + top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); + (bool ok, ) = address(top).call(""); + require(ok, "fee getter should succeed"); + require(top.pendingCount() == 1, "non-system call must not drain the queue"); } function testPerBlockCapAndFifo() public { bytes memory pubkey = new bytes(48); - // Enqueue MAX_REQUESTS_PER_BLOCK + 1 = 17 top-ups (unverified, so easy - // to queue many without distinct signatures). + uint64 amount_gwei = 1_000_000_000; for (uint i = 0; i < 17; i++) { - top.top_up{value: 1 ether}(pubkey); + top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); } require(top.pendingCount() == 17, "17 queued"); @@ -120,9 +176,42 @@ contract BuilderDepositTest { require(top.pendingCount() == 0, "queue empty"); } + // Audit Finding 1 regression: when the queue fully drains, both head and + // tail reset to 0 (EIP-7002 behavior), so storage is bounded by peak depth + // and the next request reuses index 0. + function testQueueResetsWhenDrained() public { + bytes memory pubkey = new bytes(48); + uint64 amount_gwei = 1_000_000_000; + for (uint i = 0; i < 3; i++) { + top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); + } + require(top.headIdx() == 0 && top.tailIdx() == 3, "3 queued at indices [0,3)"); + + _systemRead(address(top)); // drains all 3 (<= cap) + require(top.headIdx() == 0 && top.tailIdx() == 0, "head and tail reset to 0 on empty"); + require(top.pendingCount() == 0, "queue empty"); + + // Next request reuses index 0 rather than advancing forever. + top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); + require(top.tailIdx() == 1, "tail restarts at 1 (slot reused)"); + } + + // Audit Finding 3 regression: the fallback only accepts empty calldata. + function testFallbackRejectsNonEmptyCalldata() public { + (bool ok, ) = address(top).call(hex"deadbeefdeadbeef"); + require(!ok, "non-empty junk calldata must revert"); + // Empty calldata still works (fee getter), confirming the guard is scoped. + (bool ok2, ) = address(top).call(""); + require(ok2, "empty-calldata fee getter still works"); + } + // ── Negative paths: BLS check (nothing should enqueue) ───────────────── - function testDepositRejectsTamperedAmount() public { + // The amount is NOT part of the signed message, so the same signature is + // valid for any amount. Depositing with an amount different from the + // vector's must SUCCEED, and the queued record must reflect the amount that + // was actually passed. + function testDepositAmountNotBoundToSignature() public { ( bytes memory pubkey, bytes32 wc, @@ -132,12 +221,14 @@ contract BuilderDepositTest { BuilderDepositContract.Fp memory pubkey_y, BuilderDepositContract.Fp2 memory signature_y ) = Vectors.depositCase(); - try dep.deposit{value: (uint(amount_gwei) + 1) * 1 gwei}( - pubkey, wc, signature, pubkey_y, signature_y - ) { - require(false, "tampered amount should revert"); - } catch {} - require(dep.pendingCount() == 0, "nothing enqueued on reject"); + uint64 differentAmount = amount_gwei + 5_000_000_000; // +5 ETH, unsigned + uint value = uint(differentAmount) * 1 gwei + dep.feeWei(); + dep.deposit{value: value}(pubkey, wc, differentAmount, signature, pubkey_y, signature_y); + require(dep.pendingCount() == 1, "deposit with a different amount is accepted"); + + bytes memory data = _systemRead(address(dep)); + bytes memory expected = abi.encodePacked(pubkey, wc, _le64(differentAmount)); + require(keccak256(data) == keccak256(expected), "record reflects the passed amount"); } function testDepositRejectsTamperedSignature() public { @@ -152,9 +243,8 @@ contract BuilderDepositTest { ) = Vectors.depositCase(); bytes memory tampered = _copy(signature); tampered[10] = tampered[10] ^ bytes1(uint8(1)); - try dep.deposit{value: uint(amount_gwei) * 1 gwei}( - pubkey, wc, tampered, pubkey_y, signature_y - ) { + uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); + try dep.deposit{value: value}(pubkey, wc, amount_gwei, tampered, pubkey_y, signature_y) { require(false, "tampered signature should revert"); } catch {} require(dep.pendingCount() == 0, "nothing enqueued on reject"); @@ -173,9 +263,8 @@ contract BuilderDepositTest { ) = Vectors.depositCase(); bytes memory flipped = _copy(pubkey); flipped[0] = flipped[0] ^ bytes1(uint8(0x20)); - try dep.deposit{value: uint(amount_gwei) * 1 gwei}( - flipped, wc, signature, pubkey_y, signature_y - ) { + uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); + try dep.deposit{value: value}(flipped, wc, amount_gwei, signature, pubkey_y, signature_y) { require(false, "pubkey sign-bit flip should revert"); } catch {} require(dep.pendingCount() == 0, "nothing enqueued on reject"); @@ -193,9 +282,8 @@ contract BuilderDepositTest { ) = Vectors.depositCase(); bytes memory flipped = _copy(signature); flipped[0] = flipped[0] ^ bytes1(uint8(0x20)); - try dep.deposit{value: uint(amount_gwei) * 1 gwei}( - pubkey, wc, flipped, pubkey_y, signature_y - ) { + uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); + try dep.deposit{value: value}(pubkey, wc, amount_gwei, flipped, pubkey_y, signature_y) { require(false, "signature sign-bit flip should revert"); } catch {} require(dep.pendingCount() == 0, "nothing enqueued on reject"); @@ -213,9 +301,8 @@ contract BuilderDepositTest { ) = Vectors.depositCase(); bytes memory inf = _copy(pubkey); inf[0] = inf[0] | bytes1(uint8(0x40)); - try dep.deposit{value: uint(amount_gwei) * 1 gwei}( - inf, wc, signature, pubkey_y, signature_y - ) { + uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); + try dep.deposit{value: value}(inf, wc, amount_gwei, signature, pubkey_y, signature_y) { require(false, "infinity pubkey should revert"); } catch {} require(dep.pendingCount() == 0, "nothing enqueued on reject"); @@ -223,13 +310,14 @@ contract BuilderDepositTest { // ── Negative paths: input-shape validation ───────────────────────────── - function testDepositRejectsTooSmallAmount() public { + function testDepositRejectsTooSmallStake() public { bytes memory pubkey = new bytes(48); bytes memory signature = new bytes(96); BuilderDepositContract.Fp memory z = BuilderDepositContract.Fp(0, 0); BuilderDepositContract.Fp2 memory z2 = BuilderDepositContract.Fp2(z, z); - try dep.deposit{value: 0.5 ether}(pubkey, bytes32(0), signature, z, z2) { - require(false, "deposit < 1 ether should revert"); + // 0.5 ETH stake (< 1 ETH minimum). + try dep.deposit{value: 1 ether}(pubkey, bytes32(0), 500_000_000, signature, z, z2) { + require(false, "stake < 1 ether should revert"); } catch {} require(dep.pendingCount() == 0, "nothing enqueued on reject"); } @@ -239,32 +327,25 @@ contract BuilderDepositTest { bytes memory signature = new bytes(96); BuilderDepositContract.Fp memory z = BuilderDepositContract.Fp(0, 0); BuilderDepositContract.Fp2 memory z2 = BuilderDepositContract.Fp2(z, z); - try dep.deposit{value: 1 ether}(pubkey, bytes32(0), signature, z, z2) { + try dep.deposit{value: 2 ether}(pubkey, bytes32(0), 1_000_000_000, signature, z, z2) { require(false, "47-byte pubkey should revert"); } catch {} require(dep.pendingCount() == 0, "nothing enqueued on reject"); } - function testTopUpRejectsTooSmallAmount() public { + function testTopUpRejectsTooSmallStake() public { bytes memory pubkey = new bytes(48); - try top.top_up{value: 0.5 ether}(pubkey) { - require(false, "top_up < 1 ether should revert"); + try top.top_up{value: 1 ether}(pubkey, 500_000_000) { + require(false, "top_up stake < 1 ether should revert"); } catch {} require(top.pendingCount() == 0, "nothing enqueued on reject"); } function testTopUpRejectsWrongPubkeyLength() public { bytes memory pubkey = new bytes(47); - try top.top_up{value: 1 ether}(pubkey) { + try top.top_up{value: 2 ether}(pubkey, 1_000_000_000) { require(false, "47-byte pubkey should revert"); } catch {} require(top.pendingCount() == 0, "nothing enqueued on reject"); } - - // ── helper ───────────────────────────────────────────────────────────── - - function _copy(bytes memory src) internal pure returns (bytes memory dst) { - dst = new bytes(src.length); - for (uint i = 0; i < src.length; i++) dst[i] = src[i]; - } } diff --git a/assets/eip-draft_builder_deposit/test/TestHarness.sol b/assets/eip-draft_builder_deposit/test/TestHarness.sol index ada3f65d6d5e83..ffb2b33e45b1e7 100644 --- a/assets/eip-draft_builder_deposit/test/TestHarness.sol +++ b/assets/eip-draft_builder_deposit/test/TestHarness.sol @@ -11,21 +11,33 @@ import "../builder_deposit_contract.sol"; contract BuilderDepositHarness is BuilderDepositContract { /// @notice Number of queued-but-not-yet-dequeued records. function pendingCount() external view returns (uint) { - return queue.length - queueHead; + return queueTail - queueHead; + } + + /// @notice Current per-request fee (wei). + function feeWei() external view returns (uint) { + return _getFee(); } function computeDepositSigningRoot( bytes calldata pubkey, - bytes32 withdrawal_credentials, - uint64 amount_gwei + bytes32 withdrawal_credentials ) external pure returns (bytes32) { - return _computeDepositSigningRoot(pubkey, withdrawal_credentials, amount_gwei); + return _computeDepositSigningRoot(pubkey, withdrawal_credentials); } } /// @notice Test harness for the top-up predeploy. contract BuilderTopUpHarness is BuilderTopUpContract { function pendingCount() external view returns (uint) { - return queue.length - queueHead; + return queueTail - queueHead; } + + function feeWei() external view returns (uint) { + return _getFee(); + } + + /// @notice Raw head/tail indices, to assert the EIP-7002 reset-on-empty. + function headIdx() external view returns (uint) { return queueHead; } + function tailIdx() external view returns (uint) { return queueTail; } } diff --git a/assets/eip-draft_builder_deposit/test/Vectors.sol b/assets/eip-draft_builder_deposit/test/Vectors.sol index 29dadfcb3ef098..781818a98133ae 100644 --- a/assets/eip-draft_builder_deposit/test/Vectors.sol +++ b/assets/eip-draft_builder_deposit/test/Vectors.sol @@ -29,18 +29,18 @@ library Vectors { { pubkey = hex"b5b99c967e4c69822f427db1f6871dd119afb95ab9646ba2e707990a3db31777a59b66f69e89c2055699b0ade7357eae"; withdrawal_credentials = 0x0000000000000000000000000000000000000000000000000000000000000000; - signature = hex"ab275ac905d68eb121c85caff35043de7e7bf478ec46b28a09eec700a4d2123572f18202f701d6a2974daddf7afba23b11199fa446bae3e67c630d45f62928dc3e3244317b485ff002e126e6b917ddcf4426f0903d68eb6743ab1a33fdaf56ed"; - deposit_data_root = 0xcf743a69594fad43bccf7056e08b2a3b12a6696d0a7ccf0527c48d6cabd98f01; + signature = hex"a80fa59d29e8bac877966ed1d113c8b8f431de687c182d19f0a37951cb8a57e66757f69cbdefb2f58da6ea58e66d9a1d0504465cad3a02b8e8da35969b9cadbce789c6b0f2fa926882b526f928c10dff7386af66cccb0b162b37a75a246d5087"; + deposit_data_root = 0x625d01daa2c4638988bb35d6ca0643efbfc44f23066c0cb26401b613cecb8e0e; amount_gwei = 32000000000; pubkey_y = BuilderDepositContract.Fp(0x1201d584a96bc82775861b0611171d2f, 0xfeaa2431c48856e23d3b747f7392fad645e0cdd5aa01b6a5b24b73ab584db4ad); - signature_y = BuilderDepositContract.Fp2(BuilderDepositContract.Fp(0x12ca72b79300a13c051ed9170061f030, 0xf53694950c73fdb4f8130911d0dac6b13b423450cb333b467102fd22666d6593), BuilderDepositContract.Fp(0x14454e59e30d8480bc2bf6f3d08196f9, 0x285f86d269689bc843fcf8006d8b1448bfd6d71010aca7cba6ebde8b1154aa90)); + signature_y = BuilderDepositContract.Fp2(BuilderDepositContract.Fp(0x0291c03dc4b0cad5bbb54fed95b2f7b4, 0x26a18420768bbc16659cf22495b0d8f1723ebe189c876a6a21c9a828b4a27131), BuilderDepositContract.Fp(0x13edfbbff93a06436cd5c99133b038b8, 0xd2cc864627eef31c8324d7f5ee60f09b30408aafeb9cae41dd1a53f7bd768560)); } /// @notice Expected `compute_signing_root` for `depositCase()`, /// computed in Python and baked in so the on-chain SSZ helper can be /// cross-checked without a second BLS pairing. function depositSigningRoot() internal pure returns (bytes32) { - return 0xf764d63dcac69648e80ba943a88eaf62ce01a4c53998971787c3dc78b6f006c2; + return 0x3a635e9092f64642776ebfde7fcdf130286f10ef9825007b76e107966983c1e4; } } From 53a83263ce67cf25887f520a6a5c0ac39ab0b072 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 26 May 2026 16:33:57 -0400 Subject: [PATCH 04/12] Define CL request objects and simplify duplicate-deposit rule Add the SSZ container definitions BuilderDepositRequest (pubkey, withdrawal_credentials, amount) and BuilderTopUpRequest (pubkey, amount) to the spec, matching the EIP-7002/7251 style and tying the 88/56-byte record serialization to what the contract appends; note the absence of a signature/index field versus the EIP-6110 DepositRequest. Reduce the "Consensus-layer processing of records" rules to normative statements that reference the new objects by name; the replayability rationale lives in Security Considerations. --- EIPS/eip-draft_builder_deposit.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/EIPS/eip-draft_builder_deposit.md b/EIPS/eip-draft_builder_deposit.md index bdb10c16eb018b..b8931aaf874013 100644 --- a/EIPS/eip-draft_builder_deposit.md +++ b/EIPS/eip-draft_builder_deposit.md @@ -133,13 +133,30 @@ top_up( The deposited ETH for both entrypoints is locked in the respective contract; the consensus layer credits the builder from the dequeued request. +### Consensus layer request objects + +The consensus layer decodes each dequeued record into one of two SSZ containers, selected by request type: + +```python +class BuilderDepositRequest(object): + pubkey: Bytes48 + withdrawal_credentials: Bytes32 + amount: uint64 # Gwei + +class BuilderTopUpRequest(object): + pubkey: Bytes48 + amount: uint64 # Gwei +``` + +A type's `request_data` is the concatenation of the fixed-size SSZ serializations of its records — 88 bytes per `BuilderDepositRequest` (`pubkey ++ withdrawal_credentials ++ amount`) and 56 bytes per `BuilderTopUpRequest` (`pubkey ++ amount`), with `amount` little-endian — in the FIFO order the system call returns them. This matches the bytes the contract appends to its queue. Unlike the validator [EIP-6110](./eip-6110.md) `DepositRequest`, `BuilderDepositRequest` carries no `signature` (the execution layer has already verified it) and no `index`. + ### Consensus-layer processing of records The consensus layer processes the two request types as follows: -- A `BUILDER_DEPOSIT_REQUEST_TYPE` (`0x03`) record for a `pubkey` **not** yet in the builder set is a first deposit: it registers the builder with the record's `withdrawal_credentials` and credits its `amount_gwei`. The execution layer has already verified the proof-of-possession, so the consensus layer does not re-verify. -- A `BUILDER_DEPOSIT_REQUEST_TYPE` (`0x03`) record for a `pubkey` **already** in the builder set MUST be treated as a top-up: it credits `amount_gwei` and MUST NOT change the existing `withdrawal_credentials`. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. The rule is required because the `0x03` entrypoint's signature is replayable: it commits only to `(pubkey, withdrawal_credentials)` and is public in calldata once any deposit lands, so a third party can submit a further `0x03` record for an already-registered builder (funding it themselves). Treating it as a top-up makes that replay equivalent to a `BUILDER_TOPUP_REQUEST_TYPE` record — a permitted stake addition — and prevents it from re-registering or altering the builder. -- A `BUILDER_TOPUP_REQUEST_TYPE` (`0x04`) record MUST be rejected if its `pubkey` is not already a registered builder, and otherwise credits `amount_gwei` without touching the withdrawal credentials. +- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **not** yet in the builder set is a first deposit: it registers the builder with the record's `withdrawal_credentials` and credits its `amount`. The execution layer has already verified the proof-of-possession, so the consensus layer does not re-verify. +- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **already** in the builder set MUST be treated as a top-up: it credits `amount` and MUST NOT change the existing `withdrawal_credentials` or re-register the builder. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. +- A `BuilderTopUpRequest` (type `0x04`) MUST be rejected if its `pubkey` is not already a registered builder, and otherwise credits `amount` without touching the withdrawal credentials. ## Rationale From 8d38f839a333f28ad85ed16547dcd307cd86bd83 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 2 Jun 2026 18:15:28 +0200 Subject: [PATCH 05/12] chore: add more eip authors --- EIPS/eip-draft_builder_deposit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EIPS/eip-draft_builder_deposit.md b/EIPS/eip-draft_builder_deposit.md index b8931aaf874013..0fdf103b6ac5d8 100644 --- a/EIPS/eip-draft_builder_deposit.md +++ b/EIPS/eip-draft_builder_deposit.md @@ -1,7 +1,7 @@ --- title: Builder Deposit Contract description: Predeploy BLS-verifying builder deposit and top-up contracts as EIP-7685 requests, using EIP-2537 precompiles, for EIP-7732 builders -author: Cayman (@wemeetagain) +author: Cayman (@wemeetagain), Nico Flaig , Matthew Keil discussions-to: status: Draft type: Standards Track From 49dcdd8b5a864ed368640f3128704450647dd60c Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 2 Jun 2026 20:33:58 +0200 Subject: [PATCH 06/12] feat: add builder withdrawal/exit request contract Add BuilderWithdrawalContract (request type 0x05, EIP-7002-style) for builder partial withdrawals and full exits, and broaden the draft to the full builder lifecycle (renamed to "Builder Execution Requests"). --- ...posit.md => eip-draft_builder_requests.md} | 93 +++++++++++------- .../.gitignore | 0 .../README.md | 14 ++- .../builder_requests.sol} | 89 ++++++++++++++--- .../foundry.toml | 0 .../gen_vectors.py | 6 +- .../test/BuilderRequests.t.sol} | 97 +++++++++++++++++-- .../test/TestHarness.sol | 13 ++- .../test/Vectors.sol | 2 +- 9 files changed, 249 insertions(+), 65 deletions(-) rename EIPS/{eip-draft_builder_deposit.md => eip-draft_builder_requests.md} (55%) rename assets/{eip-draft_builder_deposit => eip-draft_builder_requests}/.gitignore (100%) rename assets/{eip-draft_builder_deposit => eip-draft_builder_requests}/README.md (72%) rename assets/{eip-draft_builder_deposit/builder_deposit_contract.sol => eip-draft_builder_requests/builder_requests.sol} (87%) rename assets/{eip-draft_builder_deposit => eip-draft_builder_requests}/foundry.toml (100%) rename assets/{eip-draft_builder_deposit => eip-draft_builder_requests}/gen_vectors.py (97%) rename assets/{eip-draft_builder_deposit/test/BuilderDeposit.t.sol => eip-draft_builder_requests/test/BuilderRequests.t.sol} (76%) rename assets/{eip-draft_builder_deposit => eip-draft_builder_requests}/test/TestHarness.sol (81%) rename assets/{eip-draft_builder_deposit => eip-draft_builder_requests}/test/Vectors.sol (98%) diff --git a/EIPS/eip-draft_builder_deposit.md b/EIPS/eip-draft_builder_requests.md similarity index 55% rename from EIPS/eip-draft_builder_deposit.md rename to EIPS/eip-draft_builder_requests.md index 0fdf103b6ac5d8..eea87cac73ffc1 100644 --- a/EIPS/eip-draft_builder_deposit.md +++ b/EIPS/eip-draft_builder_requests.md @@ -1,39 +1,34 @@ --- -title: Builder Deposit Contract -description: Predeploy BLS-verifying builder deposit and top-up contracts as EIP-7685 requests, using EIP-2537 precompiles, for EIP-7732 builders +title: Builder Execution Requests +description: Predeploy builder deposit, top-up, and withdrawal/exit contracts as EIP-7685 requests for EIP-7732 builders author: Cayman (@wemeetagain), Nico Flaig , Matthew Keil discussions-to: status: Draft type: Standards Track category: Core created: 2026-05-22 -requires: 2537, 7685, 7732 +requires: 2537, 7002, 7685, 7732 --- ## Abstract -Predeploy two [EIP-7685](./eip-7685.md) request contracts for the [EIP-7732](./eip-7732.md) builder population, modelled on the request bus that [EIP-7002](./eip-7002.md) (withdrawals) and [EIP-7251](./eip-7251.md) (consolidations) use: +Predeploy three [EIP-7685](./eip-7685.md) request contracts for the [EIP-7732](./eip-7732.md) builder population, modelled on the request bus that [EIP-7002](./eip-7002.md) (withdrawals) and [EIP-7251](./eip-7251.md) (consolidations) use: -- a builder deposit contract whose `deposit(...)` verifies a BLS proof-of-possession over the `pubkey` and `withdrawal_credentials` using the [EIP-2537](./eip-2537.md) precompiles, then appends a deposit request to its queue; and -- a builder top-up contract whose `top_up(...)` appends an additional-stake request for an existing builder without on-chain signature verification. +- a builder deposit contract whose `deposit(...)` verifies a BLS proof-of-possession over the `pubkey` and `withdrawal_credentials` using the [EIP-2537](./eip-2537.md) precompiles, then appends a deposit request to its queue; +- a builder top-up contract whose `top_up(...)` appends an additional-stake request for an existing builder without on-chain signature verification; and +- a builder withdrawal contract whose `withdraw(...)` appends a withdrawal request authorized by the caller's address — a partial withdrawal when its amount is non-zero, a full exit when the amount is zero — as a direct analogue of the [EIP-7002](./eip-7002.md) withdrawal predeploy. -Each contract maintains an in-state request queue drained by an end-of-block `SYSTEM_ADDRESS` system call; the dequeued records become the contract's [EIP-7685](./eip-7685.md) `request_data`, committed in the block `requests_hash`. Neither contract emits logs. Both are independent of the existing validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`. +Each contract maintains an in-state request queue drained by an end-of-block `SYSTEM_ADDRESS` system call; the dequeued records become the contract's [EIP-7685](./eip-7685.md) `request_data`, committed in the block `requests_hash`. None of the contracts emit logs. All three are independent of the existing validator deposit contract and of the validator request predeploys. ## Motivation -The deployed validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa` does not verify BLS signatures on chain. The consensus layer instead verifies the proof-of-possession of a `pubkey` on its **first** appearance — subsequent top-ups to the same `pubkey` are accepted without any further signature check, and in practice top-ups are submitted with all-zero signatures. Two consequences follow: +[EIP-7732](./eip-7732.md) introduces builders as a separate, staked consensus-layer class. Like a validator, a builder is created by a deposit, can have stake added, and must be able to withdraw stake or fully exit — the lifecycle validators drive from the execution layer: deposits and top-ups through the deposit contract, withdrawals and exits through [EIP-7002](./eip-7002.md). This EIP gives builders that same lifecycle as a set of dedicated [EIP-7685](./eip-7685.md) request contracts. -1. The existing contract is an immutable two-mode API: a "first deposit" must carry a valid signature, while every "top-up" intentionally omits one. Replacing its runtime with a signature-checking variant would break the top-up path and reject all-zero signatures that are in use today. -2. The signature-verification cost for new validators is borne entirely by the consensus layer. An adversary that can submit arbitrarily many invalid deposits forces every beacon node to pay the verification cost for each one. Mainnet absorbs this today only because the 32-ETH minimum validator deposit makes the per-attempt cost expensive. +Without dedicated contracts, builders must ride the validator lifecycle contracts, forcing the consensus layer to decide on every request whether it acts on the validator set or the builder set. EIP-7732 already does this for deposits: a builder is registered by an ordinary validator deposit request whose withdrawal credential carries the `0x03` `BUILDER_WITHDRAWAL_PREFIX`, and the consensus layer routes on that prefix. Dedicated builder request types instead make the actor type explicit from the request type alone, so the consensus layer never disambiguates by inspecting credentials, and the validator and builder registries can be keyed independently. A single public key can then be registered as both a validator and a builder; the protocol currently disallows that overlap, and this EIP allows the rule to be removed. -[EIP-7732](./eip-7732.md) introduces builders as a separate consensus-layer class with a substantially lower deposit threshold (as little as 1 ETH per builder). Naively reusing the existing deposit contract for builders would amplify the consensus-side DoS surface in proportion to how much cheaper a builder deposit is, while preserving the existing top-up loophole. +The builder deposit is the one operation that cannot be a plain clone of its validator counterpart. The deployed validator deposit contract does not verify BLS signatures on chain, so the consensus layer pays the proof-of-possession check for every deposit it processes — valid or not. Mainnet tolerates this only because the 32-ETH minimum makes spamming invalid deposits expensive; EIP-7732 sets the builder threshold as low as 1 ETH, which would amplify that consensus-side cost. The builder deposit contract therefore verifies the proof-of-possession on chain with the [EIP-2537](./eip-2537.md) precompiles and gas-meters it, so presenting any candidate costs the depositor's own gas and DoS resistance follows from ordinary gas pricing rather than consensus-side throttling. -This EIP introduces a **separate** deposit contract dedicated to the EIP-7732 builder population. It: - -- Verifies the BLS proof-of-possession on chain using the [EIP-2537](./eip-2537.md) precompiles, so the consensus layer can skip the per-deposit pairing cost; and -- Gas-meters the verification, so the cost of presenting a candidate (valid or invalid) is charged to the depositor's transaction. DoS resistance falls out of the existing gas-pricing rules instead of needing dedicated consensus-side throttling. - -The existing validator deposit contract is untouched, preserving its first-deposit-plus-unsigned-top-up semantics for the existing 32-ETH validator population. +The remaining operations need no such machinery. A top-up only adds stake to an already-registered builder, so — like a repeat deposit to the validator contract — it carries no signature. A withdrawal or exit is authorized by the address that controls the builder's stake, exactly as [EIP-7002](./eip-7002.md) lets a validator's withdrawal credential trigger its own, so the builder withdrawal contract reuses the EIP-7002 design directly. The deployed validator deposit contract is left untouched, and existing validator deposits, withdrawals, and exits are unaffected. ## Specification @@ -41,42 +36,46 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ### Constants -All address and request-type values below are placeholders pending allocation in consensus-specs and the execution-layer client configuration; the `0x03`/`0x04` request types in particular MUST be distinct from the existing deposit (`0x00`), withdrawal (`0x01`), and consolidation (`0x02`) types. +All address and request-type values below are placeholders pending allocation in consensus-specs and the execution-layer client configuration; the `0x03`/`0x04`/`0x05` request types in particular MUST be distinct from the existing deposit (`0x00`), withdrawal (`0x01`), and consolidation (`0x02`) types. | Name | Value | Comment | | --- | --- | --- | | `BUILDER_DEPOSIT_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007732` | Predeploy address of the builder deposit contract (placeholder) | | `BUILDER_TOPUP_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007733` | Predeploy address of the builder top-up contract (placeholder) | +| `BUILDER_WITHDRAWAL_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007734` | Predeploy address of the builder withdrawal/exit contract (placeholder) | | `BUILDER_DEPOSIT_REQUEST_TYPE` | `0x03` | [EIP-7685](./eip-7685.md) request-type byte for builder deposits (placeholder) | | `BUILDER_TOPUP_REQUEST_TYPE` | `0x04` | [EIP-7685](./eip-7685.md) request-type byte for builder top-ups (placeholder) | +| `BUILDER_WITHDRAWAL_REQUEST_TYPE` | `0x05` | [EIP-7685](./eip-7685.md) request-type byte for builder withdrawals/exits (placeholder) | | `SYSTEM_ADDRESS` | `0xfffffffffffffffffffffffffffffffffffffffe` | Address that invokes the end-of-block system call (as in [EIP-7002](./eip-7002.md)) | | `MAX_REQUESTS_PER_BLOCK` | `16` | Maximum records each contract drains into one block | | `TARGET_REQUESTS_PER_BLOCK` | `2` | Per-block request count above which the fee rises | | `MIN_REQUEST_FEE` | `1` | Minimum request fee, in wei | | `REQUEST_FEE_UPDATE_FRACTION` | `17` | Controls the fee's rate of change | +| `BUILDER_MIN_DEPOSIT` | `1000000000000000000` | Minimum credited stake for a deposit or top-up, in wei (1 ETH — the [EIP-7732](./eip-7732.md) builder minimum). Withdrawals enforce no minimum | | `DOMAIN_BUILDER_DEPOSIT` | `0x0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9` | Signing domain for builder deposit messages. The `0x0b000000` domain type is a placeholder pending consensus-specs allocation; it MUST differ from the validator `DOMAIN_DEPOSIT` (`0x03000000…`) so signatures are not interchangeable between the two contracts | | `BLS12_G2ADD` | `0x0d` | [EIP-2537](./eip-2537.md) precompile address | | `BLS12_PAIRING_CHECK` | `0x0f` | [EIP-2537](./eip-2537.md) precompile address | | `BLS12_MAP_FP2_TO_G2` | `0x11` | [EIP-2537](./eip-2537.md) precompile address | | `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder deposit contract | | `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder top-up contract | +| `BUILDER_WITHDRAWAL_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder withdrawal/exit contract | ### Fork transition -At the start of processing the first block where this EIP is active, before processing transactions, execution clients MUST install each predeploy — `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` at `BUILDER_DEPOSIT_CONTRACT_ADDRESS` and `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE` at `BUILDER_TOPUP_CONTRACT_ADDRESS` — if the account at the respective address is empty (zero `nonce`, empty `code`, empty `storage`, zero `balance`). Each installation MUST set `code` to the runtime code, `nonce = 1`, `balance = 0`, and leave `storage` empty. +At the start of processing the first block where this EIP is active, before processing transactions, execution clients MUST install each predeploy — `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` at `BUILDER_DEPOSIT_CONTRACT_ADDRESS`, `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE` at `BUILDER_TOPUP_CONTRACT_ADDRESS`, and `BUILDER_WITHDRAWAL_CONTRACT_RUNTIME_CODE` at `BUILDER_WITHDRAWAL_CONTRACT_ADDRESS` — if the account at the respective address is empty (zero `nonce`, empty `code`, empty `storage`, zero `balance`). Each installation MUST set `code` to the runtime code, `nonce = 1`, `balance = 0`, and leave `storage` empty. -If either account is not empty at fork time, clients MUST abort initialisation. This matches the predeploy pattern used by [EIP-2935](./eip-2935.md), [EIP-4788](./eip-4788.md), [EIP-7002](./eip-7002.md), and [EIP-7251](./eip-7251.md). +If any of these accounts is not empty at fork time, clients MUST abort initialisation. This matches the predeploy pattern used by [EIP-2935](./eip-2935.md), [EIP-4788](./eip-4788.md), [EIP-7002](./eip-7002.md), and [EIP-7251](./eip-7251.md). ### Request queue and system call -Both predeploys follow the [EIP-7002](./eip-7002.md) / [EIP-7251](./eip-7251.md) request-bus pattern. Each maintains a FIFO queue of request records in its own storage and an EIP-1559-style `excess` counter. A user-facing entrypoint validates a request, charges the current fee, and appends one record. +All three predeploys follow the [EIP-7002](./eip-7002.md) / [EIP-7251](./eip-7251.md) request-bus pattern. Each maintains a FIFO queue of request records in its own storage and an EIP-1559-style `excess` counter. A user-facing entrypoint validates a request, charges the current fee, and appends one record. A call with empty calldata dispatches on the caller: - From `SYSTEM_ADDRESS` (the end-of-block system call): the predeploy MUST dequeue up to `MAX_REQUESTS_PER_BLOCK` records (oldest first), return their concatenation as that contract's `request_data`, advance its queue head past the returned records, then update `excess` from the number of requests added in the block (`excess = max(0, excess + count - TARGET_REQUESTS_PER_BLOCK)`) and reset that count. Records beyond the per-block cap remain queued for subsequent blocks. - From any other caller: the predeploy MUST return the current fee (the fee getter), without modifying state. -The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). Neither contract emits logs. +The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). None of the contracts emit logs. ### Request fee @@ -107,7 +106,7 @@ deposit( `deposit(...)` MUST perform the following, in order, before appending any record: -1. Validate input lengths, and that `amount_gwei * 1 gwei` is at least the minimum stake. +1. Validate input lengths, and that `amount_gwei * 1 gwei` is at least `BUILDER_MIN_DEPOSIT`. 2. Require `msg.value >= amount_gwei * 1 gwei + fee`, where `fee` is the current request fee. Any value beyond `amount_gwei * 1 gwei` is retained by the contract (the fee, plus any overpayment, is not credited to the builder). 3. Reject `pubkey` or `signature` whose infinity flag is set. 4. Verify that the supplied `pubkey_y` and `signature_y` agree with the sign flag of the corresponding compressed encoding (i.e. `sign(pubkey_y)` equals the sign bit of `pubkey`, and likewise for the signature). This binds the point used in the pairing check to the encoding the consensus layer will register; without it the verified point could be the negation of the registered point. @@ -127,15 +126,30 @@ top_up( ) payable ``` -`top_up(...)` MUST validate the pubkey length, require `amount_gwei * 1 gwei` to be at least the minimum stake, and require `msg.value >= amount_gwei * 1 gwei + fee` (same fee as `deposit`), but MUST NOT perform any signature verification. On success it MUST append a `BUILDER_TOPUP_REQUEST_TYPE` record of `pubkey (48) ++ amount_gwei (8, little-endian)` to its queue. +`top_up(...)` MUST validate the pubkey length, require `amount_gwei * 1 gwei` to be at least `BUILDER_MIN_DEPOSIT`, and require `msg.value >= amount_gwei * 1 gwei + fee` (same fee as `deposit`), but MUST NOT perform any signature verification. On success it MUST append a `BUILDER_TOPUP_REQUEST_TYPE` record of `pubkey (48) ++ amount_gwei (8, little-endian)` to its queue. `top_up(...)` deliberately takes no `withdrawal_credentials`. A top-up only adds stake to an already-registered builder; the credentials are fixed by that builder's verified deposit. Omitting the field denies an unauthenticated caller any influence over a builder's withdrawal target. The consensus layer is responsible for rejecting top-up records that target a `pubkey` not already registered as an EIP-7732 builder. -The deposited ETH for both entrypoints is locked in the respective contract; the consensus layer credits the builder from the dequeued request. +The deposited ETH for the deposit and top-up entrypoints is locked in the respective contract; the consensus layer credits the builder from the dequeued request. A withdrawal, by contrast, moves no ETH on the execution layer. + +### Withdrawal and exit entrypoint + +``` +withdraw( + bytes pubkey, // 48-byte builder public key + uint64 amount_gwei // gwei to withdraw; 0 requests a full exit +) payable +``` + +`withdraw(...)` is a direct analogue of the [EIP-7002](./eip-7002.md) withdrawal-request entrypoint, retargeted at the builder set. It MUST validate the `pubkey` length and require `msg.value >= fee` (the same request fee as `deposit`/`top_up`), but it performs **no** signature verification and stakes **no** value: a withdrawal debits the builder's beacon-chain balance rather than moving ETH on the execution layer, so `msg.value` need only cover the fee. There is intentionally no minimum-amount check — `amount_gwei == 0` is the full-exit sentinel. + +On success it MUST append a `BUILDER_WITHDRAWAL_REQUEST_TYPE` record of `source_address (20) ++ pubkey (48) ++ amount_gwei (8, little-endian)` to its queue, where `source_address` is `msg.sender`. This record shape is identical to the [EIP-7002](./eip-7002.md) withdrawal request. + +Authorization is by `source_address`, exactly as in [EIP-7002](./eip-7002.md): the caller proves control of the builder by transacting from the builder's `execution_address` (the `0x03` builder withdrawal credential). The contract itself does not check this — it records `msg.sender` verbatim — and the consensus layer honours the request only when `source_address` equals the target builder's `execution_address` (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)). An `amount_gwei` of `0` requests a full exit (the builder's voluntary exit); any `amount_gwei > 0` requests a partial withdrawal of that many gwei. ### Consensus layer request objects -The consensus layer decodes each dequeued record into one of two SSZ containers, selected by request type: +The consensus layer decodes each dequeued record into one of three SSZ containers, selected by request type: ```python class BuilderDepositRequest(object): @@ -146,25 +160,33 @@ class BuilderDepositRequest(object): class BuilderTopUpRequest(object): pubkey: Bytes48 amount: uint64 # Gwei + +class BuilderWithdrawalRequest(object): + source_address: Bytes20 + pubkey: Bytes48 + amount: uint64 # Gwei ``` -A type's `request_data` is the concatenation of the fixed-size SSZ serializations of its records — 88 bytes per `BuilderDepositRequest` (`pubkey ++ withdrawal_credentials ++ amount`) and 56 bytes per `BuilderTopUpRequest` (`pubkey ++ amount`), with `amount` little-endian — in the FIFO order the system call returns them. This matches the bytes the contract appends to its queue. Unlike the validator [EIP-6110](./eip-6110.md) `DepositRequest`, `BuilderDepositRequest` carries no `signature` (the execution layer has already verified it) and no `index`. +A type's `request_data` is the concatenation of the fixed-size SSZ serializations of its records — 88 bytes per `BuilderDepositRequest` (`pubkey ++ withdrawal_credentials ++ amount`), 56 bytes per `BuilderTopUpRequest` (`pubkey ++ amount`), and 76 bytes per `BuilderWithdrawalRequest` (`source_address ++ pubkey ++ amount`), with `amount` little-endian — in the FIFO order the system call returns them. This matches the bytes the contract appends to its queue. Unlike the validator [EIP-6110](./eip-6110.md) `DepositRequest`, `BuilderDepositRequest` carries no `signature` (the execution layer has already verified it) and no `index`. `BuilderWithdrawalRequest` has the same shape as the validator [EIP-7002](./eip-7002.md) withdrawal request (`source_address ++ validator_pubkey ++ amount`), reused unchanged for builders. ### Consensus-layer processing of records -The consensus layer processes the two request types as follows: +The consensus layer processes the three request types as follows: - A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **not** yet in the builder set is a first deposit: it registers the builder with the record's `withdrawal_credentials` and credits its `amount`. The execution layer has already verified the proof-of-possession, so the consensus layer does not re-verify. - A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **already** in the builder set MUST be treated as a top-up: it credits `amount` and MUST NOT change the existing `withdrawal_credentials` or re-register the builder. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. - A `BuilderTopUpRequest` (type `0x04`) MUST be rejected if its `pubkey` is not already a registered builder, and otherwise credits `amount` without touching the withdrawal credentials. +- A `BuilderWithdrawalRequest` (type `0x05`) MUST be ignored unless its `pubkey` is a registered builder **and** its `source_address` equals that builder's `execution_address`. When valid, an `amount` of `0` initiates a full exit of the builder (setting its `withdrawable_epoch`), and any `amount > 0` queues a partial withdrawal of up to `amount` gwei from the builder's balance. This mirrors validator [EIP-7002](./eip-7002.md) processing — `amount == 0` is a full exit, `amount > 0` a partial withdrawal — and the `execution_address` match is the builder analogue of EIP-7002's check that `source_address` matches the validator's `0x01` withdrawal credential. ## Rationale - **A separate contract, not a replacement.** The deployed validator contract has an immutable two-mode API. Replacing its runtime would either break the all-zero-signature top-up flow that mainnet uses today, or would require keeping an unverified entrypoint in the spec — bringing the same DoS surface forward. A separate contract lets the existing validator semantics stay fixed. -- **Reuse the EIP-7685 request bus.** Builder deposits and top-ups are delivered through the same execution-to-consensus request mechanism as [EIP-7002](./eip-7002.md) withdrawals and [EIP-7251](./eip-7251.md) consolidations: an in-state queue drained by an end-of-block `SYSTEM_ADDRESS` system call, committed in `requests_hash`. This is preferred over the log-scraping path that [EIP-6110](./eip-6110.md) uses for validator deposits, which was a backwards-compatibility accommodation for the immutable validator contract. A fresh contract has no such constraint, so it uses the modern request bus and the consensus layer needs no log-parsing for builders. +- **Reuse the EIP-7685 request bus.** All three contracts deliver their records through the same execution-to-consensus mechanism as [EIP-7002](./eip-7002.md) withdrawals and [EIP-7251](./eip-7251.md) consolidations: an in-state queue drained by an end-of-block `SYSTEM_ADDRESS` system call, committed in `requests_hash`, and emitting no logs. As fresh predeploys they adopt this request bus directly, so the consensus layer reads every builder operation from the block's requests list. + +- **Three predeploys, three request types.** Mirroring withdrawals (`0x01`) and consolidations (`0x02`) — each a single-type request predeploy — builder deposits (`0x03`), top-ups (`0x04`), and withdrawals/exits (`0x05`) are separate predeploys sharing a common queue implementation. Each is a standard single-type request contract: an empty-calldata `SYSTEM_ADDRESS` call returns a flat `request_data`. The execution layer therefore needs no new read semantics, and the consensus layer distinguishes a first-sighting deposit (with an execution-layer-verified signature) from a stake-only top-up by request type rather than by inspecting record contents. -- **Two predeploys, two request types.** Mirroring withdrawals (`0x01`) and consolidations (`0x02`) — each a single-type request predeploy — builder deposits (`0x03`) and top-ups (`0x04`) are two separate predeploys sharing a common queue implementation. Each is a standard single-type request contract: an empty-calldata `SYSTEM_ADDRESS` call returns a flat `request_data`. The execution layer therefore needs no new read semantics, and the consensus layer distinguishes a first-sighting deposit (with an execution-layer-verified signature) from a stake-only top-up by request type rather than by inspecting record contents. +- **Withdrawals and exits clone EIP-7002.** The withdrawal predeploy is deliberately a direct analogue of the [EIP-7002](./eip-7002.md) withdrawal contract rather than a fresh design. A builder withdrawal or exit is authorized the same way a validator's is — by transacting from the credential that owns the stake (the builder's `execution_address`) — so no proof-of-possession is needed and the record is simply `source_address ++ pubkey ++ amount`, identical to EIP-7002. A single contract covers both operations because, as in EIP-7002, `amount == 0` is the full exit and `amount > 0` a partial withdrawal. This is the one place the consensus layer keys off a record field (the amount) rather than the request type alone: reusing the audited EIP-7002 record shape unchanged was judged more valuable than splitting exit into its own request type for uniformity with the deposit/top-up split. Unlike `deposit`/`top_up`, `withdraw(...)` stakes no value — it debits the builder's beacon-chain balance — so `msg.value` covers only the request fee. - **EIP-1559-style request fee.** Each request carries the same dynamic, demand-responsive fee as EIP-7002/7251, rather than relying on the staked value alone as the anti-spam gate. This keeps the builder predeploys uniform with the existing request bus and smooths bursts: when a block exceeds `TARGET_REQUESTS_PER_BLOCK`, the `excess` counter grows and the fee rises super-linearly, throttling demand independently of the deposit minimum; it decays back to `MIN_REQUEST_FEE` when demand subsides. Because a deposit also carries stake, the fee is charged on top of the staked value; the fee is retained by the contract (effectively burned), and the per-block cap plus the queue still bound how many records enter a single block. @@ -180,17 +202,17 @@ The consensus layer processes the two request types as follows: ## Backwards Compatibility -This EIP is additive at the execution layer: it introduces a new contract at a previously empty address. It does not modify the validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`, does not change the `DepositEvent` layout that contract emits, and does not affect any existing validator's ability to make first deposits or top-ups. +This EIP is additive at the execution layer: it introduces new contracts at previously empty addresses. It does not modify the validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`, does not change the `DepositEvent` layout that contract emits, and does not affect any existing validator's ability to make first deposits or top-ups. -At the consensus layer, EIP-7732 builders MUST be sourced from the builder deposit (`BUILDER_DEPOSIT_REQUEST_TYPE`) and top-up (`BUILDER_TOPUP_REQUEST_TYPE`) requests committed in the block `requests_hash`; the validator deposit contract continues to be the sole source of validator deposits. The new request types are additive — blocks that contain no builder requests produce empty `request_data` for these types, which [EIP-7685](./eip-7685.md) excludes from the `requests_hash`. +At the consensus layer, EIP-7732 builders MUST be sourced from the builder deposit (`BUILDER_DEPOSIT_REQUEST_TYPE`) and top-up (`BUILDER_TOPUP_REQUEST_TYPE`) requests committed in the block `requests_hash`, and builder withdrawals and exits from the builder withdrawal (`BUILDER_WITHDRAWAL_REQUEST_TYPE`) requests; the validator deposit and withdrawal contracts remain the sole sources of the corresponding validator operations. The new request types are additive — blocks that contain no builder requests produce empty `request_data` for these types, which [EIP-7685](./eip-7685.md) excludes from the `requests_hash`. ## Test Cases -A Foundry test suite under `../assets/eip-draft_builder_deposit/test/` cross-verifies the contracts against `py_ecc` (the canonical Eth2 Python reference). Coverage includes the SSZ signing-root computation; an end-to-end `deposit(...)` that enqueues a record matching a `py_ecc.bls.G2ProofOfPossession.Sign`-produced signature; the `top_up(...)` happy path; the `SYSTEM_ADDRESS` system read returning the exact `request_data` records; the per-block cap and FIFO drain order; rejection of a non-`SYSTEM_ADDRESS` system read; and the input-shape and tampering rejection paths (each asserting nothing is enqueued). +A Foundry test suite under `../assets/eip-draft_builder_requests/test/` cross-verifies the contracts against `py_ecc` (the canonical Eth2 Python reference). Coverage includes the SSZ signing-root computation; an end-to-end `deposit(...)` that enqueues a record matching a `py_ecc.bls.G2ProofOfPossession.Sign`-produced signature; the `top_up(...)` happy path; the `withdraw(...)` happy path for both a partial withdrawal and an `amount == 0` exit — asserting the recorded `source_address` is the caller and that a withdrawal stakes no value; the `SYSTEM_ADDRESS` system read returning the exact `request_data` records; the per-block cap and FIFO drain order; rejection of a non-`SYSTEM_ADDRESS` system read; and the input-shape, insufficient-fee, and tampering rejection paths (each asserting nothing is enqueued). ## Reference Implementation -Solidity source for both predeploys is published at [`../assets/eip-draft_builder_deposit/builder_deposit_contract.sol`](../assets/eip-draft_builder_deposit/builder_deposit_contract.sol), with the test harness, fixture generator, and Foundry configuration alongside it. The file defines a shared `RequestQueue` base plus `BuilderDepositContract` and `BuilderTopUpContract`. The optimised runtime bytecode of the current draft is approximately 7.8 KiB for the deposit contract and 1.5 KiB for the top-up contract — both well within the [EIP-170](./eip-170.md) 24 KiB limit, with no on-chain field-arithmetic kernel or decompression path. The final `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE`, `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE`, the predeploy addresses, and the request-type bytes will be locked in once the contracts have been independently audited. +Solidity source for all three predeploys is published at [`../assets/eip-draft_builder_requests/builder_requests.sol`](../assets/eip-draft_builder_requests/builder_requests.sol), with the test harness, fixture generator, and Foundry configuration alongside it. The file defines a shared `RequestQueue` base plus `BuilderDepositContract`, `BuilderTopUpContract`, and `BuilderWithdrawalContract`. The optimised runtime bytecode of the current draft is approximately 7.5 KiB for the deposit contract, 1.5 KiB for the top-up contract, and 1.4 KiB for the withdrawal contract — all well within the [EIP-170](./eip-170.md) 24 KiB limit, with no on-chain field-arithmetic kernel or decompression path. The final runtime codes, the predeploy addresses, and the request-type bytes will be locked in once the contracts have been independently audited. ## Security Considerations @@ -198,11 +220,12 @@ Solidity source for both predeploys is published at [`../assets/eip-draft_builde - **Sign-bit binding.** The supplied affine `Y` MUST agree with the sign flag of the compressed `pubkey`/`signature`. Without this binding, a depositor controlling the key could pass the pairing check on a point `(X, +Y)` while the queued record's `pubkey` bytes decompress to `(X, −Y)`, so the consensus layer registers a key whose proof-of-possession the execution layer never verified (it verified the negation). The deposit record carries no signature, so the consensus layer cannot detect this by re-verification — it trusts the execution-layer check — which is exactly why the binding must be enforced on chain. - **System-read access control and per-block cap.** Only `SYSTEM_ADDRESS` may invoke the end-of-block dequeue; any other empty-calldata call reverts, so a non-system caller cannot drain or replay the queue. Each contract returns at most `MAX_REQUESTS_PER_BLOCK` records per block, bounding the size each predeploy contributes to the block requests; excess records remain queued for later blocks. - **Top-up validity at CL.** A top-up appends a request without checking that the target `pubkey` exists. The consensus layer MUST reject top-ups against unregistered builders so that all-zero or junk top-ups cannot register new builders without a verified deposit. `top_up(...)` carries no `withdrawal_credentials`, so an unauthenticated caller cannot rewrite an existing builder's withdrawal target. +- **Withdrawal/exit authorization.** The withdrawal contract records `msg.sender` as the `source_address` and performs no further check, exactly like [EIP-7002](./eip-7002.md). Because the request carries no signature, this is the sole authorization: the consensus layer MUST honour a `0x05` record only when `source_address` equals the target builder's `execution_address`, or an arbitrary caller could exit or drain a builder it does not control. The `execution_address` is the credential that owns the builder's stake, so letting it trigger withdrawals and exits is the intended ownership semantics — the same rationale EIP-7002 gives for `0x01` credentials. `withdraw(...)` stakes no value and enforces no minimum amount (`0` is the exit sentinel), so the request fee together with the per-block cap are what meter it. - **Replayable deposit records.** A deposit's `(pubkey, withdrawal_credentials, signature, …)` is public in calldata, and the signature commits only to `(pubkey, withdrawal_credentials)`, so a third party can submit a further `0x03` record for an already-registered builder at an arbitrary amount (funding it themselves). The consensus layer MUST treat a `0x03` record for an already-registered `pubkey` as a top-up — crediting stake but never changing the withdrawal credentials or re-registering — so the replay cannot redirect a builder's withdrawals or reset its state (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)). This is harmless beyond a funded stake addition, exactly like a `0x04` top-up. - **DoS surface.** Verification cost is gas-metered and paid by the depositor; an adversary cannot force consensus-layer pairing work without first paying the corresponding execution-layer gas. Per [EIP-2537](./eip-2537.md) §"Gas burning on error", a precompile that rejects a malformed (off-curve or out-of-subgroup) point burns all gas forwarded to it, so the contract MUST NOT forward `gas()` to the precompiles. Because EIP-2537 pricing is deterministic (a pure function of input length), the contract forwards a fixed gas ceiling to each precompile `staticcall` — set per call at roughly 2.5x the documented cost — which bounds the worst-case burn on a malformed input to that ceiling instead of the whole transaction, while leaving ample headroom for a future reprice. The ceilings MUST be revisited if [EIP-2537](./eip-2537.md) pricing changes. - **Subgroup membership.** The [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile performs G1 and G2 subgroup checks; the contract does not need to re-implement them. - **Compressed-point flags.** The contract must reject infinity-flagged inputs to prevent acceptance of the identity element as a `pubkey` or `signature`. -- **Validator-contract co-existence.** The validator deposit contract is unmodified; nothing in this EIP changes the existing 32-ETH validator deposit semantics. +- **Validator-contract co-existence.** The validator deposit and withdrawal ([EIP-7002](./eip-7002.md)) contracts are unmodified; nothing in this EIP changes existing validator deposit, withdrawal, or exit semantics. ## Copyright diff --git a/assets/eip-draft_builder_deposit/.gitignore b/assets/eip-draft_builder_requests/.gitignore similarity index 100% rename from assets/eip-draft_builder_deposit/.gitignore rename to assets/eip-draft_builder_requests/.gitignore diff --git a/assets/eip-draft_builder_deposit/README.md b/assets/eip-draft_builder_requests/README.md similarity index 72% rename from assets/eip-draft_builder_deposit/README.md rename to assets/eip-draft_builder_requests/README.md index e09ad6a61655af..796a4a748f0129 100644 --- a/assets/eip-draft_builder_deposit/README.md +++ b/assets/eip-draft_builder_requests/README.md @@ -1,4 +1,4 @@ -# EIP-XXXX: Builder Deposit Contract — Assets +# EIP-XXXX: Builder Execution Requests — Assets Reference Solidity for the proposal, plus cross-verification tests. @@ -6,11 +6,11 @@ Reference Solidity for the proposal, plus cross-verification tests. | File | Purpose | | --- | --- | -| `builder_deposit_contract.sol` | The two proposed predeploys plus a shared base: `RequestQueue` (EIP-7002-style queue + EIP-1559 fee + `SYSTEM_ADDRESS` end-of-block read), `BuilderDepositContract` (`deposit(...)`, BLS-verified, request type `0x03`), and `BuilderTopUpContract` (`top_up(...)`, unverified, request type `0x04`). | +| `builder_requests.sol` | The three proposed predeploys plus a shared base: `RequestQueue` (EIP-7002-style queue + EIP-1559 fee + `SYSTEM_ADDRESS` end-of-block read), `BuilderDepositContract` (`deposit(...)`, BLS-verified, request type `0x03`), `BuilderTopUpContract` (`top_up(...)`, unverified, request type `0x04`), and `BuilderWithdrawalContract` (`withdraw(...)`, EIP-7002-style withdrawal/exit, request type `0x05`). | | `gen_vectors.py` | Python script that uses `py_ecc` (the canonical Eth2 reference) to produce cross-verification test vectors. | | `test/Vectors.sol` | Auto-generated Solidity library of test vectors. Regenerate by running `gen_vectors.py`. | -| `test/TestHarness.sol` | `BuilderDepositHarness` / `BuilderTopUpHarness` — inherit the predeploys and expose the pending-queue depth (and the SSZ signing-root helper) for the tests. | -| `test/BuilderDeposit.t.sol` | Foundry tests. | +| `test/TestHarness.sol` | `BuilderDepositHarness` / `BuilderTopUpHarness` / `BuilderWithdrawalHarness` — inherit the predeploys and expose the pending-queue depth (and the SSZ signing-root helper) for the tests. | +| `test/BuilderRequests.t.sol` | Foundry tests. | | `foundry.toml` | Foundry configuration (solc `0.6.11`, EVM `prague` by default). | ## Running the tests @@ -68,3 +68,9 @@ The script is deterministic: the secret key is hard-coded so the output is byte- | `testDepositRejectsWrongPubkeyLength` | `pubkey.length != 48` is rejected; nothing enqueued | no | | `testTopUpRejectsTooSmallStake` | `top_up` stake `< 1 ether` is rejected; nothing enqueued | no | | `testTopUpRejectsWrongPubkeyLength` | `top_up` with `pubkey.length != 48` is rejected; nothing enqueued | no | +| `testWithdrawalEnqueuesAndReads` | A partial withdrawal (`amount > 0`) enqueues `source ++ pubkey ++ amount`; the system read returns the exact 76-byte record | no | +| `testExitEnqueuesWithZeroAmount` | `withdraw(pubkey, 0)` (full-exit sentinel) is accepted and recorded with a zero amount | no | +| `testWithdrawalRecordsCaller` | The recorded `source_address` is the caller (`msg.sender`), the field the CL checks against the builder's `execution_address` | no | +| `testWithdrawalRequiresNoStake` | A withdrawal sends only the fee — no staked value — even for a large `amount_gwei` | no | +| `testWithdrawalRejectsInsufficientFee` | `msg.value` below the fee reverts; nothing enqueued | no | +| `testWithdrawalRejectsWrongPubkeyLength` | `withdraw` with `pubkey.length != 48` is rejected; nothing enqueued | no | diff --git a/assets/eip-draft_builder_deposit/builder_deposit_contract.sol b/assets/eip-draft_builder_requests/builder_requests.sol similarity index 87% rename from assets/eip-draft_builder_deposit/builder_deposit_contract.sol rename to assets/eip-draft_builder_requests/builder_requests.sol index 79e46c72e34b57..1d904abc5f1c6a 100644 --- a/assets/eip-draft_builder_deposit/builder_deposit_contract.sol +++ b/assets/eip-draft_builder_requests/builder_requests.sol @@ -4,32 +4,45 @@ pragma solidity 0.6.11; pragma experimental ABIEncoderV2; // for `Fp` / `Fp2` struct calldata in `deposit` // ─────────────────────────────────────────────────────────────────────────────── -// EIP-XXXX: Builder Deposit Contract +// EIP-XXXX: Builder Execution Requests // -// Two EIP-7685 request predeploys for the EIP-7732 builder population, modelled -// on the EIP-7002 (withdrawals) / EIP-7251 (consolidations) "request bus": +// Three EIP-7685 request predeploys for the EIP-7732 builder population, +// modelled on the EIP-7002 (withdrawals) / EIP-7251 (consolidations) "request +// bus": // -// * BuilderDepositContract @ BUILDER_DEPOSIT_CONTRACT_ADDRESS (request type 0x03) +// * BuilderDepositContract @ BUILDER_DEPOSIT_CONTRACT_ADDRESS (request type 0x03) // deposit(pubkey, wc, amount_gwei, signature, pubkey_y, signature_y) — // verifies the BLS proof-of-possession on chain via the EIP-2537 // precompiles, then appends a deposit record to the in-state request queue. // -// * BuilderTopUpContract @ BUILDER_TOPUP_CONTRACT_ADDRESS (request type 0x04) +// * BuilderTopUpContract @ BUILDER_TOPUP_CONTRACT_ADDRESS (request type 0x04) // top_up(pubkey, amount_gwei) — unverified additional stake for an // already-registered builder; appends a top-up record to its queue. The // consensus layer rejects top-ups whose `pubkey` is not in the builder set. // -// Neither contract emits logs. Both share the `RequestQueue` base: a user call -// appends a record; at the end of the block a `SYSTEM_ADDRESS` call with empty -// calldata pops up to MAX_REQUESTS_PER_BLOCK records and returns them as the -// flat `request_data` for that predeploy's request type. The execution layer -// prepends the type byte and commits the result in the block `requests_hash` -// (EIP-7685). Each contract is a standard single-type request predeploy, so the -// EL needs no new read semantics — exactly the withdrawals/consolidations model. +// * BuilderWithdrawalContract @ BUILDER_WITHDRAWAL_CONTRACT_ADDRESS (request type 0x05) +// withdraw(pubkey, amount_gwei) — a semantic clone of the EIP-7002 +// withdrawal predeploy, retargeted at the builder set. The builder's +// execution_address authorizes the request simply by being `msg.sender`, +// so there is no BLS check and no staked value — only the fee. An +// amount_gwei of 0 is a full exit, any amount_gwei > 0 a partial +// withdrawal. The consensus layer ignores records whose recorded +// source_address is not the target builder's execution_address. // -// Anti-spam is the staked value itself: every deposit/top-up locks >= 1 ETH and -// (for deposits) pays for gas-metered BLS verification, so there is no EIP-1559 -// request fee (unlike EIP-7002/7251, whose requests would otherwise be free). +// None of the contracts emit logs. All three share the `RequestQueue` base: a +// user call appends a record; at the end of the block a `SYSTEM_ADDRESS` call +// with empty calldata pops up to MAX_REQUESTS_PER_BLOCK records and returns them +// as the flat `request_data` for that predeploy's request type. The execution +// layer prepends the type byte and commits the result in the block +// `requests_hash` (EIP-7685). Each contract is a standard single-type request +// predeploy, so the EL needs no new read semantics — exactly the +// withdrawals/consolidations model. +// +// Anti-spam has two layers: every request carries the same EIP-1559-style +// request fee as EIP-7002/7251 (see RequestQueue), and deposits/top-ups +// additionally lock their staked value (>= 1 ETH), with a deposit also paying +// for gas-metered BLS verification. Withdrawals/exits move no ETH on this layer, +// so the fee alone meters them, exactly as in EIP-7002. // // Algorithms used (BuilderDepositContract): // * Signing root — SSZ `hash_tree_root` of `DepositMessage` mixed with @@ -713,3 +726,49 @@ contract BuilderTopUpContract is RequestQueue { _recordRequest(abi.encodePacked(pubkey, _le64(amount_gwei))); } } + +// ─────────────────────────────────────────────────────────────────────────────── +// Builder withdrawal / exit predeploy — EIP-7685 request type 0x05, installed at +// BUILDER_WITHDRAWAL_CONTRACT_ADDRESS. +// +// A semantic clone of the EIP-7002 withdrawal-request predeploy, retargeted at +// the EIP-7732 builder set. A builder's `execution_address` (the 0x03 builder +// withdrawal credential) authorizes a request simply by being `msg.sender` — +// exactly as EIP-7002's 0x01 credential does — so this contract needs NO BLS +// verification and locks NO stake: unlike a deposit or top-up, a withdrawal +// moves no ETH on the execution layer, and the caller sends only the request +// fee. `amount_gwei == 0` requests a full exit (the "voluntary exit"); any +// `amount_gwei > 0` requests a partial withdrawal of that many gwei. The +// consensus layer interprets the amount-zero sentinel exactly as it does for +// validators under EIP-7002. +// +// EIP-7685 `request_data` is the concatenation of one record per dequeued +// request: +// source_address (20) ++ pubkey (48) ++ amount_gwei (8, LE) = 76 bytes, +// identical in shape to EIP-7002's `ValidatorWithdrawalRequest`. As with the +// sibling builder predeploys this contract emits no logs (EIP-7002 emits a +// log0; the request bus does not need it). The consensus layer MUST ignore a +// record whose `source_address` does not match the target builder's +// `execution_address`, so a third party cannot withdraw or exit a builder it +// does not control. +// ─────────────────────────────────────────────────────────────────────────────── +contract BuilderWithdrawalContract is RequestQueue { + uint constant PUBLIC_KEY_LENGTH = 48; + + /// @notice Builder withdrawal / exit request. On success, appends a record + /// to the request queue (no log). `amount_gwei == 0` requests a full exit; + /// any `amount_gwei > 0` requests a partial withdrawal of that many gwei + /// from the builder's beacon-chain balance. Unlike `deposit`/`top_up` this + /// moves no ETH on the execution layer: the caller sends only + /// `msg.value >= fee`, where `fee` is the current request fee (read it by + /// calling this contract with empty calldata). The record's `source_address` + /// is `msg.sender`; the consensus layer honours the request only if it + /// equals the target builder's `execution_address`. There is intentionally + /// no minimum-amount check — `0` is the exit sentinel, mirroring EIP-7002. + function withdraw(bytes calldata pubkey, uint64 amount_gwei) external payable { + require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderWithdrawal: invalid pubkey length"); + require(msg.value >= _getFee(), "BuilderWithdrawal: insufficient value for fee"); + + _recordRequest(abi.encodePacked(msg.sender, pubkey, _le64(amount_gwei))); + } +} diff --git a/assets/eip-draft_builder_deposit/foundry.toml b/assets/eip-draft_builder_requests/foundry.toml similarity index 100% rename from assets/eip-draft_builder_deposit/foundry.toml rename to assets/eip-draft_builder_requests/foundry.toml diff --git a/assets/eip-draft_builder_deposit/gen_vectors.py b/assets/eip-draft_builder_requests/gen_vectors.py similarity index 97% rename from assets/eip-draft_builder_deposit/gen_vectors.py rename to assets/eip-draft_builder_requests/gen_vectors.py index 6116b6f2c04cc1..f66924e104952d 100644 --- a/assets/eip-draft_builder_deposit/gen_vectors.py +++ b/assets/eip-draft_builder_requests/gen_vectors.py @@ -34,7 +34,7 @@ def sha256(b: bytes) -> bytes: # Builder-deposit signing domain — distinct 4-byte domain type (0x0b000000, # a placeholder pending consensus-specs allocation) with the same GENESIS # fork-data suffix as the validator deposit domain. Must match -# DOMAIN_BUILDER_DEPOSIT in builder_deposit_contract.sol. Domain separation +# DOMAIN_BUILDER_DEPOSIT in builder_requests.sol. Domain separation # from the validator deposit domain (0x03000000…) prevents cross-context # signature replay. DOMAIN_BUILDER_DEPOSIT = bytes.fromhex( @@ -44,7 +44,7 @@ def sha256(b: bytes) -> bytes: def deposit_signing_root(pubkey: bytes, wc: bytes) -> bytes: """compute_signing_root for the 2-field builder message (pubkey, wc). - The amount is intentionally NOT signed (see builder_deposit_contract.sol): + The amount is intentionally NOT signed (see builder_requests.sol): htr = sha256(pubkey_root || wc); signing_root = sha256(htr || DOMAIN).""" assert len(pubkey) == 48 and len(wc) == 32 pubkey_root = sha256(pubkey + b"\x00" * 16) @@ -123,7 +123,7 @@ def emit(): p("pragma solidity 0.6.11;") p("pragma experimental ABIEncoderV2;") p("") - p('import "../builder_deposit_contract.sol";') + p('import "../builder_requests.sol";') p("") p("library Vectors {") p("") diff --git a/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol b/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol similarity index 76% rename from assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol rename to assets/eip-draft_builder_requests/test/BuilderRequests.t.sol index c66c4331bb2c9f..55286e9b1e581a 100644 --- a/assets/eip-draft_builder_deposit/test/BuilderDeposit.t.sol +++ b/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol @@ -2,7 +2,7 @@ pragma solidity 0.6.11; pragma experimental ABIEncoderV2; -import "../builder_deposit_contract.sol"; +import "../builder_requests.sol"; import "./TestHarness.sol"; import "./Vectors.sol"; @@ -10,6 +10,7 @@ import "./Vectors.sol"; /// dependency on this 0.6.11 project). interface Vm { function prank(address) external; + function deal(address, uint256) external; } /// @notice Tests for the EIP-7685 request-bus builder predeploys, including the @@ -19,20 +20,23 @@ interface Vm { /// ./Vectors.sol. The deposit-verification tests require the EIP-2537 BLS /// precompiles (foundry's default Prague EVM); the queue / fee / system-read / /// input tests do not. -contract BuilderDepositTest { +contract BuilderRequestsTest { Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); address constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; - uint constant DEPOSIT_RECORD_LEN = 88; // pubkey 48 + wc 32 + amount 8 - uint constant TOPUP_RECORD_LEN = 56; // pubkey 48 + amount 8 + uint constant DEPOSIT_RECORD_LEN = 88; // pubkey 48 + wc 32 + amount 8 + uint constant TOPUP_RECORD_LEN = 56; // pubkey 48 + amount 8 + uint constant WITHDRAWAL_RECORD_LEN = 76; // source 20 + pubkey 48 + amount 8 - BuilderDepositHarness internal dep; - BuilderTopUpHarness internal top; + BuilderDepositHarness internal dep; + BuilderTopUpHarness internal top; + BuilderWithdrawalHarness internal wd; function setUp() public { dep = new BuilderDepositHarness(); top = new BuilderTopUpHarness(); + wd = new BuilderWithdrawalHarness(); } function _systemRead(address target) internal returns (bytes memory) { @@ -348,4 +352,85 @@ contract BuilderDepositTest { } catch {} require(top.pendingCount() == 0, "nothing enqueued on reject"); } + + // ── Withdrawal / exit predeploy (EIP-7002-shaped, request type 0x05) ──── + + // A partial withdrawal (amount > 0) records source_address(msg.sender) ++ + // pubkey ++ amount; the system read returns the exact 76-byte record. + function testWithdrawalEnqueuesAndReads() public { + bytes memory pubkey = new bytes(48); + for (uint i = 0; i < 48; i++) pubkey[i] = bytes1(uint8(i + 1)); + + uint64 amount_gwei = 4_000_000_000; // 4 ETH partial withdrawal + wd.withdraw{value: wd.feeWei()}(pubkey, amount_gwei); + require(wd.pendingCount() == 1, "one withdrawal queued"); + + bytes memory data = _systemRead(address(wd)); + bytes memory expected = abi.encodePacked(address(this), pubkey, _le64(amount_gwei)); + require(data.length == WITHDRAWAL_RECORD_LEN, "withdrawal record length"); + require(keccak256(data) == keccak256(expected), "withdrawal record bytes mismatch"); + require(wd.pendingCount() == 0, "queue drained"); + } + + // amount_gwei == 0 is the full-exit sentinel: it MUST be accepted (there is + // no minimum-amount check) and recorded with a zero amount, like EIP-7002. + function testExitEnqueuesWithZeroAmount() public { + bytes memory pubkey = new bytes(48); + for (uint i = 0; i < 48; i++) pubkey[i] = bytes1(uint8(0xa0 + i)); + + wd.withdraw{value: wd.feeWei()}(pubkey, 0); + require(wd.pendingCount() == 1, "exit (amount 0) is accepted and queued"); + + bytes memory data = _systemRead(address(wd)); + bytes memory expected = abi.encodePacked(address(this), pubkey, _le64(0)); + require(data.length == WITHDRAWAL_RECORD_LEN, "exit record length"); + require(keccak256(data) == keccak256(expected), "exit record bytes mismatch"); + } + + // The recorded source_address is the caller, not a parameter: a withdrawal + // from a different address records that address (the builder's + // execution_address), which is what the CL checks for authorization. The + // fee is read before `vm.prank` so the prank applies to `withdraw`, not to + // the `feeWei()` argument call. + function testWithdrawalRecordsCaller() public { + address builderExecAddr = 0xb0b1DE7c0fFeE0000000000000000000000B5511; + bytes memory pubkey = new bytes(48); + for (uint i = 0; i < 48; i++) pubkey[i] = bytes1(uint8(i + 7)); + + uint64 amount_gwei = 1_500_000_000; + uint fee = wd.feeWei(); + vm.deal(builderExecAddr, 1 ether); // fund the caller so it can pay the fee + vm.prank(builderExecAddr); + wd.withdraw{value: fee}(pubkey, amount_gwei); + + bytes memory data = _systemRead(address(wd)); + bytes memory expected = abi.encodePacked(builderExecAddr, pubkey, _le64(amount_gwei)); + require(keccak256(data) == keccak256(expected), "source_address must be the caller"); + } + + // Unlike deposit/top-up, a withdrawal sends no stake — only the fee. A large + // amount_gwei with msg.value equal to just the (1 wei) fee must succeed. + function testWithdrawalRequiresNoStake() public { + bytes memory pubkey = new bytes(48); + uint64 amount_gwei = 1_000_000_000_000; // 1000 ETH, but no value is sent for it + wd.withdraw{value: wd.feeWei()}(pubkey, amount_gwei); + require(wd.pendingCount() == 1, "withdrawal needs only the fee, no staked value"); + } + + function testWithdrawalRejectsInsufficientFee() public { + bytes memory pubkey = new bytes(48); + // excess == 0 → fee is 1 wei; sending 0 cannot cover it. + try wd.withdraw{value: 0}(pubkey, 1_000_000_000) { + require(false, "withdrawal below the fee should revert"); + } catch {} + require(wd.pendingCount() == 0, "nothing enqueued on reject"); + } + + function testWithdrawalRejectsWrongPubkeyLength() public { + bytes memory pubkey = new bytes(47); + try wd.withdraw{value: wd.feeWei()}(pubkey, 1_000_000_000) { + require(false, "47-byte pubkey should revert"); + } catch {} + require(wd.pendingCount() == 0, "nothing enqueued on reject"); + } } diff --git a/assets/eip-draft_builder_deposit/test/TestHarness.sol b/assets/eip-draft_builder_requests/test/TestHarness.sol similarity index 81% rename from assets/eip-draft_builder_deposit/test/TestHarness.sol rename to assets/eip-draft_builder_requests/test/TestHarness.sol index ffb2b33e45b1e7..2882bba4e60e66 100644 --- a/assets/eip-draft_builder_deposit/test/TestHarness.sol +++ b/assets/eip-draft_builder_requests/test/TestHarness.sol @@ -2,7 +2,7 @@ pragma solidity 0.6.11; pragma experimental ABIEncoderV2; -import "../builder_deposit_contract.sol"; +import "../builder_requests.sol"; /// @notice Test harness for the deposit predeploy. Inherits BuilderDepositContract /// (so `deposit(...)` and the inherited `SYSTEM_ADDRESS` system-read `fallback` @@ -41,3 +41,14 @@ contract BuilderTopUpHarness is BuilderTopUpContract { function headIdx() external view returns (uint) { return queueHead; } function tailIdx() external view returns (uint) { return queueTail; } } + +/// @notice Test harness for the withdrawal / exit predeploy. +contract BuilderWithdrawalHarness is BuilderWithdrawalContract { + function pendingCount() external view returns (uint) { + return queueTail - queueHead; + } + + function feeWei() external view returns (uint) { + return _getFee(); + } +} diff --git a/assets/eip-draft_builder_deposit/test/Vectors.sol b/assets/eip-draft_builder_requests/test/Vectors.sol similarity index 98% rename from assets/eip-draft_builder_deposit/test/Vectors.sol rename to assets/eip-draft_builder_requests/test/Vectors.sol index 781818a98133ae..8f75ec15533388 100644 --- a/assets/eip-draft_builder_deposit/test/Vectors.sol +++ b/assets/eip-draft_builder_requests/test/Vectors.sol @@ -7,7 +7,7 @@ pragma solidity 0.6.11; pragma experimental ABIEncoderV2; -import "../builder_deposit_contract.sol"; +import "../builder_requests.sol"; library Vectors { From d0e56b916dc832855cdaa27aafd213c914559ef2 Mon Sep 17 00:00:00 2001 From: Cayman Date: Tue, 2 Jun 2026 20:57:33 +0200 Subject: [PATCH 07/12] fix: deploy builder predeploys like EIP-7002/7251 with EXCESS_INHIBITOR Switch from fork-time installation to a presigned deployment transaction plus an EXCESS_INHIBITOR that blocks requests until the first end-of-block system call, matching EIP-7002/7251. Updates the spec, the RequestQueue reference contract, and the tests. --- EIPS/eip-draft_builder_requests.md | 13 ++++--- assets/eip-draft_builder_requests/README.md | 6 ++- .../builder_requests.sol | 39 +++++++++++++------ .../test/BuilderRequests.t.sol | 38 ++++++++++++++++++ 4 files changed, 78 insertions(+), 18 deletions(-) diff --git a/EIPS/eip-draft_builder_requests.md b/EIPS/eip-draft_builder_requests.md index eea87cac73ffc1..6f5c52d9a49de9 100644 --- a/EIPS/eip-draft_builder_requests.md +++ b/EIPS/eip-draft_builder_requests.md @@ -51,6 +51,7 @@ All address and request-type values below are placeholders pending allocation in | `TARGET_REQUESTS_PER_BLOCK` | `2` | Per-block request count above which the fee rises | | `MIN_REQUEST_FEE` | `1` | Minimum request fee, in wei | | `REQUEST_FEE_UPDATE_FRACTION` | `17` | Controls the fee's rate of change | +| `EXCESS_INHIBITOR` | `2**256-1` | Excess value that makes the fee getter revert before the first system call (as in [EIP-7002](./eip-7002.md)/[EIP-7251](./eip-7251.md)); set at deployment, cleared by the first system call | | `BUILDER_MIN_DEPOSIT` | `1000000000000000000` | Minimum credited stake for a deposit or top-up, in wei (1 ETH — the [EIP-7732](./eip-7732.md) builder minimum). Withdrawals enforce no minimum | | `DOMAIN_BUILDER_DEPOSIT` | `0x0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9` | Signing domain for builder deposit messages. The `0x0b000000` domain type is a placeholder pending consensus-specs allocation; it MUST differ from the validator `DOMAIN_DEPOSIT` (`0x03000000…`) so signatures are not interchangeable between the two contracts | | `BLS12_G2ADD` | `0x0d` | [EIP-2537](./eip-2537.md) precompile address | @@ -60,11 +61,11 @@ All address and request-type values below are placeholders pending allocation in | `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder top-up contract | | `BUILDER_WITHDRAWAL_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder withdrawal/exit contract | -### Fork transition +### Deployment -At the start of processing the first block where this EIP is active, before processing transactions, execution clients MUST install each predeploy — `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` at `BUILDER_DEPOSIT_CONTRACT_ADDRESS`, `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE` at `BUILDER_TOPUP_CONTRACT_ADDRESS`, and `BUILDER_WITHDRAWAL_CONTRACT_RUNTIME_CODE` at `BUILDER_WITHDRAWAL_CONTRACT_ADDRESS` — if the account at the respective address is empty (zero `nonce`, empty `code`, empty `storage`, zero `balance`). Each installation MUST set `code` to the runtime code, `nonce = 1`, `balance = 0`, and leave `storage` empty. +Each predeploy is deployed exactly as the [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) request contracts are: by a one-time presigned transaction from a single-use deployer account (the Nick's-method scheme), so that `BUILDER_DEPOSIT_CONTRACT_ADDRESS`, `BUILDER_TOPUP_CONTRACT_ADDRESS`, and `BUILDER_WITHDRAWAL_CONTRACT_ADDRESS` are the addresses cryptographically derived from those transactions. Each contract's init code sets its `excess` slot to `EXCESS_INHIBITOR`, so no request can be enqueued until the inhibitor is cleared (see [Request fee](#request-fee)). The concrete transactions — and therefore the final addresses — will be fixed once the runtime bytecode is audited and frozen. -If any of these accounts is not empty at fork time, clients MUST abort initialisation. This matches the predeploy pattern used by [EIP-2935](./eip-2935.md), [EIP-4788](./eip-4788.md), [EIP-7002](./eip-7002.md), and [EIP-7251](./eip-7251.md). +The deployment transactions MUST be included before the fork that activates this EIP. If there is no code at any of the three predeploy addresses once the EIP is active, every block from activation onward MUST be invalid — the same handling [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) specify for their predeploys. ### Request queue and system call @@ -72,7 +73,7 @@ All three predeploys follow the [EIP-7002](./eip-7002.md) / [EIP-7251](./eip-725 A call with empty calldata dispatches on the caller: -- From `SYSTEM_ADDRESS` (the end-of-block system call): the predeploy MUST dequeue up to `MAX_REQUESTS_PER_BLOCK` records (oldest first), return their concatenation as that contract's `request_data`, advance its queue head past the returned records, then update `excess` from the number of requests added in the block (`excess = max(0, excess + count - TARGET_REQUESTS_PER_BLOCK)`) and reset that count. Records beyond the per-block cap remain queued for subsequent blocks. +- From `SYSTEM_ADDRESS` (the end-of-block system call): the predeploy MUST dequeue up to `MAX_REQUESTS_PER_BLOCK` records (oldest first), return their concatenation as that contract's `request_data`, advance its queue head past the returned records, then update `excess` from the number of requests added in the block (`excess = max(0, excess + count - TARGET_REQUESTS_PER_BLOCK)`, treating a current value of `EXCESS_INHIBITOR` as `0` so the first system call clears the inhibitor) and reset that count. Records beyond the per-block cap remain queued for subsequent blocks. - From any other caller: the predeploy MUST return the current fee (the fee getter), without modifying state. The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). None of the contracts emit logs. @@ -87,7 +88,7 @@ fee = fake_exponential(MIN_REQUEST_FEE, excess, REQUEST_FEE_UPDATE_FRACTION) where `fake_exponential` is the integer approximation of `MIN_REQUEST_FEE · e^(excess / REQUEST_FEE_UPDATE_FRACTION)` used by [EIP-1559](./eip-1559.md). Because `excess` grows whenever a block contains more than `TARGET_REQUESTS_PER_BLOCK` requests and decays otherwise, the fee rises super-linearly under sustained demand and returns to `MIN_REQUEST_FEE` when demand subsides. The fee is charged on top of any staked value (see the entrypoints below) and is left locked in the contract. -Unlike EIP-7002/7251, these predeploys carry no `EXCESS_INHIBITOR`: those contracts are deployed before their activating fork and use the inhibitor to reject requests until the first system call, whereas these are installed at the fork with empty storage (`excess = 0`, the minimum fee), so there are no pre-activation requests to inhibit. +As in EIP-7002/7251, each contract's `excess` is initialized to `EXCESS_INHIBITOR` at deployment, and the fee getter reverts while `excess == EXCESS_INHIBITOR`. Since a request is only appended after its fee is paid, this blocks every request between deployment and the first end-of-block system call; that call clears the inhibitor (treating the prior `excess` as `0`), and normal fee operation runs from the activation block onward. ### Verified deposit entrypoint @@ -212,7 +213,7 @@ A Foundry test suite under `../assets/eip-draft_builder_requests/test/` cross-ve ## Reference Implementation -Solidity source for all three predeploys is published at [`../assets/eip-draft_builder_requests/builder_requests.sol`](../assets/eip-draft_builder_requests/builder_requests.sol), with the test harness, fixture generator, and Foundry configuration alongside it. The file defines a shared `RequestQueue` base plus `BuilderDepositContract`, `BuilderTopUpContract`, and `BuilderWithdrawalContract`. The optimised runtime bytecode of the current draft is approximately 7.5 KiB for the deposit contract, 1.5 KiB for the top-up contract, and 1.4 KiB for the withdrawal contract — all well within the [EIP-170](./eip-170.md) 24 KiB limit, with no on-chain field-arithmetic kernel or decompression path. The final runtime codes, the predeploy addresses, and the request-type bytes will be locked in once the contracts have been independently audited. +Solidity source for all three predeploys is published at [`../assets/eip-draft_builder_requests/builder_requests.sol`](../assets/eip-draft_builder_requests/builder_requests.sol), with the test harness, fixture generator, and Foundry configuration alongside it. The file defines a shared `RequestQueue` base plus `BuilderDepositContract`, `BuilderTopUpContract`, and `BuilderWithdrawalContract`. The optimised runtime bytecode of the current draft is approximately 7.6 KiB for the deposit contract and about 1.6 KiB each for the top-up and withdrawal contracts — all well within the [EIP-170](./eip-170.md) 24 KiB limit, with no on-chain field-arithmetic kernel or decompression path. The final runtime codes, the predeploy addresses, and the request-type bytes will be locked in once the contracts have been independently audited. ## Security Considerations diff --git a/assets/eip-draft_builder_requests/README.md b/assets/eip-draft_builder_requests/README.md index 796a4a748f0129..a12ca6c9b974d4 100644 --- a/assets/eip-draft_builder_requests/README.md +++ b/assets/eip-draft_builder_requests/README.md @@ -59,7 +59,8 @@ The script is deterministic: the secret key is hard-coded so the output is byte- | `testDepositRejectsInsufficientValue` | `msg.value == stake` (no room for the fee) reverts; nothing enqueued | no | | `testSystemReadRequiresSystemAddress` | A non-`SYSTEM_ADDRESS` empty-calldata call is the fee getter and does NOT drain the queue | no | | `testPerBlockCapAndFifo` | 17 queued → first read drains the 16-record cap, second drains the remainder (FIFO) | no | -| `testDepositRejectsTamperedAmount` | An `amount_gwei` different from the signed one fails the pairing check; nothing enqueued | **yes** | +| `testQueueResetsWhenDrained` | When the queue fully drains, head and tail reset to 0 so slots are reused; the next request restarts at index 0 | no | +| `testFallbackRejectsNonEmptyCalldata` | The empty-calldata fallback rejects non-empty junk calldata; the empty-calldata fee getter still works | no | | `testDepositRejectsTamperedSignature` | Flipping a signature bit is rejected (subgroup/pairing failure); nothing enqueued | **yes** | | `testDepositRejectsPubkeySignBitFlip` | Flipping only the pubkey sign flag is rejected by the sign-bit binding (audit Finding 2 regression); nothing enqueued | no | | `testDepositRejectsSignatureSignBitFlip` | Flipping only the signature sign flag is rejected by the sign-bit binding; nothing enqueued | no | @@ -74,3 +75,6 @@ The script is deterministic: the secret key is hard-coded so the output is byte- | `testWithdrawalRequiresNoStake` | A withdrawal sends only the fee — no staked value — even for a large `amount_gwei` | no | | `testWithdrawalRejectsInsufficientFee` | `msg.value` below the fee reverts; nothing enqueued | no | | `testWithdrawalRejectsWrongPubkeyLength` | `withdraw` with `pubkey.length != 48` is rejected; nothing enqueued | no | +| `testFeeGetterRevertsWhileInhibited` | A freshly deployed contract is inhibited (`excess == EXCESS_INHIBITOR`); the fee getter reverts | no | +| `testRequestRevertsWhileInhibited` | A request before the first system call reverts on the inhibited fee; nothing enqueued | no | +| `testFirstSystemCallClearsInhibitor` | The first `SYSTEM_ADDRESS` call clears the inhibitor; the fee is then `MIN_REQUEST_FEE` | no | diff --git a/assets/eip-draft_builder_requests/builder_requests.sol b/assets/eip-draft_builder_requests/builder_requests.sol index 1d904abc5f1c6a..44869982147c24 100644 --- a/assets/eip-draft_builder_requests/builder_requests.sol +++ b/assets/eip-draft_builder_requests/builder_requests.sol @@ -62,7 +62,7 @@ pragma experimental ABIEncoderV2; // for `Fp` / `Fp2` struct calldata in `depos // are pre-verified and carry no signature (the CL trusts the EL check). // ─────────────────────────────────────────────────────────────────────────────── -// EIP-7002 / EIP-7251 style request bus shared by both builder predeploys. +// EIP-7002 / EIP-7251 style request bus shared by all three builder predeploys. // // A user call appends an opaque record (and increments the per-block request // count). The end-of-block `SYSTEM_ADDRESS` system call drains up to @@ -86,11 +86,12 @@ pragma experimental ABIEncoderV2; // for `Fp` / `Fp2` struct calldata in `depos // charged on top of any staked value by the derived contract and is left locked // in the contract (effectively burned). // -// Unlike EIP-7002/7251 there is no EXCESS_INHIBITOR: those contracts are -// deployed before their activating fork and use the inhibitor to reject -// requests until the first system call. These predeploys are installed at the -// fork with empty storage (`excess == 0`, i.e. the minimum fee), so there are -// no pre-activation requests to inhibit. +// Like EIP-7002/7251, each contract is deployed by a transaction that can land +// before the activating fork, so the constructor initializes `excess` to +// EXCESS_INHIBITOR: `_getFee` reverts (and therefore no request can be enqueued) +// until the first end-of-block system call clears the inhibitor. That system +// call treats a current `excess` of EXCESS_INHIBITOR as 0, after which the fee +// mechanism operates normally. contract RequestQueue { // Address used to invoke the end-of-block system operation (EIP-7002/7251). address constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; @@ -103,6 +104,10 @@ contract RequestQueue { // Minimum request fee in wei, and the fee's update fraction (mirror EIP-7002). uint constant MIN_REQUEST_FEE = 1; uint constant REQUEST_FEE_UPDATE_FRACTION = 17; + // Excess value that inhibits the fee getter before the first system call + // (mirrors EIP-7002/7251). The constructor sets `excess` to this; the first + // end-of-block system call clears it. + uint constant EXCESS_INHIBITOR = type(uint256).max; // FIFO queue of opaque request records: a head/tail ring over a mapping // (see the note above). `queueTail` is the next write index, `queueHead` @@ -116,9 +121,18 @@ contract RequestQueue { uint internal excess; uint internal count; + // Deployed (like EIP-7002/7251) by a transaction that may precede the + // activating fork, so start inhibited: no request can be enqueued until the + // first end-of-block system call clears the inhibitor. + constructor() public { + excess = EXCESS_INHIBITOR; + } + // Current per-request fee (wei). Constant within a block: `excess` is only - // updated by the end-of-block system call. + // updated by the end-of-block system call. Reverts while the inhibitor is + // set (before the first system call), exactly as EIP-7002/7251's fee getter. function _getFee() internal view returns (uint) { + require(excess != EXCESS_INHIBITOR, "RequestQueue: fee inhibited"); return _fakeExponential(MIN_REQUEST_FEE, excess, REQUEST_FEE_UPDATE_FRACTION); } @@ -181,9 +195,12 @@ contract RequestQueue { } // Update the EIP-1559-style excess from this block's demand, then reset. + // A current value of EXCESS_INHIBITOR (set at deployment) counts as 0, so + // the first system call clears the inhibitor (mirrors EIP-7002/7251). uint c = count; - excess = (excess + c > TARGET_REQUESTS_PER_BLOCK) - ? excess + c - TARGET_REQUESTS_PER_BLOCK + uint prevExcess = excess == EXCESS_INHIBITOR ? 0 : excess; + excess = (prevExcess + c > TARGET_REQUESTS_PER_BLOCK) + ? prevExcess + c - TARGET_REQUESTS_PER_BLOCK : 0; count = 0; @@ -691,7 +708,7 @@ contract BuilderDepositContract is RequestQueue { } // ─────────────────────────────────────────────────────────────────────────────── -// Builder top-up predeploy — EIP-7685 request type 0x04, installed at +// Builder top-up predeploy — EIP-7685 request type 0x04, deployed at // BUILDER_TOPUP_CONTRACT_ADDRESS. // // Unverified: adds stake to an already-registered builder. There is no BLS @@ -728,7 +745,7 @@ contract BuilderTopUpContract is RequestQueue { } // ─────────────────────────────────────────────────────────────────────────────── -// Builder withdrawal / exit predeploy — EIP-7685 request type 0x05, installed at +// Builder withdrawal / exit predeploy — EIP-7685 request type 0x05, deployed at // BUILDER_WITHDRAWAL_CONTRACT_ADDRESS. // // A semantic clone of the EIP-7002 withdrawal-request predeploy, retargeted at diff --git a/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol b/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol index 55286e9b1e581a..ca263330c1ef22 100644 --- a/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol +++ b/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol @@ -37,6 +37,13 @@ contract BuilderRequestsTest { dep = new BuilderDepositHarness(); top = new BuilderTopUpHarness(); wd = new BuilderWithdrawalHarness(); + // Each predeploy starts with excess == EXCESS_INHIBITOR (set in the + // constructor, as EIP-7002/7251 do at deployment). The activation-block + // system call clears the inhibitor; run it here so the fee/queue tests + // below operate on an active contract. + _systemRead(address(dep)); + _systemRead(address(top)); + _systemRead(address(wd)); } function _systemRead(address target) internal returns (bytes memory) { @@ -433,4 +440,35 @@ contract BuilderRequestsTest { } catch {} require(wd.pendingCount() == 0, "nothing enqueued on reject"); } + + // ── EXCESS_INHIBITOR (pre-activation), as in EIP-7002/7251 ───────────── + + // A freshly deployed contract starts inhibited (excess == EXCESS_INHIBITOR), + // so the fee getter reverts until the first system call. setUp() already + // activated dep/top/wd, so these tests use a fresh instance. + function testFeeGetterRevertsWhileInhibited() public { + BuilderTopUpHarness fresh = new BuilderTopUpHarness(); + try fresh.feeWei() { + require(false, "fee getter must revert while inhibited"); + } catch {} + } + + // No request can be enqueued before activation: the entrypoint reverts when + // it reads the inhibited fee, even with ample value; nothing is queued. + function testRequestRevertsWhileInhibited() public { + BuilderTopUpHarness fresh = new BuilderTopUpHarness(); + bytes memory pubkey = new bytes(48); + try fresh.top_up{value: 2 ether}(pubkey, 1_000_000_000) { + require(false, "request must revert while inhibited"); + } catch {} + require(fresh.pendingCount() == 0, "nothing enqueued while inhibited"); + } + + // The first SYSTEM_ADDRESS call clears the inhibitor; the fee is then + // MIN_REQUEST_FEE (excess == 0). + function testFirstSystemCallClearsInhibitor() public { + BuilderTopUpHarness fresh = new BuilderTopUpHarness(); + _systemRead(address(fresh)); + require(fresh.feeWei() == 1, "fee is MIN_REQUEST_FEE once the inhibitor clears"); + } } From aa6b27951cc74e77310a62ef3c61d0d1ab9235dd Mon Sep 17 00:00:00 2001 From: Cayman Date: Wed, 3 Jun 2026 11:15:19 +0200 Subject: [PATCH 08/12] refactor: drop on-chain BLS, merge top-up into deposit, exit-only 0x04 The builder deposit contract no longer verifies BLS; it carries the signature for the consensus layer to verify, bounded by the per-block cap. Top-up folds into the deposit request (0x03, register-or-credit) and the withdrawal/exit contract becomes exit-only (0x04), authorized by execution_address. Adds a normative "Changes to EIP-7732" section (remove the process_deposit_request and process_voluntary_exit builder branches; keep onboard_builders_from_pending_deposits for genesis). Updates the spec, contracts, and tests; deletes the BLS machinery and py_ecc fixtures. --- EIPS/eip-draft_builder_requests.md | 179 ++--- assets/eip-draft_builder_requests/README.md | 82 +-- .../builder_requests.sol | 661 ++---------------- .../eip-draft_builder_requests/foundry.toml | 10 +- .../eip-draft_builder_requests/gen_vectors.py | 169 ----- .../test/BuilderRequests.t.sol | 492 ++++--------- .../test/TestHarness.sol | 26 +- .../test/Vectors.sol | 46 -- 8 files changed, 326 insertions(+), 1339 deletions(-) delete mode 100644 assets/eip-draft_builder_requests/gen_vectors.py delete mode 100644 assets/eip-draft_builder_requests/test/Vectors.sol diff --git a/EIPS/eip-draft_builder_requests.md b/EIPS/eip-draft_builder_requests.md index 6f5c52d9a49de9..cd2b04d31b8343 100644 --- a/EIPS/eip-draft_builder_requests.md +++ b/EIPS/eip-draft_builder_requests.md @@ -1,34 +1,35 @@ --- title: Builder Execution Requests -description: Predeploy builder deposit, top-up, and withdrawal/exit contracts as EIP-7685 requests for EIP-7732 builders +description: Predeploy builder deposit and exit request contracts for EIP-7732 builders on the EIP-7685 request bus author: Cayman (@wemeetagain), Nico Flaig , Matthew Keil discussions-to: status: Draft type: Standards Track category: Core created: 2026-05-22 -requires: 2537, 7002, 7685, 7732 +requires: 7685, 7732 --- ## Abstract -Predeploy three [EIP-7685](./eip-7685.md) request contracts for the [EIP-7732](./eip-7732.md) builder population, modelled on the request bus that [EIP-7002](./eip-7002.md) (withdrawals) and [EIP-7251](./eip-7251.md) (consolidations) use: +Predeploy two [EIP-7685](./eip-7685.md) request contracts for the [EIP-7732](./eip-7732.md) builder population, modelled on the request bus that [EIP-7002](./eip-7002.md) (withdrawals) and [EIP-7251](./eip-7251.md) (consolidations) use: -- a builder deposit contract whose `deposit(...)` verifies a BLS proof-of-possession over the `pubkey` and `withdrawal_credentials` using the [EIP-2537](./eip-2537.md) precompiles, then appends a deposit request to its queue; -- a builder top-up contract whose `top_up(...)` appends an additional-stake request for an existing builder without on-chain signature verification; and -- a builder withdrawal contract whose `withdraw(...)` appends a withdrawal request authorized by the caller's address — a partial withdrawal when its amount is non-zero, a full exit when the amount is zero — as a direct analogue of the [EIP-7002](./eip-7002.md) withdrawal predeploy. +- a builder **deposit** contract whose `deposit(...)` appends a record carrying the `pubkey`, `withdrawal_credentials`, `amount`, and the BLS `signature` to its queue. It serves both first deposits and top-ups: the consensus layer registers a builder on a `pubkey`'s first appearance and credits additional stake on later deposits. The signature is carried in the record and verified by the consensus layer on dequeue. +- a builder **exit** contract whose `exit(pubkey)` appends a full-exit record authorized by the caller's address (recorded as `source_address`). -Each contract maintains an in-state request queue drained by an end-of-block `SYSTEM_ADDRESS` system call; the dequeued records become the contract's [EIP-7685](./eip-7685.md) `request_data`, committed in the block `requests_hash`. None of the contracts emit logs. All three are independent of the existing validator deposit contract and of the validator request predeploys. +Each contract maintains an in-state request queue drained by an end-of-block `SYSTEM_ADDRESS` system call; the dequeued records become the contract's [EIP-7685](./eip-7685.md) `request_data`, committed in the block `requests_hash`. Neither contract emits logs. Both are independent of the existing validator deposit contract and the validator request predeploys, and they replace EIP-7732's builder onboarding through the validator deposit flow for builders created after the fork. ## Motivation -[EIP-7732](./eip-7732.md) introduces builders as a separate, staked consensus-layer class. Like a validator, a builder is created by a deposit, can have stake added, and must be able to withdraw stake or fully exit — the lifecycle validators drive from the execution layer: deposits and top-ups through the deposit contract, withdrawals and exits through [EIP-7002](./eip-7002.md). This EIP gives builders that same lifecycle as a set of dedicated [EIP-7685](./eip-7685.md) request contracts. +[EIP-7732](./eip-7732.md) introduces builders as a separate, staked consensus-layer class. A builder is created by a deposit, can have stake added, and must be able to exit. Today EIP-7732 sources this lifecycle from the *validator* flows: a builder is registered by an ordinary validator deposit request whose withdrawal credential carries the `0x03` `BUILDER_WITHDRAWAL_PREFIX`, and a builder exits through a builder branch of the consensus-layer voluntary-exit operation. This EIP instead gives builders their own dedicated [EIP-7685](./eip-7685.md) request contracts. -Without dedicated contracts, builders must ride the validator lifecycle contracts, forcing the consensus layer to decide on every request whether it acts on the validator set or the builder set. EIP-7732 already does this for deposits: a builder is registered by an ordinary validator deposit request whose withdrawal credential carries the `0x03` `BUILDER_WITHDRAWAL_PREFIX`, and the consensus layer routes on that prefix. Dedicated builder request types instead make the actor type explicit from the request type alone, so the consensus layer never disambiguates by inspecting credentials, and the validator and builder registries can be keyed independently. A single public key can then be registered as both a validator and a builder; the protocol currently disallows that overlap, and this EIP allows the rule to be removed. +**Dedicated request types remove cross-actor coupling.** Routing builders through the validator contracts forces the consensus layer to decide, on every request, whether it acts on the validator set or the builder set (today by inspecting the credential prefix). Dedicated builder request types make the actor explicit from the request type alone, so the validator and builder registries are keyed independently. A single public key can then be registered as both a validator and a builder; the protocol currently disallows that overlap, and this EIP allows the rule to be removed. -The builder deposit is the one operation that cannot be a plain clone of its validator counterpart. The deployed validator deposit contract does not verify BLS signatures on chain, so the consensus layer pays the proof-of-possession check for every deposit it processes — valid or not. Mainnet tolerates this only because the 32-ETH minimum makes spamming invalid deposits expensive; EIP-7732 sets the builder threshold as low as 1 ETH, which would amplify that consensus-side cost. The builder deposit contract therefore verifies the proof-of-possession on chain with the [EIP-2537](./eip-2537.md) precompiles and gas-meters it, so presenting any candidate costs the depositor's own gas and DoS resistance follows from ordinary gas pricing rather than consensus-side throttling. +**The deposit bounds a consensus-side denial-of-service surface.** A builder deposit's proof-of-possession is verified by the consensus layer (as it already is in EIP-7732). Routed through the validator deposit request — which admits thousands of deposits per block — an attacker submitting invalid-signature builder deposits at the 1-ETH builder minimum could force that many proof-of-possession checks per block. Delivering builder deposits through a *dedicated* request bus caps them at `MAX_REQUESTS_PER_BLOCK` per block and charges an [EIP-1559](./eip-1559.md)-style fee on top of the staked value, bounding both the consensus-layer verification work and the spam economics. -The remaining operations need no such machinery. A top-up only adds stake to an already-registered builder, so — like a repeat deposit to the validator contract — it carries no signature. A withdrawal or exit is authorized by the address that controls the builder's stake, exactly as [EIP-7002](./eip-7002.md) lets a validator's withdrawal credential trigger its own, so the builder withdrawal contract reuses the EIP-7002 design directly. The deployed validator deposit contract is left untouched, and existing validator deposits, withdrawals, and exits are unaffected. +**Exit gains a cold-key path builders lack today.** EIP-7732 lets a builder exit only via a voluntary exit signed by its BLS key — the same hot key it uses to sign bids. The exit contract instead authorizes a full exit by the builder's `execution_address` (the address that owns its stake), exactly as [EIP-7002](./eip-7002.md) lets a validator's withdrawal credential trigger an exit. Routing builder exits through this request makes the consensus-layer voluntary-exit operation validator-only again. + +Builders that must exist at the fork are unaffected: EIP-7732's fork-transition onboarding of builder-credentialed pending deposits is retained (see [Changes to EIP-7732](#changes-to-eip-7732)); only post-fork onboarding moves to the deposit contract. The deployed validator deposit contract is left untouched, and builder stake withdrawals continue to flow through EIP-7732's existing full-balance sweep. ## Specification @@ -36,47 +37,42 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ### Constants -All address and request-type values below are placeholders pending allocation in consensus-specs and the execution-layer client configuration; the `0x03`/`0x04`/`0x05` request types in particular MUST be distinct from the existing deposit (`0x00`), withdrawal (`0x01`), and consolidation (`0x02`) types. +All address and request-type values below are placeholders. The `0x03`/`0x04` request types MUST be unallocated and unique across **all** active [EIP-7685](./eip-7685.md) request types — not only the finalized deposit (`0x00`), withdrawal (`0x01`), and consolidation (`0x02`) types, but also any other in-flight request-type proposals — with final allocation coordinated in consensus-specs. | Name | Value | Comment | | --- | --- | --- | | `BUILDER_DEPOSIT_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007732` | Predeploy address of the builder deposit contract (placeholder) | -| `BUILDER_TOPUP_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007733` | Predeploy address of the builder top-up contract (placeholder) | -| `BUILDER_WITHDRAWAL_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007734` | Predeploy address of the builder withdrawal/exit contract (placeholder) | +| `BUILDER_EXIT_CONTRACT_ADDRESS` | `0x0000000000000000000000000000000000007733` | Predeploy address of the builder exit contract (placeholder) | | `BUILDER_DEPOSIT_REQUEST_TYPE` | `0x03` | [EIP-7685](./eip-7685.md) request-type byte for builder deposits (placeholder) | -| `BUILDER_TOPUP_REQUEST_TYPE` | `0x04` | [EIP-7685](./eip-7685.md) request-type byte for builder top-ups (placeholder) | -| `BUILDER_WITHDRAWAL_REQUEST_TYPE` | `0x05` | [EIP-7685](./eip-7685.md) request-type byte for builder withdrawals/exits (placeholder) | +| `BUILDER_EXIT_REQUEST_TYPE` | `0x04` | [EIP-7685](./eip-7685.md) request-type byte for builder exits (placeholder) | | `SYSTEM_ADDRESS` | `0xfffffffffffffffffffffffffffffffffffffffe` | Address that invokes the end-of-block system call (as in [EIP-7002](./eip-7002.md)) | | `MAX_REQUESTS_PER_BLOCK` | `16` | Maximum records each contract drains into one block | | `TARGET_REQUESTS_PER_BLOCK` | `2` | Per-block request count above which the fee rises | | `MIN_REQUEST_FEE` | `1` | Minimum request fee, in wei | | `REQUEST_FEE_UPDATE_FRACTION` | `17` | Controls the fee's rate of change | | `EXCESS_INHIBITOR` | `2**256-1` | Excess value that makes the fee getter revert before the first system call (as in [EIP-7002](./eip-7002.md)/[EIP-7251](./eip-7251.md)); set at deployment, cleared by the first system call | -| `BUILDER_MIN_DEPOSIT` | `1000000000000000000` | Minimum credited stake for a deposit or top-up, in wei (1 ETH — the [EIP-7732](./eip-7732.md) builder minimum). Withdrawals enforce no minimum | -| `DOMAIN_BUILDER_DEPOSIT` | `0x0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9` | Signing domain for builder deposit messages. The `0x0b000000` domain type is a placeholder pending consensus-specs allocation; it MUST differ from the validator `DOMAIN_DEPOSIT` (`0x03000000…`) so signatures are not interchangeable between the two contracts | -| `BLS12_G2ADD` | `0x0d` | [EIP-2537](./eip-2537.md) precompile address | -| `BLS12_PAIRING_CHECK` | `0x0f` | [EIP-2537](./eip-2537.md) precompile address | -| `BLS12_MAP_FP2_TO_G2` | `0x11` | [EIP-2537](./eip-2537.md) precompile address | +| `BUILDER_MIN_DEPOSIT` | `1000000000000000000` | Minimum credited stake for a deposit, in wei (1 ETH — the [EIP-7732](./eip-7732.md) builder minimum) | | `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder deposit contract | -| `BUILDER_TOPUP_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder top-up contract | -| `BUILDER_WITHDRAWAL_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder withdrawal/exit contract | +| `BUILDER_EXIT_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder exit contract | ### Deployment -Each predeploy is deployed exactly as the [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) request contracts are: by a one-time presigned transaction from a single-use deployer account (the Nick's-method scheme), so that `BUILDER_DEPOSIT_CONTRACT_ADDRESS`, `BUILDER_TOPUP_CONTRACT_ADDRESS`, and `BUILDER_WITHDRAWAL_CONTRACT_ADDRESS` are the addresses cryptographically derived from those transactions. Each contract's init code sets its `excess` slot to `EXCESS_INHIBITOR`, so no request can be enqueued until the inhibitor is cleared (see [Request fee](#request-fee)). The concrete transactions — and therefore the final addresses — will be fixed once the runtime bytecode is audited and frozen. +Each predeploy is deployed exactly as the [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) request contracts are: by a one-time presigned transaction from a single-use deployer account (the Nick's-method scheme), so that `BUILDER_DEPOSIT_CONTRACT_ADDRESS` and `BUILDER_EXIT_CONTRACT_ADDRESS` are the addresses cryptographically derived from those transactions. Each contract's init code sets its `excess` slot to `EXCESS_INHIBITOR`, so no request can be enqueued until the inhibitor is cleared (see [Request fee](#request-fee)). The concrete transactions — and therefore the final addresses — will be fixed once the runtime bytecode is audited and frozen (see [Reference Implementation](#reference-implementation)). -The deployment transactions MUST be included before the fork that activates this EIP. If there is no code at any of the three predeploy addresses once the EIP is active, every block from activation onward MUST be invalid — the same handling [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) specify for their predeploys. +The deployment transactions MUST be included before the fork that activates this EIP. If there is no code at either predeploy address once the EIP is active, every block from activation onward MUST be invalid — the same handling [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) specify for their predeploys. ### Request queue and system call -All three predeploys follow the [EIP-7002](./eip-7002.md) / [EIP-7251](./eip-7251.md) request-bus pattern. Each maintains a FIFO queue of request records in its own storage and an EIP-1559-style `excess` counter. A user-facing entrypoint validates a request, charges the current fee, and appends one record. +Both predeploys follow the [EIP-7002](./eip-7002.md) / [EIP-7251](./eip-7251.md) request-bus pattern. Each maintains a FIFO queue of request records in its own storage and an EIP-1559-style `excess` counter. A user-facing entrypoint validates a request, charges the current fee, and appends one record. A call with empty calldata dispatches on the caller: - From `SYSTEM_ADDRESS` (the end-of-block system call): the predeploy MUST dequeue up to `MAX_REQUESTS_PER_BLOCK` records (oldest first), return their concatenation as that contract's `request_data`, advance its queue head past the returned records, then update `excess` from the number of requests added in the block (`excess = max(0, excess + count - TARGET_REQUESTS_PER_BLOCK)`, treating a current value of `EXCESS_INHIBITOR` as `0` so the first system call clears the inhibitor) and reset that count. Records beyond the per-block cap remain queued for subsequent blocks. - From any other caller: the predeploy MUST return the current fee (the fee getter), without modifying state. -The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). None of the contracts emit logs. +The execution layer prepends the contract's request-type byte and includes `request_type ++ request_data` in the block requests list, committed via the `requests_hash` ([EIP-7685](./eip-7685.md)). Neither contract emits logs. + +The end-of-block system call to each predeploy follows the same rules [EIP-7002](./eip-7002.md) and [EIP-7251](./eip-7251.md) specify, restated here because [EIP-7685](./eip-7685.md) does not: the call is made as `SYSTEM_ADDRESS` with a dedicated gas limit of `30_000_000`; the gas it consumes does not count against the block gas limit and no value is transferred; and **if any of the predeploys' system calls fails or returns an error, the block MUST be invalid.** ### Request fee @@ -90,143 +86,108 @@ where `fake_exponential` is the integer approximation of `MIN_REQUEST_FEE · e^( As in EIP-7002/7251, each contract's `excess` is initialized to `EXCESS_INHIBITOR` at deployment, and the fee getter reverts while `excess == EXCESS_INHIBITOR`. Since a request is only appended after its fee is paid, this blocks every request between deployment and the first end-of-block system call; that call clears the inhibitor (treating the prior `excess` as `0`), and normal fee operation runs from the activation block onward. -### Verified deposit entrypoint +### Deposit entrypoint ``` deposit( - bytes pubkey, // 48-byte compressed G1 (X with sign+infinity flags) - bytes32 withdrawal_credentials, // 32-byte commitment - uint64 amount_gwei, // stake to credit, in gwei (NOT signed) - bytes signature, // 96-byte compressed G2 (X with sign+infinity flags) - Fp pubkey_y, // affine Y of pubkey, in EIP-2537 encoding - Fp2 signature_y // affine Y of signature, in EIP-2537 encoding -) payable -``` - -`amount_gwei` is the stake to credit. It is an explicit parameter — and is **not** part of the signed message — because `msg.value` must cover both the stake and the dynamic fee, so the credited stake cannot be derived from `msg.value` alone. The signature commits only to `(pubkey, withdrawal_credentials)`; see [Rationale](#rationale) for why the amount is not signed. - -`deposit(...)` MUST perform the following, in order, before appending any record: - -1. Validate input lengths, and that `amount_gwei * 1 gwei` is at least `BUILDER_MIN_DEPOSIT`. -2. Require `msg.value >= amount_gwei * 1 gwei + fee`, where `fee` is the current request fee. Any value beyond `amount_gwei * 1 gwei` is retained by the contract (the fee, plus any overpayment, is not credited to the builder). -3. Reject `pubkey` or `signature` whose infinity flag is set. -4. Verify that the supplied `pubkey_y` and `signature_y` agree with the sign flag of the corresponding compressed encoding (i.e. `sign(pubkey_y)` equals the sign bit of `pubkey`, and likewise for the signature). This binds the point used in the pairing check to the encoding the consensus layer will register; without it the verified point could be the negation of the registered point. -5. Compute the signing root over the 2-field builder message: - `signing_root = sha256(hash_tree_root(pubkey, withdrawal_credentials) || DOMAIN_BUILDER_DEPOSIT)`. -6. Verify the BLS proof-of-possession via the [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile, using the supplied affine `Y` coordinates to construct the G1 and G2 points. -7. Revert the entire call if the pairing check fails. - -On success, `deposit(...)` MUST append a `BUILDER_DEPOSIT_REQUEST_TYPE` record of `pubkey (48) ++ withdrawal_credentials (32) ++ amount_gwei (8, little-endian)` to its queue. The signature is intentionally absent: it was verified at submission, so the consensus layer trusts the dequeued record without re-pairing. - -### Unverified top-up entrypoint - -``` -top_up( - bytes pubkey, // 48-byte compressed G1 of an existing builder - uint64 amount_gwei // stake to add, in gwei + bytes pubkey, // 48-byte BLS public key + bytes32 withdrawal_credentials, // 32-byte commitment (execution_address + prefix) + uint64 amount_gwei, // stake to credit, in gwei + bytes signature // 96-byte BLS proof-of-possession ) payable ``` -`top_up(...)` MUST validate the pubkey length, require `amount_gwei * 1 gwei` to be at least `BUILDER_MIN_DEPOSIT`, and require `msg.value >= amount_gwei * 1 gwei + fee` (same fee as `deposit`), but MUST NOT perform any signature verification. On success it MUST append a `BUILDER_TOPUP_REQUEST_TYPE` record of `pubkey (48) ++ amount_gwei (8, little-endian)` to its queue. +`deposit(...)` serves both a builder's first deposit and subsequent top-ups. It MUST: -`top_up(...)` deliberately takes no `withdrawal_credentials`. A top-up only adds stake to an already-registered builder; the credentials are fixed by that builder's verified deposit. Omitting the field denies an unauthenticated caller any influence over a builder's withdrawal target. The consensus layer is responsible for rejecting top-up records that target a `pubkey` not already registered as an EIP-7732 builder. +1. Validate that `pubkey` is 48 bytes and `signature` is 96 bytes. +2. Require `amount_gwei * 1 gwei >= BUILDER_MIN_DEPOSIT`. +3. Require `msg.value >= amount_gwei * 1 gwei + fee`, where `fee` is the current request fee. Any value beyond `amount_gwei * 1 gwei` is retained by the contract (the fee, plus any overpayment, is not credited to the builder). -The deposited ETH for the deposit and top-up entrypoints is locked in the respective contract; the consensus layer credits the builder from the dequeued request. A withdrawal, by contrast, moves no ETH on the execution layer. +On success it MUST append a `BUILDER_DEPOSIT_REQUEST_TYPE` record of `pubkey (48) ++ withdrawal_credentials (32) ++ amount_gwei (8, little-endian) ++ signature (96)` to its queue. The `signature` is carried in the record and verified by the consensus layer, which checks the proof-of-possession only on the `pubkey`'s first appearance and treats a later deposit to an existing builder as a stake top-up (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)). -### Withdrawal and exit entrypoint +### Exit entrypoint ``` -withdraw( - bytes pubkey, // 48-byte builder public key - uint64 amount_gwei // gwei to withdraw; 0 requests a full exit +exit( + bytes pubkey // 48-byte builder public key ) payable ``` -`withdraw(...)` is a direct analogue of the [EIP-7002](./eip-7002.md) withdrawal-request entrypoint, retargeted at the builder set. It MUST validate the `pubkey` length and require `msg.value >= fee` (the same request fee as `deposit`/`top_up`), but it performs **no** signature verification and stakes **no** value: a withdrawal debits the builder's beacon-chain balance rather than moving ETH on the execution layer, so `msg.value` need only cover the fee. There is intentionally no minimum-amount check — `amount_gwei == 0` is the full-exit sentinel. +`exit(...)` requests a full exit of the builder identified by `pubkey`. It MUST validate that `pubkey` is 48 bytes and require `msg.value >= fee` (the same request fee as `deposit`); it stakes no value and moves no ETH on the execution layer. On success it MUST append a `BUILDER_EXIT_REQUEST_TYPE` record of `source_address (20) ++ pubkey (48)` to its queue, where `source_address` is `msg.sender`. -On success it MUST append a `BUILDER_WITHDRAWAL_REQUEST_TYPE` record of `source_address (20) ++ pubkey (48) ++ amount_gwei (8, little-endian)` to its queue, where `source_address` is `msg.sender`. This record shape is identical to the [EIP-7002](./eip-7002.md) withdrawal request. - -Authorization is by `source_address`, exactly as in [EIP-7002](./eip-7002.md): the caller proves control of the builder by transacting from the builder's `execution_address` (the `0x03` builder withdrawal credential). The contract itself does not check this — it records `msg.sender` verbatim — and the consensus layer honours the request only when `source_address` equals the target builder's `execution_address` (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)). An `amount_gwei` of `0` requests a full exit (the builder's voluntary exit); any `amount_gwei > 0` requests a partial withdrawal of that many gwei. +Authorization is by `source_address`, as in [EIP-7002](./eip-7002.md): the caller proves control of the builder by transacting from the builder's `execution_address`. The contract records `msg.sender` verbatim and performs no further check; the consensus layer honours the request only when `source_address` equals the target builder's `execution_address` (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)). ### Consensus layer request objects -The consensus layer decodes each dequeued record into one of three SSZ containers, selected by request type: +The consensus layer decodes each dequeued record into one of two SSZ containers, selected by request type: ```python class BuilderDepositRequest(object): pubkey: Bytes48 withdrawal_credentials: Bytes32 amount: uint64 # Gwei + signature: Bytes96 -class BuilderTopUpRequest(object): - pubkey: Bytes48 - amount: uint64 # Gwei - -class BuilderWithdrawalRequest(object): +class BuilderExitRequest(object): source_address: Bytes20 pubkey: Bytes48 - amount: uint64 # Gwei ``` -A type's `request_data` is the concatenation of the fixed-size SSZ serializations of its records — 88 bytes per `BuilderDepositRequest` (`pubkey ++ withdrawal_credentials ++ amount`), 56 bytes per `BuilderTopUpRequest` (`pubkey ++ amount`), and 76 bytes per `BuilderWithdrawalRequest` (`source_address ++ pubkey ++ amount`), with `amount` little-endian — in the FIFO order the system call returns them. This matches the bytes the contract appends to its queue. Unlike the validator [EIP-6110](./eip-6110.md) `DepositRequest`, `BuilderDepositRequest` carries no `signature` (the execution layer has already verified it) and no `index`. `BuilderWithdrawalRequest` has the same shape as the validator [EIP-7002](./eip-7002.md) withdrawal request (`source_address ++ validator_pubkey ++ amount`), reused unchanged for builders. +A type's `request_data` is the concatenation of the fixed-size SSZ serializations of its records — 184 bytes per `BuilderDepositRequest` (`pubkey ++ withdrawal_credentials ++ amount ++ signature`) and 68 bytes per `BuilderExitRequest` (`source_address ++ pubkey`), with `amount` little-endian — in the FIFO order the system call returns them. This matches the bytes each contract appends to its queue. `BuilderDepositRequest` is the validator [EIP-6110](./eip-6110.md) `DepositRequest` without the `index` field; the consensus layer verifies its `signature` (the proof-of-possession) on the builder's first registration. ### Consensus-layer processing of records -The consensus layer processes the three request types as follows: +The consensus layer processes the two request types as follows. Both are applied immediately when processed — a `BuilderDepositRequest` is **not** routed through the validator `pending_deposits` queue — so builder onboarding has no churn or finalization delay, preserving EIP-7732's existing behavior. -- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **not** yet in the builder set is a first deposit: it registers the builder with the record's `withdrawal_credentials` and credits its `amount`. The execution layer has already verified the proof-of-possession, so the consensus layer does not re-verify. -- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **already** in the builder set MUST be treated as a top-up: it credits `amount` and MUST NOT change the existing `withdrawal_credentials` or re-register the builder. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. -- A `BuilderTopUpRequest` (type `0x04`) MUST be rejected if its `pubkey` is not already a registered builder, and otherwise credits `amount` without touching the withdrawal credentials. -- A `BuilderWithdrawalRequest` (type `0x05`) MUST be ignored unless its `pubkey` is a registered builder **and** its `source_address` equals that builder's `execution_address`. When valid, an `amount` of `0` initiates a full exit of the builder (setting its `withdrawable_epoch`), and any `amount > 0` queues a partial withdrawal of up to `amount` gwei from the builder's balance. This mirrors validator [EIP-7002](./eip-7002.md) processing — `amount == 0` is a full exit, `amount > 0` a partial withdrawal — and the `execution_address` match is the builder analogue of EIP-7002's check that `source_address` matches the validator's `0x01` withdrawal credential. +- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **not** yet in the builder set is a first deposit: the consensus layer verifies the proof-of-possession `signature` over `(pubkey, withdrawal_credentials)` under the builder-deposit signing domain and, if valid, registers the builder with the record's `withdrawal_credentials` and credits its `amount`; an invalid signature is ignored. +- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **already** in the builder set is a top-up: it credits `amount` and MUST NOT change the existing `withdrawal_credentials` or re-register the builder, and its `withdrawal_credentials` and `signature` are ignored. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. +- A `BuilderExitRequest` (type `0x04`) MUST be ignored unless its `pubkey` is a registered builder, its `source_address` equals that builder's `execution_address`, and the builder has no pending balance to withdraw. When valid, it initiates the builder's exit — setting `withdrawable_epoch = current_epoch + MIN_BUILDER_WITHDRAWABILITY_DELAY`, and is a no-op if the builder is already exiting. This is EIP-7732's existing `initiate_builder_exit`, now reached through this request rather than through a voluntary exit. -## Rationale - -- **A separate contract, not a replacement.** The deployed validator contract has an immutable two-mode API. Replacing its runtime would either break the all-zero-signature top-up flow that mainnet uses today, or would require keeping an unverified entrypoint in the spec — bringing the same DoS surface forward. A separate contract lets the existing validator semantics stay fixed. - -- **Reuse the EIP-7685 request bus.** All three contracts deliver their records through the same execution-to-consensus mechanism as [EIP-7002](./eip-7002.md) withdrawals and [EIP-7251](./eip-7251.md) consolidations: an in-state queue drained by an end-of-block `SYSTEM_ADDRESS` system call, committed in `requests_hash`, and emitting no logs. As fresh predeploys they adopt this request bus directly, so the consensus layer reads every builder operation from the block's requests list. +### Changes to EIP-7732 -- **Three predeploys, three request types.** Mirroring withdrawals (`0x01`) and consolidations (`0x02`) — each a single-type request predeploy — builder deposits (`0x03`), top-ups (`0x04`), and withdrawals/exits (`0x05`) are separate predeploys sharing a common queue implementation. Each is a standard single-type request contract: an empty-calldata `SYSTEM_ADDRESS` call returns a flat `request_data`. The execution layer therefore needs no new read semantics, and the consensus layer distinguishes a first-sighting deposit (with an execution-layer-verified signature) from a stake-only top-up by request type rather than by inspecting record contents. +This EIP modifies EIP-7732's builder lifecycle on the consensus layer: -- **Withdrawals and exits clone EIP-7002.** The withdrawal predeploy is deliberately a direct analogue of the [EIP-7002](./eip-7002.md) withdrawal contract rather than a fresh design. A builder withdrawal or exit is authorized the same way a validator's is — by transacting from the credential that owns the stake (the builder's `execution_address`) — so no proof-of-possession is needed and the record is simply `source_address ++ pubkey ++ amount`, identical to EIP-7002. A single contract covers both operations because, as in EIP-7002, `amount == 0` is the full exit and `amount > 0` a partial withdrawal. This is the one place the consensus layer keys off a record field (the amount) rather than the request type alone: reusing the audited EIP-7002 record shape unchanged was judged more valuable than splitting exit into its own request type for uniformity with the deposit/top-up split. Unlike `deposit`/`top_up`, `withdraw(...)` stakes no value — it debits the builder's beacon-chain balance — so `msg.value` covers only the request fee. +- **Deposit routing (post-fork).** The builder branch of `process_deposit_request` (which applies a deposit as a builder when its withdrawal credential carries `BUILDER_WITHDRAWAL_PREFIX`) is removed. After the fork, builders are sourced **only** from `BUILDER_DEPOSIT_REQUEST_TYPE`. A standard validator deposit (type `0x00`) whose withdrawal credential carries `BUILDER_WITHDRAWAL_PREFIX` MUST be rejected — it is applied neither as a validator nor as a builder. +- **Genesis onboarding (at the fork).** `onboard_builders_from_pending_deposits`, run once during the fork upgrade, is retained: builder-credentialed deposits already pending at the fork are onboarded as builders, so builders exist from the activation slot. Operators seed the genesis builder set by depositing to the existing deposit contract with a `BUILDER_WITHDRAWAL_PREFIX` credential before the fork. +- **Exit routing.** The builder branch of `process_voluntary_exit` is removed, making the voluntary-exit operation validator-only; builders exit only via `BUILDER_EXIT_REQUEST_TYPE`. -- **EIP-1559-style request fee.** Each request carries the same dynamic, demand-responsive fee as EIP-7002/7251, rather than relying on the staked value alone as the anti-spam gate. This keeps the builder predeploys uniform with the existing request bus and smooths bursts: when a block exceeds `TARGET_REQUESTS_PER_BLOCK`, the `excess` counter grows and the fee rises super-linearly, throttling demand independently of the deposit minimum; it decays back to `MIN_REQUEST_FEE` when demand subsides. Because a deposit also carries stake, the fee is charged on top of the staked value; the fee is retained by the contract (effectively burned), and the per-block cap plus the queue still bound how many records enter a single block. +## Rationale -- **The amount is not signed.** The builder deposit signature commits only to `(pubkey, withdrawal_credentials)`, not the amount. Signing the amount would add no security here: the unverified `top_up` already lets anyone add stake to a `pubkey` with no signature, so the staked amount is not a signature-bound quantity by design, and even on the first deposit the depositor controls both the signature and `msg.value`, so a mismatch benefits no one. Leaving the amount unsigned also removes a circularity: with the fee drawn from `msg.value`, a *signed* amount could not be derived from `msg.value` (the fee is unknown at signing time), so it would otherwise have to be both signed and passed explicitly. `amount_gwei` is therefore an explicit but unsigned parameter — the credited stake — which keeps it symmetric with `top_up`'s amount and makes the credited value deterministic regardless of the fee at inclusion time. +- **Two predeploys, two request types.** Mirroring withdrawals (`0x01`) and consolidations (`0x02`) — each a single-type request predeploy — builder deposits (`0x03`) and exits (`0x04`) are separate predeploys sharing a common queue implementation. Each is a standard single-type request contract: an empty-calldata `SYSTEM_ADDRESS` call returns a flat `request_data`, so the execution layer needs no new read semantics, and the consensus layer routes by request type rather than by inspecting credentials. -- **Y coordinates supplied by the caller.** On-chain decompression of a compressed G1 or G2 point requires an Fp or Fp2 square root, which in turn requires several thousand bytes of runtime code and an order-of-magnitude more gas than the pairing check itself. Because builders already work with affine BLS points in their off-chain infrastructure, requiring the Y coordinates as call data shrinks the canonical bytecode considerably and removes the Fp-arithmetic and Sarkar/Adj sqrt code from the audit surface. +- **One request for deposits and top-ups.** A single deposit request serves both: a deposit to a new `pubkey` registers a builder (the consensus layer verifies the proof-of-possession), and a deposit to an existing builder tops up its stake — exactly as the validator deposit contract does. A top-up cannot redirect a builder's withdrawal target, because the consensus layer ignores the supplied `withdrawal_credentials` and `signature` for an existing builder; and a junk deposit to a new `pubkey` cannot register a builder without a valid proof-of-possession. -- **Caller-supplied Y is bound to the compressed sign bit.** The contract requires `sign(pubkey_y)` to equal the sign flag of the compressed `pubkey` (and likewise for the signature). The pairing check alone does NOT make this redundant: because a depositor jointly chooses the key, the queued sign bit, and the signature, they can verify a point `(X, +Y)` while the record's `pubkey` bytes decompress to `(X, −Y)`, keeping the pairing self-consistent but causing the consensus layer to register a point whose proof-of-possession was never actually verified. Binding the sign bit closes this gap with a single field comparison and short-circuits before any pairing work. +- **Exit by `execution_address`; voluntary exit becomes validator-only.** A builder's BLS key is hot — it signs bids continuously — so authorizing exit with that key is undesirable. Routing exit through the `execution_address` (the cold address that owns the builder's stake and receives its withdrawals) mirrors EIP-7002's rationale for letting `0x01` credentials trigger validator exits, and removing the builder branch from the voluntary-exit operation gives builders a single, well-defined exit authorizer. Losing the `execution_address` key strands no funds that were not already stranded: that address is where the builder's balance is swept regardless. -- **Gas-metered verification as the DoS gate.** Verification cost (`BLS12_PAIRING_CHECK` + `BLS12_MAP_FP2_TO_G2` + supporting work) is paid by the depositor's transaction. Submitting an invalid signature therefore costs the same as submitting a valid one; there is no asymmetric drain on the consensus layer. +- **EIP-1559-style request fee.** Each request carries the same dynamic, demand-responsive fee as EIP-7002/7251. When a block exceeds `TARGET_REQUESTS_PER_BLOCK`, `excess` grows and the fee rises super-linearly, throttling demand; it decays back to `MIN_REQUEST_FEE` when demand subsides. Together with the per-block cap and the per-deposit stake, the fee is what meters submission to each predeploy. -- **Distinct signing domain (`DOMAIN_BUILDER_DEPOSIT`).** Builder deposit signatures use a domain type distinct from the validator deposit domain. Were the builder message identical to the validator `DepositMessage` and signed under the same domain, a public validator-deposit signature could be replayed here to force-enrol a validator pubkey as a builder (and vice versa). Two independent differences now prevent that: the builder message is a 2-field `(pubkey, withdrawal_credentials)` container (the validator message is 3-field, with the amount, so the message roots differ for the same key), and the signing domain differs. The distinct domain is retained as the explicit guarantee rather than relying on the structural difference alone — an explicit domain tag is more robust than the assumption that no other scheme ever signs a 2-field `(pubkey, withdrawal_credentials)` message under the validator domain. +- **Genesis onboarding via the fork transition.** Some applications depend on builders existing from the first slot of the fork. EIP-7732 already onboards builder-credentialed pending deposits during the fork upgrade; retaining that — rather than relying on post-fork deposits to the new contract, which cannot populate the activation slot — keeps the genesis builder set available immediately. That one-time onboarding is bounded, so it needs neither the per-block cap nor the fee that the steady-state contract provides. ## Backwards Compatibility -This EIP is additive at the execution layer: it introduces new contracts at previously empty addresses. It does not modify the validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`, does not change the `DepositEvent` layout that contract emits, and does not affect any existing validator's ability to make first deposits or top-ups. +This EIP is additive at the execution layer: it introduces new contracts at previously empty addresses. It does not modify the validator deposit contract at `0x00000000219ab540356cbb839cbe05303d7705fa`, the validator withdrawal/consolidation predeploys, or any existing validator's lifecycle. -At the consensus layer, EIP-7732 builders MUST be sourced from the builder deposit (`BUILDER_DEPOSIT_REQUEST_TYPE`) and top-up (`BUILDER_TOPUP_REQUEST_TYPE`) requests committed in the block `requests_hash`, and builder withdrawals and exits from the builder withdrawal (`BUILDER_WITHDRAWAL_REQUEST_TYPE`) requests; the validator deposit and withdrawal contracts remain the sole sources of the corresponding validator operations. The new request types are additive — blocks that contain no builder requests produce empty `request_data` for these types, which [EIP-7685](./eip-7685.md) excludes from the `requests_hash`. +At the consensus layer it modifies EIP-7732 (see [Changes to EIP-7732](#changes-to-eip-7732)): post-fork builder onboarding moves from the validator deposit request to `BUILDER_DEPOSIT_REQUEST_TYPE`, and builder exits move from the voluntary-exit operation to `BUILDER_EXIT_REQUEST_TYPE`. The fork-transition onboarding of builder-credentialed pending deposits is unchanged, so builders present at the fork are unaffected. The new request types are additive — blocks that contain no builder requests produce empty `request_data` for these types, which [EIP-7685](./eip-7685.md) excludes from the `requests_hash`. ## Test Cases -A Foundry test suite under `../assets/eip-draft_builder_requests/test/` cross-verifies the contracts against `py_ecc` (the canonical Eth2 Python reference). Coverage includes the SSZ signing-root computation; an end-to-end `deposit(...)` that enqueues a record matching a `py_ecc.bls.G2ProofOfPossession.Sign`-produced signature; the `top_up(...)` happy path; the `withdraw(...)` happy path for both a partial withdrawal and an `amount == 0` exit — asserting the recorded `source_address` is the caller and that a withdrawal stakes no value; the `SYSTEM_ADDRESS` system read returning the exact `request_data` records; the per-block cap and FIFO drain order; rejection of a non-`SYSTEM_ADDRESS` system read; and the input-shape, insufficient-fee, and tampering rejection paths (each asserting nothing is enqueued). +A Foundry test suite under `../assets/eip-draft_builder_requests/test/` exercises both predeploys against the shared queue. Coverage includes: the `deposit(...)` happy path (the `SYSTEM_ADDRESS` read returns the exact 184-byte `pubkey ++ withdrawal_credentials ++ amount ++ signature` record) and its input-shape and insufficient-value rejections; the `exit(...)` happy path (the read returns the exact 68-byte `source_address ++ pubkey` record, with `source_address` taken from the caller) and its rejections; the EIP-1559 fee (minimum at `excess == 0`, rising after a block above `TARGET_REQUESTS_PER_BLOCK`, and the fee getter); the per-block cap and FIFO drain order, queue reset on empty, and rejection of a non-`SYSTEM_ADDRESS` system read; and the `EXCESS_INHIBITOR` (fee getter and requests revert before activation, and the first system call clears the inhibitor). ## Reference Implementation -Solidity source for all three predeploys is published at [`../assets/eip-draft_builder_requests/builder_requests.sol`](../assets/eip-draft_builder_requests/builder_requests.sol), with the test harness, fixture generator, and Foundry configuration alongside it. The file defines a shared `RequestQueue` base plus `BuilderDepositContract`, `BuilderTopUpContract`, and `BuilderWithdrawalContract`. The optimised runtime bytecode of the current draft is approximately 7.6 KiB for the deposit contract and about 1.6 KiB each for the top-up and withdrawal contracts — all well within the [EIP-170](./eip-170.md) 24 KiB limit, with no on-chain field-arithmetic kernel or decompression path. The final runtime codes, the predeploy addresses, and the request-type bytes will be locked in once the contracts have been independently audited. +Solidity source for both predeploys is published at [`../assets/eip-draft_builder_requests/builder_requests.sol`](../assets/eip-draft_builder_requests/builder_requests.sol), with the test harness and Foundry configuration alongside it. The file defines a shared `RequestQueue` base (queue, EIP-1559 fee, `EXCESS_INHIBITOR`, and `SYSTEM_ADDRESS` end-of-block read) plus `BuilderDepositContract` and `BuilderExitContract`. The optimised runtime bytecode of the current draft is approximately 1.8 KiB for the deposit contract and 1.3 KiB for the exit contract — both far within the [EIP-170](./eip-170.md) 24 KiB limit. + +The final `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` and `BUILDER_EXIT_CONTRACT_RUNTIME_CODE`, the predeploy addresses, and the request-type bytes will be locked in once the contracts have been independently audited. The runtime bytecode, the exact compiler version and settings used to produce it, and the contracts' storage layout MUST be pinned together at that point, so the canonical bytecode is independently reproducible. ## Security Considerations -- **Signing-domain separation.** `DOMAIN_BUILDER_DEPOSIT` MUST differ from the validator `DOMAIN_DEPOSIT`, so a proof-of-possession signature is never interchangeable between this contract and the validator deposit contract (which would otherwise allow a public validator-deposit signature to be replayed here to force-enrol a validator pubkey as a builder, and vice versa). The builder message also differs structurally — a 2-field `(pubkey, withdrawal_credentials)` container versus the validator's 3-field message — which independently prevents the replay; the distinct domain is kept as the explicit guarantee rather than relying on that structural difference alone. -- **Sign-bit binding.** The supplied affine `Y` MUST agree with the sign flag of the compressed `pubkey`/`signature`. Without this binding, a depositor controlling the key could pass the pairing check on a point `(X, +Y)` while the queued record's `pubkey` bytes decompress to `(X, −Y)`, so the consensus layer registers a key whose proof-of-possession the execution layer never verified (it verified the negation). The deposit record carries no signature, so the consensus layer cannot detect this by re-verification — it trusts the execution-layer check — which is exactly why the binding must be enforced on chain. -- **System-read access control and per-block cap.** Only `SYSTEM_ADDRESS` may invoke the end-of-block dequeue; any other empty-calldata call reverts, so a non-system caller cannot drain or replay the queue. Each contract returns at most `MAX_REQUESTS_PER_BLOCK` records per block, bounding the size each predeploy contributes to the block requests; excess records remain queued for later blocks. -- **Top-up validity at CL.** A top-up appends a request without checking that the target `pubkey` exists. The consensus layer MUST reject top-ups against unregistered builders so that all-zero or junk top-ups cannot register new builders without a verified deposit. `top_up(...)` carries no `withdrawal_credentials`, so an unauthenticated caller cannot rewrite an existing builder's withdrawal target. -- **Withdrawal/exit authorization.** The withdrawal contract records `msg.sender` as the `source_address` and performs no further check, exactly like [EIP-7002](./eip-7002.md). Because the request carries no signature, this is the sole authorization: the consensus layer MUST honour a `0x05` record only when `source_address` equals the target builder's `execution_address`, or an arbitrary caller could exit or drain a builder it does not control. The `execution_address` is the credential that owns the builder's stake, so letting it trigger withdrawals and exits is the intended ownership semantics — the same rationale EIP-7002 gives for `0x01` credentials. `withdraw(...)` stakes no value and enforces no minimum amount (`0` is the exit sentinel), so the request fee together with the per-block cap are what meter it. -- **Replayable deposit records.** A deposit's `(pubkey, withdrawal_credentials, signature, …)` is public in calldata, and the signature commits only to `(pubkey, withdrawal_credentials)`, so a third party can submit a further `0x03` record for an already-registered builder at an arbitrary amount (funding it themselves). The consensus layer MUST treat a `0x03` record for an already-registered `pubkey` as a top-up — crediting stake but never changing the withdrawal credentials or re-registering — so the replay cannot redirect a builder's withdrawals or reset its state (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)). This is harmless beyond a funded stake addition, exactly like a `0x04` top-up. -- **DoS surface.** Verification cost is gas-metered and paid by the depositor; an adversary cannot force consensus-layer pairing work without first paying the corresponding execution-layer gas. Per [EIP-2537](./eip-2537.md) §"Gas burning on error", a precompile that rejects a malformed (off-curve or out-of-subgroup) point burns all gas forwarded to it, so the contract MUST NOT forward `gas()` to the precompiles. Because EIP-2537 pricing is deterministic (a pure function of input length), the contract forwards a fixed gas ceiling to each precompile `staticcall` — set per call at roughly 2.5x the documented cost — which bounds the worst-case burn on a malformed input to that ceiling instead of the whole transaction, while leaving ample headroom for a future reprice. The ceilings MUST be revisited if [EIP-2537](./eip-2537.md) pricing changes. -- **Subgroup membership.** The [EIP-2537](./eip-2537.md) `BLS12_PAIRING_CHECK` precompile performs G1 and G2 subgroup checks; the contract does not need to re-implement them. -- **Compressed-point flags.** The contract must reject infinity-flagged inputs to prevent acceptance of the identity element as a `pubkey` or `signature`. -- **Validator-contract co-existence.** The validator deposit and withdrawal ([EIP-7002](./eip-7002.md)) contracts are unmodified; nothing in this EIP changes existing validator deposit, withdrawal, or exit semantics. +- **Deposit proof-of-possession at the consensus layer.** The consensus layer verifies the proof-of-possession over `(pubkey, withdrawal_credentials)` on a builder's first registration and ignores the signature for top-ups. The per-block cap bounds the number of verifications the consensus layer performs per block, and the fee plus the 1-ETH stake (forfeited if the signature is invalid) make spamming invalid registrations expensive. +- **Builder-deposit signing-domain separation.** The signing domain the consensus layer uses to verify a builder deposit MUST differ from the validator `DOMAIN_DEPOSIT` and from every EIP-7732 domain, so a proof-of-possession signature is not interchangeable between a builder deposit, a validator deposit, and an EIP-7732 builder message. This is a consensus-layer concern (the contract holds no signing domain). +- **Exit authorization.** The exit contract records `msg.sender` as `source_address` and performs no further check. Because the request carries no signature, this is the sole authorization: the consensus layer MUST initiate an exit only when `source_address` equals the target builder's `execution_address`, or an arbitrary caller could exit a builder it does not control. A builder's only exit authorizer is therefore its `execution_address`; the voluntary-exit (BLS-key) path is removed for builders. +- **Same public key as validator and builder.** Because the registries are keyed by independent request types, one public key may exist as both a validator and a builder. The two are distinct entries with distinct indices and distinct lifecycles; neither request type can act on the other registry. +- **Replayable deposit records.** A deposit's `(pubkey, withdrawal_credentials, amount, signature)` is public in calldata, so a third party can submit a further `0x03` record for an already-registered builder at an arbitrary amount (funding it themselves). The consensus layer treats any `0x03` record for an already-registered `pubkey` as a top-up — crediting stake but ignoring the credentials and signature — so the replay cannot redirect a builder's withdrawals or re-register it; it is a harmless funded stake addition. +- **System-read access control and per-block cap.** Only `SYSTEM_ADDRESS` may invoke the end-of-block dequeue; any other empty-calldata call is the fee getter and does not modify state, so a non-system caller cannot drain or replay the queue. Each contract returns at most `MAX_REQUESTS_PER_BLOCK` records per block, bounding both the size each predeploy contributes to the block requests and the consensus-layer work to process them; excess records remain queued for later blocks. +- **Validator-contract co-existence.** The validator deposit contract and the validator request predeploys are unmodified; this EIP changes only EIP-7732's builder onboarding and exit routing (see [Changes to EIP-7732](#changes-to-eip-7732)). ## Copyright diff --git a/assets/eip-draft_builder_requests/README.md b/assets/eip-draft_builder_requests/README.md index a12ca6c9b974d4..2d34a02fb83774 100644 --- a/assets/eip-draft_builder_requests/README.md +++ b/assets/eip-draft_builder_requests/README.md @@ -1,28 +1,22 @@ # EIP-XXXX: Builder Execution Requests — Assets -Reference Solidity for the proposal, plus cross-verification tests. +Reference Solidity for the proposal, plus a Foundry test suite. ## Files | File | Purpose | | --- | --- | -| `builder_requests.sol` | The three proposed predeploys plus a shared base: `RequestQueue` (EIP-7002-style queue + EIP-1559 fee + `SYSTEM_ADDRESS` end-of-block read), `BuilderDepositContract` (`deposit(...)`, BLS-verified, request type `0x03`), `BuilderTopUpContract` (`top_up(...)`, unverified, request type `0x04`), and `BuilderWithdrawalContract` (`withdraw(...)`, EIP-7002-style withdrawal/exit, request type `0x05`). | -| `gen_vectors.py` | Python script that uses `py_ecc` (the canonical Eth2 reference) to produce cross-verification test vectors. | -| `test/Vectors.sol` | Auto-generated Solidity library of test vectors. Regenerate by running `gen_vectors.py`. | -| `test/TestHarness.sol` | `BuilderDepositHarness` / `BuilderTopUpHarness` / `BuilderWithdrawalHarness` — inherit the predeploys and expose the pending-queue depth (and the SSZ signing-root helper) for the tests. | +| `builder_requests.sol` | The two proposed predeploys plus a shared base: `RequestQueue` (EIP-7002-style queue + EIP-1559 fee + `EXCESS_INHIBITOR` + `SYSTEM_ADDRESS` end-of-block read), `BuilderDepositContract` (`deposit(...)`, request type `0x03`, serves first deposits and top-ups), and `BuilderExitContract` (`exit(...)`, request type `0x04`). Neither performs on-chain BLS; the deposit's signature is carried in the record for the consensus layer to verify. | +| `test/TestHarness.sol` | `BuilderDepositHarness` / `BuilderExitHarness` — inherit the predeploys and expose the pending-queue depth, the current fee, and (for the exit harness) the raw head/tail indices. | | `test/BuilderRequests.t.sol` | Foundry tests. | -| `foundry.toml` | Foundry configuration (solc `0.6.11`, EVM `prague` by default). | +| `foundry.toml` | Foundry configuration (solc `0.6.11`, EVM `istanbul`). | ## Running the tests -Prerequisites: +Prerequisites — Foundry only (no Python / `py_ecc`, since the contracts perform no on-chain BLS): ```bash -# Foundry (forge / cast / anvil) curl -L https://foundry.paradigm.xyz | bash && foundryup - -# Python with py_ecc for regenerating vectors -python3 -m venv venv && ./venv/bin/pip install py_ecc ``` Run the test suite: @@ -31,50 +25,28 @@ Run the test suite: forge test -vv ``` -`evm_version = "prague"` in `foundry.toml` enables the EIP-2537 BLS precompiles, required for the three tests that exercise the pairing path. To run only the queue, system-read, and input-validation tests on an older EVM (no EIP-2537 needed): - -```bash -forge test -vv --evm-version cancun --no-match-test 'Deposit(EnqueuesAndReads|AmountNotBound|RejectsTamperedSignature)' -``` - -## Regenerating vectors - -```bash -./venv/bin/python gen_vectors.py > test/Vectors.sol -``` - -The script is deterministic: the secret key is hard-coded so the output is byte-stable across runs. `py_ecc.bls.G2ProofOfPossession.Sign` (the Eth2 ciphersuite) produces the deposit signature; `py_ecc.optimized_bls12_381.normalize` extracts the canonical affine (X, Y) coordinates. +The contracts use only basic EVM features (no precompiles), so the suite runs on any post-Byzantium EVM; `foundry.toml` targets `istanbul`, the newest version solc `0.6.11` supports. ## Test coverage -| Test | What it covers | EIP-2537 required? | -| --- | --- | --- | -| `testComputeSigningRoot` | `_computeDepositSigningRoot` matches `py_ecc`-derived SSZ `compute_signing_root` | no | -| `testDepositEnqueuesAndReads` | A `py_ecc`-produced deposit is accepted, enqueued, and the `SYSTEM_ADDRESS` read returns the exact 88-byte record | **yes** | -| `testTopUpEnqueuesAndReads` | `top_up(...)` enqueues; the system read returns the exact 56-byte record | no | -| `testFeeStartsAtMinimum` | The fee is `MIN_REQUEST_FEE` (1 wei) at `excess == 0` | no | -| `testFeeRisesWithExcess` | A block of 18 requests then a system call sets `excess = 16`, so `fake_exponential(1, 16, 17) == 2` | no | -| `testFeeGetterFallbackMatches` | A non-system empty-calldata call returns the current fee | no | -| `testDepositAmountNotBoundToSignature` | The same signature is accepted with a different `amount_gwei` (the amount is unsigned); the record reflects the passed amount | **yes** | -| `testDepositRejectsInsufficientValue` | `msg.value == stake` (no room for the fee) reverts; nothing enqueued | no | -| `testSystemReadRequiresSystemAddress` | A non-`SYSTEM_ADDRESS` empty-calldata call is the fee getter and does NOT drain the queue | no | -| `testPerBlockCapAndFifo` | 17 queued → first read drains the 16-record cap, second drains the remainder (FIFO) | no | -| `testQueueResetsWhenDrained` | When the queue fully drains, head and tail reset to 0 so slots are reused; the next request restarts at index 0 | no | -| `testFallbackRejectsNonEmptyCalldata` | The empty-calldata fallback rejects non-empty junk calldata; the empty-calldata fee getter still works | no | -| `testDepositRejectsTamperedSignature` | Flipping a signature bit is rejected (subgroup/pairing failure); nothing enqueued | **yes** | -| `testDepositRejectsPubkeySignBitFlip` | Flipping only the pubkey sign flag is rejected by the sign-bit binding (audit Finding 2 regression); nothing enqueued | no | -| `testDepositRejectsSignatureSignBitFlip` | Flipping only the signature sign flag is rejected by the sign-bit binding; nothing enqueued | no | -| `testDepositRejectsInfinityPubkey` | `pubkey` with infinity flag is rejected before BLS work; nothing enqueued | no | -| `testDepositRejectsTooSmallStake` | `amount_gwei * 1 gwei < 1 ether` is rejected; nothing enqueued | no | -| `testDepositRejectsWrongPubkeyLength` | `pubkey.length != 48` is rejected; nothing enqueued | no | -| `testTopUpRejectsTooSmallStake` | `top_up` stake `< 1 ether` is rejected; nothing enqueued | no | -| `testTopUpRejectsWrongPubkeyLength` | `top_up` with `pubkey.length != 48` is rejected; nothing enqueued | no | -| `testWithdrawalEnqueuesAndReads` | A partial withdrawal (`amount > 0`) enqueues `source ++ pubkey ++ amount`; the system read returns the exact 76-byte record | no | -| `testExitEnqueuesWithZeroAmount` | `withdraw(pubkey, 0)` (full-exit sentinel) is accepted and recorded with a zero amount | no | -| `testWithdrawalRecordsCaller` | The recorded `source_address` is the caller (`msg.sender`), the field the CL checks against the builder's `execution_address` | no | -| `testWithdrawalRequiresNoStake` | A withdrawal sends only the fee — no staked value — even for a large `amount_gwei` | no | -| `testWithdrawalRejectsInsufficientFee` | `msg.value` below the fee reverts; nothing enqueued | no | -| `testWithdrawalRejectsWrongPubkeyLength` | `withdraw` with `pubkey.length != 48` is rejected; nothing enqueued | no | -| `testFeeGetterRevertsWhileInhibited` | A freshly deployed contract is inhibited (`excess == EXCESS_INHIBITOR`); the fee getter reverts | no | -| `testRequestRevertsWhileInhibited` | A request before the first system call reverts on the inhibited fee; nothing enqueued | no | -| `testFirstSystemCallClearsInhibitor` | The first `SYSTEM_ADDRESS` call clears the inhibitor; the fee is then `MIN_REQUEST_FEE` | no | +| Test | What it covers | +| --- | --- | +| `testDepositEnqueuesAndReads` | `deposit(...)` enqueues; the `SYSTEM_ADDRESS` read returns the exact 184-byte `pubkey ++ withdrawal_credentials ++ amount ++ signature` record | +| `testDepositRejectsTooSmallStake` | `amount_gwei * 1 gwei < BUILDER_MIN_DEPOSIT` (1 ETH) is rejected; nothing enqueued | +| `testDepositRejectsInsufficientValue` | `msg.value == stake` (no room for the fee) reverts; nothing enqueued | +| `testDepositRejectsWrongPubkeyLength` | `pubkey.length != 48` is rejected; nothing enqueued | +| `testDepositRejectsWrongSignatureLength` | `signature.length != 96` is rejected; nothing enqueued | +| `testExitEnqueuesAndReads` | `exit(pubkey)` enqueues; the system read returns the exact 68-byte `source_address ++ pubkey` record | +| `testExitRecordsCaller` | The recorded `source_address` is the caller (`msg.sender`), the field the CL checks against the builder's `execution_address` | +| `testExitRejectsInsufficientFee` | `msg.value` below the fee reverts; nothing enqueued | +| `testExitRejectsWrongPubkeyLength` | `exit` with `pubkey.length != 48` is rejected; nothing enqueued | +| `testFeeStartsAtMinimum` | The fee is `MIN_REQUEST_FEE` (1 wei) at `excess == 0` | +| `testFeeRisesWithExcess` | A block of 18 requests then a system call sets `excess = 16`, so `fake_exponential(1, 16, 17) == 2` | +| `testFeeGetterFallbackMatches` | A non-system empty-calldata call returns the current fee | +| `testSystemReadRequiresSystemAddress` | A non-`SYSTEM_ADDRESS` empty-calldata call is the fee getter and does NOT drain the queue | +| `testPerBlockCapAndFifo` | 17 queued → first read drains the 16-record cap, second drains the remainder (FIFO) | +| `testQueueResetsWhenDrained` | When the queue fully drains, head and tail reset to 0 so slots are reused; the next request restarts at index 0 | +| `testFallbackRejectsNonEmptyCalldata` | The empty-calldata fallback rejects non-empty junk calldata; the empty-calldata fee getter still works | +| `testFeeGetterRevertsWhileInhibited` | A freshly deployed contract is inhibited (`excess == EXCESS_INHIBITOR`); the fee getter reverts | +| `testRequestRevertsWhileInhibited` | A request before the first system call reverts on the inhibited fee; nothing enqueued | +| `testFirstSystemCallClearsInhibitor` | The first `SYSTEM_ADDRESS` call clears the inhibitor; the fee is then `MIN_REQUEST_FEE` | diff --git a/assets/eip-draft_builder_requests/builder_requests.sol b/assets/eip-draft_builder_requests/builder_requests.sol index 44869982147c24..bfeef13dfd9c29 100644 --- a/assets/eip-draft_builder_requests/builder_requests.sol +++ b/assets/eip-draft_builder_requests/builder_requests.sol @@ -1,68 +1,42 @@ // SPDX-License-Identifier: CC0-1.0 pragma solidity 0.6.11; -pragma experimental ABIEncoderV2; // for `Fp` / `Fp2` struct calldata in `deposit` // ─────────────────────────────────────────────────────────────────────────────── // EIP-XXXX: Builder Execution Requests // -// Three EIP-7685 request predeploys for the EIP-7732 builder population, -// modelled on the EIP-7002 (withdrawals) / EIP-7251 (consolidations) "request -// bus": +// Two EIP-7685 request predeploys for the EIP-7732 builder population, modelled +// on the EIP-7002 (withdrawals) / EIP-7251 (consolidations) "request bus": // -// * BuilderDepositContract @ BUILDER_DEPOSIT_CONTRACT_ADDRESS (request type 0x03) -// deposit(pubkey, wc, amount_gwei, signature, pubkey_y, signature_y) — -// verifies the BLS proof-of-possession on chain via the EIP-2537 -// precompiles, then appends a deposit record to the in-state request queue. +// * BuilderDepositContract @ BUILDER_DEPOSIT_CONTRACT_ADDRESS (request type 0x03) +// deposit(pubkey, withdrawal_credentials, amount_gwei, signature) — appends +// a deposit record to the in-state request queue. Serves BOTH first +// deposits and top-ups: the consensus layer registers a builder on a +// pubkey's first appearance (verifying the proof-of-possession) and credits +// additional stake on later deposits to an existing builder, exactly as the +// validator deposit contract does. The BLS signature is carried in the +// record and verified by the consensus layer on dequeue. // -// * BuilderTopUpContract @ BUILDER_TOPUP_CONTRACT_ADDRESS (request type 0x04) -// top_up(pubkey, amount_gwei) — unverified additional stake for an -// already-registered builder; appends a top-up record to its queue. The -// consensus layer rejects top-ups whose `pubkey` is not in the builder set. +// * BuilderExitContract @ BUILDER_EXIT_CONTRACT_ADDRESS (request type 0x04) +// exit(pubkey) — full exit of a builder, authorized by the caller being the +// builder's execution_address (recorded as source_address). No signature, +// no staked value — only the fee. // -// * BuilderWithdrawalContract @ BUILDER_WITHDRAWAL_CONTRACT_ADDRESS (request type 0x05) -// withdraw(pubkey, amount_gwei) — a semantic clone of the EIP-7002 -// withdrawal predeploy, retargeted at the builder set. The builder's -// execution_address authorizes the request simply by being `msg.sender`, -// so there is no BLS check and no staked value — only the fee. An -// amount_gwei of 0 is a full exit, any amount_gwei > 0 a partial -// withdrawal. The consensus layer ignores records whose recorded -// source_address is not the target builder's execution_address. +// Neither contract emits logs; both are thin queues over +// the shared `RequestQueue` base. A user call appends a record; at the end of the +// block a `SYSTEM_ADDRESS` call with empty calldata pops up to +// MAX_REQUESTS_PER_BLOCK records and returns them as the flat `request_data` for +// that predeploy's request type. The execution layer prepends the type byte and +// commits the result in the block `requests_hash` (EIP-7685). Each is a standard +// single-type request predeploy — exactly the withdrawals/consolidations model. // -// None of the contracts emit logs. All three share the `RequestQueue` base: a -// user call appends a record; at the end of the block a `SYSTEM_ADDRESS` call -// with empty calldata pops up to MAX_REQUESTS_PER_BLOCK records and returns them -// as the flat `request_data` for that predeploy's request type. The execution -// layer prepends the type byte and commits the result in the block -// `requests_hash` (EIP-7685). Each contract is a standard single-type request -// predeploy, so the EL needs no new read semantics — exactly the -// withdrawals/consolidations model. -// -// Anti-spam has two layers: every request carries the same EIP-1559-style -// request fee as EIP-7002/7251 (see RequestQueue), and deposits/top-ups -// additionally lock their staked value (>= 1 ETH), with a deposit also paying -// for gas-metered BLS verification. Withdrawals/exits move no ETH on this layer, -// so the fee alone meters them, exactly as in EIP-7002. -// -// Algorithms used (BuilderDepositContract): -// * Signing root — SSZ `hash_tree_root` of `DepositMessage` mixed with -// `DOMAIN_BUILDER_DEPOSIT` per `compute_signing_root`. -// * Hash-to-curve — `expand_message_xmd` + SSWU/3-isogeny via EIP-2537 -// `MAP_FP2_TO_G2`, per IETF RFC 9380. -// * Pairing check — Negation trick: verify e(-G1, σ) · e(pk, H(m)) == 1 -// via EIP-2537 `PAIRING_CHECK` (subgroup-checked). -// * Fp reduction — `MODEXP` precompile (0x05) with exponent 1. -// -// Design notes: -// * Callers supply affine Y coordinates; there is no on-chain decompression -// or Fp/Fp2 arithmetic kernel. The supplied Y is bound to the compressed -// sign bit (see `_constructG1`/`_constructG2`), and a builder-specific -// signing domain prevents cross-context replay with validator deposits. -// * BLS verification gates entry into the deposit queue, so dequeued records -// are pre-verified and carry no signature (the CL trusts the EL check). +// Anti-spam is the EIP-1559-style request fee (see RequestQueue) plus, for +// deposits, the staked value (>= 1 ETH, locked and forfeited if the consensus +// layer's proof-of-possession check fails). The per-block cap bounds the +// consensus-layer verification work to MAX_REQUESTS_PER_BLOCK records per block. // ─────────────────────────────────────────────────────────────────────────────── -// EIP-7002 / EIP-7251 style request bus shared by all three builder predeploys. +// EIP-7002 / EIP-7251 style request bus shared by both builder predeploys. // // A user call appends an opaque record (and increments the per-block request // count). The end-of-block `SYSTEM_ADDRESS` system call drains up to @@ -154,9 +128,9 @@ contract RequestQueue { return output / denominator; } - // Append a request record and count it toward this block's demand. Called - // by the derived entrypoint after it has validated (and, for deposits, - // BLS-verified) the request and confirmed the fee was paid. + // Append a request record and count it toward this block's demand. Called by + // the derived entrypoint after it has validated the request and confirmed the + // fee was paid. function _recordRequest(bytes memory record) internal { queue[queueTail] = record; queueTail += 1; @@ -179,7 +153,7 @@ contract RequestQueue { // * any other caller: fee getter — return the current `_getFee()`. fallback() external { // Only the canonical empty-calldata call reaches the fallback meaningfully - // (the system read-out, or a fee query) — `deposit`/`top_up` have their own + // (the system read-out, or a fee query) — `deposit`/`exit` have their own // selectors. Reject any other calldata, as EIP-7002 does (it only treats // zero-length input as the fee getter). require(msg.data.length == 0, "RequestQueue: unexpected calldata"); @@ -235,557 +209,74 @@ contract RequestQueue { } } +// ─────────────────────────────────────────────────────────────────────────────── +// Builder deposit predeploy — EIP-7685 request type 0x03, deployed at +// BUILDER_DEPOSIT_CONTRACT_ADDRESS. Serves both first deposits and top-ups. +// +// `deposit(...)` appends pubkey (48) ++ withdrawal_credentials (32) ++ +// amount_gwei (8, LE) ++ signature (96) = 184 bytes. The consensus layer verifies +// the BLS proof-of-possession on dequeue, but only on a pubkey's first +// appearance; a later deposit to an existing builder is a stake top-up, and the +// consensus layer ignores its `withdrawal_credentials` and `signature`. +// ─────────────────────────────────────────────────────────────────────────────── contract BuilderDepositContract is RequestQueue { + uint constant PUBLIC_KEY_LENGTH = 48; + uint constant SIGNATURE_LENGTH = 96; - // ── Constants ────────────────────────────────────────────────────────── - - uint constant PUBLIC_KEY_LENGTH = 48; - uint constant SIGNATURE_LENGTH = 96; - - // EIP-7732 sets the builder minimum stake at 1 ETH. The contract enforces - // the same lower bound at the EL boundary so junk-amount transactions are - // rejected before they reach the consensus layer. + // EIP-7732 sets the builder minimum stake at 1 ETH; enforced at the EL + // boundary so junk-amount transactions are rejected before the consensus layer. uint constant BUILDER_MIN_DEPOSIT = 1 ether; - // EIP-2537 precompile addresses. - uint8 constant BLS12_G2ADD = 0x0d; - uint8 constant BLS12_PAIRING_CHECK = 0x0f; - uint8 constant BLS12_MAP_FP2_TO_G2 = 0x11; - // Pre-existing modexp precompile (used for hash_to_field's modular reduction). - uint8 constant MOD_EXP_PRECOMPILE = 0x05; - - // Gas forwarded to each precompile staticcall. Per EIP-2537 §"Gas burning - // on error", an EIP-2537 precompile that rejects its input (malformed - // encoding, off-curve, or wrong-subgroup point) burns ALL gas forwarded to - // the call. Forwarding `gas()` would therefore let a single malformed point - // drain a whole transaction. Because EIP-2537 pricing is deterministic - // (a pure function of input length, no data-dependent loops), we instead - // forward a fixed ceiling per precompile, bounding the worst-case burn on a - // bad input to that ceiling. Each ceiling is ~2.5x the documented cost, a - // margin chosen to tolerate a moderate future reprice while still capping - // the loss far below a full transaction. - // - // precompile documented cost ceiling - // MAP_FP2_TO_G2 23800 60000 - // G2ADD 600 2000 - // PAIRING_CHECK 32600*k + 37700 256000 (k = 2 -> 102900) - // MODEXP (0x05) ~200 for our inputs 5000 (does not burn-all, - // capped for uniformity) - uint constant MAP_FP2_TO_G2_GAS = 60000; - uint constant G2ADD_GAS = 2000; - uint constant PAIRING_CHECK_GAS = 256000; - uint constant MODEXP_GAS = 5000; - - // Canonical Eth2 BLS ciphersuite, used directly as the DST for - // `expand_message_xmd` per IETF draft-irtf-cfrg-bls-signature-04. - string constant BLS_SIG_DST = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_"; - - // Mask used to clear the three flag bits from the top byte of a compressed - // BLS12-381 point's X coordinate. - bytes1 constant BLS_BYTE_WITHOUT_FLAGS_MASK = bytes1(0x1f); - - // (q - 1) / 2 in the (hi:128, lo:256) Fp packing — the IETF sign-bit - // threshold for a field element: sign(y) == 1 iff y > (q-1)/2. Used to - // bind the caller-supplied affine Y to the compressed sign flag. - uint constant Q_MINUS_1_OVER_2_HI = 0x0d0088f51cbff34d258dd3db21a5d66b; - uint constant Q_MINUS_1_OVER_2_LO = 0xb23ba5c279c2895fb39869507b587b120f55ffff58a9ffffdcff7fffffffd555; - - // Builder-deposit signing domain. Distinct from the validator deposit - // domain (0x03000000…) so a proof-of-possession signature is NOT - // interchangeable between this contract and the validator deposit contract - // at 0x00000000219ab540356cbb839cbe05303d7705fa — without this separation a - // public validator-deposit signature could be replayed here to force-enrol - // a validator pubkey as a builder (and vice versa). - // - // Constructed as compute_domain(DOMAIN_BUILDER_DEPOSIT_TYPE, - // fork_version=GENESIS_FORK_VERSION=0x00000000, genesis_validators_root=0) - // = DOMAIN_BUILDER_DEPOSIT_TYPE || sha256(64 zero bytes)[:28]. - // - // DRAFT NOTE [EIP-XXXX]: the 4-byte domain type 0x0b000000 is a PLACEHOLDER. - // The final value MUST be allocated in consensus-specs and MUST differ from - // DOMAIN_DEPOSIT (0x03000000). - bytes32 constant DOMAIN_BUILDER_DEPOSIT = 0x0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9; - - // ── Structs (EIP-2537 encoding) ──────────────────────────────────────── - - // Fp: a base-field element in the EIP-2537 64-byte encoding, with the 16 - // zero pad-bytes folded into the top of `a`. - struct Fp { uint a; uint b; } - - // Fp2 = a + b·u with u² = -1. - struct Fp2 { Fp a; Fp b; } - - // Point on BLS12-381 over Fp. - struct G1Point { Fp X; Fp Y; } - - // Point on BLS12-381 over Fp2. - struct G2Point { Fp2 X; Fp2 Y; } - - // ── Request record ───────────────────────────────────────────────────── - // - // EIP-7685 `request_data` for a builder deposit (request type 0x03) is the - // concatenation of one record per dequeued deposit: - // - // pubkey (48) ++ withdrawal_credentials (32) ++ amount_gwei (8, LE) = 88 bytes - // - // The signature is intentionally absent: it was verified at submission, so - // the consensus layer trusts the record without re-pairing. - - // ── External entrypoint ──────────────────────────────────────────────── - - /// @notice BLS-verified builder deposit. On success, appends a deposit + /// @notice Builder deposit (also serves as top-up). On success, appends a /// record to the request queue (no log). `amount_gwei` is the stake to - /// credit and is the amount bound into the signed `DepositMessage`; the - /// caller MUST send `msg.value >= amount_gwei * 1 gwei + fee`, where `fee` - /// is the current request fee (call this contract with empty calldata to - /// read it). The staked ETH is locked in the contract; the consensus layer - /// credits the builder `amount_gwei` from the dequeued request. + /// credit; the caller MUST send `msg.value >= amount_gwei * 1 gwei + fee`, + /// where `fee` is the current request fee (read it by calling this contract + /// with empty calldata). Any value beyond the stake (the fee, plus any + /// overpayment) is retained by the contract. The staked ETH is locked; the + /// consensus layer credits the builder from the dequeued record — registering + /// it on the pubkey's first appearance after verifying `signature`, or + /// crediting stake to an existing builder (in which case it ignores + /// `withdrawal_credentials` and `signature`). function deposit( bytes calldata pubkey, bytes32 withdrawal_credentials, uint64 amount_gwei, - bytes calldata signature, - Fp calldata pubkey_y, - Fp2 calldata signature_y + bytes calldata signature ) external payable { - require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderDeposit: invalid pubkey length"); - require(signature.length == SIGNATURE_LENGTH, "BuilderDeposit: invalid signature length"); + require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderDeposit: invalid pubkey length"); + require(signature.length == SIGNATURE_LENGTH, "BuilderDeposit: invalid signature length"); uint stake = uint(amount_gwei) * 1 gwei; - require(stake >= BUILDER_MIN_DEPOSIT, "BuilderDeposit: deposit value too low"); - // Fee is charged on top of the stake; overpayment of the fee is - // forfeited, as in EIP-7002. - require(msg.value >= stake + _getFee(), "BuilderDeposit: insufficient value for stake + fee"); - require(!_isInfinityFlagSet(pubkey[0]), "BuilderDeposit: infinity pubkey"); - require(!_isInfinityFlagSet(signature[0]), "BuilderDeposit: infinity signature"); - - // BLS proof-of-possession check. Performed before the record is queued - // so an invalid signature reverts the whole call and never enqueues. - // The amount is not part of the signed message (see - // `_computeDepositSigningRoot`); it is recorded below as the credited stake. - bytes32 signingRoot = _computeDepositSigningRoot(pubkey, withdrawal_credentials); - G1Point memory pk = _constructG1(pubkey, pubkey_y); - G2Point memory sig = _constructG2(signature, signature_y); - G2Point memory msgPoint = _hashToCurve(signingRoot); - require( - _blsPairingCheck(pk, msgPoint, sig), - "BuilderDeposit: invalid BLS signature" - ); + require(stake >= BUILDER_MIN_DEPOSIT, "BuilderDeposit: deposit value too low"); + require(msg.value >= stake + _getFee(), "BuilderDeposit: insufficient value for stake + fee"); _recordRequest(abi.encodePacked( - pubkey, withdrawal_credentials, _le64(amount_gwei) + pubkey, withdrawal_credentials, _le64(amount_gwei), signature )); } - - // ── Signing-root computation ─────────────────────────────────────────── - - // Algorithm: SSZ `hash_tree_root` (consensus-specs §SSZ Merkleization) + - // `compute_signing_root` (consensus-specs §Beacon-chain helpers). - // - // The builder deposit message is the 2-field container - // `(pubkey, withdrawal_credentials)` — the amount is deliberately NOT - // signed (the unverified `top_up` already lets stake be added without a - // signature, so binding it here would protect nothing). The signature is a - // proof of possession that binds only the key and the withdrawal target. - // Returns `sha256(hash_tree_root(pubkey, withdrawal_credentials) || DOMAIN_BUILDER_DEPOSIT)`. - function _computeDepositSigningRoot( - bytes memory pubkey, - bytes32 withdrawal_credentials - ) internal pure returns (bytes32) { - // `pubkey` is 48 bytes; pad to 64 bytes and sha256 to get its SSZ root. - bytes memory paddedPubkey = new bytes(64); - for (uint i = 0; i < PUBLIC_KEY_LENGTH; i++) { - paddedPubkey[i] = pubkey[i]; - } - bytes32 pubkeyRoot = sha256(paddedPubkey); - - // hash_tree_root of the 2-field container = sha256(field0 || field1): - // sha256(pubkey_root || withdrawal_credentials). - bytes32 messageRoot = sha256(abi.encodePacked(pubkeyRoot, withdrawal_credentials)); - - return sha256(abi.encodePacked(messageRoot, DOMAIN_BUILDER_DEPOSIT)); - } - - // ── hash_to_curve (BLS12-381 G2) ─────────────────────────────────────── - - // Algorithm: `expand_message_xmd` (RFC 9380 §5.3.1) with SHA-256, producing - // 256 output bytes. Layout matches draft-irtf-cfrg-hash-to-curve-16. - function _expandMessage(bytes32 message) internal pure returns (bytes memory) { - // b0 = sha256(Z_pad(64) || message(32) || lib_str(2)=0x0100 || - // I2OSP(0,1) || DST || DST_len(1)). - // Lengths: 64 + 32 + 2 + 1 + 43 + 1 = 143 bytes. - bytes memory b0Input = new bytes(143); - for (uint i = 0; i < 32; i++) { - b0Input[i + 64] = message[i]; - } - b0Input[96] = 0x01; - for (uint i = 0; i < 43; i++) { - b0Input[i + 99] = bytes(BLS_SIG_DST)[i]; - } - b0Input[142] = bytes1(uint8(43)); - bytes32 b0 = sha256(b0Input); - - // b1..b8: 8 chained sha256 invocations yielding 256 output bytes. - bytes memory output = new bytes(256); - bytes32 chunk = sha256(abi.encodePacked(b0, bytes1(uint8(1)), bytes(BLS_SIG_DST), bytes1(uint8(43)))); - assembly { - mstore(add(output, 0x20), chunk) - } - for (uint i = 2; i < 9; i++) { - bytes32 input; - assembly { - input := xor(b0, mload(add(output, add(0x20, mul(0x20, sub(i, 2)))))) - } - chunk = sha256(abi.encodePacked(input, bytes1(uint8(i)), bytes(BLS_SIG_DST), bytes1(uint8(43)))); - assembly { - mstore(add(output, add(0x20, mul(0x20, sub(i, 1)))), chunk) - } - } - return output; - } - - // Algorithm: `hash_to_field` (RFC 9380 §5.2) producing 2 Fp2 elements by - // reducing 64-byte slices of `expand_message_xmd` output mod q. - function _hashToField(bytes32 message) internal view returns (Fp2[2] memory result) { - bytes memory expanded = _expandMessage(message); - result[0] = Fp2( - _convertSliceToFp(expanded, 0, 64), - _convertSliceToFp(expanded, 64, 128) - ); - result[1] = Fp2( - _convertSliceToFp(expanded, 128, 192), - _convertSliceToFp(expanded, 192, 256) - ); - } - - // Algorithm: `hash_to_curve` (RFC 9380 §3, encode_to_curve for G2): two - // `hash_to_field` outputs each mapped to G2 via SSWU + 3-isogeny (via - // EIP-2537 `MAP_FP2_TO_G2`), summed in G2. - function _hashToCurve(bytes32 message) internal view returns (G2Point memory) { - Fp2[2] memory uvals = _hashToField(message); - G2Point memory p0 = _mapToCurveG2(uvals[0]); - G2Point memory p1 = _mapToCurveG2(uvals[1]); - return _addG2(p0, p1); - } - - // ── Field-arithmetic helper (modexp-based reduction) ─────────────────── - - // Reduce data[start:end] mod q via the MODEXP precompile (exponent 1). - // Returns a 48-byte big-endian result. - function _reduceModulo(bytes memory data, uint start, uint end) internal view returns (bytes memory) { - uint length = end - start; - require(length <= data.length, "BuilderDeposit: slice out of range"); - bytes memory result = new bytes(48); - bool success; - assembly { - let p := mload(0x40) - mstore(p, length) // length of base - mstore(add(p, 0x20), 0x20) // length of exponent - mstore(add(p, 0x40), 48) // length of modulus - // base - let ctr := length - let src := add(add(data, 0x20), start) - let dst := add(p, 0x60) - for { } or(gt(ctr, 0x20), eq(ctr, 0x20)) { ctr := sub(ctr, 0x20) } { - mstore(dst, mload(src)) - dst := add(dst, 0x20) - src := add(src, 0x20) - } - let mask := sub(exp(256, sub(0x20, ctr)), 1) - let srcpart := and(mload(src), not(mask)) - let dstpart := and(mload(dst), mask) - mstore(dst, or(dstpart, srcpart)) - // exponent: 1 (identity exponent — we only need a mod reduction) - mstore(add(p, add(0x60, length)), 1) - // modulus q (high 16 bytes ORed in, low 32 bytes as a full word) - let modulusAddr := add(p, add(0x60, add(0x10, length))) - mstore(modulusAddr, or(mload(modulusAddr), 0x1a0111ea397fe69a4b1ba7b6434bacd7)) - mstore(add(p, add(0x90, length)), 0x64774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab) - success := staticcall(MODEXP_GAS, MOD_EXP_PRECOMPILE, p, add(0xB0, length), add(result, 0x20), 48) - switch success case 0 { revert(0, 0) } - } - require(success, "BuilderDeposit: modexp failed"); - return result; - } - - function _convertSliceToFp(bytes memory data, uint start, uint end) internal view returns (Fp memory) { - bytes memory fe = _reduceModulo(data, start, end); - return Fp(_sliceToUint(fe, 0, 16), _sliceToUint(fe, 16, 48)); - } - - function _sliceToUint(bytes memory data, uint start, uint end) internal pure returns (uint result) { - uint length = end - start; - require(length <= 32, "BuilderDeposit: bad slice"); - for (uint i = 0; i < length; i++) { - result = result + (uint8(data[start + i]) * (2 ** (8 * (length - i - 1)))); - } - } - - // ── EIP-2537 precompile wrappers ─────────────────────────────────────── - - // Algorithm: simplified SWU map for BLS12-381 G2 (Wahby–Boneh 2019) - // composed with the 3-isogeny back to G2, delegated to the EIP-2537 - // `MAP_FP2_TO_G2` precompile (address 0x11). - function _mapToCurveG2(Fp2 memory fe) internal view returns (G2Point memory) { - uint[4] memory input = [fe.a.a, fe.a.b, fe.b.a, fe.b.b]; - uint[8] memory output; - bool success; - assembly { - success := staticcall(MAP_FP2_TO_G2_GAS, BLS12_MAP_FP2_TO_G2, input, 128, output, 256) - switch success case 0 { revert(0, 0) } - } - require(success, "BuilderDeposit: map_fp2_to_g2 failed"); - return G2Point( - Fp2(Fp(output[0], output[1]), Fp(output[2], output[3])), - Fp2(Fp(output[4], output[5]), Fp(output[6], output[7])) - ); - } - - // Algorithm: BLS12-381 G2 point addition, delegated to EIP-2537 `G2ADD` - // (0x0d). - function _addG2(G2Point memory a, G2Point memory b) internal view returns (G2Point memory) { - uint[16] memory input = [ - a.X.a.a, a.X.a.b, a.X.b.a, a.X.b.b, a.Y.a.a, a.Y.a.b, a.Y.b.a, a.Y.b.b, - b.X.a.a, b.X.a.b, b.X.b.a, b.X.b.b, b.Y.a.a, b.Y.a.b, b.Y.b.a, b.Y.b.b - ]; - uint[8] memory output; - bool success; - assembly { - success := staticcall(G2ADD_GAS, BLS12_G2ADD, input, 512, output, 256) - switch success case 0 { revert(0, 0) } - } - require(success, "BuilderDeposit: g2_add failed"); - return G2Point( - Fp2(Fp(output[0], output[1]), Fp(output[2], output[3])), - Fp2(Fp(output[4], output[5]), Fp(output[6], output[7])) - ); - } - - // Algorithm: BLS verification via the "fixed-(-G1)" pairing identity - // (Boneh–Lynn–Shacham 2001, §3): instead of testing - // e(pk, H(m)) == e(G1, σ), - // we test - // e(-G1, σ) · e(pk, H(m)) == 1, - // which is a single multi-pairing call. Delegated to EIP-2537 - // `PAIRING_CHECK` (0x0f), which internally performs G1 and G2 subgroup - // checks and returns 0/1. - function _blsPairingCheck(G1Point memory pk, G2Point memory msgPoint, G2Point memory sig) - internal - view - returns (bool) - { - uint[24] memory input; - - input[0] = pk.X.a; - input[1] = pk.X.b; - input[2] = pk.Y.a; - input[3] = pk.Y.b; - - input[4] = msgPoint.X.a.a; - input[5] = msgPoint.X.a.b; - input[6] = msgPoint.X.b.a; - input[7] = msgPoint.X.b.b; - input[8] = msgPoint.Y.a.a; - input[9] = msgPoint.Y.a.b; - input[10] = msgPoint.Y.b.a; - input[11] = msgPoint.Y.b.b; - - // -G1 = negation of the BLS12-381 G1 generator, in EIP-2537 encoding. - input[12] = 31827880280837800241567138048534752271; - input[13] = 88385725958748408079899006800036250932223001591707578097800747617502997169851; - input[14] = 22997279242622214937712647648895181298; - input[15] = 46816884707101390882112958134453447585552332943769894357249934112654335001290; - - input[16] = sig.X.a.a; - input[17] = sig.X.a.b; - input[18] = sig.X.b.a; - input[19] = sig.X.b.b; - input[20] = sig.Y.a.a; - input[21] = sig.Y.a.b; - input[22] = sig.Y.b.a; - input[23] = sig.Y.b.b; - - uint[1] memory output; - bool success; - assembly { - success := staticcall(PAIRING_CHECK_GAS, BLS12_PAIRING_CHECK, input, 768, output, 32) - switch success case 0 { revert(0, 0) } - } - require(success, "BuilderDeposit: pairing_check failed"); - return output[0] == 1; - } - - // ── Compressed-point construction (caller-supplied Y) ────────────────── - - // Parse a 48-byte compressed G1 X coordinate and pair it with the caller- - // supplied Y. - // - // The supplied Y MUST agree with the compressed sign flag. This binds the - // point used in the pairing check to the encoding that is emitted (and that - // the consensus layer decompresses): without it, a caller controlling its - // own key could verify (X, +Y) while the emitted bytes decompress to - // (X, -Y), so the consensus layer would register a key whose proof-of- - // possession was never actually verified. The pairing check alone does NOT - // catch this, because the depositor jointly chooses the key, the emitted - // sign bit, and the signature, keeping the pairing self-consistent. - function _constructG1(bytes memory compressed, Fp memory y) internal pure returns (G1Point memory) { - require( - _fpSignBit(y) == _isSignFlagSet(compressed[0]), - "BuilderDeposit: pubkey Y sign mismatch" - ); - bytes memory rawX = _stripFlagBits(compressed); - Fp memory X = Fp(_sliceToUint(rawX, 0, 16), _sliceToUint(rawX, 16, 48)); - return G1Point(X, y); - } - - // Parse a 96-byte compressed G2 X coordinate and pair it with the caller- - // supplied Y. BLS12-381 compressed G2 places the imaginary Fp coefficient - // first (bytes [0..48]) and the real Fp coefficient second (bytes [48..96]). - // As in `_constructG1`, the supplied Y MUST agree with the compressed sign - // flag so the verified point binds to the emitted encoding. - function _constructG2(bytes memory compressed, Fp2 memory y) internal pure returns (G2Point memory) { - require( - _fp2SignBit(y) == _isSignFlagSet(compressed[0]), - "BuilderDeposit: signature Y sign mismatch" - ); - bytes memory rawX = _stripFlagBits(compressed); - uint bA = _sliceToUint(rawX, 0, 16); - uint bB = _sliceToUint(rawX, 16, 48); - uint aA = _sliceToUint(rawX, 48, 64); - uint aB = _sliceToUint(rawX, 64, 96); - Fp2 memory X = Fp2(Fp(aA, aB), Fp(bA, bB)); - return G2Point(X, y); - } - - // ── Compressed-encoding flag handling ────────────────────────────────── - - // Algorithm: ZCash-style BLS12-381 serialization flag bits (also adopted - // by IETF draft-irtf-cfrg-bls-signature Appendix A). The first byte - // carries three flags in its top three bits — [compressed (0x80)] - // [infinity (0x40)][sign (0x20)] — and five bits of X-coordinate payload. - // We reject infinity-flagged inputs (the identity element is never a valid - // pubkey or signature) and bind the sign flag to the caller-supplied Y - // (see `_constructG1` / `_constructG2`). - function _isInfinityFlagSet(bytes1 b) internal pure returns (bool) { - return (uint8(b) & 0x40) != 0; - } - - function _isSignFlagSet(bytes1 b) internal pure returns (bool) { - return (uint8(b) & 0x20) != 0; - } - - function _stripFlagBits(bytes memory enc) internal pure returns (bytes memory) { - bytes memory copyOf = new bytes(enc.length); - for (uint i = 0; i < enc.length; i++) { - copyOf[i] = enc[i]; - } - copyOf[0] = copyOf[0] & BLS_BYTE_WITHOUT_FLAGS_MASK; - return copyOf; - } - - // ── Sign bit of an affine Y coordinate ───────────────────────────────── - // - // The IETF "sign" of a field element y is 1 iff y > (q-1)/2. For Fp2, the - // sign is that of the imaginary coefficient if non-zero, else that of the - // real coefficient. These match the conventions used by the BLS12-381 - // (de)compression routines the consensus layer applies to the emitted - // encoding. - - function _fpIsZero(Fp memory x) internal pure returns (bool) { - return x.a == 0 && x.b == 0; - } - - function _fpSignBit(Fp memory y) internal pure returns (bool) { - if (y.a > Q_MINUS_1_OVER_2_HI) return true; - if (y.a < Q_MINUS_1_OVER_2_HI) return false; - return y.b > Q_MINUS_1_OVER_2_LO; - } - - function _fp2SignBit(Fp2 memory y) internal pure returns (bool) { - // Fp2 is `a + b·u`; `.a` is the real coefficient, `.b` the imaginary. - if (!_fpIsZero(y.b)) return _fpSignBit(y.b); - return _fpSignBit(y.a); - } } // ─────────────────────────────────────────────────────────────────────────────── -// Builder top-up predeploy — EIP-7685 request type 0x04, deployed at -// BUILDER_TOPUP_CONTRACT_ADDRESS. -// -// Unverified: adds stake to an already-registered builder. There is no BLS -// check (a top-up does not register a new key), so this contract carries none -// of the cryptographic machinery — just the shared request queue. The consensus -// layer MUST reject top-ups whose pubkey is not already in the builder set. -// -// No `withdrawal_credentials`: a top-up only adds stake to an existing builder, -// whose credentials are fixed by its verified deposit. Omitting the field -// denies an unauthenticated caller any influence over a builder's withdrawal -// target. -// -// EIP-7685 `request_data` is the concatenation of one record per dequeued -// top-up: pubkey (48) ++ amount_gwei (8, LE) = 56 bytes. -// ─────────────────────────────────────────────────────────────────────────────── -contract BuilderTopUpContract is RequestQueue { - uint constant PUBLIC_KEY_LENGTH = 48; - uint constant BUILDER_MIN_DEPOSIT = 1 ether; - - /// @notice Unverified top-up. On success, appends a top-up record to the - /// request queue (no log). `amount_gwei` is the stake to add; the caller - /// MUST send `msg.value >= amount_gwei * 1 gwei + fee`, where `fee` is the - /// current request fee (read it by calling this contract with empty - /// calldata). The ETH is locked in the contract; the consensus layer - /// credits the existing builder from the dequeued request. - function top_up(bytes calldata pubkey, uint64 amount_gwei) external payable { - require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderTopUp: invalid pubkey length"); - uint stake = uint(amount_gwei) * 1 gwei; - require(stake >= BUILDER_MIN_DEPOSIT, "BuilderTopUp: deposit value too low"); - require(msg.value >= stake + _getFee(), "BuilderTopUp: insufficient value for stake + fee"); - - _recordRequest(abi.encodePacked(pubkey, _le64(amount_gwei))); - } -} - -// ─────────────────────────────────────────────────────────────────────────────── -// Builder withdrawal / exit predeploy — EIP-7685 request type 0x05, deployed at -// BUILDER_WITHDRAWAL_CONTRACT_ADDRESS. -// -// A semantic clone of the EIP-7002 withdrawal-request predeploy, retargeted at -// the EIP-7732 builder set. A builder's `execution_address` (the 0x03 builder -// withdrawal credential) authorizes a request simply by being `msg.sender` — -// exactly as EIP-7002's 0x01 credential does — so this contract needs NO BLS -// verification and locks NO stake: unlike a deposit or top-up, a withdrawal -// moves no ETH on the execution layer, and the caller sends only the request -// fee. `amount_gwei == 0` requests a full exit (the "voluntary exit"); any -// `amount_gwei > 0` requests a partial withdrawal of that many gwei. The -// consensus layer interprets the amount-zero sentinel exactly as it does for -// validators under EIP-7002. +// Builder exit predeploy — EIP-7685 request type 0x04, deployed at +// BUILDER_EXIT_CONTRACT_ADDRESS. // -// EIP-7685 `request_data` is the concatenation of one record per dequeued -// request: -// source_address (20) ++ pubkey (48) ++ amount_gwei (8, LE) = 76 bytes, -// identical in shape to EIP-7002's `ValidatorWithdrawalRequest`. As with the -// sibling builder predeploys this contract emits no logs (EIP-7002 emits a -// log0; the request bus does not need it). The consensus layer MUST ignore a -// record whose `source_address` does not match the target builder's -// `execution_address`, so a third party cannot withdraw or exit a builder it -// does not control. +// `exit(pubkey)` appends source_address (20) ++ pubkey (48) = 68 bytes, where +// source_address is `msg.sender`. The builder's execution_address authorizes the +// exit simply by being the caller; the consensus layer honours the record only +// when `source_address` equals the target builder's `execution_address`. No +// signature, no staked value — only the request fee. // ─────────────────────────────────────────────────────────────────────────────── -contract BuilderWithdrawalContract is RequestQueue { +contract BuilderExitContract is RequestQueue { uint constant PUBLIC_KEY_LENGTH = 48; - /// @notice Builder withdrawal / exit request. On success, appends a record - /// to the request queue (no log). `amount_gwei == 0` requests a full exit; - /// any `amount_gwei > 0` requests a partial withdrawal of that many gwei - /// from the builder's beacon-chain balance. Unlike `deposit`/`top_up` this - /// moves no ETH on the execution layer: the caller sends only - /// `msg.value >= fee`, where `fee` is the current request fee (read it by - /// calling this contract with empty calldata). The record's `source_address` - /// is `msg.sender`; the consensus layer honours the request only if it - /// equals the target builder's `execution_address`. There is intentionally - /// no minimum-amount check — `0` is the exit sentinel, mirroring EIP-7002. - function withdraw(bytes calldata pubkey, uint64 amount_gwei) external payable { - require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderWithdrawal: invalid pubkey length"); - require(msg.value >= _getFee(), "BuilderWithdrawal: insufficient value for fee"); + /// @notice Builder full exit. On success, appends a record to the request + /// queue (no log). The caller MUST send `msg.value >= fee` (read the fee by + /// calling this contract with empty calldata); no stake is moved. The record + /// is `msg.sender ++ pubkey`; the consensus layer initiates the builder's + /// exit only when the recorded `source_address` equals its `execution_address`. + function exit(bytes calldata pubkey) external payable { + require(pubkey.length == PUBLIC_KEY_LENGTH, "BuilderExit: invalid pubkey length"); + require(msg.value >= _getFee(), "BuilderExit: insufficient value for fee"); - _recordRequest(abi.encodePacked(msg.sender, pubkey, _le64(amount_gwei))); + _recordRequest(abi.encodePacked(msg.sender, pubkey)); } } diff --git a/assets/eip-draft_builder_requests/foundry.toml b/assets/eip-draft_builder_requests/foundry.toml index 3f5aadf687da0f..0d1cdca5447ee1 100644 --- a/assets/eip-draft_builder_requests/foundry.toml +++ b/assets/eip-draft_builder_requests/foundry.toml @@ -8,11 +8,11 @@ test = "test" out = "out" cache_path = "cache" -# Modexp-only tests work on any post-Byzantium EVM. The full BLS verification -# tests (testVerifyDeposit*) require EIP-2537 precompiles, which were -# activated in Prague. Override on the command line for older targets: -# forge test --evm-version cancun --no-match-test VerifyDeposit -evm_version = "prague" +# The contracts use only basic EVM features (no precompiles). `istanbul` is the +# newest target solc 0.6.11 supports; its opcode set is a subset of later forks, +# so the bytecode runs unchanged on Prague and beyond. (The previous `prague` +# setting was silently downgraded to istanbul, since 0.6.11 predates it.) +evm_version = "istanbul" # `bytecode_hash = "none"` makes the runtime byte string deterministic # across rebuilds — useful when comparing against the canonical bytecode. diff --git a/assets/eip-draft_builder_requests/gen_vectors.py b/assets/eip-draft_builder_requests/gen_vectors.py deleted file mode 100644 index f66924e104952d..00000000000000 --- a/assets/eip-draft_builder_requests/gen_vectors.py +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env python3 -""" -Generate cross-verification vectors for the BuilderDepositContract Foundry tests. - -Uses py_ecc as the reference implementation: - * `depositCase()` — a deterministic deposit signature produced by - `py_ecc.bls.G2ProofOfPossession.Sign` (the Eth2 ciphersuite), together - with the (X, Y) affine decomposition of the resulting BLS points. - * `depositSigningRoot()` — the canonical SSZ signing root that the - `BuilderDepositContract._computeDepositSigningRoot` function must match. - -Output: a self-contained Solidity file `test/Vectors.sol` that the Foundry -test imports as constants. No JSON parsing in Solidity needed. - -Run from this directory: - /tmp/eipenv/bin/python gen_vectors.py > test/Vectors.sol -""" - -import hashlib -import sys -from py_ecc.optimized_bls12_381 import ( - G1, G2, field_modulus as Q, multiply, normalize, FQ, FQ2, -) -from py_ecc.bls.g2_primitives import ( - G1_to_pubkey, G2_to_signature, pubkey_to_G1, signature_to_G2, -) -from py_ecc.bls import G2ProofOfPossession as bls_pop - -# ── helpers ──────────────────────────────────────────────────────────────── - -def sha256(b: bytes) -> bytes: - return hashlib.sha256(b).digest() - -# Builder-deposit signing domain — distinct 4-byte domain type (0x0b000000, -# a placeholder pending consensus-specs allocation) with the same GENESIS -# fork-data suffix as the validator deposit domain. Must match -# DOMAIN_BUILDER_DEPOSIT in builder_requests.sol. Domain separation -# from the validator deposit domain (0x03000000…) prevents cross-context -# signature replay. -DOMAIN_BUILDER_DEPOSIT = bytes.fromhex( - "0b000000f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a9" -) - -def deposit_signing_root(pubkey: bytes, wc: bytes) -> bytes: - """compute_signing_root for the 2-field builder message (pubkey, wc). - - The amount is intentionally NOT signed (see builder_requests.sol): - htr = sha256(pubkey_root || wc); signing_root = sha256(htr || DOMAIN).""" - assert len(pubkey) == 48 and len(wc) == 32 - pubkey_root = sha256(pubkey + b"\x00" * 16) - msg_root = sha256(pubkey_root + wc) - return sha256(msg_root + DOMAIN_BUILDER_DEPOSIT) - -def split_fp(x: int) -> tuple: - """Pack a 384-bit Fp value into the (hi:128, lo:256) form used by the verifier.""" - x %= Q - return (x >> 256, x & ((1 << 256) - 1)) - -def sol_fp(x: int) -> str: - hi, lo = split_fp(x) - return f"BuilderDepositContract.Fp({hi:#034x}, {lo:#066x})" - -def sol_fp2(a: int, b: int) -> str: - return f"BuilderDepositContract.Fp2({sol_fp(a)}, {sol_fp(b)})" - -def sol_hex_bytes(b: bytes) -> str: - return 'hex"' + b.hex() + '"' - -# ── end-to-end deposit signature ─────────────────────────────────────────── - -def deposit_test(): - # Deterministic private key for reproducibility (NOT a real key — purely - # for round-tripping the verifier). - sk = 0x4242424242424242424242424242424242424242424242424242424242424242 - pubkey = bls_pop.SkToPk(sk) - wc = b"\x00" * 32 - amount = 32_000_000_000 # 32 ETH in gwei (credited stake; NOT signed) - sr = deposit_signing_root(pubkey, wc) - signature = bls_pop.Sign(sk, sr) - assert bls_pop.Verify(pubkey, sr, signature), "py_ecc self-verify failed" - - pk_g1 = pubkey_to_G1(pubkey) - sig_g2 = signature_to_G2(signature) - pk_x, pk_y = normalize(pk_g1) - sig_x, sig_y = normalize(sig_g2) - - # deposit_data_root: not consumed by BuilderDepositContract (it does not - # take a deposit_data_root parameter), but included so that consensus-layer - # log consumers can cross-check against the same SSZ structure used by - # the validator deposit contract. - pubkey_root = sha256(pubkey + b"\x00" * 16) - sig_root = sha256( - sha256(signature[:64]) + sha256(signature[64:] + b"\x00" * 32) - ) - amount_bytes = amount.to_bytes(8, "little") - node = sha256( - sha256(pubkey_root + wc) + sha256(amount_bytes + b"\x00" * 24 + sig_root) - ) - return { - "pubkey": pubkey, - "wc": wc, - "amount_gwei": amount, - "signature": signature, - "deposit_data_root": node, - "signing_root": sr, - "pubkey_y_a": pk_y.n, - "signature_y_a_a": sig_y.coeffs[0], - "signature_y_a_b": sig_y.coeffs[1], - } - -# ── emit Solidity ────────────────────────────────────────────────────────── - -def emit(): - out = [] - p = out.append - - p("// SPDX-License-Identifier: CC0-1.0") - p("// AUTOGENERATED by gen_vectors.py — do not edit by hand.") - p("//") - p("// Cross-verification fixtures produced from py_ecc (the canonical Eth2") - p("// Python reference implementation). Regenerate with:") - p("// /tmp/eipenv/bin/python gen_vectors.py > test/Vectors.sol") - p("pragma solidity 0.6.11;") - p("pragma experimental ABIEncoderV2;") - p("") - p('import "../builder_requests.sol";') - p("") - p("library Vectors {") - p("") - - d = deposit_test() - p(" // ── End-to-end deposit signature ──────────────────────────────────") - p(" //") - p(" // Generated from a deterministic secret key via") - p(" // py_ecc.bls.G2ProofOfPossession (the Eth2 ciphersuite).") - p("") - p(" function depositCase() internal pure") - p(" returns (") - p(" bytes memory pubkey,") - p(" bytes32 withdrawal_credentials,") - p(" bytes memory signature,") - p(" bytes32 deposit_data_root,") - p(" uint64 amount_gwei,") - p(" BuilderDepositContract.Fp memory pubkey_y,") - p(" BuilderDepositContract.Fp2 memory signature_y") - p(" )") - p(" {") - p(f" pubkey = {sol_hex_bytes(d['pubkey'])};") - p(f" withdrawal_credentials = {'0x' + d['wc'].hex()};") - p(f" signature = {sol_hex_bytes(d['signature'])};") - p(f" deposit_data_root = {'0x' + d['deposit_data_root'].hex()};") - p(f" amount_gwei = {d['amount_gwei']};") - p(f" pubkey_y = {sol_fp(d['pubkey_y_a'])};") - p(f" signature_y = {sol_fp2(d['signature_y_a_a'], d['signature_y_a_b'])};") - p(" }") - p("") - - p(" /// @notice Expected `compute_signing_root` for `depositCase()`,") - p(" /// computed in Python and baked in so the on-chain SSZ helper can be") - p(" /// cross-checked without a second BLS pairing.") - p(" function depositSigningRoot() internal pure returns (bytes32) {") - p(f" return {'0x' + d['signing_root'].hex()};") - p(" }") - p("") - p("}") - return "\n".join(out) + "\n" - -if __name__ == "__main__": - sys.stdout.write(emit()) diff --git a/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol b/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol index ca263330c1ef22..718062b3b74820 100644 --- a/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol +++ b/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol @@ -1,10 +1,8 @@ // SPDX-License-Identifier: CC0-1.0 pragma solidity 0.6.11; -pragma experimental ABIEncoderV2; import "../builder_requests.sol"; import "./TestHarness.sol"; -import "./Vectors.sol"; /// @dev Minimal subset of the Foundry cheatcode interface (avoids a forge-std /// dependency on this 0.6.11 project). @@ -13,37 +11,31 @@ interface Vm { function deal(address, uint256) external; } -/// @notice Tests for the EIP-7685 request-bus builder predeploys, including the -/// EIP-1559-style request fee. -/// -/// Expected BLS values come from py_ecc (see ../gen_vectors.py) baked into -/// ./Vectors.sol. The deposit-verification tests require the EIP-2537 BLS -/// precompiles (foundry's default Prague EVM); the queue / fee / system-read / -/// input tests do not. +/// @notice Tests for the EIP-7685 request-bus builder predeploys (deposit/top-up +/// and exit), the EIP-1559-style request fee, and the EXCESS_INHIBITOR. Neither +/// contract performs on-chain BLS verification, so no precompiles or fixtures are +/// needed — the deposit's signature is opaque calldata carried into the record +/// for the consensus layer to verify on dequeue. contract BuilderRequestsTest { Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); address constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; - uint constant DEPOSIT_RECORD_LEN = 88; // pubkey 48 + wc 32 + amount 8 - uint constant TOPUP_RECORD_LEN = 56; // pubkey 48 + amount 8 - uint constant WITHDRAWAL_RECORD_LEN = 76; // source 20 + pubkey 48 + amount 8 + uint constant DEPOSIT_RECORD_LEN = 184; // pubkey 48 + wc 32 + amount 8 + signature 96 + uint constant EXIT_RECORD_LEN = 68; // source 20 + pubkey 48 - BuilderDepositHarness internal dep; - BuilderTopUpHarness internal top; - BuilderWithdrawalHarness internal wd; + BuilderDepositHarness internal dep; + BuilderExitHarness internal ex; function setUp() public { dep = new BuilderDepositHarness(); - top = new BuilderTopUpHarness(); - wd = new BuilderWithdrawalHarness(); + ex = new BuilderExitHarness(); // Each predeploy starts with excess == EXCESS_INHIBITOR (set in the // constructor, as EIP-7002/7251 do at deployment). The activation-block // system call clears the inhibitor; run it here so the fee/queue tests // below operate on an active contract. _systemRead(address(dep)); - _systemRead(address(top)); - _systemRead(address(wd)); + _systemRead(address(ex)); } function _systemRead(address target) internal returns (bytes memory) { @@ -58,396 +50,202 @@ contract BuilderRequestsTest { for (uint i = 0; i < 8; i++) r[i] = bytes1(uint8(v >> (8 * i))); } - function _copy(bytes memory src) internal pure returns (bytes memory dst) { - dst = new bytes(src.length); - for (uint i = 0; i < src.length; i++) dst[i] = src[i]; + function _filled(uint len, uint8 seed) internal pure returns (bytes memory b) { + b = new bytes(len); + for (uint i = 0; i < len; i++) b[i] = bytes1(uint8(uint(seed) + i)); } - // ── Cross-check: SSZ signing root ────────────────────────────────────── - - function testComputeSigningRoot() public { - (bytes memory pubkey, bytes32 wc, , , , , ) = Vectors.depositCase(); - bytes32 got = dep.computeDepositSigningRoot(pubkey, wc); - require(got == Vectors.depositSigningRoot(), "signing root mismatch vs py_ecc"); - } - - // ── Happy path: deposit / top-up enqueue, system read emits the record ── + // ── Deposit (request type 0x03): deposit + top-up, carries the signature ── function testDepositEnqueuesAndReads() public { - ( - bytes memory pubkey, - bytes32 wc, - bytes memory signature, - , - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) = Vectors.depositCase(); + bytes memory pubkey = _filled(48, 1); + bytes32 wc = 0x0300000000000000000000000000000000000000000000000000000000abcdef; + bytes memory signature = _filled(96, 100); + uint64 amount_gwei = 2_000_000_000; // 2 ETH uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); - dep.deposit{value: value}(pubkey, wc, amount_gwei, signature, pubkey_y, signature_y); + dep.deposit{value: value}(pubkey, wc, amount_gwei, signature); require(dep.pendingCount() == 1, "one record queued"); bytes memory data = _systemRead(address(dep)); - bytes memory expected = abi.encodePacked(pubkey, wc, _le64(amount_gwei)); + bytes memory expected = abi.encodePacked(pubkey, wc, _le64(amount_gwei), signature); require(data.length == DEPOSIT_RECORD_LEN, "deposit record length"); require(keccak256(data) == keccak256(expected), "deposit record bytes mismatch"); require(dep.pendingCount() == 0, "queue drained"); } - function testTopUpEnqueuesAndReads() public { - bytes memory pubkey = new bytes(48); - for (uint i = 0; i < 48; i++) pubkey[i] = bytes1(uint8(i + 1)); - - uint64 amount_gwei = 3_000_000_000; // 3 ETH - top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); - require(top.pendingCount() == 1, "one top-up queued"); - - bytes memory data = _systemRead(address(top)); - bytes memory expected = abi.encodePacked(pubkey, _le64(amount_gwei)); - require(data.length == TOPUP_RECORD_LEN, "top-up record length"); - require(keccak256(data) == keccak256(expected), "top-up record bytes mismatch"); - require(top.pendingCount() == 0, "queue drained"); - } - - // ── EIP-1559-style request fee ───────────────────────────────────────── - - function testFeeStartsAtMinimum() public { - require(dep.feeWei() == 1, "min fee is 1 wei at excess 0"); - require(top.feeWei() == 1, "min fee is 1 wei at excess 0"); - } - - function testFeeRisesWithExcess() public { - // 18 top-ups in one block → count 18. The next system call sets - // excess = 18 - TARGET(2) = 16, and fake_exponential(1, 16, 17) == 2. - bytes memory pubkey = new bytes(48); - uint64 amount_gwei = 1_000_000_000; // 1 ETH - for (uint i = 0; i < 18; i++) { - top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); - } - require(top.feeWei() == 1, "fee unchanged until the system call updates excess"); - _systemRead(address(top)); - require(top.feeWei() == 2, "fee rises after a block above target"); - } - - function testFeeGetterFallbackMatches() public { - // A non-system empty-calldata call returns the current fee. - (bool ok, bytes memory ret) = address(top).call(""); - require(ok, "fee getter call failed"); - require(ret.length == 32, "fee getter returns a word"); - require(abi.decode(ret, (uint)) == top.feeWei(), "fee getter mismatch"); + function testDepositRejectsTooSmallStake() public { + bytes memory pubkey = _filled(48, 1); + bytes memory signature = _filled(96, 100); + // 0.5 ETH stake (< 1 ETH minimum); ample value so the stake check, not + // the value check, is what reverts. + try dep.deposit{value: 1 ether}(pubkey, bytes32(0), 500_000_000, signature) { + require(false, "stake < 1 ether should revert"); + } catch {} + require(dep.pendingCount() == 0, "nothing enqueued on reject"); } function testDepositRejectsInsufficientValue() public { - ( - bytes memory pubkey, - bytes32 wc, - bytes memory signature, - , - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) = Vectors.depositCase(); + bytes memory pubkey = _filled(48, 1); + bytes memory signature = _filled(96, 100); + uint64 amount_gwei = 2_000_000_000; // Exactly the stake, with nothing left for the fee: must revert. - try dep.deposit{value: uint(amount_gwei) * 1 gwei}( - pubkey, wc, amount_gwei, signature, pubkey_y, signature_y - ) { + try dep.deposit{value: uint(amount_gwei) * 1 gwei}(pubkey, bytes32(0), amount_gwei, signature) { require(false, "stake without fee should revert"); } catch {} require(dep.pendingCount() == 0, "nothing enqueued on reject"); } - // ── System read access control + FIFO / per-block cap ────────────────── - - function testSystemReadRequiresSystemAddress() public { - // A non-system empty-calldata call is the fee getter, not a drain: it - // returns the fee and must NOT advance the queue. - bytes memory pubkey = new bytes(48); - uint64 amount_gwei = 1_000_000_000; - top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); - (bool ok, ) = address(top).call(""); - require(ok, "fee getter should succeed"); - require(top.pendingCount() == 1, "non-system call must not drain the queue"); - } - - function testPerBlockCapAndFifo() public { - bytes memory pubkey = new bytes(48); - uint64 amount_gwei = 1_000_000_000; - for (uint i = 0; i < 17; i++) { - top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); - } - require(top.pendingCount() == 17, "17 queued"); - - bytes memory first = _systemRead(address(top)); - require(first.length == 16 * TOPUP_RECORD_LEN, "first read drains the 16-record cap"); - require(top.pendingCount() == 1, "one remains after cap"); - - bytes memory second = _systemRead(address(top)); - require(second.length == 1 * TOPUP_RECORD_LEN, "second read drains the remainder"); - require(top.pendingCount() == 0, "queue empty"); - } - - // Audit Finding 1 regression: when the queue fully drains, both head and - // tail reset to 0 (EIP-7002 behavior), so storage is bounded by peak depth - // and the next request reuses index 0. - function testQueueResetsWhenDrained() public { - bytes memory pubkey = new bytes(48); - uint64 amount_gwei = 1_000_000_000; - for (uint i = 0; i < 3; i++) { - top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); - } - require(top.headIdx() == 0 && top.tailIdx() == 3, "3 queued at indices [0,3)"); - - _systemRead(address(top)); // drains all 3 (<= cap) - require(top.headIdx() == 0 && top.tailIdx() == 0, "head and tail reset to 0 on empty"); - require(top.pendingCount() == 0, "queue empty"); - - // Next request reuses index 0 rather than advancing forever. - top.top_up{value: uint(amount_gwei) * 1 gwei + top.feeWei()}(pubkey, amount_gwei); - require(top.tailIdx() == 1, "tail restarts at 1 (slot reused)"); - } - - // Audit Finding 3 regression: the fallback only accepts empty calldata. - function testFallbackRejectsNonEmptyCalldata() public { - (bool ok, ) = address(top).call(hex"deadbeefdeadbeef"); - require(!ok, "non-empty junk calldata must revert"); - // Empty calldata still works (fee getter), confirming the guard is scoped. - (bool ok2, ) = address(top).call(""); - require(ok2, "empty-calldata fee getter still works"); - } - - // ── Negative paths: BLS check (nothing should enqueue) ───────────────── - - // The amount is NOT part of the signed message, so the same signature is - // valid for any amount. Depositing with an amount different from the - // vector's must SUCCEED, and the queued record must reflect the amount that - // was actually passed. - function testDepositAmountNotBoundToSignature() public { - ( - bytes memory pubkey, - bytes32 wc, - bytes memory signature, - , - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) = Vectors.depositCase(); - uint64 differentAmount = amount_gwei + 5_000_000_000; // +5 ETH, unsigned - uint value = uint(differentAmount) * 1 gwei + dep.feeWei(); - dep.deposit{value: value}(pubkey, wc, differentAmount, signature, pubkey_y, signature_y); - require(dep.pendingCount() == 1, "deposit with a different amount is accepted"); - - bytes memory data = _systemRead(address(dep)); - bytes memory expected = abi.encodePacked(pubkey, wc, _le64(differentAmount)); - require(keccak256(data) == keccak256(expected), "record reflects the passed amount"); - } - - function testDepositRejectsTamperedSignature() public { - ( - bytes memory pubkey, - bytes32 wc, - bytes memory signature, - , - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) = Vectors.depositCase(); - bytes memory tampered = _copy(signature); - tampered[10] = tampered[10] ^ bytes1(uint8(1)); - uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); - try dep.deposit{value: value}(pubkey, wc, amount_gwei, tampered, pubkey_y, signature_y) { - require(false, "tampered signature should revert"); - } catch {} - require(dep.pendingCount() == 0, "nothing enqueued on reject"); - } - - // Regression for audit Finding 2: flip only the pubkey sign flag (keep Y). - function testDepositRejectsPubkeySignBitFlip() public { - ( - bytes memory pubkey, - bytes32 wc, - bytes memory signature, - , - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) = Vectors.depositCase(); - bytes memory flipped = _copy(pubkey); - flipped[0] = flipped[0] ^ bytes1(uint8(0x20)); - uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); - try dep.deposit{value: value}(flipped, wc, amount_gwei, signature, pubkey_y, signature_y) { - require(false, "pubkey sign-bit flip should revert"); + function testDepositRejectsWrongPubkeyLength() public { + bytes memory pubkey = _filled(47, 1); + bytes memory signature = _filled(96, 100); + try dep.deposit{value: 2 ether}(pubkey, bytes32(0), 1_000_000_000, signature) { + require(false, "47-byte pubkey should revert"); } catch {} require(dep.pendingCount() == 0, "nothing enqueued on reject"); } - function testDepositRejectsSignatureSignBitFlip() public { - ( - bytes memory pubkey, - bytes32 wc, - bytes memory signature, - , - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) = Vectors.depositCase(); - bytes memory flipped = _copy(signature); - flipped[0] = flipped[0] ^ bytes1(uint8(0x20)); - uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); - try dep.deposit{value: value}(pubkey, wc, amount_gwei, flipped, pubkey_y, signature_y) { - require(false, "signature sign-bit flip should revert"); + function testDepositRejectsWrongSignatureLength() public { + bytes memory pubkey = _filled(48, 1); + bytes memory signature = _filled(95, 100); + try dep.deposit{value: 2 ether}(pubkey, bytes32(0), 1_000_000_000, signature) { + require(false, "95-byte signature should revert"); } catch {} require(dep.pendingCount() == 0, "nothing enqueued on reject"); } - function testDepositRejectsInfinityPubkey() public { - ( - bytes memory pubkey, - bytes32 wc, - bytes memory signature, - , - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) = Vectors.depositCase(); - bytes memory inf = _copy(pubkey); - inf[0] = inf[0] | bytes1(uint8(0x40)); - uint value = uint(amount_gwei) * 1 gwei + dep.feeWei(); - try dep.deposit{value: value}(inf, wc, amount_gwei, signature, pubkey_y, signature_y) { - require(false, "infinity pubkey should revert"); - } catch {} - require(dep.pendingCount() == 0, "nothing enqueued on reject"); - } + // ── Exit (request type 0x04) ─────────────────────────────────────────── - // ── Negative paths: input-shape validation ───────────────────────────── + function testExitEnqueuesAndReads() public { + bytes memory pubkey = _filled(48, 1); + ex.exit{value: ex.feeWei()}(pubkey); + require(ex.pendingCount() == 1, "one exit queued"); - function testDepositRejectsTooSmallStake() public { - bytes memory pubkey = new bytes(48); - bytes memory signature = new bytes(96); - BuilderDepositContract.Fp memory z = BuilderDepositContract.Fp(0, 0); - BuilderDepositContract.Fp2 memory z2 = BuilderDepositContract.Fp2(z, z); - // 0.5 ETH stake (< 1 ETH minimum). - try dep.deposit{value: 1 ether}(pubkey, bytes32(0), 500_000_000, signature, z, z2) { - require(false, "stake < 1 ether should revert"); - } catch {} - require(dep.pendingCount() == 0, "nothing enqueued on reject"); + bytes memory data = _systemRead(address(ex)); + bytes memory expected = abi.encodePacked(address(this), pubkey); + require(data.length == EXIT_RECORD_LEN, "exit record length"); + require(keccak256(data) == keccak256(expected), "exit record bytes mismatch"); + require(ex.pendingCount() == 0, "queue drained"); } - function testDepositRejectsWrongPubkeyLength() public { - bytes memory pubkey = new bytes(47); - bytes memory signature = new bytes(96); - BuilderDepositContract.Fp memory z = BuilderDepositContract.Fp(0, 0); - BuilderDepositContract.Fp2 memory z2 = BuilderDepositContract.Fp2(z, z); - try dep.deposit{value: 2 ether}(pubkey, bytes32(0), 1_000_000_000, signature, z, z2) { - require(false, "47-byte pubkey should revert"); - } catch {} - require(dep.pendingCount() == 0, "nothing enqueued on reject"); + // The recorded source_address is the caller (the builder's execution_address), + // which is what the CL checks for authorization. Fee is read before + // `vm.prank` so the prank applies to `exit`, not to the `feeWei()` call. + function testExitRecordsCaller() public { + address builderExecAddr = 0xb0b1DE7c0fFeE0000000000000000000000B5511; + bytes memory pubkey = _filled(48, 7); + uint fee = ex.feeWei(); + vm.deal(builderExecAddr, 1 ether); + vm.prank(builderExecAddr); + ex.exit{value: fee}(pubkey); + + bytes memory data = _systemRead(address(ex)); + bytes memory expected = abi.encodePacked(builderExecAddr, pubkey); + require(keccak256(data) == keccak256(expected), "source_address must be the caller"); } - function testTopUpRejectsTooSmallStake() public { - bytes memory pubkey = new bytes(48); - try top.top_up{value: 1 ether}(pubkey, 500_000_000) { - require(false, "top_up stake < 1 ether should revert"); + function testExitRejectsInsufficientFee() public { + bytes memory pubkey = _filled(48, 1); + // excess == 0 → fee is 1 wei; sending 0 cannot cover it. + try ex.exit{value: 0}(pubkey) { + require(false, "exit below the fee should revert"); } catch {} - require(top.pendingCount() == 0, "nothing enqueued on reject"); + require(ex.pendingCount() == 0, "nothing enqueued on reject"); } - function testTopUpRejectsWrongPubkeyLength() public { - bytes memory pubkey = new bytes(47); - try top.top_up{value: 2 ether}(pubkey, 1_000_000_000) { + function testExitRejectsWrongPubkeyLength() public { + bytes memory pubkey = _filled(47, 1); + try ex.exit{value: ex.feeWei()}(pubkey) { require(false, "47-byte pubkey should revert"); } catch {} - require(top.pendingCount() == 0, "nothing enqueued on reject"); + require(ex.pendingCount() == 0, "nothing enqueued on reject"); } - // ── Withdrawal / exit predeploy (EIP-7002-shaped, request type 0x05) ──── - - // A partial withdrawal (amount > 0) records source_address(msg.sender) ++ - // pubkey ++ amount; the system read returns the exact 76-byte record. - function testWithdrawalEnqueuesAndReads() public { - bytes memory pubkey = new bytes(48); - for (uint i = 0; i < 48; i++) pubkey[i] = bytes1(uint8(i + 1)); + // ── EIP-1559-style request fee ───────────────────────────────────────── - uint64 amount_gwei = 4_000_000_000; // 4 ETH partial withdrawal - wd.withdraw{value: wd.feeWei()}(pubkey, amount_gwei); - require(wd.pendingCount() == 1, "one withdrawal queued"); + function testFeeStartsAtMinimum() public { + require(dep.feeWei() == 1, "min fee is 1 wei at excess 0"); + require(ex.feeWei() == 1, "min fee is 1 wei at excess 0"); + } - bytes memory data = _systemRead(address(wd)); - bytes memory expected = abi.encodePacked(address(this), pubkey, _le64(amount_gwei)); - require(data.length == WITHDRAWAL_RECORD_LEN, "withdrawal record length"); - require(keccak256(data) == keccak256(expected), "withdrawal record bytes mismatch"); - require(wd.pendingCount() == 0, "queue drained"); + function testFeeRisesWithExcess() public { + // 18 exits in one block → count 18. The next system call sets + // excess = 18 - TARGET(2) = 16, and fake_exponential(1, 16, 17) == 2. + bytes memory pubkey = _filled(48, 1); + for (uint i = 0; i < 18; i++) { + ex.exit{value: ex.feeWei()}(pubkey); + } + require(ex.feeWei() == 1, "fee unchanged until the system call updates excess"); + _systemRead(address(ex)); + require(ex.feeWei() == 2, "fee rises after a block above target"); } - // amount_gwei == 0 is the full-exit sentinel: it MUST be accepted (there is - // no minimum-amount check) and recorded with a zero amount, like EIP-7002. - function testExitEnqueuesWithZeroAmount() public { - bytes memory pubkey = new bytes(48); - for (uint i = 0; i < 48; i++) pubkey[i] = bytes1(uint8(0xa0 + i)); + function testFeeGetterFallbackMatches() public { + (bool ok, bytes memory ret) = address(ex).call(""); + require(ok, "fee getter call failed"); + require(ret.length == 32, "fee getter returns a word"); + require(abi.decode(ret, (uint)) == ex.feeWei(), "fee getter mismatch"); + } - wd.withdraw{value: wd.feeWei()}(pubkey, 0); - require(wd.pendingCount() == 1, "exit (amount 0) is accepted and queued"); + // ── System read access control + FIFO / per-block cap ────────────────── - bytes memory data = _systemRead(address(wd)); - bytes memory expected = abi.encodePacked(address(this), pubkey, _le64(0)); - require(data.length == WITHDRAWAL_RECORD_LEN, "exit record length"); - require(keccak256(data) == keccak256(expected), "exit record bytes mismatch"); + function testSystemReadRequiresSystemAddress() public { + bytes memory pubkey = _filled(48, 1); + ex.exit{value: ex.feeWei()}(pubkey); + (bool ok, ) = address(ex).call(""); + require(ok, "fee getter should succeed"); + require(ex.pendingCount() == 1, "non-system call must not drain the queue"); } - // The recorded source_address is the caller, not a parameter: a withdrawal - // from a different address records that address (the builder's - // execution_address), which is what the CL checks for authorization. The - // fee is read before `vm.prank` so the prank applies to `withdraw`, not to - // the `feeWei()` argument call. - function testWithdrawalRecordsCaller() public { - address builderExecAddr = 0xb0b1DE7c0fFeE0000000000000000000000B5511; - bytes memory pubkey = new bytes(48); - for (uint i = 0; i < 48; i++) pubkey[i] = bytes1(uint8(i + 7)); + function testPerBlockCapAndFifo() public { + bytes memory pubkey = _filled(48, 1); + for (uint i = 0; i < 17; i++) { + ex.exit{value: ex.feeWei()}(pubkey); + } + require(ex.pendingCount() == 17, "17 queued"); - uint64 amount_gwei = 1_500_000_000; - uint fee = wd.feeWei(); - vm.deal(builderExecAddr, 1 ether); // fund the caller so it can pay the fee - vm.prank(builderExecAddr); - wd.withdraw{value: fee}(pubkey, amount_gwei); + bytes memory first = _systemRead(address(ex)); + require(first.length == 16 * EXIT_RECORD_LEN, "first read drains the 16-record cap"); + require(ex.pendingCount() == 1, "one remains after cap"); - bytes memory data = _systemRead(address(wd)); - bytes memory expected = abi.encodePacked(builderExecAddr, pubkey, _le64(amount_gwei)); - require(keccak256(data) == keccak256(expected), "source_address must be the caller"); + bytes memory second = _systemRead(address(ex)); + require(second.length == 1 * EXIT_RECORD_LEN, "second read drains the remainder"); + require(ex.pendingCount() == 0, "queue empty"); } - // Unlike deposit/top-up, a withdrawal sends no stake — only the fee. A large - // amount_gwei with msg.value equal to just the (1 wei) fee must succeed. - function testWithdrawalRequiresNoStake() public { - bytes memory pubkey = new bytes(48); - uint64 amount_gwei = 1_000_000_000_000; // 1000 ETH, but no value is sent for it - wd.withdraw{value: wd.feeWei()}(pubkey, amount_gwei); - require(wd.pendingCount() == 1, "withdrawal needs only the fee, no staked value"); - } + // When the queue fully drains, both head and tail reset to 0 (EIP-7002 + // behavior), so storage is bounded by peak depth and the next request reuses + // index 0. + function testQueueResetsWhenDrained() public { + bytes memory pubkey = _filled(48, 1); + for (uint i = 0; i < 3; i++) { + ex.exit{value: ex.feeWei()}(pubkey); + } + require(ex.headIdx() == 0 && ex.tailIdx() == 3, "3 queued at indices [0,3)"); - function testWithdrawalRejectsInsufficientFee() public { - bytes memory pubkey = new bytes(48); - // excess == 0 → fee is 1 wei; sending 0 cannot cover it. - try wd.withdraw{value: 0}(pubkey, 1_000_000_000) { - require(false, "withdrawal below the fee should revert"); - } catch {} - require(wd.pendingCount() == 0, "nothing enqueued on reject"); + _systemRead(address(ex)); // drains all 3 (<= cap) + require(ex.headIdx() == 0 && ex.tailIdx() == 0, "head and tail reset to 0 on empty"); + require(ex.pendingCount() == 0, "queue empty"); + + ex.exit{value: ex.feeWei()}(pubkey); + require(ex.tailIdx() == 1, "tail restarts at 1 (slot reused)"); } - function testWithdrawalRejectsWrongPubkeyLength() public { - bytes memory pubkey = new bytes(47); - try wd.withdraw{value: wd.feeWei()}(pubkey, 1_000_000_000) { - require(false, "47-byte pubkey should revert"); - } catch {} - require(wd.pendingCount() == 0, "nothing enqueued on reject"); + // The fallback only accepts empty calldata. + function testFallbackRejectsNonEmptyCalldata() public { + (bool ok, ) = address(ex).call(hex"deadbeefdeadbeef"); + require(!ok, "non-empty junk calldata must revert"); + (bool ok2, ) = address(ex).call(""); + require(ok2, "empty-calldata fee getter still works"); } // ── EXCESS_INHIBITOR (pre-activation), as in EIP-7002/7251 ───────────── // A freshly deployed contract starts inhibited (excess == EXCESS_INHIBITOR), // so the fee getter reverts until the first system call. setUp() already - // activated dep/top/wd, so these tests use a fresh instance. + // activated dep/ex, so these tests use a fresh instance. function testFeeGetterRevertsWhileInhibited() public { - BuilderTopUpHarness fresh = new BuilderTopUpHarness(); + BuilderExitHarness fresh = new BuilderExitHarness(); try fresh.feeWei() { require(false, "fee getter must revert while inhibited"); } catch {} @@ -456,9 +254,9 @@ contract BuilderRequestsTest { // No request can be enqueued before activation: the entrypoint reverts when // it reads the inhibited fee, even with ample value; nothing is queued. function testRequestRevertsWhileInhibited() public { - BuilderTopUpHarness fresh = new BuilderTopUpHarness(); - bytes memory pubkey = new bytes(48); - try fresh.top_up{value: 2 ether}(pubkey, 1_000_000_000) { + BuilderExitHarness fresh = new BuilderExitHarness(); + bytes memory pubkey = _filled(48, 1); + try fresh.exit{value: 1 ether}(pubkey) { require(false, "request must revert while inhibited"); } catch {} require(fresh.pendingCount() == 0, "nothing enqueued while inhibited"); @@ -467,7 +265,7 @@ contract BuilderRequestsTest { // The first SYSTEM_ADDRESS call clears the inhibitor; the fee is then // MIN_REQUEST_FEE (excess == 0). function testFirstSystemCallClearsInhibitor() public { - BuilderTopUpHarness fresh = new BuilderTopUpHarness(); + BuilderExitHarness fresh = new BuilderExitHarness(); _systemRead(address(fresh)); require(fresh.feeWei() == 1, "fee is MIN_REQUEST_FEE once the inhibitor clears"); } diff --git a/assets/eip-draft_builder_requests/test/TestHarness.sol b/assets/eip-draft_builder_requests/test/TestHarness.sol index 2882bba4e60e66..1a66c2139fdd7f 100644 --- a/assets/eip-draft_builder_requests/test/TestHarness.sol +++ b/assets/eip-draft_builder_requests/test/TestHarness.sol @@ -1,13 +1,11 @@ // SPDX-License-Identifier: CC0-1.0 pragma solidity 0.6.11; -pragma experimental ABIEncoderV2; import "../builder_requests.sol"; /// @notice Test harness for the deposit predeploy. Inherits BuilderDepositContract /// (so `deposit(...)` and the inherited `SYSTEM_ADDRESS` system-read `fallback` -/// are exercised as-is) and exposes the internal queue depth plus the SSZ -/// signing-root helper for cross-checking against py_ecc. +/// are exercised as-is) and exposes the internal queue depth and fee. contract BuilderDepositHarness is BuilderDepositContract { /// @notice Number of queued-but-not-yet-dequeued records. function pendingCount() external view returns (uint) { @@ -18,17 +16,10 @@ contract BuilderDepositHarness is BuilderDepositContract { function feeWei() external view returns (uint) { return _getFee(); } - - function computeDepositSigningRoot( - bytes calldata pubkey, - bytes32 withdrawal_credentials - ) external pure returns (bytes32) { - return _computeDepositSigningRoot(pubkey, withdrawal_credentials); - } } -/// @notice Test harness for the top-up predeploy. -contract BuilderTopUpHarness is BuilderTopUpContract { +/// @notice Test harness for the exit predeploy. +contract BuilderExitHarness is BuilderExitContract { function pendingCount() external view returns (uint) { return queueTail - queueHead; } @@ -41,14 +32,3 @@ contract BuilderTopUpHarness is BuilderTopUpContract { function headIdx() external view returns (uint) { return queueHead; } function tailIdx() external view returns (uint) { return queueTail; } } - -/// @notice Test harness for the withdrawal / exit predeploy. -contract BuilderWithdrawalHarness is BuilderWithdrawalContract { - function pendingCount() external view returns (uint) { - return queueTail - queueHead; - } - - function feeWei() external view returns (uint) { - return _getFee(); - } -} diff --git a/assets/eip-draft_builder_requests/test/Vectors.sol b/assets/eip-draft_builder_requests/test/Vectors.sol deleted file mode 100644 index 8f75ec15533388..00000000000000 --- a/assets/eip-draft_builder_requests/test/Vectors.sol +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-License-Identifier: CC0-1.0 -// AUTOGENERATED by gen_vectors.py — do not edit by hand. -// -// Cross-verification fixtures produced from py_ecc (the canonical Eth2 -// Python reference implementation). Regenerate with: -// /tmp/eipenv/bin/python gen_vectors.py > test/Vectors.sol -pragma solidity 0.6.11; -pragma experimental ABIEncoderV2; - -import "../builder_requests.sol"; - -library Vectors { - - // ── End-to-end deposit signature ────────────────────────────────── - // - // Generated from a deterministic secret key via - // py_ecc.bls.G2ProofOfPossession (the Eth2 ciphersuite). - - function depositCase() internal pure - returns ( - bytes memory pubkey, - bytes32 withdrawal_credentials, - bytes memory signature, - bytes32 deposit_data_root, - uint64 amount_gwei, - BuilderDepositContract.Fp memory pubkey_y, - BuilderDepositContract.Fp2 memory signature_y - ) - { - pubkey = hex"b5b99c967e4c69822f427db1f6871dd119afb95ab9646ba2e707990a3db31777a59b66f69e89c2055699b0ade7357eae"; - withdrawal_credentials = 0x0000000000000000000000000000000000000000000000000000000000000000; - signature = hex"a80fa59d29e8bac877966ed1d113c8b8f431de687c182d19f0a37951cb8a57e66757f69cbdefb2f58da6ea58e66d9a1d0504465cad3a02b8e8da35969b9cadbce789c6b0f2fa926882b526f928c10dff7386af66cccb0b162b37a75a246d5087"; - deposit_data_root = 0x625d01daa2c4638988bb35d6ca0643efbfc44f23066c0cb26401b613cecb8e0e; - amount_gwei = 32000000000; - pubkey_y = BuilderDepositContract.Fp(0x1201d584a96bc82775861b0611171d2f, 0xfeaa2431c48856e23d3b747f7392fad645e0cdd5aa01b6a5b24b73ab584db4ad); - signature_y = BuilderDepositContract.Fp2(BuilderDepositContract.Fp(0x0291c03dc4b0cad5bbb54fed95b2f7b4, 0x26a18420768bbc16659cf22495b0d8f1723ebe189c876a6a21c9a828b4a27131), BuilderDepositContract.Fp(0x13edfbbff93a06436cd5c99133b038b8, 0xd2cc864627eef31c8324d7f5ee60f09b30408aafeb9cae41dd1a53f7bd768560)); - } - - /// @notice Expected `compute_signing_root` for `depositCase()`, - /// computed in Python and baked in so the on-chain SSZ helper can be - /// cross-checked without a second BLS pairing. - function depositSigningRoot() internal pure returns (bytes32) { - return 0x3a635e9092f64642776ebfde7fcdf130286f10ef9825007b76e107966983c1e4; - } - -} From a59fa1cbcb00dcd6bdd03856962ea7d8f32bf23f Mon Sep 17 00:00:00 2001 From: Cayman Date: Wed, 3 Jun 2026 15:42:44 +0200 Subject: [PATCH 09/12] fix: reuse DOMAIN_DEPOSIT, specify deposit-reject + fork-cutover, exit precondition (review 2) Resolves the second adversarial review of the redesigned EIP. Builder deposits reuse DOMAIN_DEPOSIT (document the benign cross-class signature interchange instead of asserting a non-existent domain separation); give the exact process_deposit_request inert-return for a 0x03-prefix deposit plus a post-fork deposit-routing transition window so in-flight pre-fork deposits are not stranded; align the exit predicate to gloas is_active_builder and document consumed-not-retried; correct the PoP message to the 3-field DepositMessage; name the EIP-7804 0x03 collision; add Spam/state-growth and Locked-funds security notes. Spec-only; contracts and tests unchanged. --- EIPS/eip-draft_builder_requests.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/EIPS/eip-draft_builder_requests.md b/EIPS/eip-draft_builder_requests.md index cd2b04d31b8343..36df1299733bab 100644 --- a/EIPS/eip-draft_builder_requests.md +++ b/EIPS/eip-draft_builder_requests.md @@ -37,7 +37,7 @@ The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "S ### Constants -All address and request-type values below are placeholders. The `0x03`/`0x04` request types MUST be unallocated and unique across **all** active [EIP-7685](./eip-7685.md) request types — not only the finalized deposit (`0x00`), withdrawal (`0x01`), and consolidation (`0x02`) types, but also any other in-flight request-type proposals — with final allocation coordinated in consensus-specs. +All address and request-type values below are placeholders. The `0x03`/`0x04` request types MUST be unallocated and unique across **all** active [EIP-7685](./eip-7685.md) request types — not only the finalized deposit (`0x00`), withdrawal (`0x01`), and consolidation (`0x02`) types, but also any other in-flight request-type proposals (notably [EIP-7804](./eip-7804.md), a Draft that also defines request type `0x03`) — with final allocation coordinated in consensus-specs. | Name | Value | Comment | | --- | --- | --- | @@ -137,18 +137,19 @@ A type's `request_data` is the concatenation of the fixed-size SSZ serialization ### Consensus-layer processing of records -The consensus layer processes the two request types as follows. Both are applied immediately when processed — a `BuilderDepositRequest` is **not** routed through the validator `pending_deposits` queue — so builder onboarding has no churn or finalization delay, preserving EIP-7732's existing behavior. +The consensus layer processes the two request types as follows. Both are applied immediately when processed — a `BuilderDepositRequest` is **not** routed through the validator `pending_deposits` queue, so a builder's balance is credited without an activation-churn queue, preserving EIP-7732's existing behavior. (A newly registered builder still becomes active for bidding and exit only once its deposit epoch is finalized, per gloas `is_active_builder`; only the churn queue is skipped, not finality.) -- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **not** yet in the builder set is a first deposit: the consensus layer verifies the proof-of-possession `signature` over `(pubkey, withdrawal_credentials)` under the builder-deposit signing domain and, if valid, registers the builder with the record's `withdrawal_credentials` and credits its `amount`; an invalid signature is ignored. +- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **not** yet in the builder set is a first deposit: the consensus layer verifies the proof-of-possession `signature` over the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` under `DOMAIN_DEPOSIT` — the same check validator deposits use (gloas `is_valid_deposit_signature`) — and, if valid, registers the builder with the record's `withdrawal_credentials` and credits its `amount`; an invalid signature is ignored. - A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **already** in the builder set is a top-up: it credits `amount` and MUST NOT change the existing `withdrawal_credentials` or re-register the builder, and its `withdrawal_credentials` and `signature` are ignored. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. -- A `BuilderExitRequest` (type `0x04`) MUST be ignored unless its `pubkey` is a registered builder, its `source_address` equals that builder's `execution_address`, and the builder has no pending balance to withdraw. When valid, it initiates the builder's exit — setting `withdrawable_epoch = current_epoch + MIN_BUILDER_WITHDRAWABILITY_DELAY`, and is a no-op if the builder is already exiting. This is EIP-7732's existing `initiate_builder_exit`, now reached through this request rather than through a voluntary exit. +- A `BuilderExitRequest` (type `0x04`) MUST be ignored unless its `pubkey` is a registered, active builder (gloas `is_active_builder`: its deposit epoch is finalized and it is not already exiting), its `source_address` equals that builder's `execution_address`, and it has no pending balance to withdraw (`get_pending_balance_to_withdraw_for_builder == 0`). This is precisely EIP-7732's `process_voluntary_exit` builder branch with the BLS-signature check replaced by the `source_address` check; when the predicate holds it runs `initiate_builder_exit` (`withdrawable_epoch = current_epoch + MIN_BUILDER_WITHDRAWABILITY_DELAY`). A request that fails any precondition is **consumed and discarded, not re-queued** — the fee is spent. Because an active builder routinely has a non-zero pending balance from recent bid payments, a legitimate exit may be dropped until those settle, in which case the caller must resubmit once the pending balance has been swept. (The execution layer dequeues the record deterministically regardless, so a dropped request never affects `requests_hash` agreement.) ### Changes to EIP-7732 This EIP modifies EIP-7732's builder lifecycle on the consensus layer: -- **Deposit routing (post-fork).** The builder branch of `process_deposit_request` (which applies a deposit as a builder when its withdrawal credential carries `BUILDER_WITHDRAWAL_PREFIX`) is removed. After the fork, builders are sourced **only** from `BUILDER_DEPOSIT_REQUEST_TYPE`. A standard validator deposit (type `0x00`) whose withdrawal credential carries `BUILDER_WITHDRAWAL_PREFIX` MUST be rejected — it is applied neither as a validator nor as a builder. -- **Genesis onboarding (at the fork).** `onboard_builders_from_pending_deposits`, run once during the fork upgrade, is retained: builder-credentialed deposits already pending at the fork are onboarded as builders, so builders exist from the activation slot. Operators seed the genesis builder set by depositing to the existing deposit contract with a `BUILDER_WITHDRAWAL_PREFIX` credential before the fork. +- **Deposit routing.** `process_deposit_request` no longer creates or tops up builders. Its builder branch — `if is_builder or (is_builder_withdrawal_credential(...) and not is_validator and not is_pending_validator)` → `apply_deposit_for_builder` — is replaced by `if is_builder_withdrawal_credential(deposit_request.withdrawal_credentials): return`: the deposit is inert — it is **not** appended to `pending_deposits` (so no validator is minted) and its ETH is forfeited in the immutable deposit contract, as with any misdirected deposit. All other deposits process as validator deposits unchanged. Consequently a validator-contract deposit to a `pubkey` that is already a builder now creates or credits a **validator** with that key (the same key may be both — see [Rationale](#rationale)), never the builder; builders are created and topped up **only** via `BUILDER_DEPOSIT_REQUEST_TYPE`. +- **Deposit-routing transition.** A `0x03`-credentialed deposit included shortly before the fork is processed in the first post-fork block(s), *after* `onboard_builders_from_pending_deposits` has snapshotted `pending_deposits` — so the inert-return rule above would discard an already-funded, in-flight deposit and forfeit its stake. To avoid that, `process_deposit_request` MUST retain the pre-fork builder branch (onboarding such deposits as builders) for a transition period after the fork, applying the inert-return rule only once no pre-fork builder deposit can still be in flight. A deposit is processed by exactly one of the genesis onboarding, the retained branch, or (after the period) the new contract, so no `pubkey` is double-registered. +- **Genesis onboarding (at the fork).** `onboard_builders_from_pending_deposits`, run once during the fork upgrade, is retained: builder-credentialed deposits already in `pending_deposits` at the upgrade are onboarded as builders, so builders exist from the activation slot. Operators seed the genesis set by depositing to the existing deposit contract with a `BUILDER_WITHDRAWAL_PREFIX` credential before the fork — late enough that the deposit is still pending at the upgrade (a deposit applied earlier would create a stranded validator), with later-arriving deposits caught by the deposit-routing transition above. - **Exit routing.** The builder branch of `process_voluntary_exit` is removed, making the voluntary-exit operation validator-only; builders exit only via `BUILDER_EXIT_REQUEST_TYPE`. ## Rationale @@ -181,11 +182,13 @@ The final `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` and `BUILDER_EXIT_CONTRACT_RUN ## Security Considerations -- **Deposit proof-of-possession at the consensus layer.** The consensus layer verifies the proof-of-possession over `(pubkey, withdrawal_credentials)` on a builder's first registration and ignores the signature for top-ups. The per-block cap bounds the number of verifications the consensus layer performs per block, and the fee plus the 1-ETH stake (forfeited if the signature is invalid) make spamming invalid registrations expensive. -- **Builder-deposit signing-domain separation.** The signing domain the consensus layer uses to verify a builder deposit MUST differ from the validator `DOMAIN_DEPOSIT` and from every EIP-7732 domain, so a proof-of-possession signature is not interchangeable between a builder deposit, a validator deposit, and an EIP-7732 builder message. This is a consensus-layer concern (the contract holds no signing domain). +- **Deposit proof-of-possession at the consensus layer.** The consensus layer verifies the proof-of-possession over the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` on a builder's first registration, and ignores the signature for top-ups. The per-block cap bounds how many such verifications the consensus layer performs per block; see *Spam and state growth* below for the full anti-abuse picture. +- **Cross-class deposit signatures.** Builder deposits reuse the validator deposit proof-of-possession check — the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` under `DOMAIN_DEPOSIT` — so a builder-deposit signature and a validator-deposit signature over the same tuple are interchangeable. A third party can therefore take any public deposit proof-of-possession and submit it as a builder deposit (or vice versa), registering that `pubkey` as a builder while funding the ≥1-ETH stake itself. This is low-harm: builders are non-slashable and take no duties; such a builder cannot bid (that needs the BLS key, which the submitter lacks), so it sits inert until its balance is swept to the `execution_address` named in the credential; and it can neither redirect the corresponding validator nor change an already-registered builder's credentials. A distinct builder-deposit signing domain would prevent the interchange, but it would break genesis seeding (which is signed under `DOMAIN_DEPOSIT`) and add a tooling change for a benign threat, so it is deliberately not introduced. - **Exit authorization.** The exit contract records `msg.sender` as `source_address` and performs no further check. Because the request carries no signature, this is the sole authorization: the consensus layer MUST initiate an exit only when `source_address` equals the target builder's `execution_address`, or an arbitrary caller could exit a builder it does not control. A builder's only exit authorizer is therefore its `execution_address`; the voluntary-exit (BLS-key) path is removed for builders. - **Same public key as validator and builder.** Because the registries are keyed by independent request types, one public key may exist as both a validator and a builder. The two are distinct entries with distinct indices and distinct lifecycles; neither request type can act on the other registry. - **Replayable deposit records.** A deposit's `(pubkey, withdrawal_credentials, amount, signature)` is public in calldata, so a third party can submit a further `0x03` record for an already-registered builder at an arbitrary amount (funding it themselves). The consensus layer treats any `0x03` record for an already-registered `pubkey` as a top-up — crediting stake but ignoring the credentials and signature — so the replay cannot redirect a builder's withdrawals or re-register it; it is a harmless funded stake addition. +- **Spam and state growth.** The per-block cap bounds only the drain rate — the consensus-layer verifications and the `request_data` size per block — not enqueue: within a block, appends are limited only by gas, and the in-state queue grows across blocks, reclaiming slots only when it fully drains. Queue growth is instead gated by the value locked per record: every deposit locks at least `BUILDER_MIN_DEPOSIT` (1 ETH) plus the fee, so growing the queue by N records costs at least N ETH locked. A griefer submitting **valid** proofs-of-possession forfeits nothing — the stake becomes a real, withdrawable builder balance (a capital-lock for `MIN_BUILDER_WITHDRAWABILITY_DELAY`, not a burn) — so post-fork onboarding can be throttled behind a FIFO wall of attacker deposits for the cost of locking capital; the cap plus FIFO ordering, not the fee, is the binding throttle. This is tolerable because the time-critical genesis builder set is seeded before the fork through the uncapped onboarding path, not through the steady-state contract. +- **Locked funds.** The request fee, any overpayment or sub-gwei remainder, and the principal of a first deposit whose proof-of-possession the consensus layer rejects are permanently locked in the predeploy (which has no withdrawal path) and irrecoverable by anyone — including an honest depositor who submits a bad signature, since the execution layer cannot verify BLS and the consensus-layer rejection is silent. This mirrors EIP-7002/7251; submitters SHOULD verify the proof-of-possession off-chain before broadcasting. `BUILDER_MIN_DEPOSIT` is enforced only at the execution layer (as the validator deposit contract enforces its own minimum), with no consensus-layer re-assertion. - **System-read access control and per-block cap.** Only `SYSTEM_ADDRESS` may invoke the end-of-block dequeue; any other empty-calldata call is the fee getter and does not modify state, so a non-system caller cannot drain or replay the queue. Each contract returns at most `MAX_REQUESTS_PER_BLOCK` records per block, bounding both the size each predeploy contributes to the block requests and the consensus-layer work to process them; excess records remain queued for later blocks. - **Validator-contract co-existence.** The validator deposit contract and the validator request predeploys are unmodified; this EIP changes only EIP-7732's builder onboarding and exit routing (see [Changes to EIP-7732](#changes-to-eip-7732)). From e2a1b51bfc293e678480f0fb36bddbd421aa6614 Mon Sep 17 00:00:00 2001 From: Cayman Date: Wed, 3 Jun 2026 16:34:01 +0200 Subject: [PATCH 10/12] fix: gate builder deposits on the 0x03 prefix, drop the deposit transition window (review 3) Resolves the third adversarial review of the redesigned EIP. Require a builder first deposit to carry a 0x03-prefixed withdrawal_credentials (a consensus-layer check mirroring process_deposit_request), so a registered builder always has a well-formed execution_address and validator and builder deposits no longer cross-register; the cross-class Security note is rewritten accordingly and now records that DOMAIN_DEPOSIT is chain- and fork-agnostic. Drop the post-fork deposit-routing transition window in favour of a single deterministic cutover: the genesis snapshot onboards pending builder deposits, and from the fork onward every 0x03-credentialed validator-contract deposit is dropped (a late straggler is re-onboarded via the builder deposit contract). Document the exited-builder top-up (credited stake is non-reactivatable and sweeps to the execution_address) and the custodial-split exit standoff (a bidding operator can hold the pending balance non-zero and block the execution_address holder from exiting). Spec-only; contracts and tests unchanged. --- EIPS/eip-draft_builder_requests.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/EIPS/eip-draft_builder_requests.md b/EIPS/eip-draft_builder_requests.md index 36df1299733bab..9def4764268bd7 100644 --- a/EIPS/eip-draft_builder_requests.md +++ b/EIPS/eip-draft_builder_requests.md @@ -139,8 +139,8 @@ A type's `request_data` is the concatenation of the fixed-size SSZ serialization The consensus layer processes the two request types as follows. Both are applied immediately when processed — a `BuilderDepositRequest` is **not** routed through the validator `pending_deposits` queue, so a builder's balance is credited without an activation-churn queue, preserving EIP-7732's existing behavior. (A newly registered builder still becomes active for bidding and exit only once its deposit epoch is finalized, per gloas `is_active_builder`; only the churn queue is skipped, not finality.) -- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **not** yet in the builder set is a first deposit: the consensus layer verifies the proof-of-possession `signature` over the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` under `DOMAIN_DEPOSIT` — the same check validator deposits use (gloas `is_valid_deposit_signature`) — and, if valid, registers the builder with the record's `withdrawal_credentials` and credits its `amount`; an invalid signature is ignored. -- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **already** in the builder set is a top-up: it credits `amount` and MUST NOT change the existing `withdrawal_credentials` or re-register the builder, and its `withdrawal_credentials` and `signature` are ignored. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. +- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **not** yet in the builder set is a first deposit. The consensus layer registers the builder only if both checks pass: its `withdrawal_credentials` begins with the `0x03` `BUILDER_WITHDRAWAL_PREFIX` (`is_builder_withdrawal_credential`), and the proof-of-possession `signature` over the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` under `DOMAIN_DEPOSIT` is valid — the same signature check validator deposits use (gloas `is_valid_deposit_signature`). If both hold, it registers the builder with the record's `withdrawal_credentials` (whose last 20 bytes are the builder's `execution_address`) and credits its `amount`. A record whose `withdrawal_credentials` is not `0x03`-prefixed, or whose signature is invalid, is ignored (consumed, stake forfeited). The prefix check mirrors the credential discrimination `process_deposit_request` applies on the validator path, so a registered builder always carries a `0x03` credential and therefore a well-formed `execution_address`. +- A `BuilderDepositRequest` (type `0x03`) for a `pubkey` **already** in the builder set is a top-up: it credits `amount` and MUST NOT change the existing `withdrawal_credentials` or re-register the builder, and its `withdrawal_credentials` and `signature` are ignored. This mirrors the validator deposit contract, where the proof-of-possession is checked only on a pubkey's first appearance and later deposits are stake additions. The builder set still contains entries that have **exited** (a slot is reclaimed only once the builder's `withdrawable_epoch` has passed and its balance is zero), so a deposit to an exited `pubkey` is also a top-up — it credits an entry that EIP-7732 does not reactivate, so the added stake merely sweeps to that entry's `execution_address` and never resumes bidding; re-registering the key requires waiting for its prior slot to be recycled. - A `BuilderExitRequest` (type `0x04`) MUST be ignored unless its `pubkey` is a registered, active builder (gloas `is_active_builder`: its deposit epoch is finalized and it is not already exiting), its `source_address` equals that builder's `execution_address`, and it has no pending balance to withdraw (`get_pending_balance_to_withdraw_for_builder == 0`). This is precisely EIP-7732's `process_voluntary_exit` builder branch with the BLS-signature check replaced by the `source_address` check; when the predicate holds it runs `initiate_builder_exit` (`withdrawable_epoch = current_epoch + MIN_BUILDER_WITHDRAWABILITY_DELAY`). A request that fails any precondition is **consumed and discarded, not re-queued** — the fee is spent. Because an active builder routinely has a non-zero pending balance from recent bid payments, a legitimate exit may be dropped until those settle, in which case the caller must resubmit once the pending balance has been swept. (The execution layer dequeues the record deterministically regardless, so a dropped request never affects `requests_hash` agreement.) ### Changes to EIP-7732 @@ -148,8 +148,7 @@ The consensus layer processes the two request types as follows. Both are applied This EIP modifies EIP-7732's builder lifecycle on the consensus layer: - **Deposit routing.** `process_deposit_request` no longer creates or tops up builders. Its builder branch — `if is_builder or (is_builder_withdrawal_credential(...) and not is_validator and not is_pending_validator)` → `apply_deposit_for_builder` — is replaced by `if is_builder_withdrawal_credential(deposit_request.withdrawal_credentials): return`: the deposit is inert — it is **not** appended to `pending_deposits` (so no validator is minted) and its ETH is forfeited in the immutable deposit contract, as with any misdirected deposit. All other deposits process as validator deposits unchanged. Consequently a validator-contract deposit to a `pubkey` that is already a builder now creates or credits a **validator** with that key (the same key may be both — see [Rationale](#rationale)), never the builder; builders are created and topped up **only** via `BUILDER_DEPOSIT_REQUEST_TYPE`. -- **Deposit-routing transition.** A `0x03`-credentialed deposit included shortly before the fork is processed in the first post-fork block(s), *after* `onboard_builders_from_pending_deposits` has snapshotted `pending_deposits` — so the inert-return rule above would discard an already-funded, in-flight deposit and forfeit its stake. To avoid that, `process_deposit_request` MUST retain the pre-fork builder branch (onboarding such deposits as builders) for a transition period after the fork, applying the inert-return rule only once no pre-fork builder deposit can still be in flight. A deposit is processed by exactly one of the genesis onboarding, the retained branch, or (after the period) the new contract, so no `pubkey` is double-registered. -- **Genesis onboarding (at the fork).** `onboard_builders_from_pending_deposits`, run once during the fork upgrade, is retained: builder-credentialed deposits already in `pending_deposits` at the upgrade are onboarded as builders, so builders exist from the activation slot. Operators seed the genesis set by depositing to the existing deposit contract with a `BUILDER_WITHDRAWAL_PREFIX` credential before the fork — late enough that the deposit is still pending at the upgrade (a deposit applied earlier would create a stranded validator), with later-arriving deposits caught by the deposit-routing transition above. +- **Genesis onboarding (at the fork).** `onboard_builders_from_pending_deposits`, run once during the fork upgrade, is retained: builder-credentialed deposits already in `pending_deposits` at the upgrade are onboarded as builders, so builders exist from the activation slot. Operators seed the genesis set by depositing to the existing deposit contract with a `BUILDER_WITHDRAWAL_PREFIX` credential before the fork — late enough that the deposit is still pending at the upgrade (a deposit applied earlier would create a stranded validator). The cutover is then a single deterministic switch with no transition window to parameterize: deposits captured by the snapshot are onboarded, and from the fork onward the deposit-routing rule above drops every `0x03`-credentialed validator-contract deposit. A `0x03`-credentialed deposit that lands too late for the snapshot — included only in the first post-fork block(s) — is therefore **not** onboarded; its stake is forfeited like any other dropped deposit, and the operator re-onboards through `BUILDER_DEPOSIT_REQUEST_TYPE`. No `pubkey` is onboarded by more than one path. - **Exit routing.** The builder branch of `process_voluntary_exit` is removed, making the voluntary-exit operation validator-only; builders exit only via `BUILDER_EXIT_REQUEST_TYPE`. ## Rationale @@ -183,12 +182,13 @@ The final `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` and `BUILDER_EXIT_CONTRACT_RUN ## Security Considerations - **Deposit proof-of-possession at the consensus layer.** The consensus layer verifies the proof-of-possession over the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` on a builder's first registration, and ignores the signature for top-ups. The per-block cap bounds how many such verifications the consensus layer performs per block; see *Spam and state growth* below for the full anti-abuse picture. -- **Cross-class deposit signatures.** Builder deposits reuse the validator deposit proof-of-possession check — the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` under `DOMAIN_DEPOSIT` — so a builder-deposit signature and a validator-deposit signature over the same tuple are interchangeable. A third party can therefore take any public deposit proof-of-possession and submit it as a builder deposit (or vice versa), registering that `pubkey` as a builder while funding the ≥1-ETH stake itself. This is low-harm: builders are non-slashable and take no duties; such a builder cannot bid (that needs the BLS key, which the submitter lacks), so it sits inert until its balance is swept to the `execution_address` named in the credential; and it can neither redirect the corresponding validator nor change an already-registered builder's credentials. A distinct builder-deposit signing domain would prevent the interchange, but it would break genesis seeding (which is signed under `DOMAIN_DEPOSIT`) and add a tooling change for a benign threat, so it is deliberately not introduced. +- **Cross-class deposit signatures.** Builder deposits reuse the validator deposit proof-of-possession check — the `DepositMessage` `(pubkey, withdrawal_credentials, amount)` under `DOMAIN_DEPOSIT`, a chain- and fork-agnostic domain — rather than a distinct builder-deposit domain. Two consequences follow. First, because a first deposit registers a builder only when its `withdrawal_credentials` is `0x03`-prefixed (see [Consensus-layer processing of records](#consensus-layer-processing-of-records)), a *validator* deposit proof-of-possession — which commits to a `0x00`/`0x01`/`0x02` credential — cannot be replayed to register a builder, and (post-fork) a *builder* proof-of-possession routed to the validator deposit contract is dropped; the two classes do not cross-register. Second, what remains replayable is a *builder's own* public proof-of-possession: because `DOMAIN_DEPOSIT` ignores the chain and fork, anyone can take a builder deposit's public `(pubkey, withdrawal_credentials, amount, signature)` from any network and resubmit it as a builder deposit, funding the ≥1-ETH stake themselves. This is low-harm: the signature commits to the original signer's own `0x03` credential, so the result is exactly the builder that signer authorized — non-slashable, unable to bid (that needs the BLS key the submitter lacks), with its balance ultimately swept to the signer's chosen `execution_address`; the replayer donates only stake and timing, and can redirect nothing. A distinct builder-deposit signing domain would close even this benign replay, but it would break genesis seeding (which is signed under `DOMAIN_DEPOSIT`) and add a tooling change for no real gain, so it is deliberately not introduced. - **Exit authorization.** The exit contract records `msg.sender` as `source_address` and performs no further check. Because the request carries no signature, this is the sole authorization: the consensus layer MUST initiate an exit only when `source_address` equals the target builder's `execution_address`, or an arbitrary caller could exit a builder it does not control. A builder's only exit authorizer is therefore its `execution_address`; the voluntary-exit (BLS-key) path is removed for builders. +- **Custodial-split exit standoff.** A builder's exit precondition requires its pending balance to be zero (`get_pending_balance_to_withdraw_for_builder == 0`), every winning bid adds a pending payment, and the `execution_address` is the builder's sole exit authorizer (the BLS voluntary-exit path is removed). When the `execution_address` (the capital owner) and the BLS key (the bidding operator) are held by different parties — a custodial or staking-pool arrangement this design explicitly enables — the operator can keep the pending balance non-zero by continuing to win bids, so the capital owner cannot satisfy the exit precondition and the stake stays locked (a builder that never exits is never swept). The standoff is self-limiting, since the operator's bids must keep being included on-chain, but the protocol gives the `execution_address` holder no on-chain lever to halt bidding. Parties delegating builder operation SHOULD retain off-chain (contractual or operational) control over the operator's bidding, so a delegated builder can always be brought to a state in which it can exit. - **Same public key as validator and builder.** Because the registries are keyed by independent request types, one public key may exist as both a validator and a builder. The two are distinct entries with distinct indices and distinct lifecycles; neither request type can act on the other registry. - **Replayable deposit records.** A deposit's `(pubkey, withdrawal_credentials, amount, signature)` is public in calldata, so a third party can submit a further `0x03` record for an already-registered builder at an arbitrary amount (funding it themselves). The consensus layer treats any `0x03` record for an already-registered `pubkey` as a top-up — crediting stake but ignoring the credentials and signature — so the replay cannot redirect a builder's withdrawals or re-register it; it is a harmless funded stake addition. - **Spam and state growth.** The per-block cap bounds only the drain rate — the consensus-layer verifications and the `request_data` size per block — not enqueue: within a block, appends are limited only by gas, and the in-state queue grows across blocks, reclaiming slots only when it fully drains. Queue growth is instead gated by the value locked per record: every deposit locks at least `BUILDER_MIN_DEPOSIT` (1 ETH) plus the fee, so growing the queue by N records costs at least N ETH locked. A griefer submitting **valid** proofs-of-possession forfeits nothing — the stake becomes a real, withdrawable builder balance (a capital-lock for `MIN_BUILDER_WITHDRAWABILITY_DELAY`, not a burn) — so post-fork onboarding can be throttled behind a FIFO wall of attacker deposits for the cost of locking capital; the cap plus FIFO ordering, not the fee, is the binding throttle. This is tolerable because the time-critical genesis builder set is seeded before the fork through the uncapped onboarding path, not through the steady-state contract. -- **Locked funds.** The request fee, any overpayment or sub-gwei remainder, and the principal of a first deposit whose proof-of-possession the consensus layer rejects are permanently locked in the predeploy (which has no withdrawal path) and irrecoverable by anyone — including an honest depositor who submits a bad signature, since the execution layer cannot verify BLS and the consensus-layer rejection is silent. This mirrors EIP-7002/7251; submitters SHOULD verify the proof-of-possession off-chain before broadcasting. `BUILDER_MIN_DEPOSIT` is enforced only at the execution layer (as the validator deposit contract enforces its own minimum), with no consensus-layer re-assertion. +- **Locked funds.** The request fee, any overpayment or sub-gwei remainder, and the principal of a first deposit the consensus layer rejects — one with an invalid proof-of-possession, or with a `withdrawal_credentials` that is not `0x03`-prefixed — are permanently locked in the predeploy (which has no withdrawal path) and irrecoverable by anyone, including an honest depositor who submits a bad signature, since the execution layer cannot verify BLS and the consensus-layer rejection is silent. This mirrors EIP-7002/7251; submitters SHOULD verify the proof-of-possession and the `0x03` credential prefix off-chain before broadcasting. `BUILDER_MIN_DEPOSIT` is enforced only at the execution layer (as the validator deposit contract enforces its own minimum), with no consensus-layer re-assertion. - **System-read access control and per-block cap.** Only `SYSTEM_ADDRESS` may invoke the end-of-block dequeue; any other empty-calldata call is the fee getter and does not modify state, so a non-system caller cannot drain or replay the queue. Each contract returns at most `MAX_REQUESTS_PER_BLOCK` records per block, bounding both the size each predeploy contributes to the block requests and the consensus-layer work to process them; excess records remain queued for later blocks. - **Validator-contract co-existence.** The validator deposit contract and the validator request predeploys are unmodified; this EIP changes only EIP-7732's builder onboarding and exit routing (see [Changes to EIP-7732](#changes-to-eip-7732)). From ff4ff86797a20dbf454c0665af50598f36521bcf Mon Sep 17 00:00:00 2001 From: Cayman Date: Thu, 4 Jun 2026 09:54:59 +0200 Subject: [PATCH 11/12] chore: update eip number and eth magicians link --- EIPS/{eip-draft_builder_requests.md => eip-8282.md} | 7 ++++--- assets/{eip-draft_builder_requests => eip-8282}/.gitignore | 0 assets/{eip-draft_builder_requests => eip-8282}/README.md | 2 +- .../builder_requests.sol | 2 +- .../{eip-draft_builder_requests => eip-8282}/foundry.toml | 0 .../test/BuilderRequests.t.sol | 0 .../test/TestHarness.sol | 0 7 files changed, 6 insertions(+), 5 deletions(-) rename EIPS/{eip-draft_builder_requests.md => eip-8282.md} (95%) rename assets/{eip-draft_builder_requests => eip-8282}/.gitignore (100%) rename assets/{eip-draft_builder_requests => eip-8282}/README.md (98%) rename assets/{eip-draft_builder_requests => eip-8282}/builder_requests.sol (99%) rename assets/{eip-draft_builder_requests => eip-8282}/foundry.toml (100%) rename assets/{eip-draft_builder_requests => eip-8282}/test/BuilderRequests.t.sol (100%) rename assets/{eip-draft_builder_requests => eip-8282}/test/TestHarness.sol (100%) diff --git a/EIPS/eip-draft_builder_requests.md b/EIPS/eip-8282.md similarity index 95% rename from EIPS/eip-draft_builder_requests.md rename to EIPS/eip-8282.md index 9def4764268bd7..d60174cc7e41d2 100644 --- a/EIPS/eip-draft_builder_requests.md +++ b/EIPS/eip-8282.md @@ -1,8 +1,9 @@ --- +eip: 8282 title: Builder Execution Requests description: Predeploy builder deposit and exit request contracts for EIP-7732 builders on the EIP-7685 request bus author: Cayman (@wemeetagain), Nico Flaig , Matthew Keil -discussions-to: +discussions-to: https://ethereum-magicians.org/t/eip-8282-builder-execution-requests/28699 status: Draft type: Standards Track category: Core @@ -171,11 +172,11 @@ At the consensus layer it modifies EIP-7732 (see [Changes to EIP-7732](#changes- ## Test Cases -A Foundry test suite under `../assets/eip-draft_builder_requests/test/` exercises both predeploys against the shared queue. Coverage includes: the `deposit(...)` happy path (the `SYSTEM_ADDRESS` read returns the exact 184-byte `pubkey ++ withdrawal_credentials ++ amount ++ signature` record) and its input-shape and insufficient-value rejections; the `exit(...)` happy path (the read returns the exact 68-byte `source_address ++ pubkey` record, with `source_address` taken from the caller) and its rejections; the EIP-1559 fee (minimum at `excess == 0`, rising after a block above `TARGET_REQUESTS_PER_BLOCK`, and the fee getter); the per-block cap and FIFO drain order, queue reset on empty, and rejection of a non-`SYSTEM_ADDRESS` system read; and the `EXCESS_INHIBITOR` (fee getter and requests revert before activation, and the first system call clears the inhibitor). +A Foundry test suite under the [`test/`](../assets/eip-8282/test/) directory exercises both predeploys against the shared queue. Coverage includes: the `deposit(...)` happy path (the `SYSTEM_ADDRESS` read returns the exact 184-byte `pubkey ++ withdrawal_credentials ++ amount ++ signature` record) and its input-shape and insufficient-value rejections; the `exit(...)` happy path (the read returns the exact 68-byte `source_address ++ pubkey` record, with `source_address` taken from the caller) and its rejections; the EIP-1559 fee (minimum at `excess == 0`, rising after a block above `TARGET_REQUESTS_PER_BLOCK`, and the fee getter); the per-block cap and FIFO drain order, queue reset on empty, and rejection of a non-`SYSTEM_ADDRESS` system read; and the `EXCESS_INHIBITOR` (fee getter and requests revert before activation, and the first system call clears the inhibitor). ## Reference Implementation -Solidity source for both predeploys is published at [`../assets/eip-draft_builder_requests/builder_requests.sol`](../assets/eip-draft_builder_requests/builder_requests.sol), with the test harness and Foundry configuration alongside it. The file defines a shared `RequestQueue` base (queue, EIP-1559 fee, `EXCESS_INHIBITOR`, and `SYSTEM_ADDRESS` end-of-block read) plus `BuilderDepositContract` and `BuilderExitContract`. The optimised runtime bytecode of the current draft is approximately 1.8 KiB for the deposit contract and 1.3 KiB for the exit contract — both far within the [EIP-170](./eip-170.md) 24 KiB limit. +Solidity source for both predeploys is published at [`builder_requests.sol`](../assets/eip-8282/builder_requests.sol), with the test harness and Foundry configuration alongside it. The file defines a shared `RequestQueue` base (queue, EIP-1559 fee, `EXCESS_INHIBITOR`, and `SYSTEM_ADDRESS` end-of-block read) plus `BuilderDepositContract` and `BuilderExitContract`. The optimised runtime bytecode of the current draft is approximately 1.8 KiB for the deposit contract and 1.3 KiB for the exit contract — both far within the [EIP-170](./eip-170.md) 24 KiB limit. The final `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` and `BUILDER_EXIT_CONTRACT_RUNTIME_CODE`, the predeploy addresses, and the request-type bytes will be locked in once the contracts have been independently audited. The runtime bytecode, the exact compiler version and settings used to produce it, and the contracts' storage layout MUST be pinned together at that point, so the canonical bytecode is independently reproducible. diff --git a/assets/eip-draft_builder_requests/.gitignore b/assets/eip-8282/.gitignore similarity index 100% rename from assets/eip-draft_builder_requests/.gitignore rename to assets/eip-8282/.gitignore diff --git a/assets/eip-draft_builder_requests/README.md b/assets/eip-8282/README.md similarity index 98% rename from assets/eip-draft_builder_requests/README.md rename to assets/eip-8282/README.md index 2d34a02fb83774..c640a35ac4e6a9 100644 --- a/assets/eip-draft_builder_requests/README.md +++ b/assets/eip-8282/README.md @@ -1,4 +1,4 @@ -# EIP-XXXX: Builder Execution Requests — Assets +# EIP-8282: Builder Execution Requests — Assets Reference Solidity for the proposal, plus a Foundry test suite. diff --git a/assets/eip-draft_builder_requests/builder_requests.sol b/assets/eip-8282/builder_requests.sol similarity index 99% rename from assets/eip-draft_builder_requests/builder_requests.sol rename to assets/eip-8282/builder_requests.sol index bfeef13dfd9c29..11daea0851987d 100644 --- a/assets/eip-draft_builder_requests/builder_requests.sol +++ b/assets/eip-8282/builder_requests.sol @@ -3,7 +3,7 @@ pragma solidity 0.6.11; // ─────────────────────────────────────────────────────────────────────────────── -// EIP-XXXX: Builder Execution Requests +// EIP-8282: Builder Execution Requests // // Two EIP-7685 request predeploys for the EIP-7732 builder population, modelled // on the EIP-7002 (withdrawals) / EIP-7251 (consolidations) "request bus": diff --git a/assets/eip-draft_builder_requests/foundry.toml b/assets/eip-8282/foundry.toml similarity index 100% rename from assets/eip-draft_builder_requests/foundry.toml rename to assets/eip-8282/foundry.toml diff --git a/assets/eip-draft_builder_requests/test/BuilderRequests.t.sol b/assets/eip-8282/test/BuilderRequests.t.sol similarity index 100% rename from assets/eip-draft_builder_requests/test/BuilderRequests.t.sol rename to assets/eip-8282/test/BuilderRequests.t.sol diff --git a/assets/eip-draft_builder_requests/test/TestHarness.sol b/assets/eip-8282/test/TestHarness.sol similarity index 100% rename from assets/eip-draft_builder_requests/test/TestHarness.sol rename to assets/eip-8282/test/TestHarness.sol From 396c3c428080b04e2ed71607aa98ca77ab6ec8bc Mon Sep 17 00:00:00 2001 From: Cayman Date: Thu, 4 Jun 2026 11:51:32 +0200 Subject: [PATCH 12/12] fix: satisfy markdownlint MD049 and htmlproofer --- EIPS/eip-8282.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/EIPS/eip-8282.md b/EIPS/eip-8282.md index d60174cc7e41d2..fb8c1fd38be728 100644 --- a/EIPS/eip-8282.md +++ b/EIPS/eip-8282.md @@ -53,8 +53,8 @@ All address and request-type values below are placeholders. The `0x03`/`0x04` re | `REQUEST_FEE_UPDATE_FRACTION` | `17` | Controls the fee's rate of change | | `EXCESS_INHIBITOR` | `2**256-1` | Excess value that makes the fee getter revert before the first system call (as in [EIP-7002](./eip-7002.md)/[EIP-7251](./eip-7251.md)); set at deployment, cleared by the first system call | | `BUILDER_MIN_DEPOSIT` | `1000000000000000000` | Minimum credited stake for a deposit, in wei (1 ETH — the [EIP-7732](./eip-7732.md) builder minimum) | -| `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder deposit contract | -| `BUILDER_EXIT_CONTRACT_RUNTIME_CODE` | _see [Reference Implementation](#reference-implementation)_ | Runtime bytecode of the builder exit contract | +| `BUILDER_DEPOSIT_CONTRACT_RUNTIME_CODE` | *see [Reference Implementation](#reference-implementation)* | Runtime bytecode of the builder deposit contract | +| `BUILDER_EXIT_CONTRACT_RUNTIME_CODE` | *see [Reference Implementation](#reference-implementation)* | Runtime bytecode of the builder exit contract | ### Deployment @@ -172,7 +172,7 @@ At the consensus layer it modifies EIP-7732 (see [Changes to EIP-7732](#changes- ## Test Cases -A Foundry test suite under the [`test/`](../assets/eip-8282/test/) directory exercises both predeploys against the shared queue. Coverage includes: the `deposit(...)` happy path (the `SYSTEM_ADDRESS` read returns the exact 184-byte `pubkey ++ withdrawal_credentials ++ amount ++ signature` record) and its input-shape and insufficient-value rejections; the `exit(...)` happy path (the read returns the exact 68-byte `source_address ++ pubkey` record, with `source_address` taken from the caller) and its rejections; the EIP-1559 fee (minimum at `excess == 0`, rising after a block above `TARGET_REQUESTS_PER_BLOCK`, and the fee getter); the per-block cap and FIFO drain order, queue reset on empty, and rejection of a non-`SYSTEM_ADDRESS` system read; and the `EXCESS_INHIBITOR` (fee getter and requests revert before activation, and the first system call clears the inhibitor). +A Foundry test suite ([`BuilderRequests.t.sol`](../assets/eip-8282/test/BuilderRequests.t.sol)) exercises both predeploys against the shared queue. Coverage includes: the `deposit(...)` happy path (the `SYSTEM_ADDRESS` read returns the exact 184-byte `pubkey ++ withdrawal_credentials ++ amount ++ signature` record) and its input-shape and insufficient-value rejections; the `exit(...)` happy path (the read returns the exact 68-byte `source_address ++ pubkey` record, with `source_address` taken from the caller) and its rejections; the EIP-1559 fee (minimum at `excess == 0`, rising after a block above `TARGET_REQUESTS_PER_BLOCK`, and the fee getter); the per-block cap and FIFO drain order, queue reset on empty, and rejection of a non-`SYSTEM_ADDRESS` system read; and the `EXCESS_INHIBITOR` (fee getter and requests revert before activation, and the first system call clears the inhibitor). ## Reference Implementation