Cute Bow Tie Hearts Blinking Pink Pointer

블록체인/스마트 컨트랙트

[typescript] NFT 토큰 민팅하는 dApp 간단히 구현하기

청포도 에이드 2022. 7. 28. 16:47
728x90

 

 

목차

 

- 디렉토리 구조

- 모듈 설치 및 기본 설정

- 구현 코드

- 결과

 

 

 

디렉토리 구조

 

루트

 

프론트

 

 

모듈 설치 및 기본 설정

 

프론트

 

npx create-next-app@latest --typescript front
npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^6
npm install web3

샤크라라는 라이브러리를 사용해서

이미 만들어져있는 리액트 컴포넌트를 사용해서 편하게 작업할 것임

 

 

mkdir truffle
cd truffle
truffle init
cd contracts
npm init
npm i openzeppelin-solidity

truffle.config.js에서 development 주석 해제

 

 
nft 토큰마다 랜덤으로 들어갈 고유한 사진들을 json 파일로 만들어놓은 메타데이터이다.
오늘은 이걸 사용하겠다.(사진이 나오진 않음)

구현

 

truffle/contracts/gyulToken.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";
contract GyulToken is ERC721Enumerable, Ownable{
    uint constant public MAX_TOKEN_COUNT = 1000; //발행량 설정
    uint public mint_price = 1 ether; // 토큰 하나 민팅하려면 1이더 필요
    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;
    //TokenDatas란 녀석은 uint 값(숫자)을 넣어주면 TokenData 형식의 구조체가 나오는 애다.
    uint[4][4] public tokenCount;
    // 4 * 4 이중배열 형태 

    function mintToken() public payable{

        require(msg.value == mint_price);
        //          1000              0~999
        require(MAX_TOKEN_COUNT > totalSupply());
        // 전체 토큰 개수는 처음에지정한 토큰 발행 총량을 넘을 수 없음
        uint tokenId = totalSupply() + 1;
		// 토큰 id 부여
        TokenDatas[tokenId] = getRandomNum(msg.sender, tokenId);
        tokenCount[TokenDatas[tokenId].Rank-1][TokenDatas[tokenId].Type-1] += 1;
        
        payable(Ownable.owner()).transfer(msg.value);
        //owner contract 배포자
        _mint(msg.sender, tokenId);
    }

    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"));

        //tokenId -> rank, type 1 -> (1,4) 2 -> (2,1)
        // url return... baseurl http://localhost:3000
        // return http://localhost:3000/metadata/1/4.json

    }

    function getRandomNum(address _owner, uint _tokenId) private pure returns(TokenData memory){
        uint randomNum = uint(keccak256(abi.encodePacked(_owner, _tokenId)))%100; //32byte

        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 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;
    }
}

 

truffle/contracts/SaleToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;

import "./gyulToken.sol";

contract SaleToken{
    GyulToken public Token;

    constructor(address _tokenAddress){
        Token = GyulToken(_tokenAddress);
    }

    struct TokenInfo{
        uint tokenId;
        uint Rank;
        uint Type;
        uint price;
    }

    // 토큰 가격 맵핑
    mapping(uint => uint) public tokenPrices;

    // 판매 토큰 배열
    uint[] public SaleTokenList;

    function SalesToken(uint _tokenId, uint _price) public {

        address tokenOwner = Token.ownerOf(_tokenId);

        require((tokenOwner) == msg.sender);
        require(_price > 0);
        // 오픈씨 isapprovedForall
        require(Token.isApprovedForAll(msg.sender, address(this)));

        tokenPrices[_tokenId] = _price;
        SaleTokenList.push(_tokenId);

    }

    //구매 구매자 -> CA-> 판매자
    function PurchaseToken(uint _tokenId) public payable{
        address tokenOwner = Token.ownerOf(_tokenId);
        require(tokenOwner != msg.sender);
        require(tokenPrices[_tokenId] > 0);
        // 토큰 가격은 0보다 커야된다. 그래야 팔지...
        require(tokenPrices[_tokenId]<= msg.value);

        payable(tokenOwner).transfer(msg.value);

        Token.transferFrom(tokenOwner, msg.sender, _tokenId);
        tokenPrices[_tokenId] = 0;
        popSaleToken(_tokenId);
    }
		//판매 취소
        function cancelSaleToken(uint _tokenId) public {
        address tokenOwner = Token.ownerOf(_tokenId);
        require(tokenOwner == msg.sender);
        require(tokenPrices[_tokenId] > 0);

        tokenPrices[_tokenId] = 0;
        popSaleToken(_tokenId);
    }
	//판매 끝났으면, 판매 리스트에서 삭제
    function popSaleToken(uint _tokenId) private returns(bool){
        for(uint i=0; i<SaleTokenList.length; i++){
            if (SaleTokenList[i] == _tokenId){
                // i = index

                SaleTokenList[i] = SaleTokenList[SaleTokenList.length - 1];
                SaleTokenList.pop();
                return true;
            }
        }
        return false;
    }
	//판매중인 토큰 리스트
    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 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;
    }

    

}

 

