Optimism Bedrock Wrap Up Series 3 [KR]

입·출금 프로세스의 흐름

Aaron Lee
Tokamak Network
44 min readOct 26, 2023

--

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

Fig. Optimism 옆 Ethereum 주화 일러스트 (Source: unplash)

본 글은 Onther에서 기획한 ‘Optimism Bedrock Wrap Up 시리즈’ 총 5편의 3번째 글로 입·출금 프로세스의 흐름을 다룹니다. 시리즈의 구성이 서로 연관되어 있으므로, 차례대로 읽어보시는 것을 추천해 드립니다.

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

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

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

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

Deposit & Withdrawal Flow

Optimism의 입·출금 과정은 L1과 L2를 연결하는 가장 기본적이고, 솔루션의 초석이 되는 중요한 과정이다. 입금은 Optimism을 활용하기 위한 첫 번째 단계로 사용자가 L1에서 L2로 자산을 이체하고 그 자산을 L2 환경에서 활용 가능하게 한다. 그리고, 출금은 L2 환경에서 보유한 자신의 자산을 논리적이고 납득 가능한 증빙을 통해 L1으로 안전하게 반환하는 과정을 담고 있다. 이번 시리즈는 입·출금 프로세스에서 레이어 간의 상호작용을 위한 코드를 데이터의 흐름에 따라 분석하고 각 트랜잭션이 어떠한 메커니즘으로 동작하는지 살펴본다.

Deposit Flow

Bedrock 버전에서 ‘Deposit Transaction’은 L1에 의해서 트리거된 모든 L2 트랜잭션과 컨트랙트 호출을 지칭한다.

Fig. L1에서 L2 입금 흐름

위 그림에서는 입금 과정에서 호출되는 중요 컨트랙트와 함수를 입금의 흐름에 따라 정리한 것이다.

①. 입금의 시작과 끝은 StandardBridge로부터 이루어지며, 이 컨트랙트는 L1과 L2 간에 실질적인 자산 이동을 위한 Lock & Mint 기능을 지원한다.

②. L1CrossDomainMessenger는 해당 트랜잭션을 수신하여 OptimismPortal 컨트랙트의 depositTransaction 함수를 호출하며, 이 과정에서 TransactionDeposited 이벤트가 발생한다.

③. Op-node 컴포넌트는 L1 트랜잭션 정보를 Listen하고 있다가, OptimismPortal에서 입금 트랜잭션이 발생하면 execution engine (Op-geth)이 L2에 입금 트랜잭션을 실행할 수 있도록 정보를 파싱하여 전달한다.

④. 파싱된 정보는 L2CrossDomainMessenger로 전송되어 relayMessage 함수를 호출하고, 최종적으로 L2StandardBridge를 통해 L2 사용자에게 자산이 전달된다.

이제 위 과정을 코드로 상세히 살펴보자.

L1

①. L1StandardBridge 컨트랙트에서는 입금을 진행할 자산을 구분하여 Lock 과정을 거친 후 L1CrossDomainMessenger 컨트랙트의 sendMessage 함수를 호출하여 입금 트랜잭션을 L2로 relay 한다.

Fig. L1StandardBridge.sol/depositERC20, depositETH (Source: github link)

위에서 자산이 구분된다고 표현했는데, 이는 ERC20과 ETH의 입금 프로세스가 서로 다른 함수를 호출하여 각 경로에 따라 따로 처리되기 때문이다. 자산을 lock 하는 과정부터 나뉘게 되는데, ERC20 같은 경우 L1StandardBridge에서 lock되지만 ETH의 경우 OptimismPortal 내에 Lock된다. (여기서 Optimism은 Ethereum이 지원하는 모든 ERC20 토큰의 입금을 지원하는 것은 아니고, MINTABLE_ERC20으로 따로 구분된 ERC20 토큰만이 지원된다. 지원하는 토큰은 Link를 통해 확인 가능하다.)

depositERC20 / depositETH 파라미터

  • _l1Token(ERC20): 입금을 진행하고 있는 L1 ERC20의 토큰 컨트랙트 주소이다.
  • _l2Token(ERC20): 입금될 L2 ERC20의 토큰 컨트랙트 주소이다.
  • _amount(ERC20): 입금할 ERC20 수량이다.
  • _minGasLimit(ERC20, ETH): 입금하는 과정에서 발생하는 가스비의 최소한도이다.
  • _extraData(ERC20, ETH): 입금에 첨부할 추가 데이터이다.

