ETC/프로젝트

[NFT Code] ERC-721 컨트랙트 리뷰

지과쌤 2021. 6. 15.
반응형

기본적인 ERC-20, ERC-721과 같은 이더리움 표준의 개념들은 리포트에 자세하게 정리되어있다.

(사실 그냥 봐주시면 좋겠습니다.. 열심히 썼거든요..)

 

NFT : 메타버스 시대로 가는 첫번재 발판

이번에 발간된 헥슬란트 이슈 리포트 주제는 ‘NFT : 메타버스 시대로 가는 첫번재 발판’입니다.

medium.com

 

따라서 기본 개념을 건너뛰고, 코드레벨단 리뷰를 간단하게 해보려고 한다.

 

기본적으로 ERC-20를 알고있다는 가정 하에 작성하였고,  ERC-721에 대한 포스팅을 작성하고, 추후 ERC-20 등 대표적인 ERC 표준들을 코드레벨단에서 리뷰할 예정이다.

ERC-721 Interface

/// @title ERC-721 Non-Fungible Token Standard
/// @dev See https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md
///  Note: the ERC-165 identifier for this interface is 0x80ac58cd.
contract ERC721 /* is ERC165 */ {
  
    event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
    event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
    event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);

    function balanceOf(address _owner) external view returns (uint256);
    function ownerOf(uint256 _tokenId) public view returns (address);
    
    function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes memory data) public payable;
    function safeTransferFrom(address _from, address _to, uint256 _tokenId) public payable;
    function transferFrom(address _from, address _to, uint256 _tokenId) public payable;
    
    function approve(address _approved, uint256 _tokenId) external payable;
    function getApproved(uint256 _tokenId) external view returns (address);    
    function setApprovalForAll(address _operator, bool _approved) external;
    function isApprovedForAll(address _owner, address _operator) external view returns (bool);
}

 

Specification

모든 ERC-721 컨트랙트는 ERC-721과 ERC-165 인터페이스를 포함해야한다.

(ERC-00은 IERC-00에 정의된 인터페이스의 구현체이다.)

/// @title ERC-721 Non-Fungible Token Standard
/// @dev See https://github.com/ethereum/EIPs/blob/master/EIPS/eip-721.md
///  Note: the ERC-165 identifier for this interface is 0x80ac58cd.
contract ERC721 /* is ERC165 */ {
  
    event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
    event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
    event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);

    function balanceOf(address _owner) external view returns (uint256);
    function ownerOf(uint256 _tokenId) public view returns (address);
    
    function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes memory data) public payable;
    function safeTransferFrom(address _from, address _to, uint256 _tokenId) public payable;
    function transferFrom(address _from, address _to, uint256 _tokenId) public payable;
    
    function approve(address _approved, uint256 _tokenId) external payable;
    function getApproved(uint256 _tokenId) external view returns (address);    
		
    function setApprovalForAll(address _operator, bool _approved) external;
    function isApprovedForAll(address _owner, address _operator) external view returns (bool);
}

interface ERC165 {
    /// @notice Query if a contract implements an interface
    /// @param interfaceID The interface identifier, as specified in ERC-165
    /// @dev Interface identification is specified in ERC-165. This function
    ///  uses less than 30,000 gas.
    /// @return `true` if the contract implements `interfaceID` and
    ///  `interfaceID` is not 0xffffffff, `false` otherwise
    function supportsInterface(bytes4 interfaceID) external view returns (bool);
}

contract ERC165 is IERC165 {
	bytes4 private constant _INTERFACE_ID_ERC165 = 0x01ffc9a7;
	/**
	* 0x01ffc9a7 === bytes4(keccak256('supportsInterface(bytes4)'))
	*/
	mapping(bytes4 => bool) private _supportedInterfaces;

	constructor () insternal {
		_registerInterface(_INTERFACE_ID_ERC165);
	} 
	
	// 시간복잡도는 0(1)이며, 항상 30000가스 미만을 사용하도록 보장한다.
	function supportsInterface(bytes4 interfaceID) external view returns (bool) {
		return _supportedInterfaces[interfaceId];
	}
	
	// 컨트랙트가 interfaceId로 정의한 인터페이스를 구현했음을 등록한다.
	// 실제 ERC165 인터페이스의 지원은 자동으로 되며 이 인터페이스 ID의 등록은 필요하지 않다.
	function _registerInterface(bytes4 interfaceId) internal {
		require(interfaceId != 0xffffffff);
		_supportedInterfaces{interfaceId] = true;
	}
}

