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

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

ERC-20이 대체 가능한 토큰이라면, ERC-721은 대체 불가능 토큰(Non-Fungible Token, NFT) 표준입니다.

이미 NFT가 무엇인지에 대해서는 제 블로그에 많은 글이 있으니 이번에는 코드 부분만 조금 살펴보도록 하겠습니다.

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

EIP-721에서는 다음 인터페이스를 이야기하고 있습니다.

  • IERC721: 모든 ERC-721의 호환에 필요한 인터페이스
  • IERC721Receiver: 지갑/매매/시장 어플에서 safeTransferFrom을 사용할 때, 필수적으로 구현해야하는 인터페이스
  • IERC721Metadata: 선택적으로 메타데이터 추가 (이름, 기호, 토큰 등)
  • IERC721Enumerable: 온체인 토큰을 열거하기 위한 선택적 인터페이스. 가스비가 많이 들어 필수는 아님.

오픈제플린에서는 위 4가지 인터페이스에 대해 모두 구현을 제공하고 있으며, 3개의 구현체가 있습니다.

  • ERC721: 핵심 구현 with URI 메커니즘
  • ERCEnumerable: 열거 기능을 위한 확장
  • ERC721Holder: Reciever 구현

이번에도 각 파일의 함수에서 핵심적인 구현을 살펴보겠습니다.

💡
해당 ERC-721의 인터페이스들은 ERC-165(Standard Interface Detection)에 부합합니다. ERC-165는 추후 더 자세하게 다뤄보겠습니다.

[OpenZeppelin] IERC721

ERC-721에 필요한 함수는 크게(1) 소유 체크, (2) 전송, (3) 대리 전송 승인으로 나누어 살펴볼 수 있습니다.

  • balanceOf(owner): 특정 주소가 가진 전체 토큰 개수
  • ownerOf(tokenId): 토큰의 소유 주소

전송 함수는 3가지입니다.

  • transferFrom(from, to, tokenId): from에서 to로 보내는 함수
  • safeTransferFrom(from, to, tokenId, data): to가 NFT를 받을 수 있는 주소인지 체크하는 기능을 추가하여 안전하게 전송합니다. 토큰과 함께 data를 전달할 수 있습니다.
  • safeTransferFrom(from, to, tokenId): data가 없는 함수입니다.
💡
이전 ERC-20에서는 transfertransferFrom이 따로 존재했는데, 이제는 합쳐진 것도 살펴볼만한 포인트입니다.

승인 기능은 총 4가지입니다. 권한 제공을 개별 또는 전체로 하는 것에 따라 분류됩니다.

  • approve(to, tokenId): 개별 토큰 전송 권한 제공
  • getApproved(tokenId): 토큰 거래 권한을 가진 주소 반환
  • setApprovalForAll(operator, _approved): 모든 토큰 전송 권한 제공 또는 취소
  • isApprovedForAll(owner, operator): 모든 토큰 전송 권한을 가진 주소임을 확인
보통 특정 사이트에 지갑을 연결하고 해킹 당하는 사례는 이런 approve 관련 승인을 해버리기 때문입니다. 그렇기에 사이트에 접속하며 주소를 연결할 때 "approve ~~"가 있다면 일단 조심하세요.

[OpenZeppelin] IERC-721 Enumerable

PFP, Collective 등 다양한 케이스에서 여러개의 토큰을 발행할 때 쉽게 접근하기 위해 열거가 가능하도록(Enumerable) 몇 가지 기능을 추가할 수 있습니다.

  • totalSupply(): 전체 개수 반환
  • tokenOfOwnerByIndex(owner, index): owner의 토큰 중 index 에 위치하는 데이터 가져오기. 물론 여기서도 list 대신 mapping을 사용
  • tokenByIndex(index): 전체 토큰에서 index에 위치하는 토큰의 owner 주소 반환

[OpenZeppelin] IERC-721 Receiver 이해하기

IERC721Receiver는 safeTransfers후에 사용하는 인터페이스로, 지갑이나 거래소를 사용하기 위해서는 반드시 구현해야합니다. onERCReceived 를 구현해야 합니다. 해당 함수는 토큰 전송을 확인하기 위해 함수 선택자(selector)를 반환해야 합니다. 함수 선택자는 함수의 정보에 따른 해시값이라 고정값으로 지정되어 있습니다.

오픈제플린에서는 ERC721Holder.sol에서 구현을 확인할 수 있습니다. 코드에는 "항상 selector를 반환해야 한다"는 내용이 주석처리되어 있습니다.

// ERC721Holder.sol
// SPDX-License-Identifier: MIT

pragma solidity >=0.6.0 <0.8.0;

import "./IERC721Receiver.sol";

contract ERC721Holder is IERC721Receiver {

    /**
     * @dev See {IERC721Receiver-onERC721Received}.
     *
     * Always returns `IERC721Receiver.onERC721Received.selector`.
     */
    function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) {
        return this.onERC721Received.selector;
    }

