How does Optimism’s Rollup really work?[KR]

신건우(Thomas Shin)
Tokamak Network
Published in
32 min readMay 13, 2021

이 글은 paradigmGeorgios Konstantopoulos옵티미즘(Optimism)을 분석한 글을 우리말로 옮겨 쓴 글이다.

옵티미즘(Optimism)은 옵티미즘 팀이 구현한 옵티미스틱 롤업(Optimistic Rollup) 구현체다. 옵티미즘은 7월에 출시될 예정이며 토카막 네트워크 역시 옵티미스틱 롤업을 기반으로 한 레이어 2 서비스를 준비하고 있다.

https://medium.com/token-terminal/eli5-what-is-optimism-f623730503df

이 글은 옵티미스틱 롤업 매커니즘을 이해하고 있는 독자를 대상(옵티미즘 롤업에 대한 글은 여기서 확인할 수 있다.)으로 옵티미즘이 어떻게 동작하는지 설명한다.

The importance of software reuse in Optimism

이더리움의 가장 큰 강점은 바로 풍부한 개발자 생태계다. 전세계 수많은 개발자들이 지금도 이더리움에 많은 기여를 하고 있다. 이더리움의 개발 스택은 크게 3가지로 나뉜다.

  • Solidity / Vyper: 이더리움 기반 스마트 컨트랙트를 작성하기 위한 프로그래밍 언어다. 그리고 이 언어를 지원하는 여러 도구(e.g. Ethers, Hardhat, dapp, slither)들이 존재한다.
  • Ethereum Virtual Machine: 이더리움에서 사용하는 가상 머신이다.
  • Go-ethereum(geth): 이더리움 구현체는 다양한 언어로 구현되어 있다. 그 중 고언어로 작성된 Go-ethereum이 이더리움 네트워크의 75% 이상 차지하고 있다.

옵티미즘은 이더리움을 레이어 1으로 사용하는 레이어 2 솔루션이다. 옵티미즘 개발 도구들은 이더리움 개발 도구들을 약간 수정한 것이다. 그렇기 때문에 기존 이더리움 개발자들은 옵티미즘을 기반으로 개발하기 위해 새로운 기술을 따로 배울 필요가 없다. 그리고 옵티미즘이 기존의 이더리움 도구들을 약간 수정해서 사용하는 이유는 바로 재사용으로 인한 안전성 때문이다.

블록체인은 경제적 가치를 지닌 자산을 다루기 때문에 안전성이 매우 중요하다. 이더리움 개발 도구들은 오랜 기간동안 많은 개발자들의 의해 버그 수정이 이루어졌으며 그로 인해 안전성을 가지고 있다. 옵티미즘 개발 도구들은 기존의 이더리움 개발 도구에 옵티미스틱 롤업과 관련된 코드를 삽입 및 수정한 것이다. 따라서 옵티미즘 개발 도구들의 안전성은 전체 코드를 감사(audit)하는 것 대신에 옵티미스틱 롤업과 관련된 코드들만 감사하는 것으로도 충족될 수 있다.

우리는 이제 각 이더리움 개발 스택에서 옵티미스틱 롤업과 관련된 것들을 살펴볼 것이다.

The Optimistic Virtual Machine

옵티미스틱 롤업은 유효하지 않은 상태 변환을 막기 위해 사기 증명(fraud proof)을 사용한다. 이를 위해 레이어 2 트랜잭션을 레이어 1 체인인 이더리움에서 실행할 수 있어야 한다. 하지만 EVM의 특정 opcode는 레이어 1과 레이어 2에서 다르게 동작한다. 예를 들어 스토리지 값을 읽는 SLOAD opcode의 실행은 레이어 1과 레이어 2의 상태가 서로 다르기 때문에 다른 값을 리턴한다. 그리고 블록의 타임스탬프를 리턴하는 TIMESTAMP opcode는 레이어 1에서 실행되는 시점과 레이어 2에서 실행되는 시점에 따라 다른 값을 리턴한다. 앞으로 이런 opcode들을 컨텍스트에 의존성을 가진 opcode라고 부를 것이다. 결국 컨텍스트에 의존성을 가진 opcode는 레이어 1과 레이어 2에서 서로 다른 값을 가진다.

이를 막기 위해서는 레이어 2 트랜잭션이 레이어 1과 레이어 2에서 실행되었을 때 동일한 상태값을 가지도록 보장하는 샌드박스 환경이 필요하다. 이를 위해 옵티미즘은 OVM(Optimistic Virtaul Machine)을 만들었다. OVM은 컨텍스트에 의존성을 가진 모든 opcode를 ovm{OPCODE}로 대체해서 사용한다.

