나만의 NFT 발행하고 DApp 만들기

비운의 사건으로 힙한 개발자의 비밀 챌린지가 중단되었다. 4주나 진행되고 있었던 챌린지가 중단되버린게 너무 아쉽다.😥
챌린지 진행 중 나만의 NFT만들기라는 주제가 있어 NFT를 직접 발행하고 opensea 테스트넷에 민팅까지 한 경험이 생겼다. 그래서 간단하게 기록해보고자한다.

스마트 컨트랙트와 어플리케이션

스마트 컨트랙트만으로 모든 것을 할 수는 없다. 컨트랙트가 중요한 데이터를 저장할 수는 있겠지만 사람들이 사용할 수 있는 서비스가 되려면 프론트엔드와 백엔드, 데이터베이스도 함께 활용해야 한다.

따라서 스마트 컨트랙트와 연결되는 간단한 프론트엔드를 구현하여 다음의 주제를 다뤘다.

  • 오픈제플린 라이브러리를 사용하여 ERC-721 표준을 구현한 컨트랙트
  • 리액트 라이브러리를 활용한 프론트엔드 어플리케이션

사실 프론트엔드 개발은 블록체인 그 자체와는 크게 관련이 없지만, 스마트 컨트랙트가 프론트엔드와 어떻게 데이터를 주고받는지 이해하는 것은 블록체인 기반의 서비스를 개발할 때 중요한 요소이다.

스마트 컨트랙트

단일한 유형의 NFT를 발행할 것이므로 ERC-721 표준을 준수하였다. OpenZeppelin이라는 라이브러리를 사용해서 수월하게 구현할 수 있었다.

자바스크립트 어플리케이션

사용자와 웹에서 상호작용을 해야하니 웹 어플리케이션으로 개발해야한다. React를 사용했다.

개발 도구

몇 년전 대학생때 부동산 거래 DApp을 개발했던 경험이 있다. 그 땐 트러플가나슈를 사용했는데 이번에는 하드햇을 사용했다. 더욱 간단하게 구현이 가능했다.

개발 순서

  1. 각 속성들의 이미지를 준비한다.
  2. 이미지들을 조합하여 하나의 이미지를 생성하는 스크립트를 작성한다.
  3. 생성된 이미지를 IPFS에 업로드하고 메타정보를 생성한다. 메타정보는 JSON 형식으로
    IPFS에 업로드한다.
  4. ERC-721 표준을 준수하는 스마트 컨트랙트를 작성한다.
  5. 리액트를 사용하여 NFT를 발행할 수 있는 어플리케이션을 만든다. 여기서 발행이라는 것은 미리 생성된 메타정보를 나타내는 NFT의 소유권 정보를 스마트 컨트랙트에 기록하는 것을 의미한다.
  6. 발행된 토큰을 OpenSea에서 조회하고 판매해본다. OpenSea API를 이용하여 판매상태 등을
    어플리케이션에서 조회할 수 있도록 한다.

지난 포스팅에서는 OpenSea에서 제공하는 NFT 발행 기능을 사용했었다. 하지만 이번 포스팅에서는 직접 스마트 컨트랙트를 작성하고, 어플리케이션도 개발하였다.

이미지 준비

이미지 생성

OpenSea를 둘러보면 NFT는 주로 이미지로 표현되고있다. 주로 PFP(Profile Picture) NFT 프로젝트들이 많은데 이를 둘러보면 각 부위를 모듈로 만들어서 합성한 것으로 추측할 수 있다.
Like this?

PFP NFT에서 쓰는 것과 같은 방식

그래서 나도 이미지를 합성해서 NFT를 발행하기로 결정했다. 고양이와 배경이미지 이렇게 두가지 종류의 이미지를 합성하기로 결정했다.

고양이배경이미지기대 결과물

다양한 특징(trait)을 가진 서로 유일하게 구별되는 이미지를 만들어야하니 NFT 발행에 필요한 이미지 수보다 경우의 수를 충분히 늘려서 조합해준다.

고양이 이미지 10장과 배경이미지 10장, 총 경우의 수 100장 중 20장의 최종 이미지를 만들기로 한다.
랜덤하게 이미지를 생성할 것이므로 확률적으로 동일한 조합이 나올 수도 있다. 하지만 한 속성의 이미지가 연속 3회 같은 이미지가 선택될 확률은 0.001%이다. 이는 벼락맞을 확률, 원하는 게임 아이템 뽑을 확률, 걸그룹 성공 확률에 맞먹는 수치이다.

