Blockchain

#31 NFT minting dApp 만들기 ch1

Sila 2022. 8. 4. 19:52

 

마지막으로 지난 글에서 우리가 만든 두 개의 컨트랙트를 기반으로

 

사용자가 본인의 지갑으로 민팅을 진행할 수 있는 dApp을 만들어보자.

 

ts와 react, nextjs를 사용할 것이고, chakra-ui를 사용해 디자인을 입혀줄 것이다.

 

chakra-ui는 ant design처럼 미리 디자인을 만들어둔 것을 우리가 가져다 사용하도록 해주는 툴이라고 보면 된다.

 

솔리디티나 블록체인에 대한 내용보다는 우리가 만든 것들을 어떻게 사용자가 프론트에서 보고 사용할 수 있는지를

 

알아보는데 초점을 두고 진행한다.

1. set up

우선 루트 디렉토리에 폴더가 두 개 필요할 것이다. 하나는 컨트랙트들이 들어갈 트러플 폴더,

 

하나는 리액트 관련된 데이터들을 넣을 front 폴더이다.

 

truffle 폴더에서 truffle을 셋업해주고, contracts 폴더에서 openzeppelin-solidity를 설치한 후,

 

우리가 지난 번 만들었던 abcToken, SaleToken 파일들을 가져오자.

 

(컨트랙트들을 추후 몇 가지 기능을 추가하기 위해 수정을 가할 예정이다)

 

이 두 컨트랙트를 순서에 맞춰 migration 해준다.

 

migration 파일은 다음과 같이 작성해주면 된다.

 

/*  truffle/migration/2_deploy_minting.js */

const abcToken = artifacts.require("abcToken");
const SaleToken = artifacts.require("SaleToken");

module.exports = async (deployer) => {
    await deployer.deploy(abcToken, 'test', 'test', 'https://gateway.pinata.cloud/ipfs/Qmbn1xFmAkrUuEi9nhxcH3NysdsSAwYVF2sJtMghMUGUUF');
    const abcTokenInstance = await abcToken.deployed();

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

 

uri에 문제가 발생한다면 그 때가서 uri를 가져오는 함수를 수정하던가 하고 일단은 이렇게 해서 배포하면 된다.

 

abcToken을 먼저 배포후, 만들어진 ca를 이용해 saleToken을 배포하는 것임에 유의하면 된다.

 

ganache 네트워크를 실행하고 배포해주면 블록체인 쪽의 셋업은 끝이다.

 

 

이제 프론트앤드 쪽을 셋업해준다. 앞서 말한것처럼 ts, react, nextjs를 이용할 것이고, 디자인은 chakra-ui를 사용할 것이다.

 

루트 디렉토리로 돌아와 다음처럼 앱 생성 명령어를 입력한다.

 

npx create-next-app@latest --typescript front

 

front는 새로 생성될 폴더명이므로 원하는대로 정해주면 된다.

 

완료되면 front 폴더 내부로 들어가 chakra-ui에 관련된 패키지들과 앞으로 사용할 패키지들을 설치한다.

 

npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^6 axios web3

 

라이브러리들의 설치가 끝나면 front 폴더에서  _app.tsx를 열어 다음과 같이 수정해보자.

 

/*  front/pages/_app.tsx  */

import { ChakraProvider } from '@chakra-ui/react'
import type { AppProps } from 'next/app'

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

export default MyApp;

 

필요없는 부분은 지우고 chakra-ui를 import해온 후, MyApp에서 <Component/>를 <ChakraProvider>로 감싸주었다.

 

이는 감싼 부분 전체에서 chakra-ui가 제공하는 디자인된 element들을 사용할 수 있게 되었다는 것을 의미한다.

 

이제 index.tsx로 가서 안에 있는 내용을 다 지우고 다음과 같이 수정해준다.

 

/*  front/pages/index.tsx  */

import { Button, Flex } from '@chakra-ui/react'
import type { NextPage } from 'next'

const Home: NextPage = () => {
  return (
    <>
      <Flex bg='red.100' minH='100vh' justifyContent='center' alignItems='center'>
        <Button colorScheme='blue'> Minting </Button>
      </Flex>
    </>
  );
};

export default Home;

 

html에서 inline styling을 하는 것처럼 렌더링될 element에 직접 스타일링을 해줄 수 있다.

 

bg는 background, minH는 minimum height, 뒤에 2개는 flex box의 속성과 동일하다.

 

아무튼 이렇게 하면 우리가 프로그램을 실행했을 때, 화면 중앙에 버튼 하나만이 랜더링 될 것이다.

 

npm run dev

 

localhost:3000으로 접속하면 잘 실행되는지를 확인할 수 있을 것이다.

 

2. minting 화면 만들기

이제 다음으로 하고 싶은 것은 minting 버튼을 누르면 팝업창을 띄우고(modal 이라고 부름) 거기서 민팅을 진행,

 

메타마스크를 거쳐 블록체인 네트워크 상의 스마트 컨트랙트를 실행해 토큰을 민팅하는 것이다.

 

chakra-ui에서 이 modal에 관련된 기능 또한 제공해주고 있으므로 이를 활용해보자.

 

front에서 components 폴더를 만들고 MintingModal.tsx를 다음과 같이 작성해준다.

 

/*  front/components/MintingModal.tsx  */

import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay } from "@chakra-ui/react";
import { FC } from "react";

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

