Optimism Bedrock Wrap Up Series 4 [KR]

Block Derivation 과정 분석

Aaron Lee
Tokamak Network
36 min readNov 9, 2023

--

포스팅을 작성하는 데에 도움을 주신 Theo L. Austin O. 님에게 감사를 표합니다.

Fig. Optimism 일러스트 (Source: OP LABS)

본 글은 Onther에서 기획한 ‘Optimism Bedrock Wrap Up 시리즈’ 총 5편의 4번째 글로 L1에 제출된 batcher transaction이 다시 L2 블록으로 변환되는 block derivation 과정을 분석합니다. 시리즈의 구성이 서로 연관되어 있으므로, 차례대로 읽어보시는 것을 추천해 드립니다.

1. Bedrock 업그레이드 개요 및 계층별 구성요소: Bedrock 버전의 새로운 구조를 이해하기 위해 Bedrock 업그레이드의 개요를 살펴보고, 각 계층의 핵심 구성요소와 배포된 스마트 컨트랙트를 간략히 살펴봅니다.

2. Bedrock 업그레이드 이후 주요 변경 사항: Bedrock 업그레이드 이후 주요 변경 사항을 이해하기 쉽게 설명하여 이후 시리즈에 대한 이해를 돕고자 합니다.

3. 입·출금 프로세스 분석: 입·출금 프로세스의 흐름을 계층별 핵심 코드 로직을 통해 순차적으로 분석합니다.

5. Optimism Bedrock 구성 요소들의 역할 및 동작: 해당 시리즈의 마지막 구성으로 Op-Batcher와 Op-Proposer의 역할과 동작 로직을 종합적으로 살펴봅니다.

Block Derivation

Sequencer는 L2 블록을 생성하고, batch submitter는 이러한 L2 블록 정보를 압축하여 L1으로 보내는 과정을 담당한다. 그리고, 이렇게 L1으로 전송된 정보를 기반으로 L2 체인이 다시 구성되는 과정을 ‘block derivation(블록 파생)’이라고 한다. 이러한 과정은 sequencer(시퀀서)가 생성한 블록에 대해 validator(검증자)가 sanity check(건전성 검사)를 수행할 수 있도록 하며, 현재는 중앙화된 rollup-node가 두 역할을 대행하고 책임을 지며 네트워크를 운영하고 있다.

Derivation이 이루어지기 위한 전제 조건으로 L2 블록은 블록 생성 시간인 2초 간격으로 생성되어야 하며, L2 블록의 타임스탬프는 L1의 타임스탬프와 동기화되도록 정의되어야 한다는 것이다. 이 동기화는 단순히 똑같이 만든다는 것이 아니라 각 체인에서 생성된 블록이 논리적 시간 순서대로 연결되도록 보장하는 것을 의미한다. 이를 위해 Bedrock 업그레이드에서 도입된 몇 가지 개념이 있으며, 이러한 개념을 먼저 설명하고 코드 레벨 분석을 이어가도록 하겠다.

Sequencing Window

Fig. SWS가 3으로 설정된 가정하에, 각 epoch에 대한 L1 블록과 L2 블록의 mapping 예시

Bedrock에서 도입된 ‘sequencing Epoch’ 에 의해 각 L1 블록이 Sequencing Epoch 범위 내에 mapping 되어 L2 블록과 연결되어 있다. 그리고, 설정한 범위 내 하나의Epoch에는 다수의 L2 블록이 포함된다. 또한, 각 Sequencing epoch은 고유한 Epoch 번호로 식별되며, Epoch 내에서 각 L2 블록은 L1 블록과 1:1 대응 관계를 가지게 된다.

