Blockchain

#32 NFT Minting dApp 만들기 ch2

Sila 2022. 8. 5. 14:53

 

저번에 했던 것에 이어서 이번에는 디앱과 컨트랙트에 사용자 편의적인 기능을 좀 추가하고,

 

토큰 판매 기능을 구현해보려고 한다.

 

이를 위해 우선 컨트랙트를 좀 수정하고 (약간의 함수와 변수의 추가),

 

이를 사용할 ui를 만들어주면 되겠다.

 

1. 컨트랙트 수정

abcToken에는 우선 metadataURI를 바꿀 수 있는 (컨트랙트 배포자만 사용가능한) 함수를 넣고,

 

사용자가 원하는 경우 특정 토큰의 rank나 type을 볼 수 있게 해주는 함수를 추가할 것이다.

 

또, 지금까지 민팅된 토큰 통계를 볼 수 있는 함수도 만들어보자.

 

(어떤 type은 지금까지 몇 개 배포되었는지와 같은 정보들)

 

/*  truffle/contracts/abcToken.sol. */

// ...중략

contract abcToken is ERC721Enumerable, Ownable {
    uint constant public MAX_TOKEN_COUNT = 1000;
    uint public mint_price = 1 ether;
    string public metedataURI;
    
    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;
    // tokenId로 그 토큰에 대한 데이터를 볼 수 있는 매핑 추가
    
    uint[4][4] public tokenCount;
    // rank, type의 통계에 대한 정보를 2중 배열로 나타낸다.
    //[[1,2,3,4],[5,6,7,8],[1,2,3,4],[5,6,7,8]]
    // 라고 하면 배열안의 두번째 배열에 세번째 요소는 rank=2, type=3인 토큰이 7개 있다는 의미
    
    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"));
    }
    
    function mintToken() public payable {
        require(msg.value == mint_price);
        require( MAX_TOKEN_COUNT > totalSupply());

        uint tokenId = totalSupply() + 1;
        // 새로 만들어질 토큰의 tokenId 설정

        TokenDatas[tokenId] = getRandomNum(msg.sender, tokenId);
        
        tokenCount[TokenDatas[tokenId].Rank-1][TokenDatas[tokenId].Type-1] += 1;
        // 토큰이 민팅될 때마다 랭크, 타입에 따라 배열의 요소값을 1씩 추가해준다.

        payable(Ownable.owner()).transfer(msg.value);
        _mint(msg.sender, tokenId);
    }
    
    function getRandomNum (address _owner, uint _tokenId) private pure returns (TokenData memory) {
        // ...중략
    }
    
    // 하단의 함수들을 추가
    
    function setMetadataURI(string memory _uri) public onlyOwner() {
        // onlyOwner는 ownerable 컨트랙트에서 제공하는 함수인데, 배포자만이 함수를
        // 실행할 수 있도록 제한을 걸어준다.
        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;
    }
    
}

 

다음으로 saleToken.sol에서 판매에 관련된 데이터들을 추가해주자.

 

/*  truffle/contracts/SaleToken.sol  */

// ...중략

