Optimism Bedrock Wrap Up Series 5 [KR]

Optimism Bedrock 구성 요소들의 역할 및 동작

Aaron Lee
Tokamak Network
37 min readNov 24, 2023

--

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

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

본 글은 Onther에서 기획한 ‘Optimism Bedrock Wrap Up 시리즈’ 총 5편의 5번째 글로 op-batcher와 op-proposer의 역할과 동작 로직을 종합적으로 분석합니다. 시리즈의 구성이 서로 연관되어 있으므로, 차례대로 읽어보시는 것을 추천해 드립니다.

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

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

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

4. 블록의 파생(Block Derivation): Optimism은 OP 메인넷(레이어2)에서 블록이 생성된 후, 해당 블록을 다시 레이어1으로 rollup 하는 과정을 거칩니다. 그리고 그 rollup 된 데이터만을 활용하여 L2 블록을 재생성하는 프로세스를 Block Derivation이라고 하며, 이 프로세스를 단계별로 분석합니다.

Optimistic Rollup 솔루션의 필수 구성 요소

Fig. Optimism 필수 구성 요소 아키텍처

위 아키텍처에서 볼 수 있듯이 현재 Optimism Rollup을 동작하는 4가지 필수 요소는 op-geth, op-node(rollup-node), op-batcher 그리고 op-proposer이다. 현재로서는 이 네 가지 요소가 모두 중앙화되어 마치 하나의 객체처럼 운영되고 있지만, 전반적인 메커니즘을 이해할 때는 각 요소를 독립적으로 고려해야 한다. 다만, 어디까지 독립적인 객체로 인지해야 하는지도 명확하지 않고 여러 의문이 드는 것이 사실이다.

Ethereum과 같은 탈중앙화 시스템에서는 geth, sequencer, proposer, verifier 등의 권한과 역할이 명확하게 분리돼 동작하는 것을 볼 수 있다. 그리고, 이는 Optimism의 궁극적 목표 또한 크게 다르지 않을 것으로 예상되며, 그 과정을 지나고 있다고 판단된다. 따라서 작성자 또한 위 4개의 요소의 미묘한 상관관계와 역할의 분배에 있어서 독립적인 객체로 생각하고 설명할 예정이지만, 앞으로 Optimism에서 탈중앙화를 이루어 가면서 각 요소가 어떻게 나누어질지는 시간을 두고 지켜보아야 할 것으로 생각된다.

우선 각 요소에 대해 간략한 설명을 하고 분석을 이어가도록 하겠다.

op-geth(L2-geth)

L1과 L2 사용자가 생성한 트랜잭션을 old state(이전 상태)에 적용하여 new state(새로운 상태)을 생성하는 역할을 한다. 이 프로세스는 트랜잭션을 처리하는 전체 과정에 걸쳐 state의 저장 및 수정 내역을 관리하여 모든 state transition에 대한 신뢰성을 확보한다.

op-node(Rollup-node)

Fig. Bedrock 계층별 구성요소 간 상관관계 (Source:Optimism)

위 다이어그램에서 볼 수 있듯이 op-node는 중앙화되어 분리되지 않은 각 요소(sequencer, verifier, proposer)를 포괄적으로 묶고 있다. 하지만, 각 process에서 담당하는 요소의 역할은 명확히 구별되기에, 이를 따로 분류해서 이해하는 편이 솔루션의 전반적인 메커니즘을 이해하는 데 도움이 된다.

우선, Standard Ethereum Engine API를 통해 L1에서 발생한 L2 트랜잭션의 raw data를 execution layer(op-geth)로 전달 및 변환하여 L2 블록을 생성하는 sequencer가 있다. 이는 sequencer 즉, 생성자의 역할이다. 그리고, 아래에서 말씀드릴 op-batcher와 op-proposer의 역할 또한 sequencer가 맡고 있지만, 통상적으로 proposer(제안자)라고 이해하는 편이 도움이 된다.