이러한 Sequencing Epoch의 범위를 Sequencing Window Size(SWS)라고 하며 optimism에서는 각 Epoch이 제출된 시점을 구분하기 위해 이를 도입하고, 특정 간격마다 L1과 동기화하도록 했다. 이에 L1 블록 N에 대응되는 Epoch N이 존재하며, 해당 Epoch N에 속하는 L2 블록의 batch 정보는 L1 블록 번호 N + SWS 사이에 정의된다. 다시 말하면, Epoch N에 속하는 L2 블록 정보는 L1 블록 번호 N에서 N + SWS 사이의 L1 블록에 저장된다는 것이다. 위 예시를 보면 SWS가 3이라는 가정하에 Epoch 100에 있는 L2 블록이 L1의 블록과 mapping 될 때, 100번, 101번, 102번 블록까지만 mapping 되는 것을 볼 수 있다.

Epoch 내의 모든 L2 블록은 L1 블록의 block hash와 timestamp를 가지고 있으며, epoch의 첫 번째 블록에는 L1의 OpimismPortal을 통해 시작된 모든 입금 트랜잭션이 포함된다.

Fig. 각 epoch의 첫 번째 L2 블록 트랜잭션 예시 (source: optimistic.etherscan. optimistic.etherscan)

위 스크린샷은 서로 다른 두 L2 블록 Epoch의 첫 번째 블록 트랜잭션 로그를 보여준다. 먼저, 111899054번째 L2 블록 내 트랜잭션 목록을 보면 제일 하단에 노란색으로 마킹된 트랜잭션이 L1 Attributes Deposited Transaction이며, 그 바로 위 트랜잭션이 Relay Message로 deposit transaction임을 확인할 수 있다. 그리고 나머지 초록색으로 마킹된 트랜잭션은 L2 내에서 발생하고 실행된 다른 트랜잭션으로 채워져 있다.

반면, 111687608번째 L2 블록을 보면 어떤 deposit transaction도 없고, L2 내에서 발생한 트랜잭션 또한 없으므로, 해당 블록에는 L1 Attributes Deposited Transaction 하나만 생성된 것을 볼 수 있다.

그리고, 특정 epoch에 속하는 L2 블록의 batch 정보는 SWS 내 어느 L1 블록 번호에든 제출할 수 있고, 하나의 epoch을 다시 구성하기 위해서는 epoch에 포함되는 모든 블록을 가져다가 batch 정보를 검색해야 한다. 정리하자면, sequencing window가 필요한 이유는 불확실성을 방지하기 위함이다. 이는 L1과 L2 네트워크 트래픽 상황에 따라 여러 변수가 생길수 있는데, 만일 Sequencing window가 없다면 sequencer가 이전 epoch에 블록을 추가하거나, 특정 트랙잭션을 추적하는데 있어 불필요한 자원이 너무 많이 소비될 수 있다.

이어서 L2에서 생성된 블록이 어떠한 과정을 거쳐서 L1에 제출되는지 알아보도록 하겠다.

Batch Submission Architecture

Fig. L2 블록이 L1의 BatchInbox에 제출되기까지의 데이터 변환 과정

Sequencer가 L1 트랜잭션을 전달받아 L2 블록을 생성하면, 여러 L2 블록이 모여서 하나의 sequencer batch가 생성된다. 그리고, sequencer batch는 다시 ‘channel’ 형태의 데이터로 변환되고 channel은 다시 여러 channel frame으로 분할된다. 이어서 분할된 frame은 다시 batcher transaction으로 변환되어 최종적으로 batch submitter에 의해 L1의 BatchInbox에 제출되는 것으로 rollup이 마무리된다.

그렇다면, 여기서 L2 블록과 Batch의 차이를 살펴보면 L2 블록에는 state root(상태 루트)가 포함되지만, batch에는 L2 block number나 timestamp와 같은 트랜잭션 데이터만 포함된다는 것이다. 어떻게 보면 batch는 state라는 알맹이 없이 L1에서 L2 블록을 효율적이고 명확하게 가리키는 역할을 한다.

Batch Submission Wire Format

위와 같이 batch를 제출하는 과정은 L2 chain derivation과 밀접한 관련이 있는데, 이를 반대로 수행하는 것이 derivation이라고 이해하면 편하다. 결국, L2 chain derivation은 BatchInbox에 제출된 batcher transaction을 가지고 다시 L2 블록을 생성하여 sanity-check를 진행하는 것에 목적을 둔다.