const MintingModal:FC<MintingModalProps> = ({isOpen, onClose}) => {
    return(
    <>
      <Modal isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalHeader>Minting</ModalHeader>
          <ModalCloseButton />

          <ModalBody>
            민팅 시 1eth가 사용됩니다 
          </ModalBody>

          <ModalFooter>
            <Button colorScheme="green" mr={3}>Minting</Button>
            <Button colorScheme="blue" mr={3} onClick={onClose}>
              Close
            </Button>
            
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  )
}


export default MintingModal;

 

chakra-ui 웹 사이트에서 복사해와 약간의 수정을 거친 것이다.

 

함수를 잘 보면 isOpen (boolean), onClose(함수)를 props로 받는 것을 알 수 있는데,

 

(여기선 redux를 사용하지 않았으므로 parent에서 child로 뭔가 전달할때는 props를 사용해 일일히 전달해야 한다.)

 

ts에선 이렇게 parent component에서 뭔가를 상속받을 때도 interface를 따로 선언해 상속받을 데이터들을 지정해주어야 한다.

 

그게 컴포넌트 상단의 interface MintingModalProps 이다.

 

이제 parent component인 index.tsx의 Home에서 상속해줄 변수와 함수를 선언해주고, MintingModal을 import한다.

 

/*  front/pages/index.tsx  */

import { Button, Flex, useDisclosure } from '@chakra-ui/react'
import type { NextPage } from 'next'
import MintingModal from '../components/MintingModal';

const Home: NextPage = () => {
  const {isOpen, onOpen, onClose } = useDisclosure();
  // modal을 열고 닫는 함수 useDisclosure
  
  return (
    <>
      <Flex bg='red.100' minH='100vh' justifyContent='center' alignItems='center'>
        <Button colorScheme='blue'onClick={onOpen}> Minting </Button>
      </Flex>
      <MintingModal isOpen={isOpen} onClose={onClose}/>
    </>
  )
}

export default Home

 

isOpen, onOpen, onClose는 modal을 열고 닫는 함수, 그리고 open/close 상태의 여부를 저장하는 boolean 타입 변수이다.

 

어떻게 작동하는지는 여기서 정확히 알 필요는 없고 이렇게 쓴다는 것만 알고 넘어가자.

 

Home 컴포넌트 안에 MintingModal을 집어 넣었고, 함수와 변수를 상속해주었다.

 

MintingModal 컴포넌트에서는 이 함수, 변수를 interface로 상속받는 값이라고 지정하고 사용하면 된다.

 

여기까지 하고 다시 브라우저에서 민팅 버튼 클릭시 팝업창이 잘 뜨는지 확인해본다.

 

3. metamask, 블록체인 네트워크와 연결

이제 ui는 만들었으니 ui를 통해 메타마스크를 거쳐 블록체인 상에 존재하는 컨트랙트들과 상호작용하도록 해보자.

 

우선 첫 번째로 사용자의 account, 지갑 주소를 metamask를 통해 가져와야 하는데, 이는 커스텀 훅을 이용해보자.

 

front에서 hooks 폴더를 만들고 useAccount.tsx 파일을 생성한다.

 

/*  front/hooks/useAccount.tsx  */

import { useEffect, useState } from "react";

const useAccount = () => {
    const [ account, setAccount ] = useState<string>('');

    const getAccount = async () => {
        try {
            if(!window.ethereum) throw new Error('no metamask here')

            const accounts = await window.ethereum.request({
                method:"eth_requestAccounts"
            });

            if(accounts && Array.isArray(accounts)) {
                setAccount(accounts[0]);
            }
        }
        catch(e) {
            console.error(e)
        }
    };

    useEffect(() => {
        getAccount();
    }, []);
    return {account};
};

export default useAccount;

 

account라는 변수에 지갑의 주소를 담아줄 것이다. 이를 위해선 메타마스크에 요청을 보내 받아온 지갑 주소를 account 변수에

 

setAccount를 통해 넣어주면 된다.

 

이는 처음 컴포넌트가 랜더링될 때 한 번 실행되며, 최종적으로 getAccount 함수를 통해 얻은 account 값을 리턴한다.

 

 

