Optimism Transaction (sequencer transaction and queue transaction)[KR]

신건우(Thomas Shin)
Tokamak Network
Published in
17 min readMay 26, 2021
https://optimism.io/

옵티미즘은 옵티미스틱 롤업 구현체다. 옵티미스틱 롤업은 레이어 2 솔루션으로 이더리움을 레이어 1 체인으로 사용한다. 이 글은 옵티미즘의 기본 개념을 이해하고 있는 독자를 대상으로, 옵티미즘 트랜잭션이 어떻게 처리되는지 살펴본다.

옵티미즘은 7월에 출시될 예정이며 토카막 네트워크 역시 옵티미스틱 롤업을 기반으로 한 레이어 2 서비스를 준비하고 있다. 옵티미스틱 롤업과 옵티미즘에 대한 글은 여기1, 여기2서 확인할 수 있다. (옵티미즘 코드는 모두 cc209be 커밋을 기준으로 한다.)

이더리움 트랜잭션

이더리움은 트랜잭션 기반의 상태 머신이다. 컴퓨터 과학에서 상태 머신이란 인풋을 읽고 현재 상태를 기반으로 새로운 상태로 변환하는 머신을 말한다. 이더리움 상태 머신은 제네시스 상태로 시작한다. 트랜잭션이 실행되면 최종 상태로 변환하는데, 이 최종 상태가 바로 이더리움의 현재 상태다.

https://preethikasireddy.medium.com/how-does-ethereum-work-anyway-22d1df506369

트랜잭션은 메시지 콜을 하거나 새로운 컨트랙트를 배포하기 위해 EOA(Externally owned account)가 서명한 데이터를 말한다. 트랜잭션은 이더리움 네트워크에서 처리되어 블록에 기록으로 남게 된다. 메시지 콜은 다른 어카운트에 메시지를 보내는 것을 말하는데, 여기서 메시지는 어카운트 간의 전송되는 데이터를 말한다.

이더리움 클라이언트는 메시지 콜을 하는 트랜잭션을 받으면 이를 메시지로 변환한다. 이 메시지는 현재 상태를 기반으로 실행되어 새로운 상태를 만든다.

옵티미즘 트랜잭션

유저는 옵티미즘에서 3가지 종류의 트랜잭션을 만들 수 있다.

  1. 레이어 2 트랜잭션
  2. 레이어 1 ⇒ 레이어 2 트랜잭션
  3. 레이어 2 ⇒ 레이어 1 트랜잭션

이 글에서는 레이어 2 트랜잭션과 레이어 1 ⇒ 레이어 2 트랜잭션만을 다룬다. 레이어 2 ⇒ 레이어 1 트랜잭션은 이후에 작성될 Message Relayer를 설명하는 글에서 자세히 다룰 예정이다.

유저는 메시지 콜 또는 새로운 컨트랙트를 배포하기 위해 이더리움의 트랜잭션 포맷과 같은 레이어 2 트랜잭션을 시퀀서(현재 옵티미즘은 단일 시퀀서만을 지원한다.)에게 제출한다. 이렇게 시퀀서에게 직접 제출하는 레이어 2 트랜잭션을 시퀀서 트랜잭션(sequencer transaction)이라고 부른다.

레이어 1 ⇒ 레이어 2 트랜잭션은 큐 트랜잭션(queue transaction)이라고 부른다. 큐 트랜잭션은 레이어 1에서 만들어져서 레이어 2에서 실행된다. 시퀀서 트랜잭션과 큐 트랜잭션이 옵티미즘에서 어떻게 만들어지고 실행되는지 하나씩 살펴보자.

시퀀서 트랜잭션

시퀀서 트랜잭션은 이더리움과 동일한 방식으로 만들어져서 시퀀서에게 전송된다. 시퀀서는 트랜잭션을 전달받으면 메타 정보를 새로 만드는데, 메타 정보는 실행 환경에 필요한 컨텍스트 정보라고 할 수 있다. 시퀀서는 메타 정보를 만들고 이를 트랜잭션에 추가한다.