코드 분석에 들어가기 전 Optimism에서 제공한 그림을통해 전반적인 derivation 프로세스를 살펴보겠다.

Fig. block derivation 과정을 도식화한 다이어그램 (Source: github link)

Batcher transaction을 보여준다. 이 경우 channel 내 모든 frame이 순서대로 존재하지만, 일반적으로는 그렇지 않다. 예를 들어, 두 번째 트랜잭션에서 A1과 B0의 위치가 바뀌어도 결과적으로는 차이가 없다.

나눠었던 channel frame이 순서에 맞게 재정렬되어 다시 channel로 만들어진다.

Channel에서 압축되어 있던 batch를 다시 추출한다.

각 batch에서 트랜잭션을 추출한다. (batch와 블록은 1대1 mapping 되어 있지만 L1에서 트랜잭션 사이 ‘gap(간격)’이 발생했을 경우 빈 블록이 삽입될 수도 있다.)

각 L2 블록의 epoch과 일치하는 L1 블록에 대한 정보를 기록하는 L1 attributes deposited transaction을 나타낸다. 첫 번째 숫자 99는 epoch의 번호를 나타내고 두 번째 숫자 2는 epoch 내 순서를 나타낸다.

L1 deposit contract(OptimismPortal) 이벤트에서 생성된 user-deposited transaction을 나타낸다.

Sanity-Check

앞서 sanity-check에 관해서 언급하였는데, 여기까지의 설명을 들으면 과연 Block Derivation이 무엇을 하기 위해 만들어진 로직인가 하는 의문이 든다. 작성자는 처음 derivation이라고 해서 이전에 L2에서 생성된 블록이 잘못 만들어졌거나 위변조에 대한 검증이 이루어지는 것인가 했지만, batch라는 것은 어차피 sequencer가 생성한 L2 블록을 기반으로 생성한 것이기에 사실 블록이나 트랜잭션에 대해 검증한다는 것은 맞지 않는다.

따라서, L2 체인으로 거슬러 올라가는 동안 트랜잭션의 초기 형태인 forkchoice state와 실제 L1에 제출된 batch 사이에서 시스템상 블록 구성이 잘못되지는 않았는지 확인하는 프로세스로 이해하면 쉽다. 다른 하나는 L1 chain re-org가 발생했는지에 대한 감지를 할 수 있게 해줌으로, L1 블록의 finality(완결성)를 추적하는 역할을 하고, 블록의 type을 finalize(확정) 하는 핵심 기능을 제공해 준다(L2 블록의 finality 부분은 시리즈2의 ‘Rollup Node의 Block Derivation’을 참고). 이는 결국 L2 블록의 re-org 발생 가능성을 결정짓는 과정이자, ‘Block Derivation’ 과정이 반드시 필요한 이유가 된다. 이제 이렇게 검사하는 과정을 코드로 살펴보도록 하겠다.

L2 Chain Derivation Pipeline

이번 섹션에서는 derivation pipeline 아키텍처를 통해 L1에 제출된 batcher transaction이 L2 체인으로 다시 생성되는 실질적인 derivation 과정을 단계별로 살펴본다.

Fig. derivation pipeline 과정의 데이터의 흐름과 반대되는 실행 우선순위

위 그림에서 데이터의 흐름은 L1에 제출된 batcher transaction이 다시 L2 블록으로 변환되는 과정이며, 1번 단계부터 순차적으로 진행된다. 하지만, 실행 우선순위는 8번 단계인 Engine Queue에서부터 역순으로 진행되는데, 이는 각 과정에서 처리할 데이터가 더 이상 없을 경우, 이전 단계에 해당 데이터를 요청하여 단계별로 거슬러 올라가면서 변환할 데이터를 받아오는 방식으로 진행된다. 즉, 각 단계 각 데이터를 받아서 변환하는 과정에서 함수 호출이 engine equeue에서부터 순차적으로 호출되는 것이다. 따라서 해당 단계가 어떤 방식으로 호출됐는지를 알고 싶다면 그 이전 단계가 아닌 그 다음 단계에서 어떤 메소드로 인해서 호출됐는지를 보면 알 수 있다.

