Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deterministic L1 starting block selection (Config in advance) #342

Open
wants to merge 21 commits into
base: celo-rebase-12
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 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
259 changes: 259 additions & 0 deletions op-chain-ops/cmd/celo-migrate/beacon.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"

"github.com/ethereum-optimism/optimism/op-service/eth"
"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/common"
)

const (
// See - https://eth2book.info/capella/part3/containers/state/
beaconChainGenesisTimeSeconds = 1606824000
beaconChainSlotDurationSeconds = 12
beaconSlotsPerEpoch = 32
)

type beaconClient struct {
cl *http.Client
// A beaconchain RPC API endpoint.
beaconRPC string
// A beaconcha.in api endpoint.
beaconchainURL string
}

func NewBeaconClient(beaconRPC string, beaconchainURL string) *beaconClient {
return &beaconClient{
beaconRPC: beaconRPC,
beaconchainURL: beaconchainURL,
cl: &http.Client{},
}
}

// MostRecentFinalizedL1BlockAtTime returns the hash of the most recent
// finalized L1 block at the given point in time. It finds the epoch within which the
// given time falls and then looks back from there to find the first finalized
// block.
func (c *beaconClient) MostRecentFinalizedL1BlockAtTime(ctx context.Context, pointInTime uint64) (common.Hash, error) {
// Find the epoch starting at or before the L2 start time.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you renamed the arg, but the comment still references the old name

epochNumber := ContainingSlot(pointInTime) / beaconSlotsPerEpoch

// This epoch is guaranteed to not be complete at L2 start time (if the L2 start time falls in the last
// second of the epoch the epoch is still not complete, and if it was we'd be selecting the next epoch)
// The previous epoch is the most recent completed epoch.
// The one prior to that is the most recent justified epoch.
// And the first block of the justified epoch (the epoch boundary block) will be finalized.
// This is assuming that there was at least 2/3 participation in the completed and justified epochs.
var epoch *Epoch
var err error
names := [2]string{"completed", "justified"}

// Check the most recent completed and justified epochs had a participation rate of at least 0.67.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and following steps are dependent on that the point-in-time has actually passed (in wall-time and on-chain in terms of prior epoch justifications).
In the L1StartBlock determining code you wait for that time to pass prior to calling this method.

I think it would make sense to include the waiting logic here instead, since the function basically doesn't do anything when called before that time anyways.

Additionally, you could make the point in time arg optional, and if it is nil it takes time.Now().

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to wait in this method, makes sense thanks!

for i := uint64(1); i <= 2; i++ {
epoch, err = c.Epoch(ctx, epochNumber-i)
if err != nil {
return common.Hash{}, fmt.Errorf("error fetching epoch %d: %w", epochNumber-i, err)
}
if epoch.Data.Globalparticipationrate < 0.67 {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have preferred to use the BeaconAPI for this, but it seems like you would have to process the BLS signatures and the graph of source to target in the attestations yourself for each block in the epoch...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I think I can use the /eth/v1/beacon/states/{state_id}/finality_checkpoints endpoint for this, I wasn't able to test before since I hadn't found an rpc that supported historical requests for /eth/v1/beacon/states/{state_id}/finality_checkpoints but https://ethereum-holesky-beacon-api.publicnode.com seems to. So I'm planning to remove the reliance on beaconcha.in

return common.Hash{}, fmt.Errorf("most recent %s epoch before the L2 start time (%d) has less than 0.67 participation rate (%.2f)", names[i-1], pointInTime, epoch.Data.Globalparticipationrate)
}
}
// Calculate the first slot of the most recent justified epoch.
mostRecentFinalizedSlot := (epochNumber - 2) * beaconSlotsPerEpoch

// Find the most recent actual finalized block, slots can be empty so we
// search back if we encounter empty slots. We check up to 10 slots, if they
// are all empty something serious is wrong with the L1 so we abort.
var beaconBlock *BeaconBlock
for i := range uint64(10) {
beaconBlock, err = c.BeaconBlock(ctx, mostRecentFinalizedSlot-i)
if errors.Is(err, ethereum.NotFound) {
// If there is not block for this slot then skip to the next.
continue
}
if err != nil {
return common.Hash{}, fmt.Errorf("error fetching beacon block at slot %d: %w", mostRecentFinalizedSlot, err)
}
if !beaconBlock.Finalized {
return common.Hash{}, fmt.Errorf("expecting beacon block at slot %d to be finalized", mostRecentFinalizedSlot)
}
break // We found a good block.
}
if beaconBlock == nil {
return common.Hash{}, fmt.Errorf("failed to find finalized block searching up to 10 slots back from the most recent finalized slot (%d) at the L2 fork time (%d)", mostRecentFinalizedSlot, pointInTime)
}
return common.HexToHash(beaconBlock.Data.Message.Body.ExecutionPayload.BlockHash), nil
}