TIMESTAMP opcode를 예로 살펴보자.

  1. 레이어 2 트랜잭션이 TIMESTAMP opcode를 호출하고 이 opcode는 1610889676를 리턴한다.
  2. 1시간 후에 이 트랜잭션이 이더리움에서 재실행된다.
  3. 이 트랜잭션은 EVM에서 정상적으로 실행되고, TIMESTAMP opcode는 1610889676+3600을 리턴한다. 레이어 1과 레이어 2에서 같은 트랜잭션을 실행했지만 TIMESTAMP opcode는 다른 값을 리턴했다. 만약 TIMESTAMP에 의존성이 있는 코드가 있다면 같은 트랜잭션이라고 할지라도 레이어 1과 레이어 2에서 서로 다른 결과값을 만들어낼 것이다.
  4. OVM에서 TIMESTAMP opcode는 ovmTIMESTAMP opcode로 대체된다. ovmTIMESTAMP opcode는 레이어 2에서 실행된 트랜잭션의 시간 값과 동일한 값을 리턴한다. 그렇기 때문에 레이어 1과 레이어 2에서 ovmTIMESTAMP opcode는 동일한 값을 가진다.

위의 예시처럼 컨텍스트에 의존성을 가진 모든 opcode들은 ovm{OPCODE}를 가진다. 이 ovm{OPCODE}들은 모두 ExecutionManager 컨트랙트에 함수로 구현되어 있다. 즉, OVM에서 ovm{OPCODE}를 만나면 ExecutionManager 컨트랙트의 ovm{OPCODE} 함수를 호출한다.

OVM에서 트랜잭션은 모두 ExecutionManager 컨트랙트의 run 함수 호출로 시작한다. 이는 레이어 1에서도 OVM 환경에서 트랜잭션을 실행할 수 있도록 하기 위함이다. ExecutionManager 컨트랙트는 레이어 1과 레이어 2 양쪽에 모두 배포된다. 그렇기 때문에 레이어 1에서도 OVM 환경에서 트랜잭션을 실행할 수 있다.

ovm{OPCODE}StateManager 컨트랙트와 상호작용한다. 레이어 2의 상태들은 모두 StateManager 컨트랙트에 저장되어 관리된다. 레이어 1에서 실행되는 ovm{OPCODE}들 역시 레이어 1에 배포된 StateManager 컨트랙트와 상호작용하는데, 레이어 2와 동일한 결과값을 보장하기 위해 검증자는 논쟁 트랜잭션(시퀀서가 유효하지 않은 상태값을 제출하게 되면, 검증자는 이 상태값을 만들어 낸 트랜잭션과 증거들을 올려 검증을 하게 되는데, 이 트랜잭션은 레이어 1에서 재실행된다. 이러한 트랜잭션을 논쟁 트랜잭션이라고 부른다.)이 건드린 어카운트와 스토리지 값들을 모두 레이어 1에 올린다. 이와 관련된 내용은 Fraud Proof 섹션에서 자세히 다룰 것이다.

컨텍스트에 의존성을 가진 opcode 외에도 OVM에서 사용할 수 없는 opcode도 존재한다. OVM에는 SafetyChecker 컨트랙트가 존재하는데, 이 컨트랙트는 트랜잭션이 담고 있는 바이트코드에 OVM에서 사용할 수 없는 opcode가 포함되어 있는지를 확인한다. 만약 OVM에서 사용할 수 없는 opcode가 포함되어 있으면 해당 트랜잭션은 revert된다.

OVM은 아래 그림과 같이 ExecutionManager 컨트랙트와 SafetyChecker 컨트랙트로 구성된다(아래 그림에서 파란색 영역은 사기 증명을 위한 컨트랙트로 구성되는데, 이는 Fraud Proof 섹션에서 설명한다.).

그림1. The Optimistic Virtual Machine

OVM에서 취급하는 opcode들은 모두 여기서 확인할 수 있다.

Optimistic Solidity

OVM은 사용할 수 없는 opcode가 포함되어 있으면 revert 시킨다. 이를 위해 옵티미즘은 기존의 Solidity 컴파일러인 solc를 수정해서 사용한다. 이를 옵티미스틱 solc라고 부른다. 기존의 solc는 Solidity 코드를 Yul로 변환하고 Yul을 바이트코드로 변환한다. 옵티미스틱 solc는 기존의 solc가 만들어 낸 바이트코드에서 컨텍스트에 의존성을 가진 opcode들을 ovm{OPCODE}로 변환하고, OVM에서 사용할 수 없는 opcode가 포함되어 있으면 에러를 낸다. 따라서 solc는 EVM용 바이트코드를 생성하고, 옵티미스틱 solc는 OVM용 바이트코드를 생성한다.