type TransactionMeta struct {
L1BlockNumber *big.Int
L1Timestamp uint64
SignatureHashType SignatureHashType
QueueOrigin *big.Int
Index *uint64
QueueIndex *uint64
RawTransaction []byte
}
  • L1BlockNumber: 레이어 1 컨텍스트의 블록 넘버 값이다. 시퀀서 트랜잭션은 레이어 2 컨텍스트의 블록 넘버 값을 사용한다.
  • L1Timestamp: 레이어 1 컨텍스트의 타임스탬프 값이다. 시퀀서 트랜잭션은 레이어 2 컨텍스트의 타임스탬프 값을 사용한다.
  • SignatureHashType: 트랜잭션의 인코딩 포맷에 따라 SighashEIP155 = 0, SighashEthSign = 1 그리고 CreateEOA = 2 값중 하나를 가진다.
  • QueueOrigin: QueueOriginSequencer = 0QueueOriginL1ToL2 = 1 값으로 옵티미즘 트랜잭션 타입을 구분한다. QueueOriginSequencer 는 시퀀서 트랜잭션 타입이고, QueueOriginL1ToL2 는 큐 트랜잭션 타입이다.
  • Index: 레이어 2 체인의 블록 넘버다(옵티미즘의 블록은 트랜잭션 하나만 담는다). 큐 트랜잭션은 이 값을 가지지 않는다.
  • QueueIndex: queue에 쌓인 큐 트랜잭션의 인덱스 값을 가진다. 시퀀서 트랜잭션은 이 값을 가지지 않는다.
  • RawTransaction: 트랜잭션 데이터를 RLP 인코딩한 값이 들어간다.

유저가 시퀀서에게 트랜잭션을 보내면 SendTransaction 함수가 호출된다. SendTransaction 함수는 트랜잭션 데이터를 RLP 인코딩하고 SendRawTransaction 함수를 호출한다. SendRawTransaction 함수는 위와 같이 메타 정보를 생성하면서 RawTransaction 필드값에 트랜잭션 데이터를 RLP 인코딩한 값을 넣는다.

옵티미즘의 트랜잭션을 실행하기 위해서는 이더리움과 마찬가지로 트랜잭션을 메시지로 변환해야 한다. 옵티미즘은 이를 위해 AsOvmMessage 함수를 호출한다. 옵티미즘의 메시지는 다음과 같이 구성되어 있다.

type Message interface {
From() common.Address
To() *common.Address
GasPrice() *big.Int
Gas() uint64
Value() *big.Int
Nonce() uint64
CheckNonce() bool
Data() []byte
L1MessageSender() *common.Address
L1BlockNumber() *big.Int
QueueOrigin() *big.Int
SignatureHashType() types.SignatureHashType
}

AsOvmMessage 함수는 메시지의 데이터를 다음과 같이 수정한다.

  1. 메시지의 to 필드의 값을 OVM_SequencerEntrypoint 컨트랙트 주소로 변경한다.
  2. 메시지의 data 필드의 값을 트랜잭션 메타 정보의 RawTransaction 값으로 변경한다.

옵티미즘은 이렇게 수정된 OVM 메시지를 실행하기 전에 OVM 메시지를 한번 더 수정한다.

  1. 메시지의 to 필드의 값을 OVM_ExecutionManager 컨트랙트 주소로 변경한다.
  2. 메시지의 data 필드의 값을 OVM_ExecutionManager 컨트랙트의 run 함수를 호출하는 바이트코드로 변경한다. run 함수의 인자값은 수정된 OVM 메시지를 기반으로 OVM 트랜잭션를 만들어 이를 인코딩한 값OVM_StateManager 컨트랙트의 주소가 된다.

OVM 트랜잭션은 아래와 같이 구성되어 있다. 시퀀서 트랜잭션의 entrypoint 필드값은 항상 SequencerEntrypoint 컨트랙트의 주소가 된다.

