MetaData는 NFT의 핵심으로 JSON 파일로 되어있으며 아래와 같은 내용을 가지고 있습니다.
NFT의 이름
NFT에 대한 설명
- 호스팅된 이미지의 링크
- 특성들
- 이 외에 어떤 값이든 추가할 수 있지만
OpenSea와 같은 NFT Market 플랫폼에서 요구하는 변수명으로 입력하는 내용만 화면에 보여줄 수 있습니다.
NFT 구매 및 판매 Contract 작성
open-zeppelin 라이브러리를 설치하여 작업하였고 remix IDE를 사용하여 배포 및 테스트를 해볼 것입니다.
NFT를 발행할 Contract
pragma solidity ^0.8.15;
import "./node_modules/openzeppelin-solidity/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "./node_modules/openzeppelin-solidity/contracts/access/Ownable.sol";
import "./node_modules/openzeppelin-solidity/contracts/utils/Strings.sol";
contract SwanToken is ERC721Enumerable, Ownable {
uint constant public MAX_TOKEN_COUNT = 1000;
uint public mint_price = 1 ether;
string public metadataURI;
constructor(string memory _name, string memory _symbol, string memory _metadataURI) ERC721(_name,_symbol){
metadataURI = _metadataURI;
}
struct TokenData {
uint Rank;
uint Type;
}
mapping(uint => TokenData) public TokenDatas;
uint[4][4] public tokenCount;
}
- 설치한
open-zeppelin 라이브러리에서 필요한 파일들을 import하여 상속받거나 import한 파일의 함수를 사용하였습니다.
Token의 최대 발행량의 제한을 두기 위해 constant 속성을 사용하여 값을 변경 못하도록 상수로 만들어주고 최대 발행량을 1000으로 제한하였습니다.
- 생성자 함수를 통해 인자값으로 발행할 토큰의 이름, 심볼명,
json파일인 metadata의 URI를 인자값으로 받고 토큰 이름과 심볼명을 가지고 새로운 토큰을 만들어주었고, 인자값으로 받은 _metadataURI을 미리 선언한 metadataURI에 할당하여 주었습니다. 이 metadata URI는 후에 많은 metadata들의 URI에 baseURI 역할을 할 것입니다.
- 발행한
NFT마다 다른 json파일을 가리키기위해 TokenData 구조체를 만들어줍니다. 후에 Rank와 Type으로 서로 다른 metadata URI를 가르키도록 처리할 것입니다.
tokenId에 따른 TokenData를 알고 값을 입력해주기 위해 TokenDatas mapping을 만들어줍니다.
- 각각의
NFT들이 Rank와 Type마다 몇개씩 발행되었는지 사용자에게 알려주기 위해 2차 배열로 tokenCount 상태변수를 만들었습니다
function mintToken() public payable{
require(msg.value == mint_price);
require(MAX_TOKEN_COUNT > totalSupply());
uint tokenId = ERC721Enumerable.totalSupply() + 1;
TokenDatas[tokenId] = getRandomNum(msg.sender, tokenId);
tokenCount[TokenDatas[tokenId].Rank - 1][TokenDatas[tokenId].Type - 1] += 1;
payable(Ownable.owner()).transfer(msg.value);
_mint(msg.sender, tokenId);
}
function mintToken
- 실제로
ETH를 받고 NFT를 발행해줄 mintToken 함수를 만들어줍니다. ETH에 대한 거래를 포함하는 함수이니 payable 함수로 만들어줍니다.
require()를 사용하여 받은 ETH가 NFT를 발행할 ETH와 같은지 확인하고 NFT를 1000개까지만 발행하도록 검증하는 코드를 추가하였습니다.
NFT의 고유한 값인 tokenId는 총 발행된 NFT의 갯수의 + 1을하여 최초 발행시에는 1, 그후로 1씩 Auto Increment 처리하였습니다.
- 발행한
NFT마다 metadata의 구분을 짓기 위한 Rank와 Type을 getRandomNum 함수를 사용하여 랜덤한 값으로 할당받도록 처리하였습니다. 뒤에서 getRandomNum 함수의 code를 살펴보도록 하겠습니다.
- 발행한
NFT가 Rank 1, Type 1 이란 metadataURI를 가진 NFT로 만들어졌다면 2차 배열인 tokenCount의 해당 Rank와 Type에 위치한 index의 Count를 + 1하여 Rank 1, Type 1이란 NFT가 1개 발행되었다고 알 수 있도록 하였습니다.
NFT를 발행할 때 받은 ETH를 CA계정이 아닌 해당 Contract를 배포한 EOA계정에게 바로 ETH를 보내주었습니다.
- 마지막으로
_mint 함수를 사용하여 함수를 실행한 계정에게 발행한 NFT의 소유권을 할당하였습니다.
function tokenURI(uint _tokenId) public override view returns(string memory) {
string memory Rank = Strings.toString(TokenDatas[_tokenId].Rank);
string memory Type = Strings.toString(TokenDatas[_tokenId].Type);
return string(abi.encodePacked(metadataURI,"/",Rank,"/",Type,".json"));
}
function tokenURI
open-zeppelin에서 import해온 Strings 파일의 toString 함수를 사용하여 uint Type으로 반환되는 Rank와 Type의 값을 byte code로 변환 후 다시 string type으로 변환하여 memory에 저장해주었습니다.
tokenId에 Random 값인 Rank와 Type으로 NFT의 metadata인 json파일의 URI값을 만들어 반환해주었습니다.
function getRandomNum(address _owner, uint _tokenId) private pure returns(TokenData memory){
uint randomNum = uint(keccak256(abi.encodePacked(_owner, _tokenId)))%100;
TokenData memory data;
if (randomNum < 5) {
if (randomNum == 1) {
data.Rank = 4;
data.Type = 1;
} else if (randomNum == 2) {
data.Rank = 4;
data.Type = 2;
} else if (randomNum == 3) {
data.Rank = 4;
data.Type = 3;
} else {
data.Rank = 4;
data.Type = 4;
}
} else if (randomNum < 13) {
if (randomNum < 7) {
data.Rank = 3;
data.Type = 1;
} else if (randomNum < 9) {
data.Rank = 3;
data.Type = 2;
} else if (randomNum < 11) {
data.Rank = 3;
data.Type = 3;
} else {
data.Rank = 3;
data.Type = 4;
}
} else if (randomNum < 37) {
if (randomNum < 19) {
data.Rank = 2;
data.Type = 1;
} else if (randomNum < 25) {
data.Rank = 2;
data.Type = 2;
} else if (randomNum < 31) {
data.Rank = 2;
data.Type = 3;
} else {
data.Rank = 2;
data.Type = 4;
}
} else {
if (randomNum < 52) {
data.Rank = 1;
data.Type = 1;
} else if (randomNum < 68) {
data.Rank = 1;
data.Type = 2;
} else if (randomNum < 84) {
data.Rank = 1;
data.Type = 3;
} else {
data.Rank = 1;
data.Type = 4;
}
}
return data;
}
function getRandomNum
abi의 encodePacked 메서드를 사용하여 Type과 상관없이 인자값의 값들을 이어붙이고 그 값을 keccak256 메서드를 사용하여 해쉬화하고 그값을 다시 uint type으로 변환해주고 % 100으로 그 값의 마지막 두자리만 가져옵니다.
TokenData Type의 data를 memory로 선언하고 if문으로 일정 확률로 Rank와 Type의 값을 할당해주고 해당 값을 return하였습니다.
- 이렇게
Random한 요소로 생성돠는 NFT의 Rank와 Type으로 구매자의 NFT metadata를 마치 뽑기처럼 랜덤으로 처리해주었습니다.
function setMetaDataURI(string memory _uri) public onlyOwner {
metadataURI = _uri;
}
function getTokenRank(uint _tokenId) public view returns(uint) {
return TokenDatas[_tokenId].Rank;
}
function getTokenType(uint _tokenId) public view returns(uint) {
return TokenDatas[_tokenId].Type;
}
function getTokenCount() public view returns(uint[4][4] memory) {
return tokenCount;
}
function setMetaDataURI
- 혹시 후에
metadata URI를 수정해야 할 수도있으니 metadata URI을 수정하는 함수를 만들어줍니다. onlyOwner 속성을 사용하여 해당 함수는 Contract를 배포한 사람만 수정할 수 있도록 처리하였습니다.
function getTokenRank
tokenId를 입력하였을 때 해당 NFT의 Rank를 return해줍니다.
function getTokenType
tokenId를 입력하였을 때 해당 NFT의 Type를 return해줍니다.
function getTokenCount
NFT들이 Rank와 Type마다 몇개씩 발행되었는지를 알 수 있는 tokenCount를 return해줍니다. 굳이 새로운 getter 함수를 만드는 이유는 기존 배열의 getter 함수는 하나의 값만 return 해주었지만 지금은 배열의 모든 값을 확인해야 하는 상황입니다. 이같은 상황에서 memory 속성을 return 값에 추가하면 함수 내부적으로 배열의 모든값들을 memory에 새로 추가하여 그 값을 return해주는 것 같습니다.
NFT를 구매 및 판매할 Contract
pragma solidity ^0.8.15;
import "./SwanToken.sol";
contract SaleToken {
SwanToken public Token;
constructor(address _tokenAddress) {
Token = SwanToken(_tokenAddress);
}
struct TokenInfo {
uint tokenId;
uint Rank;
uint Type;
uint price;
}
mapping(uint => uint) public tokenPrices;
uint[] public SaleTokenList;
}
NFT를 발행할 SwanToken Contract의 CA값을 생성자 함수 인자값에 넣어 Token이라는 상태 변수에 할당하여 SwanToken Contract의 메서드들을 사용할 수 있도록 하였습니다.
NFT의 메타 데이터에 들어가는 내용들을 화면에 보여주기 편하도록 TokenInfo라는 구조체를 선언하였습니다.
- 마지막으로
NFT의 판매가를 설정할 tokenPrices 매핑과 NFT의 판매중 여부를 판단하는 SaleTokenList 배열을 선언하였습니다.
function SalesToken(uint _tokenId, uint _price) public {
address tokenOwner = Token.ownerOf(_tokenId);
require(tokenOwner == msg.sender);
require(_price > 0);
require(Token.isApprovedForAll(msg.sender, address(this)));
tokenPrices[_tokenId] = _price;
SaleTokenList.push(_tokenId);
}
function SalesToken
NFT보유자가 NFT를 판매 등록할 함수를 만들어줍니다. NFT의 소유자를 가져오고 소유자와 함수를 실행한 사람이 같은지 확인하고 Token에 있는 isApprovedForAll 메서드를 사용하여 SaleToken의 CA값이 NFT 판매자의 대리인이 맞는지 확인합니다.
- 위의 검증을 모두 통과하였으면
tokenPrices에 해당 NFT의 tokenId값과 판매가를 할당하고 판매중인 NFT 목록인 SaleTokenList에 판매할 tokenId값을 넣어줍니다.
function BuyToken(uint _tokenId) public payable {
address tokenOwner = Token.ownerOf(_tokenId);
require(tokenOwner != msg.sender);
require(tokenPrices[_tokenId] > 0);
require(tokenPrices[_tokenId] <= msg.value );
payable(tokenOwner).transfer(msg.value);
Token.transferFrom(tokenOwner, msg.sender, _tokenId);
tokenPrices[_tokenId] = 0;
popSaleToken(_tokenId);
}
function BuyToken
NFT 판매 등록함수를 만들었으니 이제 NFT를 구매할 함수를 만들어줍니다. 구매하려는 NFT의 tokenId값을 가지고 NFT의 소유자를 가져온 후 구매자와 NFT 판매자가 혹시 같은지 확인합니다. 또한 구매자가 구매하려는 NFT가 현재 판매중인지 확인하고 마지막으로 구매자가 NFT 판매가에 알맞게 ETH를 보냈는지 확인합니다.
- 위의 검증을 모두 통과할 경우 NFT는 환불 기능이 없으니 바로
CA계정에서 받은 ETH를 SaleToken Contract를 배포한 EOA계정에게 보내줍니다.
- 이제 판매중이던
NFT를 판매 대리인이 구매자에게 보내줍니다. 판매가 완료된 NFT니 판매중인 NFT 매핑인 tokenPrices 매핑에서 해당 tokenId에 0을 할당하여 판매중이지 않은 NFT로 처리합니다.
- 마지막으로 판매중인
NFT 배열에서 판매 완료된 NFT를 제거하기 위해 popSaleToken 함수를 실행합니다. 해당 함수에 대한 해석은 뒤에서 설명하겠습니다.
function cancleSaleToken(uint _tokenId) public {
address tokenOwner = Token.ownerOf(_tokenId);
require(tokenOwner == msg.sender);
require(tokenPrices[_tokenId] > 0);
tokenPrices[_tokenId] = 0;
popSaleToken(_tokenId);
}
function cancleSaleToken
NFT 판매등록을 하고 취소하고 싶을수도 있으니 NFT 판매 취소를 처리할 함수를 만들어줍니다. 먼저 NFT의 소유자를 가져오고 소유자와 함수를 실행한 계정과 같은지 확인하고 해당 NFT가 판매중이던 상품이었는지 확인합니다.
NFT 판매 목록 매핑인 tokenPrices에서 해당 tokenId에 0을 할당하여 판매중이지 않은 NFT로 처리합니다
- 마지막으로 판매중인
NFT 목록 배열에서 해당 NFT를 제거하기위해 popSaleToken 함수를 실행합니다. 해당 함수에 대한 해석은 뒤에서 설명하겠습니다.
function popSaleToken(uint _tokenId) private returns(bool) {
for(uint i = 0; i < SaleTokenList.length; i++) {
if(SaleTokenList[i] == _tokenId) {
SaleTokenList[i] = SaleTokenList[SaleTokenList.length-1];
SaleTokenList.pop();
return true;
}
}
return false;
}
function popSaleToken
- 판매가 완료되거나 판매를 취소할때 판매중인
NFT 배열에서 해당 NFT를 제거하는 함수를 만들어줍니다.
- 반복문을 사용하여 판매중인
NFT 목록인 SaleToken안에 모든 NFT들을 확인하여 판매 목록에서 제거할 NFT의 tokenId와 일치하는 NFT를 찾으면 해당 index에 위치한 값을 배열 마지막 index의 값으로 변경합니다. 이렇게하면 배열의 마지막 index의 위치했던 tokenId값은 2개가 되고 제거해야할 tokenId값은 사라지게 되었을것입니다. 이제 pop 메서드를 사용하여 배열의 마지막 index의 tokenId값을 제거하여 중복된 tokenId값을 하나 제거합니다.
function getSaleTokenList() public view returns(TokenInfo[] memory){
require(SaleTokenList.length > 0);
TokenInfo[] memory list = new TokenInfo[](SaleTokenList.length);
for(uint i = 0; i < SaleTokenList.length; i++) {
uint tokenId = SaleTokenList[i];
uint Rank = Token.getTokenRank(tokenId);
uint Type = Token.getTokenType(tokenId);
uint price = tokenPrices[tokenId];
list[i] = TokenInfo(tokenId, Rank, Type, price);
}
return list;
}
function getSaleTokenList
- 판매중인 모든
NFT들을 화면에 보여줄 함수를 만들어줍니다. TokenInfo type에 맞는 list라는 새로운 배열을 선언하는데 memory 속성을 사용하여 해당 함수 내에서만 사용하도록 하였고 판매중인 NFT 목록인 SaleTokenList와 같은 길이로 생성하였습니다.
- 판매중인
NFT 목록인 SaleTokenList을 모두 확인하여 tokenId값을 가져온뒤 해당 Token에 해당하는 Rank와 Type는 SwanToken Contract에서 생성한 메서드들을 가지고 각각 구해오고 해당 NFT의 판매가격을 가지고 있는 매핑을 통해 해당 tokenId의 price를 구해옵니다.
- 구해온 값들을
list 배열안에 새로 할당합니다. list 배열안에는 각 Token별 tokenInfo 구조체가 들어갈 것입니다.
- 마지막으로 각
Token에 정보들이 담긴 list 배열을 return합니다.
function getOwnerTokens(address _tokenOwner) public view returns(TokenInfo[] memory){
uint balance = Token.balanceOf(_tokenOwner);
require(balance != 0);
TokenInfo[] memory list = new TokenInfo[](balance);
for(uint i = 0; i < balance; i++) {
uint tokenId = Token.tokenOfOwnerByIndex(_tokenOwner, i);
uint Rank = Token.getTokenRank(tokenId);
uint Type = Token.getTokenType(tokenId);
uint price = tokenPrices[tokenId];
list[i] = TokenInfo(tokenId, Rank, Type, price);
}
return list;
}
function getOwnerTokens
- 서비스 이용자가 본인이 소유하고 있는
NFT를 확인하는 함수를 만들어줍니다. 인자값으로 지갑주소를 받고 해당 계정이 가지고 있는 NFT가 있는지 확인합니다.
- 위의 검증을 마쳤다면 본인의
balance, 즉 본인이 소유하고 있는 NFT 갯수와 같은 길이의 배열을 만들어줍니다. 마찬가지로 함수 내에서만 사용하는 상태변수로 memory 속성을 사용하였습니다.
- 반복문을 돌려 사용자가 소유하고 있는
NFT를 모두 확인하여 위의 getSaleTokenList 함수와 같은 방식으로 Token의 정보들을 가져오는데 tokenId를 가져올때만 tokenOfOwnerByIndex 함수를 사용하여 소유자의 n번째있는 NFT의 tokenId를 가져오도록 합시다.
- 마찬가지로 소유자의 모든
Token 정보가 list 배열에 구조체로 들어갈 것입니다. 마지막으로 각 Token에 정보들이 담긴 list 배열을 return합니다.
function getLatestToken(address _tokenOwner) public view returns(TokenInfo memory) {
uint balance = Token.balanceOf(_tokenOwner) - 1;
uint tokenId = Token.tokenOfOwnerByIndex(_tokenOwner,balance);
uint Rank = Token.getTokenRank(tokenId);
uint Type = Token.getTokenType(tokenId);
uint price = tokenPrices[tokenId];
return TokenInfo(tokenId,Rank,Type,price);
}
function getLatestToken
- 마지막으로 사용자가
NFT를 Minting할 때 어떤 Metadata를 가진 NFT인지 알 수 있도록 마지막 NFT 정보를 가져오는 함수를 만들어줍니다.
- 마찬가지로
memory 속성을 사용하고 balanceOf 함수를 사용하여 소유자의 보유한 NFT 갯수를 가져오는데 - 1을 하여 해당 값을 index로하여 tokenId를 가져옵니다.
- 이제 해당
NFT의 Rank, Type를 구해오고 price는 필요없지만 TokenInfo의 type에 price가 있으니 넣어주어 해당 구조체를 return합니다.