EVM용 바이트코드와 OVM용 바이트코드가 어떻게 동작하는지 간단히 살펴보자. EVM에서 바이트코드가 어떻게 동작하는지에 대한 자세한 설명은 여기서 확인할 수 있다.

그림2. foo 함수는 상태 변수 x에 1을 더한 값을 저장한다.

위 컨트랙트를 solc로 컴파일하면 EVM용 바이트코드가 생성된다.

$ solc C.sol --bin-runtime --optimize --optimize-runs 200
6080604052348015600f57600080fd5b506004361060285760003560e01c8063c298557814602d575b600080fd5b60336035565b005b60008054600101905556fea264697066735822122001fa42ea2b3ac80487c9556a210c5bbbbc1b849ea597dd6c99fafbc988e2a9a164736f6c634300060c0033

EVM은 컴파일 된 바이트코드를 아래와 같이 해석하여 실행한다.

...
[025] 35 CALLDATALOAD
...
[030] 63 PUSH4 0xc2985578 // id("foo()")
[035] 14 EQ
[036] 60 PUSH1 0x2d // int: 45
[038] 57 JUMPI // jump to PC 45
...
[045] 60 PUSH1 0x33
[047] 60 PUSH1 0x35 // int: 53
[049] 56 JUMP // jump to PC 53
...
[053] 60 PUSH1 0x00
[055] 80 DUP1
[056] 54 SLOAD // load the 0th storage slot
[057] 60 PUSH1 0x01
[059] 01 ADD // add 1 to it
[060] 90 SWAP1
[061] 55 SSTORE // store it back
[062] 56 JUMP
...

[025]와 같은 번호는 PC(Program Counter)를 나타낸다. [025] PC에서는 CALLDATALOAD opcode를 호출해서 calldata를 로드한다. calldata에 foo() 함수 셀렉터가 포함되어 있으면 EVM은 foo 함수를 호출한다. foo 함수의 실행은 [053] PC에서부터 시작한다. [056] PC에서는 SLOAD opcode를 호출해서 x 값을 스택에 넣는다. [061] PC에서 SSTORE opcode를 호출해서 x 값에 1을 더한 값을 저장한다.

그럼 옵티미스틱 solc로 생성한 OVM용 바이트코드는 어떻게 동작하는지 살펴보자.

$ osolc C.sol --bin-runtime --optimize --optimize-runs 200
60806040523480156100195760008061001661006e565b50505b50600436106100345760003560e01c8063c298557814610042575b60008061003f61006e565b50505b61004a61004c565b005b6001600080828261005b6100d9565b019250508190610069610134565b505050565b632a2a7adb598160e01b8152600481016020815285602082015260005b868110156100a657808601518282016040015260200161008b565b506020828760640184336000905af158601d01573d60011458600c01573d6000803e3d621234565260ea61109c52505050565b6303daa959598160e01b8152836004820152602081602483336000905af158601d01573d60011458600c01573d6000803e3d621234565260ea61109c528051935060005b60408110156100695760008282015260200161011d565b6322bd64c0598160e01b8152836004820152846024820152600081604483336000905af158601d01573d60011458600c01573d6000803e3d621234565260ea61109c5260008152602061011d56

같은 컨트랙트를 컴파일 했지만 옵티미스틱 solc는 더 긴 바이트코드를 생성한다.

...
[036] 35 CALLDATALOAD
...
[041] 63 PUSH4 0xc2985578 // id("foo()")
[046] 14 EQ
[047] 61 PUSH2 0x0042
[050] 57 JUMPI // jump to PC 66
...
[066] 61 PUSH2 0x004a
[069] 61 PUSH2 0x004c // int: 76
[072] 56 JUMP // jump to PC 76

calldata로부터 foo 함수의 셀렉터를 찾는 과정은 동일하다.

...
[076] 60 PUSH1 0x01 // Push 1 to the stack (to be used for the addition later)
[078] 60 PUSH1 0x00
[080] 80 DUP1
[081] 82 DUP3
[082] 82 DUP3
[083] 61 PUSH2 0x005b
[086] 61 PUSH2 0x00d9 (int: 217)
[089] 56 JUMP // jump to PC 217
...
[217] 63 PUSH4 0x03daa959 // <---| id("ovmSLOAD(bytes32)")
[222] 59 MSIZE // |
[223] 81 DUP2 // |
[224] 60 PUSH1 0xe0 // |
[226] 1b SHL // |
[227] 81 DUP2 // |
[228] 52 MSTORE // |
[229] 83 DUP4 // |
[230] 60 PUSH1 0x04 // | CALL to the CALLER's ovmSLOAD
[232] 82 DUP3 // |
[233] 01 ADD // |
[234] 52 MSTORE // |
[235] 60 PUSH1 0x20 // |
[237] 81 DUP2 // |
[238] 60 PUSH1 0x24 // |
[240] 83 DUP4 // |
[241] 33 CALLER // |
[242] 60 PUSH1 0x00 // |
[244] 90 SWAP1 // |
[245] 5a GAS // |
[246] f1 CALL // <---|

