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 구현
이번에도 각 파일의 함수에서 핵심적인 구현을 살펴보겠습니다.
[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가 없는 함수입니다.
transfer
와 transferFrom
이 따로 존재했는데, 이제는 합쳐진 것도 살펴볼만한 포인트입니다.승인 기능은 총 4가지입니다. 권한 제공을 개별 또는 전체로 하는 것에 따라 분류됩니다.
approve(to, tokenId)
: 개별 토큰 전송 권한 제공getApproved(tokenId)
: 토큰 거래 권한을 가진 주소 반환setApprovalForAll(operator, _approved)
: 모든 토큰 전송 권한 제공 또는 취소isApprovedForAll(owner, operator)
: 모든 토큰 전송 권한을 가진 주소임을 확인
[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 구문에 대한 설명은 다음 글을 참고해주세요.
[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의 가이드라인을 참고하시기 바랍니다.
[OpenZeppelin] ERC20 Extensions
ERC721도 다양한 확장 기능을 추가할 수 있습니다.
ERC721Pausable
: 토큰 전송 일시 중지ERC721Burnable
: 토큰 소각ERC721URIStorage
: 스토리지 기반 URI 관리ERC721Votes
: 투표ERC721Royalty
:ERC-2981 NFT Royalty Standard
에 따른 로열티 지불
또한 ERC721의 코드를 개선한 버전들도 존재합니다.
ERC721A
: Azuki팀에서 만든 표준. 여러 최적화를 통해 가스비를 줄임.ERC721R
: 러그를 방지한 환불 기능 제공