node-canvas 라이브러리를 사용해서 이미지를 합성해주었다.
canvas로 이미지를 합치려면 순서가 중요하다. 맨 아래에 있어야 하는 이미지를 먼저 그리도록 해야한다.

const create = async (t, i) => {

 const cat = await loadImage(`${FILE_PATH}/cat/${t[0]}.png`);
 const background = await loadImage(`${FILE_PATH}/background/${t[1]}.png`);

await ctx.drawImage(background, 0, 0, 500, 500);
await ctx.drawImage(cat, 0, 0, 500, 500);

 saveImage(canvas, i+1);

};

메타정보 업로드

NFT는 메타정보가 있어야 한다. 일반적으로 메타정보는 JSON 형식인데, OpenSea의 형식을 따랐다. 이를 쉽게 구현할 수 있는 NFT 스토리지라는 무료 IPFS 서비스를 이용하였다.
nft.storage 라이브러리를 이용하여 메타정보 파일과 생성된 이미지를 동시에 NFT 스토리지에 업로드 할 수 있다. 그 과정 중에 IPFS의 CID, 즉 컨텐츠 해시 값이 콘솔에도 출력된다.

1=ipfs://bafyreicaupcm32egqq7knyzdoyuwzg3u6casgbyrachnpm2d7ouoko7ytm/metadata.json
2=ipfs://bafyreibvefdsvc2zcgbteobmj7nafa3jcumc2k5pdbjttjfjr6izswoqyq/metadata.json
3=ipfs://bafyreiekyaifavwtahl6ykhnrqu4t5thllbt3gxtnwu2b6uvsbbtmnqmv4/metadata.json
...

크롬 브라우저에서 링크를 열면 메타정보가 나오는 것을 알 수 있다. ipfs:// 링크를 직접 열려면
IPFS 크롬 확장 프로그램을 설치하거나, 공용 게이트웨이 gateway.ipfs.io를 이용하면 볼 수 있다.

메타정보의 처리를 웹에서 수월하게 하기 위하여 ipfs:// 형태의 URI를 HTTP에서도 접근 가능하도록 공용 게이트웨이 URL로 변환했다.
나중에 어플리케이션에서 메타정보를 가져올 때 fetch를 사용하게 되는데 이 때 HTTP 요청으로 보내야 하기 때문이다. nft.storage가 제공하는 toGatewayURL() 함수를 이용하면 쉽게 변환할 수 있다.

1=https://nftstorage.link/ipfs/bafyreicaupcm32egqq7knyzdoyuwzg3u6casgbyrachnpm2d7ouoko7ytm/metadata.json
2=https://nftstorage.link/ipfs/bafyreibvefdsvc2zcgbteobmj7nafa3jcumc2k5pdbjttjfjr6izswoqyq/metadata.json
3=https://nftstorage.link/ipfs/bafyreiekyaifavwtahl6ykhnrqu4t5thllbt3gxtnwu2b6uvsbbtmnqmv4/metadata.json
...

ex) https://nftstorage.link/ipfs/bafyreicaupcm32egqq7knyzdoyuwzg3u6casgbyrachnpm2d7ouoko7ytm/metadata.json

스마트 컨트랙트

ERC-721 컨트랙트

ERC-721 표준 컨트랙트는 오픈제플린을 사용하면 비교적 쉽게 작성할 수 있다.
컨트랙트 개발도구는 하드햇을 사용했다. 그리고 테스트넷에 배포할 예정이므로 미리 networks에 Rinkeby를 추가해줬다. RInkeby 접속은 Alchemy를 사용했다.

컨트랙트는 오픈제펠린의 ERC-721 구현체 중 하나인 ERC721URIStorage를 사용했다. 이 컨트랙트는 필수 ERC-721 표준을 모두 구현하고 있다.
이미 대부분의 기능이 구현된 ERC721URIStorage을 상속받으므로 코드가 길지 않다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

contract CYBERCATnft is ERC721URIStorage {

    address owner;

    modifier onlyOwner {
        require(msg.sender == owner);
        _;
    }

    constructor() ERC721("nya", "CYBER_CAT") {
        owner = msg.sender;
    }

    function mint(address toAddr, uint256 tokenId, string memory tokenURI) public onlyOwner returns (uint256) {
        uint256 newItemId = tokenId;
        _mint(toAddr, newItemId);
        _setTokenURI(newItemId, tokenURI);

        return newItemId;
    }
}

이 컨트랙트에서 외부로 노출된 함수들은 다음과 같다.

함수명구분ERC-721ERC-165
approve트랜잭션O-
safeTransferFrom트랜잭션O-
setApproveForAll트랜잭션O-
transferFrom트랜잭션O-
balanceOf조회O-
getApproved조회O-
isApproveForAll조회O-
name조회O-
ownerOf조회O-
supportsInterface조회-O
tokenURI조회O
symbol조회O
mint트랜잭션--