truffle/migrations/2_deploy_minting.js
const GyulToken = artifacts.require("GyulToken");
const SaleToken = artifacts.require("SaleToken");

module.exports = async (deployer) => {
    await deployer.deploy(
        GyulToken,
        "test",
        "gyul",
        "https://gateway.pinata.cloud/ipfs/QmUsEKtVS5Gn4rZWbYfD7D4qLLKPf1YbsBWYaSqtmCPBzf"
    );
    const gyulTokenInstance = await GyulToken.deployed();

    await deployer.deploy(SaleToken, gyulTokenInstance.address);
};

 

truffle migration --reset

 

bulid에 생성 된 GyulToken.json과 SaleToken.json 파일을 복사해서 프론트 contracts 디렉토리에 넣어주어 편하게 작업하겠다. (컴파일 할 때마다 바꿔주는 게 귀찮긴 하지만...)

프론트

 

front/next-env.d.ts

 

import { MetaMaskInpageProvider } from "@metamask/providers";

declare global {
    interface Window {
        ethereum?: MetaMaskInpageProvider;
    }
}

 

front/index.d.ts

 

declare interface Window {
    ethereum: any;
}

 

front/pages/_app.tsx
import type { AppProps } from "next/app";
import { ChakraProvider } from "@chakra-ui/react";

function MyApp({ Component, pageProps }: AppProps) {
    return (
        <ChakraProvider>
            <Component {...pageProps} />
        </ChakraProvider>
    );
}

export default MyApp;
front/pages/index.tsx
import { Box, Button, Flex, useAccordion, useDisclosure } from "@chakra-ui/react";
import type { NextPage } from "next";
import { useEffect } from "react";
import useAccount from "../hooks/useAccount";
import useWeb3 from "../hooks/useWeb3";
import MintingModal from "./components/mintingModal";

const Home: NextPage = () => {
    const { isOpen, onOpen, onClose } = useDisclosure();
    const { account } = useAccount();
    const { web3, gyulToken, saleToken } = useWeb3();

    useEffect(() => {
        console.log(account);
        console.log(web3);
        console.log(gyulToken);
        console.log(saleToken);
    });
    return (
        <>
            <Flex bg="red.100" minH="100vh" justifyContent="center" alignItems="center">
                <Button colorScheme="blue" onClick={onOpen}>
                    민팅하기
                </Button>
            </Flex>
            <MintingModal isOpen={isOpen} onClose={onClose} />
        </>
    );
};

export default Home;

 

 

front/hooks/useWeb3.tsx

 

import { useEffect, useState } from "react";
import Web3 from "web3";
import { Contract } from "web3-eth-contract";
import { AbiItem } from "web3-utils";

const useWeb3 = () => {
    const [web3, setWeb3] = useState<Web3 | undefined>(undefined);
    const [gyulToken, setGyulToken] = useState<Contract>();
    const [saleToken, setSaleToken] = useState<Contract>();

    const getWeb3 = () => {
        try {
            if (window.ethereum) {
                setWeb3(new Web3(window.ethereum as any));
            }
        } catch (e) {
            console.error(e);
        }
    };

    const getGyulToken = (networkId: number) => {
        if (!web3) return;
        const gyulTokenJSON = require("../contracts/GyulToken.json");
        const abi: AbiItem = gyulTokenJSON.abi;
        const ca: string = gyulTokenJSON.networks[networkId]?.address;

        const instance = new web3.eth.Contract(abi, ca);
        setGyulToken(instance);
    };

    const getSaleToken = (networkId: number) => {
        if (!web3) return;
        const saleTokenJSON = require("../contracts/SaleToken.json");
        const abi: AbiItem = saleTokenJSON.abi;
        const ca: string = saleTokenJSON.networks[networkId]?.address;
        const instance = new web3.eth.Contract(abi, ca);
        setSaleToken(instance);
    };

    useEffect(() => {
        getWeb3();
    }, []);

    useEffect(() => {
        (async () => {
            if (!web3) return;
            const networkId: number = await web3.eth.net.getId();
            getGyulToken(networkId);
            getSaleToken(networkId);
        })();
    }, [web3]);

    return { web3, gyulToken, saleToken };
};