struct Transaction {
uint256 timestamp;
uint256 blockNumber;
QueueOrigin l1QueueOrigin;
address l1TxOrigin;
address entrypoint;
uint256 gasLimit;
bytes data;
}

최종적으로, 옵티미즘은 위와 같이 수정된 OVM 메시지를 현재 옵티미즘 상태를 기반으로 실행하여 새로운 상태를 만들어 낸다. 이제 이렇게 수정된 메시지를 시퀀서가 어떻게 실행하는지 살펴보자.

시퀀서 트랜잭션 실행

수정된 OVM 메시지는 결국 ExecutionManager 컨트랙트의 run 함수를 호출하게 된다. run 함수의 인자값은 인코딩된 트랜잭션 데이터StateManager 컨트랙트의 주소다.

OVM에서 트랜잭션의 실행은 모두 ExecutionManager 컨트랙트의 run 함수로부터 시작한다. 그 이유는 OVM의 샌드박스된 환경에서 트랜잭션이 실행되어야 하기 때문이다(이에 대한 자세한 내용은 이전에 작성한 옵티미즘 글에서 확인할 수 있다.). run 함수는 ovmCALL 함수를 호출하고 최종적으로 _handleExternalMessage 함수를 호출한다. _handleExternalMessage 함수는 (success, returndata) = _contract.call{gas: _gasLimit}(_data) 함수를 호출하는데, 여기서 _contract는 SequencerEntrypoint 컨트랙트 주소고, _data는 유저가 전송한 트랜잭션을 RLP 인코딩한 데이터다. 결국 이 코드는 SequencerEntrypoint 컨트랙트에 메시지 콜을 하게 된다.

function run(
Lib_OVMCodec.Transaction memory _transaction,
address _ovmStateManager
)

OVM_SequencerEntrypoint 컨트랙트에는 fallback 함수만 존재한다. 따라서 ExecutionManager 컨트랙트의 run 함수는 결국 SequencerEntrypoint 컨트랙트의 fallback 함수를 호출하게 된다.

fallback 함수는 우선 인코딩된 트랜잭션 데이터를 디코딩한다. 디코딩된 트랜잭션 데이터는 아래와 같다.

struct EIP155Tx {
uint256 nonce;
uint256 gasPrice;
uint256 gasLimit;
address to;
uint256 value;
bytes data;
uint8 v;
bytes32 r;
bytes32 s;
uint256 chainId;
uint8 recoveryParam;
bool isCreate;
}

이후 디코딩된 트랜잭션의 서명값(v, r, s)을 이용해서 트랜잭션을 보낸 어카운트의 주소를 구한다. 어카운트 주소를 구하면 해당 주소로 배포된 EOA 컨트랙트가 존재하는지 확인(확인하기 위해서 어카운트의 codesize를 확인한다.)한다. 만약 EOA 컨트랙트가 존재하지 않으면 EOA 컨트랙트를 새로 배포한다. 마지막으로 EOA 컨트랙트의 execute(bytes) 함수를 호출하는 메시지 콜을 하게 된다.

여기에 EOA 컨트랙트라는 낯선 용어가 나타난다. 이를 이해하기 위해서는 옵티미즘이 어카운트와 상태를 어떻게 처리하는지 알아야 한다.

우선 옵티미즘의 모든 어카운트는 컨트랙트 어카운트다. 즉, 옵티미즘에는 EOA가 존재하지 않는다.

이를 실현하기 위해 옵티미즘은 OVM_ECDSAContractAccount 컨트랙트를 만들었다. 따라서 옵티미즘의 EOA는 모두 ECDSAContractAccount 컨트랙트이고, 이를 EOA 컨트랙트라고 부른다. 이 컨트랙트에는 execute 함수가 존재하는데, 이 함수는 이더리움의 EOA와 동일한 방식으로 동작한다. 이 함수가 호출되면 트랜잭션의 서명값을 통해 어카운트 주소를 확인하고 nonce 값도 확인한다. 그후 수수료를 지불한 뒤 nonce를 1 증가시키고 유저가 생성한 트랜잭션을 호출한다.