contract SaleToken {
    abcToken public Token;
    
    constructor(address _tokenAddress) {
        Token = abcToken(_tokenAddress);
    }
    
    // 토큰에 대한 정보를 담은 구조체 형성 
    struct TokenInfo {
        uint tokenId;
        uint Rank;
        uint Type;
        uint price;
    }
    
    // tokenId와 price 매핑
    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)));

        tokenPrices[_tokenId] = _price;
        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();
                return true;
            }
        }
        return false;
    }

    function cancelSaleToken(uint _tokenId) public {
        address tokenOwner = Token.ownerOf(_tokenId);
        require(tokenOwner == msg.sender);
        require(tokenPrices[_tokenId] > 0);
        // 가격이 0보다 크면 판매중인 토큰, 0이면 판매중이 아닌 토큰으로 간주

        tokenPrices[_tokenId] = 0;
        deListingToken(_tokenId);
    }

    function PurchaseToken(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;
        deListingToken(_tokenId);
    }
    
    // 하단의 함수들을 추가
    
    // 판매중인 토큰리스트를 보여주는 함수
    function getSaleTokenList() public view returns(TokenInfo[] memory) {
        // TokenInfo 구조체를 요소로 가지는 배열을 리턴
        require(SaleTokenList.length > 0);
        TokenInfo[] memory list = new TokenInfo[](SaleTokenList.length);
        // TokenInfo를 요소로 가지는 배열형태인 변수 list 선언
        // 배열의 요소 갯수는 SaleTokenList(배열)과 동일하다.

        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);
        }
        // 컨트랙트의 SaleTokenList 배열에서 tokenId를 가져와 그 토큰의 정보를 구해
        // 그 정보를 기반으로 구조체를 만들어 배열에 삽입
        
        return list;
    }

    // address가 가진 토큰을 보여주는 함수
    function getOwnerTokens(address _tokenOwner) public view returns(TokenInfo[] memory) {
        uint balance = Token.balanceOf(_tokenOwner);
        require(balance != 0);

        // getSaleTokenList 함수처럼 TokenInfo 구조체를 만들어 그걸 배열로 줄 것이다.
        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;
    }

    // address의 가장 마지막 토큰을 보여주는 함수
    function latestToken (address _tokenOwner) public view returns(TokenInfo memory) {
        uint balance = Token.balanceOf(_tokenOwner);
        uint tokenId = Token.tokenOfOwnerByIndex(_tokenOwner, balance-1);

        uint Rank = Token.getTokenRank(tokenId);
        uint Type = Token.getTokenType(tokenId);
        uint price = tokenPrices[tokenId];

        return TokenInfo(tokenId, Rank, Type, price);
    }
}

 

핵심적인 정보 하나를 가지고 있으면 컨트랙트의 함수나 매핑을 이용해 연결된 정보들을 가져올 수 있다.

 

추가한 함수들을 보면 토큰 소유자에 대한 정보만으로 토큰에 대한 모든 정보를 가져오는 것이 가능했다.

 

컨트랙트 수정이 끝났으면 배포를 해주고 front의 contract 폴더에 있는 json 파일을 새로 build된 json으로 교체해준다.

 

2. dApp 수정

우리가 방금 SaleToken 컨트랙트에서 tokenId, Rank, Type, price에 대한 정보를 담은 TokenInfo 구조체를 만들어주었다.

 

이를 프론트로 가져와하는데, 이를 위해서는 또 (ts 환경이므로) class를 따로 만들어주어야 한다.

 

front폴더에서 interface 폴더를 만들고 다음과 같이 tokendata.interface.ts 파일을 작성해준다.

 

/*  front/interface/tokendata.interface.ts  */

interface TokenData {
    tokenId : string;
    Rank : string;
    Type : string;
    price : string;
}

export default TokenData;

 

이제 데이터 타입 TokenData를 사용할 수 있고, 그 객체 안에는 tokenId, Rank, Type, price 4개의 속성이 존재해야한다.

 

이 타입을 MintingModal에서 사용할 것이다.

 

/*  front/components/MintingModal.tsx  */

// ...중략
import TokenData from '../interface/tokendata.interface';

// ...중략

const MintingModal:FC<MintingModalProps> = ({isOpen, onClose}) => {
    // ...중략
    
    const handleClick = async () => {
		try {
            if(!account || !web3 || !abcToken || !saleToken) return;
            
            const response = await abcToken.methods.mintToken().send({
                from : account,
                value : web3.utils.toWei('1','ether')
            })
            
            console.log(response)
            // 민팅일 잘 진행될 경우 response.status가 true값을 가진다.
            
            if(response.status) {
                const latestToken: TokenData = await saleToken.methods
                .latestToken(account)
                .call();
                
                const tokenURI : string = await abcToken.methods
                .tokenURI(latestToken.tokenId)
                .call();
                
                console.log(tokenURI);
            }
        }
        catch (e) {
            console.error(e);
        }
    }
    
    // ...중략
}

 

우선 토큰 민팅에 성공했을 경우 민팅된 토큰 (이 시점에선 가장 최근 토큰)의 metadata를 저장한 uri를 출력해보자.

 

콘솔에 uri가 출력되면 웹 페이지에 들어가 json을 잘 돌려주는지 확인해보면 된다.

 