ERC165 인터페이스

_registerInterface를 통해 interfaceId를 등록하고,

supportsInterface를 통해 해당 interface 즉 함수가 있는지 확인한다.

 

interfaceID ⇒ 함수에 대한 정보를 16진수로 표현하는것.

함수를 bytes4형으로 변환하고, _supportedInterfaces에 매핑한다.

 

0x01ffc9a7메소드가 존재 한다면,

_supportedInterfaces[0x01ffc9a7] = true

 

존재하지 않는다면

_supportedInterfaces[0x01ffc9a7] = false 라는 데이터를 얻게될것이다.

 

IERC165 interface를 변환하는 방법은 2가지가 있다.

  1. 직접 변환
    • bytes4(keccak256('supportsInterface(bytes4)'))
  2. 메소드 사용
    • this.supportsInterface.selector

두 방법 모두 0x01ffc9a7라는 같은 결과값을 얻을 수 있다.

/*
     *     bytes4(keccak256('balanceOf(address)')) == 0x70a08231
     *     bytes4(keccak256('ownerOf(uint256)')) == 0x6352211e
     *     bytes4(keccak256('approve(address,uint256)')) == 0x095ea7b3
     *     bytes4(keccak256('getApproved(uint256)')) == 0x081812fc
     *     bytes4(keccak256('setApprovalForAll(address,bool)')) == 0xa22cb465
     *     bytes4(keccak256('isApprovedForAll(address,address)')) == 0xe985e9c
     *     bytes4(keccak256('transferFrom(address,address,uint256)')) == 0x23b872dd
     *     bytes4(keccak256('safeTransferFrom(address,address,uint256)')) == 0x42842e0e
     *     bytes4(keccak256('safeTransferFrom(address,address,uint256,bytes)')) == 0xb88d4fde
     *
     *     => 0x70a08231 ^ 0x6352211e ^ 0x095ea7b3 ^ 0x081812fc ^
     *        0xa22cb465 ^ 0xe985e9c ^ 0x23b872dd ^ 0x42842e0e ^ 0xb88d4fde == 0x80ac58cd
     */
bytes4 private constant _INTERFACE_ID_ERC721 = 0x80ac58cd;

constructor () public {
        // ERC165를 통해 ERC721을 준수하도록 지원되는 인터페이스를 등록하세요
        _registerInterface(_INTERFACE_ID_ERC721);
    }

보통 여러 smart contract 를 보면 interfaceID의 경우 미리 계산을 한 후 변수에 값만 대입해놓았다.

가스비 절약을 위해

 

interface가 정해진 ERC표준들의 경우, 결국 모두 같은 interfaceID를 갖게 되므로 이 값이 어떻게 생성되었는지 명시적으로 적어놓을 필요가 없을수도 있겠다.

 

 

ERC-165를 사용하는 이유?

*예외처리

  • function을 통해 생성, 수정, 거래 등을 추가적으로 할 수 있는데 실수로 존재하지 않는 메소드를 호출했거나, 인자 값을 다르게 입력했을 경우 예외처리가 필요하다.
  • 함수명이 아닌, 함수명 & 인자값들로 byte4 interfaceID를 만든 후 조회해야하지만 예외처리는 있으면 좋으므로 쓸만하다.

 

Function

 

OpenZeppelin/openzeppelin-contracts

OpenZeppelin Contracts is a library for secure smart contract development. - OpenZeppelin/openzeppelin-contracts

github.com

ERC-721 표준 OpenZeppelin contracts

 

balanceOf() function

     /**
     * @dev See {IERC721-balanceOf}.
     */
    function balanceOf(address owner) public view virtual override returns (uint256) {
        require(owner != address(0), "ERC721: balance query for the zero address");
        return _balances[owner];
    }

