코어 이더리움 프로그래밍 - 스마트 컨트랙트 프로그래밍

jhcha·2023년 11월 15일
0

스마트 컨트랙트

1996년 닉 사보 (Nick Szabo)가 제안한 "Smart Contracts: Building Blocks for Digital Free Markets" 이라는 논문에서 스마트 컨트랙트의 개념이 생겼다. 이 논문에서 사보는 전자적인 커머스 프로토콜을 이용해서 인터넷에서 서로 모르는 사람들끼리 계약 (Contract Law)을 만들고 계약과 관련된 비즈니스 프랙티스 (business Practice)를 만드는 방법을 기술했고, 이 방법을 스마트 컨트랙트 (Smart Contract)라고 불렀다.

스마트 컨트랙트는 계약 참가자들이 정의된 약속들을 수행하는 프로토콜을 포함하는 프로그램이다. 스마트 컨트랙트는 사람의 개입 없이 프로그램으로 명시된 내용에 따라 계약을 집행하기 때문에 계약 당사자 간에 분쟁이 발생하기 어렵다.

이더리움 가상 머신 (EVM: Ethereum Virtual Machine)

EVM은 이더리움에서 스마트 컨트랙트를 실행하기 위한 실행 환경을 제공한다. 스마트 컨트랙트는 EVM에서 실행 가능한 바이트 코드로 컴파일되고 블록체인에 저장된다.

EVM 구조

  • 스택 (Stack) 영역: 연산에 필요한 데이터를 저장하기 위한 공간으로, 256비트 크기로 값들이 저장된다.
  • 콜 데이터 (Call Data) 영역: 이더리움에 트랜잭션을 요청했을 때 전송되는 데이터들이 기록되는 저장 공간
  • 스토리지 (Storage) 영역: 블록체인에 영구적으로 기록되는 저장 공간
  • 메모리 (Memory) 영역: 함수를 호출하거나 메모리 연산을 수행할 때 임시로 사용되는 영역

#1 예제 - Greeter

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Greeter { 
    function sayHello() public pure returns (string memory) { 
        return "Hello?"; 
    } 
} 
  • 함수 가시성: 예제 코드에서 public에 해당하는 키워드가 함수 가시성에 해당한다. 함수 가시성은 4가지 형태를 가지고 있다.
    • public: 컨트랙트의 내부, 외부에서 접근할 수 있다.
    • external: 컨트랙트의 내부에서 접근할 수 없고, 외부에서만 접근할 수 있다.
    • internal: 컨트랙트의 내부에서 접근할 수 있고, 상속 컨트랙트에서도 접근할 수 있다.
    • private: 컨트랙트의 내부에서만 접근할 수 있다.
  • 상태 변경성 (state mutability): 예제 코드에서 pure에 해당하는 키워드가 상태 변경성에 해당한다. 상태 변경성은 해당 함수 내에서 기능을 제한하기 위한 키워드이다.
    • view: 함수 내부에서 어떤 상태도 변경하지 못하도록 제한한다.
    function addToX(uint y) public view returns (uint) {
          // x = 1; 상태 변수를 수정하지 못한다.
          return x + y;
    }
    • pure: 함수 내부에서 컨트랙트의 상태 변수를 읽거나 수정하지 못하도록 제한한다.
    function addToX(uint y) public view returns (uint) {
          // x = 1; 상태 변수를 읽거나 수정하지 못한다. 
          return x + y;
    }
  • 함수 반환: returns (string: 자료형 memory: 데이터위치), solidity 언어는 returns 키워드와 반환할 매개변수 정보를 명시해서 사용한다.

상태 변경성은 함수 제어자 (function modifier)라고도 하는데, 책에서 상태 변경성 (state mutability) 라는 단어를 사용했다. 아래와 같이 에러를 발생해서 확인해보니 에러 메세지에 state mutability 라고 나오는 것을 확인했다.

#2 예제 - Greeter 응용

pragma solidity ^0.8.20;

contract GreeterContract { 
    mapping (uint8 => Greeting) greetingByLang;
    mapping (string => uint8) langMap;

    struct Greeting {
        string hello;
        string goodbye;
    }

    enum Lang { Korean, English }

    constructor () {
        greetingByLang[uint8(Lang.Korean)] = Greeting("annyeong?", "bye!");
        greetingByLang[uint8(Lang.English)] = Greeting({goodbye: "Goodybye!", hello: "Hello"});
        langMap["Korean"] = uint8(Lang.Korean);
        langMap["English"] = uint8(Lang.English);
    }

    function sayHello(string calldata _lang ) public view returns (string memory){
        uint8 lang = langMap[_lang];
        return greetingByLang[lang].hello;
    }
    function changeHello(string calldata _lang, string calldata _hello) public {
        uint8 lang = langMap[_lang];
        greetingByLang[lang].hello = _hello;
    }
    function sayGoodbye(string calldata _lang ) public view returns (string memory){
        uint8 lang = langMap[_lang];
        return greetingByLang[lang].goodbye;
    }
    function changeGoodbye(string calldata _lang, string calldata _goodbye) public  {
        uint8 lang = langMap[_lang];
        greetingByLang[lang].hello = _goodbye;
    }
} 

