‘이더리움 댑 개발’ 세미나 15-3. 오픈제플린 3

Tokens

오픈제플린은 4가지 토큰 표준을 구현하고 있습니다.

  • ERC20
    • 대체가능 토큰
  •  ERC721
    • 대체 불가 토큰
  • ERC777
    • ERC20 기능 보강, ERC20과 호환
  • ERC1155
    • 다수의 대체 가능 토큰과 대체 불가 토큰을 하나의 컨트랙트로 다루는 것을 가능하게 함. 다수의 토큰을 다루어야 하는 경우 가스 효율성을 높일 수 있음.

ERC20

ERC20은 ‘대체 가능(fungible) 토큰 구현을 위한’ 컨트랙트 작성 표준입니다.

ERC20에 대한 대표적인 구현으로 다음과 같은 것들이 있습니다. 우리가 여기서 오픈제플린 구현에 대해 살펴볼 것입니다.

 

EIP-20: ERC-20 Token Standard

  • ‘대체 가능하다’는 같은 토큰(종류)이고 같은 단위인 경우 성립합니다.
    •  ERC20은 namesymbol로 이를 지원합니다.
  • ‘대체 가능하다’는 것은 같은 토큰이고 같은 단위일 경우 같은 기능(function)을 할 수 있다는 것입니다.
    • 같은 기능을 할 수 있기 때문에 교환할 수 있는 것입니다. 주고 받을 수 있다는 것입니다.
    • 내가 가지고 있는 2달러와 남이 가지고 있는 2달러는 같은 종류의 화폐(달러)이고 같은 단위(2)로 대체 가능하기 때문에 서로 교환해도 문제가 되지 않습니다.
    • ERC20은 transfer로 이를 지원합니다.
  • ERC20은 좀 더 작은 단위로 가치를 표현할 수 있도록 합니다.
    • decimals
  • ERC20은 현재 시점을 기준으로 총공급량을 관리하도록 합니다.
    • totalSupply
  • ERC20은 계정 잔고로 토큰 소유권을 나타냅니다.
    • balanceOf
    • 다른 컨트랙트에서 토큰 컨트랙트의 토큰을 전송할 수 있도록 해야 합니다.
      • approve를 통해 누구의 토큰을 얼마만큼 전송할 수 있도록 허용할지를 설정할 수 있어야 합니다.
        • 물론 허용 주소는 컨트랙트 주소가 아닌 외부 주소여도 됩니다. 하지만 주요한 목적이 다른 컨트랙트에서 토큰 컨트랙트의 토큰 전송을 위한 것이라는 점을 기억해 둡니다.
      • 전송이 허용된 수량이 얼마인지를 알 수 있어야 합니다.
        • allowance
      • transfer와 구분하기 위해 transferFrom 함수를 지원합니다.
  • ERC20은 이벤트로 중요한 행위 실행에 대한 결과를 통지합니다.
    • transfer – Transfer 
    • approve – Approval

오픈제플린 ERC20 구현

  • openzeppelin-contracts/contracts/token/erc20/에 위치합니다. 
  • ERC20 구현을 위한 인터페이스, 컨트랙트, 유틸리티들을 포함합니다.
    • IERC20
      • ERC20에 대한 솔리디티 인터페이스 구현
        • ERC20에는 name, symbol, decimals에 대한 접근 함수는 선택적으로 명세되어 있습니다.
    • ERC20
      • ERC20에 대한 솔리디티 컨트랙트 구현
      • IERC20을 구현(상속)
    • ERC20Snapshot, ERC20Burnable, ERC20Capped, ERC20Pausable
      • ERC20에 추가적인 기능을 확장한 것들
    •  SafeERC20, TokenTimelock
      • 다양한 방법으로 ERC20과 상호작용하기 위한 유틸리티들
