From 4c28ec4dc1f162b8e6b26dfedfa3c0bf55dfb919 Mon Sep 17 00:00:00 2001 From: pk910 Date: Thu, 7 May 2026 18:33:38 +0200 Subject: [PATCH] add generate_batch_deposits task & mass builder deposit test --- pkg/tasks/generate_batch_deposits/README.md | 68 ++ .../batch_contract/BatchDeposit.sol | 54 ++ .../batch_contract/batch_deposit_contract.go | 255 ++++++ .../batch_contract/build/BatchDeposit.abi | 1 + .../batch_contract/build/BatchDeposit.bin | 1 + .../batch_contract/build/IDepositContract.abi | 1 + .../batch_contract/build/IDepositContract.bin | 0 pkg/tasks/generate_batch_deposits/config.go | 77 ++ pkg/tasks/generate_batch_deposits/task.go | 829 ++++++++++++++++++ pkg/tasks/tasks.go | 2 + playbooks/gloas-dev/builder-deposit-spam.yaml | 75 ++ 11 files changed, 1363 insertions(+) create mode 100644 pkg/tasks/generate_batch_deposits/README.md create mode 100644 pkg/tasks/generate_batch_deposits/batch_contract/BatchDeposit.sol create mode 100644 pkg/tasks/generate_batch_deposits/batch_contract/batch_deposit_contract.go create mode 100644 pkg/tasks/generate_batch_deposits/batch_contract/build/BatchDeposit.abi create mode 100644 pkg/tasks/generate_batch_deposits/batch_contract/build/BatchDeposit.bin create mode 100644 pkg/tasks/generate_batch_deposits/batch_contract/build/IDepositContract.abi create mode 100644 pkg/tasks/generate_batch_deposits/batch_contract/build/IDepositContract.bin create mode 100644 pkg/tasks/generate_batch_deposits/config.go create mode 100644 pkg/tasks/generate_batch_deposits/task.go create mode 100644 playbooks/gloas-dev/builder-deposit-spam.yaml diff --git a/pkg/tasks/generate_batch_deposits/README.md b/pkg/tasks/generate_batch_deposits/README.md new file mode 100644 index 00000000..14578f77 --- /dev/null +++ b/pkg/tasks/generate_batch_deposits/README.md @@ -0,0 +1,68 @@ +## `generate_batch_deposits` Task + +### Description +The `generate_batch_deposits` task spams the chain with valid, unique validator deposits by routing them through a small forwarder contract (`BatchDeposit`). Every deposit carries a freshly-derived BLS keypair and a fully-valid signature, which forces the consensus layer through the worst-case per-deposit signature verification path. + +If `batchContract` is empty the task deploys a fresh forwarder bound to the configured deposit contract before generation starts and exposes its address via task outputs. + +### Use Cases + +- Stress-test consensus client deposit signature verification. +- Fill the deposit/pending queue at maximum throughput against a devnet. +- Reproduce edge cases where many deposits land in a single block. + +### Configuration Parameters + +- **`limitPerSlot`**: Maximum number of deposits to generate per slot. Counted in deposits, not in batches. +- **`limitTotal`**: Total deposits to generate. +- **`limitPendingBatches`**: Maximum number of in-flight batch transactions. +- **`mnemonic`**: Mnemonic phrase used to derive validator keys. +- **`startIndex`**: Index within the mnemonic at which to start key derivation. +- **`indexCount`**: Maximum number of validator keys to derive (an alternative cap to `limitTotal`). +- **`walletPrivkey`**: Private key of the wallet used to send the batch transactions and (if needed) deploy the contract. +- **`depositContract`**: Address of the beacon chain deposit contract. +- **`batchContract`**: Optional address of an already-deployed `BatchDeposit` forwarder. If empty, the task deploys one on start. +- **`batchSize`**: Number of deposits per batched transaction. Default `100`. +- **`batchTxGasLimit`**: Gas limit per batched transaction. Default `12_000_000`. +- **`depositAmount`**: ETH amount per deposit. Default `32` ETH. +- **`depositTxFeeCap`** / **`depositTxTipCap`**: Fee/tip caps (wei) for batch transactions. +- **`withdrawalCredentials`**: Required 32-byte withdrawal credentials shared by every deposit in every batch (e.g. `0x03 + 11 zero bytes + 20-byte address` for builder credentials). +- **`clientPattern`** / **`excludeClientPattern`**: Client selection regexes. +- **`awaitReceipt`**: Wait for every batch transaction receipt before completing. +- **`failOnReject`**: Fail the task if any batch transaction is rejected or reverted. +- **`awaitInclusion`**: Wait for every individual deposit to appear in a beacon block before completing. + +### Outputs + +- **`batchContract`**: Address of the forwarder contract used (or deployed by) this task. +- **`validatorPubkeys`**: All derived validator pubkeys, in the order they were submitted. +- **`batchTransactions`**: Hashes of the submitted batch transactions. +- **`batchReceipts`**: Receipts for the batch transactions (when `awaitReceipt` is enabled). +- **`includedDeposits`**: Number of deposits confirmed on the beacon chain (when `awaitInclusion` is enabled). + +### Defaults + +```yaml +- name: generate_batch_deposits + config: + limitPerSlot: 0 + limitTotal: 0 + limitPendingBatches: 0 + mnemonic: "" + startIndex: 0 + indexCount: 0 + walletPrivkey: "" + depositContract: "" + batchContract: "" + batchSize: 100 + batchTxGasLimit: 12000000 + depositAmount: 32 + depositTxFeeCap: 100000000000 + depositTxTipCap: 1000000000 + withdrawalCredentials: "" + clientPattern: "" + excludeClientPattern: "" + awaitReceipt: false + failOnReject: false + awaitInclusion: false +``` diff --git a/pkg/tasks/generate_batch_deposits/batch_contract/BatchDeposit.sol b/pkg/tasks/generate_batch_deposits/batch_contract/BatchDeposit.sol new file mode 100644 index 00000000..d8d022eb --- /dev/null +++ b/pkg/tasks/generate_batch_deposits/batch_contract/BatchDeposit.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IDepositContract { + function deposit( + bytes calldata pubkey, + bytes calldata withdrawal_credentials, + bytes calldata signature, + bytes32 deposit_data_root + ) external payable; +} + +/// @notice Forwards a batch of deposits to the beacon-chain deposit contract in a single transaction. +/// @dev All deposits in a batch share the same withdrawal credentials and amount. +/// Each deposit must still carry its own pubkey, signature, and data root, so the +/// consensus layer is forced to verify every BLS signature individually. +contract BatchDeposit { + IDepositContract public immutable depositContract; + + constructor(address _depositContract) { + require(_depositContract != address(0), "BatchDeposit: zero deposit contract"); + depositContract = IDepositContract(_depositContract); + } + + /// @param pubkeys Concatenated BLS pubkeys, 48 bytes per deposit. + /// @param signatures Concatenated BLS signatures, 96 bytes per deposit. + /// @param dataRoots One deposit_data_root per deposit. + /// @param withdrawalCredentials Shared 32-byte withdrawal credentials. + /// @param amountWei Amount (in wei) forwarded per deposit. msg.value must equal amountWei * dataRoots.length. + function batchDeposit( + bytes calldata pubkeys, + bytes calldata signatures, + bytes32[] calldata dataRoots, + bytes calldata withdrawalCredentials, + uint256 amountWei + ) external payable { + uint256 count = dataRoots.length; + require(count > 0, "BatchDeposit: empty batch"); + require(pubkeys.length == count * 48, "BatchDeposit: bad pubkeys length"); + require(signatures.length == count * 96, "BatchDeposit: bad signatures length"); + require(withdrawalCredentials.length == 32, "BatchDeposit: bad creds length"); + require(msg.value == amountWei * count, "BatchDeposit: bad value"); + + IDepositContract dc = depositContract; + for (uint256 i = 0; i < count; ++i) { + dc.deposit{value: amountWei}( + pubkeys[i * 48:(i + 1) * 48], + withdrawalCredentials, + signatures[i * 96:(i + 1) * 96], + dataRoots[i] + ); + } + } +} diff --git a/pkg/tasks/generate_batch_deposits/batch_contract/batch_deposit_contract.go b/pkg/tasks/generate_batch_deposits/batch_contract/batch_deposit_contract.go new file mode 100644 index 00000000..60703bcf --- /dev/null +++ b/pkg/tasks/generate_batch_deposits/batch_contract/batch_deposit_contract.go @@ -0,0 +1,255 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package batchcontract + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// BatchDepositContractMetaData contains all meta data concerning the BatchDepositContract contract. +var BatchDepositContractMetaData = &bind.MetaData{ + ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"_depositContract\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[{\"internalType\":\"bytes\",\"name\":\"pubkeys\",\"type\":\"bytes\"},{\"internalType\":\"bytes\",\"name\":\"signatures\",\"type\":\"bytes\"},{\"internalType\":\"bytes32[]\",\"name\":\"dataRoots\",\"type\":\"bytes32[]\"},{\"internalType\":\"bytes\",\"name\":\"withdrawalCredentials\",\"type\":\"bytes\"},{\"internalType\":\"uint256\",\"name\":\"amountWei\",\"type\":\"uint256\"}],\"name\":\"batchDeposit\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"depositContract\",\"outputs\":[{\"internalType\":\"contractIDepositContract\",\"name\":\"\",\"type\":\"address\"}],\"stateMutability\":\"view\",\"type\":\"function\"}]", + Bin: "0x60a0346100c957601f6105e038819003918201601f19168301916001600160401b038311848410176100cd578084926020946040528339810103126100c957516001600160a01b038116908190036100c9578015610078576080526040516104fe90816100e28239608051818181604701526101840152f35b60405162461bcd60e51b815260206004820152602360248201527f42617463684465706f7369743a207a65726f206465706f73697420636f6e74726044820152621858dd60ea1b6064820152608490fd5b5f80fd5b634e487b7160e01b5f52604160045260245ffdfe6080806040526004361015610012575f80fd5b5f3560e01c908163ddbd9dd91461007a575063e94ad65b14610032575f80fd5b34610076575f366003190112610076576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b5f80fd5b60a03660031901126100765760043567ffffffffffffffff8111610076576100a6903690600401610462565b9160243567ffffffffffffffff8111610076576100c7903690600401610462565b9390916044359167ffffffffffffffff831161007657366023840112156100765782600401359167ffffffffffffffff8311610076573660248460051b860101116100765760643567ffffffffffffffff81116100765761012c903690600401610462565b9790956084359285156104205750603085028581046030036102eb5784036103dc57606085028581046060036102eb57820361038b5760208903610346578483028381048614841517156102eb5734036103015793967f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031694905f5b898110156102ff57603081029080158183046030148117156102eb5760018201908183116102eb5760308202938215948381046030148617156102eb576101f8918b88610490565b929094606085029285840460601417156102eb576060820291820460601417156102eb57610227918888610490565b9390918a3b15610076576102888f93610264968f610276905f976040519a8b9889986304512a2360e31b8a52608060048b015260848a01916104a8565b878103600319016024890152916104a8565b848103600319016044860152916104a8565b60248d8660051b01013560648301520381898c5af180156102e0576102b2575b60019150016101b0565b67ffffffffffffffff82116102cc576001916040526102a8565b634e487b7160e01b5f52604160045260245ffd5b6040513d5f823e3d90fd5b634e487b7160e01b5f52601160045260245ffd5b005b60405162461bcd60e51b815260206004820152601760248201527f42617463684465706f7369743a206261642076616c75650000000000000000006044820152606490fd5b60405162461bcd60e51b815260206004820152601e60248201527f42617463684465706f7369743a20626164206372656473206c656e67746800006044820152606490fd5b60405162461bcd60e51b815260206004820152602360248201527f42617463684465706f7369743a20626164207369676e617475726573206c656e6044820152620cee8d60eb1b6064820152608490fd5b606460405162461bcd60e51b815260206004820152602060248201527f42617463684465706f7369743a20626164207075626b657973206c656e6774686044820152fd5b62461bcd60e51b815260206004820152601960248201527f42617463684465706f7369743a20656d707479206261746368000000000000006044820152606490fd5b9181601f840112156100765782359167ffffffffffffffff8311610076576020838186019501011161007657565b90939293848311610076578411610076578101920390565b908060209392818452848401375f828201840152601f01601f191601019056fea26469706673582212200fa012285dbbc8ae854030852f9cb7b2f6496cbff2a5e662c11e8aedb1e99e3b64736f6c634300081e0033", +} + +// BatchDepositContractABI is the input ABI used to generate the binding from. +// Deprecated: Use BatchDepositContractMetaData.ABI instead. +var BatchDepositContractABI = BatchDepositContractMetaData.ABI + +// BatchDepositContractBin is the compiled bytecode used for deploying new contracts. +// Deprecated: Use BatchDepositContractMetaData.Bin instead. +var BatchDepositContractBin = BatchDepositContractMetaData.Bin + +// DeployBatchDepositContract deploys a new Ethereum contract, binding an instance of BatchDepositContract to it. +func DeployBatchDepositContract(auth *bind.TransactOpts, backend bind.ContractBackend, _depositContract common.Address) (common.Address, *types.Transaction, *BatchDepositContract, error) { + parsed, err := BatchDepositContractMetaData.GetAbi() + if err != nil { + return common.Address{}, nil, nil, err + } + if parsed == nil { + return common.Address{}, nil, nil, errors.New("GetABI returned nil") + } + + address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(BatchDepositContractBin), backend, _depositContract) + if err != nil { + return common.Address{}, nil, nil, err + } + return address, tx, &BatchDepositContract{BatchDepositContractCaller: BatchDepositContractCaller{contract: contract}, BatchDepositContractTransactor: BatchDepositContractTransactor{contract: contract}, BatchDepositContractFilterer: BatchDepositContractFilterer{contract: contract}}, nil +} + +// BatchDepositContract is an auto generated Go binding around an Ethereum contract. +type BatchDepositContract struct { + BatchDepositContractCaller // Read-only binding to the contract + BatchDepositContractTransactor // Write-only binding to the contract + BatchDepositContractFilterer // Log filterer for contract events +} + +// BatchDepositContractCaller is an auto generated read-only Go binding around an Ethereum contract. +type BatchDepositContractCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// BatchDepositContractTransactor is an auto generated write-only Go binding around an Ethereum contract. +type BatchDepositContractTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// BatchDepositContractFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type BatchDepositContractFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// BatchDepositContractSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type BatchDepositContractSession struct { + Contract *BatchDepositContract // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// BatchDepositContractCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type BatchDepositContractCallerSession struct { + Contract *BatchDepositContractCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// BatchDepositContractTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type BatchDepositContractTransactorSession struct { + Contract *BatchDepositContractTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// BatchDepositContractRaw is an auto generated low-level Go binding around an Ethereum contract. +type BatchDepositContractRaw struct { + Contract *BatchDepositContract // Generic contract binding to access the raw methods on +} + +// BatchDepositContractCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type BatchDepositContractCallerRaw struct { + Contract *BatchDepositContractCaller // Generic read-only contract binding to access the raw methods on +} + +// BatchDepositContractTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type BatchDepositContractTransactorRaw struct { + Contract *BatchDepositContractTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewBatchDepositContract creates a new instance of BatchDepositContract, bound to a specific deployed contract. +func NewBatchDepositContract(address common.Address, backend bind.ContractBackend) (*BatchDepositContract, error) { + contract, err := bindBatchDepositContract(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &BatchDepositContract{BatchDepositContractCaller: BatchDepositContractCaller{contract: contract}, BatchDepositContractTransactor: BatchDepositContractTransactor{contract: contract}, BatchDepositContractFilterer: BatchDepositContractFilterer{contract: contract}}, nil +} + +// NewBatchDepositContractCaller creates a new read-only instance of BatchDepositContract, bound to a specific deployed contract. +func NewBatchDepositContractCaller(address common.Address, caller bind.ContractCaller) (*BatchDepositContractCaller, error) { + contract, err := bindBatchDepositContract(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &BatchDepositContractCaller{contract: contract}, nil +} + +// NewBatchDepositContractTransactor creates a new write-only instance of BatchDepositContract, bound to a specific deployed contract. +func NewBatchDepositContractTransactor(address common.Address, transactor bind.ContractTransactor) (*BatchDepositContractTransactor, error) { + contract, err := bindBatchDepositContract(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &BatchDepositContractTransactor{contract: contract}, nil +} + +// NewBatchDepositContractFilterer creates a new log filterer instance of BatchDepositContract, bound to a specific deployed contract. +func NewBatchDepositContractFilterer(address common.Address, filterer bind.ContractFilterer) (*BatchDepositContractFilterer, error) { + contract, err := bindBatchDepositContract(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &BatchDepositContractFilterer{contract: contract}, nil +} + +// bindBatchDepositContract binds a generic wrapper to an already deployed contract. +func bindBatchDepositContract(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := BatchDepositContractMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_BatchDepositContract *BatchDepositContractRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _BatchDepositContract.Contract.BatchDepositContractCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_BatchDepositContract *BatchDepositContractRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _BatchDepositContract.Contract.BatchDepositContractTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_BatchDepositContract *BatchDepositContractRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _BatchDepositContract.Contract.BatchDepositContractTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_BatchDepositContract *BatchDepositContractCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _BatchDepositContract.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_BatchDepositContract *BatchDepositContractTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _BatchDepositContract.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_BatchDepositContract *BatchDepositContractTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _BatchDepositContract.Contract.contract.Transact(opts, method, params...) +} + +// DepositContract is a free data retrieval call binding the contract method 0xe94ad65b. +// +// Solidity: function depositContract() view returns(address) +func (_BatchDepositContract *BatchDepositContractCaller) DepositContract(opts *bind.CallOpts) (common.Address, error) { + var out []interface{} + err := _BatchDepositContract.contract.Call(opts, &out, "depositContract") + + if err != nil { + return *new(common.Address), err + } + + out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) + + return out0, err + +} + +// DepositContract is a free data retrieval call binding the contract method 0xe94ad65b. +// +// Solidity: function depositContract() view returns(address) +func (_BatchDepositContract *BatchDepositContractSession) DepositContract() (common.Address, error) { + return _BatchDepositContract.Contract.DepositContract(&_BatchDepositContract.CallOpts) +} + +// DepositContract is a free data retrieval call binding the contract method 0xe94ad65b. +// +// Solidity: function depositContract() view returns(address) +func (_BatchDepositContract *BatchDepositContractCallerSession) DepositContract() (common.Address, error) { + return _BatchDepositContract.Contract.DepositContract(&_BatchDepositContract.CallOpts) +} + +// BatchDeposit is a paid mutator transaction binding the contract method 0xddbd9dd9. +// +// Solidity: function batchDeposit(bytes pubkeys, bytes signatures, bytes32[] dataRoots, bytes withdrawalCredentials, uint256 amountWei) payable returns() +func (_BatchDepositContract *BatchDepositContractTransactor) BatchDeposit(opts *bind.TransactOpts, pubkeys []byte, signatures []byte, dataRoots [][32]byte, withdrawalCredentials []byte, amountWei *big.Int) (*types.Transaction, error) { + return _BatchDepositContract.contract.Transact(opts, "batchDeposit", pubkeys, signatures, dataRoots, withdrawalCredentials, amountWei) +} + +// BatchDeposit is a paid mutator transaction binding the contract method 0xddbd9dd9. +// +// Solidity: function batchDeposit(bytes pubkeys, bytes signatures, bytes32[] dataRoots, bytes withdrawalCredentials, uint256 amountWei) payable returns() +func (_BatchDepositContract *BatchDepositContractSession) BatchDeposit(pubkeys []byte, signatures []byte, dataRoots [][32]byte, withdrawalCredentials []byte, amountWei *big.Int) (*types.Transaction, error) { + return _BatchDepositContract.Contract.BatchDeposit(&_BatchDepositContract.TransactOpts, pubkeys, signatures, dataRoots, withdrawalCredentials, amountWei) +} + +// BatchDeposit is a paid mutator transaction binding the contract method 0xddbd9dd9. +// +// Solidity: function batchDeposit(bytes pubkeys, bytes signatures, bytes32[] dataRoots, bytes withdrawalCredentials, uint256 amountWei) payable returns() +func (_BatchDepositContract *BatchDepositContractTransactorSession) BatchDeposit(pubkeys []byte, signatures []byte, dataRoots [][32]byte, withdrawalCredentials []byte, amountWei *big.Int) (*types.Transaction, error) { + return _BatchDepositContract.Contract.BatchDeposit(&_BatchDepositContract.TransactOpts, pubkeys, signatures, dataRoots, withdrawalCredentials, amountWei) +} diff --git a/pkg/tasks/generate_batch_deposits/batch_contract/build/BatchDeposit.abi b/pkg/tasks/generate_batch_deposits/batch_contract/build/BatchDeposit.abi new file mode 100644 index 00000000..79a81a76 --- /dev/null +++ b/pkg/tasks/generate_batch_deposits/batch_contract/build/BatchDeposit.abi @@ -0,0 +1 @@ +[{"inputs":[{"internalType":"address","name":"_depositContract","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[{"internalType":"bytes","name":"pubkeys","type":"bytes"},{"internalType":"bytes","name":"signatures","type":"bytes"},{"internalType":"bytes32[]","name":"dataRoots","type":"bytes32[]"},{"internalType":"bytes","name":"withdrawalCredentials","type":"bytes"},{"internalType":"uint256","name":"amountWei","type":"uint256"}],"name":"batchDeposit","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[],"name":"depositContract","outputs":[{"internalType":"contract IDepositContract","name":"","type":"address"}],"stateMutability":"view","type":"function"}] \ No newline at end of file diff --git a/pkg/tasks/generate_batch_deposits/batch_contract/build/BatchDeposit.bin b/pkg/tasks/generate_batch_deposits/batch_contract/build/BatchDeposit.bin new file mode 100644 index 00000000..8fea493f --- /dev/null +++ b/pkg/tasks/generate_batch_deposits/batch_contract/build/BatchDeposit.bin @@ -0,0 +1 @@ +60a0346100c957601f6105e038819003918201601f19168301916001600160401b038311848410176100cd578084926020946040528339810103126100c957516001600160a01b038116908190036100c9578015610078576080526040516104fe90816100e28239608051818181604701526101840152f35b60405162461bcd60e51b815260206004820152602360248201527f42617463684465706f7369743a207a65726f206465706f73697420636f6e74726044820152621858dd60ea1b6064820152608490fd5b5f80fd5b634e487b7160e01b5f52604160045260245ffdfe6080806040526004361015610012575f80fd5b5f3560e01c908163ddbd9dd91461007a575063e94ad65b14610032575f80fd5b34610076575f366003190112610076576040517f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03168152602090f35b5f80fd5b60a03660031901126100765760043567ffffffffffffffff8111610076576100a6903690600401610462565b9160243567ffffffffffffffff8111610076576100c7903690600401610462565b9390916044359167ffffffffffffffff831161007657366023840112156100765782600401359167ffffffffffffffff8311610076573660248460051b860101116100765760643567ffffffffffffffff81116100765761012c903690600401610462565b9790956084359285156104205750603085028581046030036102eb5784036103dc57606085028581046060036102eb57820361038b5760208903610346578483028381048614841517156102eb5734036103015793967f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031694905f5b898110156102ff57603081029080158183046030148117156102eb5760018201908183116102eb5760308202938215948381046030148617156102eb576101f8918b88610490565b929094606085029285840460601417156102eb576060820291820460601417156102eb57610227918888610490565b9390918a3b15610076576102888f93610264968f610276905f976040519a8b9889986304512a2360e31b8a52608060048b015260848a01916104a8565b878103600319016024890152916104a8565b848103600319016044860152916104a8565b60248d8660051b01013560648301520381898c5af180156102e0576102b2575b60019150016101b0565b67ffffffffffffffff82116102cc576001916040526102a8565b634e487b7160e01b5f52604160045260245ffd5b6040513d5f823e3d90fd5b634e487b7160e01b5f52601160045260245ffd5b005b60405162461bcd60e51b815260206004820152601760248201527f42617463684465706f7369743a206261642076616c75650000000000000000006044820152606490fd5b60405162461bcd60e51b815260206004820152601e60248201527f42617463684465706f7369743a20626164206372656473206c656e67746800006044820152606490fd5b60405162461bcd60e51b815260206004820152602360248201527f42617463684465706f7369743a20626164207369676e617475726573206c656e6044820152620cee8d60eb1b6064820152608490fd5b606460405162461bcd60e51b815260206004820152602060248201527f42617463684465706f7369743a20626164207075626b657973206c656e6774686044820152fd5b62461bcd60e51b815260206004820152601960248201527f42617463684465706f7369743a20656d707479206261746368000000000000006044820152606490fd5b9181601f840112156100765782359167ffffffffffffffff8311610076576020838186019501011161007657565b90939293848311610076578411610076578101920390565b908060209392818452848401375f828201840152601f01601f191601019056fea26469706673582212200fa012285dbbc8ae854030852f9cb7b2f6496cbff2a5e662c11e8aedb1e99e3b64736f6c634300081e0033 \ No newline at end of file diff --git a/pkg/tasks/generate_batch_deposits/batch_contract/build/IDepositContract.abi b/pkg/tasks/generate_batch_deposits/batch_contract/build/IDepositContract.abi new file mode 100644 index 00000000..c5e7c889 --- /dev/null +++ b/pkg/tasks/generate_batch_deposits/batch_contract/build/IDepositContract.abi @@ -0,0 +1 @@ +[{"inputs":[{"internalType":"bytes","name":"pubkey","type":"bytes"},{"internalType":"bytes","name":"withdrawal_credentials","type":"bytes"},{"internalType":"bytes","name":"signature","type":"bytes"},{"internalType":"bytes32","name":"deposit_data_root","type":"bytes32"}],"name":"deposit","outputs":[],"stateMutability":"payable","type":"function"}] \ No newline at end of file diff --git a/pkg/tasks/generate_batch_deposits/batch_contract/build/IDepositContract.bin b/pkg/tasks/generate_batch_deposits/batch_contract/build/IDepositContract.bin new file mode 100644 index 00000000..e69de29b diff --git a/pkg/tasks/generate_batch_deposits/config.go b/pkg/tasks/generate_batch_deposits/config.go new file mode 100644 index 00000000..1ecda3b5 --- /dev/null +++ b/pkg/tasks/generate_batch_deposits/config.go @@ -0,0 +1,77 @@ +package generatebatchdeposits + +import ( + "errors" +) + +type Config struct { + LimitPerSlot int `yaml:"limitPerSlot" json:"limitPerSlot" require:"A.1" desc:"Maximum number of deposits to generate per slot. Counted in deposits, not batches."` + LimitTotal int `yaml:"limitTotal" json:"limitTotal" require:"A.2" desc:"Total limit on the number of deposits to generate."` + LimitPendingBatches int `yaml:"limitPendingBatches" json:"limitPendingBatches" desc:"Maximum number of pending batch transactions to allow before waiting."` + Mnemonic string `yaml:"mnemonic" json:"mnemonic" require:"B" desc:"Mnemonic phrase used to generate validator keys."` + StartIndex int `yaml:"startIndex" json:"startIndex" desc:"Index within the mnemonic from which to start generating validator keys."` + IndexCount int `yaml:"indexCount" json:"indexCount" require:"A.3" desc:"Number of validator keys to generate from the mnemonic."` + WalletPrivkey string `yaml:"walletPrivkey" json:"walletPrivkey" require:"C" desc:"Private key of the wallet used to fund deposit transactions and (if needed) deploy the batch contract."` + DepositContract string `yaml:"depositContract" json:"depositContract" require:"D" desc:"Address of the beacon chain deposit contract on the execution layer."` + BatchContract string `yaml:"batchContract" json:"batchContract" desc:"Address of an already-deployed BatchDeposit forwarder contract. If empty, a fresh contract is deployed at task start."` + BatchSize int `yaml:"batchSize" json:"batchSize" desc:"Number of deposits to bundle into a single transaction. Default 100."` + BatchTxGasLimit uint64 `yaml:"batchTxGasLimit" json:"batchTxGasLimit" desc:"Gas limit for each batched deposit transaction. Default 12,000,000."` + DepositAmount uint64 `yaml:"depositAmount" json:"depositAmount" desc:"Amount of ETH to deposit per validator. Default 32 ETH."` + DepositTxFeeCap int64 `yaml:"depositTxFeeCap" json:"depositTxFeeCap" desc:"Maximum fee cap (in wei) for batch transactions."` + DepositTxTipCap int64 `yaml:"depositTxTipCap" json:"depositTxTipCap" desc:"Maximum priority tip (in wei) for batch transactions."` + WithdrawalCredentials string `yaml:"withdrawalCredentials" json:"withdrawalCredentials" require:"E" desc:"32-byte withdrawal credentials shared by all deposits in every batch. For 0x03 builder credentials use '0x03' + 11 zero bytes + 20-byte address."` + ClientPattern string `yaml:"clientPattern" json:"clientPattern" desc:"Regex pattern to select specific client endpoints for submitting transactions."` + ExcludeClientPattern string `yaml:"excludeClientPattern" json:"excludeClientPattern" desc:"Regex pattern to exclude certain client endpoints."` + AwaitReceipt bool `yaml:"awaitReceipt" json:"awaitReceipt" desc:"Wait for batch transaction receipts on the execution layer before completing."` + FailOnReject bool `yaml:"failOnReject" json:"failOnReject" desc:"Fail the task if any batch transaction is rejected."` + AwaitInclusion bool `yaml:"awaitInclusion" json:"awaitInclusion" desc:"Wait for all generated deposits to be included in beacon blocks before completing."` +} + +func DefaultConfig() Config { + return Config{ + // Total tx fee = DepositTxFeeCap * BatchTxGasLimit. + // Keep under 1 ETH to satisfy geth's default --rpc.txfeecap=1eth. + // With 12M gas, max safe fee cap is ~83 gwei. We use 50 gwei for headroom. + DepositTxFeeCap: 50000000000, // 50 gwei + DepositTxTipCap: 1000000000, // 1 gwei + DepositAmount: 32, // 32 ETH + BatchSize: 100, + BatchTxGasLimit: 12000000, + } +} + +func (c *Config) Validate() error { + if c.LimitPerSlot == 0 && c.LimitTotal == 0 && c.IndexCount == 0 { + return errors.New("either limitPerSlot or limitTotal or indexCount must be set") + } + + if c.Mnemonic == "" { + return errors.New("mnemonic must be set") + } + + if c.WalletPrivkey == "" { + return errors.New("walletPrivkey must be set") + } + + if c.DepositContract == "" { + return errors.New("depositContract must be set") + } + + if c.WithdrawalCredentials == "" { + return errors.New("withdrawalCredentials must be set") + } + + if c.DepositAmount == 0 { + return errors.New("depositAmount must be > 0") + } + + if c.BatchSize <= 0 { + return errors.New("batchSize must be > 0") + } + + if c.BatchTxGasLimit == 0 { + return errors.New("batchTxGasLimit must be > 0") + } + + return nil +} diff --git a/pkg/tasks/generate_batch_deposits/task.go b/pkg/tasks/generate_batch_deposits/task.go new file mode 100644 index 00000000..abe906c4 --- /dev/null +++ b/pkg/tasks/generate_batch_deposits/task.go @@ -0,0 +1,829 @@ +package generatebatchdeposits + +import ( + "context" + "crypto/ecdsa" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + ethcommon "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + clientpool "github.com/ethpandaops/assertoor/pkg/clients" + "github.com/ethpandaops/assertoor/pkg/clients/consensus" + "github.com/ethpandaops/assertoor/pkg/clients/execution" + batchcontract "github.com/ethpandaops/assertoor/pkg/tasks/generate_batch_deposits/batch_contract" + "github.com/ethpandaops/assertoor/pkg/types" + "github.com/ethpandaops/go-eth2-client/spec" + "github.com/ethpandaops/spamoor/spamoor" + "github.com/ethpandaops/spamoor/txbuilder" + hbls "github.com/herumi/bls-eth-go-binary/bls" + "github.com/holiman/uint256" + "github.com/protolambda/zrnt/eth2/beacon/common" + "github.com/protolambda/ztyp/tree" + "github.com/sirupsen/logrus" + "github.com/tyler-smith/go-bip39" + util "github.com/wealdtech/go-eth2-util" +) + +const ( + outputTypeArray = "array" + outputTypeString = "string" + outputTypeNumber = "number" +) + +var ( + TaskName = "generate_batch_deposits" + TaskDescriptor = &types.TaskDescriptor{ + Name: TaskName, + Description: "Generates valid unique deposits and forwards them through a BatchDeposit contract in batchSize-sized transactions. Optionally deploys the contract first.", + Category: "validator", + Config: DefaultConfig(), + Outputs: []types.TaskOutputDefinition{ + { + Name: "batchContract", + Type: outputTypeString, + Description: "Address of the BatchDeposit forwarder contract used (deployed by this task if batchContract was empty).", + }, + { + Name: "validatorPubkeys", + Type: outputTypeArray, + Description: "Array of validator public keys for all generated deposits.", + }, + { + Name: "batchTransactions", + Type: outputTypeArray, + Description: "Array of batch transaction hashes (one per submitted batch).", + }, + { + Name: "batchReceipts", + Type: outputTypeArray, + Description: "Array of batch transaction receipts (when awaitReceipt is enabled).", + }, + { + Name: "includedDeposits", + Type: outputTypeNumber, + Description: "Number of deposits included on beacon chain (when awaitInclusion is enabled).", + }, + }, + NewTask: NewTask, + } +) + +type Task struct { + ctx *types.TaskContext + options *types.TaskOptions + config Config + logger logrus.FieldLogger + valkeySeed []byte + nextIndex uint64 + lastIndex uint64 + walletPrivKey *ecdsa.PrivateKey + depositContractAddr ethcommon.Address + batchContractAddr ethcommon.Address + withdrawalCreds []byte +} + +// preparedDeposit holds a single fully-signed deposit ready to be packed into a batch. +type preparedDeposit struct { + pubkey common.BLSPubkey + signature []byte // 96 bytes + dataRoot [32]byte +} + +// runState carries mutable state through the batched generation loop. +type runState struct { + totalDeposits int + depositsThisSlot int + pubkeys []string + txHashes []string + receipts map[string]*ethtypes.Receipt + receiptsMtx sync.Mutex + pendingWg sync.WaitGroup + pendingChan chan struct{} + targetCount int +} + +func NewTask(ctx *types.TaskContext, options *types.TaskOptions) (types.Task, error) { + return &Task{ + ctx: ctx, + options: options, + logger: ctx.Logger.GetLogger(), + }, nil +} + +func (t *Task) Config() interface{} { + return t.config +} + +func (t *Task) Timeout() time.Duration { + return t.options.Timeout.Duration +} + +func (t *Task) LoadConfig() error { + config := DefaultConfig() + + if t.options.Config != nil { + if err := t.options.Config.Unmarshal(&config); err != nil { + return fmt.Errorf("error parsing task config for %v: %w", TaskName, err) + } + } + + err := t.ctx.Vars.ConsumeVars(&config, t.options.ConfigVars) + if err != nil { + return err + } + + if valerr := config.Validate(); valerr != nil { + return valerr + } + + t.valkeySeed, err = mnemonicToSeed(config.Mnemonic) + if err != nil { + return err + } + + t.walletPrivKey, err = crypto.HexToECDSA(config.WalletPrivkey) + if err != nil { + return err + } + + creds := ethcommon.FromHex(config.WithdrawalCredentials) + if len(creds) != 32 { + return fmt.Errorf("withdrawalCredentials must be 32 bytes, got %d", len(creds)) + } + + t.withdrawalCreds = creds + t.config = config + t.depositContractAddr = ethcommon.HexToAddress(config.DepositContract) + + if config.BatchContract != "" { + t.batchContractAddr = ethcommon.HexToAddress(config.BatchContract) + } + + return nil +} + +func (t *Task) Execute(ctx context.Context) error { + if t.config.StartIndex > 0 { + t.nextIndex = uint64(t.config.StartIndex) + } + + if t.config.IndexCount > 0 { + t.lastIndex = t.nextIndex + uint64(t.config.IndexCount) + } + + clientPool := t.ctx.Scheduler.GetServices().ClientPool() + + clients, err := t.selectClients(ctx, clientPool) + if err != nil { + return err + } + + walletMgr := t.ctx.Scheduler.GetServices().WalletManager() + + txWallet, err := walletMgr.GetWalletByPrivkey(t.ctx.Scheduler.GetTestRunCtx(), t.walletPrivKey) + if err != nil { + return fmt.Errorf("cannot initialize wallet: %w", err) + } + + t.logger.Infof("wallet: %v [nonce: %v] %v ETH", txWallet.GetAddress().Hex(), txWallet.GetNonce(), txWallet.GetReadableBalance(18, 0, 4, false, false)) + + spamoorClients := make([]*spamoor.Client, len(clients)) + for i, c := range clients { + spamoorClients[i] = walletMgr.GetClient(c) + } + + if (t.batchContractAddr == ethcommon.Address{}) { + t.ctx.ReportProgress(0, "Deploying BatchDeposit contract") + + addr, deployErr := t.deployBatchContract(ctx, txWallet, spamoorClients) + if deployErr != nil { + return fmt.Errorf("failed to deploy batch contract: %w", deployErr) + } + + t.batchContractAddr = addr + t.logger.Infof("deployed BatchDeposit contract at %s", addr.Hex()) + } else { + t.logger.Infof("using existing BatchDeposit contract at %s", t.batchContractAddr.Hex()) + } + + t.ctx.Outputs.SetVar("batchContract", t.batchContractAddr.Hex()) + + var inclusionSubscription *consensus.Subscription[*consensus.Block] + + if t.config.AwaitInclusion { + inclusionSubscription = clientPool.GetConsensusPool().GetBlockCache().SubscribeBlockEvent(10) + defer inclusionSubscription.Unsubscribe() + } + + var slotSubscription *consensus.Subscription[*consensus.Block] + + if t.config.LimitPerSlot > 0 { + slotSubscription = clientPool.GetConsensusPool().GetBlockCache().SubscribeBlockEvent(10) + defer slotSubscription.Unsubscribe() + } + + bound, err := batchcontract.NewBatchDepositContract(t.batchContractAddr, clients[0].GetRPCClient().GetEthClient()) + if err != nil { + return fmt.Errorf("cannot bind BatchDeposit contract: %w", err) + } + + state := t.newRunState() + + if err := t.runBatchLoop(ctx, txWallet, bound, spamoorClients, slotSubscription, state); err != nil { + return err + } + + if t.config.AwaitReceipt { + state.pendingWg.Wait() + } + + t.publishOutputs(state) + + t.ctx.ReportProgress(100, fmt.Sprintf("Submitted %d deposits across %d batches", state.totalDeposits, len(state.txHashes))) + + if t.config.FailOnReject && t.checkReceiptFailures(state) { + return nil + } + + if t.config.AwaitInclusion && len(state.pubkeys) > 0 { + return t.awaitInclusion(ctx, inclusionSubscription, state.pubkeys) + } + + return nil +} + +func (t *Task) newRunState() *runState { + state := &runState{ + pubkeys: []string{}, + txHashes: []string{}, + receipts: map[string]*ethtypes.Receipt{}, + } + + if t.config.LimitTotal > 0 { + state.targetCount = t.config.LimitTotal + } else if t.lastIndex > t.nextIndex { + state.targetCount = int(t.lastIndex - t.nextIndex) //nolint:gosec // bounded by config + } + + if t.config.LimitPendingBatches > 0 { + state.pendingChan = make(chan struct{}, t.config.LimitPendingBatches) + } + + return state +} + +func (t *Task) runBatchLoop( + ctx context.Context, + txWallet *spamoor.Wallet, + bound *batchcontract.BatchDepositContract, + spamoorClients []*spamoor.Client, + slotSub *consensus.Subscription[*consensus.Block], + state *runState, +) error { + t.ctx.ReportProgress(0, "Starting batched deposit generation") + + for { + batchSize := t.nextBatchSize(state.totalDeposits) + if batchSize <= 0 { + return nil + } + + if t.config.LimitPerSlot > 0 && state.depositsThisSlot+batchSize > t.config.LimitPerSlot { + state.depositsThisSlot = 0 + + select { + case <-ctx.Done(): + return nil + case <-slotSub.Channel(): + } + } + + prepared, pubkeys, prepErr := t.prepareBatch(batchSize) + if prepErr != nil { + return fmt.Errorf("failed to prepare deposit batch: %w", prepErr) + } + + if state.pendingChan != nil { + select { + case <-ctx.Done(): + return nil + case state.pendingChan <- struct{}{}: + } + } + + state.pendingWg.Add(1) + + tx, sendErr := t.submitBatch(ctx, txWallet, bound, spamoorClients, prepared, t.makeOnComplete(state, len(prepared))) + if sendErr != nil { + state.pendingWg.Done() + + if state.pendingChan != nil { + <-state.pendingChan + } + + return fmt.Errorf("failed sending batch tx: %w", sendErr) + } + + state.txHashes = append(state.txHashes, tx.Hash().Hex()) + state.pubkeys = append(state.pubkeys, pubkeys...) + state.totalDeposits += len(prepared) + state.depositsThisSlot += len(prepared) + + t.reportLoopProgress(state) + + if ctx.Err() != nil { + return nil + } + + if t.lastIndex > 0 && t.nextIndex >= t.lastIndex { + return nil + } + + if t.config.LimitTotal > 0 && state.totalDeposits >= t.config.LimitTotal { + return nil + } + } +} + +// nextBatchSize returns the largest batch we can still submit, capped by the +// configured BatchSize and remaining limits. Returns 0 (or less) when we are done. +func (t *Task) nextBatchSize(totalDeposits int) int { + batchSize := t.config.BatchSize + + if t.config.LimitTotal > 0 { + remaining := t.config.LimitTotal - totalDeposits + if remaining < batchSize { + batchSize = remaining + } + } + + if t.lastIndex > 0 { + if t.nextIndex >= t.lastIndex { + return 0 + } + + idxRemaining := t.lastIndex - t.nextIndex + if batchSize < 0 || idxRemaining < uint64(batchSize) { + batchSize = int(idxRemaining) //nolint:gosec // bounded by remaining indices < batchSize + } + } + + return batchSize +} + +func (t *Task) reportLoopProgress(state *runState) { + if state.targetCount > 0 { + progress := float64(state.totalDeposits) / float64(state.targetCount) * 100 + t.ctx.ReportProgress(progress, fmt.Sprintf("Submitted %d/%d deposits across %d batches", state.totalDeposits, state.targetCount, len(state.txHashes))) + + return + } + + t.ctx.ReportProgress(0, fmt.Sprintf("Submitted %d deposits across %d batches", state.totalDeposits, len(state.txHashes))) +} + +func (t *Task) makeOnComplete(state *runState, depositCount int) spamoor.TxCompleteFn { + return func(tx *ethtypes.Transaction, receipt *ethtypes.Receipt, err error) { + if state.pendingChan != nil { + <-state.pendingChan + } + + state.receiptsMtx.Lock() + state.receipts[tx.Hash().Hex()] = receipt + state.receiptsMtx.Unlock() + + switch { + case receipt != nil: + t.logger.Infof("batch tx %v confirmed in block %v (nonce: %v, status: %v, deposits: %d)", + tx.Hash().Hex(), receipt.BlockNumber, tx.Nonce(), receipt.Status, depositCount) + case err != nil: + t.logger.Errorf("error awaiting batch tx receipt: %v", err) + default: + t.logger.Warnf("no receipt for batch tx: %v", tx.Hash().Hex()) + } + + state.pendingWg.Done() + } +} + +func (t *Task) publishOutputs(state *runState) { + t.ctx.Outputs.SetVar("validatorPubkeys", state.pubkeys) + t.ctx.Outputs.SetVar("batchTransactions", state.txHashes) + + receiptList := make([]interface{}, 0, len(state.txHashes)) + + for _, txhash := range state.txHashes { + receipt := state.receipts[txhash] + if receipt == nil { + receiptList = append(receiptList, nil) + continue + } + + var receiptMap map[string]interface{} + + receiptJSON, jerr := json.Marshal(receipt) + if jerr != nil { + t.logger.Errorf("could not marshal batch tx receipt: %v", jerr) + + receiptList = append(receiptList, nil) + + continue + } + + receiptMap = map[string]interface{}{} + + if uerr := json.Unmarshal(receiptJSON, &receiptMap); uerr != nil { + t.logger.Errorf("could not unmarshal batch tx receipt: %v", uerr) + + receiptList = append(receiptList, nil) + + continue + } + + receiptList = append(receiptList, receiptMap) + } + + t.ctx.Outputs.SetVar("batchReceipts", receiptList) +} + +// checkReceiptFailures returns true if a failure was found and the task result was set. +func (t *Task) checkReceiptFailures(state *runState) bool { + for _, txhash := range state.txHashes { + receipt := state.receipts[txhash] + + if receipt == nil { + t.logger.Errorf("no receipt for batch tx: %v", txhash) + t.ctx.SetResult(types.TaskResultFailure) + + return true + } + + if receipt.Status == 0 { + t.logger.Errorf("batch tx failed: %v", txhash) + t.ctx.SetResult(types.TaskResultFailure) + + return true + } + } + + return false +} + +func (t *Task) selectClients(ctx context.Context, clientPool *clientpool.ClientPool) ([]*execution.Client, error) { + if t.config.ClientPattern == "" && t.config.ExcludeClientPattern == "" { + clients := clientPool.GetExecutionPool().AwaitReadyEndpoints(ctx, true) + if len(clients) == 0 { + return nil, ctx.Err() + } + + return clients, nil + } + + poolClients := clientPool.GetClientsByNamePatterns(t.config.ClientPattern, t.config.ExcludeClientPattern) + if len(poolClients) == 0 { + return nil, fmt.Errorf("no client found with pattern %v", t.config.ClientPattern) + } + + out := make([]*execution.Client, len(poolClients)) + for i, c := range poolClients { + out[i] = c.ExecutionClient + } + + return out, nil +} + +// deployBatchContract deploys a fresh BatchDeposit contract bound to the configured deposit contract +// and waits for the receipt before returning the deployed address. +func (t *Task) deployBatchContract(ctx context.Context, txWallet *spamoor.Wallet, spamoorClients []*spamoor.Client) (ethcommon.Address, error) { + walletMgr := t.ctx.Scheduler.GetServices().WalletManager() + + parsed, err := batchcontract.BatchDepositContractMetaData.GetAbi() + if err != nil { + return ethcommon.Address{}, fmt.Errorf("cannot parse abi: %w", err) + } + + bytecode := ethcommon.FromHex(batchcontract.BatchDepositContractMetaData.Bin) + + constructorArgs, err := parsed.Pack("", t.depositContractAddr) + if err != nil { + return ethcommon.Address{}, fmt.Errorf("cannot pack constructor args: %w", err) + } + + deployData := make([]byte, 0, len(bytecode)+len(constructorArgs)) + deployData = append(deployData, bytecode...) + deployData = append(deployData, constructorArgs...) + + txData, err := txbuilder.DynFeeTx(&txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(big.NewInt(t.config.DepositTxFeeCap)), + GasTipCap: uint256.MustFromBig(big.NewInt(t.config.DepositTxTipCap)), + Gas: 2_000_000, + To: nil, + Value: uint256.NewInt(0), + Data: deployData, + }) + if err != nil { + return ethcommon.Address{}, fmt.Errorf("cannot build deploy tx: %w", err) + } + + tx, err := txWallet.BuildDynamicFeeTx(txData) + if err != nil { + return ethcommon.Address{}, fmt.Errorf("cannot sign deploy tx: %w", err) + } + + receipt, err := walletMgr.GetTxPool().SendAndAwaitTransaction(ctx, txWallet, tx, &spamoor.SendTransactionOptions{ + Client: spamoorClients[0], + ClientList: spamoorClients, + SubmitCount: len(spamoorClients), + Rebroadcast: true, + LogFn: func(client *spamoor.Client, retry int, rebroadcast int, err error) { + if err != nil { + return + } + + logEntry := t.logger.WithField("client", client.GetName()) + if rebroadcast > 0 { + logEntry = logEntry.WithField("rebroadcast", rebroadcast) + } + + logEntry.Infof("submitted batch contract deploy tx (nonce: %v, attempt: %v)", tx.Nonce(), retry) + }, + }) + if err != nil { + txWallet.MarkSkippedNonce(tx.Nonce()) + return ethcommon.Address{}, fmt.Errorf("deploy tx failed: %w", err) + } + + if receipt == nil { + return ethcommon.Address{}, errors.New("deploy tx receipt was nil") + } + + if receipt.Status == 0 { + return ethcommon.Address{}, fmt.Errorf("deploy tx reverted (hash %s)", tx.Hash().Hex()) + } + + if (receipt.ContractAddress == ethcommon.Address{}) { + return ethcommon.Address{}, errors.New("deploy receipt has no contract address") + } + + return receipt.ContractAddress, nil +} + +// prepareBatch deterministically generates `count` deposits, each with a unique BLS keypair +// derived from the configured mnemonic. The signatures are produced using the genesis fork +// version so the consensus layer accepts them as valid, forcing a real (worst-case) BLS +// verification per deposit. +func (t *Task) prepareBatch(count int) ([]preparedDeposit, []string, error) { + clientPool := t.ctx.Scheduler.GetServices().ClientPool() + + genesis := clientPool.GetConsensusPool().GetBlockCache().GetGenesis() + if genesis == nil { + return nil, nil, errors.New("consensus genesis info not available yet") + } + + domain := common.ComputeDomain(common.DOMAIN_DEPOSIT, common.Version(genesis.GenesisForkVersion), common.Root{}) + + depositAmountGwei := new(big.Int).SetUint64(t.config.DepositAmount) + depositAmountGwei.Mul(depositAmountGwei, big.NewInt(1_000_000_000)) + + if !depositAmountGwei.IsUint64() { + return nil, nil, fmt.Errorf("deposit amount too large: %v ETH", t.config.DepositAmount) + } + + prepared := make([]preparedDeposit, 0, count) + pubkeyStrs := make([]string, 0, count) + + for i := 0; i < count; i++ { + accountIdx := t.nextIndex + t.nextIndex++ + + pd, err := t.prepareSingle(accountIdx, domain, depositAmountGwei.Uint64()) + if err != nil { + return nil, nil, err + } + + prepared = append(prepared, pd) + pubkeyStrs = append(pubkeyStrs, pd.pubkey.String()) + } + + return prepared, pubkeyStrs, nil +} + +func (t *Task) prepareSingle(accountIdx uint64, domain common.BLSDomain, amountGwei uint64) (preparedDeposit, error) { + keyPath := fmt.Sprintf("m/12381/3600/%d/0/0", accountIdx) + + validatorPriv, err := util.PrivateKeyFromSeedAndPath(t.valkeySeed, keyPath) + if err != nil { + return preparedDeposit{}, fmt.Errorf("failed generating validator key %v: %w", keyPath, err) + } + + pub := common.BLSPubkey{} + copy(pub[:], validatorPriv.PublicKey().Marshal()) + + depositData := common.DepositData{ + Pubkey: pub, + WithdrawalCredentials: tree.Root(t.withdrawalCreds), + Amount: common.Gwei(amountGwei), + Signature: common.BLSSignature{}, + } + + msgRoot := depositData.ToMessage().HashTreeRoot(tree.GetHashFn()) + signingRoot := common.ComputeSigningRoot(msgRoot, domain) + + var secKey hbls.SecretKey + if err := secKey.Deserialize(validatorPriv.Marshal()); err != nil { + return preparedDeposit{}, fmt.Errorf("cannot convert validator priv key: %w", err) + } + + sig := secKey.SignHash(signingRoot[:]) + copy(depositData.Signature[:], sig.Serialize()) + + dataRoot := depositData.HashTreeRoot(tree.GetHashFn()) + + pd := preparedDeposit{ + pubkey: pub, + signature: depositData.Signature[:], + } + copy(pd.dataRoot[:], dataRoot[:]) + + return pd, nil +} + +// submitBatch packs the prepared deposits into a single batchDeposit() call and sends the tx. +func (t *Task) submitBatch( + ctx context.Context, + txWallet *spamoor.Wallet, + bound *batchcontract.BatchDepositContract, + spamoorClients []*spamoor.Client, + prepared []preparedDeposit, + onComplete spamoor.TxCompleteFn, +) (*ethtypes.Transaction, error) { + count := len(prepared) + + pubkeys := make([]byte, 0, count*48) + signatures := make([]byte, 0, count*96) + dataRoots := make([][32]byte, 0, count) + + for i := range prepared { + pubkeys = append(pubkeys, prepared[i].pubkey[:]...) + signatures = append(signatures, prepared[i].signature...) + dataRoots = append(dataRoots, prepared[i].dataRoot) + } + + amountWei := new(big.Int).SetUint64(t.config.DepositAmount) + amountWei.Mul(amountWei, big.NewInt(1_000_000_000_000_000_000)) + + totalValue := new(big.Int).Mul(amountWei, big.NewInt(int64(count))) + + txMeta := &txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(big.NewInt(t.config.DepositTxFeeCap)), + GasTipCap: uint256.MustFromBig(big.NewInt(t.config.DepositTxTipCap)), + Gas: t.config.BatchTxGasLimit, + Value: uint256.MustFromBig(totalValue), + } + + tx, err := txWallet.BuildBoundTx(ctx, txMeta, func(opts *bind.TransactOpts) (*ethtypes.Transaction, error) { + return bound.BatchDeposit(opts, pubkeys, signatures, dataRoots, t.withdrawalCreds, amountWei) + }) + if err != nil { + return nil, fmt.Errorf("cannot build batch tx: %w", err) + } + + walletMgr := t.ctx.Scheduler.GetServices().WalletManager() + + err = walletMgr.GetTxPool().SendTransaction(ctx, txWallet, tx, &spamoor.SendTransactionOptions{ + Client: spamoorClients[0], + ClientList: spamoorClients, + SubmitCount: len(spamoorClients), + Rebroadcast: true, + OnComplete: onComplete, + LogFn: func(client *spamoor.Client, retry int, rebroadcast int, err error) { + if err != nil { + return + } + + logEntry := t.logger.WithFields(logrus.Fields{ + "client": client.GetName(), + "size": count, + }) + + if rebroadcast > 0 { + logEntry = logEntry.WithField("rebroadcast", rebroadcast) + } + + logEntry.Infof("submitted batch deposit tx (nonce: %v, attempt: %v)", tx.Nonce(), retry) + }, + }) + if err != nil { + txWallet.MarkSkippedNonce(tx.Nonce()) + return nil, err + } + + return tx, nil +} + +func (t *Task) awaitInclusion(ctx context.Context, sub *consensus.Subscription[*consensus.Block], pubkeys []string) error { + pending := make(map[string]bool, len(pubkeys)) + for _, pk := range pubkeys { + pending[pk] = true + } + + includedCount := 0 + t.ctx.Outputs.SetVar("includedDeposits", includedCount) + + total := len(pending) + t.logger.Infof("waiting for %d deposits to be included in beacon blocks", total) + t.ctx.ReportProgress(50, fmt.Sprintf("Awaiting inclusion: 0/%d", total)) + + for len(pending) > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + case block := <-sub.Channel(): + t.scanBlockForDeposits(ctx, block, pending, &includedCount) + + t.ctx.Outputs.SetVar("includedDeposits", includedCount) + + if includedCount > 0 { + progress := 50 + (float64(includedCount)/float64(total))*50 + t.ctx.ReportProgress(progress, fmt.Sprintf("Awaiting inclusion: %d/%d", includedCount, total)) + + t.logger.Infof("deposits included in block %d (%d/%d)", block.Slot, includedCount, total) + } + } + } + + t.ctx.SetResult(types.TaskResultSuccess) + t.ctx.ReportProgress(100, fmt.Sprintf("All %d deposits included on beacon chain", total)) + + return nil +} + +// scanBlockForDeposits checks a beacon block (and its execution payload envelope on Gloas+) +// for deposit pubkeys we are waiting on, removing matches from `pending` and bumping `included`. +func (t *Task) scanBlockForDeposits(ctx context.Context, block *consensus.Block, pending map[string]bool, included *int) { + blockData := block.AwaitBlock(ctx, 2*time.Second) + if blockData == nil { + return + } + + if deposits, err := blockData.Deposits(); err == nil { + for _, deposit := range deposits { + pubkeyStr := deposit.Data.PublicKey.String() + if pending[pubkeyStr] { + delete(pending, pubkeyStr) + + *included++ + } + } + } + + if blockData.Version >= spec.DataVersionGloas { + payload := block.AwaitPayload(ctx, 2*time.Second) + if payload != nil { + payloadData := payload.Gloas + if payloadData != nil && payloadData.Message.ExecutionRequests != nil { + for _, depositReq := range payloadData.Message.ExecutionRequests.Deposits { + pubkeyStr := depositReq.Pubkey.String() + if pending[pubkeyStr] { + delete(pending, pubkeyStr) + + *included++ + } + } + } + } + + return + } + + execRequests, err := blockData.ExecutionRequests() + if err != nil || execRequests == nil { + return + } + + for _, depositReq := range execRequests.Deposits { + pubkeyStr := depositReq.Pubkey.String() + if pending[pubkeyStr] { + delete(pending, pubkeyStr) + + *included++ + } + } +} + +func mnemonicToSeed(mnemonic string) ([]byte, error) { + mnemonic = strings.TrimSpace(mnemonic) + if !bip39.IsMnemonicValid(mnemonic) { + return nil, errors.New("mnemonic is not valid") + } + + return bip39.NewSeed(mnemonic, ""), nil +} diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index f430c632..7d3c4802 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -19,6 +19,7 @@ import ( checkethconfig "github.com/ethpandaops/assertoor/pkg/tasks/check_eth_config" checkexecutionsyncstatus "github.com/ethpandaops/assertoor/pkg/tasks/check_execution_sync_status" generateattestations "github.com/ethpandaops/assertoor/pkg/tasks/generate_attestations" + generatebatchdeposits "github.com/ethpandaops/assertoor/pkg/tasks/generate_batch_deposits" generateblobtransactions "github.com/ethpandaops/assertoor/pkg/tasks/generate_blob_transactions" generateblschanges "github.com/ethpandaops/assertoor/pkg/tasks/generate_bls_changes" generatechildwallet "github.com/ethpandaops/assertoor/pkg/tasks/generate_child_wallet" @@ -67,6 +68,7 @@ var AvailableTaskDescriptors = []*types.TaskDescriptor{ checkethconfig.TaskDescriptor, checkexecutionsyncstatus.TaskDescriptor, generateattestations.TaskDescriptor, + generatebatchdeposits.TaskDescriptor, generateblobtransactions.TaskDescriptor, generateblschanges.TaskDescriptor, generatechildwallet.TaskDescriptor, diff --git a/playbooks/gloas-dev/builder-deposit-spam.yaml b/playbooks/gloas-dev/builder-deposit-spam.yaml new file mode 100644 index 00000000..65fce7a1 --- /dev/null +++ b/playbooks/gloas-dev/builder-deposit-spam.yaml @@ -0,0 +1,75 @@ +id: builder-deposit-spam +name: Spam chain with batched 0x03 builder deposits +timeout: 1h +config: + walletPrivkey: '' + depositContract: '0x00000000219ab540356cBB839Cbe05303d7705Fa' + # Each batch tx fans out into batchSize deposits; CL validates every BLS signature individually. + batchSize: 100 + # Total deposits to push through the chain. Set to 0 to run unbounded until timeout. + totalDeposits: 1000 + # Hard cap of in-flight batches so we don't drown the mempool. + pendingBatches: 4 + # Deposit amount per validator (ETH). 1 ETH keeps funding cheap on devnets. + depositAmount: 1 + # Set to true to skip waiting for the GLOAS fork to activate before spamming. + skipForkActivationCheck: false +tasks: + - name: check_clients_are_healthy + title: Check that at least one client is ready + timeout: 5m + config: + minClientCount: 1 + + - name: get_consensus_specs + id: get_specs + title: Get consensus chain specs + if: "skipForkActivationCheck == false" + + - name: check_consensus_slot_range + title: "Wait for GLOAS activation (epoch >= ${{ tasks.get_specs.outputs.specs.GLOAS_FORK_EPOCH }})" + timeout: 1h + if: "skipForkActivationCheck == false" + configVars: + minEpochNumber: "tasks.get_specs.outputs.specs.GLOAS_FORK_EPOCH" + + - name: generate_child_wallet + id: spam_wallet + title: Generate funded wallet for batched deposits + config: + walletSeed: builder-deposit-spam + configVars: + privateKey: walletPrivkey + # Wallet must hold totalDeposits * depositAmount + headroom for tx fees. + # jq expression below = (totalDeposits * depositAmount + 50) * 1e18 wei. + prefundMinBalance: '| ((.totalDeposits * .depositAmount + 50) * 1000000000000000000)' + + - name: get_random_mnemonic + id: spam_mnemonic + title: Generate fresh mnemonic for spam validators + + - name: generate_batch_deposits + title: Deploy BatchDeposit and spam ${totalDeposits} 0x03 builder deposits in batches of ${batchSize} + timeout: 45m + config: + awaitReceipt: true + awaitInclusion: true + failOnReject: true + configVars: + walletPrivkey: tasks.spam_wallet.outputs.childWallet.privkey + mnemonic: tasks.spam_mnemonic.outputs.mnemonic + depositContract: depositContract + batchSize: batchSize + limitTotal: totalDeposits + limitPendingBatches: pendingBatches + depositAmount: depositAmount + # 0x03 builder withdrawal credentials — version byte (0x03) + 11 zero bytes + 20-byte address. + withdrawalCredentials: '| "0x03" + ("00" * 11) + (.tasks.spam_wallet.outputs.childWallet.address | ltrimstr("0x"))' + + - name: check_consensus_finality + title: Verify the chain still finalizes under deposit load + timeout: 15m + config: + minFinalizedEpochs: 1 + maxUnfinalizedEpochs: 4 + failOnCheckMiss: true