그리고 옵티미즘의 모든 어카운트의 상태는 StateManager 컨트랙트에 의해 관리된다. 예를 들어, EOA 컨트랙트가 존재하는지 확인하고, EOA 컨트랙트를 새로 배포하고, 어카운트의 nonce를 확인하고 nonce를 1 증가시키는 것 모두 StateManager 컨트랙트가 관여한다.

최종적으로, ExecutionManager 컨트랙트의 run 함수를 시발점으로 유저가 생성한 트랜잭션이 OVM의 샌드박스된 환경에서 실행되고, 변경된 어카운트와 스토리지는 모두 StateManager 컨트랙트에서 처리된다.

큐 트랜잭션

큐 트랜잭션은 레이어 1에서 만들어진 레이어 2 트랜잭션이다. 큐 트랜잭션을 만드는 이유는 시퀀서가 특정 유저를 검열(레이어 2에서 검열을 받는 유저는 레이어 1에서 트랜잭션을 만들어 검열을 회피할 수 있다.)하거나 레이어 1에서 레이어 2로 자산을 옮기기 위해서다.

큐 트랜잭션을 만들기 위해서는 CanonicalTransactionChain 컨트랙트의 enqueue 함수를 호출한다. 큐 트랜잭션이 만들어지면 queue 자료 구조에 쌓이게 되고, 큐 트랜잭션은 큐 인덱스를 갖게 된다. enqueue 함수는 다음과 같다.

function enqueue(
address target,
uint256 gasLimit,
bytes memory data
)

enqueue 함수는 중간에 일정 가스를 소모하는 행위를 한다(실제로 gasLimit / 32만큼 가스를 소모한다.). 이렇게 하는 이유는 큐 트랜잭션은 레이어 2에서 gasPrice가 0이기 때문에 도스 공격을 막기 위함이다. 즉, 일정 가스를 소모하게 만들어서 레이어 1에서 레이어 2에 부담이 될 수 있는 큐 트랜잭션을 무자비하게 만드는 것을 막는다.

마지막으로 enqueue 함수가 성공적으로 실행되면 TransactionEnqueued 이벤트가 발생한다.

event TransactionEnqueued(
address l1TxOrigin,
address target,
uint256 gasLimit,
bytes data,
uint256 queueIndex,
uint256 timestamp
);
  • l1TxOrigin: enqueue 함수를 호출하는 트랜잭션을 보낸 어카운트 주소다.
  • target: enqueue 함수의 target 인자값과 같다.
  • gasLimit: enqueue 함수의 gasLimit 인자값과 같다.
  • data: enqueue 함수의 data 인자값과 같다.
  • queueIndex: 큐 트랜잭션의 인덱스 값이다.
  • timestamp: enqueue 함수가 실행되는 시점의 타임스탬프 값이다.

TransactionEnqueued 이벤트의 인자값들은 큐 트랜잭션을 레이어 2에서 실행하기 위해 필요한 모든 정보를 담고 있다.

dtl (data transport layer)

시퀀서가 큐 트랜잭션을 레이어 2에서 실행하기 위해서는 반드시 data transport layer라는 프로그램을 돌려야 한다. dtl에는 두가지 서비스가 동작한다.

  1. Transaction Ingestor: CanonicalTransactionChain 컨트랙트에서 발생한 TransactionEnqueued (리스닝) 이벤트 데이터를 파싱하고 파싱된 데이터를 데이터베이스에 저장한다.
  2. Transaction Indexer: 데이터베이스에 저장된 데이터에 접근할 수 있도록 API 서버를 제공한다. 결국 이 데이터베이스는 이벤트 데이터 정보를 저장하게 된다.