여기서부터는 depositERC20과 depositETH 함수 호출의 흐름 외에는 크게 다르지 않기 때문에 두 자산을 따로 구분하지 않고 ETH를 입금한다는 가정하에 진행하도록 하겠다.

이어서, _initiateETHDeposit 함수를 통해 L1StandardBridge.sol 컨트랙트의 _initiateBridgeETH를 호출한다.

Fig.StandardBridge.sol/_initiateBridgeETH (Source: github link)

messenger.sendMessage 파라미터

  • address(OTHER_BRIDGE): 현재 L1에서 호출되었기에 OTHER_BRIDGE는 L2StandardBridge의 주소가 된다.
  • abi.encodeWithSelector: finalizeBridgeETH.selector, _from, _to, _amount, 그리고 _extraData를 하나의 파라미터로 ABI 인코딩을 한다.
Fig. CrossDomainMessenger.sol/sendMessage (Source: github link)

sendMessage 파라미터

  • _target: L2 target 주소이다.
  • _message:abi.encodeWithSelector에서 ABI 인코딩한 결과값이 _message가 된다.

②. L1CrossDomainMessenger_sendMessage 함수를 통해 OptimismPortal 컨트랙트의 depositTransaction 함수를 호출하여 TransactionDeposited 이벤트가 발생시킨다.

Fig. L1CrossDomainMessenger.sol/_sendMessage (Source: github link)

_sendMessage 파라미터

  • _to: 메시지의 목적지 주소이며, 입금 시에는 항상 L2CrossDomainMessenger 주소(0x4200000000000000000000000000000000000007)가 된다.
  • _gasLimit: 가스 한도로 baseGas 함수의 로직에 따라 계산된다.
  • _value: L2로 전송할 ETH 수량이다.
  • _data:sendMessage에서 _sendMessage를 호출할 때 파라미터로 전달한 6개의 파라미터(this.relayMessage.selector, messageNonce(), msg.sender, _target, msg.value, _minGasLimit, _message)를 모두 인코딩한 값이다. 이는 메시지를 릴레이하는 데 필요한 calldata가 된다. (여기서 _datarelayMessage의 ABI 형식에 따라 생성된다.)

이어서 OptimismPortal 컨트랙트의 depositTransaction에 대한 설명이다.

Fig. OptimismPortal.sol/depositTransaction (Source: github link)

depositTransaction 함수는 L2로 입금 메시지를 전달하기 위해 OptimismPortal에서 발생시키는 함수이다.

depositTransaction 파라미터

  • _to: 목적지 주소이다.
  • _value: L2로 전송할 ETH 수량이다.
  • _gasLimit: 가스 한도로 baseGas 함수의 로직에 따라 계산된다.
  • _isCreation: CA를 생성하는 트랜잭션인지 일반 트랜잭션인지 확인한다.
  • _data: _sendMessage 파라미터에서 전달받은 _data와 같다.

이제 L1에서의 입금 과정을 마무리하며, OptimismPortalTransactionDeposited 이벤트를 발생시킨다.

지금까지의 과정이 L1에서 발생한 트랜잭션을 L2에서 수행하기 위한 데이터의 전달 과정이었다면, 이제부터는 op-node와 execution engine에 의해서 입금 트랜잭션이 처리되고 L2 블록이 생성되어 트랜잭션이 실행되는 단계이다. 그렇기에, 앞으로 Op-node에서의 과정은 입금 트랜잭션을 통해 L2 블록의 attribute(속성)을 빌드하고 새로운 L2 블록을 생성하여 전달하는 개념으로 이해하면 편하다.

Op-node

③ Op-node는 해당 입금 트랜잭션을 L2로 파싱하는 역할을 담당하며, 모든 L1 트랜잭션 정보를 Listen하고 있다가, OptimismPortal에서 트랜잭션이 발생하면 바로 파싱을 진행한다.

이 과정에서는 발생하는 모든 함수나 메소드를 코드 레벨로 분석하기에는 분량이 많기에 중요한 로직의 흐름에 따라 요약해서 설명하도록 하겠다.