[247] 58 PC // <---|
[248] 60 PUSH1 0x1d // |
[250] 01 ADD // |
[251] 57 JUMPI // |
[252] 3d RETURNDATASIZE // |
[253] 60 PUSH1 0x01 // |
[255] 14 EQ // |
[256] 58 PC // |
[257] 60 PUSH1 0x0c // |
[259] 01 ADD // |
[260] 57 JUMPI // | Handle the returned data
[261] 3d RETURNDATASIZE // |
[262] 60 PUSH1 0x00 // |
[264] 80 DUP1 // |
[265] 3e RETURNDATACOPY // |
[266] 3d RETURNDATASIZE // |
[267] 62 PUSH3 0x123456 // |
[271] 52 MSTORE // |
[272] 60 PUSH1 0xea // |
[274] 61 PUSH2 0x109c // |
[277] 52 MSTORE // <---|

하지만 OVM용 바이트코드에서는 SLOAD 대신에 스택에 바이트코드를 담고 CALL을 한다. CALL opcode는 다른 컨트랙트의 함수를 호출하는데, 이 컨트랙트의 주소는 CALLER opcode 값이 된다. CALLER opcode는 메시지 콜을 호출한 어카운트의 주소를 리턴한다. OVM에서 모든 트랜잭션은 ExecutionManager 컨트랙트의 run 함수로 시작하기 때문에 CALLER는 ExecutionManager 컨트랙트의 주소가 된다. CALL을 하기 위해 ovmSLOAD(bytes32) 셀렉터와 인자값이 사용된다. 최종적으로 OVM은 ExecutionManager 컨트랙트의 ovmSLOAD 함수를 호출하고, 리턴된 데이터를 처리하고 이를 메모리에 추가한다.

정리하면, 옵티미스틱 solc는 컨텍스트에 의존성을 가진 opcode들을 ovm{OPCODE} 로 대체하는데, ovm{OPCODE} 는 결국 ExecutionManager 컨트랙트의 ovm{OPCODE} 함수를 호출하는 바이트코드다. 아래 그림을 보면, EVM은 SLOAD가 바로 사용되는 반면, OVM은 ExecutionManager의 ovmSLOAD 함수를 호출하는 것을 확인할 수 있다.

그림3. EVM vs OVM execution

위에서 살펴본 것처럼, 같은 컨트랙트를 옵티미스틱 solc로 컴파일하면 기존의 solc가 생성하는 바이트코드보다 더 긴 바이트코드를 생성한다. EVM은 배포되는 컨트랙트의 사이즈를 24KB로 제한된다. OVM도 배포되는 컨트랙트 사이즈를 24KB로 제한하는데, 그 이유는 OVM에 배포된 컨트랙트가 EVM에도 배포 가능해야 하기 때문이다.

Optimistic Geth

geth(go-ethereum)은 고언어로 작성된 이더리움 구현체로 가장 대중적으로 사용되고 있는 클라이언트다. 우선 트랜잭션이 geth에서 어떻게 처리되는지 살펴보자.

geth는 블록을 전파받으면 state processorProcess 함수를 호출한다. 블록에 포함된 트랜잭션들은 Process 함수에서 ApplyTransaction 함수에 의해 처리된다. ApplyTransaction 함수는 트랜잭션을 메시지(message)로 변환하고 현재 상태를 기반으로 메시지를 실행한다. 메시지가 실행되면 새로운 상태값이 도출되고 이 상태값은 데이터베이스에 저장된다.

이러한 흐름은 Optimistic geth에서도 크게 다르지 않다. 트랜잭션들이 OVM 환경에서 실행되어야 하기 때문에 옵티미즘은 geth를 다음과 같이 수정해서 사용한다.

수정 1: Sequencer Entrypoint 컨트랙트를 경유하는 OVM 메시지를 만든다.

트랜잭션들은 모두 OVM 메시지로 변환된다. 이를 위해 AsOvmMessage 함수를 호출한다. 이 OVM 메시지는 압축되어 OVM_SequencerEntrypoint 컨트랙트로 보내진다. 압축된 트랜잭션들은 모두 이더리움에 제출(옵티미즘은 데이터 가용성(data availability) 문제를 해결하기 위해 모든 트랜잭션을 이더리움에 제출한다.)되는데, 만약 트랜잭션을 더 작은 사이즈로 압축할 수 있으면 더 많은 트랜잭션을 이더리움에 제출할 수 있을 것이다.

