ERC-20 코드 살펴보기(feat. OpenZeppelin)

ERC-20 코드 살펴보기(feat. OpenZeppelin)
본 글은 OpenZeppelin 4.x 버전을 기반으로 작성되었습니다.

ERC-20은 이더리움 블록체인 네트워크에서 정한 토큰의 표준 규격(세부적인 내용은 이전 포스팅 참고)을 의미합니다. ERC-20이 제공하는 기능 중 일부는 다음과 같습니다.

  • 한 계정에서 다른 계정으로 토큰 전송
  • 계정의 현재 잔여 토큰(잔액) 확인
  • 계정 내 토큰을 제3자가 사용할 수 있게 승인. (DEX 등 가능)

즉, 토큰에 필요한 다양한 기능의 규격을 통일하여 거래소 등에서 활용할 수 있게 만든 것이 특징입니다. 해당 내용을 준수하지 않는 이더리움 기반 토큰은 거래소에 상장될 수 없습니다.

이번 글에서는 코드를 통해 ERC-20를 살펴보도록 하겠습니다.

오픈제플린으로 살펴보는 ERC-20

오픈제플린(OpenZeppelin)은 솔리디티 기반의 스마트 컨트랙트를 개발할 수 있는 표준 프레임워크입니다. 스크래치로 ERC20 토큰을 작성할 수는 있으나, 테스트를 거친 템플릿을 사용하여 더 안전하고 효율적인 컨트랙트를 빠르게 구축할 수 있습니다.

오픈제플린에서는 ERC-20, ERC-721, ERC-1155등 다양한 토큰 표준을 제공합니다. 가장 최근 버전인 4.x 을 기반으로 살펴볼 예정이며, 전체 코드는 아래 링크에서 확인 가능합니다.

openzeppelin-contracts/contracts/token/ERC20 at master · OpenZeppelin/openzeppelin-contracts
OpenZeppelin Contracts is a library for secure smart contract development. - openzeppelin-contracts/contracts/token/ERC20 at master · OpenZeppelin/openzeppelin-contracts

오픈제플린 ERC20은 다음과 같이 코드가 구성되어 있습니다.

  • IERC20: 모든 ERC20 구현이 준수해야하는 인터페이스
  • IERC20Metadata: name, symbol, decimals 등의 설정
  • ERC20: ERC20의 실제 구현체

[OpenZeppelin] IERC20와 IERC20MetaData

IERC는 ERC-20에 필요한 함수 등 구현해야할 목록으로 생각하면 됩니다. ERC20을 위해서는 다음 함수가 구현되어야 합니다. 즉, 거래소에 상장된 토큰은 아래의 함수가 모두 구현되어 있습니다.

  • totalSupply(): 전체 공급량. 전체 발행된 토큰양 반환
  • balanceOf(account): 잔고. 특정 주소가 소유한 토큰 양 반환
  • approve(spender, amount): 승인. 다른 사람에게 토큰 사용 허용. 토큰의 양을 확인하여 거래량 제한
  • allowance(owner, spender): 허용. ownerspender에게 허락한 토큰 개수 확인
  • transfer(to, amount): 전송. 함수 호출 주소에서 개인 계정으로 송금
  • transferFrom(sender, recipient, amount): 대리 전송.approve 이후 사용자 간 송금 제공

IERC20MetaData 에는 ERC-20의 name, symbol, decimals 함수와 관련한 인터페이스입니다.

  • name() : 토큰의 이름.
  • symbol() : 토큰의 심볼. (이름의 축약어로 이해할 수 있음)
  • decimals() : 소수점 단위 설정.

일반적으로 decimal은 18로 설정합니다. 이더(ether)와 단위와 같으며 대다수 이제 표준처럼 사용하고 있습니다.

[OpenZeppelin] ERC20

ERC20 함수는 6개이지만 실제 코드에서 핵심적인 내용은 변수 3개와 함수 2개에 담겨있습니다.

우선 변수  3개는 코드와 함께 살펴보겠습니다.

mapping(address => uint256) private _balances;

mapping(address => mapping(address => uint256)) private _allowances;

uint private _totalSupply;

맵핑(mapping) 변수는 C++에서 map, Java에서 HashMap, Python에서 dict, JavaScript에서 Map 의 역할을 하는 솔리디티 변수입니다.

💡
Solidity의 mapping은 해시맵을 사용합니다. 해시에 사용하는 단방향 함수는 keccak256입니다.

_balances는 주소와 각 주소가 보유한 토큰량을 쌍으로 들고 있는 변수이며, _allowances는 주소 A의 토큰을 주소 B가 대리 사용가능하며 이때 사용 가능한 토큰량을 저장하고 있는 변수입니다. 그리고 _totalSupply는 전체 발행된 토큰량을 저장합니다.

그리고 핵심 함수 2개는 다음과 같습니다.

  • _approve(owner, spender, amount): 승인 함수
  • _transfer(from, to, amount): 전송 함수.

_approve의 함수 내부를 살펴보겠습니다.

function _approve(
        address owner,
        address spender,
        uint256 amount
    ) internal virtual {
    require(owner != address(0), "ERC20: approve from the zero address");
    require(spender != address(0), "ERC20: approve to the zero address");

    _allowances[owner][spender] = amount;
    emit Approval(owner, spender, amount);
}