Fig. state.go/Driver (Source: github link)

Driver는 op-node의 전반적인 동작을 제어하고 명령하는 이벤트를 eventLoop()를 통해 수신받아 수행한다. 이러한 명령은 L2 블록 생성을 Start 하거나 Stop 하는 것과 같은 명령을 내리는 것부터, L1 블록의 새로운 Block Header 정보를 가져오거나 Block Type을 파악하는 것까지 포함된다.

Fig. pipeline.go/Step (Source: github link), engine_queue.go/Step (Source: github link)

우선, Step() 메소드에서 부터 시작해 보면, Step()은 pipeline.go와engine_queue.go 내 Step() 함수를 반복하여 파이프라인을 통해 새로운 L1 블록 데이터를 추가하면서 execution engine queue와의 동기화를 유지한다.

다음으로 attributes queue(속성 대기행렬)에 NextAttributes를 추가해야 한다. 여기서 attributes queue는 batch queue와 engine queue 사이에 위치하며, 여러 L1 트랜잭션의 집합체인 batch가 payload attributes(페이로드 속성)로 변환하는 역할을 한다. 이후 변환된 payload attributes는 최종적으로는 engine queue에게 전달된다. 이 과정은 특정 Epoch 동안의 L1 트랜잭션을 L2 블록으로 생성할 Payload Attributes 템플릿을 준비하는 과정으로 이해하면 쉽다.

Fig. sequencer.go/StartBuildingBlock (Source: github link)

StartBuildingBlock 메소드에서 부터 L2 블록을 생성하기 위한 준비 작업이 시작되며, 이 메소드는 l1OriginSelector 오브젝트로부터 L1 block 정보를 가져온다.

그리고 “fetchCtx”라는 context.Context 오브젝트를 생성하며, 이를 사용하여 AttributesBuilder 오브젝트 내의 PreparePayloadAttributes 메소드를 호출한다. 이때 payload attributes를 준비하는 작업이 진행되며, 그 과정은 아래와 같다.

Fig. attributes_queue.go/AttributesBuilder(Source: github link), attributes.go/PreparePayloadAttributes(Source: github link)

AttributesBuilder 인터페이스에서 정의된 PreparePayloadAttributes 메소드는 ctx context.Context, l2Parent eth.L2BlockRef, epoch eth.BlockID라는 3개의 파라미터를 리턴 받아 호출된다. 각각의 파라미터에 포함될 데이터를 추출하기 위해 여러 함수가 호출되며, 그중에 핵심 함수라고 할 수 있는 DeriveDeposits 함수를 살펴보겠다.

Fig. deposit.go/DeriveDeposits (Source: github link)

DeriveDeposits 함수는 receipts라는 *types.Receipt 포인터 슬라이스와 depositContractAddr이라 하는 common.Address를 파라미터로 받는다. 이를 위해 DeriveDeposit 함수는 먼저 receiptsdepositContractAddr를 인수로 사용해서 UserDeposits 함수를 호출하며, 자세한 내용은 아래와 같다.

Fig. deposit.go/UserDeposits (Source: github link)

UserDeposits은 L1의 transaction receipt 배열인 receipts의 log 필드를 반복적으로 가져오다가 depositContractAddr와 일치하는 log를 찾으면 해당 receipts를 언마샬링(디코딩)하여 *types.DepositTx에 output 슬라이스로 추가한다.

  • receipts: EVM에서 트랜잭션을 실행한 결과값으로, 실제로 사용된 가스비, 상태 코드와 컨트랙트 호출 시에 발생하는 log 등이 저장되는 구조이다. L1 receipts은 실행된 트랜잭션의 특정 정보를 인코딩하고 인덱스 키가 있는 Merkle Patricia Trie root에 기록되며, Optimism은 해당 정보를 토대로 입금 트랜잭션을 받아온다. (receipts에 대한 자세한 사항은 Ethereum yellow paper 섹션 4.3.1을 참고)
  • depositContractAddr: 지정된 L1 입금 컨트랙트 주소를 의미하며 여기서는 OptimismPortal의 주소이다.

이러한 방식으로 L1에서 발생한 트랜잭션 중 OptimismPortal에서 발생한 입금 트랜잭션을 식별하여 추출해 온다.