마지막으로 해당 시리즈에서 다루지는 않았지만, L1에 제출된 batch와 output을 가지고 L2 체인을 검증하는 verifier의 역할이 있다. (본문에서 각 요소의 역할을 명확하게 나누어 설명토록 하겠다.)

op-batcher

Op-batcher는 batch submitter나 batcher라고도 하며, 주로 L2 트랜잭션을 batch로 변환시켜 L1의 BatchInbox에 기록하는 작업을 한다. 이 과정에서 Op-node는 L2 트랜잭션을 압축하여 최소한의 데이터 전송과 메모리를 적게 사용하도록 최적화하고, L2 chain derivation을 가능케 한다.

op-proposer

L2에서 발생한 state transitions(상태 전환)를 L1에서 마무리하는 역할을 한다고 이해하면 쉽다. 실제로 rollup 솔루션은 L2에서 발생한 모든 트랜잭션이 L1에서 검증 및 증명되지 않고서는 그 과정을 신뢰할 수 없다. 따라서 L1에서 인정되지 않는 L2 트랜잭션은 발생한 적이 없는 트랜잭션이라고 생각해도 무방하다.

Op-geth가 state를 업데이트하면, op-proposer는 업데이트한 state에 대한 커미션을 L1에 작성하여 업데이트된 state를 기록한다. 이는 단순히 기록에서 그치는 것이 아니라 해당 state에 대한 새로운 Merkle root를 동시에 제안하는 것이며, L1에 기록되는 데이터를 최소화하여 트랜잭션 비용을 줄일 수 있다. 이러한 state root 제안은 L1의 L2OutputOracle에 게시되며, 해당 proposal은 7일간의 finalization period 기간을 거쳐 검증된다.

이전 4편의 시리즈에서 여러 과정을 통해 op-geth와 op-node에 대한 분석을 이어갔다면, 이번 시리즈에서는 op-proposer와 op-batcher가 어떤 방식으로 L2 state과 트랜잭션을 L1에 제출하는지 그 과정을 살펴보도록 하겠다.

op-proposer의 역할과 동작 로직

앞서 말씀드린 대로, proposer의 역할은 L2 state에 대한 output root를 L1의 L2OutputOracle 컨트랙트로 제출하는 역할을 담당한다. 우선, output root의 구성부터 살펴보도록 하겠다

Fig. specs/proposals.md (Source: github link)
  • version_byte : output root의 버전으로 구조가 변경될 때마다 업데이트된다.
  • payload: 임의 길이의 byte 문자열로 구성은 위와 같다.
  • state_root: 모든 execution layer 계정의 Merkle-Patricia-Trie(MPT) root이다.
  • withdrawal_storage_root: MessagePasser contract 저장소의 Merkle-Patricia-Trie(MPT) root이다.
  • lastest_block_hash: 최신 L2 블록의 block hash이다.

이어서 L2 output이 L1으로 제출되는 주기를 알아보도록 하겠다.

Fig. deploy-config/mainnet.json (Source: github link),
Fig. op-proposer의 트랜잭션을 모아둔 State Batch 섹션 (Source: optimistic.etherscan.io)

L2 어카운트의 state transition은 블록 생성 주기인 2초마다 시시각각 발생하며, 매번 발생할 때마다 받아온다면 좋겠지만, 그 비용이 만만치 않기에 적당한 주기로 받아오는 것이 중요하다. 이 주기는 처음 node가 배포될 때 deploy-config/mainnet.json 파일에 의해서 설정되며, 현재 mainnet은 1,800블록, Goerli는 120블록, Devnet은 20블록으로 설정되어 있다. Mainnet 기준 1,800블록이면 3,600초, 즉 1시간 간격으로 L2 output root이 L1으로 제출된다.(이는, Withdrawal 1단계에서출금 트랜잭션이 L1 L2OutputOracle contract에제출되는 시간이 1시간 이상 소요되는 이유이기도 하다.)

