main-logo

스마트 컨트랙트는 뭘로 만들어요?

ERC20 코드로 배워보는 Solidity 기본 문법

profile
doworld
2024년 01월 20일 · 0 분 소요

들어가며

블록체인과 스마트 컨트랙트의 세계에 입문하며 스마트 컨트랙트는 뭘로 어떻게 만드는지 알아보려고 해요.

스마트 컨트랙트가 무엇인지는 블로그에 올라온 글이 있으니 참고 부탁드려요.

 

Solidity란?

솔리디티는 이더리움 블록체인에서 스마트 컨트랙트를 개발하기 위해 특별히 설계된 프로그래밍 언어입니다.

자바스크립트, C++, 파이썬과 같은 언어들의 영향을 받은 고수준, 정적 타입의 객체지향 언어로서 정수, 문자열, 불리언, 배열 등 다양한 데이터 타입과 if-else, 반복문, switch 등의 제어 구조를 지원하며, 상속, 다형성, 캡슐화 등의 객체지향 프로그래밍 특성을 갖고 있습니다. 
솔리디티로 작성된 컨트랙트는 바이트 코드로 컴파일 되어 이더리움 블록체인에 배포되어 실행됩니다.

솔리디티에 영향을 준 언어들을 학습해 본 적이 있는 분들은 아마도 대략적으로 코드를 읽을 수 있을 것 같아요.
이 솔리디티 코드는 간단한 ERC20 토큰 컨트랙트인 SimpleToken의 전체 코드를 보며 솔리디티의 기본 문법을 살펴보도록 해요.

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.10;

interface ERC20Interface {
    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address recipient, uint256 amount) external returns (bool);
    function approve(address spender, uint256 amount) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function transferFrom(address spender, address recipient, uint256 amount) external returns (bool);
    
    event Transfer(address indexed from, address indexed to, uint256 amount);
    event Transfer(address indexed spender, address indexed from, address indexed to, uint256 amount);
    event Approval(address indexed owner, address indexed spender, uint256 oldAmount, uint256 amount);
}

contract SimpleToken is ERC20Interface {
    mapping (address => uint256) private _balances;
    mapping (address => mapping (address => uint256)) public _allowances;

    uint256 public _totalSupply;
    string public _name;
    string public _symbol;
    uint8 public _decimals;
    
    constructor(string memory getName, string memory getSymbol) {
        _name = getName;
        _symbol = getSymbol;
        _decimals = 18;
        _totalSupply = 100000000e18;
        _balances[msg.sender] = _totalSupply;
    }
    
    function name() public view returns (string memory) {
        return _name;
    }
    
    function symbol() public view returns (string memory) {
        return _symbol;
    }
    
    function decimals() public view returns (uint8) {
        return _decimals;
    }
    
    function totalSupply() external view virtual override returns (uint256) {
        return _totalSupply;
    }
    
    function balanceOf(address account) external view virtual override returns (uint256) {
        return _balances[account];
    }
    
    function transfer(address recipient, uint amount) public virtual override returns (bool) {
        _transfer(msg.sender, recipient, amount);
        emit Transfer(msg.sender, recipient, amount);
        return true;
    }
    
    function allowance(address owner, address spender) external view override returns (uint256) {
        return _allowances[owner][spender];
    }
    
    function approve(address spender, uint amount) external virtual override returns (bool) {
        uint256 currentAllownace = _allowances[spender][msg.sender];
        require(currentAllownace >= amount, "ERC20: Transfer amount exceeds allowance");
        _approve(msg.sender, spender, currentAllownace, amount);
        return true;
    }
    
    function transferFrom(address sender, address recipient, uint256 amount) external virtual override returns (bool) {
        _transfer(sender, recipient, amount);
        emit Transfer(msg.sender, sender, recipient, amount);
        uint256 currentAllowance = _allowances[sender][msg.sender];
        require(currentAllowance >= amount, "ERC20: transfer amount exceeds allowance");
        _approve(sender, msg.sender, currentAllowance, currentAllowance - amount);
        return true;
    }
    
    function _transfer(address sender, address recipient, uint256 amount) internal virtual {
        require(sender != address(0), "ERC20: transfer from the zero address");
        require(recipient != address(0), "ERC20: transfer to the zero address");
        uint256 senderBalance = _balances[sender];
        require(senderBalance >= amount, "ERC20: transfer amount exceeds balance");
        _balances[sender] = senderBalance - amount;
        _balances[recipient] += amount;
    }
    
    function _approve(address owner, address spender, uint256 currentAmount, uint256 amount) internal virtual {
        require(owner != address(0), "ERC20: approve from the zero address");
        require(spender != address(0), "ERC20: approve to the zero address");
        require(currentAmount == _allowances[owner][spender], "ERC20: invalid currentAmount");
        _allowances[owner][spender] = amount;
        emit Approval(owner, spender, currentAmount, amount);
    }
}

 

라이선스 및 프라그마 설정

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.10;

컨트랙트의 라이선스를 명시하고, 솔리디티 컴파일러 버전을 지정합니다.

인터페이스 정의