수정 2: Execution Manager 컨트랙트를 통해 OVM 샌드박스 환경에서 메시지가 실행되도록 한다.

OVM 샌드박스 환경에서 메시지를 처리하기 위해 메시지는 ExecutionManager 컨트랙트의 run 함수로 보내진다. 즉, 메시지의 to 필드는 ExecutionManager 컨트랙트의 주소로 대체되고 메시지의 원래 데이터들은 run 함수의 인자값으로 사용된다.

정리하면, OVM 메시지는 압축되어 SequencerEntrypoint 컨트랙트로 보내지고, 이 메시지는 ExecutionManager 컨트랙트의 run 함수 호출을 통해 OVM 샌드박스 환경에서 실행된다. 이러한 일련의 과정은 여기서 코드로 확인할 수 있다.

수정 3: State Manager 컨트랙트 함수 호출을 막는다.

StateManager 컨트랙트는 Optimistic geth에 존재하지 않는 특별한 컨트랙트다. 이 컨트랙트는 오직 사기 증명(fraud proof) 기간에 이더리움에 배포해 사용된다. StateManager 컨트랙트는 레이어 2 컨트랙트의 상태값을 관리하는 컨트랙트로 ExecutionManager의 ovmSLOADovmSSTORE 함수와 상호작용한다. Optimsitic geth에 StateManager 컨트랙트의 주소는 하드코딩되어 있다. Optimistic geth에서 ovmSLOAD 또는 ovmSSTORE가 StateManager 컨트랙트를 호출하면 Optimistic geth는 StateManager 컨트랙트를 호출하는 대신 데이터베이스와 직접적으로 상호작용하도록 만든다.

Optimistic geth는 geth에 옵티미스틱 롤업에 필요한 코드를 수정 및 삽입한 것이다. 옵티미스틱 롤업과 관련된 코드는 UsingOVM 조건문을 가지고 있다. 코드 변경 내역은 여기서 확인 가능하다.

수정 4: 블록 대신에 에폭(epoch)을 기반으로 가스 사용량을 제한한다.

OVM은 블록이라는 개념을 사용하지 않는다. 그렇기 때문에 OVM에는 블록 가스 리밋이 존재하지 않는다. 블록 가스 리밋은 한 블록이 최대 사용할 수 있는 가스양을 말한다. 대신에 OVM은 에폭이라고 부르는 시간 단위를 이용해 가스 소비량을 제한한다. OVM에는 한 에폭 내에서 실행된 트랜잭션들의 가스 사용량을 누적해서 저장하는 특수한 스토리지 슬롯을 갖고 있다. 만약 단일 에폭에서 가스 누적 사용량의 최대값을 넘기면 이 에폭에 남아있는 모든 트랜잭션은 revert된다. OVM에서 모든 트랜잭션은 ExecutionManager의 run 함수에서 실행되기 때문에 가스 소비량을 제한하는 것과 관련된 로직은 모두 ExecutionManager 컨트랙트에 구현되어 있다.

수정 5: 롤업 싱크 서비스

옵티미즘은 이더리움을 레이어 1으로 사용하는 레이어 2 솔루션이다. 그렇기 때문에 Optimistic geth는 이더리움에서 발생하는 이벤트들을 모니터링 해야 한다. 이를 위해 Optimistic geth에는 별도의 롤업 싱크 서비스가 존재하고, Optimistic geth가 실행될 때 함께 실행된다.

Data Availability batches

그림4. https://blog.idex.io/all-posts/o2-rollup-overview

시퀀서(sequencer)는 트랜잭션들을 롤업해서 배치(batch)로 만들고 이를 이더리움에 CTC(Canonical Transaction Chain) 컨트랙트에 제출한다. 옵티미즘의 데이터 가용성 문제는 트랜잭션을 모두 이더리움에 제출하는 것으로 해결한다. 즉, 기존의 시퀀서가 존재하지 않게 되더라도 새로운 시퀀서가 이더리움에 올라간 트랜잭션들을 실행해서 레이어 2를 다시 운영할 수 있다.

레이어 2 트랜잭션은 2가지 타입이 존재한다. 하나는 유저가 레이어 2에 직접 전송한 트랜잭션인 시퀀서 트랜잭션(sequencer transaction)이고 또 다른 하나는 큐 트랜잭션(queue transaction)이다. 큐 트랜잭션은 레이어 1의 CTC 컨트랙트의 enqueue 함수를 호출해서 만들어진다. 이렇게 만들어진 큐 트랜잭션은 모두 queue에 쌓이게 되는데, 시퀀서는 queue에 쌓인 트랜잭션을 가져와 레이어 2에서 실행한다. 큐 트랜잭션은 레이어 1에서 레이어 2로 자산을 보내거나 시퀀서의 검열 저항을 피하기 위해 사용된다.

