ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Deploy Smart Contract 원리 및 Opcode 해석 : Creation Code & Runtime Code
    evm 2024. 2. 7. 22:53

    Author : 최원혁

     

    Intro


    Remix 또는 Hardhat 같은 프레임워크로 간단하게 스마트 컨트랙트 배포를 경험해본적이 있을 것이다. 하지만 컨트랙트 배포(Deploy)는 Low-Level 수준에서 보면 복잡하고 읽기 힘든 Opcode에 따라 동작하게된다. 이런 복잡하고 머리 아픈 저수준 바이트코드를 왜 해석해야는지 의문이 들수도 있다.

    글쓴이는 Factory Pattern에서 다양한 컨트랙트를 개발하는 요즘 트렌드를 보며 Creation Code와 Runtime Code를 저수준에서 해석할수 있는 역량이 필요하다고 생각했다. 또한, Minimal Proxy(ERC-1167)처럼 스마트컨트랙트 로직을 전부 Opcode로 구현하는 경우도 있다. 이러한 계기로 이번 주제를 통해 스마트컨트랙트 배포에 사용되는 Opcode를 하나하나 분해하여 상세히 해석해보려고 한다.

    EVM은 Stack Machine이며, LIFO(Last In, FirstOut) 원리로 작동한다. 이번 해석은 Low-Level 수준에서 Memory와 Stack 구조를 오고가며 Opcode를 해석할것이다. 만약 이런 내용에 대한 지식이 없다면 불친절한 내용이 될 수 있으니 참고바란다.

     

     

    Creation Code & Runtime Code


    EVM 기반의 스마트컨트랙트는 Solidity(또는 Yul)로 작성된 코드를 bytecode로 컴파일(변환)하여 EVM 엔진을 통해 블록체인에 배포된다. 이때 bytecode는 크게 Creation Code와 Runtime Code로 구성된다.

    • Runtime Code : 스마트컨트랙트의 함수 동작에 사용되는 코드
      Runtime Code는 스마트컨트랙트의 함수 내부 로직이 bytecode 변환된 코드이다. 사용자가 함수를 실행시키면 해당 코드에 따라 동작하게된다.
    • Creation Code : 컨트랙트 배포(Deploy)를 수행하는 코드
      Creation Code는 Account의 code hash에 Runtime Code를 저장하는 코드이다.

    https://ethereum.org/ko/developers/docs/accounts/

     

    이더리움 블록체인에서 주소(address)에는 위 이미지와 같이 4가지 필드가 존재한다. Runtime Code는 컨트랙트 배포 시 Creation Code에 의해 스마트컨트랙트의 code hash라는 공간에 저장된다. code hash에 저장되는 Runtime Code는 최초로 저장되는 순간 바꿀수없게된다.

    Creation Code는 Solidity의 생성자 함수(constructor) 로직도 수행한다. 때문에 Init Code라고도 불린다. 여기서 Creation Codecode hash field에 저장되지 않는다. 이름 그대로 생성(Creation)만 수행하고 없어지게 된다.

     

    Deep Dive Into Creation Code


    // <https://gist.github.com/ajsantander/dce951a95e7608bc29d7f5deeb6e2ecf>
    pragma solidity ^0.8.0;
    
    contract BasicToken {
      
      uint256 totalSupply_;
      mapping(address => uint256) balances;
      
      constructor(uint256 _initialSupply) {
        totalSupply_ = _initialSupply;
        balances[msg.sender] = _initialSupply;
      }
    
      function totalSupply() public view returns (uint256) {
        return totalSupply_;
      }
    
      function transfer(address _to, uint256 _value) public returns (bool) {
        require(_to != address(0));
        require(_value <= balances[msg.sender]);
        balances[msg.sender] = balances[msg.sender] - _value;
        balances[_to] = balances[_to] + _value;
        return true;
      }
    
      function balanceOf(address _owner) public view returns (uint256) {
        return balances[_owner];
      }
    }
    

     

    Openzepplin에서 이미 Creation Code를 주제로 글을 발행한 블로그가 있다. 거기에서 활용한 아주 간단한 BasicToken.sol라는 코드를 참고할 예정이다. 우리들이 평소에 개발한 스마트컨트랙트가 어떤 과정을 통해 배포되는지 BasicToken.sol를 통해 알아보자.

     

    Bytecodes of BasicToken.sol


    Solidity는 사람들이 보기 편한 문법이라 저수준 코드를 수용하는 EVM 엔진이 이해하지 못한다. 때문에 우리가 작성한 스마트컨트랙트 코드는 배포되기 전, 컴파일러에 의해 Opcode로 구성된 Bytecode로 변환 된다.

    https://gist.github.com/ajsantander/03a4a183756980ef0865825bea96d6f5

     

    위 사진은 우리가 사용할 BasicToken.sol의 Bytecodes이다. 위에서 언급했던것 처럼 Creation Code와 Runtime Code로 구성되어 있다.

    Bytecodes with Creation Code & Runtime Code

     

    보기 힘든 Bytecodes는 위 사진처럼 크게 Creation Code & Runtime Code & Constructor Params 3가지로 분류할 수 있다. BasicToken.sol의 생성자 함수(constructor)는 uint256 initialSupply를 파라미터로 받고 있다. Creation Code에서 수행하는 constructor의 Params는 위 사진처럼 Bytecodes의 가장 마지막에 붙는 특징을 갖고 있다. 

    ( 참고로 0x0000000000000000000000000000000000000000000000000000000000002710는 10진수로 10000이다. )

    앞으로 다룰 “Deep Dive Into Creation Code”는 위 사진의 Creation Code(+ Constructor Params)를 심층 분석하여 Low-Level 수준의 컨트랙트 배포(Deploy) 과정을 배워볼 예정이다.

     

     

    Deep Dive Into Creation Code


    Creation Code

     

    Creation Code는 위 사진 처럼 정해진 부트스트랩(bootstrap)에 따라 단계별로 구성되어 있다. 단계별로 하나씩 알아보자.

     

    Free Memory Pointer


    • Bytescode : 6080604052
    • Mnemonic : PUSH1 80 PUSH1 40 MSTORE

     

    Memory Layout

     

    EVM의 Memory는 위 사진처럼 32 bytes 용량을 가지는 무수히 많은 Memory Block(32-byte slots)으로 나열되어 있다. Free Memory Pointer의 Bytescode 6080604052를 Opcodes로 변환하면 PUSH1 80 PUSH1 40 MSTORE가 되는데, 이를 해석하면 Memory의 0x40 offset부터 32 bytes에 해당하는 slots까지, 즉 slot 0x40 ~  slot 0x60에 데이터 0x80(128)를 저장하는 동작을 의미한다.

    EVM의 Memory는 데이터가 저장되는 Memory Block 앞에 다른 데이터가 없으면 0으로 채우는 Padding이 적용된다. 때문에 0x40 offset부터 0x80를 저장했으니, 0x40 앞에 있는 Memory Block은 전부 0으로 채워진다.

    “Free Memory Pointer”라 불리는 메모리의 처음 4개 Memory Block(0x00 ~ 0x60)은 특정 목적을 위해 미리 예약되는데, Memory에 저장되는 데이터의 시작점(Pointer)을 설정하기 위함이다. 위 사진처럼 Memory Slots는 많고 넒다. 만약 데이터가 저장되는 Slots의 Pointer를 모른다면, 어디에 데이터를 저장해뒀는지 모르기 때문에 조회(read)를 하지 못하는 참사가 일어날 수 있다. 때문에 Memory에 저장되는 Slot의 시작점에 미리 0x80라는 초기(init) 데이터를 저장해서 표시를 한것이다.

     

    Non-Payable check


    • Bytescode : 34801561001057600080fd
    • Mnemonic : CALLVALUE DUP1 ISZERO PUSH2 0x0010 JUMPI PUSH1 0x00 DUP1 REVERT

     

    생성자(constructor) 함수에 Payable를 정의하여 스마트컨트랙트 배포와 동시에 배포되는 컨트랙트로 이더를 전송할 수 있다. 하지만 Payable를 정의하지 않으면 배포단계에서 이더를 보내면 트랜잭션은 Revert된다. 이런 메커니즘이 바로 Non-Payable check” 코드에 의해 동작하게 된다.

     

    if(msg.value != 0) revert();
    

     

    “Non-Payable check”의 Bytescode 34801561001057600080fd를 개발자가 이해하기 쉬운 Solidity 코드로 변환하면 위와 같다. 트랜잭션의 msg.value에 전송하는 이더가 있으면 revert를, 없으면 constructor의 로직을 수행한다.

    이런 조건문에 따라 로직의 순서를 바꿔주는 역할은 Opcode JUMPI 덕분이다. JUMPI(c,b)는 counter(c)와 b 두가지 파라미터를 Stack의 상단 데이터로부터 순차적으로 대입받는다. 이때, b가 0이 아니면 counter에 적힌 program counter(pc)로 이동하고, 0인 경우 바로 다음 Opcode를 실행한다.

    현재 BasicToken.sol의 배포 과정에서 이더를 보내지 않기 때문에 CALLVALUE는 0을 return한다. 그리고, ISZEROCALLVALUE를 통해 Stack 상단에 1를 추가한다. 때문에, 동작 순서는 REVERT를 뛰어넘어(JUMP) JUMPDEST가 있는 Opcode로 넘어가게 된다.

    Mnemonic을 보면 PUSH2 0x0010를 통해 counter는 0x0010(16)이 된다. JUMPI는 counter 위치에 JUMPDEST가 없으면 Opcode 동작 규칙에 위배되어 트랜잭션이 실패하게 되는 특징을 갖고 있다. 때문에 “Non-Payable check”에서 JUMPI의 counter 0x0010는 다음 단계인 “Retrieve constructor parameters”의 시작점 JUMPDEST(5b)가 된다.

     

    Retrieve constructor parameters


    • Bytescode : 5b506040516020806102178339810160409081529051
    • Mnemonic : JUMPDEST POP PUSH1 0x40 MLOAD PUSH1 0x20 DUP1 PUSH2 0x0217 DUP4 CODECOPY DUP2 ADD PUSH1 0x40 SWAP1 DUP2 MSTORE SWAP1 MLOAD

     

    위에서 constructor의 Params는 Bytecodes의 가장 마지막에 붙는 특징을 갖고 있다고 했다. “Retrieve constructor parameters”는 Bytecode 마지막에 있는 constructor의 Params을 Memory로 저장하는 동작을 수행한다. Mnemonic 중에 CODECOPY가 이번 동작의 핵심 Opcode이다.

    CODECOPY(destOffset, offset, size)는 Bytescode의 일부를 Memory로 복사하는 동작을 수행하며 3가지 파라미터를 Stack의 상단으로부터 순차적으로 대입한다.

    - offsetsize는 Bytescode의 어디서부터 어디까지 복사하는지를 정의한다.

    - destOffset는 Bytescode로부터 복사한 코드를 저장하는 Memory의 offset을 의미한다.

    Mnemonic을 실행해보면, CODECOPY를 실행하기 전 Stack에는 0x80, 0x0217, 0x20가 최상단(top)부터 저장되어 있다. 이는 Bytescode의 0x0217(535)부터 size 0x20(=bytes32)만큼을 Memory의 offset 0x80부터 저장함을 의미한다. 그리고 해당 위치에는 constructor parameter가 있다.

    이러한 일련과 과정을 통해 Bytescode 최하단에 저장된 constructor의 parameters 값을 memory에 저장하게 된다.

     

    Run Constructor code


    • Bytescode : 600081815533815260016020529190912055
    • Mnemonic : PUSH1 0x00 DUP2 DUP2 SSTORECALLER DUP2 MSTORE PUSH1 0x01 PUSH1 0x20 MSTORE SWAP2 SWAP1 SWAP2 SHA3 SSTORE

     

    constructor의 parameter도 가져왔으니, “Run constructor code”에서 constructor의 코드를 실행(Run)해볼 것이다.

     

     

    BasicToken.sol의 constructor 코드를 보면 파라미터로 받은 _initialSupply totalSupply balances[msg.sender]에 저장하고 있다. Menmonic은 스마트 컨트랙트의 저장소(storage)에 데이터가 저장되는 Opcode 동작에 대한 내용이며 크게 3가지 동작으로 구분할 수 있다. 

    맥을 끊게되어 정말 죄송하지만, 이번 글에서는 스마트 컨트랙트의 저장소(storage) 저장 원리에 대한 내용을 다루지 않는다. 해당 내용은 다른 아래 포스트에서 중점적으로 다뤘으니, EVM의 저장 원리가 궁금하면 참고하면 좋을 것 같다.

    @TODO Smart Contract Storage 저장 원리

     

    Copy Runtime Code to memory


    • Bytescode : 6101d180610046600039
    • Mnemonic : PUSH2 0x01d1 DUP1 PUSH2 0x0046 PUSH1 0x00 CODECOPY

     

    컨트랙트 배포 전에 선행해야하는 constructor code까지 실행을 끝났고, 이제 본격적으로 컨트랙트 배포를 위한 과정을 수행하려고 한다.

    Bytescode는 Creation Code와 Runtime Code로 구성되어 있다고 위에서 설명했다. “Copy Runtime Code to memory”는 Bytescode에 있는 Runtime Code를 memory에 저장하는 동작을 수행한다.

    “Retrieve constructor parameters”에서 자세히 설명했듯이, Runtime Code 또한 CODECOPY Opcode를 통해 memory에 복사하여 Creation Code와 분리한다. Creation Code가 code hash field에 저장되지 않는 이유가 여기에 있었다.

     

    Return Runtime Code


    • Bytescode : 6000f300
    • Mnemonic : PUSH1 0x00 RETURN STOP

     

    RETURN(offset, size)는 memory에 저장된 데이터를 갖고와 EVM으로 전달하는 코드이다. 모든 함수는 return에 따라 로직이 종료하게 되며, 결과값을 Output으로 전달한다. 하지만 Creation Code의 return은 Output을 EVM으로 전달하여 code hash에 저장하는 동작이 실행된다.

    스마트컨트랙트 배포는 Runtime Code를 address의 code hash field에 저장하는것과 같다고 위에서 설명했다. Runtime Code를 저장하고 STOP Opcode를 통해 컨트랙트 배포를 수행하는 EVM 엔진 업무를 종료시킨다.

     

    EVM Bytecode Simulation


    https://www.evm.codes/playground?fork=shanghai&unit=Wei&codeType=Bytecode&code=%27Kq10itOogpn2178339WRtr8k2rOjW8k533w_UrUg55n1d1px46j39jf3NKZnx56X63%7E7cRvvvNj3504h631WodddWSVXp6370a08231S82Xp63ar59cbbSb0XVQq67Jxf5Yuq8eJsZ5hxfbmqbcixe1sZ5ho2435n123Ykkuj54rmshlrmjs83hkkn147y33l8211knh3i33jrwWr_tp83T5r03r55s85h83_rUT3Rr559Pk0505o0ah5627a7a723058ga5d999f4459642872aPbe93a4rX5d345e40fcUa7cccb2cfPc88bcdaf3beNPvvvL2710%27%7EMMMMzN0yXQxn0w8k2oRogvLLLu82_OrWr03ogRrf3Vto40s73%7E%7E%7E%7E%7Er90q34pkxp80o60n610m56Vljrw_trg54k15j6ziy50h16g20_52Zo043YmtpOUX57W81V5bU91Tgp548S14xR01QjpfdVP29O51N00MffLzzKopt_Jix70%01JKLMNOPQRSTUVWXYZ_ghijklmnopqrstuvwxyz%7E_

     

    지금까지 다뤄온 스마트 컨트랙트 배포 과정을 Low-Level 수준에서 알아봤. Opcode 하나 하나가 동작하는 모습과 Memory, Stack, Storage에서 input&output되는 모습을 직접 보지 않으면 이해하기 쉽지 않다. 때문에 글씬이 evm.codes에서 제공하는 UI 친화적인 기능을 통해 이해하려고 노력했기에 글의 마무리로 링크를 공유하려고 한다. 독자들도 링크를 통해 부디 Smart Contract 배포에 대해 이해할 있기를 바라며 이만 글을 마친다

     

     


     

     

    Reference


    OpenZeppelin Blog : Deconstructing a Solidity Contract — Part II: Creation vs. Runtime - OpenZeppelin blog

     

    Deconstructing a Solidity Contract — Part II: Creation vs. Runtime - OpenZeppelin blog

    Let’s get started by attacking the disassembled gibberish of our contract with our divide-and-conquer lightsaber. As we saw in the introductory article, this disassembled code is very low-level...

    blog.openzeppelin.com

     

    ethereum.org : Opcodes for the EVM | ethereum.org

     

    Opcodes for the EVM | ethereum.org

    A list of all available opcodes for the Ethereum virtual machine.

    ethereum.org

     

    ethervm.io : Ethereum Virtual Machine Opcodes

     

    Ethereum Virtual Machine Opcodes

    3D RETURNDATASIZE - size = RETURNDATASIZE() Byzantium hardfork, EIP-211: the size of the returned data from the last external call, in bytes

    ethervm.io

     

    evm.codes : EVM Codes

     

    EVM Codes

    An Ethereum Virtual Machine Opcodes Interactive Reference

    www.evm.codes

     

    Solidity Memory Layout : Solidity Tutorial: All About Memory

     

    Solidity Tutorial: All About Memory

    Understanding EVM memory

    betterprogramming.pub

     

Designed by Tistory.