현재 진행 중인 프로젝트에는 NFT 발행이 들어간다.
NFT 발행 컨트랙트를 만드는 도중 겪은 어려움을 여기에 적어본다...
ERC-2981: NFT Royalty Standard
ERC-2981 은 NFT 에 대한 로열티의 기준을 정한 Ethereum Improvement Proposals 의 2981 번째 제안이다.
오픈제플린에서 컨트랙트를 제공하고 있다.
이것을 import 해서 사용하면 된다.
사용하기 전에, 간단하게 몇가지 코드만 살펴보면..
struct RoyaltyInfo {
address receiver;
uint96 royaltyFraction;
}
function _setDefaultRoyalty(address receiver, uint96 feeNumerator) internal virtual {
uint256 denominator = _feeDenominator();
if (feeNumerator > denominator) {
// Royalty fee will exceed the sale price
revert ERC2981InvalidDefaultRoyalty(feeNumerator, denominator);
}
if (receiver == address(0)) {
revert ERC2981InvalidDefaultRoyaltyReceiver(address(0));
}
_defaultRoyaltyInfo = RoyaltyInfo(receiver, feeNumerator);
}
_tokenRoyaltyInfo
부분에서 로열티 정보가 저장이 된다.
_setDefaultRoyalty
함수는 로열티를 설정하는 함수다.
먼저, 총 로열티값은 판매가를 넘을 수 없으며, 넘을 경우 revert 된다.
그리고 RoyaltyInfo
구조체에 (수수료를 받을 주소, 수수료)
로 설정이 된다.
사용할 때에는 아래와 같이 컨스트럭터, 인터페이스 함수를 사용해주면 된다.
constructor(string memory _uri, address _Pool) ERC721("test","ttest"){
URI = _uri;
_setDefaultRoyalty(_Pool, 500); // 5% 로열티 설정
}
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721URIStorage, ERC2981) returns (bool) {
return super.supportsInterface(interfaceId);
}
컨스트럭터를 통해 NFT 컨트랙트를 배포할 때, _setDefaultRoyalty(로열티 받을 주소, 로열티)
를 설정할 수 있다.
주의해야할 점은, 설정한 로열티는 개인간의 NFT 전송에는 적용되지 않는다. opensea, rarible 과 같은 NFT 마켓 플레이스에서 로열티를 받을 주소와 로열티를 인식하도록 설정하는 것이다.
컨스트럭터 아래의 인터페이스 함수는 선언만 해주면 된다. ERC2981 컨트랙트를 사용할 수 있는지 확인하는 과정이라고 생각하면 되겠다.
인터페이스 함수를 선언하지 않으면 위의 오류가 뜨며 컴파일링이 되질않는다. 이 부분에서 정말 오랜 시간을 소모했다.
저 오류를 잡기위해서 ERC2981 컨트랙트를 다 뒤져가며 찾아냈던게 생각난다..
덕분에, ERC2981 에서는 왜 인터페이스를 선언해야 했는지 잘 이해하게 되었다.
ERC721URIStorage 는 URI 를 저장하는 스토리지 맵핑을 가지고 있는 ERC721의 Extension 라이브러리이다.
ERC721 을 베이스로, 확장판의 개념으로 URI storage 개념을 추가한 라이브러리라고 할 수 있다.
이 라이브러리를 사용하게 된 계기는 NFT의 무제한 발행과 실시간 데이터를 이용한 NFT 이미지 생성에 있었다.
기존의 ERC721Enumerable 을 사용할 땐, PINATA 에 이미 생성된 이미지와 메타데이터 JSON 파일을 폴더 째로 업로드 했다. 고정된 URI 값에 토큰ID 값만 바뀌기에 토큰ID 상태변수를 읽어오면 쉽게 민팅이 가능했다.
하지만 현재의 프로젝트는 다르게 민팅을 하는데, 민팅 버튼을 누르면 아래의 순서대로 민팅이 된다.
- 현재의 UTC 시간 값, 현재 토큰의 가격 정보등을 담은 이미지를 생성.
- 생성된 이미지를 PINATA 로 업로드.
- 업로드된 이미지의 IpfsHash 값을 받아와 메타데이터 생성.
- 메타데이터를 PINATA 로 업로드.
- 업로드된 메타데이터의 IpfsHash 값을 return.
- return 받은 IpfsHash 값을 컨트랙트의 함수로 전달, 함수 실행.
- 함수 내의 로직에서 BaseUri + IpfsHash 를 합쳐 URI 생성 후 민팅.
5번을 통해 return 받은 ipfsHash 값이 매번 달랐다.
ERC721Enumerable 을 사용하는 경우엔 계속 변하는 ipfsHash 값을 토큰id 마다 지정할 수 없었다.
tokenURI
함수를 오버라이딩하려 했으나 불가능했고, 새로운 함수를 만들어서 사용했으나 역시나 민팅을 해도 오픈씨에서 메타데이터를 인식할 수 없는 현상이 발생했다..
위의 사진처럼 아무런 메타데이터를 받아오지 못하기에, 7번의 URI 생성과정에서 문제가 생긴것으로 생각했다.
그래서 return string(abi.encodePacked(BaseURI , _ipfsHash))
이 코드를 작성해서 return 되는 URI 값을 확인했다.
하지만, return 되는 URI 값은 문제가 없었고, 따라서 ERC721 컨트랙트의 URI 부분을 다 찾아봤지만 머리만 복잡해지고 해결은 할 수 없었는데..
그때 URIStorage 를 발견하고 혹시나 하는 마음에 컨트랙트 코드를 뒤져보았다.
코드의 몇가지 부분만 가져왔다.
mapping(uint256 => string) private _tokenURIs;
function _setTokenURI(uint256 tokenId, string memory _tokenURI) internal virtual {
if (!_exists(tokenId)) {
revert ERC721NonexistentToken(tokenId);
}
_tokenURIs[tokenId] = _tokenURI;
emit MetadataUpdate(tokenId);
}
이 코드에서는 _tokenURIs
맵핑을 선언해서, 각 토큰id 마다 URI 를 지정하는 방식을 쓰고 있다.
_setTokenURI
함수를 사용하면 위의 맵핑을 사용해 토큰id 마다의 URI 를 지정한다.
이렇게 하면 토큰마다의 URI 를 지정해서 민팅할 수 있다.
정리하자면,
TokenId 값으로 URI 지정을 할 수 없을 때 ERC721URIStorage
를 사용하면 되겠다.
아래와 같이 오픈씨를 통해 정상적으로 민팅되었음을 확인할 수 있다.
맨 아래 Creator Earnings 부분에 수수료도 정상적으로 설정된 것을 볼 수 있다.
예시 코드는 아래와 같다.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.18;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract Mint721Token is ERC721URIStorage, ERC2981 {
string public URI;
uint public tokenId;
constructor(string memory _uri, address _Pool) ERC721("test","ttest"){
URI = _uri;
_setDefaultRoyalty(_Pool, 500); // 5% 로열티 설정.
}
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721URIStorage, ERC2981) returns (bool) {
return super.supportsInterface(interfaceId);
}
function mintNFT_Cover(string memory _ipfsHash) public {
_mint(msg.sender, tokenId);
_setTokenURI(tokenId++, string(abi.encodePacked(URI , _ipfsHash)));
}
}