코드를 한 줄 씩 순서대로 살펴보면 다음과 같습니다.

  • 보내는 주소와 받는 주소가 zero address인지 체크합니다.
  • _allowancesowner- spender 매핑에 amount 값을 갱신합니다. 즉, 토큰 소유주-토큰 가용자 매칭과 가용 토큰양을 기록해둡니다.
  • Approval 이벤트를 발생시킵니다. (로그 기록)

_transfer 함수 내부를 살펴보겠습니다.

function _transfer(
        address from,
        address to,
        uint256 amount
    ) internal virtual {
    require(from != address(0), "ERC20: transfer from the zero address");
    require(to != address(0), "ERC20: transfer to the zero address");

    _beforeTokenTransfer(from, to, amount);

    uint256 fromBalance = _balances[from];
    require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
    unchecked {
        _balances[from] = fromBalance - amount;
    }
    _balances[to] += amount;

    emit Transfer(from, to, amount);

    _afterTokenTransfer(from, to, amount);
}

코드를 한 줄 씩 순서대로 살펴보면 다음과 같습니다.

  • 보내는 주소와 받는 주소가 zero address인지 체크합니다.
  • _beforeTokenTransfer_afterTokenTransfer는 토큰 전송 이전과 이후 추가적인 내용을 작성할 수 있는 선택적 함수입니다. 생략해도 무방합니다.
  • from의 잔액을 불러옵니다.
  • 잔액이 amount보다 많은지 체크합니다.
  • from에서는 보내는 양을 빼주고, to에 그만큼 더해줍니다.
  • Transfer 이벤트를 발생시킵니다. (로그 기록)

결국 간단하게 설명하면 잔액 체크 후, 보내는 계좌에서 빼고 받는 계좌에서 더해주는 간단한 코드입니다.

이제 위 변수와 함수를 사용하면 ERC-20에 필요한 기능을 모두 구현할 수 있습니다.

  • totalSupply(): _totalSupply 변수 사용
  • balanceOf(): _balances 변수 사용
  • approve(): _approve 함수 사용
  • allowance(): _allowances 사용
  • transfer():  _transfer 사용
  • transferFrom(): (1) allowance 함수로 체크후, (2) _approve 함수 갱신, (3) _transfer 함수 사용

간단하게 적긴 했으나, 내부에는 여러 require문이 존재합니다. 내부 코드 점검에는 크게 3가지 포인트만 잊지 않으면 됩니다.

  • 잔액과 전송되는 토큰량이 음수가 되게 하지 말 것.
  • 데이터가 변경될 때, 연관되는 데이터를 모두 고려할 것.
  • 불필요한 연산은 최소화 시킬 것. (예시. _transfer에서 잔액>전송량require로 체크했으므로 마이너스 연산에 unchecked 키워드를 사용하여 연산 효율성 증가)

[OpenZeppelin] 그 외 구현

그렇다면 꼭 위에 정해진 함수만 작성해야할까요? 정답부터 말하면 그렇지 않습니다. 위 기능은 기본 기능이고, 토큰의 목적에 따라 다양한 함수를 추가적으로 구현할 수 있습니다. 오픈제플린에는 4개의 함수가 추가적으로 구현되어 있습니다.

  • increaseAllowance()/decreaseAllowance(): allowance 양 증감
  • _mint: 토큰 민팅. _totalSupply_balances[owner]에 토큰 동시 증가
  • _burn: 토큰 소각. _totalSupply_balances[owner]에 토큰 동시 감소

[OpenZeppelin] ERC20 Extensions

오픈제플린에서도 ERC-20에 여러 추가 기능을 구현한 템플릿이 일부 존재합니다.

  • ERC20Burnable: 토큰 소각과 대리 소각 가능
  • ERC20Capped: 총 공급량 한정
  • ERC20Pausable: 토큰 전송 일시 중지 가능
  • ERC20Snapshot: 과거 상태를 저장하는  스냅샷 기능
  • ERC20Votes: 투표 및 투표 위임 기능
  • ERC20VotesComp: 투표 및 투표 위임 기능(Compound와 호환)
  • ERC20Wrapper: 서로 다른 종류의 토큰으로 변환(민팅 및 소각 사용)
  • ERC20FlashMint: 플래시론 지원

ERC-20 토큰 종류

이더스캔에서 목록을 살펴볼 수 있습니다. 거래가 안되고 있는 토큰을 포함하여 940개의 토큰이 이더스캔에 잡히며, 총 54만개 정도의 내부 컨트랙트 함수가 있다고 합니다. 대표적으로 알려져 있는 토큰은 다음과 같습니다.

  • BNB(BNB)
  • Tether USD(USDT)
  • USD Coin(USDC)
  • Wrapped BTC(BTC)
  • ChainLink Token(LINK)
  • Uniswap(UNI)

그 외에도 해당 ERC20 토큰은 이더스캔에서 내부 작성된 컨트랙트를 살펴볼 수 있습니다.

BNB 토큰 예시

마치며

ERC-20을 시작으로 ERC-721, ERC-1155 등의 표준을 하나씩 살펴보고, 핵심적인 스마트 컨트랙트 사례(DeFi 등)를 살펴볼 예정입니다.

그 외에도 솔리디티와 함께 구체적으로 살펴보기 위해서는 아래 주제로 살펴볼 부분도 있는데, 기회가 되면 하나씩 다뤄보겠습니다.

  • overflow 방지를 위한 SafeMath 라이브러리
  • 접근 권한 제한과 OnlyOwner
  • ERC-223과 ERC-777
  • 각 연산 메모리 영역(storage, memory, colldata, stack)

Reference