위 optimistic.etherscan 스크린샷을 봐도 약 한 시간 간격으로 트랜잭션이 발생한 것과, 해당 트랜잭션이 저장된 L1 블록, L1 transaction hash, output root 등을 함께 확인할 수 있다.

이제 본격적인 코드 분석을 이어가도록 하겠다.

CLIConfig

Fig. config.go/CLIConfig (Source: github link)

위 코드는 op-proposer의 환경설정을 위한 코드이다. 이 환경설정 구조체를 위한 여러 가지 파라미터를 담고 있는데, 그에 따른 설명은 아래와 같다.

Required parameters

  • L1EthRpc: L1 HTTP 공급자 URL이다.
  • RollupRpc: Rollup-node HTTP 공급자 URL이다.
  • L2OOAddress: L2OutputOracle 컨트랙트의 주소이다.
Fig. flags.go/PollIntervalFlag (Source: github link)
  • PollInterval time.Duration: Output root를 생성하기 위해 L2 블록(트랜잭션)을 query 되어오는 주기로 위와 같이 6초로 설정되어 있다. L2 블록은 2초마다 생성되므로, 3개의 블록을 한 번에 query해 온다. 이런 방식으로 1,800개의 L2 블록이 모이면 L1으로 제출된다.
  • AllowNonFinalized: 아직 finalize 되지 않은 L1 트랜잭션에서 derived(파생)된 L2 블록의 output에 대한 propose를 허용하기 위해 true로 설정된 bool 플래그이다.
  • TxMgrConfig: 트랜잭션 관리를 위한 구조체이다.

Optional parameters

  • RPCConfig: Remote Procedure Call(원격 프로시저 호출)을 위한 구조체이다.
  • LogConfig: Proposer의 로깅을 구성하기 위한 구조체이다.
  • MetricsConfig: Proposer의 metrics를 구성하기 위한 구조체이다.
  • PprofConfig: pprof는 go 애플리케이션 데이터를 profiling 해주는 도구이며, 원하는 타겟의 cpu, memory, trace 등을 tracking 할 수 있다. 이는 go tool에서 pprof 기능을 기본적으로 지원해 주기 때문에 쉽게 이용 가능하다.

Main

Fig. L2_output_submitter.go/Main (Source: github link)

Main 함수는 L2 output submitter(proposer) 서비스의 진입 지점이라고 할 수 있으며, versioncli.Context 두 파라미터를 받고 있다. 여기서, version은 서비스의 버전을 지정하고, cli.Context는 서비스에 전달된 command-line argument(명령행 인자)이며, 프로그램이 실행될 때 프로그램에 전달되는 인자 값이다. 이어서 함수 내부를 더 구체적으로 살펴보겠다.

  • flags.CheckRequired: 위에서 말씀드린 필수 flag가 설정되었는지 확인하고 하나라도 누락된 경우 error를 반환한다.
  • NewConfig: 이 함수를 사용하여 cli.Context에서 새로운 Config 객체를 생성하고 설정한 모든 configuration(flag) 정보가 들어간다.
  • oplog.NewLogger: 새로운 logger를 생성하고, oplog.SetGlobalLogHandler 함수를 사용해서 global log handler로 설정한다.
  • metrics.NewMetrics: 새로운 metrics 객체를 생성한다.
  • NewL2OutputSubmitterConfigFromCLIConfig: Config 객체에서 새로운 L2OutputSubmitterConfig 객체를 생성하여 L2 output proposal을 초기화한다.
  • NewL2OutputSubmitter: L2OutputSubmitterConfig 객체에서 L2OutputSubmitter 객체를 새로 생성한다.
  • L2OutputSubmitter: 이 객체의 Start 메소드를 사용하여 L2 output submitter를 시작한다.
  • defer: 함수가 종료될 때 L2 output submitter 또한 종료되도록 한다.

여기까지, 새로운 submitter를 생성하는 부분이었다면 이어서 pprof, metrics와 rpc 서버가 실행되는 코드를 보도록 하겠다.

Fig. L2_output_submitter.go/Main (Source: github link)

