Blockchain

#30 ERC-721 토큰 만들기 Ch3

Sila 2022. 8. 2. 22:05

지금까지 공부해온 ERC-721 관련 컨트랙트들에 관한 지식을 기반으로 실제로 nft 토큰을 만들고 배포해보자.

 

ch2까지가 이론편이었다면 이번 편부터는 실전편이라고 생각하면 되겠다.

 

우선 발행자 입장에서 랜덤으로 nft를 민팅할 수 있는 기능을 만들고,

 

사용자 입장에선 그 기능을 사용해 실제로 nft 민팅을 하고, 민팅한 nft를 다른 사람에게 줄 수 있도록 해볼 것이다.

 

오늘은 erc721 관련 컨트랙트들의 함수들을 직접 작성하는 일은 거의 없고

 

오픈 제펠린에서 제공하는 컨트랙트와 함수들을 활용할 것이다.

 

우선 truffle 셋업, open-zeppelin 설치 (contract 폴더 안에) 과정을 실행한 후, abcToken.sol 파일을 contract 폴더에 만들어주자.

 

truffle init
// 트러플 폴더 안에서

npm init
npm i openzeppelin-solidity
// truffle/contract 폴더 안에서

 

1. 토큰 민팅

 

/*  truffle/contracts/abcToken.sol  */

// SPDX-License-Identifier: MIT
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";
// Strings.sol은 데이터 타입을 쉽게 변환하기 위한 기능을 담고 있다. 이를 가져와 활용한다.