그런데 이렇게 작성을 하면 ethereum이 window에 그 값이 없다는 에러가 나올 것이다.

 

ts에서는 metamask에서 제공하는 ethereum 객체를 인식시키려면 그 객체 안의 모든 속성을 나열해줘야 하는데,

 

이게 한두개도 아니고 말이 안된다. 이런 상황을 해결하기 위한 라이브러리가 다행히 존재한다.

 

npm i @metamask/providers

 

웹 페이지에서 ethereum 객체를 인식하는 것을 보다 편하게 해결하기 위해 이 라이브러리를 설치하고

 

ethereum이라는 객체를 전역으로 데이터 타입을 지정해줄 것이다.

 

/*  front/next-env.d.ts  */
// 처음 셋업시 이미 만들어져 있는 파일

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

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

 

window객체에 ethereum이 있을 경우 그 타입을 MetaMaskInPageProvider로 설정해준다는 것을 의미한다.

 

이렇게 하면 에러가 사라지는 것을 확인할 수 있다.

 

혹시 이렇게 해서 문제가 해결되지 않으면 새로 front 폴더에 index.d.ts 파일을 만들고 다음과 같이 작성해주면 된다.

 

/*  front/index.d.ts  */

declare interface Window {
    ethereum : any;
}

 

그냥 어떤 값이든 올 수 있도록 any를 ethereum 객체의 데이터 타입으로 지정하면 된다.

 

이제 useAccount에서 만든 account를 index.tsx로 가져온다.

 

/*  front/pages/index.tsx  */

// ...중략
import useAccount from '../hooks/useAccount';

const Home : NextPage = () => {
    const { isOpen, onOpen, onClose } = useDisclosure();
    const { account } = useAccount();
    // useAccount를 import해 그 안의 account 값을 사용한다.
    
    // ...중략
};

export default Home;

 

동일한 방법으로 컨트랙트들도 가져올 수 있다.

 

hooks 폴더에 useWeb3.tsx 커스텀 훅을 만들어준 후, web3, abcToken, saleToken을 가져온다.

 

/*  front/hooks/useWeb3.tsx  */

import { useState } from "react";
import Web3 from "web3";

import { Contract } from 'web3-eth-Contract';

const useWeb3 = () => {
    const [web3, setWeb3] = useState<Web3 | undefined>(undefined);

    const getWeb3 = () => {
        try {
            if(window.ethereum) {
                setWeb3(new Web3(window.ethereum));
            }
        }
        catch (e) {
            console.error(e)
        }
    };
    
    useEffect(() => {
        getWeb3();
    },[]);
}

export default useWeb3;

 

ethereum에서 에러가 나면 Index.d.ts 를 위에서 한것처럼 (아직 안했다면) 작성해줄 것

 

useState 함수를 사용할 때도 state를 바꿔줄 변수의 타입을 전부 지정해주어야 한다.

 

web3 변수의 경우 처음엔 undefined이므로 데이터 타입은 undefined였다가 window.ethereum이 존재하면 데이터 타입을

 

Web3로 바꿔줄 것이므로 useState 뒤에 데이터 타입을 <Web3 | undefined> 로 지정해주었다.

 

 

컴포넌트가 랜더링되면 useEffect 함수가 발동해 getWeb3 함수를 실행한다.

 

getWeb3는 메타마스크가 있다면 (window.ethereum이 있다면) web3변수의 state를 그걸로 바꿔준다.

 

 

마찬가지 방법으로 abcToken과 saleToken을 가져와 상태를 바꿔줄 getAbcToken과 getSaleToken 함수를 추가해줄텐데,

 

이를 위해서는 우리가 truffle에서 컨트랙트를 배포할 때 build 폴더에 만들어진 컨트랙트에 관한 정보를 가지고 있는 json을 

 

front 폴더 쪽으로 가져올 필요가 있다. (이 json 안에 abi, ca에 관한 정보가 다 있으므로)

 

truffle/build/contracts 폴더에서 abcToken.json, saleToken.json을 복사한 후,

 

front 폴더에서 contracts 폴더를 만들어 이 안에 붙여넣는다.

 

 

이제 가져온 json 파일에서 컨트랙트에 관한 정보들을 쉽게 뽑아내 사용할 수 있다.

 

useWeb3 컴포넌트에 다음과 같이 getAbcToken, getSaleToken 함수를 추가해주자.

 

/*  front/hooks/useWeb3.tsx  */

// ...중략

import { Contract } from 'web3-eth-Contract';
// Contract라는 데이터 타입을 가져오는 것이다

import { AbiItem } from 'web3-utils';
// 마찬가지로 AbiItem이라는 데이터 타입을 가져오는 것이다.