출력된 uri로 접속해보면 다음과 같이 json을 리턴해주는데, 이 메타데이터를 변수에 저장할 것이다.

 

우선 저 객체를 받아줄 데이터 타입을 만들어주어야 한다.

 

interface 폴더에 다음과 같이 tokenMetadata.ts를 작성한다.

 

/*  front/interface/tokenMetadata.ts  */

interface TokenMetaData {
    name : string;
    description : string;
    image : string;
    attributes : [
        { 0 : {trait_type : "Rank"; value : number}},
        { 1 : {trait_type : "Type"; value : number}}
    ];
}

export default TokenMetaData;

 

커스텀 훅을 하나 만들어 metadata(:TokenMetaData)를 state로서 변수에 저장할 수 있도록한다.

 

/*  front/hooks/useMetaData.tsx  */

import axios from "axios"
import { useState } from "react"
import TokenMetaData from "../interface/tokenMetadata"

export const useMetaData = () => {
    const [metadata, setMetaData] = useState<TokenMetaData|undefined>(undefined)

    const getMetaData = async (_uri : string) => {
        try {
            const response = await axios.get(_uri);

            setMetaData(response.data);
        }
        catch(e) {
            console.error(e);
        }
    }

    return {
        metadata, getMetaData
    }
}

 

이 훅 안의 getMetaData 함수와 metadata 상태변수도 MintingModal 컴포넌트로 가져온다.

 

/*  front/components/MintingModal.tsx  */

// ...중략
import { useMetaData } from "../hooks/useMetaData";

// ...중략

const MintingModal : FC <MintingModalProps> = ({isOpen, onClose}) => {
    // ...중략
    const { metadata, getMetaData } = useMetaData();
    
    const handleClick = async () => {
    try {
      if(!account || !web3 || !abcToken || !saleToken) return;

      const response = await abcToken.methods.mintToken().send({
        from : account,
        value : web3.utils.toWei('1', 'ether')
      })

      // console.log(response);

      if(response.status) {
        console.log('minting success')

        const latestToken : TokenData = await saleToken.methods.latestToken(account).call();
        const tokenURI : string = await abcToken.methods
          .tokenURI(latestToken.tokenId)
          .call();

        getMetaData(tokenURI);
        // getMetaData 함수를 실행해 metadata의 state를 바꿔준다
      }
    }
    catch (e) {
      console.error(e)
    }
  }
  
  // ...중략
}

 

이제 가져온 메타데이터 정보를 이용해 실제로 그에 맞는 데이터를 랜더링해주자.

 

객체 각 key값에 대한 value를, image는 uri에 있는 이미지를 실제로 랜더링 할 것이다.

 

이 데이터들을 담을 컴포넌트 TokenCard를 다음과 같이 작성해준다.

 

/*  front/components/TokenCard.tsx  */

import { Box, Image, Text } from "@chakra-ui/react";
import { FC } from "react";
import TokenMetaData from "../interface/tokenMetadata";

interface TokenCardProps {
    metadata : TokenMetaData | undefined;
}

const TokenCard: FC<TokenCardProps> = ({metadata}) => {
    return (
        <Box w='200'>
            <Image src={metadata?.image}/>
            <Text>{metadata?.name}</Text>
            <Text>{metadata?.description}</Text>
        </Box>
    );
}

export default TokenCard;

 

Image element를 @chakra-ui에서 import해와야 하는것에 주의하자.

 

이 컴포넌트를 MintingModal에 import해준다.

 

ModalBody를 다음과 같이 수정해주면 된다.

 

/*  front/components/MintingModal.tsx  */

// ...중략
import TokenCard from "./TokenCard";
import { Text } from "@chakra-ui/react";

// ...중략

<ModalBody>
    {
        metadata ? <TokenCard metadata={metadata}></TokenCard> 
        : <Text>민팅 시 1 eth가 소모됩니다.</Text>
    } 
</ModalBody>

 

이제 민팅을 하면 토큰의 이름과 이미지가 modal창에 나타날 것이다.

 

3. dApp 기능 추가 - 민팅할 수 있는 토큰 수 보기

우리가 처음 컨트랙트를 작성할때 최대 발행 토큰 갯수는 1000개로 정해두었다.

 