contract abcToken is ERC721Enumerable, Ownable {
    uint constant public MAX_TOKEN_COUNT = 1000;
    // 토큰 최대 발행 갯수
    
    uint public mint_price = 1 ether;
    // 민팅 가격 : 불필요한 연산을 줄이기 위해 솔리디티가 ether 단위를 제공한다.
    
    string public metadataURI;
    
    constructor(string memory _name, string memory _symbol, string memory _metadataURI) ERC721 (_name, _symbol) {
        metadataURI = _metadataURI;
    }
    
    // 토큰에는 여러 가지 속성이 있을 수 있지만 여기서는 랭크와 타입 2가지만을 설정해준다.
    struct TokenData {
        uint Rank;
        uint Type;
    }
    
    // 토큰 아이디와 토큰 정보를 연결하는 매핑
    mapping(uint => TokenData) public TokenDatas;
   
    // 토큰에 대한 정보 (json)을 응답으로 줄 uri를 만들어주는 함수
    // 이 함수는 원래 있는 함수를 상속받아 오되, 수정을 가할 것이므로 override를 붙인다.
    function tokenURI(uint _tokenId) public override view returns(string memory) {
        // 토큰 정보를 주는 uri는 랭크, 타입에 따라 달라져야 하므로 uri에 이 값이 포함되어야 한다.
        string memory Rank = Strings.toString(TokenDatas[_tokenId].Rank);
        string memory Type = Strings.toString(TokenDatas[_tokenId].Type);
        
        return string(abi.encodePacked(metadataURI,"/", Rank, "/", Type, ".json"));
        // Rank, Type 값을 포함한 uri를 만든다.
        // Rank, Type은 uint 타입이지만 Strings.sol의 기능을 이용해 쉽게 문자화할 수 있다.
    }
    
    // 난수를 생성하고 그 값을 이용해 토큰의 Rank, Type을 결정하는 함수 생성
    function getRandomNum (address _owner, uint _tokenId) private pure returns(TokenData memory) {
        uint rendomNum = 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 mintToken() public payable {
        require(msg.value == mint_price);
        require(MAX_TOKEN_COUNT > totalSupply());
        
        // 새로 만들어질 토큰의 tokenId 설정
        uint tokenId = totalSupply() + 1;
        
        TokenDatas[tokenId] = getRandomNum(msg.sender, tokenId);
        // tokenId와 data의 매핑 관계 형성
        
        payable(Ownable.owner()).transfer(msg.value);
        // 이더리움을 지불하고 민팅을 진행한다
        _mint(msg.sender, tokenId);
    }
}

 

여기까지 했으면 remix.ethereum 웹 사이트에 들어가 테스트를 진행해볼 것이다.

 

ganache-cli를 실행하고 Remix와 연결한 다음 컴파일을 진행하고

 

name, symbol, metadataURI를 입력한 후, deploy를 진행한다.

 

metadataURI는

 

https://gateway.pinata.cloud/ipfs/QmUsEKtVS5Gn4rZWbYfD7D4qLLKPf1YbsBWYaSqtmCPBzf 

 

를 입력하면 된다. 

 

(해당 uri는 nft에 관련된 json들을 보관해주는 사이트인데 이런 json 보관용 서버가 존재한다는 건

 

nft가 완전한 탈중앙화는 아니라는 것을 의미한다.)

 

 

배포가 문제없이 진행되었다면 mintToken 버튼을 몇 번 클릭해 토큰을 몇개 민팅하고,

 

(1 ether를 주어야 하는 걸 잊지 말 것)

 

tokenURI 함수를 tokenId를 넣고 호출해보자.

 

 

내가 tokenId가 3인 토큰의 uri를 함수를 통해 찾아낸다면 다음과 같이 metadataURI 와 type, rank가 있는 uri가 반환된다.

 

이 uri에 접속하면 이 토큰에 대한 json을 확인할 수 있다.

 

2. 토큰 소유권 이전

무작위적인 속성값들을 갖는 토큰을 생성하는 것까진 완료했으니, 이제 만든 토큰의 소유권을 바꾸는 코드들을 작성해보자.

 

contracts 폴더에 saleToken.sol 파일을 새로 만들어준다. 이 컨트랙트에는 ca를 이용해 특정한 토큰을 가져온다.

 

2.1 abcToken 가져오기

/*  truffle/contracts/saleToken.sol  */

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.15;

import "./abcToken.sol";

contract SaleToken {
    abcToken public Token;
    // Token 데이터 타입의 변수 abcToken 선언
}

 

여기서 abcToken이라는 변수는 우리가 방금까지 작업하던 abcToken 컨트랙트와는 무관하다.

 

단지 SaleToken 컨트랙트 안에서 선언한 Token (데이터 타입이 Token) 형태의 변수일 뿐이다.

 

생성자 함수에서 우리가 만든 abcToken 컨트랙트의 ca를 이용해 abcToken 컨트랙트를 가져와 이 변수 안에 담아줄 것이다.

 

/*  truffle/contracts/saleToken.sol  */

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.15;

import "./abcToken.sol";

contract SaleToken {
    abcToken public Token;
    // abcToken 데이터 타입의 변수 Token 선언
    
    constructor(address _tokenAddress) {
        Token = abcToken(_tokenAddress);
        // abcToken 변수에 ca를 이용해 원하는 토큰을 (함수, 매핑 등 정보를 포함한)
        // 가져와 변수에 넣는다.
    }
}

 

가져올 토큰의 ca를 이용해 그 토큰의 정보들을 변수에 담는다는 말은 비단 우리가 만든 abcToken (컨트랙트) 뿐만 아니라 다른 토큰도

 

ca를 안다면 그걸 가져올 수 있다는 것을 의미한다.

 

2.2 list/delist

상점에서 물건을 판매할 때 판매 중인 물건만을 진열대에 올리는 것처럼 우리들의 토큰을 판매하기 위해서도

 

우선 진열대에 토큰을 올리는 과정이 선행되어야 한다. 이를 리스팅 (list) 이라고 한다.

 

 

바이낸스에서 필자가 실수로 산 nft인데, 이를 판매하려면 list nft를 클릭하면 된다.

 

이상하게도 nft를 살 때는 정말로 살거냐고 물어보지 않고 클릭 한 방에 구매가 완료되어 

 

돈이 빠져나가는 경우가 유독 많으니 혹시라도 관련 사이트를 둘러볼 기회가 있다면 조심하자.

 

 

 

아무튼 '진열대에 올리는 과정' 이 코드 상에서는 '판매할 토큰의 id를 배열에 추가하는 일'로 치환된다.

 

반대로 진열대에서 내리는 과정 (마음을 바꿔 판매를 안하고 갖고 있기로 했다던가, 팔렸다던가)은 이 배열에서 토큰의 id를 빼면 된다.

 

/*  truffle/contracts/saleToken.sol  */

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.15;

import "./abcToken.sol";

contract SaleToken {
    abcToken public Token;
    // abcToken 데이터 타입의 변수 Token 선언
    
    constructor(address _tokenAddress) {
        Token = abcToken(_tokenAddress);
        // abcToken 변수에 ca를 이용해 원하는 토큰을 (함수, 매핑 등 정보를 포함한)
        // 가져와 변수에 넣는다.
    }
    
    //토큰 아이디와 가격 매핑
    mapping(uint => uint) public tokenPrices;
    
    // 판매중인 토큰을 넣을 배열
    uint[] public SaleTokenList;
    
    // 토큰 리스팅 함수
    function ListingToken(uint _tokenId, uint _price) public {
        // 매개 변수는 '어떤 토큰'을 '얼마에' 팔지를 알려준다.
        address tokenOwner = Token.ownerOf(_tokenId);
        require(tokenOwner == msg.sender);
        require(_price > 0);
        
        require(Token.isApprovedForAll(msg.sender, address(this)));
        // 위임여부 확인 함수는 abcToken이 ERC721함수를 상속했으므로
        // 이를 가져온 Token 변수 내부에도 존재하므로 사용 가능하다.
        
        tokenPrices[_tokenId] = _price;
        // tokenId와 가격의 매핑 관계 형성
        
        SaleTokenList.push(_tokenId);
        // 배열에 판매할 토큰 추가
    }
    
    // 토큰 리스팅 함수
    function DelistingToken(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();
                // 반복문을 통해 배열에서 뺄 tokenId를 찾은 후 제거
                
                return true
                // 배열에 있고, 제거에 성공했다면 true를 반환
            }
        }
        return false;
    }
    
}

 