const useWeb3 = () => {
    const [ web3, setWeb3 ] = useState<Web3 | undefined>(undefined);
    
    const [ abcToken, setAbcToken ] = useState<Contract>();
    const [ saleToken, setSaleToken ] = useState<Contract>();
    
    const getWeb3 = () => {
        // ...중략
    }
    
    const getAbcToken = (networkId : number) => {
        if(!web3) return;
        
        const abcTokenJSON = require('../contracts/abcToken.json');
        const abi : AbiItem = abcTokenJSON.abi;
        const ca : string = abcTokenJSON.networks[netWorkId]?.address;
        
        const instance = new web3.eth.Contract(abi, ca);
        setAbcToken(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();
            
            getAbcToken(networkId);
            getSaleToken(networkId);
        })();
    }, [web3]);
    
    return{ web3, abcToken, saleToken};
};

export default useWeb3;

 

이게 돌아가는 메커니즘을 잘 파악해야 한다.

 

아마 이 글의 가장 핵심적인 내용 중 하나일 것이다..

 

1. 컴포넌트가 랜더링 되면 위쪽 useEffect가 발동해 getWeb3 함수를 호출한다.

 

2. getWeb3 함수는 window.ethereum이 있다면 이를 이용해 변수 web3 값을 바꿔준다.

 

3. 변수 web3의 값이 바뀌었으므로 아래 쪽 useEffect가 발동한다.

 

4. useEffect 내부의 즉시 실행 함수가 실행되어 메타마스크를 서쳐 블록체인의 네트워크 아이디를 가져온다.

 

5. 구해온 networkId 값을 매개변수로 주고 컨트랙트를 가져오는 getAbcToken, getSaleToken 함수를 실행한다.

 

6. 두 함수에서는 매개 변수로 받은 networkId 값과 json 파일에 있는 abi, ca 정보를 이용해

 

블록체인 네트워크에서 컨트랙트를 가져와 instance 변수에 담고, 이 변수값으로 abcToken, saleToken 변수의 상태를 바꾼다.

 

7. 6까지 문제 없이 진행되면 최종 상태값을 가진 web3, abcToken, saleToken을 반환한다.

 

8. 이제 이 변수들을 import하면 컨트랙트와 상호작용을 하는 것이 가능해진다.

 

 

그러면 이 컨트랙트들과 상호작용할 (민팅을 한다던가..) 컴포넌트에 이렇게 만든 web3, abcToken, saleToken을 가져와서 쓰면 된다.

 

민팅을 진행할 곳이 MintingModal이므로 여기서 변수들을 가져와 활용해보자.

 

/*  front/components/MintingModal.tsx  */

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

// ...중략

const MintingModal:FC<MintingModalProps> = ({isOpen, onClose}) => {
    const { account } = useAccount();
    const { web3, abcToken, saleToken } = useWeb3();
    
    // ...중략
}

export default MintingModal;

 

4. 프론트 ui를 통해 컨트랙트 발동

이제 Minting 버튼에 클릭하면 1 ether를 지불하고 민팅을 진행할 수 있도록 onClick 함수를 추가해보자.

 

/* front/components/MintingModal.tsx  */

// ...중략

const MintingModal:FC<MintingModalProps> = ({isOpen, onClose}) => {
    const { account } = useAccount();
    const { web3, abcToken, saleToken } = useWeb3();
    
    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.data)
            
            if(response.status) {
                // 위의 response가 잘 실행되면 response.status가 true
                console.log('minting success');
                
                const tokenId : string = await abcToken.methods.totalSupply().call();
                // 방금 생성된 토큰의 id는 총 토큰의 수와 동일한 수이다.
                
                const info = await abcToken.methods.TokenDatas(tokenId).call();
                // tokenId 값과 관련 매핑을 이용해 해당 토큰의 정보를 가져온다.
                
                console.log(info);
            }
        }
        catch (e) {
            console.error(e);
        }
    }
}

export default MintingModal;

 

여기까지 한 후 민팅 버튼을 클릭해 민팅에 성공하면 민팅한 토큰에 대한 정보가 콘솔에 출력 될 것이다.

 

다음 글에서는 컨트랙트를 약간 수정해 사용자 입장에서 좀 더 컨트랙트와 토큰에 대한 정보에 쉽게 접근할 수 있도록 해주고,

 

민팅한 토큰을 보이게끔 하는 기능, 내 토큰을 판매하는 기능들을 추가해보자.

 

 

'Blockchain' 카테고리의 다른 글

#33 NFT minting dApp 만들기 ch3  (0) 2022.08.05
#32 NFT Minting dApp 만들기 ch2  (0) 2022.08.05
#30 ERC-721 토큰 만들기 Ch3  (0) 2022.08.02
#29 ERC-721 토큰 만들기 Ch2  (0) 2022.07.28
#28 ERC-721 토큰 만들기  (0) 2022.07.26