먼저, prof.StartServer 함수를 사용하여 pprof 서버를 시작하고 서비스에 대한 cpu, memory, trace 등의 데이터를 기록한다. 그리고 마찬가지로 defer 문을 사용하여 함수가 종료될 때 pprof 서버 또한 종료되도록 설정했다.

Fig. L2_output_submitter.go/Main (Source: github link)

이어서, m.Start 함수를 사용하여, metrics 서버를 시작하고 시작되었음을 나타내는 메시지를 기록한다. 마찬가지로, defer 문을 사용하여 함수가 종료될 때 metrics 서버 또한 종료되도록 설정한다. 그리고, m.StartBalanceMetrics를 사용하여 해당 op-proposer의 Ethereum Account에 대한 잔액을 수집한다. 이는, L2OutputOracle 컨트랙트에 output을 게시하는 행위 자체가 gas fee가 발생하는 행위이기에 필요한 balance가 없다면 동작할 수 없다.

Fig. L2_output_submitter.go/Main (Source: github link)

Main 함수의 마지막 과정이다. oprpc.NewServer 함수를 사용하여 새로운 RPC 서버를 생성하고, admin API를 추가한다. 그런 다음 `Start` 메소드를 사용하여 RPC 서버를 시작하고 서버가 시작되었음을 나타내는 메시지를 기록한다.

Start(loop) / Stop(loop)

Fig. L2_output_submitter.go/Start(), Stop() (Source: github link)

Propose 과정에서 ‘loop’ 메소드는 L2에서 발생한 새로운 트랜잭션을 지속해서 query 하여 L1으로 받아오는 event-loop이다. 그리고, 이 Start() 메소드는 loop를 실행하여 L2OutputSubmitter가 제출한 output을 받아오는 역할을 한다.

이어서, Stop() 메소드는 loop 메소드가 사용 중인 context를 취소하고 ‘done’ channel을 닫은 후 wg.Wait() 메소드를 사용하여 loop 메소드가 종료될 때까지 기다렸다가 L2 output submitter를 중지시키는 역할을 담당한다.

그렇다면, 다음으로 loop 메소드를 살펴보도록 하겠다.

loop()

Fig. L2_output_submitter.go/loop (Source: github link)

loop 메소드는 다음 output 정보를 지속적으로 가져오며, 해당 output을 제안할지 여부를 결정하고, 제안된 output을 L1으로 전송하는 메소드를 호출하고 기록하는 등의 역할을 담당한다.

우선, L2OutputSubmitter 구조체의 ‘pollInterval’ 필드에 ticker가 일정 간격으로 발동하여 select 문의 첫번째 케이스가 실행된다. select 문이 실행되면 먼저, FetchNextOutputInfo 메소드를 호출하여 다음으로 제출할 output을 가져온다. 그리고, 가져온 output이 propose 가능한 상태인지 여부를 나타내는 bool을 리턴하고, 만일 err가 발생하면 오류를 나타내는 OutputInfo 객체를 리턴한다. 이때, 오류가 리턴되면 loop가 중단되고 ticker가 다시 실행된 후에야 다시 실행될 수 있다.

반대로, 해당 output이 propose 가능한 경우 context.WithTimeout를 사용하여 timeout이 10분인 새로운 context를 생성한다. 그런 다음 sendTransaction 메소드를 호출하여 L1으로 output을 제출하게 되는데, 이때 트랜잭션이 성공하면 metrics 객체에서 RecordL2BlocksProposed 메소드를 호출하여 propose된 블록을 기록한다.

여기서는, loop()에 대한 간략한 설명과 전체적인 흐름을 말씀드렸다면, 세부적으로 호출되는 FetchNextOutputInfo과 sendTransaction 메소드를 통해 보다 상세한 설명을 이어가도록 하겠다.

FetchNextOutputInfo

Fig. L2_output_submitter.go/FetchNextOutputInfo (Source: github link)