ERC20.sol
  • // SPDX-License-Identifier: MIT
    • MIT 라이센스
  • pragma solidity >=0.6.0 <0.8.0;
    • 솔리디티 0.6.0에서 0.7.x 버전까지 지원
  • import “../../GSN/Context.sol”;
    import “./IERC20.sol”;
    contract ERC20 is Context, IERC20
  • Context를 확장하고, IERC20 인터페이스를 구현

 

  • import “../../math/SafeMath.sol”;
    using SafeMath for uint256;

 

  • unit256에 SafeMath 라이브러리 attach

 

  • mapping (address => uint256) private _balances;
    • 어떤 계정이 얼마만큼의 잔고를 갖고 있는지를 매핑
  • mapping (address => mapping (address => uint256)) private _allowances;
    • 어떤 계정에 전송이 허용된 계정 별 수량 매핑
  • uint256 private _totalSupply;
    • 현재 시점에서의 총 공급량을 관리
  • string private _name;
    string private _symbol;
    uint8 private _decimals;

 

  • 토큰 정의에 해당하는 토큰 이름, 심볼, 소숫점 자리수를 관리
  • constructor (string memory name_, string memory symbol_) public
    • 생성자
    • _name, _symbol을 인자로 받아 설정
    • _decimals는 18로 설정
      • 이더리움 소숫점 자리수와 같은 18을 사용하는 것이 기본
        • function _setupDecimals(uint8 decimals_) internal
          • 다른 소숫점 자리수를 사용하고자 할 때 ERC20 컨트랙트를 상속받는 컨트랙트 생성자에서 호출합니다.
  • name, symbol, decimals, totalSupply
    • _name, _symbol, _decimals에 대한 get 함수
  • balanceOf, allowance
    • 잔고와 전송 허용 수량에 대한 get 함수
  • _transfer
    • transfer와 transferFrom의 공통적인 로직을 분리한 것
    • 전송자와 수신자는 0주소가 아니어야 합니다.
    • 전송 전에 처리해야 하는 일을 재정의할 수 있도록 합니다.
      • _beforeTokenTransfer
        • 재정의 가능하도록 virtual로 선언
    • 토큰 전송은 실제적으로는 송신자 잔고에서 전송 수량만큼 빼고, 수신자 잔고에 전송 수량만큼 더하는 것입니다.
      • _balances[sender] = _balances[sender].sub(amount, “ERC20: transfer amount exceeds balance”);
        _balances[recipient] = _balances[recipient].add(amount);
    • Transfer 이벤트를 발생시킵니다.
      • emit Transfer(sender, recipient, amount);
  • transfer
    • _transfer(_msgSender(), recipient, amount);
      • 오픈제플린은 가스수수료 대납이 가능하므로 msg.sender가 실제 transfer하는 sender와 다를 수 있습니다.
        • 직접적으로 msg.sender를 사용하지 말고 _msgSender()를 호출하도록 합니다.
  • _approve
    • approve와 transferFrom의 공통적인 로직을 분리한 것
      • 토큰 소유자와 토큰 전송을 허락받는 주소는 0주소가 아니어야 합니다.
    • 토큰 소유자가 허락한 주소에 대한 전송 가능한 수량을 설정합니다.
      • _allowances[owner][spender] = amount;
    • Approval 이벤트를 발생시킵니다.
      • emit Approval(owner, spender, amount);
  • transferFrom
    • msg.sender에게 허용한 만큼의 수량만 전송이 가능합니다.
    • 실제적인 전송은 msg.sender가 아니라 토큰 소유자가 sender가 됩니다.
        • 토큰 전송이 허용되었는지를 확인하고 전송하는 방식이 아니라, 전송하고 승인하는 방법으로 구현했습니다.
          • 전송 후에 변경된 승인 수량을 이벤트로 통지 받을 수 있습니다.
  • approve
    • _approve(_msgSender(), spender, amount);
  • increaseAllowance, decreaseAllowance
    • 허용 수량을 늘리거나 줄이는 함수
    • ERC20 표준은 아님
  • _mint
    • 토큰 총 공급량을 늘리기 위해 추가적으로 토큰을 신규 발행하는 함수로 ERC20 표준은 아닙니다.
    • 토큰을 받는 주소는 0주소가 아니어야 합니다.
    • 토큰을 신규로 발행한다는 것은 총 공급량 수량과 토큰을 받을 주소의 잔고를 발행 수량만큼 늘리는 것을 의미합니다.
    • _totalSupply = _totalSupply.add(amount);
    • _balances[account] = _balances[account].add(amount);
    • 토큰 신규 발행은 결국 0주소에서 토큰을 받을 계정으로 토큰을 전송한다는 의미를 갖습니다.
      • _beforeTokenTransfer(address(0), account, amount);
      • emit Transfer(address(0), account, amount);
  • _burn
    • 토큰 총 공급량을 줄이기 위해 토큰을 소각하는 함수로 ERC20 표준은 아닙니다.
    • 소각 주소는 0주소가 아니어야 합니다.
    • 소각한다는 것은 총 공급량 수량과 소각 주소의 잔고를 소각 수량만큼 줄이는 것을 의미합니다.
      • _balances[account] = _balances[account].sub(amount, “ERC20: burn amount exceeds balance”);
        _totalSupply = _totalSupply.sub(amount);
    • 토큰 소각은 결국 소각 주소에서 0주소로 토큰을 전송한다는 의미를 갖습니다.
      • _beforeTokenTransfer(account, address(0), amount);
        emit Transfer(account, address(0), amount);