interface ERC20Interface {
	// ...
}

ERC20 토큰 표준에 따라 정의된 메서드와 이벤트를 포함하는 인터페이스입니다.
토큰 관련 작업인 이체, 허용량 및 승인과 관련된 기능을 지정해요.
인터페이스 자체는 어떤 작업을 수행하는 방법을 정의하지는 않아요.

컨트랙트 정의

contract SimpleToken is ERC20Interface {
	// ...
}

ERC20 인터페이스를 구현하는 SimpleToken 컨트랙트를 선언합니다.

  • is : 상속을 나타내는 키워드로, 현재 컨트랙트가 ERC20Interface를 상속한다는 것을 의미합니다. 이는 ERC20 표준의 메서드와 이벤트를 현재 컨트랙트에서 구현하겠다는 의미입니다.
  • 주소별 잔액 및 허용량을 저장하기 위해 매핑을 사용합니다.
  • 공개변수로는 총 공급량, 이름, 심볼, 소수 자릿수가 포함됩니다.
  • 생성자는 토큰을 주어진 이름과 심볼로 초기화하고, 소수 자릿수를 18로 설정하며, 총 공급량을 컨트랙트 배포자의 주소에 할당합니다.
  • name(), symbol(), decimals(), totalSupply(), balanceOf()와 같은 함수를 구현하여 토큰 정보를 검색합니다.
  • 이체 관련 함수(transfer, transferFrom) 및 승인 관련 함수(approve, _approve)를 적절한 확인 및 이벤트와 함께 구현합니다.
  • 내부 함수 _transfer는 주소 간의 토큰 이체를 처리합니다.
  • 내부 함수 _approve는 허용량의 승인을 처리합니다.
  • 토큰 이체 및 승인에 대한 알림을 위해 이벤트(Transfer, Approval)를 발생시킵니다.

변수 및 매핑 정의

mapping (address => uint256) private _balances;
mapping (address => mapping (address => uint256)) public _allowances;
uint256 public _totalSupply;
string public _name;
string public _symbol;
uint8 public _decimals;
  • mapping : 키-값 쌍을 저장하는데 사용되는 매핑 데이터 구조입니다.
  • private : _balances 매핑은 private으로 선언되어 외부에서 직접 접근 할 수 없습니다.
  • public : _allowances 매핑은 외부에서 읽기 가능하게 선언되었습니다.
  • _totalSupply, _name, _symbol, _decimals : 공개 변수로 토큰의 총 공급량 및 메타데이터 정보를 저장합니다.

기본 자료형으로 다음과 같은 것들이 있어요.

  • 정수형(int, uint)
    가장 기본적인 자료형으로 숫자 데이터 중 정수에 해당하는 값을 입력할 때 사용합니다.
    스마트 컨트랙트는 실수형 데이터는 지원하지만 자료형은 지원하지 않습니다.
    부호가 있는 정수와 없는 정수를 각각 int와 uint로 표현합니다.
    int와 uint는 각각 int256, uint256과 같은 의미입니다.
    솔리디티가 지원하는 정수형의 크기는 8비트, 16비트, 24비트, 32비트, 128비트까지 다양합니다.
  • 참거짓형(bool)
    bolean 형 또는 bool형이라 부릅니다.
    데이터로 참(true)과 거짓(false)만 가질 수 있습니다.
  • 나열형(enum)
    나열형은 개발자가 정의할 수 있는 자료형으로, 특정한 값들만 갖는 변수를 만들고 싶을 때 유용한 자료형입니다.
  • 주소형(address)
    주소는 20바이트 크기의 자료형으로 address 키워드로 선언하며 컨트랙트의 주소를 저장할 때 사용합니다. 
    40자리의 16진수 정수로 표현되고, 다양한 함수를 제공합니다.
    • balance : 해당 지갑이나 컨트랙트의 이더 잔고를 조회할 때 사용
    • transfer, send : 해당 지갑이나 컨트랙트로 이더를 송금할 때 사용
      • transfer와 send는 비슷하지만 약간 다르다. 송금 과정에서 어떠한 이유(가스비가 부족하다던가..)에 의해 송금이 실패한 경우, transfer는 즉시 오류를 발생시켜 컨트랙트 실행을 취소한다. send는 오류를 발생시키지 않고 false만 반환하며 뒤의 코드는 계속 실행한다.
  • 튜플형(Turple)
    튜플은 크기가 컴파일 전에 미리 정해진 데이터들의 묶음이라 할 수 있습니다.
    각 데이터가 다른 자료형이어도 됩니다.
    데이터들을 묶어서 쓰므로 여러 데이터가 한번에 처리되고, 코드가 보기 간결해지는 장점이 있습니다.

생성자 함수