저장된 이벤트 데이터 정보는 API 서버로부터 다음과 같이 가져올 수 있다.

요청:

GET /enqueue/index/{index: number}

응답:

{
"index": number,
"target": string,
"data": string,
"gasLimit": number,
"origin": string,
"blockNumber": number,
"timestamp": number
}

dtl의 API 서버가 제공하는 다른 API는 여기서 확인할 수 있다.

큐 트랜잭션 실행

옵티미즘 클라이언트는 큐 트랜잭션을 실행하기 위해 dtl의 API 서버로부터 이벤트 데이터 정보를 가지고 와야 한다. 이를 위해 옵티미즘 클라이언트는 sync 서비스를 돌려 dtl과 동기화한다. 시퀀서는 dtl과 동기화하기 위해 옵티미즘 클라이언트를 실행할 때 --rollup.clienthttp 플래그 값을 반드시 넣어야 한다. 이 값은 바로 dtl의 API 서버 주소가 된다.

$ USING_OVM=true ./build/bin/geth \
--rollup.addrclienthttp
...

시퀀서가 옵티미즘 클라이언트를 실행하면 sequence 함수가 호출된다. sequence 함수는 dtl의 API 서버로부터 이벤트 데이터 정보를 가지고 온다. 그런 다음 시퀀서는 가져온 이벤트 데이터 정보를 가지고 큐 트랜잭션을 만든다. 큐 트랜잭션은 다음의 두가지 특징을 가진다.

  1. 큐 인덱스 값을 트랜잭션의 nonce로 사용한다.
  2. gasPrice는 항상 0이다. 그렇기 때문에 enqueue 함수에서 일정량의 가스를 미리 소모한다.

그리고 위에서 언급했듯이, 옵티미즘 트랜잭션은 모두 메타 정보를 가진다. 큐 트랜잭션의 메타 정보는 다음과 같다.

이제 시퀀서는 이러한 큐 트랜잭션을 실행한다. 큐 트랜잭션도 시퀀서 트랜잭션과 마찬가지로 AsOvmMessage 함수를 호출해서 OVM 메시지로 변환한다. 하지만 큐 트랜잭션은 시퀀서 트랜잭션처럼 메시지의 to 필드값을 SequencerEntrypoint 컨트랙트 주소로, data 필드값을 트랜잭션 메타 정보의 RawTransaction으로 변경하지 않는다.

다만, 이 메시지가 ExecutionManager 컨트랙트의 run 함수에서 시작하도록 메시지를 수정한다.

이렇게 수정된 메시지는 시퀀서 트랜잭션과 마찬가지로 현재 상태를 기반으로 실행되어 새로운 상태를 만든다. 시퀀서 트랜잭션과 큐 트랜잭션의 차이점은 시퀀서 트랜잭션은 항상 SequencerEntrypoint 컨트랙트를 거치지만, 큐 트랜잭션은 target 주소에 바로 메시지 콜을 보낸다는 것이다.

실제로 큐 트랜잭션은 시퀀서가 특정 기간 내에 레이어 2에 반드시 반영해야 한다. 이를 위해 CanonicalTransactionChain 컨트랙트에는 appendQueueBatch 함수가 존재하지만, 현재는 사용할 수 없게 되어 있다. 이는 추후에 개발될 것으로 보이며, 개발이 완료되면 해당 내용을 이 글에 추가할 예정이다.

Conclusion

이번 글에서는 옵티미즘에서 취급하는 두 종류의 트랜잭션인 시퀀서 트랜잭션과 큐 트랜잭션에 대해 알아보았다. 옵티미즘은 트랜잭션을 실행한 후에 트랜잭션과 상태값을 레이어 1에 제출해야 한다. 이를 위해서는 batch submitter라고 하는 프로그램이 필요한데, 다음 글에서는 batch submitter가 트랜잭션과 상태값을 레이어 1에 어떻게 제출하는지에 대해 알아보고자 한다.

Reference

--

--