ERC721

ERC721은 ‘대체불가능(non-fungible) 토큰(NFT) 구현을 위한’ 컨트랙트 작성 표준입니다.

대체불가능하다는 것은 ‘고유함’의 의미를 내포하고 있습니다. 토큰 하나 하나가 고유하기 때문에 대체 불가능한 것입니다. 각각이 고유하기 때문에 구별가능하다(distinguishable)고도 합니다.

NFT는 디지털 또는 물리적 자산의 소유권을 나타내는데 주로 사용하기 때문에 증서(deeds)라고 하기도 합니다.

 

ERC721은 EIP721로 제안되었습니다.

EIP721은 ERC721 토큰이 다음과 같은 인터페이스를 구현해야 한다고 명세하고 있습니다.

  • ERC721
    • ERC721 토큰은 고유하다.
      • 고유한 id를 갖는다.
        • tokenId로 토큰에 대한 소유자를 구할 수 있어야 한다.
          • function ownerOf(uint256 _tokenId) external view returns (address);
    • 소유자의 토큰 잔고를 알 수 있어야 한다.
      • function balanceOf(address _owner) external view returns (uint256);
    • 토큰 전송이 가능해야 합니다. 전송할 토큰은 tokenId로 명시합니다.
      • function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
    •  수신자가 ERC721 토큰을 받아 처리할 수 있어야 합니다. 수신자가 ERC721TokenReceiver 인터페이스를 구현했는지를 확인할 수 있다면 좀 더 안전한 전송이 가능합니다.
      • function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
      • function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
    • ERC721TokenReceiver 인터페이스
      • function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data) external returns(bytes4);
    • 토큰 전송을 위임할 수 있어야 합니다. 소유자의 개별 토큰 또는 모든 토큰에 대한 위임이 가능해야 합니다.
      • function approve(address _approved, uint256 _tokenId) external payable;
      • function setApprovalForAll(address _operator, bool _approved) external;
      • function getApproved(uint256 _tokenId) external view returns (address);
      • function isApprovedForAll(address _owner, address _operator) external view returns (bool);
  • ERC165
    • 컨트랙트가 특정 함수를 지원하는지를 알 수 있도록 하는 표준입니다.
      • function supportsInterface(bytes4 interfaceID) external view returns (bool);
  • ERC721Metadata(선택)
    • NFT 토큰의 이름이나 심볼 또는 자산에 대한 세부정보를 관리할 수 있어야 합니다.
      • function name() external view returns (string _name);
      • function symbol() external view returns (string _symbol);
      • function tokenURI(uint256 _tokenId) external view returns (string);
  • ERC721Enumerable(선택)
    • function totalSupply() external view returns (uint256);
    • function tokenByIndex(uint256 _index) external view returns (uint256);
    • function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256);

 

