Skip to content

Commit

Permalink
devmode (rebased again)
Browse files Browse the repository at this point in the history
  • Loading branch information
jwasinger committed Jun 12, 2023
1 parent 7f563d1 commit 23ee876
Show file tree
Hide file tree
Showing 12 changed files with 498 additions and 13 deletions.
22 changes: 22 additions & 0 deletions clmock/api.go
@@ -0,0 +1,22 @@
package clmock

import (
"context"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)

type API struct {
mock *CLMock
}

func (api *API) AddWithdrawal(ctx context.Context, withdrawal *types.Withdrawal) error {
return api.mock.addWithdrawal(*withdrawal)
}

func (api *API) SetFeeRecipient(ctx context.Context, feeRecipient *common.Address) {
api.mock.mu.Lock()
api.mock.feeRecipient = *feeRecipient
api.mock.mu.Unlock()
}
222 changes: 222 additions & 0 deletions clmock/clmock.go
@@ -0,0 +1,222 @@
// Copyright 2023 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library 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.
//
// The go-ethereum library 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 the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.

package clmock

import (
"context"
"fmt"
"sync"
"time"

"github.com/ethereum/go-ethereum/beacon/engine"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/eth"
"github.com/ethereum/go-ethereum/eth/catalyst"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/node"
"github.com/ethereum/go-ethereum/rpc"
)

type CLMock struct {
ctx context.Context
cancel context.CancelFunc
eth *eth.Ethereum
period time.Duration
withdrawals []*types.Withdrawal
feeRecipient common.Address
// mu controls access to the feeRecipient/withdrawals which can be modified by the dev-mode RPC API methods
mu sync.Mutex
nextWithdrawalIdx uint64
}

func NewCLMock(eth *eth.Ethereum) *CLMock {
chainConfig := eth.APIBackend.ChainConfig()
if chainConfig.Dev == nil {
log.Crit("incompatible pre-existing chain configuration")
}

return &CLMock{
eth: eth,
period: time.Duration(chainConfig.Dev.Period) * time.Second,
withdrawals: []*types.Withdrawal{},
feeRecipient: common.Address{},
}
}

// Start invokes the clmock life-cycle function in a goroutine
func (c *CLMock) Start() error {
c.ctx, c.cancel = context.WithCancel(context.Background())
go c.loop()
return nil
}

// Stop halts the clmock service
func (c *CLMock) Stop() error {
c.cancel()
return nil
}

func (c *CLMock) addWithdrawal(w types.Withdrawal) error {
c.mu.Lock()
defer c.mu.Unlock()

if w.Index < c.nextWithdrawalIdx {
return fmt.Errorf("withdrawal has index (%d) less than or equal to latest received withdrawal index (%d)", w.Index, c.nextWithdrawalIdx-1)
}
c.nextWithdrawalIdx = w.Index + 1
c.withdrawals = append(c.withdrawals, &w)
return nil
}

// remove up to 10 withdrawals from the withdrawal queue
func (c *CLMock) popWithdrawals() []*types.Withdrawal {
c.mu.Lock()
defer c.mu.Unlock()

var popCount int
if len(c.withdrawals) >= 10 {
popCount = 10
} else {
popCount = len(c.withdrawals)
}

popped := make([]*types.Withdrawal, popCount)
copy(popped[:], c.withdrawals[0:popCount])
c.withdrawals = append([]*types.Withdrawal{}, c.withdrawals[popCount:]...)
return popped
}