loop()를 통해 호출된 FetchNextOutputInfo 메소드는 파라미터로 context.Context 객체를 받는데, 이는 해당 메소드가 수행하는 요청에 대한 시간제한을 설정하는 데 사용된다.

이는, context.WithTimeout 메소드를 사용하여 시간제한이 l.networkTimeout인 새로운 context.Context 객체를 생성하는 것으로 시작한다. 그런 다음 From 필드가 txMgr객체의 From 주소로 설정되고 Context 필드가 이번에 새로 만든 context로 설정된 bind.CallOpts 객체를 만들게 된다.

이어서, callOpts 객체를 사용하여 L2 output 컨트랙트의 NextBlockNumber 메소드를 호출하여 다음 체크포인트 블록 번호를 가져오게 된다. 그리고, 다시 context.WithTimeout 함수를 사용하여 timeout이 networkTimeout으로 설정된 새로운 context로 생성하고, 이번에는 현재 처리하고 있는 L2 block head를 요청한다.

다음으로, rollupClient.SyncStatus가 호출되어 현재 L2와의 sync 상태를 가져와 context.Context를 파라미터로 받는다. 여기까지 진행되면, currentBlockNumber 변수가 최신 L2 블록 번호로 설정된다.

그런 다음 L2OutputSubmitterallowNonFinalized 필드에 따라 true라면 safe head를 false라면 finalized head를 사용한다. 이때, L2OutputSubmitter는 현재 블록 번호가 다음 체크포인트 블록 번호보다 작은지 확인하는데, 만일 현재 블록 번호가 더 앞서 있다면 propose 하기에 이르다고 판단하고 nil을 리턴한다. 반대로 현재 블록 번호가 다음 체크포인트 블록 번호보다 크거나 같으면, 제출할 output을 가져오기 위해 fetchOutput 메소드가 호출된다.

이 함수를 요약하자면, 다음 propose의 블록 번호를 가져와서 L2의 현재 state에 따라 propose 여부를 결정짓고 해당 output을 가져오는 프로세스로 이해하면 쉽다.

sendTransaction

Fig. L2_output_submitter.go/sendTransaction (Source: github link)

이제 propose의 마지막 단계로 L1으로 output root를 보내는 sendTransaction 메소드이다. 파라미터로 context.Contexteth.OutputResponse 객체를 받고 있는데, eth.OutputResponse에는 제출할 L2 output transaction 정보가 들어 있다.

먼저, waitForL1Head 메소드를 호출하여 L2 output 트랜잭션이 지정된 L1 블록에 제출되기 위해 차례를 기다린다. 그리고 ProposeL2OutputTxData를 호출하여 L2 output transaction 데이터를 생성하며, 트랜잭션 데이터를 byte array로 변환해 eth.OutputResponse에 리턴한다.

변환을 마치면, txMgr.Send 메소드를 호출하여 해당 트랜잭션 데이터를 L1으로 전송하게 된다. 이 메소드는 txmgr.TxCandidate 객체를 파라미터로 받으며, 해당 객체에는 transaction data, 트랜잭션을 전송할 컨트랙트의 주소(L2OutputOracle), 그리고 트랜잭션의 gas limit이 포함되어 있다.

트랜잭션이 성공하면 transaction receipt의 상태를 확인하여 트랜잭션이 성공했는지 여부를 확인하고, transaction hash, L1 block number와 L1 block hash가 함께 로깅된다.

이어서, 이 메소드에서 호출된 proposeL2OutputTxData 함수를 더 자세히 살펴보도록 하겠다.

proposeL2OutputTxData

Fig. L2_output_submitter.go/proposeL2OutputTxData (Source: github link), L2OutputOracle/proposeL2Output, emit outputProposed (Source: github link),

이 함수는 abi.Pack 메소드를 호출하여 L2OutputOracle 컨트랙트의 proposeL2Output 함수에 대한 arguments를 packing(하나의 변수에 여러 개의 값을 넣은 것) 하는 것으로 시작되며, Packing에 필요한 데이터는 전달받은 eth.OutputResponse를 통해 가져온다.