ERC-165와 ERC-721 표준에 있는 함수가 모두 구현되어 있고, 토큰 발행 함수 mint도 작성했다.
mint는 다시 internal 함수인 _mint를 호출한다.

단위 테스트

기본적인 입출력이 올바른 지 테스트 케이스를 만들고 기능 테스트를 해봐야된다.
대략 아래와 같은 테스트 환경을 구성하고 테스트를 해본다.

  • ERC-721과 ERC-165 인터페이스를 구현했는지 검사
  • 토큰 발행 후 해당 토큰의 소유자가 맞는지 검사
  • 발행되지 않은 토큰을 소유한 계정이 있는지 검사
  • 토큰 소유자의 토큰 개수가 맞는지 검사
  • 토큰 발행 권한이 없는 사용자가 토큰을 발행했을 때 revert가 되는지 검사
  • 토큰을 다른 계정에게 전송했을 때 수취인 계정이 토큰을 받았는지 검사
  • 전송 후 정해진 이벤트가 발생하는지 검사
  • 특정 토큰에 대해 전송 권한을 위임할 수 있는지 검사
  • 위임 계정으로 토큰을 전송할 수 있는지 검사
  • 위임을 철회했을 때 전송 권한이 없는지 검사

리액트 어플리케이션

리액트를 사용하여 토큰을 관리하는 Dapp을 만든다. 꼭 리액트가 아니여도 된다. 하지만 나는 가장 익숙한게 리액트라서 리액트를 사용했다.
CRA로 프로젝트 생성을 해주고 material ui, MUI 라이브러리를 사용했다.

간단하게 상단메뉴에 DApp 이름과 지갑을 연결하는 버튼을 만들고, 본문에서는 NFT이미지와 정보, 민팅,세일 버튼을 카드 형식으로 만들어 배치하였다.

지갑 연결

대부분의 DApp들은 웹브라우저의 지갑과 연결되어 동작한다. 대표적인 지갑은 메타마스크가 있다. 지갑이라는 것은 전자서명을 하기 위한 소프트웨어이기 때문에 어느 특정 어플리케이션에 종속적이지 않다. 메타마스크뿐만 아니라 여러 종류의 지갑이 있고, 따라서 메타마스크만 쓸 수 있게 하는 것보다는 여러 지갑 중 하나를 선택하게 하는 것이 바람직하다.

메타마스크와 WalletConnect라는 것을 사용했다. WalletConnect는 지갑 그 자체는 아니고 Dapp과 모바일 지갑을 연결시켜주는 오픈 프로토콜이다.
지갑 연결 시 METAMASK 버튼을 누르면 메타마스크 지갑이 열리고 WALLETCONNECT를 클릭하면 모바일 지갑으로 스캔할 수 있는 QR코드가 표시되도록 하였다.

스마트 컨트랙트 호출

스마트 컨트랙트의 함수를 호출하여 NFT를 발행할 차례다. 컨트랙트는 이미 Rinkeby에 배포되어 있고 메타정보도(이미지도 함께) IPFS에 업로드되어 있다. NFT 발행이라는 것은 메타정보가 나타내는 것(여기서는 이미지)에 대한 소유권 정보를 컨트랙트에 기록하는 일이다.

Dapp에서 이더리움에 배포된 스마트 컨트랙트와 데이터를 주고받기 위해서는 ABI(Application Binary Interface)를 사용해야 한다. ABI는 컨트랙트를 컴파일한 결과물(artifacts)에 포함되어 있다.

이것을 --export 옵션을 사용하여 컨트랙트의 정보를 파일로 만들어 리액트 어플리케이션에서 참조하면 된다.

컨트랙트의 함수를 호출하려면 Ether.js 라이브러리에서 제공하는 ethers.Contract()를 사용해야한다.

  useEffect(() => {
    if (!isEmpty(web3)) {
		...
      web3
        .getSigner(0)
        .getAddress()
        .then((v) => setAccount(v));
      
      web3
        .getSigner(0)
        .getBalance()
        .then((v) => setBalance(parseFloat(ethers.utils.formatEther(v)).toFixed(3)));
      
      const CYBERCAT = new ethers.Contract(artifact.contracts.CYBERCATnft.address, artifact.contracts.CYBERCATnft.abi, web3.getSigner());
      ...
      setCYBERCATNft(CYBERCAT);
    }
  }, [web3]);