그래서 이를 기반으로 사용자가 현재 발행될 수 있는 토큰이 몇개인지 확인할 수 있도록 코드를 추가해보자.

 

index.tsx로 가서 상태 변수 remain을 선언해주면 다음과 같이 민팅 가능한 토큰 수를 구할 수 있다.

 

/*  front/pages/index.tsx  */

// ...중략
import useWeb3 from '../hooks/useWeb3'

const Home: NextPage = () => {
    // ...중략
    const {web3, abcToken, saleToken } = useWeb3();
    const [remain, setRemain] = useState<number>(0);
    
    const getRemain = async () => {
        try {
            if(!abcToken) return;
            const [totalSupply, MAX_TOKEN_COUNT] = await Promise.all([
                abcToken.methods.totalSupply().call();
                abcToken.methods.MAX_TOKEN_COUNT().call();
            ]};
            // 두 함수를 순차적으로 실행하는게 아니라 두 함수를 동시에 실행하고,
            // 둘 다 완료되면 다음으로 넘어간다.
            // 굳이 await을 통해 순차적으로 실행할 필요가 없는 관계이므로..
            
            setRemain(parseInt(MAX_COUNT_COUNT) - parseInt(totalSupply));
        }
        catch (e) {
            console.error(e)
        }  
    }
    
    useEffect(() => {
        getRemain()
    },[abcToken]);
}

 

이렇게 구한 remain 값을 props로 MintingModal 에게 상속해줄 것이다.

 

/*  front/pages/index.tsx  */

// ...중략

return(
    <>
        // ...중략
        <MintingModal isOpen={isOpen} onclose={onClose} getRemain={getRemain}/>
    </>
)

 

처음에 MintingModal 컴포넌트를 작성할 때 한것처럼 parent 컴포넌트로부터 상속을 받으려면 뭘 상속받을지를

 

interface로 만들어 지정해주어야 한다.

 

MintingModal.tsx에서 Props 인터페이스를 수정하고 상속받은 getRemain함수를 실행해주자.

 

/*  front/components/MintingModal.tsx  */

// ...중략

interface MintingModalProps {
    isOpen:boolean;
    onClose : () => void;
    getRemain : () => Promise<void>;
}

const MintingModal:FC<MintingModalProps> = ({isOpen, onClose, getRemain}) => {
    // ...Wndfir
    
    const handleClick = async() => {
        // ...중략
        
        if(response.status) {
            // ...중략
            
            const tokenURI : string = await abcToken.methods
            .tokenURI(latestToken.tokenId)
            .call();
            
            getMetaData(tokenURI);
            
            getRemain(); // 여기에서 getRemain 함수를 추가
            // ...중략
        }
    }
    // ...이하 생략
}

 

MintingModal 컴포넌트에서 민팅이 진행된 후, getRemain 함수가 실행되면 index.tsx에서 remain값을 바꿔준다.

 

remain의 value는 처음에 한 번 그 값을 불러와 보여주고, 민팅이 일어날떄마다 줄어들어 그 줄어든 값을 보여줘야한다.

 

/* front/pages/index.tsx  */

// ...중략

return(
    <>
      <Flex bg='red.100' minH='100vh' justifyContent='center' alignItems='center'>
        <Text mb={4}>남은 nft 갯수 : {remain}</Text>
        <Button colorScheme='blue'onClick={onOpen}> Minting </Button>
      </Flex>
      <MintingModal isOpen={isOpen} onClose={onClose} getRemain={getRemain}/>
    </>
)

// ...이하 생략

 

다음 글에서는 각 랭크와 타입을 가진 토큰이 몇개씩 민팅되었는지 보여주는 기능, 마이 페이지에서 토큰을 리스팅하는

 

기능을 추가해보도록 하자.

 

'Blockchain' 카테고리의 다른 글

Hardhat  (1) 2022.11.02
#33 NFT minting dApp 만들기 ch3  (0) 2022.08.05
#31 NFT minting dApp 만들기 ch1  (0) 2022.08.04
#30 ERC-721 토큰 만들기 Ch3  (0) 2022.08.02
#29 ERC-721 토큰 만들기 Ch2  (0) 2022.07.28