Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions cl/beacon/handler/block_production_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright 2024 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package handler

import (
"bytes"
"context"
"testing"
"time"

"github.com/holiman/uint256"
"github.com/stretchr/testify/require"

"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/phase1/execution_client"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/execmodule/chainreader"
"github.com/erigontech/erigon/execution/execmodule/execmoduletester"
"github.com/erigontech/erigon/execution/protocol/params"
"github.com/erigontech/erigon/execution/tests/blockgen"
"github.com/erigontech/erigon/execution/types"
"github.com/erigontech/erigon/node/direct"
"github.com/erigontech/erigon/node/gointerfaces/txpoolproto"
)

// TestCaplinBlockProductionWithWithdrawalRequest tests Caplin's produceBeaconBody
// against a real Erigon execution layer. A withdrawal request transaction is
// submitted to the EIP-7002 system contract, and then Caplin's actual block
// production code builds the beacon body — calling ForkChoiceUpdate,
// GetAssembledBlock, and decoding the execution requests. This is the code path
// that was broken in issue #14319 and fixed in PR #14326.
func TestCaplinBlockProductionWithWithdrawalRequest(t *testing.T) {
ctx := context.Background()

// --- Set up real execution layer ---

m := execmoduletester.New(t, execmoduletester.WithTxPool(), execmoduletester.WithChainConfig(chain.AllProtocolChanges))

// Insert 1 initial block so we have a chain head.
chainPack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 1, func(i int, gen *blockgen.BlockGen) {
tx, err := types.SignTx(
types.NewTransaction(gen.TxNonce(m.Address), common.Address{1}, uint256.NewInt(10_000), params.TxGas, uint256.NewInt(m.Genesis.BaseFee().Uint64()), nil),
*types.LatestSignerForChainID(m.ChainConfig.ChainID), m.Key,
)
require.NoError(t, err)
gen.AddTx(tx)
})
require.NoError(t, err)
err = m.InsertChain(chainPack)
require.NoError(t, err)

// Submit a withdrawal request transaction (EIP-7002).
var pubkey [48]byte
for i := range pubkey {
pubkey[i] = 0x01
}
var calldata []byte
calldata = append(calldata, pubkey[:]...)
calldata = append(calldata, make([]byte, 8)...) // amount=0 → full exit

baseFee := chainPack.TopBlock.BaseFee().Uint64()
withdrawalAddr := params.WithdrawalRequestAddress.Value()
withdrawalTx, err := types.SignTx(
&types.LegacyTx{
CommonTx: types.CommonTx{
Nonce: 1,
GasLimit: 1_000_000,
To: &withdrawalAddr,
Value: *uint256.NewInt(500_000_000_000_000_000), // 0.5 ETH
Data: calldata,
},
GasPrice: *uint256.NewInt(baseFee),
},
*types.LatestSignerForChainID(m.ChainConfig.ChainID),
m.Key,
)
require.NoError(t, err)

var txBuf bytes.Buffer
err = withdrawalTx.EncodeRLP(&txBuf)
require.NoError(t, err)
addResp, err := m.TxPoolGrpcServer.Add(ctx, &txpoolproto.AddRequest{RlpTxs: [][]byte{txBuf.Bytes()}})
require.NoError(t, err)
require.Equal(t, "success", addResp.Errors[0])

// --- Wire real EL into Caplin's ApiHandler ---

chainRW := chainreader.NewChainReaderEth1(
m.ChainConfig,
direct.NewExecutionClientDirect(m.ExecModule),
time.Hour,
)
engine, err := execution_client.NewExecutionClientDirect(chainRW, nil)
require.NoError(t, err)

// Set up handler with Electra test data (provides validator set, RANDAO, etc.)
// and our real execution engine.
_, blocks, _, _, postState, h, _, _, fcu, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
h.engine = engine

// Patch the beacon state's execution payload header to point at the real
// EL chain head — this is how produceBeaconBody knows what hash to send
// in ForkChoiceUpdate.
elHead := chainPack.TopBlock.Header()
elHeader := cltypes.NewEth1Header(clparams.ElectraVersion)
elHeader.BlockHash = elHead.Hash()
elHeader.BlockNumber = elHead.Number.Uint64()
elHeader.Time = elHead.Time
elHeader.BaseFeePerGas = common.BigToHash(elHead.BaseFee.ToBig())
postState.SetLatestExecutionPayloadHeader(elHeader)