ethers.Contract()에 전달해주어야 하는 인자는 배포 컨트랙트의 주소, ABI, 그리고 현재 어플리케이션과 연결된 지갑 계정이다. 컨트랙트 주소와 ABI는 모두 CYBERCATnft.json에 있다. 연결된 계정은 web3.getSigner()로 가져온다. 이렇게 생성된 컨트랙트 인스턴스를 setCYBERCATNft()를 사용하여 컴포넌트 상태로 저장한다.

Mint 버튼 구현

Mint 버튼을 클릭하면 컨텍스트 API를 통해 전달받은 컨트랙트 인스턴스 CYBERCATNft사용하여 컨트랙트의 mint 함수를 호출한다.
컨트랙트의 함수를 호출할 때는 항상 비동기 호출인 async-await를 써야 한다.

 const handleMint = async (e) => {
    if (CYBERCATNft !== null) {
      const tokenId = e.target.getAttribute('tokenid');
      const tokenURI = await getTokenURI(tokenId);

      const tx = await CYBERCATNft.mint(OWNER, tokenId, tokenURI, { gasLimit: 3000000 });
      try {
        await tx.wait();
        console.log(`${minted} minted successfully`);
      } catch (error) {
        console.log(error.reason);
      }
    } else {
      console.log('WALLET DISCONNECTED');
    }
  };

mint가 정상적으로 처리된 경우 ERC-721 표준에 의해서 Transfer 이벤트가 발생해야 한다. 따라서 어플리케이션에서는 Transfer 이벤트를 확인하여 해당 토큰이 생성되었음을 알 수 있다. 여기서 이벤트 구독은 알케미의 이더리움 게이트웨이 서비스를 이용하였다.

발행이 된 후에는 이더스캔에서 확인할 수 있고 또 메타마스크 모바일에서는 CYBERCATnft 컨트랙트의 주소와 토큰 번호를 넣으면 NFTs 목록에 NFT 이미지들이 표시될 것이다.

OpenSea 게시하기

현재 발행된 NFT들은 전부 컨트랙트를 배포한 계정이 소유하고 있다. 이것을 다른 사람에게 팔기 위해서는 NFT 거래소, 소위 말하는 마켓플레이스가 있어야 한다. 마켓플레이스를 직접 만들 수도 있지만 현재 사람들이 많이 이용하는 OpenSea를 이용하기로 하였다.

OpenSea는 회원 가입 절차 없이 메타마스크와 같은 지갑으로 즉시 로그인할 수 있다. 현재 발행된 NFT는 테스트넷인 Rinkeby에 배포되어 있으므로 Rinkeby OpenSea에 접속해야 한다. 테스트넷과 연동되는 OpenSea는 실제 거래가 이루어지는 곳이 아니라 어디까지나 테스트 용도로 사용되는 것이므로 진짜 이더를 전송하지 않도록 주의해야한다.

OpenSea Testnets

자동으로 수집품 목록에 본인이 소유한 NFT들이 표시된다.
메뉴에서 My Collections를 클릭하고 Create a collection 옆에 있는 메뉴를 열어보면 컨트랙트를 가져올 수 있는 import an existing smart contract를 선택할 수 있다.

Collection 목록에 NFT들이 표시되고 각 NFT들을 클릭해보면 IPFS에 업로드한 메타정보 그대로 속성들이 나타난다.
이는 ERC-721 표준에 맞추어 작성되었기 때문에 별다른 작업 없이도 데이터가 표시될 수 있는 것이다.

OpenSea에서 제공하는 판매 기능을 통해 NFT를 판매 상태로 전환할 수도 있다.

700!

정리

짧게 정리하려고 했는데 쓰다보니까 밤새버렸다. 솔리디티 스마트 컨트랙트와 자바스크립트, 리액트를 사용하여 간단한 어플리케이션을 만들어 보았다. 컨트랙트는 오픈제플린을 이용해서 비교적 쉽게 ERC-721 컨트랙트를 구현했고 자바스크립트를 사용하여 NFT 생성과 메타정보 업로드, 그리고 최종적으로 컨트랙트의 ABI를 결합하여 이더리움과 데이터를 주고받는 어플리케이션을 개발하였다.
데이터베이스나 백엔드 어플리케이션 없이 웹브라우저의 localStorage를 이용하여 필요한 데이터를 저장했다. 또 직접 마켓플레이스를 구현하지 않아도 OpenSea와 같은 대형 NFT 마켓플레이스의 API를 활용하여 NFT를 민팅하고, 판매할 수 있었다.

Reference

자바스크립트 개발자를 위한 이더리움
힙한 개발자의 비밀

좋은 웹페이지 즐겨찾기