해당 주소가 보유하고있는 NFT 토큰의 개수

 

owner가 고양이 "나르", 고양이 "렝가", 고양이 "냐옹카이"를 보유하고 있을 때, owner.balanceOf는 3을 리턴한다.

참고로, 각각의 고양이는 대체 불가능(NFT)하기 때문에, "나르" 2마리, "렝가" 5마리 이런식으로 보유할 수 없다.

 

ownerOf() function

     /**
     * @dev See {IERC721-ownerOf}.
     */
    function ownerOf(uint256 tokenId) public view virtual override returns (address) {
        address owner = _owners[tokenId];
        require(owner != address(0), "ERC721: owner query for nonexistent token");
        return owner;
    }

해당 NFT토큰을 소유하고 있는 주소를 보여준다.

 

ownerOf 는 token의 id를 변수로 받고, address를 리턴한다.

예를들어 고양이 "나르"의 token id와 함께 함수를 호출하면 "나르"를 보유하는 address "0x..."이런식으로 값을 리턴하게된다.

고양이 "나르" 는 유일하기때문에 "나르" 를 보유하고 있는 address는 오직 하나밖에 없다.

 

approve() function

     /**
     * @dev See {IERC721-approve}.
     */
    function approve(address to, uint256 tokenId) public virtual override {
        address owner = ERC721.ownerOf(tokenId);
        require(to != owner, "ERC721: approval to current owner");

        require(_msgSender() == owner || isApprovedForAll(owner, _msgSender()),
            "ERC721: approve caller is not owner nor approved for all"
        );

        _approve(to, tokenId);
    }

    /**
     * @dev See {IERC721-getApproved}.
     */
    function getApproved(uint256 tokenId) public view virtual override returns (address) {
        require(_exists(tokenId), "ERC721: approved query for nonexistent token");

        return _tokenApprovals[tokenId];
    }

approve는 ERC-20의 approve와 동일하다.

 

ERC-20은 토큰을 전송하는 것 뿐만 아니라, 승인받은 제삼자가 토큰을 전송하는 기능에 대한 표준을 제공한다. 토큰을 대신 전송하는 사람을 oprator라고 하며, 토큰을 보유하고 있는 사람이 token id 와 operator의 address를 입력하면 operator에게 해당 토큰 거래를 허용하게 되는 것이다.

getApproved는 해당 token id를 입력하면 그 토큰에 해당하는 operator를 반환해준다.

 

setApprovalForAll(), isApprovedForAll() function

    /**
     * @dev See {IERC721-setApprovalForAll}.
     */
    function setApprovalForAll(address operator, bool approved) public virtual override {
        require(operator != _msgSender(), "ERC721: approve to caller");

        _operatorApprovals[_msgSender()][operator] = approved;
        emit ApprovalForAll(_msgSender(), operator, approved);
    }

    /**
     * @dev See {IERC721-isApprovedForAll}.
     */
    function isApprovedForAll(address owner, address operator) public view virtual override returns (bool) {
        return _operatorApprovals[owner][operator];
    }

둘은 위의 approve와 getApproved를 한꺼번에 처리한다고 이해하면 된다.

 

setApprovalForAll은 NFT토큰 소유자가 해당 주소에게 모든 NFT 토큰에 대한 전송 권한을 부여 또는 해제한다.

setApprovalForAll을 호출한 NFT토큰 owner는 자신이 보유한 모든 NFT토큰에 대해 operator가 전송 권한을 갖게 할 수 있다. _approved 변수에 true를 입력하면 모든 토큰에 대한 전송 권한을 갖게, false를 입력하면 모든 토큰에 대한 전송 권한을 취소하게 된다.

isApprovedForAll은 setApprovalForAll의 권한이 있는지 bool 형태로 리턴한다.

 