constructor(string memory getName, string memory getSymbol) {
    _name = getName;
    _symbol = getSymbol;
    _decimals = 18;
    _totalSupply = 100000000e18;
    _balances[msg.sender] = _totalSupply;
}
  • constructor : 컨트랙트를 배포할 때 호출되는 생성자 함수입니다.
  • memory : 문자열을 처리할 때 사용되는 메모리 영역을 지정하는 키워드입니다.
    솔리디티에서는 문자열을 저장할 때 storage(상태 변수에 저장)나 memory(임시로 저장) 중 하나를 선택해야 합니다.
    함수 매개변수의 문자열은 기본적으로 memory에 저장됩니다.
  • _name, _symbol`을 생성자에서 받아 초기화 하고 기본값으로 _decimals, _totalSupply, _balances를 설정합니다.

함수 구현

function totalSupply() external view virtual override returns (uint256) {
    return _totalSupply;
}

function balanceOf(address account) external view virtual override returns (uint256) {
    return _balances[account];
}

function transfer(address recipient, uint amount) public virtual override returns (bool) {
    _transfer(msg.sender, recipient, amount);
    emit Transfer(msg.sender, recipient, amount);
    return true;
}

function allowance(address owner, address spender) external view override returns (uint256) {
    return _allowances[owner][spender];
}

function approve(address spender, uint amount) external virtual override returns (bool) {
    uint256 currentAllownace = _allowances[spender][msg.sender];
    require(currentAllownace >= amount, "ERC20: Transfer amount exceeds allowance");
    _approve(msg.sender, spender, currentAllownace, amount);
    return true;
}

function transferFrom(address sender, address recipient, uint256 amount) external virtual override returns (bool) {
    _transfer(sender, recipient, amount);
    emit Transfer(msg.sender, sender, recipient, amount);
    uint256 currentAllowance = _allowances[sender][msg.sender];
    require(currentAllowance >= amount, "ERC20: transfer amount exceeds allowance");
    _approve(sender, msg.sender, currentAllowance, currentAllowance - amount);
    return true;
}
  • view : 상태를 변경하지 않고 읽기만 하는 함수에 사용되는 키워드입니다.
  • returns : 함수가 반환하는 값의 타입을 지정합니다.
  • external : 함수가 외부에서 호출 가능한 것을 나타내는 키워드입니다.
  • virtual : 함수가 상속될 수 있다는 것을 나타내는 키워드입니다.
  • override : 부모 클래스나 인터페이스의 함수를 재정의한다는 것을 나타내는 키워드입니다.

내부 함수(Internal Function) 구현

function _transfer(address sender, address recipient, uint256 amount) internal virtual {
    require(sender != address(0), "ERC20: transfer from the zero address");
    require(recipient != address(0), "ERC20: transfer to the zero address");
    uint256 senderBalance = _balances[sender];
    require(senderBalance >= amount, "ERC20: transfer amount exceeds balance");
    _balances[sender] = senderBalance - amount;
    _balances[recipient] += amount;
}

function _approve(address owner, address spender, uint256 currentAmount, uint256 amount) internal virtual {
    require(owner != address(0), "ERC20: approve from the zero address");
    require(spender != address(0), "ERC20: approve to the zero address");
    require(currentAmount == _allowances[owner][spender], "ERC20: invalid currentAmount");
    _allowances[owner][spender] = amount;
    emit Approval(owner, spender, currentAmount, amount);
}

내부 함수(Internal Function)는 Solidity에서 사용되는 함수 중 하나로, 같은 컨트랙트 내에서만 호출할 수 있는 함수를 말합니다. 
내부 함수는 외부에서 직접 호출할 수 없으며, 주로 컨트랙트 내부에서 사용되는 보조 함수나 내부 로직을 담당하는 함수로 활용됩니다.

  • internal: 내부 함수는 기본적으로 internal 접근 제어자를 가집니다. 이는 같은 컨트랙트 내에서만 호출 가능하다는 것을 의미합니다.

이벤트 발생

event Transfer(address indexed from, address indexed to, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint256 oldAmount, uint256 amount);
  • event: 외부로 이벤트를 알리는 데 사용됩니다.

제어문 및 예외 처리

require(senderBalance >= amount, "ERC20: transfer amount exceeds balance");
require(currentAllowance >= amount, "ERC20: transfer amount exceeds allowance");
  • require: 조건이 참이 아니면 실행이 중단되고 예외가 발생합니다.

그 외 키워드들

emit Transfer(msg.sender, recipient, amount);

emit 키워드
이벤트를 발생시키는 키워드입니다. 이더리움 블록체인에서는 이벤트를 통해 외부로 데이터를 전달하거나 로그를 남길 수 있습니다.

 

_balances[msg.sender] = _totalSupply;

msg.sender
현재 함수를 호출한 주소를 나타내는 변수입니다. 즉, 컨트랙트를 배포한 계정의 주소를 나타냅니다.

 

마치며

기본적인 ERC20 토큰 컨트랙트의 코드를 통해 솔리디티의 기본 문법 및 특징을 키워드 중심으로 살펴보았어요.

내부 함수, 이벤트, 예외 처리 등 다양한 솔리디티의 기능을 활용하여 효율적이고 안전한 스마트 컨트랙트를 작성할 수 있도록 블록체인과 스마트 컨트랙트에 대한 학습을 많이 해야겠습니다.

더 많은 솔리디티 프로젝트와 경험을 통해 블록체인 개발 분야에서 함께 성장해 나가요~