devops

ERC-721, Non-Fungible Token 본문

개발/Solidity

ERC-721, Non-Fungible Token

vata500 2023. 11. 5. 15:19
반응형

ERC721, NFT는 크립토에 관심이 없는 사람들도 아는 대표적인 토큰 유형으로 toknID 기준, 다른 토큰들과는 구별되는 고유한 특성이 있다. 워낙 유명해서.. 굳이 소개할 필요는 없을 것같다.

내가 활동하는 CURG 라는 블록체인 학회에서 관련 코드를 재정리했기 때문에 코드 구성을 한번 확인하고 정리해보려 한다.


SolRoot/token/ERC-721

CURG 학회에서 진행하는 오픈소스 프로젝트인 SolRoot에서 ERC-721 코드 디렉토리는 다음과 같이 구성되어 있다. (Extension은 제외)

  • ERC721Base.sol
  • ERC721Extension.sol
  • ERC721Metadata.sol
  • ERC721SlotBase.sol
  • ERC721Utils.sol
  • IERC721.sol
  • IERC721Enumerable.sol
  • IERC721Metadata.sol
  • IERC721Receiver.sol

먼저 인터페이스를 살펴보자.

IERC721

// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.9.0) (token/ERC721/IERC721.sol)

pragma solidity ^0.8.0;

import { IERC165 } from "../../interfaces/IERC165.sol";

interface IERC721 is IERC165 {

    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
	// tokenId 토큰이 from 주소에서 to 주소로 이전

    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
	// tokenId 토큰에 대해 owner가 approved 주소에게 권한 부여

    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
	// owner가 operator에게 자신의 모든 토큰을 관리할 권한 부여
    // true일때 권한 부여, false일때 권한 취소

    function owners(uint256 tokenId) external view returns (address);
	// tokenId에 해당하는 토큰 소유자의 address 반환
    
    function balances(address account) external view returns (uint256);
	// account가 소유한 토큰의 수를 반환

    function tokenApprovals(uint256 tokenId) external view returns (address);
	// tokenId 토큰에 대한 승인된 주소를 반환

    function operatorApprovals(address owner, address operator) external view returns (bool);
	// owner에 의해 operator에게 부여된 모든 토큰을 관리할 수 있는 권한의 유무를 반환

    function balanceOf(address owner) external view returns (uint256 balance);
    // owner의 주소에 해당하는 토큰의 수를 반환

    function ownerOf(uint256 tokenId) external view returns (address owner);
	// 특정 tokenId의 소유자 address 반환

    function safeTransferFrom(
        address from,
        address to,
        uint256 tokenId,
        bytes calldata data
    ) external;
 	// from -> to tokenId 토큰을 전송, calldata와 함께 전달됨
    // 해당 계약이 토큰을 받을 수 있는지 확인하는 safe 장치가 존재
   
    function safeTransferFrom(
        address from,
        address to,
        uint256 tokenId
    ) external;
	// from -> to tokenId 토큰을 전송하지만 추가 데이터 없이 전송
    // 해당 계약이 토큰을 받을 수 있는지 확인하는 safe 장치가 존재

    function transferFrom(
        address from,
        address to,
        uint256 tokenId
    ) external;
    // safe 장치 없이 tokenId를 from -> to 이전 

    function approve(address to, uint256 tokenId) external;
	// tokenId에 대한 transfer 권한을 to에게 부여

    function setApprovalForAll(address operator, bool approved) external;
	// 호출자가 operator에게 자신의 모든 토큰을 관리할 수 있는 권한을 부여하거나 취소

    function getApproved(uint256 tokenId) external view returns (address operator);
	// 특정 tokenId에 대해서 현재 승인된 주소를 반환

    function isApprovedForAll(address owner, address operator) external view returns (bool);
	// owner가 operator에게 자신의 모든 토큰을 관리할 수 있느 권한을 부여했는지 여부 반환
}

인터페이스를 보면 특이하게 transfer가 3가지다.

  • transferFrom
  • data를 포함하는 safeTransferFrom
  • data가 없는 safeTransferFrom

safe가 붙지않는 transferFrom은 말그대로 안전장치없이 NFT가 전송되는 함수다. 

    function transferFrom(address from, address to, uint256 tokenId) public virtual override {
        require(
            _isApprovedOrOwner(_msgSender(), tokenId),
            "ERC721: caller is not token owner or approved");

        _transfer(from, to, tokenId);
    }

그러나 safeTransferFrom은 아래와 같이 data를 포함여부를 기준으로 2가지로 나뉘어 있다.

    function _safeTransfer(
        address from,
        address to,
        uint256 tokenId,
        bytes memory data
    ) internal virtual {
        _transfer(from, to, tokenId);
        require(
            _checkOnERC721Received(from, to, tokenId, data),
            "ERC721: transfer to non ERC721Receiver implementer");
    }

    function safeTransferFrom(
        address from,
        address to,
        uint256 tokenId
    ) public virtual override {
        safeTransferFrom(from, to, tokenId, "");
    }

	function safeTransferFrom(
        address from,
        address to,
        uint256 tokenId,
        bytes memory data
        ) public virtual override {
            require(_isApprovedOrOwner(_msgSender(), tokenId),
            "ERC721: caller is not token owner or approved");
            _safeTransfer(from, to, tokenId, data);
        }

 

