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
2 changes: 1 addition & 1 deletion .github/workflows/docker-build-scan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- uses: actions/checkout@v4
- name: Detect files changed
id: detect-files-changed
uses: tj-actions/changed-files@v44
uses: step-security/changed-files@3dbe17c78367e7d60f00d78ae6781a35be47b4a1
with:
separator: ','

Expand Down
39 changes: 25 additions & 14 deletions op-chain-ops/cmd/celo-migrate/README.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
# Celo L2 Migration Script
# Celo L2 Migration Tool

## Overview
Tool for preparing a pre-L2 Celo database for use in an L2 Celo node.

This script migrates a Celo L1 database (old datadir) into a new database compatible with Celo L2 (new datadir). It consists of 3 main processes that respectively migrate ancient blocks, non-ancient blocks and state. Migrated data is copied into a new datadir, leaving the old datadir unchanged.
> ⚠️ The instructions in this README are for illustrative purposes only. For the most complete and up-to-date information on how to participate in the Celo L2 hardfork, please see the [Celo Docs](https://docs.celo.org/cel2/notices/l2-migration).

See also the [celo-l2-node-docker-compose](https://github.com/celo-org/celo-l2-node-docker-compose) repo, which provides tooling to make migrating and running Celo L2 nodes easy. We recommend migrating data using the tooling provided there, as it simplifies the migration interface significantly and provides necessary configuration artifacts.

## Migration Script

This script migrates a pre-L2 database into one compatible with Celo L2. It consists of 3 main processes for migrating ancient blocks, non-ancient blocks and state. Migrated data is written to a new datadir, leaving the old datadir unchanged.

To minimize migration downtime, the script is designed to run in two stages:
1. The `pre migration` stage can be run ahead of the `full migration` and will process as much of the migration as possible up to that point.
2. The `full migration` can then be run to finish migrating new blocks that were created after the `pre migration` and apply necessary state changes on top of the migration block.

### Pre migration
1. The `pre-migration` stage can be run ahead of the `full migration` and will process as much of the migration as possible up to that point.
2. The `full migration` can then be run to finish migrating new blocks that were created after the `pre-migration` and apply necessary state changes on top of the migration block.

### Pre-migration

The `pre migration` consists of two parts that are run in parallel:
- Copy and transform the ancient / frozen blocks (i.e. all blocks before the last 90000).
- Copy over the rest of the database using `rsync`.
The `pre-migration` consists of two steps that are run in parallel:

1. Copy and transform the ancient / frozen blocks (i.e. all blocks before the last 90000).
2. Copy over the rest of the database using `rsync`.

The ancients db is migrated sequentially because it is append-only, while the rest of the database is copied and then transformed in-place. We use `rsync` because it has flags for ignoring the ancients directory, skipping any already copied files and deleting any extra files in the new db, ensuring that we can run the script multiple times and only copy over actual updates.

The `pre migration` step is still run during a `full migration` but it will be much quicker as only newly frozen blocks and recent file changes need to be migrated.
The `pre-migration` step is still run during a `full migration` but it will be much quicker as only newly frozen blocks and recent file changes need to be migrated.

### Full migration

During the `full migration`, we re-run the `pre migration` step to capture any updates since the last `pre migration` and then apply in-place changes to non-ancient blocks and state. While this is happening, the script also checks for any stray ancient blocks that have remained in leveldb despite being frozen and removes them from the new db. Non-ancient blocks are then transformed to ensure compatibility with the L2 codebase.
During the `full migration`, we repeat the `pre-migration` step to capture any updates since the last `pre-migration` and then apply in-place changes to non-ancient blocks and state. While this is happening, the script also checks for any stray ancient blocks that have remained in leveldb despite being frozen and removes them from the new db. Non-ancient blocks are then transformed to ensure compatibility with the L2 codebase.

Finally after all blocks have been migrated, the script performs a series of modifications to the state db:

1. First, it deploys the L2 smart contracts by iterating through the genesis allocs passed to the script and setting the nonce, balance, code and storage for each address accordingly, overwritting existing data if necessary.
2. Finally, these changes are committed to the state db to produce a new state root and create the first Celo L2 block.

Expand All @@ -31,9 +40,11 @@ Finally after all blocks have been migrated, the script performs a series of mod
> [!TIP]
> See `--help` for how to run each portion of the script individually, along with other configuration options.

The longest running section of the script is the ancients migration, followed by the `rsync` command. By running these together in a `pre migration` we greatly reduce how long they will take during the `full migration`. Changes made to non-ancient blocks and state during a `full migration` are erased by the next `rsync` command.
- The script outputs a `rollup-config.json` file that is passed to the sequencer in order to start the L2 network.

- The longest running section of the script is the ancients migration, followed by the `rsync` command. By running these together in a `pre-migration` we greatly reduce how long they will take during the `full migration`. Changes made to non-ancient blocks and state during a `full migration` are erased by the next `rsync` command.

The script outputs a `rollup-config.json` file that is passed to the sequencer in order to start the L2 network.
> ⚠️ **Do not migrate archive data, only full node data**. Because we use `rsync` with checksums, the command will take a very long time if run on archive data. All the historical state stored by an archive node will be checksummed even if a `pre-migration` has already been performed to copy over the data. This is slow and memory instensive. Moreover, Celo L2 nodes cannot use pre-hardfork state, so all the state data will be copied over and stored for no reason. Therefore, we do not recommend running the migration script on an archive datadir.

### Running the script

Expand Down Expand Up @@ -124,7 +135,7 @@ forge script scripts/L2Genesis.s.sol:L2Genesis \

To minimize downtime caused by the migration, node operators can prepare their Cel2 databases by running the pre-migration command a day ahead of the actual migration. This will pre-populate the new database with most of the ancient blocks needed for the final migration and copy over other chaindata without transforming it.

If node operators would like to practice a `full migration` they can do so and reset their databases to the correct state by running another `pre migration` afterward.
If node operators would like to practice a `full migration` they can do so and reset their databases to the correct state by running another `pre-migration` afterward.

> [!IMPORTANT]
> The pre-migration should be run using a chaindata snapshot, rather than a db that is being used by a node. To avoid network downtime, we recommend that node operators do not stop any nodes in order to perform the pre-migration.
Expand Down
12 changes: 11 additions & 1 deletion op-chain-ops/cmd/celo-migrate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,17 @@ func runStateMigration(celoL1Head *types.Header, newDBPath string, opts stateMig
}
log.Info("Updated Cel2 state")

rollupConfig, err := config.RollupConfig(l1StartBlock.Header(), cel2Header.Hash(), cel2Header.Number.Uint64(), cel2Header.Time)
// We switched to using the time of the L2 start block as the l2_time in
// aca03db46a48441b17bdb6b7da1a93e7d2565f5e. This happened before our
// mainnet release, but we had already released our testnets using the
// l1StartBlock.Time as the l2_time.
l2Time := cel2Header.Time
switch config.L2ChainID {
case AlfajoresNetworkID, BaklavaNetworkID:
l2Time = l1StartBlock.Time()
}

rollupConfig, err := config.RollupConfig(l1StartBlock.Header(), cel2Header.Hash(), cel2Header.Number.Uint64(), l2Time)
if err != nil {
return err
}
Expand Down
24 changes: 24 additions & 0 deletions op-node/node/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,30 @@ func (n *OpNode) initP2PSigner(ctx context.Context, cfg *Config) (err error) {
}

func (n *OpNode) Start(ctx context.Context) error {
// If n.cfg.Driver.SequencerUseFinalized is true, sequencer does not use non-finalized L1 blocks as L1 origin
// The OpNode periodically fetches the latest safe and finalized L1 block heights (1 epoch ≒ 6.4 minutes by default),
// but these values are not available immediately after startup until the first polling occurs.
// In some cases, this can cause the sequencer to get stuck because it fails to retrieve the next L1 block.
// To prevent this, fetch and initialize the latest safe and finalized L1 block references at startup.
if n.cfg.Driver.SequencerUseFinalized {
reqCtx, reqCancel := context.WithTimeout(ctx, time.Second*20)
defer reqCancel()

finalizedRef, err := n.l1Source.L1BlockRefByLabel(reqCtx, eth.Finalized)
if err != nil {
log.Warn("failed to fetch L1 block", "label", eth.Finalized, "err", err)
} else if finalizedRef != (eth.L1BlockRef{}) {
n.OnNewL1Finalized(reqCtx, finalizedRef)
}

safeRef, err := n.l1Source.L1BlockRefByLabel(reqCtx, eth.Safe)
if err != nil {
log.Warn("failed to fetch L1 block", "label", eth.Safe, "err", err)
} else if safeRef != (eth.L1BlockRef{}) {
n.OnNewL1Safe(reqCtx, safeRef)
}
}

if n.interopSys != nil {
if err := n.interopSys.Start(ctx); err != nil {
n.log.Error("Could not start interop sub system", "err", err)
Expand Down
2 changes: 1 addition & 1 deletion op-node/rollup/driver/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ func NewDriver(
attrBuilder := derive.NewFetchingAttributesBuilder(cfg, l1, l2)
var seqL1Blocks sequencing.L1Blocks
if driverCfg.SequencerUseFinalized {
seqL1Blocks = finalized.NewFinalized(statusTracker.L1Finalized, l1)
seqL1Blocks = finalized.NewFinalized(statusTracker.L1Finalized, l1, log)
} else {
seqL1Blocks = confdepth.NewConfDepth(driverCfg.SequencerConfDepth, statusTracker.L1Head, l1)
}
Expand Down
7 changes: 5 additions & 2 deletions op-node/rollup/finalized/finalized.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"

"github.com/ethereum/go-ethereum"
"github.com/ethereum/go-ethereum/log"

"github.com/ethereum-optimism/optimism/op-node/rollup/derive"
"github.com/ethereum-optimism/optimism/op-service/eth"
Expand All @@ -12,17 +13,19 @@ import (
type finalized struct {
derive.L1Fetcher
l1Finalized func() eth.L1BlockRef
log log.Logger
}

func NewFinalized(l1Finalized func() eth.L1BlockRef, fetcher derive.L1Fetcher) *finalized {
return &finalized{L1Fetcher: fetcher, l1Finalized: l1Finalized}
func NewFinalized(l1Finalized func() eth.L1BlockRef, fetcher derive.L1Fetcher, log log.Logger) *finalized {
return &finalized{L1Fetcher: fetcher, l1Finalized: l1Finalized, log: log}
}

func (f *finalized) L1BlockRefByNumber(ctx context.Context, num uint64) (eth.L1BlockRef, error) {
l1Finalized := f.l1Finalized()
if num == 0 || num <= l1Finalized.Number {
return f.L1Fetcher.L1BlockRefByNumber(ctx, num)
}
f.log.Warn("requested L1 block is beyond local finalized height", "requested_block", num, "finalized_block", l1Finalized.Number)
return eth.L1BlockRef{}, ethereum.NotFound
}

Expand Down
3 changes: 2 additions & 1 deletion op-node/rollup/finalized/finalized_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

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

"github.com/ethereum-optimism/optimism/op-service/eth"
Expand All @@ -27,7 +28,7 @@ func (ft *finalizedTest) Run(t *testing.T) {
l1Finalized := eth.L1BlockRef{Number: ft.final, Hash: ft.hash}
l1FinalizedGetter := func() eth.L1BlockRef { return l1Finalized }

f := NewFinalized(l1FinalizedGetter, l1Fetcher)
f := NewFinalized(l1FinalizedGetter, l1Fetcher, log.New())

if ft.pass {
// no calls to the l1Fetcher are made if the block number is not finalized yet
Expand Down
2 changes: 2 additions & 0 deletions op-node/rollup/sequencing/sequencer.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,8 @@ func (d *Sequencer) startBuildingBlock() {
// Figure out which L1 origin block we're going to be building on top of.
l1Origin, err := d.l1OriginSelector.FindL1Origin(ctx, l2Head)
if err != nil {
d.nextAction = d.timeNow().Add(time.Second)
d.nextActionOK = d.active.Load()
d.log.Error("Error finding next L1 Origin", "err", err)
d.emitter.Emit(rollup.L1TemporaryErrorEvent{Err: err})
return
Expand Down
54 changes: 54 additions & 0 deletions op-node/rollup/sequencing/sequencer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package sequencing
import (
"context"
"encoding/binary"
"fmt"
"math/rand" // nosemgrep
"testing"
"time"
Expand Down Expand Up @@ -613,6 +614,59 @@ func TestSequencerBuild(t *testing.T) {
require.Equal(t, testClock.Now(), nextTime, "start asap on the next block")
}

func TestSequencerL1TemporaryErrorEvent(t *testing.T) {
logger := testlog.Logger(t, log.LevelError)
seq, deps := createSequencer(logger)
testClock := clock.NewSimpleClock()
seq.timeNow = testClock.Now
testClock.SetTime(30000)
emitter := &testutils.MockEmitter{}
seq.AttachEmitter(emitter)

// Init will request a forkchoice update
emitter.ExpectOnce(engine.ForkchoiceRequestEvent{})
require.NoError(t, seq.Init(context.Background(), true))
emitter.AssertExpectations(t)
require.True(t, seq.Active(), "started in active mode")

// It will request a forkchoice update, it needs the head before being able to build on top of it
emitter.ExpectOnce(engine.ForkchoiceRequestEvent{})
seq.OnEvent(SequencerActionEvent{})
emitter.AssertExpectations(t)

// Now send the forkchoice data, for the sequencer to learn what to build on top of.
head := eth.L2BlockRef{
Hash: common.Hash{0x22},
Number: 100,
L1Origin: eth.BlockID{
Hash: common.Hash{0x11, 0xa},
Number: 1000,
},
Time: uint64(testClock.Now().Unix()),
}
seq.OnEvent(engine.ForkchoiceUpdateEvent{UnsafeL2Head: head})
emitter.AssertExpectations(t)

// force FindL1Origin to return an error
deps.l1OriginSelector.l1OriginFn = func(l2Head eth.L2BlockRef) (eth.L1BlockRef, error) {
return eth.L1BlockRef{}, fmt.Errorf("l1OriginFn error")
}

emitter.ExpectOnceRun(func(ev event.Event) {
_, ok := ev.(rollup.L1TemporaryErrorEvent)
require.True(t, ok)
})

sealTargetTime1, ok1 := seq.NextAction()
seq.OnEvent(SequencerActionEvent{})
emitter.AssertExpectations(t)

// FindL1Origin error will updating d.nextAction
sealTargetTime2, ok2 := seq.NextAction()

require.True(t, ok1 == ok2 && sealTargetTime2.After(sealTargetTime1))
}

type sequencerTestDeps struct {
cfg *rollup.Config
attribBuilder *FakeAttributesBuilder
Expand Down