그리고, L1의 L2OutputOracle 컨트랙트의 proposeL2Output 함수를 호출하게 되는데, 이를 살펴보면 아래 4개의 파라미터를 받고 있다.

  • _outputRoot: L2블록의 output root이다.
  • _l2BlockNumber: output root를 생성한 L2 block number이다.
  • _l1BlockHash: 현재 처리 중인 L1 block hash이다.
  • _l1BlockNumber: 위 block hash의 block number이다.

이어서, emit OutputProposed를 보면, 4가지 파라미터를 받는다.

  • _outputRoot: L2블록의 output root이다.
  • nextOutputIndex(): 다음 output의 인덱스이다
  • _l2BlockNumber: output root를 생성한 L2 block number이다.
  • Block.timestamp: 현재 L1블록의 타임스탬프이다.

그리고, propose된 L2 output 트랜잭션에 대한 정보를 저장하기 위해 새로운 Types.OutputProposal 구조체를 생성하여 output root, 해당 블록의 타임스탬프, L2 output 트랜잭션의 블록 번호를 push 한다.

여기까지, op-proposer가 L2 output을 L1에 있는 L2OutputOracle에 제출하는 과정을 살펴보았고, 이어서 op-batcher에 대한 설명을 이어가도록 하겠다.

op-batcher의 역할과 동작 로직

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

Bedrock 업그레이드 이전 Legacy 버전에서는 L2 트랜잭션 하나당 한 개의 블록이 생성됐다. 그리고, 모든 L2 블록은 batch-submitter의 polling interval에 따라 하나의 batch에 여러 개의 트랜잭션을 담아 CTC 컨트랙트에 제출하는 방식으로 운영됐다. 그러나, Bedrock에서는 데이터 가용성 비용을 낮추기 위해 여러 batch를 하나의 channel로 통합하여 제출하는 방식으로 업그레이드됐다.

BatchInbox

위와 같이 만들어진 batcher transaction은 L1에 있는 BatchInbox라 불리는 특수한 EOA 계정으로 전달된다. 이때 BatchInbox는 EOA 주소이기 때문에 EVM 코드를 실행하지 않아서 가스 비용을 절감할 수 있다.

다음으로, op-batcher의 코드가 어떻게 구현되어 있는지 살펴보도록 할 텐데, 우선 큰 맥락에서 먼저 설명하고 구체적인 로직은 세부 메소드를 통해 설명을 이어가도록 하겠다.

loop()

Fig. driver.go/loop() (Source: github link)

위 코드는 batcher의 loop() 메소드이며, 이 loop를 계속 반복해서 L2 블록을 channel로 변환할 데이터를 가져오게 된다.

여기서 중점적으로 봐야 하는 함수는 loadBlocksIntoState publishStateToL1 메소드이다. 우선, loadBlocksIntoState 메소드는 제출해야 할 새로운 L2 블록이 감지되면 이를 local state로 load 하는 역할을 담당한다. 그리고, publishStateToL1 메소드는 load 한 블록을 channel frame 형태로 변환시키고, 최종적으로는 batcher transaction으로 변환해 L1 BatchInbox에 제출하는 역할을 한다.

이러한 과정은 select 문에 의해서 제어되며, 이 select 문은 tickertick, receiptsChrecipt, shutdownCtxsignal 이 세 가지 이벤트 중 하나를 응답받기 위해 대기한다. 그리고, tick이 수신되면 loadBlocksIntoState 메소드를 호출하여 L2의 최신 block을 state에 load하고, publishStateToL1 함수를 사용해서 L1에 제출하게 된다.

이어서, loadBlocksIntoState과 publishStateToL1 메소드를 보다 상세히 살펴보도록 하겠다.

loadBlocksIntoState

Fig. driver.go/loadBlocksIntoState (Source: github link)

