Skip to content

Commit

Permalink
Fix sync-start bug + improve sync-start comments (ethereum-optimism#355)
Browse files Browse the repository at this point in the history
Fix bug (L1 origin head with non-canonical ancestors)

This happened in the scenario where `l2ahead == true` (L1 origin of start is
ahead of know L1 head) and `start` had an ancestor whose L1 origin was known not
to be canonical.

Example: L1 head is at block X. `start` has an L1 origin with number X + 1 but its
parent L2 block has an L1 origin with number X whose blockhash is not canonical.
In this case, the algorithm still returned `start` as the unsafe L1 head, which is
incorrect.

This also touches a bunch of comments to generally improve understanbility.
  • Loading branch information
norswap authored Apr 8, 2022
1 parent 72d6735 commit 55a3868
Showing 1 changed file with 122 additions and 81 deletions.
203 changes: 122 additions & 81 deletions opnode/rollup/sync/start.go
Original file line number Diff line number Diff line change
@@ -1,47 +1,45 @@
// The sync package is responsible for reconciling L1 and L2.
//
// The Ethereum chain is a DAG of blocks with the root block being the genesis block. At any given
// time, the head (or tip) of the chain can change if an offshoot/branch of the chain has a higher
// number. This is known as a re-organization of the canonical chain. Each block points to a parent
// block and the node is responsible for deciding which block is the head and thus the mapping from
// block number to canonical block.
//
// The ethereum chain is a DAG of blocks with the root block being the genesis block.
// At any given time, the head (or tip) of the chain can change if an offshoot of the chain
// has a higher number. This is known as a re-organization of the canonical chain.
// Each block points to a parent block and the node is responsible for deciding which block is the head
// and thus the mapping from block number to canonical block.
// The Optimism (L2) chain has similar properties, but also retains references to the Ethereum (L1)
// chain. Each L2 block retains a reference to an L1 block (its "L1 origin", i.e. L1 block
// associated with the epoch that the L2 block belongs to) and to its parent L2 block. The L2 chain
// node must satisfy the following validity rules:
//
// The optimism chain has similar properties, but also retains references to the ethereum chain.
// Each optimism block retains a reference to an L1 block and to its parent L2 block.
// The L2 chain node must satisfy the following validity rules
// 1. l2block.height == l2parent.block.height + 1
// 2. l2block.l1Origin.height >= l2block.l2parent.l1Origin.height
// 1. l2block.number == l2block.l2parent.block.number + 1
// 2. l2block.l1Origin.number >= l2block.l2parent.l1Origin.number
// 3. l2block.l1Origin is in the canonical chain on L1
// 4. l1_rollup_genesis is an ancestor of l2block.l1Origin
//
// During normal operation, both the L1 and L2 canonical chains can change, due to a re-organisation
// or due to an extension (new L1 or L2 block).
//
// During normal operation, both the L1 and L2 canonical chains can change, due to a reorg
// or an extension (new block).
// - L1 reorg
// - L1 extension
// - L2 reorg
// - L2 extension
// When one of these changes occurs, the rollup node needs to determine what the new L2 head blocks
// should be. We track two L2 head blocks:
//
// When one of these changes occurs, the rollup node needs to determine what the new L2 Heads should be.
// In a simple extension case, the L2 head remains the same, but in the case of a re-org on L1, it needs
// to find the unsafe and safe blocks.
// - The *unsafe L2 block*: This is the highest L2 block whose L1 origin is a plausible (1)
// extension of the canonical L1 chain (as known to the opnode).
// - The *safe L2 block*: This is the highest L2 block whose epoch's sequencing window is
// complete within the canonical L1 chain (as known to the opnode).
//
// Unsafe Block: The highest L2 block. If the L1 Attributes is ahead of the L1 head, it is assumed to be valid,
// if not, it walks back until it finds the first L2 block whose L1 Origin is canonical in the L1 chain.
// Safe Block: The highest L2 block whose sequence window has not changed during a reorg.
// (1) Plausible meaning that the blockhash of the L2 block's L1 origin (as reported in the L1
// Attributes deposit within the L2 block) is not canonical at another height in the L1 chain,
// and the same holds for all its ancestors.
//
// The safe block can be found by walking back one sequence window from the "latest" L2 block. The latest L2
// block is the first L2 block whose L1 Origin is canonical in L1. If the unsafe block is ahead of the L1
// chain, the latest block and unsafe block are not the same.
// In particular, in the case of L1 extension, the L2 unsafe head will generally remain the same,
// but in the case of an L1 re-org, we need to search for the new safe and unsafe L2 block.
package sync

import (
"context"
"errors"
"fmt"

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

"github.com/ethereum-optimism/optimistic-specs/opnode/eth"
Expand All @@ -62,96 +60,139 @@ var TooDeepReorgErr = errors.New("reorg is too deep")

const MaxReorgDepth = 500

// isCanonical returns true if the supplied block ID is canonical in the L1 chain.
// It will suppress ethereum.NotFound errors
func isCanonical(ctx context.Context, l1 L1Chain, block eth.BlockID) (bool, error) {
canonical, err := l1.L1BlockRefByNumber(ctx, block.Number)
if err != nil && !errors.Is(err, ethereum.NotFound) {
return false, err
} else if err != nil {
return false, nil
// isCanonical returns the following values:
// - `aheadOrCanonical: true if the supplied block is ahead of the known head of the L1 chain,
// or canonical in the L1 chain.
// - `canonical`: true if the block is canonical in the L1 chain.
func isAheadOrCanonical(ctx context.Context, l1 L1Chain, block eth.BlockID) (aheadOrCanonical bool, canonical bool, err error) {
if l1Head, err := l1.L1HeadBlockRef(ctx); err != nil {
return false, false, err
} else if block.Number > l1Head.Number {
return true, false, nil
} else if canonical, err := l1.L1BlockRefByNumber(ctx, block.Number); err != nil {
return false, false, err
} else {
canonical := canonical.Hash == block.Hash
return canonical, canonical, nil
}
return canonical.Hash == block.Hash, nil
}

// FindL2Heads walks back from the supplied L2 blocks and finds the unsafe and safe L2 blocks.
// Unsafe Block: The highest L2 block. If the L1 Attributes is ahead of the L1 head, it is assumed to be valid,
// if not, it walks back until it finds the first L2 block whose L1 Origin is canonical in the L1 chain.
// Safe Block: The highest L2 block whose sequence window has not changed during a reorg.
// FindL2Heads walks back from `start` (the previous unsafe L2 block) and finds the unsafe and safe
// L2 blocks.
//
// - The *unsafe L2 block*: This is the highest L2 block whose L1 origin is a plausible (1)
// extension of the canonical L1 chain (as known to the opnode).
// - The *safe L2 block*: This is the highest L2 block whose epoch's sequencing window is
// complete within the canonical L1 chain (as known to the opnode).
//
// (1) Plausible meaning that the blockhash of the L2 block's L1 origin (as reported in the L1
// Attributes deposit within the L2 block) is not canonical at another height in the L1 chain,
// and the same holds for all its ancestors.
func FindL2Heads(ctx context.Context, start eth.L2BlockRef, seqWindowSize uint64,
l1 L1Chain, l2 L2Chain, genesis *rollup.Genesis) (unsafe eth.L2BlockRef, safe eth.L2BlockRef, err error) {

// Loop 1. Walk the L2 chain backwards until we find an L2 block whose L1 origin is canonical.

// Current L2 block.
n := start

// Number of blocks between n and start.
reorgDepth := 0

// Blockhash of L1 origin hash for the L2 block during the previous iteration, 0 for first
// iteration. When this changes as we walk the L2 chain backwards, it means we're seeing a different
// (earlier) epoch.
var prevL1OriginHash common.Hash
// First check if the L1 Origin of the start block is ahead of the current L1 head
// If so, we assume that this should be the next unsafe head for the sequencing window
// We still need to walk back the safe head because we don't know where the reorg started.
l1Head, err := l1.L1HeadBlockRef(ctx)
if err != nil {
return eth.L2BlockRef{}, eth.L2BlockRef{}, err
}
l2Ahead := start.L1Origin.Number > l1Head.Number
var latest eth.L2BlockRef

// Walk L2 chain until we find the "latest" L2 block. This the first L2 block whose L1 Origin is canonical.
for n := start; ; {
// Check if l1Origin is canonical when we get to a new epoch
// The highest L2 ancestor of `start` (or `start` itself) whose ancestors are not (yet) known
// to have a non-canonical L1 origin. Empty if no such candidate is known yet. Guaranteed to be
// set after exiting from Loop 1.
var highestPlausibleCanonicalOrigin eth.L2BlockRef

for {
// Check if l1Origin is canonical when we get to a new epoch.
if prevL1OriginHash != n.L1Origin.Hash {
if ok, err := isCanonical(ctx, l1, n.L1Origin); err != nil {
prevL1OriginHash = n.L1Origin.Hash

if plausible, canonical, err := isAheadOrCanonical(ctx, l1, n.L1Origin); err != nil {
return eth.L2BlockRef{}, eth.L2BlockRef{}, err
} else if ok {
latest = n
break
} else if !plausible {
// L1 origin nor ahead of L1 head nor canonical, discard previous candidate and
// keep looking.
highestPlausibleCanonicalOrigin = eth.L2BlockRef{}
} else {
if highestPlausibleCanonicalOrigin == (eth.L2BlockRef{}) {
// No highest plausible candidate, make L2 block new candidate.
highestPlausibleCanonicalOrigin = n
}
if canonical {
break
}
}
prevL1OriginHash = n.L1Origin.Hash
}
// Don't walk past genesis. If we were at the L2 genesis, but could not find the L1 genesis
// pointed to from it, we are on the wrong L1 chain.

// Don't walk past genesis. If we were at the L2 genesis, but could not find its L1 origin,
// the L2 chain is building on the wrong L1 branch.
if n.Hash == genesis.L2.Hash || n.Number == genesis.L2.Number {
return eth.L2BlockRef{}, eth.L2BlockRef{}, WrongChainErr
}

// Pull L2 parent for next iteration
n, err = l2.L2BlockRefByHash(ctx, n.ParentHash)
if err != nil {
return eth.L2BlockRef{}, eth.L2BlockRef{}, fmt.Errorf("failed to fetch L2 block by hash %v: %w", n.ParentHash, err)
return eth.L2BlockRef{}, eth.L2BlockRef{},
fmt.Errorf("failed to fetch L2 block by hash %v: %w", n.ParentHash, err)
}

reorgDepth++
if reorgDepth >= MaxReorgDepth {
// If the reorg depth is too large, something is fishy.
// This can legitimately happen if L1 goes down for a while. But in that case,
// restarting the L2 node with a bigger configured MaxReorgDepth is an acceptable
// stopgap solution.
// Currently this can also happen if the L2 node is down for a while, but in the future
// state sync should prevent this issue.
return eth.L2BlockRef{}, eth.L2BlockRef{}, TooDeepReorgErr
}
}
depth := uint64(1) // SeqWindowSize is a length, but we are counting elements in the window.
prevL1OriginHash = latest.L1Origin.Hash
// Walk from the latest block back 1 Sequence Window of L1 Origins to determine the safe L2 block.
for n := latest; ; {
// Advance depth if new origin

// Loop 2. Walk from the L1 origin of the `n` block (*) back to the L1 block that starts the
// sequencing window ending at that block. Instead of iterating on L1 blocks, we actually
// iterate on L2 blocks, because we want to find the safe L2 head, i.e. the highest L2 block
// whose L1 origin is the start of the sequencing window.

// (*) `n` being at this stage the highest L2 block whose L1 origin is canonical.

// Depth counter: we need to walk back `seqWindowSize` L1 blocks in order to find the start
// of the sequencing window.
depth := uint64(1)

// Before entering the loop: `prevL1OriginHash == n.L1Origin.Hash`
// The original definitions of `n` and `prevL1OriginHash` still hold.
for {
// Advance depth if we change to a different (earlier) epoch.
if n.L1Origin.Hash != prevL1OriginHash {
depth++
prevL1OriginHash = n.L1Origin.Hash
}
// Walked sufficiently far
if depth == seqWindowSize {
if l2Ahead {
return start, n, nil
} else {
return latest, n, nil
}

// Found an L2 block whose L1 origin is the start of the sequencing window.
if depth == seqWindowSize {
return highestPlausibleCanonicalOrigin, n, nil
}

// Genesis is always safe.
if n.Hash == genesis.L2.Hash || n.Number == genesis.L2.Number {
safe = eth.L2BlockRef{Hash: genesis.L2.Hash, Number: genesis.L2.Number, Time: genesis.L2Time, L1Origin: genesis.L1}
if l2Ahead {
return start, safe, nil
} else {
return latest, safe, nil
}

safe = eth.L2BlockRef{Hash: genesis.L2.Hash, Number: genesis.L2.Number,
Time: genesis.L2Time, L1Origin: genesis.L1}
return highestPlausibleCanonicalOrigin, safe, nil
}
// Pull L2 parent for next iteration

// Pull L2 parent for next iteration.
n, err = l2.L2BlockRefByHash(ctx, n.ParentHash)
if err != nil {
return eth.L2BlockRef{}, eth.L2BlockRef{}, fmt.Errorf("failed to fetch L2 block by hash %v: %w", n.ParentHash, err)
return eth.L2BlockRef{}, eth.L2BlockRef{},
fmt.Errorf("failed to fetch L2 block by hash %v: %w", n.ParentHash, err)
}
}

}

0 comments on commit 55a3868

Please sign in to comment.