배치는 다음과 같이 구성된다.

  • 헤더
  • 배치 컨텍스트 (≥1)
  • 트랜잭션 (≥1)
그림5. Compact batch format

헤더는 이전에 제출된 트랜잭션의 개수(shouldStartAtElement)와 제출될 트랜잭션의 개수(totalElementsToAppend), 그리고 컨텍스트의 개수(numContexts)를 포함한다.

컨텍스트는 배치에 포함된 시퀀서 트랜잭션의 개수(numSequencedTxs)와 큐 트랜잭션의 개수(numSubsequentQueueTxs) 그리고 타입스탬프(timestamp)와 블록 넘버(blocknumber)를 포함한다.

트랜잭션은 트랜잭션 헤더(txDataLength)와 트랜잭션 데이터(txData)를 포함한다. 트랜잭션 헤더는 트랜잭션 데이터의 길이 정보를 가진다.

이 배치는 모두 batch submitter 서비스가 만들어서 제출한다. 따라서 시퀀서는 배치를 제출하기 위해서 반드시 batch submitter 서비스를 운영해야 한다.

State Commitments

이더리움에서 트랜잭션을 실행하면 이더리움의 상태가 변경된다. 이 상태는 MPT(Merkle Patricia Tree) 자료 구조에 저장된다. 특정 블록에서 어떤 어카운트의 ETH가 존재하는지 검증하기 위해서는 상태 루트값과 머클 패스가 필요하다. 상태 루트값부터 머클 패스를 따라 값을 추적했을 때 해당 값이 존재하는지 확인하는 방식으로 검증을 하게 된다.

그림 6. Merkle Patricia Trie

이더리움의 블록은 여러 개의 트랜잭션을 담는다. 하지만 상태 루트 값은 블록 안의 트랜잭션이 모두 실행되고 생성된다.

만약 1000개의 트랜잭션을 담은 블록에서 988번째 트랜잭션에 사기(fraud)가 발견되면, 이를 검증하기 위해서 이전 상태를 기반으로 987개의 트랜잭션을 모두 실행하고 988번째 트랜잭션을 따로 돌려봐야 한다. 이는 매우 비효울적이다.

옵티미즘에서 사기 증명은 매우 중요하다. 옵티미즘은 각 블록(위에서 블록이란 개념은 사용하지 않는다고 했지만, 실제론 옵티미즘은 블록을 이런 식으로 사용한다.)에 단 하나의 트랜잭션만을 담는다. 이러한 블록을 옵티미즘은 마이크로 블록(microblock)이라고 부른다. 마이크로블록은 단 1개의 트랜잭션만을 담기 때문에 각 블록의 상태 루트값은 실제로 단 하나의 트랜잭션을 실행하고 만들어진 값이 된다.

옵티미즘에서 만들어진 상태 루트값들은 모두 SCC(State Commitment Chain)에 제출된다. SCC는 마이크로블록의 상태 루트값 리스트를 저장한다. CTC(트랜잭션은 모두 압축되어 이더리움에 제출된다.)와 다르게 SCC의 상태 루트값은 압축되지 않고 제출된다.

Fraud Proof

우리는 지금까지 OVM의 기본 개념과 레이어 2의 트랜잭션과 상태 루트값을 이더리움에 어떤 방식으로 제출하는지 확인했다. 지금부터는 시퀀서가 악의적인 행위를 했을 때 이를 어떻게 막는지 알아보고자 한다.

시퀀서는 다음의 3가지 행위를 한다.

  1. 유저로부터 트랜잭션들을 받는다.
  2. 트랜잭션을 롤업해서 배치로 만들고 이 배치를 Canonical Transaction Chain 컨트랙트에 제출한다.
  3. 트랜잭션 실행으로 생성된 상태 루트값을 상태 배치로 만들어 State Commitment Chain 컨트랙트에 제출한다.

시퀀서가 8개의 트랜잭션을 CTC에 제출하고 8개의 상태 루트값을 SCC에 제출했다고 가정해보자. 그로 인해 S1에서 S8로의 상태 변환이 이루어졌다.

여기서 시퀀서가 자신의 어카운트의 이더 잔액을 변경하는 악의적인 행위를 했다고 가정해보자. 이는 유효하지 않은 상태 루트값을 만들어낼 것이고 이후에 생기는 상태 루트값도 유효하지 않게 된다. 시퀀서가 제출한 데이터는 아래 그림과 같을 것이다.