// Epoch gets the requested epoch from the beaconcha.in api.
func (c *beaconClient) Epoch(ctx context.Context, num uint64) (epoch *Epoch, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v1/epoch/%d", c.beaconchainURL, num), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request to get epoch: %w", err)
}
resp, err := c.cl.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch epoch: %w", err)
}
defer func() {
err = errors.Join(err, resp.Body.Close())
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch epoch, http status %d", resp.StatusCode)
}
epoch = &Epoch{}
if err := json.NewDecoder(resp.Body).Decode(epoch); err != nil {
return nil, fmt.Errorf("failed to decode epoch: %w", err)
}
return epoch, nil
}

// BeaconBlock gets the beacon block from the beacon rpc api.
func (c *beaconClient) BeaconBlock(ctx context.Context, slot uint64) (block *BeaconBlock, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/eth/v2/beacon/blocks/%d", c.beaconRPC, slot), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request to get beacon block: %w", err)
}
resp, err := c.cl.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch beacon block: %w", err)
}
defer func() {
err = errors.Join(err, resp.Body.Close())
}()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("failed to fetch beacon block at slot %d: %w", slot, ethereum.NotFound)
}
return nil, fmt.Errorf("failed to fetch beacon block at slot %d, http status %d", slot, resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&block); err != nil {
return nil, fmt.Errorf("failed to decode beacon block: %w", err)
}
return block, nil
}

// EpochStartTime returns the start time of an epoch.
func EpochStartTime(epoch uint64) uint64 {
return beaconChainGenesisTimeSeconds + (epoch * beaconSlotsPerEpoch * beaconChainSlotDurationSeconds)
}

// ContainingEpoch returns the number of the epoch whithin which the given time falls.
func ContainingEpoch(unixTime uint64) uint64 {
return ContainingSlot(unixTime) / beaconSlotsPerEpoch
}

// ContainingSlot returns the slot within which the given time falls.
func ContainingSlot(unixTime uint64) uint64 {
// Get the slot at or before the given time.
// Slot = (start - genesis) / slotDuration
return (unixTime - beaconChainGenesisTimeSeconds) / beaconChainSlotDurationSeconds
}