책 버전과 변경된 점은 다음과 같다.
1. contract 이름과 동일한 function을 생성할 수 없다.
2. 생성자 함수는 function 키워드 붙이지 않고 가시 지정자 선언을 할 수 없다.
3. 함수 매개변수에서 array, string, mapping과 같은 complex types 형태의 변수는 반드시 data location (storage, memory, calldata)를 명시해야 한다.

  • Data Location
    storage - 상태 변수와 동일한 의미로 블록체인에 저장되어 영속성을 갖는다.
    memory - 변수가 메모리에 존재하며 함수가 호출되는 동안에만 존재한다.
    calldata - 트랜잭션의 data field을 input data 혹은 calldata 라고 한다. calldata는 함수 호출 시 입력값에 사용되고 memory와 같이 임시 저장되는 변수지만 수정이 불가능하다.

memory와 calldata의 주요 차이점은 수정이 불가능하다. memory는 데이터에 대한 read, write가 가능하지만 calldata는 readonly 형태를 가진다. 따라서, calldata는 함수의 return 매개변수로 반환할 수 없다. 함수 매개변수로 memory를 입력받을 때 동작 과정은 다음과 같다.
1. 매개변수로 입력받은 데이터가 calldata 형태로 입력받는다.
2. memory를 만들어서 calldata의 데이터를 복사하여 저장한다.
만약, memory가 아닌 calldata로 받는다면 2번 과정인, memory를 생성해서 데이터를 복사하는 과정이 생략된다.

#3 예제 - ERC-20 Token

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract MinimumViableToken { 
    mapping (address => uint256) public balanceOf; 

    constructor (uint256 initialSupply) { 
        balanceOf[msg.sender] = initialSupply; 
    } 
    
    function transfer(address _to, uint256 _value) public { 
        balanceOf[msg.sender] -= _value; balanceOf[_to] += _value; 
    } 
}

ERC-20 Standard Method

function name() public view returns (string)
function symbol() public view returns (string)
function decimals() public view returns (uint8)
function totalSupply() public view returns (uint256)
function balanceOf(address _owner) public view returns (uint256 balance)
function transfer(address _to, uint256 _value) public returns (bool success)
function transferFrom(address _from, address _to, uint256 _value) public returns (bool success)
function approve(address _spender, uint256 _value) public returns (bool success)
function allowance(address _owner, address _spender) public view returns (uint256 remaining)

ERC-20에 명시되어 있는 기본 요구사항을 충족하면 ERC-20 호환 토큰이 된다.
ERC-20 참고자료: https://velog.io/@jhcha/Solidity-ERC-20

  • 불안전한 지갑 호환 토큰

    만약 위와 같이 transfer 함수내에서 if(..) return을 통해 데이터 정합성을 처리한다면, balanceOf[msg.sender] -= _value; 코드가 동작하고 비정상 종료될 수 있다.

마찬가지로 비정상적인 오류를 통해 return하는 경우, 단순 함수의 동작만 종료하는 것이 아닌 revert같은 트랜잭션 오류 처리를 사용해야 한다.
revert, throw는 모두 해당 트랜잭션에서 발생한 모든 상태 변경을 무효화하고 트랜잭션 시작 이전 상태로 되돌리기 위한 명령이다.

require, revert, assert를 호출하여 에러를 발생시킬 수 있다.

  • require: 실행 전에 입력과 상태를 검증한다. if 조건문과 유사하고, 거짓인 경우 error 반환
	function testRequire(uint _i) public pure {
        // 입력 값이 10 이하인 경우, 에러 메세지를 반환하고 종료
        require(_i > 10, "Input must be greater than 10");
    }
  • revert: 에러 메세지를 출력 후 종료하는 반환문과 유사하다.
    function testRevert(uint _i) public pure {
        // 입력 값이 10 이하인 경우
        if (_i <= 10) {
        	//에러 메세지를 반환하고 종료
            revert("Input must be greater than 10");
        }
    }
  • assert: assert는 거짓이 되어서는 안 되는 코드를 확인하는 데 사용된다.
    function multiple(uint num1, uint num2) public pure returns (uint) {
        require(num1 % 2 == 0 || num2 % 2 == 0, "input error");
        uint result = num1 * num2;
        assert(result % 2 == 0);
        return result;
    }

require, revert는 오류 발생 시 가스를 사용하지 않고 종료된다. 하지만, assert는 가스를 모두 소모한 후 종료된다.

#4 예제 - 크라우드 펀드 DAO