// Make GetEth1Hash return the EL head hash for any checkpoint root —
// produceBeaconBody falls back to head when the hash is zero, but we
// set it explicitly for clarity.
elHeadHash := elHead.Hash()
fcu.Eth1Hashes[postState.FinalizedCheckpoint().Root] = elHeadHash
fcu.Eth1Hashes[postState.CurrentJustifiedCheckpoint().Root] = elHeadHash

// --- Call Caplin's actual block production ---

baseBlock := blocks[len(blocks)-1].Block
targetSlot := baseBlock.Slot + 1

beaconBody, execValue, err := h.produceBeaconBody(
ctx, 3, baseBlock, postState, targetSlot,
common.Bytes96{0xc0}, // infinity BLS signature (skip RANDAO verification)
common.Hash{},
)
require.NoError(t, err)
require.NotNil(t, beaconBody)
require.NotZero(t, execValue)

// --- Verify execution requests were decoded by Caplin ---

require.NotNil(t, beaconBody.ExecutionRequests,
"ExecutionRequests must not be nil — this was the bug in issue #14319")
require.Greater(t, beaconBody.ExecutionRequests.Withdrawals.Len(), 0,
"expected at least 1 withdrawal request from the EL system contract")

gotWithdrawal := beaconBody.ExecutionRequests.Withdrawals.Get(0)
require.Equal(t, common.Bytes48(pubkey), gotWithdrawal.ValidatorPubKey,
"withdrawal request pubkey should match what was submitted")
require.Equal(t, uint64(0), gotWithdrawal.Amount,
"withdrawal request amount should be 0 (full exit)")
}
7 changes: 7 additions & 0 deletions cl/beacon/handler/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ func setupTestingHandler(t *testing.T, v clparams.StateVersion, logger log.Logge
bcfg.BellatrixForkEpoch = 1
bcfg.CapellaForkEpoch = 1
blocks, preState, postState = tests.GetCapellaRandom()
} else if v == clparams.ElectraVersion {
bcfg.AltairForkEpoch = 1
bcfg.BellatrixForkEpoch = 1
bcfg.CapellaForkEpoch = 1
bcfg.DenebForkEpoch = 1
bcfg.ElectraForkEpoch = 1
blocks, preState, postState = tests.GetElectraRandom()
}
fcu = mock_services2.NewForkChoiceStorageMock(t)
db = memdb.NewTestDB(t, dbcfg.ChainDB)
Expand Down
12 changes: 11 additions & 1 deletion cl/phase1/forkchoice/mock_services/forkchoice_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ type ForkChoiceStorageMock struct {

Pool pool.OperationsPool

Eth1Hashes map[common.Hash]common.Hash

// Mock for PeerDas
MockPeerDas *mock_services.MockPeerDas
}
Expand Down Expand Up @@ -121,6 +123,10 @@ func makeSyncContributionPoolMock(t *testing.T) sync_contribution_pool.SyncContr
}
return nil
}).AnyTimes()
pool.EXPECT().
GetSyncAggregate(gomock.Any(), gomock.Any()).
Return(&cltypes.SyncAggregate{}, nil).
AnyTimes()
return pool
}

Expand Down Expand Up @@ -188,6 +194,7 @@ func NewForkChoiceStorageMock(t *testing.T) *ForkChoiceStorageMock {
LCUpdates: make(map[uint64]*cltypes.LightClientUpdate),
Headers: make(map[common.Hash]*cltypes.BeaconBlockHeader),
GetBeaconCommitteeMock: nil,
Eth1Hashes: make(map[common.Hash]common.Hash),
SyncContributionPool: makeSyncContributionPoolMock(t),
MockPeerDas: mockPeerDas,
}
Expand Down Expand Up @@ -218,7 +225,10 @@ func (f *ForkChoiceStorageMock) FinalizedSlot() uint64 {
}