transferFrom() function

    /**
    *  토큰 전송
    */
    function transferFrom(address _from, address _to, uint256 _tokenId) public payable {

        //토큰의 소유계정
        address addr_owner = ownerOf(_tokenId);

        //인자로 받는 _from이 토큰의 소유 계정과 일치하지 않으면 예외 발생.
        require(addr_owner == _from, "_from is NOT the owner of the token");
        //인자로 받는 _to가 0(null)이라면 예외 발생.
        require(_to != address(0), "Transfer _to address 0x0");

        //해당 토큰의 allowance address 여부 저장
        address addr_allowed = allowance[_tokenId];
        //토큰의 본 소유계정이 메소드를 호출한 사람에게 소유권을 이전할 수 있도록 승인을 했는지 여부 저장
        bool isOp = operators[addr_owner][msg.sender];

        //msg.sender가 토큰의 소유계정이거나, 토큰의 allowance에 있는 계정이거나, 중개인 계정 true인 경우가 아니라면 예외 발생.
        require(addr_owner == msg.sender || addr_allowed == msg.sender || isOp, "msg.sender does not have transferable token");


        //transfer : change the owner of the token
        //토큰의 주인을 _to 계정으로 변경
        tokenOwners[_tokenId] = _to;
        //safemath 사용해서 balance 감소
        balances[_from] = balances[_from].sub(1);
        //safemath 사용해서 balance 증가
        balances[_to] = balances[_to].add(1);

        //reset approved address
        //erc721표준에 의하면, 이전의 allowance 를 갖고있던 계정을 리셋해줘야한다.
        if (allowance[_tokenId] != address(0)) {
            //null로..
            delete allowance[_tokenId];
        }

        //이벤트 발생.
        emit Transfer(_from, _to, _tokenId);

    }

    /**
    *  기능상 transferfrom과 동일.
    * 다만, _to 계정이 contract 계정인지 체크한다.
    */
    function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes memory data) public payable {

        transferFrom(_from, _to, _tokenId);

        //check if _to is CA

        //토큰을 이전받는 계정이 contract라면
        if (_to.isContract()) {
            // result = onERC721Received의 selector, 함수 signature.
            bytes4 result = ERC721TokenReceiver(_to).onERC721Received(msg.sender, _from, _tokenId, data);

            //erc165 selector 구하여 일치하지 않으면 에외 발생.
            require(
                result == bytes4(keccak256("onERC721Received(address,address,uint256,bytes)")),
                "receipt of token is NOT completed"
            );
        }

    }

NFT토큰 소유자로부터 해당 NFT토큰을 다른 주소로 전송한다.

 

safeTransferFrom이 두개 있는데, 하나는 bytes형의 인자를 더 받는것을 볼 수 있다.

후자의 경우, 실제 유저의 주소(EOA)가 아닌 스마트컨트랙트 주소에 보낼 때 사용된다. 해당 스마트컨트랙트가 ERC-721 토큰을 받을 수 있는지 확인을 하는것이다.

그래서 transferFrom을 사용할 때, 실제 유저의 주소로 보내는것은 상관없지만, 스마트컨트랙트 주소에 ERC-721 토큰을 보낼 때, 소실될 수 있음에 주의해야한다.

 

transferFrom 소유권 및 전송 승인 관련

전송 가능한 조건

  • msg.sender가 owner일경우
  • msg.sender가 addr_allowed 일경우
  • isOp, 토큰의 본 소유계정이 메소드를 호출한 사람에게 소유권을 이전할 수 있도록 승인을 한 경우(bool)

→ 셋다 만족하지 못할경우 예외발생. (or)

전송전, 받는 계정(_to)로 소유권 이전.

safeMath 사용하여 _from 계정과 _to 계정의 balance 증감.

allowance[_tokenId] 제거 : ERC721표준에 의해 이전에 allowance를 갖고있던 계정 초기화 필요.

 

Wallet Interface