①. L1 Traversal

L1 Traversal 단계에서는 다음 L1 블록의 header 정보를 읽어오는 것까지만 진행한다.

Fig. l1_traveral.go/AdvanceL1Block (Source: github link)

AdvanceL1Block은 현재 L1 block hash가 다음 L1 블록의 parent hash와 일치하는지 확인하고, 만일 hash가 일치하지 않는다면 L1 re-org가 발생했음을 의미하며, NewResetError를 리턴한다.

Hash가 일치한다면, FetchReceipts 메소드를 사용해서 다음 L1 블록의 receipt를 가져온 후 UpdateSystemConfigWithL1Receipts 함수를 통해 L1 system configuration을 업데이트하고, 이어서 블록의 Header를 L1Traversal 구조체에 업데이트한다.

②. L1 Retrieval

L1 Retrieval 단계에서는 L1 Traversal에서 가져온 블록 header 정보에서 batcher transaction 데이터를 추출한다. 이를 추출할 때 두 가지 조건이 맞아야 하는데, 그 조건은 아래와 같다.

  • 수신자는 bacthInbox 주소와 일치해야 한다.
  • 발신자는 batcher의 주소와 일치해야 한다.
Fig. l1_retrieval.go/NextData (Source: github link), l1_traveral.go/NextL1Block (Source: github link)

NextData는 L1 블록의 헤더 정보를 가져올지 여부를 검색하며, 정보가 없을 경우 L1 Retrieval의 이전 단계인 L1 Traversal의 NextL1Block 메소드를 호출하여 블록 header 정보를 가져온다.

반대로 블록 header 정보가 존재한다면, dataSrcOpenData 메소드를 호출하여 context, Next L1 block ID, batcher contract address를 받아와 블록 header 정보를 읽고 그 안에서 batcher transaction 데이터를 추출한다.

Fig. frame_queue.go/NextFrame (Source: github link), l1_retrieval.go/NextData (Source: github link),

덧붙이자면, L1 Retrieval 단계에서의 NextData도 마찬가지로 다음 단계인 Frame Queue의 NextFrame 함수 내에서 호출되어 데이터를 전달하게 된다. 이때, 다른 단계에서도 마찬가지로 ‘prev.’ 필드를 유의해서 살펴보면 다음 단계에서 어떤 메소드를 통해 호출하는지 간단히 찾을 수 있다.

이어서, L1 Retrieval 단계를 보조하는 몇 가지 객체를 더 설명하자면 아래와 같다.

Fig. l1_retrieval.go/DataAvailabilitySource (Source: github link), calldata_source.go/OpenData, Next (Source: github link)

DataSourceFactory는 위에서 호출했던 OpenData 메소드를 통해 context.Context, eth.BlockID, common.Address 객체를 파라미터로 받아 Datalter를 리턴한다. 이를 위해 OpenData 메소드는 다음 L1 block ID와 batcher contract address를 반복해서 가져오고, NewDataSource 함수와 전달된 파라미터를 사용해서 DataSource를 생성하여, DataIter를 리턴 받는다.

이어서, Next 메소드는 전달받은 데이터에서 다음 데이터를 리턴하는 역할을 한다. 이 메소드는 검색할 데이터가 남았는지 확인하고, 없다면 다음 L1 블록을 가져오고, 데이터가 남아 있다면 다음 데이터를 가져온다. 이를 통해 batcher transaction을 하나씩 리턴 해 준다.

③. Frame Queue

Frame Queue에서는 bacher transaction을 channel frame으로 디코딩하여 다음 단계로 이어갈 수 있도록 연결시켜준다.

Fig. frame_queue.go/NextFrame (Source: github link),