func (f *ForkChoiceStorageMock) GetEth1Hash(eth2Root common.Hash) common.Hash {
panic("implement me")
if f.Eth1Hashes != nil {
return f.Eth1Hashes[eth2Root]
}
return common.Hash{}
}

func (f *ForkChoiceStorageMock) GetHead(_ *state.CachingBeaconState) (common.Hash, uint64, error) {
Expand Down
28 changes: 28 additions & 0 deletions execution/engineapi/engine_api_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package engineapi_test

import (
"context"
"encoding/binary"
"math/big"
"testing"

Expand Down Expand Up @@ -425,5 +426,32 @@ func TestEngineApiBuiltBlockWithWithdrawalRequest(t *testing.T) {

// Verify execution requests are present in the payload (Prague includes withdrawal requests).
require.NotNil(t, payload.ExecutionRequests)

// Verify withdrawal request content — the system contract should have
// dequeued the request we submitted and included it in the block.
var foundWithdrawalRequest bool
for _, req := range payload.ExecutionRequests {
if len(req) == 0 || req[0] != types.WithdrawalRequestType {
continue
}
requestData := []byte(req[1:])
// A withdrawal request is: 20-byte source address + 48-byte pubkey + 8-byte LE amount.
require.Equal(t, types.WithdrawalRequestDataLen, len(requestData),
"withdrawal request should be exactly %d bytes", types.WithdrawalRequestDataLen)

sourceAddr := common.BytesToAddress(requestData[:20])
gotPubkey := requestData[20:68]
gotAmount := binary.LittleEndian.Uint64(requestData[68:76])

require.Equal(t, sender, sourceAddr,
"withdrawal request source address should be the sender")
require.Equal(t, pubkey, gotPubkey,
"withdrawal request pubkey should match the one we sent")
require.Equal(t, uint64(0), gotAmount,
"withdrawal request amount should be 0 (full exit)")
foundWithdrawalRequest = true
}
require.True(t, foundWithdrawalRequest,
"should find at least one withdrawal request in execution requests")
})
}
117 changes: 117 additions & 0 deletions execution/execmodule/exec_module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@ import (
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/commitment/commitmentdb"
"github.com/erigontech/erigon/execution/execmodule"
"github.com/erigontech/erigon/execution/execmodule/chainreader"
"github.com/erigontech/erigon/execution/execmodule/execmoduletester"
eth1utils "github.com/erigontech/erigon/execution/execmodule/moduleutil"
"github.com/erigontech/erigon/execution/protocol/params"
"github.com/erigontech/erigon/execution/state/contracts"
"github.com/erigontech/erigon/execution/tests/blockgen"
"github.com/erigontech/erigon/execution/types"
"github.com/erigontech/erigon/node/direct"
"github.com/erigontech/erigon/node/gointerfaces"
"github.com/erigontech/erigon/node/gointerfaces/executionproto"
"github.com/erigontech/erigon/node/gointerfaces/txpoolproto"
Expand Down Expand Up @@ -738,3 +740,118 @@ func TestAssembleBlockMixedTxTypes(t *testing.T) {
err = insertValidateAndUfc1By1(ctx, exec, []*types.Block{block})
require.NoError(t, err)
}

// TestAssembleBlockWithWithdrawalRequest sends a withdrawal request transaction
// to the EIP-7002 system contract, builds a block via the real EL builder, and
// verifies execution requests are returned through ChainReaderWriterEth1.GetAssembledBlock
// — the exact interface Caplin uses in production (PR #14326 fixed this path).
// It then validates the block and extends the chain via insert + validate + FCU.
func TestAssembleBlockWithWithdrawalRequest(t *testing.T) {
t.Parallel()
ctx := t.Context()

m := execmoduletester.New(t, execmoduletester.WithTxPool(), execmoduletester.WithChainConfig(chain.AllProtocolChanges))
exec := m.ExecModule
txpool := m.TxPoolGrpcServer

// Insert 1 initial block.
chainPack, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 1, func(i int, gen *blockgen.BlockGen) {
tx, err := types.SignTx(
types.NewTransaction(gen.TxNonce(m.Address), common.Address{1}, uint256.NewInt(10_000), params.TxGas, uint256.NewInt(m.Genesis.BaseFee().Uint64()), nil),
*types.LatestSignerForChainID(m.ChainConfig.ChainID), m.Key,
)
require.NoError(t, err)
gen.AddTx(tx)
})
require.NoError(t, err)
err = m.InsertChain(chainPack)
require.NoError(t, err)

// Submit withdrawal request transaction.
var pubkey [48]byte
for i := range pubkey {
pubkey[i] = 0x02
}
var calldata []byte
calldata = append(calldata, pubkey[:]...)
calldata = append(calldata, make([]byte, 8)...) // amount=0

baseFee := chainPack.TopBlock.BaseFee().Uint64()
withdrawalAddr := params.WithdrawalRequestAddress.Value()
withdrawalTx, err := types.SignTx(
&types.LegacyTx{
CommonTx: types.CommonTx{
Nonce: 1,
GasLimit: 1_000_000,
To: &withdrawalAddr,
Value: *uint256.NewInt(500_000_000_000_000_000),
Data: calldata,
},
GasPrice: *uint256.NewInt(baseFee),
},
*types.LatestSignerForChainID(m.ChainConfig.ChainID),
m.Key,
)
require.NoError(t, err)

var txBuf bytes.Buffer
err = withdrawalTx.EncodeRLP(&txBuf)
require.NoError(t, err)
addResp, err := txpool.Add(ctx, &txpoolproto.AddRequest{RlpTxs: [][]byte{txBuf.Bytes()}})
require.NoError(t, err)
require.Equal(t, "success", addResp.Errors[0])

// Assemble block.
payloadId, err := assembleBlock(ctx, exec, &executionproto.AssembleBlockRequest{
ParentHash: gointerfaces.ConvertHashToH256(chainPack.TopBlock.Hash()),
Timestamp: chainPack.TopBlock.Header().Time + 1,
PrevRandao: gointerfaces.ConvertHashToH256(randomHash()),
SuggestedFeeRecipient: gointerfaces.ConvertAddressToH160(common.Address{1}),
Withdrawals: make([]*typesproto.Withdrawal, 0),
ParentBeaconBlockRoot: gointerfaces.ConvertHashToH256(randomHash()),
})
require.NoError(t, err)

// Get the assembled block via ChainReaderWriterEth1 — Caplin's production interface.
chainRW := chainreader.NewChainReaderEth1(
m.ChainConfig,
direct.NewExecutionClientDirect(exec),
time.Hour,
)

eth1Block, blobsBundle, requestsBundle, blockValue, err := chainRW.GetAssembledBlock(payloadId)
require.NoError(t, err)
require.NotNil(t, eth1Block, "Eth1Block should not be nil")
require.NotNil(t, blobsBundle, "BlobsBundle should not be nil")
require.NotNil(t, blockValue, "blockValue should not be nil")

// This is the critical assertion: the RequestsBundle must be returned.
// PR #14326 added this return value. If reverted, this would be nil.
require.NotNil(t, requestsBundle, "RequestsBundle must not be nil — "+
"this is the return value added by PR #14326 to fix issue #14319")
require.NotEmpty(t, requestsBundle.GetRequests(),
"should contain at least one execution request")

// Find and decode the withdrawal request.
var foundWithdrawalRequest bool
for _, req := range requestsBundle.GetRequests() {
if len(req) == 0 || req[0] != types.WithdrawalRequestType {
continue
}
requestData := req[1:]
require.Equal(t, types.WithdrawalRequestDataLen, len(requestData))

gotPubkey := requestData[20:68]
require.Equal(t, pubkey[:], gotPubkey,
"withdrawal request pubkey should match what was submitted")
foundWithdrawalRequest = true
}
require.True(t, foundWithdrawalRequest,
"should find a withdrawal request via ChainReaderWriterEth1.GetAssembledBlock")

// Verify the block also passes validation.
block, err := getAssembledBlock(ctx, exec, payloadId)
require.NoError(t, err)
err = insertValidateAndUfc1By1(ctx, exec, []*types.Block{block})
require.NoError(t, err)
}
Loading
Loading