UnmarshalDepositLogEvent(log): 함수의 목적은 deposit log를 언마샬하여 *types.DepositTx 포인터에 log를 추가하는 것이다.

  • 여기서 “언마샬링(unmarshaling)”을 수행한다는 것은 depositTransaction의 파라미터인 _to, _value, _gasLimit, _isCreation, _data 등의 필드를 추출할 수 있다는 것을 의미한다.

이렇게 *types.DepositTx까지 리턴 받으면, 다시 DeriveDeposit 함수로 돌아와 남은 절차를 수행한다.

Fig. deposit.go/DeriveDeposits (Source: github link)

encodedTxs: for 루프를 사용해서 `*types.DepositTx 값을 지속해서 받아오고, types.NewTx(tx).MarshalBinary() 메소드로 해당 값을 바이트 배열로 인코딩한다. 이 과정이 성공적으로 수행되면 모든 바이트 배열은 opaqueTx에 저장되며, 발생한 모든 오류는 err에 기록된다.

이제 DeriveDeposits 함수에서 필요한 리턴값은 다 받아왔으므로, 다시 PreparePayloadAttributes 함수로 돌아와서 새 L2 블록을 생성하는 데 필요한 payload attributes가 포함된 *eth.PayloadAttributes 포인터를 리턴하여 함수를 마무리한다.

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

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

새로운 payload attributes를 생성하는 것은 AttributesQueue에 새로운 큐를 추가하는 것과 동일하다. AttributesQueue 내의 ‘builder’ 오브젝트가 ‘PreparePayloadAttributes’ 메소드를 사용하여 다음 L2 블록에 대한 payload attributes를 가져온다. 그 후, 해당 PayloadAttributes 오브젝트에 트랜잭션 개수 및 batch의 타임스탬프 정보를 기록한 다음, 새로운 PayloadAttributes 오브젝트를 생성하여 리턴한다.

PayloadAttributes까지 생성되면, 다시 seqencer의 BuildingBlock 메소드로 돌아와 블록 생성 작업을 완료한다.

Fig. sequencer.go/StartBuildingBlock (Source: github link)

ConfirmPayload 메소드를 사용해서 L2 블록을 빌드하는데 오류가 있는지 확인하고, 오류가 없다면 *eth.ExecutionPayload 포인터에 완성된 L2 블록을 기록한다.

여기서 squencer가 실제로 블록을 생성하지 않고, sequencer와 execution engine은 engine API를 통해 서로 상호작용하여 블록을 생성한다.

Fig. engine_update.go/StartPayload (Source: github link)

StartPayload()는 execution engine에서 주어진 payload에 대한 빌드를 시작한다. 이 함수는 ForkchoiceUpdate()를 실행하여, engine_forkchoiceUpdatedV1 API를 통해 execution engine (op-geth)에서 트랜잭션을 처리하고 L2 블록을 생성한다. 이에 대한 자세한 내용은 현재 시리즈에서 다루기에 분량이 너무 많아지기에 4번째 시리즈에서 자세히 다룰 예정이다.

L2

④. Execution engine이 입금 트랜잭션을 처리하는 과정에서 CrossDomainMessengerrelayMessage가 호출된다.

Fig. CrossDomainMessenger.sol/relayMessage (Source: github link)

relayMessage 파라미터

  • _nonce: relay 중인 메시지의 논스이다.
  • _sender: 메시지를 보낸 사용자의 주소이다.
  • _target: 입금될 L2 target 주소이다. (L2StandardBridge 주소)
  • _value: 전송할 이더리움 값이다.
  • _minGasLimit: 메시지를 실행할 수 있는 가스 한도이다.
  • _message: target 주소에 보낼 메시지이다.

메시지를 relay 하는 과정에서 아무런 error가 없다면, relay 된 calldata를 가지고 실제 target 주소로 자금을 전달한다.

Fig. CrossDomainMessenger.sol/relayMessage (Source: github link)

xDomainMsgSender = _sender

  • sender의 정보를 추적하기 위해 _senderxDomainMsgSender 변수로 설정한다.

bool success = SafeCall.call(_target, gasleft() — RELAY_RESERVED_GAS, _value, _message)

  • SafeCall.sol 컨트랙트를 사용하여 RELAY_RESERVED_GAS, _value _message_target 주소로 호출하고, 리턴 값으로 성공 여부를 받아온다.

이제 최종적으로 relay 된 입금 정보를 가지고 L2StandardBridge를 호출해서 입금할 계정으로 전달한다.

Fig. L2StandardBridge.sol/finalizeDeposit (Source: github link)

finalizeDeposit 함수는 위 6개의 파라미터로 받아 L2에서의 입금을 마무리한다. 이 함수는 먼저 입금되는 자산이 ETH인지 ERC20인지 확인하고 ETH인 경우 _from, _to, _amount, _extraData를 파라미터로 finalizeBridgeETH 함수를 호출하여 입금을 마무리하고, ERC20 토큰의 경우 _l2Token, _l1Token, _from, _to, _amount, 와 _extraData를 파라미터로 finalizeBridgeERC20 함수를 호출하여 입금을 마무리한다.

여기까지 입금 프로세스의 흐름에 대해서 살펴보았고, 다음으로 출금 프로세스의 흐름에 대한 분석이 이어진다.

Withdrawal Flow

Fig. Optimism Bridge 출금 진행 캡처 화면. (Source: optimism bridge)

사용자가 Optimism Bridge를 통해 출금을 진행할 때 출금 화면은 위와 같으며, 사용자는 출금을 위해 3가지 트랜잭션을 제출해야 한다.

①. Withdrawal initiating transaction: 사용자가 L2에서 처음 출금 요청을 보내는 트랜잭션이다.

②. Withdrawal proving transaction: Two-Phase Withdrawals(2단계 출금)에서 첫 번째 단계로 유효한 출금이라는 것을 입증하기 위한 ‘proveWithdrawalTransaction(증명)’을 L1에 있는 OptimismPortal에 제출하는 단계이다.

③. Withdrawal finalizing transaction: finalization period(확정 기간, Legacy 버전에서의 Challenge Period)가 지난 후 사용자가 실제로 자산을 청구하기 위해 제출하는 ‘finalizeWithdrawalTransaction(확정)’ 트랜잭션이다. 이는 Two-Phase Withdrawals의 마지막 단계로 사용자가 출금에 대한 claim(요청) 하는 단계이다.

앞선 Series 2에서 Two-Phase Withdrawals에 대한 간단한 리뷰를 진행했었는데, 이번 시리즈에서는 출금을 진행하는 당사자의 입장에서 본 출금 프로세스에 대한 코드레벨 분석이 담겨있다.

L2

①. Withdrawal initiating transaction

Fig. Withdrawal initiating transaction의 함수 경로

L2에서 시작된 출금 트랜잭션이 L1에 relay 되기 직전까지의 함수 경로는 위 다이어그램과 같다.

Fig. L2StandardBridge.sol/withdraw (Source: github link)

우선, 출금 요청은 L2에 배포된 L2StandardBridge.solwithdraw 함수에서부터 시작되며, 해당 함수는 L2에서 mint 가능한 ERC20 토큰(OptimismMintableERC20)이나 ETH을 출금할 때만 사용 가능한 함수이다.

Withdrawal 파라미터.

  • _l2Token: 출금할 L2 토큰의 주소(발신자)이다.
  • _amount: 출금할 L2 토큰의 금액이다.
  • _minGasLimit: 트랜잭션에 사용할 최소 가스 한도이다.
  • _extraData: 출금에 첨부할 추가 데이터이다.

이어서 withdraw 함수는 _initiateWithdrawal 함수를 호출한다.

Fig. L2StandardBridge.sol/_initiateWithdrawal (Source: github link)

여기서 한가지 알아야 할 점은 Bedrock 업그레이드 이전 생성됐던 ETH는 LEGACY_ERC20_ETH라는 사전 배포 컨트랙트에 의해서 관리되었는데, Bedrock 업그레이드 이후로는 모든 ETH를 마이그레이션 하여 네이티브 개념으로 바뀌었다. 따라서 Bedrock 업그레이드 전후로 생성된 ETH를 구분하여 처리하여야 한다.

LEGACY_ERC20_ETH(0xDeadDeAddeAddEAddeadDEaDDEAdDeaDDeAD0000)

  • Legacy 버전에서 ETH를 관리했던 컨트랙트로, 이제 더 이상 사용되지 않는다.

OPTIMISM_MINTABLE_ERC20_FACTORY(0x4200000000000000000000000000000000000012;)

  • ERC20이나 ETH를 입금할 때나 출금하는 경우 L2StandardBridge가 토큰을 발행하고 소각하는 것을 허용해 주는 컨트랙트이다.

확인한 결과 Legacy 버전 이전에 생성된 ETH라면 StandardBridge.sol_initiateBridgeETH 함수를 통해서_emitETHBridgeInitiated 함수를 호출하여 출금을 진행하고, Bedrock 업그레이드 이후에 생성된 ETH이거나 ERC20 토큰이라면 StandardBridge.sol_initiateBridgeERC20 함수를 통해_emitERC20BridgeInitiated 함수를 호출한다.

LEGACY_ERC20_ETH는 더 이상 사용되지 않기에 Bedrock 업그레이드 이후에 생성된 ETH를 출금한다는 가정하에 진행토록 하겠다.

Fig. StandardBridge.sol/_initiateBridgeERC20 (Source: github link)

이어서, _initiateBridgeERC20 함수를 보면 L2CrossdomainMessengersendMessage를 호출하기 위한 준비 과정이 담겨있으며, 생소한 이름의 파라미터를 살펴보면 아래와 같다.

  • _localToken: 출금할 토큰이 있는 L2 주소이다.
  • _remoteToken: 출금될 L1 주소이다.

그리고 몇 가지 확인하는 절차가 있다.

  • _isCorrectTokenPair: L2에서 L1으로 출금하고자 하는 토큰의 종류가 서로 일치하는지 확인한다.
  • OptimismMintableERC20(_localToken).burn: 종류가 일치한다면, _localToken_amount만큼 burn 한다.
  • 그리고 만약에 일치하지 않는다면, _from 주소인 출금을 요청한 주소에게 다시 돌려준다.
  • 마지막으로 출금이 생성되면, _emitERC20BridgeInitiate 함수를 통해 ERC20BridgeInitiated 이벤트를 발생시킨다.

ERC20BridgeInitiated 이벤트까지 발생하면, MESSENGER.sendMessage 함수를 호출해서 sendMessage를 호출할 준비를 한다.

Fig. StandardBridge.sol/MESSENGER.sendMessage (Source: github link)

MESSENGER.sendMessage를 자세히 살펴보면 아래와 같다.

  • value: _amount: 메시지에 담길 ETH의 수량이다.
  • address(OTHER_BRIDGE): L2에서 트랜잭션이 시작됐기 때문에 OTHER_BRIDGEL1StandardBridge 주소가 된다.
  • abi.encodeWithSelector: L1StandardBridge에서 실행하게 될 함수의 ABI 정보 즉 finalizedBridegeETH라는 함수에 ABI를 담아서 보내게 되는데 담기는 정보로는 _from, _to, _amount, 그리고 _extraData가 된다.

이제 L1으로 출금 메시지를 relay 하기 위해 sendMessage를 호출한다.

Fig. CrossDomainMessenger.sol/sendMessage (Source: github link)

sendMessage 함수는 3가지 파라미터를 받아온다.

  • _target: L1의 대상 주소.
  • _message: L1 트랜잭션의 호출 데이터.
  • _minGasLimit: withdrawal finalizing transaction까지 과정에서 사용될 최소 가스양이다.

그리고, sendMessage는 L1과 L2 두 도메인 간 메신저에게 모두 사용되는 Universal 함수이기 때문에, L2CrossDomainMessenger의 _sendMessage를 다시 호출한다. 이어서, _sendMessageL2ToL1MessagePasser에 있는 initiateWithdrawal을 호출하고, initiateWithdrawal은 현재 상태에서 raw withdrawal field에 대한 withdrawalHash 값을 구한다.

Fig.L2ToL1MessagePasser.sol/initiateWithdrawal (Source: github link)

initiateWithdrawal 함수의 raw withdrawal field는 아래와 같다.

  • messageNonce: 동일한 출금 두 건이 동일한 값으로 해싱되는 것을 방지하기 위하여 트랜잭션에 할당된 일회성 번호이다.
  • msg.sender: 전송을 시작한 L2 주소이다.
  • _target: L2에서 출금한 자산이 입금될 L1 주소이다.
  • msg.value: 전송할 ETH의 양이다.
  • _gasLimit: 트랜잭션의 가스 한도이다.
  • _data: 출금 트랜잭션의 calldata이다.

여기까지 진행이 됐다면, MessagePassed 이벤트를 발생시키는 것을 마지막으로 L2에서의 출금 과정은 끝이 나며, L1으로 출금 메시지가 relay 된다.

Fig. 출금 흐름 다이어그램 (Source: Optimism Docs link)

L1으로 출금 메시지가 전달되고 나면 잠시 대기 시간이 발생한다. 이는 L2에서 지금까지 처리된 출금 트랜잭션에 대한 output root가 Proposer에 의해서 생성되고 L2OutputOracle까지 전달될 때까지 소요되는 대기 시간이며, 보통 메인넷 기준 1시간 정도 소요된다.

L1

②. Withdrawal proving transaction:

Fig. Withdrawal proving transaction:‘증명’을 생성하기 위한 필수 데이터 수집 및 생성 과정

이제 L2에서 시작한 출금 트랜잭션이 proposer(제안자)에 의해 L2OutputOracle에 propose되었다. 그다음으로 Two-Phase Withdrawal에서 첫 번째 과정인 ‘증명’과 메시지를 함께 OptimismPortal로 제출하는 단계이다. 이때 사용자는 relayer를 통해 증명을 생성하는 데 필요한 필수 입력 값을 L2로부터 relay 받아 증명을 생성한 후 OptimismPortal에 제출한다.

Fig. OptimismPortal.sol/proveWithdrawalTransaction (Source: github link), Types.sol/WithdrawalTransaction, OutputRootProof (Source: github link)

proveWithdrawalTransaction() 즉, 출금 ‘증명’에는 4개의 파라미터가 포함된다.

  • _tx: 트랜잭션에 대한 정보이다.
  • _l2OutputIndex: L2 output의 인덱스이다.
  • _outputRootProof: L2ToL1MessagePasser 컨트랙트의 storage root가 state root에 포함되어 있는지에 대한 포함 증명을 나타낸다.
  • _withdrawalProof: L2ToL1MessagePasser 컨트랙트의 storage에 해당하는 withdraw가 포함되어 있는지 확인하는 포함 증명을 나타낸다.

여기까지 보면 ‘증명’이라고 하는 것은 결국 위 4개의 파라미터로 만들어진다는 얘기인데, 그렇다면 제각각 위치한 정보는 어떻게 받아오는가 하는 의문이 생기게 된다. 이를 위해 Optimism은 L2 output이 L1에 rollup된 이후 사용자가 직접 브릿지 서비스를 통해 proveMessage()를 호출하도록 했다.

Fig. sdk/src/cross-chain-messenger.ts/proveMessage (Source: github link)

proveMessage에는 증명을 만드는 데 필요한 데이터를 리턴 받아 저장하며, 저장되는 항목은 아래와 같다.

  • withdrawal: 지금까지 과정에서 생겨난 MessagePassed와 같은 출금 이벤트를 받아와 만들어진다.
  • proof: 증명이 생성되어 저장되며, 생성되는 방법은 아래에서 자세히 설명하겠다.
Fig. cross-chain-messenger.ts/proveMessage, getBedrockMessageProof (Source: github link)

① proof를 만들기 위해 getMessageBedrockOutput를 사용하여 L2OutputOracle에 제출(propose)된 output root를 쿼리해서 받아오게 된다.

② 다음으로 Withdrawal을 생성하고, 해당 출금의 slot 위치에 대한 slot hash 값, messagePasserStorageRoot, L2ToL1MessagePasser 컨트랙트의 storage root와 withdrawal hash를 파라미터로 stateTrieProof를 생성한다.

위와 같은 방식으로 출금에 필요한 모든 정보를 리턴 받고 나면 아래와 같은 파라미터 그룹들이 수집된다.

Fig. cross-chain-messenger.ts/proveMessage (Source: github link), OptimismPortal.sol/proveWithdrawalTransaction (Source: github link)

따라서, 최종적으로 위 4개의 파라미터 그룹을 가지고 OptimismPortalproveWithdrawalTransaction 함수를 호출한다.

Fig. OptimismPortal.sol/getL2Output (Source: github link)

계속해서 proveWithdrawalTransaction() 함수를 살펴보면 준비된 증명의 output root를 확인하는 절차를 수행한다. 출금 트랜잭션에 대한 output root를 가져오기 위해 L2OutputIndex 함수를 사용해서 L2OutputOracle 컨트랙트 내 특정 output root를 가져와서 해당 output root와 파라미터로 받은 output root가 같은지 확인하고 같다면 확인이 완료된다.

Fig. Types.sol에서 색별로 구분한 OutputRootProof 파라미터. (Source: github link)

여기서 잠시 OutputRootProof를 살펴보면, output root는 versionpayload에 대한 정보를 해시 한 값이 되며, payload는 state_root, withdrawal_storage_root와 latest_block_hash가 포함된다.

Fig. OptimismPortal.sol/provenWithdrawal (Source: github link)

마지막으로 provenWithdrawal이라는 mapping variable에 해당 증명을 추가하여 해당 출금에 대한 증명이 제출되었다는 이벤트를 발생시킨다.

Fig. 옵티미즘 브릿지 출금 진행 캡처 화면 (Source: optimism bridge)

출금 증명이 제출되고 나서는 7일간의 finalization period를 가지게 되고, 7일 후에는 사용자가 직접 출금에 대한 claim을 진행해야 한다. 이때 사용자는 브릿지 서비스로 finalizeMessage() 함수를 실행하여 직접 자신의 출금을 확정하고, finalizeMessage()OptimismPortalfinalizeWithdrawalTransaction()을 호출한다.

③. Withdrawal finalizing transaction

fig. Withdrawal finalizing transaction: finalization period 기간 이후 출금 claim 과정.

최종적으로 출금을 마무리하는 단계이다. finalizeWithdrawalTransaction은 withdrawal hash를 identifier로 사용하여 증명 과정에서 생성한 ProvenWithdrawal을 불러온다.

Fig. OptimismPortal.sol/finalizeWithdrawalTransaction (Source: github link)

그 다음, 해당 증명에 대한 finalization period (메인넷 기준 7일)가 끝났는지를 확인하기 위해 provenWithdrawal.timestamp를 확인한다. 여기서 0이라는 것은 finalization period가 지나지 않았음을 의미하며, finalization period가 지난 후에는 timestamp가 부여된다.

그뿐만 아니라 현재 증명으로 올라가 있는 provenWithdrawal.outputrootL2OutputOracle에 올라가 있는 출금에 대한 Output root와 일치하는지와 같은 몇 가지 검증 과정을 지나고, 모두 문제가 없다면 CrossDomainMesengerrelayMessage 함수를 호출하여 ETH를 출금할 수 있다.

Fig. OptimismPortal.sol/SafeCall.callWithMinGas (Source: github link), CrossDomainMessenger.sol/relayMessage (Source: github link), StandardBridge.sol//finalizeBridgeETH (Source: github link)

relayMessage는 sender의 주소, ETH를 출금할 주소, ETH 출금량 등을 파라미터로 받아서, L2에서 생성한 출금 메시지의 target 함수인 StandardBridgefinalizeBridgeETH를 호출한다. 그리고, finalizeBridgeETH 함수에서 L2에서 burn한 수량만큼 mint하고 L1 사용자 주소에 전송하면 모든 출금 과정을 마치게 된다.

마치며

여기까지, Bedrock 업그레이드 이후 입·출금 프로세스의 흐름을 코드 레벨에서 최대한 살펴보았습니다. 특히, 출금 프로세스에서 증명을 생성하기 위해 필요한 여러 출금 정보, 블록 정보, output root 값 등의 요소를 불러오고 또 다시 확인하는 절차는 따라가기 쉽지 않은 과정이었습니다. 이미 구현된 코드를 해석하는 것만으로도 어려움이 있었는데, 이러한 프로세스를 연구하고 개발하는 데 필요한 노력을 고려하면 감탄하지 않을 수 없었습니다. 이어지는 시리즈에서는 L1에서 시작된 트랜잭션이 L2 블록에서 처리되고 다시 배치에 담겨 파생되는 과정을 코드 레벨에서 순차적으로 분석하도록 하겠습니다.

Reference:

https://static.optimism.io/optimism.tokenlist.json

--

--