type BeaconBlock struct {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there some lib for that kind of stuff?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found these two options:

But they both just provide clients for the beacon-rpc, so I'd still need to write a client for the beaconcha.in requests, it didn't seem worth it to import all that stuff for one method.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use something like go-swagger to generate the schema (if you didn't use that already) directly fro m the swagger file, but I don't think it's worth it

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds interesting, I will have a look, where is the swagger file?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://ethereum.github.io/beacon-APIs/releases/v3.0.0/beacon-node-oapi.json

The beacon-api specs is basically just a render of that

Version string `json:"version"`
ExecutionOptimistic bool `json:"execution_optimistic"`
Finalized bool `json:"finalized"`
Data struct {
Message struct {
Slot eth.Uint64String `json:"slot"`
ProposerIndex string `json:"proposer_index"`
ParentRoot string `json:"parent_root"`
StateRoot string `json:"state_root"`
Body struct {
RandaoReveal string `json:"randao_reveal"`
Eth1Data struct {
DepositRoot string `json:"deposit_root"`
DepositCount string `json:"deposit_count"`
BlockHash string `json:"block_hash"`
} `json:"eth1_data"`
Graffiti string `json:"graffiti"`
ProposerSlashings []interface{} `json:"proposer_slashings"`
AttesterSlashings []interface{} `json:"attester_slashings"`
Attestations []struct {
AggregationBits string `json:"aggregation_bits"`
Data struct {
Slot string `json:"slot"`
Index string `json:"index"`
BeaconBlockRoot string `json:"beacon_block_root"`
Source struct {
Epoch string `json:"epoch"`
Root string `json:"root"`
} `json:"source"`
Target struct {
Epoch string `json:"epoch"`
Root string `json:"root"`
} `json:"target"`
} `json:"data"`
Signature string `json:"signature"`
} `json:"attestations"`
Deposits []interface{} `json:"deposits"`
VoluntaryExits []interface{} `json:"voluntary_exits"`
SyncAggregate struct {
SyncCommitteeBits string `json:"sync_committee_bits"`
SyncCommitteeSignature string `json:"sync_committee_signature"`
} `json:"sync_aggregate"`
ExecutionPayload struct {
ParentHash string `json:"parent_hash"`
FeeRecipient string `json:"fee_recipient"`
StateRoot string `json:"state_root"`
ReceiptsRoot string `json:"receipts_root"`
LogsBloom string `json:"logs_bloom"`
PrevRandao string `json:"prev_randao"`
BlockNumber eth.Uint64String `json:"block_number"`
GasLimit string `json:"gas_limit"`
GasUsed string `json:"gas_used"`
Timestamp eth.Uint64String `json:"timestamp"`
ExtraData string `json:"extra_data"`
BaseFeePerGas string `json:"base_fee_per_gas"`
BlockHash string `json:"block_hash"`
Transactions []string `json:"transactions"`
Withdrawals []struct {
Index string `json:"index"`
ValidatorIndex string `json:"validator_index"`
Address string `json:"address"`
Amount string `json:"amount"`
} `json:"withdrawals"`
BlobGasUsed string `json:"blob_gas_used"`
ExcessBlobGas string `json:"excess_blob_gas"`
} `json:"execution_payload"`
BlsToExecutionChanges []interface{} `json:"bls_to_execution_changes"`
BlobKzgCommitments []string `json:"blob_kzg_commitments"`
} `json:"body"`
} `json:"message"`
Signature string `json:"signature"`
} `json:"data"`
}

type Epoch struct {
Status string `json:"status"`
Data struct {
Attestationscount int `json:"attestationscount"`
Attesterslashingscount int `json:"attesterslashingscount"`
Averagevalidatorbalance int64 `json:"averagevalidatorbalance"`
Blockscount int `json:"blockscount"`
Depositscount int `json:"depositscount"`
Eligibleether int64 `json:"eligibleether"`
Epoch int `json:"epoch"`
Finalized bool `json:"finalized"`
Globalparticipationrate float64 `json:"globalparticipationrate"`
Missedblocks int `json:"missedblocks"`
Orphanedblocks int `json:"orphanedblocks"`
Proposedblocks int `json:"proposedblocks"`
Proposerslashingscount int `json:"proposerslashingscount"`
RewardsExported bool `json:"rewards_exported"`
Scheduledblocks int `json:"scheduledblocks"`
Totalvalidatorbalance int64 `json:"totalvalidatorbalance"`
Ts time.Time `json:"ts"`
Validatorscount int `json:"validatorscount"`
Voluntaryexitscount int `json:"voluntaryexitscount"`
Votedether int64 `json:"votedether"`
Withdrawalcount int `json:"withdrawalcount"`
} `json:"data"`
}
64 changes: 64 additions & 0 deletions op-chain-ops/cmd/celo-migrate/beacon_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package main

import (
"context"
"testing"
"time"

"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestFinalizedL1BlockSelection(t *testing.T) {
bc := NewBeaconClient("https://eth-beacon-chain.drpc.org/rest", "https://beaconcha.in/api")
var targetTime uint64 = 1740759647 // this is the timestamp of slot 11161302 https://beaconcha.in/slot/11161302
var expectedSlot uint64 = 11161216 // this is the beginning slot of the epoch 2 before https://beaconcha.in/slot/11161216
expectedL1BlockHash := common.HexToHash("0x6c71e110fc83faa393017681ab97b26621cda68d12e31d24917e210d169d7be5")

// We expect the same block to be chosen regardless of which slot in an
// epoch the target time falls in. The only thing that would change the
// chosen block would be if the target time fell in a different epoch.

t.Run("MidEpochSlot", func(t *testing.T) {
checkFinalizedL1BlockSelection(t, bc, targetTime, expectedSlot, expectedL1BlockHash)
})

t.Run("MidEpochMidSlot", func(t *testing.T) {
checkFinalizedL1BlockSelection(t, bc, targetTime+beaconChainSlotDurationSeconds/2, expectedSlot, expectedL1BlockHash)
})

epochFirstSlotTime := EpochStartTime(ContainingEpoch(targetTime))
t.Run("StartEpochSlot", func(t *testing.T) {
checkFinalizedL1BlockSelection(t, bc, epochFirstSlotTime, expectedSlot, expectedL1BlockHash)
})

epochLastSlotTime := epochFirstSlotTime + beaconChainSlotDurationSeconds*(beaconSlotsPerEpoch-1)
t.Run("EndEpochSlot", func(t *testing.T) {
checkFinalizedL1BlockSelection(t, bc, epochLastSlotTime, expectedSlot, expectedL1BlockHash)
})

// Execution payload block hash retrieved here - https://beaconcha.in/slot/11161184
expectedL1BlockHashPrevEpoch := common.HexToHash("0xca6237f41190e1adfa52ba20fadb03ef14a7f57c516806edf7660f301b08de36")
t.Run("PriorEpochEndSlot", func(t *testing.T) {
checkFinalizedL1BlockSelection(t, bc, epochFirstSlotTime-1, expectedSlot-beaconSlotsPerEpoch, expectedL1BlockHashPrevEpoch)
})

// Execution payload block hash retrieved here - https://beaconcha.in/slot/11161248
expectedL1BlockHashNextEpoch := common.HexToHash("0x7892cbc14d57ab7036a722306bcae1fc07a202fc5907a227cce539b5f1ca41b3")
t.Run("NextEpochStartSlot", func(t *testing.T) {
checkFinalizedL1BlockSelection(t, bc, epochLastSlotTime+beaconChainSlotDurationSeconds, expectedSlot+beaconSlotsPerEpoch, expectedL1BlockHashNextEpoch)
})
}

func checkFinalizedL1BlockSelection(t *testing.T, bc *beaconClient, targetTime, expectedSlot uint64, expectedBlockHash common.Hash) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
t.Cleanup(cancel)
calculatedHash, err := bc.MostRecentFinalizedL1BlockAtTime(ctx, targetTime)
require.NoError(t, err)
assert.Equal(t, expectedBlockHash, calculatedHash)
// Double check that we are targeting the correct slot
block, err := bc.BeaconBlock(ctx, expectedSlot)
require.NoError(t, err)
assert.Equal(t, expectedBlockHash.String(), block.Data.Message.Body.ExecutionPayload.BlockHash)
}
Loading