// loop manages the lifecycle of clmock.
// it drives block production, taking the role of a CL client and interacting with Geth via public engine/eth APIs
func (c *CLMock) loop() {
var (
ticker = time.NewTicker(time.Millisecond * 100)
lastBlockTime = time.Now()
engineAPI = catalyst.NewConsensusAPI(c.eth)
header = c.eth.BlockChain().CurrentHeader()
curForkchoiceState = engine.ForkchoiceStateV1{
HeadBlockHash: header.Hash(),
SafeBlockHash: header.Hash(),
FinalizedBlockHash: header.Hash(),
}
)

// if genesis block, send forkchoiceUpdated to trigger transition to PoS
if header.Number.BitLen() == 0 {
if _, err := engineAPI.ForkchoiceUpdatedV2(curForkchoiceState, nil); err != nil {
log.Crit("failed to initiate PoS transition for genesis via Forkchoiceupdated", "err", err)
}
}

for {
select {
case <-c.ctx.Done():
break
case curTime := <-ticker.C:
if curTime.Unix() > lastBlockTime.Add(c.period).Unix() {
c.mu.Lock()
feeRecipient := c.feeRecipient
c.mu.Unlock()

payloadAttr := &engine.PayloadAttributes{
Timestamp: uint64(curTime.Unix()),
Random: common.Hash{}, // TODO: make this configurable?
SuggestedFeeRecipient: feeRecipient,
Withdrawals: c.popWithdrawals(),
}

// trigger block building
fcState, err := engineAPI.ForkchoiceUpdatedV2(curForkchoiceState, payloadAttr)
if err != nil {
log.Crit("failed to trigger block building via forkchoiceupdated", "err", err)
}

var payload *engine.ExecutableData

var (
restartPayloadBuilding bool
// building a payload times out after SECONDS_PER_SLOT (12s on mainnet).
// trigger building a new payload if this amount of time elapses w/o any transactions or withdrawals
// having been received.
payloadTimeout = time.NewTimer(12 * time.Second)
// interval to poll the pending state to detect if transactions have arrived, and proceed if they have
// (or if there are pending withdrawals to include)
buildTicker = time.NewTicker(100 * time.Millisecond)
)
for {
select {
case <-buildTicker.C:
pendingHeader, err := c.eth.APIBackend.HeaderByNumber(context.Background(), rpc.PendingBlockNumber)
if err != nil {
log.Crit("failed to get pending block header", "err", err)
}
// don't build a block if we don't have pending txs or withdrawals
if pendingHeader.TxHash == types.EmptyTxsHash && len(payloadAttr.Withdrawals) == 0 {
continue
}
payload, err = engineAPI.GetPayloadV1(*fcState.PayloadID)
if err != nil {
log.Crit("error retrieving payload", "err", err)
}
// Don't build a block if it doesn't contain transactions or withdrawals.
// Somehow, txs can arrive, be detected by this routine, but be missed by the miner payload builder.
// So this last clause prevents empty blocks from being built.
if len(payload.Transactions) == 0 && len(payloadAttr.Withdrawals) == 0 {
restartPayloadBuilding = true
}
case <-payloadTimeout.C:
restartPayloadBuilding = true
case <-c.ctx.Done():
return
}
break
}
if restartPayloadBuilding {
continue
}
// mark the payload as the one we have chosen
if _, err = engineAPI.NewPayloadV2(*payload); err != nil {
log.Crit("failed to mark payload as canonical", "err", err)
}

newForkchoiceState := &engine.ForkchoiceStateV1{
HeadBlockHash: payload.BlockHash,
SafeBlockHash: payload.BlockHash,
FinalizedBlockHash: payload.BlockHash,
}
// mark the block containing the payload as canonical
_, err = engineAPI.ForkchoiceUpdatedV2(*newForkchoiceState, nil)
if err != nil {
log.Crit("failed to mark block as canonical", "err", err)
}
lastBlockTime = time.Unix(int64(payload.Timestamp), 0)
curForkchoiceState = *newForkchoiceState
}
}
}
}

func RegisterAPIs(stack *node.Node, c *CLMock) {
stack.RegisterAPIs([]rpc.API{
{
Namespace: "dev",
Service: &API{c},
Version: "1.0",
},
})
}
139 changes: 139 additions & 0 deletions clmock/clmock_test.go
@@ -0,0 +1,139 @@
// Copyright 2023 The go-ethereum Authors
// This file is part of the go-ethereum library.
//
// The go-ethereum library 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.
//
// The go-ethereum library 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 the go-ethereum library. If not, see <http://www.gnu.org/licenses/>.

package clmock