/// @dev Note: the ERC-165 identifier for this interface is 0x150b7a02.
interface ERC721TokenReceiver {
    /**
     * @notice NFT 수신 처리
     * @dev ERC721 스마트 컨트랙트는 `safeTransfer` 후 수신자가 구현한
     * 이 함수를 호출합니다. 이 함수는 반드시 함수 선택자를 반환해야 하며,
     * 그렇지 않을 경우 호출자는 트랜잭션을 번복할 것입니다. 반환될 선택자는
     * `this.onERC721Received.selector`로 얻을 수 있습니다. 이 함수는
     * 전송을 번복하거나 거절하기 위해 예외를 발생시킬 수도 있습니다.
     * 참고: ERC721 컨트랙트 주소는 항상 메시지 발신자입니다.
     * @param _operator `safeTransferFrom` 함수를 호출한 주소
     * @param _from 이전에 토큰을 소유한 주소
     * @param _tokenId 전송하고자 하는 NFT 식별자
     * @param _data 특별한 형식이 없는 추가적인 데이터
     * @return bytes4 `bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))`
     */
    function onERC721Received(address _operator, address _from, uint256 _tokenId, bytes _data) external returns(bytes4);
}

따라서 ERC-721 토큰을 받으려는 스마트컨트랙트 주소는 위 인터페이스를 필수적으로 구현해야한다.

 

다시말해 지갑, 옥션, 중매 관련 앱은 반드시 위 인터페이스를 갖고있어야한다.

 

Metadata Extention

/// @title ERC-721 Non-Fungible Token Standard, optional metadata extension
/// @dev See https://eips.ethereum.org/EIPS/eip-721
///  Note: the ERC-165 identifier for this interface is 0x5b5e139f.
interface ERC721Metadata /* is ERC721 */ {
    /// @notice A descriptive name for a collection of NFTs in this contract
    function name() external view returns (string _name);

    /// @notice An abbreviated name for NFTs in this contract
    function symbol() external view returns (string _symbol);

    /// @notice A distinct Uniform Resource Identifier (URI) for a given asset.
    /// @dev Throws if `_tokenId` is not a valid NFT. URIs are defined in RFC
    ///  3986. The URI may point to a JSON file that conforms to the "ERC721
    ///  Metadata JSON Schema".
    function tokenURI(uint256 _tokenId) external view returns (string);
}

메타데이터를 관리할때 위 인터페이스를 사용한다.

 

{
    "title": "Asset Metadata",
    "type": "object",
    "properties": {
        "name": {
            "type": "string",
            "description": "Identifies the asset to which this NFT represents"
        },
        "description": {
            "type": "string",
            "description": "Describes the asset to which this NFT represents"
        },
        "image": {
            "type": "string",
            "description": "A URI pointing to a resource with mime type image/* representing the asset to which this NFT represents. Consider making any images at a width between 320 and 1080 pixels and aspect ratio between 1.91:1 and 4:5 inclusive."
        }
    }
}

ERC721 metadata sample JSON Schema이다.

 

Enumeration Extention

/// @title ERC-721 Non-Fungible Token Standard, optional enumeration extension
/// @dev See https://eips.ethereum.org/EIPS/eip-721
///  Note: the ERC-165 identifier for this interface is 0x780e9d63.
interface ERC721Enumerable /* is ERC721 */ {
    /// @notice Count NFTs tracked by this contract
    /// @return A count of valid NFTs tracked by this contract, where each one of
    ///  them has an assigned and queryable owner not equal to the zero address
    function totalSupply() external view returns (uint256);

    /// @notice Enumerate valid NFTs
    /// @dev Throws if `_index` >= `totalSupply()`.
    /// @param _index A counter less than `totalSupply()`
    /// @return The token identifier for the `_index`th NFT,
    ///  (sort order not specified)
    function tokenByIndex(uint256 _index) external view returns (uint256);

    /// @notice Enumerate NFTs assigned to an owner
    /// @dev Throws if `_index` >= `balanceOf(_owner)` or if
    ///  `_owner` is the zero address, representing invalid NFTs.
    /// @param _owner An address where we are interested in NFTs owned by them
    /// @param _index A counter less than `balanceOf(_owner)`
    /// @return The token identifier for the `_index`th NFT assigned to `_owner`,
    ///   (sort order not specified)
    function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns (uint256);
}

모든 NFT 리스트를 퍼블리시하거나 탐색할 수 있게 해준다.

반응형

댓글

💲 추천 글