delist가 일어나는 경우는 두 가지가 있는데, 판매자가 스스로 판매를 취소하거나,

 

토큰을 구입하는 사람이 나타나 소유권이 변하는 경우이다. 각각의 경우에 대해 함수를 만들어주자.

 

/*  truffle/contracts/saleToken.sol  */

// ...중략

// 판매 취소 함수
function cancelSale(uint _tokenId) public {
    address tokenOwner = Token.ownerOf(_tokenId);
    require(tokenOwner == msg.sender);
    require(tokenPrices[_tokenId] > 0);
    // 가격이 0보다 크면 판매중인 토큰 그렇지 않다면 판매중이지 않은 토큰으로 간주할 수 있다
    
    tokenPrices[_tokenId] = 0;
    DelistingToken(_tokenId);
    // DelistingToken 함수를 호출해 배열에서 제거
}

// 토큰 구매 함수
function PurhaseToken(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);
    // ca가 토큰 오너에게 구매자가 지불한 msg.value만큼의 이더리움을 준다.
    
    Token.transferFrom(tokenOwner, msg.sender, _tokenId);
    // 토큰 (nft)이 소유권을 변경한다.
    
    tokenPrices[_tokenId] = 0;
    DelistingToken(_tokenId);
    // 거래가 완료되면 가격을 없애고 디리스팅 진행
}

 

여기까지 했으면 다시 remix.ethereum으로 돌아간다.

 

2.3 토큰 거래

이제부터는 ganache에서 만들어준 지갑중 3번, 4번 지갑을 사용한다.

 

3번 지갑으로 3개의 토큰을 민팅한 후, 그 중 하나의 토큰을 위임하고 리스팅한 후, 4번 지갑으로 토큰을 구매할 것이다.

 

(배포한 지갑은 1번 지갑이라고 생각하자)

 

SaleToken.sol 을 컴파일하고, 배포할 때 필요한 것은 생성자 함수에서 정했듯 가져올 토큰의 ca이다.

 

먼저 배포한 abcToken의 casms Deployed Contracts에 나와 있으므로 이걸 복사해 _tokenaddress에 붙여넣은 후 배포한다.

 

 

 

이제 3번 지갑으로 바꿔서 토큰을 3개 정도 민팅해주자. (value란을 1 ether로 바꾸는 걸 잊지 말 것)

 

 

민팅한 토큰중 1번 토큰을 판매해보도록 하겠다.

 

우선 리스팅과 구매, 판매 함수가 실행될 컨트랙트에 내 토큰을 위임해주어야 한다.

 

 

다음과 같이 abcToken에서 setApprovalForAll 함수를 실행해 위임을 진행할 수 있다.

 

operator(위임 받을 address)는 SaleToken 의 ca를, approved는 true를 입력하고 tx 한다.

 

이러면 이제 SaleToken 컨트랙트에서 내 토큰 사용에 대한 위임을 받아 리스팅, 구매, 판매를 대리해줄 수 있다.

 

위임이 잘 진행되었는지 확인하려면 abcToken > iaApprovedForAll 에서 owner와 operator를 넣고 확인해보자.

 

 

이제 SaleToken 컨트랙트에서 위임받은 토큰을 리스팅한다.

 

 

리스팅할 토큰과 (현재 전체 토큰을 위임하는 함수를 실행했으므로 3번 지갑 소유의 모든 토큰이 리스팅 가능하다)

 

판매 가격을 정한 후 tx를 실행한다.

 

리스팅이 잘 되었는지 확인하기 위해 바로 아래의 tokenPrices 함수에 tokenId를 호출할 수 있다.

 

나같은 경우 1을 넣고 호출하면 가격인 2가 반환될 것이다.

 

리스팅하지 않은 토큰의 경우 0을 리턴한다.

 

 

이제 다른 지갑으로 바꿔서 리스팅된 토큰을 구입해보자.

 

4번 지갑으로 SaleToken의 purchaseToken 함수를 실행하는데, 매개 변수는 1 (구입할 토큰의 id값) 을 주고,

 

상단에서 value를 2 ether로 바꿔준 후, tx를 발생시키면 토큰의 거래가 실행된다.

 

거래가 성공했는지 확인하는 방법은 2가지를 생각해볼 수 있다.

 

SaleToken 컨트랙트에서 tokenPrices 함수를 다시 호출해 가격이 0으로 변했는지 확인하거나,

 

abcToken 컨트랙트에서 ownerOf 함수를 실행해 owner가 바뀌였는지 확인해보면 된다.