Openzeppelin이 제공하는 Token 표준

2021. 12. 23. 22:51TIL💡/Blockchain

회사에서 NFT 과제를 하면서 개발 기술로 구현하기 이전에 배경 지식이 상당히 많이 필요했다.

Ethereum 튜토리얼과 가이드, Nethereum 도큐먼트, Github의 example project 등 여러 영문 자료들을 찾아보며 공부를 한 결과 일주일 동안 간단하게 블록체인 작동 방식을 익힐 수 있었다.

하지만 아직 Solidity의 언어에 대한 이해와 토큰의 구조를 이해하지 못했다고 생각하여 Openzeppelin에 존재하는 표준 양식을 시작으로 공부해보고자 한다.

 

Openzeppelin은 4가지의 토큰 표준을 제공한다.

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

1. ERC20

ERC20은 대체 가능 토큰 구현을 위한 컨트랙트 작성 표준이다.

pragma solidity ^0.5.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20Detailed.sol";

contract GLDToken is ERC20, ERC20Detailed {
    constructor(uint256 initialSupply) ERC20Detailed("Gold", "GLD", 18) public {
        _mint(msg.sender, initialSupply);
    }
}
  • '대체 가능하다'는 같은 토큰이고 같은 단위인 경우 성립한다. → A medium of exchange currency, voting rights, staking, and more
  • ERC20은 namesymbol로 이를 지원한다.
  •  
  • 대체 가능하면 교환할 수 있기 때문에 다른 account로 넘겨누는 일을 transfer로 지원한다.
  • decimals라는 더 작은 단위로 가치를 표현할 수 있다. 표현만 가능하고 연산은 불가
  • totalSupply를 통해 현재 시점을 기준으로 총공급량을 관리하도록 한다.
  • balanceOf를 통해 계정 잔고로 토큰 소유권을 나타낸다.
  • 호출 계정이 아닌 계정에서 토큰 컨트랙트의 토큰을 전송할 수 있다.
    • approve를 통해 누구의 토큰을 얼만큼 전송할 수 있도록 허용할지를 설정해야 한다.
    • 전송이 허용된 수량을 allowance를 통해 얼마인지 파악한다.
    • transfer와 구분짓기 위해 transferFrom함수를 지원한다.
      • transfer(address recipient, uint256 amount): Moves amount tokens from the callers' account to recipient
      • transferFrom(address sender, address recipient, uint256 amount): Moves amount token from sender to recipient using allowance mechanism. amount is thne deducted from the caller’s allowance. Returns a boolean value indicating whether the operation succedded.
      • _transfer: transfer와 transferFrom의 공통적인 로직 분리
      • _beforeTokenTransfer로 전송 전에 처리해야하는 일을 재정할 수 있도록 한다.
      • Openzeppelin은 가스 수수료 대납이 가능하므로 msg.sender가 실제 transfer하는 sender와 다를 수 있다.그래서 직접적으로 msg.sender을 사용하지 않고 _msgSender()을 사용하자.
  • _mint
    • 토큰 총 공급량을 늘리기 위해 추가적으로 토큰을 신규발행하는 함수
    • 토큰을 받는 주소는 0주소가 아니어야 합니다.
    • 토큰을 신규로 발행한다 = 총 공급량 수량과 토큰을 받을 주소의 잔고를 발행 수량만큼 늘린다.
      • _totalSupply = _totalSupply.add(amount)
      • _balances[account] = _balances[account].add(amount)
    • 결국 0주소에서 토큰을 받을 계정으로 토큰을 전송한다는 의미
      • _beforeTokenTransfer(address(0), account, amount);
      • emit Transfer(address(0), account, amount);
  • _burn
    • 토큰 총 공급량을 줄이기 위해 토큰을 소각하는 함수
    • 소각 주소는 0주소가 아니어야 한다.
    • 토큰을 소각한다 = 총 공급량 수량과 소각 주소의 잔고를 소각 수량만큼 줄인다.
      • _balances[account] = _balances[account].sub(amount, "ERC20: burn amount exceeds balance");
      • _totalSupply = _totalSupply.sub(amount)
    • 결국 소각 주소에서 0주소로 토큰을 전송한다는 의미
      • _beforeTokenTransfer(account, address(0), amount);
  • 이벤트로 중요한 행위 실행에 대한 결과를 통지한다.
    • transfer → Transfer
    • approve → Approval 