export default useWeb3;

 

front/hooks/useAccount.tsx
import { useEffect, useState } from "react";
import Web3 from "web3";
import { Contract } from "web3-eth-contract";
import { AbiItem } from "web3-utils";

const useWeb3 = () => {
    const [web3, setWeb3] = useState<Web3 | undefined>(undefined);
    const [gyulToken, setGyulToken] = useState<Contract>();
    const [saleToken, setSaleToken] = useState<Contract>();

    const getWeb3 = () => {
        try {
            if (window.ethereum) {
                setWeb3(new Web3(window.ethereum as any));
            }
        } catch (e) {
            console.error(e);
        }
    };

    const getGyulToken = (networkId: number) => {
        if (!web3) return;
        const gyulTokenJSON = require("../contracts/GyulToken.json");
        const abi: AbiItem = gyulTokenJSON.abi;
        const ca: string = gyulTokenJSON.networks[networkId]?.address;

        const instance = new web3.eth.Contract(abi, ca);
        setGyulToken(instance);
    };

    const getSaleToken = (networkId: number) => {
        if (!web3) return;
        const saleTokenJSON = require("../contracts/SaleToken.json");
        const abi: AbiItem = saleTokenJSON.abi;
        const ca: string = saleTokenJSON.networks[networkId]?.address;
        const instance = new web3.eth.Contract(abi, ca);
        setSaleToken(instance);
    };

    useEffect(() => {
        getWeb3();
    }, []);

    useEffect(() => {
        (async () => {
            if (!web3) return;
            const networkId: number = await web3.eth.net.getId();
            getGyulToken(networkId);
            getSaleToken(networkId);
        })();
    }, [web3]);

    return { web3, gyulToken, saleToken };
};

export default useWeb3;

 

front/pages/components/mintingModal.tsx
import {
    Button,
    Modal,
    ModalBody,
    ModalCloseButton,
    ModalContent,
    ModalFooter,
    ModalHeader,
    ModalOverlay,
    Text,
} from "@chakra-ui/react";
import { FC } from "react";
import useAccount from "../../hooks/useAccount";
import useWeb3 from "../../hooks/useWeb3";

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

const MintingModal: FC<MintingModalProps> = ({ isOpen, onClose }) => {
    const { account } = useAccount();
    const { web3, gyulToken, saleToken } = useWeb3();

    const handleClick = async () => {
        try {
            if (!account || !web3 || !gyulToken || !saleToken) return;
            const response = await gyulToken.methods.mintToken().send({
                from: account,
                value: web3.utils.toWei("1", "ether"),
            });
            console.log(response);

            if (response.status) {
                //내가 민팅한 내역을 볼 수 있게
                console.log("민팅 성공");
                const tokenId: string = await gyulToken.methods.totalSupply().call();
                // console.log(total);
            }
        } catch (e) {
            console.error(e);
        }
    };
    return (
        <>
            <Modal isOpen={isOpen} onClose={onClose}>
                <ModalOverlay />
                <ModalContent>
                    <ModalHeader>Modal Title</ModalHeader>
                    <ModalCloseButton />
                    <ModalBody>
                        <Text>민팅 시 1 ETH가 소모 됩니다.</Text>
                    </ModalBody>

                    <ModalFooter>
                        <Button colorScheme="green" mr={3} onClick={handleClick}>
                            민팅하기
                        </Button>
                        <Button mr={3} colorScheme="blue" onClick={onClose}>
                            Close
                        </Button>
                    </ModalFooter>
                </ModalContent>
            </Modal>
        </>
    );
};

export default MintingModal;

 

결과

 

 

민팅이 잘 된 것을 확인할 수 있다.

728x90