loadBlocksIntoState는 state에 저장할 L2 블록의 범위(시작, 끝)를 계산하기 위해 calculateL2BlockRangeToStore 메소드를 호출한다. 이 메소드는 context.Context 객체를 파라미터로 받아와 범위의 시작과 끝을 나타내는 두 개의 eth.BlockID 객체를 리턴하여, 마지막으로 제출한 batch 이후 모든 블록 정보를 L2 체인에서 sync 한다.

Fig. driver.go/loadBlocksIntoState (Source: github link)

다음으로, loadBlockIntoState를 호출하여 해당 범위 내 op-batcher의 channel을 관리하는 channel manager의 state를 load 하는 loop가 시작된다. 이때 만일, L2 re-org error가 리턴되면, 해당 error를 기록하고, lastStoredBlock 변수를 비어있는 eth.BlockID 재설정하여 해당 state이 L1으로 제출되는 것을 막는다. 반대로, 아무런 error가 발생하지 않으면 lastStoredBlocklatestBlock 변수를 load 된 state에 맞게 업데이트한다.

Fig. driver.go/loadBlocksIntoState (Source: github link)

다음으로, 해당하는 모든 블록이 channel manager의 state에 load되면, derive.L2BlockToBlockRef 메소드를 호출하여 가장 최신 블록과 rollup chain이 처음 시작한 genesis 블록을 reference(참조)로 저장한다. 그리고 마지막으로, Metr.RecordL2BlocksLoaded를 사용하여 state에 load 된 L2 블록의 수를 기록하는 것으로 해당 메소드의 역할이 마무리된다.

publishStateToL1

publishStateToL1 메소드를 짧게 요약하자면, 위 channel manager의 state에 저장한 블록 정보를 가지고 channel frame 형태로 변환하여 다시 loop() 메소드로 리턴하는 역할을 한다.

Fig. driver.go/publishStateToL1 (Source: github link)

먼저, 모든 state이 load 되었음을 알리는 txDone이라는 새로운 channel을 생성한다. 그런 다음 새로운 goroutine(Go 런타임이 관리하는 Lightweight Thread)을 시작하여 해당 state를 전송하고 L1으로 전송될 때까지 기다린다. 여기서, goroutine은 대기열에 있는 각 트랜잭션에 publishTxToL1을 호출하여 L2 블록 정보를 batcher transaction으로 변환시켜 L1으로 전송하는 loop으로 처리된다.

이 메소드에서 batcher transaction으로 변환하는 로직은 publishTxTo의 TxData 함수가 핵심적인 역할을 하는데, 이어서 살펴보도록 하겠다.

publishTxToL1

Fig. driver.go/publishTxToL1 (Source: github link)

먼저, l.l1Tip 메소드를 호출하여 최신 L1 블록 헤더를 가져온다. 그리고, recordL1Tip 메소드를 사용하여 L1 tip(priority fee)을 기록한다. 그런 다음 local state의 TxData 메소드를 호출하여 다음 batcher transaction을 가져온다. 만일 가져올 트랜잭션 데이터가 없는 경우, trace log를 기록하고 io.EOF err를 리턴한다.

여기서, TxData는 L2 블록 정보로 batch를 만들고 인코딩 및 압축을 해서 channel에 채워 넣으며 만들어진 channel을 channel frame으로 나누어 batcher transaction을 생성하는 역할을 한다. 이는 사실 제일 중요한 과정이기에 아래에서 보다 상세히 설명토록 하겠다.

다음으로, sendTransaction 메소드를 호출하여 트랜잭션 데이터를 L1으로 전송하며, 여기에는 data, queuereceiptsCh 객체를 파라미터로 받는다. 이 메소드 또한 실질적으로 L1으로 batch를 전송하는 메소드이기에 마찬가지로 아래서 보다 상세히 다루도록 하겠다.

TxData

Fig. channel_manager.go/TxData (Source: github link)