2. ERC721

  • 대체불가능한 토큰 구현을 위한 컨트랙트 작성 표준
  • 토큰마다 고유함을 내포하고, 이를 ID로 구분할 수 있어야 한다.
    • real estate or collectibles
    • function ownerOf(uint256 _tokenId) external view returns (address);
  • 소유자의 토큰 잔고를 알 수 있어야 한다.
    • function balanceOf(address _owner) external view returns (uint256);
    • 여기서 발생하는 의문! 토큰마다 고유하다고 했는데 잔고라는 개념이 의미가 있는가?
    • 토큰 전송이 가능하고, 전송할 토큰은 tokenId로 명시한다.
      • function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable
      • function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable
    • 토큰 전송을 위임할 수 있습니다. 소유자의 개별 토큰 또는 모든 토큰에 대한 위임이 가능해야 합니다.
      • 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 토큰의 이름이나 symbol 또는 asset에 대한 세부 정보를 관리할 수 있어야 한다.
      • 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, uin256 _index) external view returns (uint256);
    • Openzeppelin ERC721 구현
      • contract ERC721 is Context, ERC165, IERC721, IERC721Metadata, IERC721Enumerable
        • 오픈제플린은 인터페이스에 접두어 I를 붙여 컨트랙트와 구분한다.
        • 가스 대납 기능이 가능하도록 Context를 상속
        • ERC165
          • IERC165를 구현
            • abstract contract ERC165 is IERC165
          • 지원하는 인터페이스를 관리
            • mapping(bytes4 => bool)private _supportedInterfaces;
            • 생성자에서 지원하는 인터페이스의 함수 선택자(selector)를 등록
          • 생성자에서 ERC721 지원 인터페이스들을 등록
            • 지원 인터페이스에 대한 id는 인터페이스에 포함한 함수들에 대한 selector를 XOR해서 구한다.
        • IERC721Enumerable 함수들을 구현함으로써 소유 토큰 표현
          • 보유한 토큰들을 열거
            • 소유자를 중심으로 보유한 토큰을 알 수 있어야하고, 소유자 기준으로 토큰을 매핑한다.
              • mapping(address => EnumerableSet.UintSet) private _holderTokens;
                • EnumerableSet 라이브러리 사용
              • 소유자를 기준으로 id에 해당하는 토큰을 구할 수 있어야 한다.
                • tokenOfOwnerByIndex를 구현
                  • return _holderTokens[owner].at(index);
              • 토큰 Id를 중심으로 토큰 소유자와 토큰을 구할 수 있어야 한다.
                • 토큰 Id에 소유자 주소를 열거 가능한 형태로 매핑
                • EnumerableMap.UintToAddressMap private _tokenOwners;
        • 토큰 신규 발행이나 소각이 가능
          • 상속받는 컨트랙트에서 호출하는 것 전제
            • _mint
              • 수신자에 해당하는 to주소는 0이아니어야 하고, 토큰 ID는 중복 불가
                • require(to != address(0), "ERC721: mint to the zero address");
                • require(!_exists(tokenId), "ERC721: token already minted");
              • 신규발행은 새로 발행한 토큰을 수신자에게 전송
                • 전송 전에 처리해야하는 일을 위해 _beforeTokenTransfer을 hook으로 작성
                • Transfer 이벤트 발생
              • _holderTokens와 _tokenOwners에 토큰 추가
                • _holderTokes[to].add(tokenId);
                • _tokenOwners.set(tokenId, to);
            • _burn
              • tokenId에 해당하는 토큰 소유자와 토큰을 구한다.
              • _beforeTokenTransfer을 호출
              • 전송 대행으로 승인되어있는 부분을 제거
                • _approve(address(0), tokenId);
              • tokenURI 설정 부분 제거
              • _holderTokens와 _tokenOwners에서 토큰 제거
              • Transfer이벤트 발생
            • ERC721 토큰을 다룰 수 있는지를 먼저 확인하는 것을 권장
              • _safeMint
                • _checkOnERC721Received
                  • 수신자가 컨트랙트여야하므로 Address 라이브러리의 isContract 함수를 사용해서 확인
                • 수신자가 IERC721Receiver 인터페이스를 구현
                  • Address 라이브러리의 function Call 후 그 리턴 값을 사용해서 인터페이스 구현 여부를 확인한다.
                  • abi.encodeWithSelector로 functionCall 인자로 넘겨줄 data를 생성한다.
                  • 리턴 값의 4바이트를 디코딩하고, 이것이 _ERC721_RECEIVED와 같은지 비교한다. 만약 같으면 IERC721Receiver 인터페이스를 구현하고 있다.
              • 토큰 전송이 가능해야하고, 이를 위해 msg.sender가 소유자거나 전송 대행자로 설정되어 있어야 한다.
                • require(_isApprovedOrOwner(msgSner(), tokenId), "ERC721: transfer caller is not owner nor approved");
                  • _isApprovedOrOwner
                    • return (spender == owner || getApproved(tokenId) == spender || isApprovedForAll(owner, spender));
                • 실제적인 전송은 _transfer로 구현 된다.
                  • 전송자 (from)가 토큰 소유자이어야 하고, 수신자(to)까지는 0주소가 아니어야 한다.
                    • require(ownerOf(tokenId) == from, "ERC721:transfer of token that is not own");
                    • requrie(to != address(0), "ERC721: transfer to the zero address");
                  • _beforeTokenTransfer를 호출
                  • 전송 대행으로 승인되어 있는 부분을 제거한다.
                  • _holderTokens[from]에서 토큰 제거, _holderTokens[to]에 토큰 추가
                  • _tokenOwners에 토큰 ID로 to 설정
                  • Transfer 이벤트 발생
              • 토큰 전송 대행이 가능해야 한다.
                • 토큰별로 승인이 가능해야 한다.
                  • approve
                    • 토큰 소유자와 승인 대상자가 달라야 한다.
                      • 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
              • IERC721 Metadata함수들을 구현한다.
                • name, symbol을 제공한다.
                  • 상태변수를 선언한다. → _name, _symbol
                • 생성자에서 토큰 이름과 심볼을 설정한다.
                • tokenURI을 제공할 수 있어야 한다.
                  • 일반적으로 tokenURI들은 공통의 URI로 시작할 것이다.
                    • baseURI vs. tokenURI
                    • 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, tokenId.toString());
                        • uint256인 tokenId를 문자열 처리하기 위해 Strings라이브러리를 사용하고, 이를 uint256에 결합한다.

 

참고

- http://www.umlcert.com/ethereum-dapps-15-3/