옵티미즘은 검증자의 존재를 가정하고 있다. 시퀀서가 제출한 각 트랜잭션에 대해서, 검증자는 해당 트랜잭션을 다운로드하고 실행하여 상태 루트값을 계산한다. 만약 시퀀서가 제출한 상태 루트값과 검증자가 트랜잭션을 실행하여 계산한 상태 루트값이 같으면 검증자는 아무것도 하지 않는다. 하지만 현재 S4의 상태 루트값과 검증자의 상태 루트값이 다르기 때문에, 이 문제를 해결하기 위해 검증자는 T4 트랜잭션을 이더리움에서 재실행하여 S4` 상태 루트값을 새로 구한다. 이런 과정을 사기 증명이라고 한다. 사기 증명이 성공적으로 끝나면 유효하지 않은 S4 상태 루트값과 S4 이후의 모든 상태 루트값들은 삭제된다.

정리하면, 사기 증명은 S3 상태를 기반으로 T4 트랜잭션을 이더리움에서 실행하여 새로운 S4` 상태 루트값을 생성하고, 시퀀서가 제출한 유효하지 않은 S4와 S4`를 비교하여 값이 서로 다르면 S4와 S4를 뒤따르는 상태 루트값들 모두 삭제한다.

실제로 이들이 어떻게 구현되어 있는지 살펴보도록 하자.

이더리움에서 사기 증명을 하기 위해서는 ExecutionManager 컨트랙트와 SafetyChecker 컨트랙트 말고도 다른 컨트랙트가 필요하다.

그림7. The OVM in Fraud Proof mode

이전의 그림1은 OVM의 단순 실행 모드를 나타낸 것이라면 위의 그림7은 OVM의 사기 증명 모드라고 볼 수 있다. 사기 증명은 여러 단계로 이루어지는데 하나씩 살펴보도록 하자.

단계 1: 잘못된 상태 변환을 선언한다.

  1. 검증자는 FraudVerifier 컨트랙트의 initializeFraudVerification 함수를 호출하여 이전 상태 루트값(S3)과 논쟁이 있는 트랜잭션(T4)을 제공한다. 이때 제출된 S3가 실제로 StateChainCommitment 컨트랙트에 존재하는지 그리고 T4가 실제로 CanonicalTransactionChain 컨트랙트에 존재하는지 검증해줄 증거 데이터도 필요하다.
  2. StateTransitioner 컨트랙트가 StateTransitionerFactory 컨트랙트에 의해 배포된다.
  3. StateManager 컨트랙트가 StateManagerFactory 컨트랙트에 의해 배포된다. 현재 StateManager 컨트랙트에는 레이어 2의 어떠한 상태값도 가지고 있지 않다. 다음 단계에서 트랜잭션이 터치한 모든 상태값이 StateManager 컨트랙트에 업로드된다.

현재 StateTransitioner 컨트랙트의 상태는 실행 전 상태를 가진다.

단계 2: 트랜잭션이 실행되기 전의 상태를 업로드한다.

예를 들어, 논쟁 트랜잭션이 ERC20 토큰을 전송하는 트랜잭션이라고 가정해보자. 그럼 다음과 같은 절차를 밟게 된다.

  1. ERC20 토큰 컨트랙트를 레이어 1 체인인 이더리움에 배포한다. 배포하려는 컨트랙트의 바이트코드는 레이어 2의 배포된 바이트코드와 같아야 한다.
  2. proveContractState 함수를 호출한다. 이 함수는 새로 배포된 컨트랙트와 기존에 레이어 2의 컨트랙트를 연결하고 논쟁 트랜잭션이 터치한 모든 어카운트의 상태를 StateManager 컨트랙트에 업로드한다. 이때 업로드되는 어카운트가 실제로 레이어 2에 존재하는지 검증해줄 수 있는 머클 증거도 함께 보낸다.
  3. proveStorageSlot 함수를 호출한다. ERC20 토큰을 전송하는 트랜잭션은 송신자의 잔액을 차감하고 수신자의 잔액을 증가시킨다. 잔액 데이터는 balances라는 mapping 타입에 저장되는데 mapping 타입의 키는 keccak256(slot+address)(이더리움이 컨트랙트 스토리지를 어떻게 읽는지는 여기서 자세히 확인할 수 있다.). proveStorageSlot 함수는 송신자와 수신자의 잔액 데이터를 StateManager 컨트랙트에 업로드한다. 이때 업로드되는 스토리지도 어카운트와 마찬가지로 머클 증거를 보낸다.

정리하면, 레이어 2에 배포된 컨트랙트와 같은 바이트코드를 가진 컨트랙트를 레이어 1에 새로 배포한다. 그리고 논쟁 트랜잭션이 터치한 어카운트는 모두 proveContractState 함수를 통해, 스토리지는 모두 proveStorageSlot 함수를 통해 StateManager 컨트랙트에 업로드된다.

단계 3: 모든 상태가 등록되면 논쟁 트랜잭션을 실행한다.

StateTransitioner 컨트랙트의 applyTransaction 함수를 호출해서 논쟁 트랜잭션을 실행한다. 이 단계에서 ExecutionManager 컨트랙트는 StateManager 컨트랙트를 이용해 트랜잭션을 실행한다. 실행이 끝나면 StateTransition 컨트랙트의 상태는 실행 후 상태가 된다.

단계 4: 새로운 상태 루트값을 제공한다.

단계 3에서 트랜잭션이 실행되면 StateManager 컨트랙트의 어카운트나 스토리지 값이 변한다. 그러나 StateTransitioner와 StateManager 컨트랙트는 레이어 2의 전체 상태를 가지고 있는 것이 아니기 때문에 새로운 상태 루트값은 자동으로 계산되지 않는다. 이를 위해 옵티미즘은 커밋 함수를 이용해 상태 루트값을 구한다.

스토리지나 어카운트의 상태가 변경되면 스토리지와 어카운트는 모두 “changed”라고 마크된다. 그리고 커밋되지 않은 스토리지 또는 커밋되지 않은 어카운트의 카운터를 증가시킨다. 커밋은 commit 함수를 통해 이루어지는데 어카운트는 commitAccount 함수를 통해 스토리지는 commitStorageSlot 함수를 통해 이루어진다. 이러한 commit 함수가 호출되면 커밋되지 않은 스토리지 또는 커밋되지 않은 어카운트의 카운터가 감소하게 된다. commitStorageSlot 함수를 호출하면 스토리지 루트값이 업데이트되고 스토리지 루트값이 모두 업데이트되면(커밋되지 않은 스토리지의 카운터 값이 0이면 스토리지는 모두 커밋된 것이다.) commitAccount 함수를 통해 아카운트의 루트값도 업데이트 한다.

최종적으로 모든 스토리지와 어카운트를 모두 커밋하고 새로운 상태 루트값을 구하게 된다.

단계 5: 상태 변환을 완수하고 사기 증명을 끝낸다.

단계 4에서 상태 루트값을 구하고 나면 completeTransition 함수를 호출한다. completeTransition 함수를 호출하면 StateTransitioner 컨트랙트의 상태는 실행 후 상태가 된다.

마지막으로 FraudVerifier 컨트랙트의 finalizeFraudVerification 함수를 호출하고, 사기 증명이 성공적으로 끝났으면 deleteStateBatch 함수를 호출해서 시퀀서가 제출한 유효하지 않은 상태 루트값과 해당 상태 루트값을 뒤따르는 모든 상태 루트값을 지운다. CanonicalTransactionChain에 남아있는 트랜잭션들 데이터들은 모두 그대로 유지되고 이후에 다시 실행된다.

인센티브와 담보

옵티미즘은 시스템을 퍼블릭하게 사용할 수 있도록, 누구나 시퀀서가 되어 상태 배치를 제출할 수 있도록 StateCommitmentChain 컨트랙트를 디자인 했다. 그러나 시스템이 퍼블릭하게 사용되면 스팸 공격을 받을 수 있기 때문에 SCC 컨트랙트에 하나의 제한을 추가했다.

하나의 제한은 바로 시퀀서가 되기 위해 반드시 BondManager 컨트랙트에 담보를 맡겨야 한다는 것이다. 만약 시퀀서가 악의적인 행위를 하면 검증자에 의해 사기 증명이 이루어지고 사기 증명이 성공하면 시퀀서의 담보의 일부분은 소각되고 나머지는 검증자가 가져가게 된다.

Nuisance Gas

옵티미즘에는 사기 증명에 사용되는 가스 비용을 제한하기 위해 또 다른 차원의 가스인 “nuisance gas”를 사용한다. nuisance gas는 레이어 1에서 사기 증명에 필요한 가스의 최대 상한선이 제공된다. 논쟁 트랜잭션을 레이어 1에서 실행하기 위해서는 사전에 어카운트나 스토리지를 StateManager 컨트랙트에 제공해야 한다. 이때도 가스가 소모되는데, 레이어 2에서는 발생하지 않는 가스이기 때문에 nuisance라는 이름이 붙여진 것이다. nuisance gas는 논쟁 트랜잭션이 새로운 어카운트나 스토리지를 터치할 때마다 부과된다. 만약 허용된 nuisance gas보다 많이 사용하게 되면 revert를 발생시킨다.

마치며

옵티미즘은 이더리움의 처리량을 향상시키기 위한 솔루션으로 이더리움 커뮤니티가 만든 여러 도구들을 그대로 사용할 수 있다. 옵티미즘은 7월 출시 예정이며, 옵티미즘이 출시되면 옵티미즘을 기반으로 한 새로운 유즈케이스와 서비스가 탄생할 수 있을 것이다.

Reference

--

--