먼저, channelQueue에서 제출 가능한 frame이 있는 첫 번째 channel을 검색하여, 발견하면 firstWithFrame 변수를 해당 channel로 설정한다. 그런 다음 보류 중인 데이터가 있거나, channelManager 객체가 닫혀있는 경우 nextTxData 메소드를 호출하여 다음 트랜잭션 데이터를 리턴한다. 반대로, 보류 중인 데이터가 없다면 frame을 생성하는 데 사용할 수 있는 저장된 블록이 있는지 확인하고, ensureChannelWithSpace를 호출하여 L1에 제출할 수 있는 space가 있는 channel이 존재하는지 확인한다.

존재한다면, processBlocks 메소드를 호출하여 저장된 블록을 channel에 채워 넣는 작업을 한 후, registerL1Block 메소드를 호출하여 현재 L1 head를 등록한다.

여기까지 완료됐다면, outputFrames 메소드를 호출하여 frame을 출력하고 frame queue에 추가한다. 마지막으로 nextTxData 메소드를 가지고 frame queue에 있는 channel frame을 모아 batcher transaction을 만들어 리턴하게 된다.

이어서 processBlocks 메소드에서 어떠한 방식으로 저장된 블록을 channel에 채워 넣는지 살펴보도록 하겠다.

processBlocks

Fig. channel_manager.go/processBlocks (Source: github link)

여기서는, 각 블록 slice에 저장된 개별 블록을 따로 처리하는 loop가 발생하며, channel이 가득 차면 그 loop에서 벗어나게 된다. 그리고, 각 슬라이스 저장된 블록은 AddBlock 메소드에 의해서 channel에 추가된다.

모든 블록이 성공적으로 channel에 추가되면, blocksAdded 변수를 하나 증가시키고, RecordL2BlockInChannel 메소드를 사용하여 channel의 모든 사항을 기록한다.

  • blocksAdded: 추가된 블록 수를 기록한다.
  • len(s.blocks): 보류 중인 블록 수를 기록한다.
  • s.currentChannel.InputBytes(): 현재 channel의 input bytes를 계산하여 기록한다.
  • s.currentChannel.ReadyBytes(): 현재 channel의 ready bytes를 계산하여 기록한다.

이제 channel이 추가되는 과정까지 살펴보았으니 다시 publishTxToL1 메소드의 마지막 부분인 sendTransaction을 호출하고 L1에 제출하는 과정을 살펴보도록 하겠다.

sendTransaction

Fig. driver.go/sendTransaction (Source: github link)

먼저, core.IntrinsicGas 메소드를 사용하여 오프라인에서 gas fee에 대한 추정치를 계산한다. 그런 다음, transaction data, intrinsic gas limit 및 BatchInbox 주소가 포함된 txmgr.TxCandidate 객체를 생성하여, 마지막으로 Send를 사용하여 앞서 만든 batcher transaction을 L1 BatchInbox에 보내는 트랜잭션을 발생시키는 것으로 이번 과정이 마무리된다.

마치며

이번 5번째 시리즈를 끝으로 Bedrock Wrap Up 시리즈가 마무리됐습니다. 리서치를 진행하면서 가장 어려움을 겪은 부분은 각 process에서 등장하는 새로운 용어와 개념으로 인한 혼란이었습니다. 특히, op-stack, op-proposer, op-batcher, sequencer, verifier, derivation, sequencing window 등의 용어가 중간마다 빈번히 등장하면서, 종종 하나의 객체가 2~3가지 다른 이름으로 불리기도 하고, 두서없이 등장하면서 혼란이 가중되었습니다. 결국, 각 용어와 개념을 체계적으로 나누고, 코드 레벨에서 전반적인 로직과 각 요소 간의 관계를 명확하게 글로 정리하다 보니 보다 뚜렷하게 다가왔고 리서치를 이어갈 수 있었습니다.

총 5편으로 작성된 이 리서치를 통해 한국의 여러 블록체인 연구자에게 많은 도움이 되길 희망합니다. 앞으로도 Optimism을 비롯한 Layer 2 기술에 대한 연구와 개발에 대한 리서치를 이어가고, 또 결과물을 공유토록 하겠습니다. 감사합니다.

Reference:

--

--