NextFrame 메소드 또한 Channel Bank 단계의 NextData 메소드 내에서 호출되며, frame queue의 다음 frame을 리턴하는 역할을 한다.

먼저, 현재 queue에 frame이 남아 있지 않는다면, NextData 함수를 통해 L1 Retrieval 단계에서 준비했던 batcher transaction을 받아온다. 그리고, batcher transaction을 channel frame으로 디코딩 후 ParseFrames를 통해 frame에 채워 넣는다.

④. Channel Bank

Channel frame을 channel queue(채널 대기열)에 순차적으로 리스팅한다.

Fig. channel_bank.go/ChannelBank, NextFrame (Source: github link),

그 다음 NextData는 먼저 Read 메소드를 사용하여 channel bank 객체의 데이터를 읽어오고, 읽어온 데이터를 channel bank에 전달한다. 그리고 만일 channel bank에 데이터가 남아 있지 않는다면, channelBuilder의 NextFrame 메소드를 사용하여 channel bank에 데이터를 load 한다. 이렇게 load 된 데이터는 channel 대기열에 모여 FIFO(First In, First Out, 먼저 들어온 것부터) 순서대로 처리한다.

여기서 Read 프로세스를 더 상세히 살펴보면 아래와 같다.

Fig. channel_bank.go/Read() (Source: github link),

Read 메소드는 channel bank에 있는 첫 번째 channel의 raw data를 읽어오는 역할을 한다. 이 메소드는 먼저 queue의 가장 최신 channel을 검색하고 시간 초과 여부를 확인하여 channel에 읽을 수 있는 데이터가 있는지 확인한다.

Channel 검색이 끝나고 읽을 준비가 되면 ChannelBankchannels 필드와 channelQueue 필드에서 channel을 제거하고 io.ReadAll 함수를 사용하여 해당 channel 내 모든 데이터를 읽어온다.

만일, Read 과정에서 io.EOF가 리턴되면 Frame Queue 단계에서 새로운 channel frame을 받아와 channel에 삽입한다. 이때, Channel frame 구조를 살펴보면 아래와 같다.

Fig. frame.go/Frame (Source: github link),
  • channel_id: channel을 식별하는 ID이다.
  • frame_number: channel 내 frame의 Index이다.
  • frame_data: channel frame의 데이터이다.
  • is_last: 마지막 frame을 표시하는 플래그로 마지막 frame이라면 1, 아니면 0이 된다.
Fig. channel_bank.go/IngestFrame (Source: github link),