ERC721.sol과 함께 살펴보면 이해가 더 좋습니다. IERC721Receiver_checkOnERC721Received 함수에서 사용하며, 이 함수는 _safeTransfer 내부에 require문에서 호출이 됩니다.

// ERC721.sol
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 _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;
    }
}

해당 코드는 onERC721Received 함수가 구현되어 있음을 검사합니다. 토큰을 받을 수 있는 주소인 경우에만 코드가 실행되는 구조입니다. 그리고 만약 try~catch문이 실패하면 커스텀 오류 여부에 따라 이유를 반환하고, 이전 transfer 등의 연산을 되돌립니다. 내부 assembly 구문에 대한 설명은 다음 글을 참고해주세요.

💡
왜 transfer 이후에 require를 사용할까요? 이는 재진입 공격(Reentrancy Attack)을 막기 위한 Checks-Effects-Interactions 패턴으로 솔리디티 보안을 위한 구현 방식입니다.

[OpenZeppelin] ERC-721

ERC-721 코드 중 일부를 살펴보겠습니다. 변수는 크게 4가지가 있습니다.

  • _owners: 각 토큰의 주소
  • _balances: 주소 당 가지고 있는 토큰 개수
  • _tokenApprovals: 토큰 거래 허용 주소
  • _operatorApprovals: 전체 토큰 거래 허용 주소-주소
// Mapping from token ID to owner address
mapping(uint256 => address) private _owners;

// Mapping owner address to token count
mapping(address => uint256) private _balances;

// Mapping from token ID to approved address
mapping(uint256 => address) private _tokenApprovals;

// Mapping from owner to operator approvals
mapping(address => mapping(address => bool)) private _operatorApprovals;

전송은 _transfer 함수를 사용하며 다음과 같습니다.

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

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

    _balances[from] -= 1;
    _balances[to] += 1;
    _owners[tokenId] = to;

    emit Transfer(from, to, tokenId);
    _afterTokenTransfer(from, to, tokenId);
}

코드의 순서는 다음과 같습니다.

  • 토큰의 권한 확인 및 보내는 주소가 zero address가 아닌지 점검
  • 토큰 권한의 초기화
  • 소유자(from)에서 -1, 수령자(to)에서 +1을 하여 개수 확인
  • 토큰 아이디에 새로운 수령자 주소 매칭
  • Transfer 이벤트 발생

전송 또는 승인 기능은 매우 단순하게 구현되어 있어 복잡하지 않습니다.

다만 token의 자료형은 uint256 으로 저장되며, 이미지 등 큰 자료를 담기에는 부족합니다. 그렇기에 대다수 NFT는 메타데이터를 따로 저장합니다. 메타데이터는 tokenURI함수를 통해 전달받습니다. URI(Uniform Resource Identifier)은 인터넷 자원에 대한 고유 식별자입니다. 토큰에 따라 메타데이터는 다음과 같이 매칭됩니다.

//ERC721URIStorage.sol
mapping (uint256 => string) private _tokenURIs;

function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
    _requireMinted(tokenId);

    string memory _tokenURI = _tokenURIs[tokenId];
    string memory base = _baseURI();

    // If there is no base URI, return the token URI.
    if (bytes(base).length == 0) {
        return _tokenURI;
    }
    // If both are set, concatenate the baseURI and tokenURI (via abi.encodePacked).
    if (bytes(_tokenURI).length > 0) {
        return string(abi.encodePacked(base, _tokenURI));
    }

    return super.tokenURI(tokenId);
}

function _setTokenURI(uint256 tokenId, string memory _tokenURI) internal virtual {
    require(_exists(tokenId), "ERC721Metadata: URI set of nonexistent token");
    _tokenURIs[tokenId] = _tokenURI;
}

URI에는 메타데이터가 담긴 https 주소나 ipfs 주소를 담습니다. 보통 NFT는 번호를 순차적으로 매기는 경우가 많기 때문에 Base URI를 설정하여 사용하는 방식이 보편적입니다.

메타데이터의 형태는 json 포맷을 사용합니다.

{
    "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."
        }
    }
}

현재는 거래소에서 다양한 메타데이터를 추가적으로 저장하며, 구체적인 내용은 Opensea의 가이드라인을 참고하시기 바랍니다.

Metadata Standards
How to add rich metadata to your ERC721 or ERC1155 NFTs

[OpenZeppelin] ERC20 Extensions

ERC721도 다양한 확장 기능을 추가할 수 있습니다.

  • ERC721Pausable: 토큰 전송 일시 중지
  • ERC721Burnable: 토큰 소각
  • ERC721URIStorage: 스토리지 기반 URI 관리
  • ERC721Votes: 투표
  • ERC721Royalty: ERC-2981 NFT Royalty Standard에 따른 로열티 지불

또한 ERC721의 코드를 개선한 버전들도 존재합니다.

  • ERC721A: Azuki팀에서 만든 표준. 여러 최적화를 통해 가스비를 줄임.
  • ERC721R: 러그를 방지한 환불 기능 제공

Reference