오픈제플린 ERC721 구현 – 오픈제플린 컨트랙트 코드

  • ERC721 컨트랙트는 ERC165, ERC721, ERC721Metadata, ERC721Enumerable을 구현합니다.
    • contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable
      • 오픈제플린은 인터페이스에 접두어 “I”를 붙여 컨트랙트와 구분합니다.
        • IERC721, IERC721Metadata, IERC721Enumerable, IERC721Receiver
      • 가스 대납 기능이 가능하도록 Context를 상속받습니다.
      • ERC165
        • IERC165를 구현합니다.
          • abstract contract ERC165 is IERC165
        • 지원하는 인터페이스를 관리할 수 있어야 합니다.
          • mapping(bytes4 => bool) private _supportedInterfaces;
          • 생성자에서 지원하는 인터페이스의 함수 선택자(selector)를 등록합니다.
            • 함수 선택자를  구하는 예)
              • bytes4(keccak256(‘supportsInterface(bytes4)’))
            • _registerInterface를 사용해서 등록합니다.
        • 인터페이스 지원 여부를 확인할 수 있어야 합니다.
          • supportsInterface
      • 생성자에서 ERC721 지원 인터페이스들을 등록합니다.
        • 지원 인터페이스에 대한 id는 인터페이스에 포함한 함수들에 대한 selector를 xor해서 구합니다.
  • IERC721Enumerable 함수들을 구현합니다.
    • 보유한 토큰들을 열거할 수 있어야 합니다.
      • 소유자를 중심으로 보유한 토큰을 알 수 있어야 합니다.
        • 소유자를 기준으로 보유한 토큰을 매핑해야 합니다.
          • mapping (address => EnumerableSet.UintSet) private _holderTokens;
            • EnumerableSet 라이브러리를 사용합니다.
              • import “../../utils/EnumerableSet.sol”;
            • EnumerableSet.UintSet을 결합합니다.
              • using EnumerableSet for EnumerableSet.UintSet;
        • 소유자를 기준으로 id에 해당하는 토큰을 구할 수 있어야 합니다.
          • tokenOfOwnerByIndex를 구현합니다.
            • return _holderTokens[owner].at(index);
      • 토큰 id를 중심으로 토큰 소유자와 토큰을 구할 수 있어야 합니다.
        • 토큰 id에 소유자 주소를 열거 가능한 형태로 매핑합니다.
          • EnumerableMap.UintToAddressMap private _tokenOwners;
            • EnumerableMap 라이브러리를 사용합니다.
              • import “../../utils/EnumerableMap.sol”;
              • EnumerableMap .UintToAddressMap을 결합합니다.
                • using EnumerableMap for EnumerableMap.UintToAddressMap;
        • totalSupply를 구현합니다.
          • return _tokenOwners.length();
        • tokenByIndex를 구현합니다.
          • (uint256 tokenId, ) = _tokenOwners.at(index);
  • balanceOf를 구현합니다.
    • 소유자를 기준으로 구해야 함으로 _holderTokens를 사용합니다.
      • return _holderTokens[owner].length();
  • ownerOf를 구현합니다.
    • 토큰 id를 기준으로 해야 함으로 _tokenOwners를 사용합니다.
    • return _tokenOwners.get(tokenId, “ERC721: owner query for nonexistent token”);
  • 토큰 신규 발행이나 소각이 가능해야 합니다.
    • 상속받는 컨트랙트에서 호출하는 것을 전제로 합니다.
      • _mint
        • 수신자에 해당하는 to 주소는 0주소가 아니어야 하고, 토큰 id는 중복되어서는 안 됩니다.
          • require(to != address(0), “ERC721: mint to the zero address”);
          • require(!_exists(tokenId), “ERC721: token already minted”);
            • 토큰이 존재하는지를 확인합니다.
              • _exists
        • 신규 발행은 새로 발행한 토큰을 수신자에게 전송하는 것입니다.
          • 전송 전에 처리해야 하는 일을 위해 _beforeTokenTransfer를 hook으로 작성합니다.
          • Transfer 이벤트를 발생시킵니다.
        • _holderTokens와 _tokenOwners에 토큰을 추가합니다.
          • _holderTokens[to].add(tokenId);
          • _tokenOwners.set(tokenId, to);
      • _burn
        • tokenId에 해당하는 토큰 소유자와 토큰을 구합니다.
        • _beforeTokenTransfer를 호출합니다.
        • 전송 대행으로 승인되어 있는 부분을 제거합니다.
          • _approve(address(0), tokenId);
        • tokenURI 설정 부분을 제거합니다.
        • _holderTokens와 _tokenOwners에서 토큰을 제거합니다.
        • Transfer 이벤트를 발생시킵니다.
    • ERC721 토큰을 다룰 수 있는 지를 먼저 확인하는 것을 권장합니다.
      • _safeMint
        • _checkOnERC721Received
          • 수신자(to)가 컨트랙트이어야 합니다.
            • Address 라이브러리의 isContract 함수 사용해서 확인합니다.
          • 수신자가 IERC721Receiver 인터페이스를 구현하고 있어야 합니다.
            • Address 라이브러리의 functionCall을 호출하고 그 리턴 값을 사용해서 인터페이스 구현 여부를 확인합니다.
              • abi.encodeWithSelector로 functionCall 인자로 넘겨줄 data를 생성합니다.
                • abi.encodeWithSelector(IERC721Receiver(to).onERC721Received.selector, _msgSender(), from, tokenId, _data)
              • 리턴 값의 4바이트를 디코딩하고 이것이 _ERC721_RECEIVED와 같은지 비교합니다. 같으면 IERC721Receiver 인터페이스를 구현하고 있는 것입니다.
                • bytes4 retval = abi.decode(returndata, (bytes4));
                  return (retval == _ERC721_RECEIVED);
  • bytes4 private constant _ERC721_RECEIVED = 0x150b7a02;
    • 0x150b7a02
      • bytes4(keccak256(“onERC721Received(address,address,uint256,bytes)”))
  • 토큰 전송이 가능해야 합니다.
    • transferFrom
    • 안전한 전송을 권장합니다.
      • 토큰을 전송하려면 msg.sender가 소유자이거나 전송 대행자로 설정되어 있어야 합니다.
        • require(_isApprovedOrOwner(_msgSender(), tokenId), “ERC721: transfer caller is not owner nor approved”);
          • _isApprovedOrOwner
            • 토큰 전송자(spender)가 owner이거나 tokenId에 대해 전송이 승인되었거나 owner에 대해 spender가 모든 토큰 전송 대행자로 설정되어 있으면 true를 리턴합니다.
              • return (spender == owner || getApproved(tokenId) == spender || isApprovedForAll(owner, spender));
      • ERC721 토큰을 다룰 수 있어야 합니다.
        • safeTransferFrom
          • _checkOnERC721Received
    • 실제적인 전송은 _transfer로 구현됩니다.
      • 전송자(from)가 토큰 소유자이어야 합니다. 수신자(to)는 0주소가 아니어야 합니다.
        • require(ownerOf(tokenId) == from, “ERC721: transfer of token that is not own”);
        • require(to != address(0), “ERC721: transfer to the zero address”);
      • _beforeTokenTransfer를 호출합니다.
      • 전송 대행으로 승인되어 있는 부분을 제거합니다.
      • _holderTokens와 _tokenOwners에서 토큰을 제거합니다.
      • _holderTokens[from]에서 토큰을 제거하고, _holderTokens[to]에 토큰을 추가합니다.
      • _tokenOwners에 토큰 id로 to를 설정합니다.
      • Transfer 이벤트를 발생시킵니다.
  • 토큰 전송 대행이 가능해야 합니다.
    • 토큰 별로 승인이 가능해야 합니다.
      • approve
        • 토큰 소유자와 승인 대상자(to)가 달라야 합니다.
          • address owner = ownerOf(tokenId);
            require(to != owner, “ERC721: approval to current owner”);
        • owner가 msg.sender이거나 msg.sender에 의해 모든 토큰 전송이 허용되어 있어야 합니다.
          • require(_msgSender() == owner || isApprovedForAll(owner, _msgSender()), “ERC721: approve caller is not owner nor approved for all”);
        • 실제적인 승인은 _approve 함수로 실행됩니다.
          • 토큰 별로 토큰 전송 대행자(to)가 관리되어야 합니다.
            • mapping (uint256 => address) private _tokenApprovals;
          • tokenId에 to를 매핑합니다.
            • _tokenApprovals[tokenId] = to;
          • Approval 이벤트를 발생시킵니다.
      • 토큰에 대한 전송 대행자를 제공합니다.
        • getApproved
    • 모든 토큰에 대해서 토큰 전송 대행이 승인될 수 있어야 합니다.
      • 토큰 보유자 주소에 토큰 전송 대행자 주소에 승인여부를 매핑한 매핑이 매핑되어야 합니다.
        • mapping (address => mapping (address => bool)) private _operatorApprovals;
      • 토큰 소유자에 대한 토큰 전송 대행자가 모든 토큰 전송 승인을 받았는지 여부를 제공합니다.
        • isApprovedForAll
  • IERC721Metadata 함수들을 구현합니다.
    • name, symbol을 제공할 수 있어야 합니다.
      • 상태 변수를 선언합니다.
        • _name, _symbol
      • 생성자에서 토큰 이름과 심볼을 설정합니다.
    • tokenURI을 제공할 수 있어야 합니다.
      • 일반적으로 tokenURI들은 공통의 URI로 시작할 것입니다.
        • baseURI와 tokenURI를 구분합니다. tokenURI는 토큰 id와 URI를 매핑으로 관리합니다.
          • string private _baseURI;
          • mapping (uint256 => string) private _tokenURIs;
        • baseURI와 tokenURI를 설정할 수 있어야 합니다.
          • 오픈제플린은 ERC721을 상속받아 구체적인 NFT 토큰 컨트랙트를 작성한다고 가정하기 때문에 internal virtual로 작성합니다.
            • _setBaseURI
            • _setTokenURI
        • baseURI를 제공합니다.
      • _tokenURIs를 사용해서 토큰 id로 tokenURI를 구합니다. _baseURI가 설정되어 있으면 _baseURI와 tokenURI를 합칩니다.
        • string(abi.encodePacked(_baseURI, _tokenURI));
        • tokenURI가 없으면 _baseURI에 tokenId를 합칩니다.
          • string(abi.encodePacked(_baseURI, tokenId.toString()));
            • uint256인 tokenId를 문자열 처리하기 위해 Strings 라이브러리를 사용하고, 이를 uint256에 결합(attach)합니다.
              • import “../../utils/Strings.sol”;
              • using Strings for uint256;
About the Author
(주)뉴테크프라임 대표 김현남입니다. 저에 대해 좀 더 알기를 원하시는 분은 아래 링크를 참조하세요. http://www.umlcert.com/kimhn/

Leave a Reply

*