import (
"context"
"math/big"
"testing"
"time"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/eth"
"github.com/ethereum/go-ethereum/eth/downloader"
"github.com/ethereum/go-ethereum/eth/ethconfig"
"github.com/ethereum/go-ethereum/node"
"github.com/ethereum/go-ethereum/p2p"
"github.com/ethereum/go-ethereum/params"
)

func startEthService(t *testing.T, genesis *core.Genesis) (*node.Node, *eth.Ethereum, *CLMock) {
t.Helper()

n, err := node.New(&node.Config{
P2P: p2p.Config{
ListenAddr: "127.0.0.1:8545",
NoDiscovery: true,
MaxPeers: 0,
},
})
if err != nil {
t.Fatal("can't create node:", err)
}

ethcfg := &ethconfig.Config{Genesis: genesis, SyncMode: downloader.FullSync, TrieTimeout: time.Minute, TrieDirtyCache: 256, TrieCleanCache: 256}
ethservice, err := eth.New(n, ethcfg)
if err != nil {
t.Fatal("can't create eth service:", err)
}

clmock := NewCLMock(ethservice)

n.RegisterLifecycle(clmock)

if err := n.Start(); err != nil {
t.Fatal("can't start node:", err)
}

ethservice.SetSynced()
return n, ethservice, clmock
}

// send 20 transactions, >10 withdrawals and ensure they are included in order
// send enough transactions to fill multiple blocks
func TestCLMockSendWithdrawals(t *testing.T) {
var withdrawals []types.Withdrawal
txs := make(map[common.Hash]types.Transaction)

var (
// testKey is a private key to use for funding a tester account.
testKey, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")

// testAddr is the Ethereum address of the tester account.
testAddr = crypto.PubkeyToAddress(testKey.PublicKey)
)

// short period (1 second) for testing purposes
period := 1
var gasLimit uint64 = 10_000_000
genesis := core.DeveloperGenesisBlock(uint64(period), gasLimit, testAddr)
node, ethService, mock := startEthService(t, genesis)
_ = mock
defer node.Close()

chainHeadCh := make(chan core.ChainHeadEvent, 10)
subscription := ethService.BlockChain().SubscribeChainHeadEvent(chainHeadCh)
defer subscription.Unsubscribe()

// generate some withdrawals
for i := 0; i < 20; i++ {
withdrawals = append(withdrawals, types.Withdrawal{Index: uint64(i)})
if err := mock.addWithdrawal(withdrawals[i]); err != nil {
t.Fatal("addWithdrawal failed", err)
}
}

// generate a bunch of transactions
signer := types.NewEIP155Signer(ethService.BlockChain().Config().ChainID)
for i := 0; i < 20; i++ {
tx, err := types.SignTx(types.NewTransaction(uint64(i), common.Address{}, big.NewInt(1000), params.TxGas, big.NewInt(params.InitialBaseFee), nil), signer, testKey)
if err != nil {
t.Fatalf("error signing transaction, err=%v", err)
}
txs[tx.Hash()] = *tx

if err := ethService.APIBackend.SendTx(context.Background(), tx); err != nil {
t.Fatal("SendTx failed", err)
}
}

includedTxs := make(map[common.Hash]struct{})
var includedWithdrawals []uint64

timer := time.NewTimer(12 * time.Second)
for {
select {
case evt := <-chainHeadCh:
for _, includedTx := range evt.Block.Transactions() {
includedTxs[includedTx.Hash()] = struct{}{}
}
for _, includedWithdrawal := range evt.Block.Withdrawals() {
includedWithdrawals = append(includedWithdrawals, includedWithdrawal.Index)
}

// ensure all withdrawals/txs included. this will take two blocks b/c number of withdrawals > 10
if len(includedTxs) == len(txs) && len(includedWithdrawals) == len(withdrawals) && evt.Block.Number().Cmp(big.NewInt(2)) == 0 {
return
}
case <-timer.C:
t.Fatal("timed out without including all withdrawals/txs")
}
}
}

0 comments on commit 23ee876

Please sign in to comment.