영화 제작을 위한 크라우드 펀드 컨트랙트 요구사항은 다음과 같다.

  • 총 모금액은 500이더이고, 500이더가 넘으면 청약을 받지 않는다.
  • 마지막 청약자가 청약한 금액이 500이더를 넘으면, 초과한 금액을 마지막 청약자가 찾을 수 있다.
  • 모금 기간은 분 단위로 지정한다.
  • EOA가 펀드에 청약하면 청약한 금액에 해당하는 증표로 토큰을 발행한다.
  • 청약 금액과 토큰 비율은 1:1로 한다.
  • 모금 기간 안에 모금 목표 금액을 달성하면 영화 제작사에게 모금 금액을 모두 송금한다.
  • 모금 기간이 지나도 목표 금액이 달성되지 않으면 청약자들에게 청약 금액을 모두 반환한다.
    보상 토큰 정보는 다음과 같다.
  • Token name: Crowd Fund Token
  • Token symbol: CFTKs
  • Decimal units: 0
  • Initial supply: 1000
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface token {
    function transfer(address receiver, uint amount) external;
}

contract CrowdFundContract {

    address public beneficiary;
    uint public fundingGoal;
    uint public amountRaised;
    uint public deadline;
    uint public price;
    token public tokenReward;
    mapping(address => uint256) public balanceOf;
    bool public fundingGoalReached = false;
    bool public crowdsaleClosed = false;

    event GoalReached(address beneficiaryAddress, uint amountRaisedValue);
    event FundTransfer(address backer, uint amount, bool isContribution);
    constructor (
        address ifSuccessfulSendTo,
        uint fundingGoalInEthers,
        uint durationInMinutes,
        uint etherCostOfEachToken,
        address addressOfTokenUsedAsReward
    ) {
        beneficiary = ifSuccessfulSendTo;
        fundingGoal = fundingGoalInEthers * 1 ether;
        deadline = block.timestamp + durationInMinutes * 1 minutes;
        price = etherCostOfEachToken * 1 ether;
        tokenReward = token(addressOfTokenUsedAsReward);
    }

    fallback () external payable{
        require(!crowdsaleClosed);
        uint amount = msg.value;
        balanceOf[msg.sender] += amount;
        amountRaised += amount;
        tokenReward.transfer(msg.sender, amount / price);
        emit FundTransfer(msg.sender, amount, true);
    }

    modifier afterDeadline() { if (block.timestamp >= deadline) _; }

    function checkGoalReached() external afterDeadline {
        if (amountRaised >= fundingGoal){ 
            fundingGoalReached = true;
            emit GoalReached(beneficiary, amountRaised);
        }
        crowdsaleClosed = true;
    }

    function safeWithdrawal() external afterDeadline {
        if (!fundingGoalReached) {
            uint amount = balanceOf[msg.sender];
            balanceOf[msg.sender] = 0;
            if (amount > 0) {
                if (payable(msg.sender).send(amount)) {
                    emit FundTransfer(msg.sender, amount, false);
                } else {
                    balanceOf[msg.sender] = amount;
                }
            }
        }
        if (fundingGoalReached && beneficiary == msg.sender) {
            if (payable(beneficiary).send(amountRaised)) {
                emit FundTransfer(beneficiary, amountRaised, false);
            } else {
                fundingGoalReached = false;
            }
        }
    }
}
  • 변경사항
  1. now 대신, block.timestamp로 변경
  2. 함수명을 지정하지 않은 함수, fallback 함수는 function 대신 fallback 키워드로 선언해야 한다. (function () => fallback)
  3. interface는 실제 구현한 함수 가시성과 동일하게 작성해야 한다. (external 추가)
  4. payable address로 선언되지 않은 address 타입은 value를 보낼 수 없다. 따라서, payable address로 형변환이 필요하다. (msg.sender.send(amount) => payable(msg.sender).send(amount))
  5. 이벤트 발생시키기 위해서 emit 키워드를 사용해야 한다. (emit FundTransfer(...))
  • 추가적으로 fallback 함수만 구현시, solidity 컴파일러는 컨트랙트 내에 receive 함수를 생성하는 것을 권장하고 있다.
  • fallback 함수와 receive 함수의 사용은 다음과 같다.
    //            is msg.data empty?
    //            /                \
    //           yes                no
    //          /                    \
    //  receive() exists?        is the function selector fundMe()?
    //      /      \                    /      \
    //    yes       no                 no      yes
    //    /          \                /          \
    // receive()     fallback() exists?         fundMe()
    //                  /       \ 
    //                 yes       no
    //                /           \ 
    //          fallback()    transaction is reverted

메세지 없이 value만 전송하는 경우 => receive
메세지와 value를 같이 전송하는 경우 => payable function
fallback은 payable function이 없거나, receive가 없는 경우 default payable function으로 기능한다.

참고자료: 박재현, 오재훈, 박혜영. 『코어 이더리움 프로그래밍』. 제이펍(2020). p161-276.

0개의 댓글