data 매개변수가 없는 버전은 결론적으로 data 파라미터가 ""로 비어있는 상태에서 다시 data 매개변수가 있는 safeTransferFrom을 호출한다.

그리고 두 메소드는 결국엔 _isApprovedOrOwner를 통해서 Owner이거나 approve된 account인지를 확인한 다음,  internal로 선언된 _safeTransfer를 호출해서  _checkOnERC721Received 조건을 만족하는지 확인하고 _transfer가 호출된다.

    function _checkOnERC721Received(
        address from,
        address to,
        uint256 tokenId,
        bytes memory data
    ) private returns (bool) {
        if (to.isContract()) {
            try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) {
                return retval == IERC721Receiver.onERC721Received.selector;
            } catch (bytes memory reason) {
                if (reason.length == 0) {
                    revert("ERC721: transfer to non ERC721Receiver implementer");
                } else {
                    /// @solidity memory-safe-assembly
                    assembly {
                        revert(add(32, reason), mload(reason))
                    }
                }
            }
        } else {
            return true;
        }
    }

_checkOnERC721Received()가 사실상 safe 장치의 핵심인데, 이 녀석은 안전한 토큰 trasfer를 보장하기 위해서 토큰을 받는 컨트랙트가 IERC721Receiver 인터페이스를 잘 구현했는지 확인한다.

  1. to.isContract: 주소 to가 컨트랙트 주소인지 확인 (to가 EOA라면 함수는 바로 true를 반환함)
  2. to가 컨트랙트 주소일 경우, onERC721Received 함수를 호출 (보내는 주소 from, tokenId 이전되는 토큰 Id와 추가 데이터를 파라미터로 받음) bytes4 타입 반환
  3. return retval == IERC721Receiver.onERC721Received.selector; 호출 성공하면 반환 값인 retval이 onERC721Received의 selector와 일치하는 지 확인, 일치 시 true 반환
  4. onERC721Received 호출 실패시에는 에러메시지인 reason이 catch되어 2가지로 분기처리됨 (에러 메시지가 없으면 단순 revert, 있으면 에러메시지 출력)

간단히 정리하면, to로 받는 주소가 컨트랙트일 경우에 NFT 토큰을 관리할 인터페이스가 없으면 관리에 문제가 생기므로, onERCReceived 확인하는 절차라고 보면 된다.

    function _transfer(address from, address to, uint256 tokenId) internal virtual {
        require(ownerOf(tokenId) == from, "ERC721: transfer from incorrect owner");
        require(to != address(0), "ERC721: transfer to the zero address");

        _beforeTokenTransfer(from, to, tokenId, 1);

        // Check that tokenId was not transferred by `_beforeTokenTransfer` hook
        require(ownerOf(tokenId) == from, "ERC721: transfer from incorrect owner");

        // Clear approvals from the previous owner
        setTokenApproval(tokenId, address(0));

        unchecked {
            // `_balances[from]` cannot overflow for the same reason as described in `_burn`:
            // `from`'s balance is the number of token held, which is at least one before the current
            // transfer.
            // `_balances[to]` could overflow in the conditions described in `_mint`. That would require
            // all 2**256 token ids to be minted, which in practice is impossible.
            setBalances(from, balances(from) - 1);
            setBalances(to, balances(to) + 1);
        }
        setOwners(tokenId, to);

        emit Transfer(from, to, tokenId);

        _afterTokenTransfer(from, to, tokenId, 1);
    }

internal로 선언된 _transfer 코드를 보면, transfer 전에 hook으로 _beforeTokenTransfer() 메소드 그리고 transfer 이후의 hoo인 _afterTokenTransfer()도 선언되어 있다. 특히 _beforeTokenTransfer() 실행 후에 혹시나 모를 상황을 대비한 owner를 확인하는 require 문을 추가로 선언했다.

이외에는 storageSlot을 활용해서 지정된 슬롯에 데이터를 할당하도록 하는 ERC721Metadata와 ERC721SlotBase 그리고 URI를 설정하는 ERC721Utils, 여러 기능을 추가한 ERC721Extensions로 구성된다.


현재 크립토 씬에서 NFT는 대체로 PFP로 많이 사용된다. 그러나 NFT의 궁극적인 활용가치는 소유권을 쉽게 증명하는데 있다고 생각한다.

ERC6551을 활용하면 NFT하나로 여러 컨트랙트의 Ownership을 쉽게 관리할 수 있는데, 여기서 더욱 효용있는 유스케이스가 생겨날 것이라고 우리 CURG 팀은 생각하고 있다. 

반응형

'개발 > Solidity' 카테고리의 다른 글

ERC1155, Multi-token standard  (0) 2023.11.12
ERC-6551, NFT Bound Account  (0) 2023.11.05
OpenZeppelin ERC-20 Solidity 코드 리뷰  (1) 2023.10.09
Comments