IngestFrame은 먼저 frame의 channel_id 값을 통해 현재 channel queue에 동일한 ID를 가진 channel이 이미 존재하는지 확인한다. 확인 후 없다면 `NewChannel` 함수를 사용하여 새 channel을 생성하고 `ChannelBankchannels 필드에 해당 channel을 추가한다. 그리고, 새로 생성된 channel_idchannelQueue 필드에 기록하고, frame을 channel queue에 추가한다. 이어서, ChannelBankprune 메소드를 실행한다.

Fig. channel_bank.go/prune (Source: github link),

prune 메소드는 channel bank가 최대 크기를 초과하지 않도록 가지치기를 진행한다. 우선 ChannelBankchannels 필드에 있는 모든 channel의 총 크기를 계산한다. 그리고, channel의 총 크기가 최대 channel bank 크기보다 작거나 같을 때까지 루프를 돌린다.

루프로 반복될 때마다 메소드는 ChannelBankchannelQueue 필드에서 가장 최신 channel_id를 검색하고, channels 필드에서 해당 ID를 가진 채널을 검색하여 두 필드 모두에서 해당 채널을 제거한다(MaxChannelBankSize: 100,000,000 bytes).

⑤. Channel Reader (Batch Decoding)

Channel Bank에서 channel을 가져오고, 압축 해제와 디코딩 프로세스를 진행한다.

Fig. channel_in_reader.go/NextBatch (Source: github link)

NextBatch 메소드는 Payload Attributes Derivation 단계의 NextAttributes 메소드 내에서 호출되며, channel에서 다음 batch를 읽어 오는 역할을 담당한다. 먼저, ChannelInReadernextBatchFn 필드가 nil인지 확인(전에 batch에서 마무리가 안 됐다면)한다. 만약 nil이라면, 다음 데이터를 읽어오고 읽은 데이터를 channel에 저장한다. 반대로 nextBatchFn 필드가 nil이 아닌 경우, channel에 다음 batch를 찾아 리턴한다.

이렇게 batch를 읽어오는 주체를 Reader라고 지칭하여 함수로 정의되어 있으며, 이는 batch를 단순히 복사하는 것이 아니라 데이터를 압축 해제와 디코딩의 과정이 필요하기 때문이다. 그에 따른 BatcherReader 함수에 대한 설명은 아래와 같다.

Fig.channel.go/BatchReader (Source: github link)

BatchReader 함수는 먼저 zlib.NewReader 함수와 io.Reader 객체를 사용하여 압축 해제 단계를 설정한다. 그런 다음 rlp.NewStream 함수와 압축 해제 단계를 기반으로 RLP reader를 설정하여 압축 해제 준비를 해준다. 이어서, 설정에 따라 RLP reader에서 각 batch를 순차적으로 읽어오고, 각 batch를 읽을 때, rlpReader.Decode 메소드를 사용하여 batch 데이터에 대한 압축 해제와 디코딩을 진행한다. 마지막으로 결과물을BatchWithL1InclusionBlock 객체에 저장하여 리턴한다.

⑥. Batch Queue

BatchQueue에서는 timestamp와 header의 safe/unsafe 여부에 따라 다음 batch 순서를 다시 지정한다.

Fig. batch_queue.go/BatchQueue (Source: github link)

BatchQueue의 구조는 아래와 같다.

  • log: 메시지를 기록하는 데 사용된다.
  • config: BatchQueue의 형태나 구성을 나타내는 객체이다.
  • prev: NextBatchProvider 객체로, 다음 batch를 제공하는 데 사용된다.
  • origin: L1블록의 reference(참조)를 나타내는 eth.L1BlockRef 객체이다.
  • l1Blocks: L1 블록을 나타낸다.
  • batches: uint64(batch index) 키를 BatchWithL1InclusionBlock 객체 슬라이스에 mapping 하는 필드이다.
Fig. batch_queue.go/BatchQueue (Source: github link)

BatchQueue 구조체에 있는 NextBatch 메소드는 BatchQueue에서 다음 데이터 batch를 제공하는 역할을 담당한다. 먼저 BatchQueue 객체의 origin이 L2 safe head의 origin보다 뒤에 있는지를 확인하는데, 이는 현재 작업하는 batch가 safe L2 head의 timestamp보다 다음에 오는지 확인하는 것이다. 만일, 다음에 온다면 안전한 batch로 여기고 origin을 더 전진시켜 batch queue에 더 많은 데이터를 load 한다.

⑦. Payload Attributes Derivation

이전 단계에서 가져온 batch를 Payload Attributes 구조의 인스턴스로 변환한다. Payload attributes는 블록에 포함해야 하는 트랜잭션과 다른 블록 입력값(timestamp, fee, recipient 등)이 있다.

Fig. attributes_queue.go/NextAttributes (Source: github link)

NextAttributes 메소드는 AttributesQueuebatch 필드가 `nil`인지 확인하고, nil이면 이 메소드는 다음 batch를 가져오기 위해 prev 필드 안에 있는 NextBatch 메소드를 호출한다.

그런 다음 payload attribute을 생성하기 위해 AttributesQueue 객체의 createNextAttributes 메소드를 호출한다. createNextAttributes 메소드는 파라미터로 context.Context, BatchData,eth.L2BlockRef를 받아 PayloadAttributes를 리턴 한다.

이어서, createNextAttributes 메소드를 보면, 생성할 L2 블록에 대한 payload attributes를 만든다.

Fig. attributes_queue.go/createNextAttributes (Source: github link)

createNextAttributes 메소드는 batch 데이터와 L2 safe head에서 다음 새로운 payload attribute 속성을 생성하는 역할을 한다. 새로운 payload attributes를 생성하는 것은 AttributesQueue에 새로운 queue를 추가하는 것과 동일하다. AttributesQueue 내의 builder 객체가 PreparePayloadAttributes 메소드를 사용하여 다음 L2 블록에 대한 payload attributes를 가져온다. 그 후, 해당 PayloadAttributes 객체에 트랜잭션 개수 및 batch의 타임스탬프 정보를 기록한 다음, 새로운 PayloadAttributes 객체를 생성하여 리턴한다.

⑧. Engine Queue

이전 단계에서 가져온 payload attributes 데이터를 execution engine으로 전송하여 L2 블록을 생성한다.

Fig. Rollup Node와 Engine API 상호작용 아키텍처 (Source: github link)

Execution engine과 상호 작용하기 위해 API를 사용하며 각 API는 다음과 같다:

  • engine_forkchoiceUpdateV1: chain head가 다른 경우 새로 업데이트하고, payload attributes가 null이 아닌 경우 엔진이 execution payload를 build하도록 지시한다.
  • engine_getPayloadV1: 이전에 새로 build 한 execution payload를 가져온다.
  • engine_newPayloadV1: 생성한 execution payload를 실행하여 블록을 생성한다.
engine_queue.go/Step (Source: github link)

Step() 메소드는 EngineQueue의 여러 필드를 사용하여 블록 생성 프로세스를 단계별로 진행하도록 지시한다.

  1. needForkchoiceUpdate 필드가 ‘true’라면 EngineQueuetryUpdateEngine 메소드를 호출하여 엔진 업데이트를 진행한다.
  2. EngineQueueunsafePayloads 필드의 길이가 0보다 큰지 확인하여, 크다면 tryNextUnsafePayload를 호출하여 다음 unsafe payload를 처리한다.
  3. EngineQueuesafeAttributes 필드가 `nil`이 아닌지 확인하고, `nil`이 아니라면, tryNextSafeAttributes 메소드를 호출하여 다음 safe attributes를 처리한다.
  4. prev 필드의 origin을 호출하여 newOrigin 변수를 이전 블록의 origin으로 설정하여, verifyNewL1Origin의 메소드를 호출하여 L2 unsafe head의 origin이 newOrigin과 일치하는지 확인한다. 이는 engine queue가 새로운 queue에 대해서 올바른 순서의 블록들을 처리하고 있는지에 대해 origin을 통해 확인하는 프로세스이다.
  5. postProcessSafeL2를 호출하여 새로 생성되고 있는 최신 L1 블록과 마지막 L2 safe head와 mapping이 잘되어 있는지 확인한다.
  6. 마지막으로, tryFinalizePastL2Blocks를 호출하여 지금까지 동기화된 L2 블록을 마무리하고, NextAttributes를 호출하여 다음 safe attributes를 호출한다.

이후, fork choice 업데이트가 발생하면 tryUpdateEngine 메소드가 실행되어 동기화 작업이 시작되며, 동기화는 다음과 같은 상황에서 발생합니다.

  • L2 block을 ‘safe’ 상태로 업데이트할 때 한다.
  • L2 block을 ‘finalized’ 상태로 업데이트할 때 한다.
  • pipeline이 reset 됐을 때 한다.
engine_queue.go/Step (Source: github link)

tryUpdateEngine 메소드는 먼저 L2 unsafe head가 engine 동기화 대상의 해시와 같지 않은지 확인하고, 같다면 EngineQueue의 head block hash, safe block hash, finalized block hash를 사용하여 ForkchoiceState 객체를 생성한다.

그런 다음에 engine 필드의 ForkchoiceUpdate 메소드를 호출하여 engine을 현재 ForkchoiceState로 업데이트한다. 그리고, 동기화가 필요한 데이터를 검증하고 engine 필드의 state를 L1 블록과 동기화한다. 이는 engine queue 안에 있는 대기열을 관리하고 트랜잭션을 처리하기 위한 과정으로 이해하면 쉽다.

다음으로, L1 블록에서 가져온 가장 최신 unsafe head가 safe head보다 앞서 있는 경우, 기존의 unsafe L2 chain이 L1 데이터에서 파생된 L2 입력과 일치하는지 확인하여 통합한다.

engine_queue.go/trySafeNextAttributes (Source: github link)

tryNextSafeAttributes 메소드는 engine queue에서 다음 safeAttributes를 가져와 처리하려 한다. 먼저, safeAttributes 필드가 ‘nil’ 인지 아닌지 확인하고, EngineQueuesafeHead 필드가 safeAttributes 필드의 parent와 같은지까지 확인한다. 그리고, 만약 safeAttributes 필드가 `nil`이 아니고 safeHead 필드가 parent와 같으면, unsafe head가 safe head 보다 앞서있는 것으로 간주하고 통합한다. 여기서 통합한다는 것은 가장 최신 unsafe head가 safe head와 일치한다는 것이고, 해당 unsafe head 또한 safe head인 것으로 간주해 새로운 safe head가 된다는 것이다.

engine_queue.go/tryNextUnsafeAttributes (Source: github link)

tryNextUnsafePayload 메소드는 다음과 같은 과정을 수행한다.

  • 먼저, EngineQueueunsafePayloads 필드에서 Peek를 호출하여 engine queue에서 가장 최신 unsafePayload를 가져온다.
  • 그런 다음, 해당 unsafePayload의 블록 수가 safe head 또는 unsafe head의 수보다 큰지 작은지 확인한다. 이를 통해 현재 queue에서 unsafePayload를 L2 블록으로 만드는데 오류가 있는지 확인하는 것이다.
  • 만약 오류가 없다면, engine 필드의 NewPayload를 호출하여 가장 최신의 unsafePayload를 engine에 삽입한다. 이때, NewPayload 메소드는 context.Contextpayload를 파라미터로 받아와서, *eth.Statuserror 객체를 리턴한다.
engine_queue.go/tryNextUnsafeAttributes (Source: github link)
  • 삽입하는 과정에서 오류가 없다면, head block hash, safe block hash,과 finalized block hash를 인자로 ForkchoiceUpdate 객체를 생성하고 engine 필드의 ForkchoiceUpdate 메소드를 호출하여 fork choice 업데이트를 진행한다.
  • 마지막으로, ForkchoiceUpdate는 context.Context, *eth.ForkchoiceState, *eth.ForkchoiceUpdateOpts를 파라미터로 받아 *eth.ForkchoiceUpdateResulterror 객체를 리턴하여, L2 블록을 다시 생성한다.

마치며

여기까지 L1에 제출된 batcher transaction이 다시 L2 블록으로 변환되는 과정을 살펴보았고, 중간마다 어떠한 방식으로 sanity-check가 이루어지고, 블록의 type을 확인하여 L2 블록의 finality를 설정하는지 알 수 있었습니다. 새로운 개념들이 등장했지만 결국 L1에 제출된 batcher transaction을 가지고 다시 L2 블록을 생성/재현 한다는 것이 조립은 분해의 역순이라는 말과 같이 정리가 됩니다. Optimism은 이러한 과정을 통해 검증자가 L2에서 생성된 블록이 L1에 제출되기까지의 과정을 다시 확인하여 데이터의 유효성을 보장하는 방식을 선택했습니다. 다음 시리즈는 Optimism Bedrock Wrap Up의 마지막인 5 번째 시리즈로 Op-Node와 Op-Batcher 그리고 Op-Proposer의 역할과 동작 방식을 종합적으로 살펴보겠습니다.

Reference:

https://blog.oplabs.co/heres-how-bedrock-will-bring-significantly-lower-fees-to-optimism-mainnet/

--

--