ERC-20 토큰으로 ERC-721 NFT 민팅하기

taeheeyoon·2022년 8월 25일
2

Blockchain 실습

목록 보기
7/8
post-thumbnail

시작하며

안녕하세요.
이번 글에서는 ERC-20 토큰을 민팅하고, 민팅한 토큰을 일정 수량 지불해야만 ERC-721 NFT를 민팅할 수 있는 스마트 컨트랙트 코드를 작성해보겠습니다.

설명

계정의 종류

크게 총 3가지 유형의 계정이 있습니다.

  • 배포 계정 : 컨트랙트를 배포하는 계정, 토큰을 민팅하는 계정
  • 서버 계정 : 토큰 전송 권한을 위임받아 유저에게 토큰을 전송하고, 유저에게 토큰을 받아 NFT 민팅을 하는 계정
  • 유저 계정 : 특정 이벤트를 통해 토큰을 지급받고, 일정 수량의 토큰을 모아 NFT를 민팅 할 수 있는 계정

ERC-20 토큰을 사용하여 ERC-721 NFT를 민팅하는 과정

  1. 편의상 배포 계정에서 배포와 동시에 토큰을 민팅합니다.
  2. NFT 컨트랙트에서 사용 할 토큰 컨트랙트의 CA를 설정해줍니다.
  3. 서버 계정유저 계정에게 토큰을 전송하기 위해, 배포 계정에서 서버 계정으로 토큰 전송 권한을 위임(approve)합니다.
  4. 배포 계정으로부터 토큰 전송 권한을 위임 받은 서버 계정에서 특정 이벤트가 발생하면 유저 계정으로 토큰을 전송합니다.
  5. 유저는 일정한 수량의 토큰을 모아서 NFT를 민팅할 수 있습니다.
  6. 유저 계정의 토큰을 사용하여 민팅을 하기 위해서 유저 계정의 토큰 전송 권한을 서버 계정(NFT 컨트랙트 CA)에 위임합니다.
  7. 서버 계정(NFT 컨트랙트 CA)은 NFT 발행에 필요한 유저 계정의 토큰의 수량을 검증하고 충분하다면 NFT를 민팅합니다.

프로젝트 초기 설정

먼저 해당 프로젝트를 시작하기 앞서 Truffle 설정이 선행되어야합니다.
지난 글인 Truffle로 SmartContract 배포하기를 참조하여 설정하시기 바랍니다.

추가적으로 저는 테스트를 하기 위하여 테스트 코드는 Ganache를 이용하여 실행하였습니다.

토큰 컨트랙트

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.10;

 import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract SimpleToken is ERC20 {
    constructor() ERC20("SimpleToken", "SIM") {
          _mint(msg.sender, 10000);
    }

}

OpenZepplin의 ERC-20을 상속받아 토큰을 민팅해줍니다.

NFT 컨트랙트

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.10;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract SimpleNFT is ERC721URIStorage, Ownable {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    IERC20 token;
    uint256 nftPrice;

    constructor() ERC721("SimpleNFTs", "SNFT") {
        nftPrice = 100;
    }

    function mintNFT(address recipient, string memory tokenURI) public returns (uint256) {
        require(token.balanceOf(recipient) > nftPrice);

        token.transferFrom(recipient, msg.sender, nftPrice);
        
        _tokenIds.increment();
        uint256 newItemId = _tokenIds.current();
        _mint(recipient, newItemId);
        _setTokenURI(newItemId, tokenURI);

        return newItemId;
    }

    function setToken (address tokenAddress) public onlyOwner returns (bool) {
        require(tokenAddress != address(0x0));
        token = IERC20(tokenAddress);
        return true;
    }

}

마찬가지로 OpenZeppelin의 ERC-721을 상속받아 NFT를 민팅해줍니다.
보시면 아시겠지만 토큰을 받아 NFT를 민팅해줄 예정이기 때문에 토큰 컨트랙트의 주소를 설정하고 토큰의 수량을 검증하는 로직이 추가로 구현이 되어있습니다.

테스트

먼저 두 컨트랙트 모두 각각 ERC-20, ERC-721를 상속받고있기 때문에 이에 포함된 함수들을 사용할 수 있습니다.

토큰 컨트랙트 테스트

const SimpleToken = artifacts.require('SimpleToken');

contract('SimpleToken', (accounts) => {
  it('토큰의 이름이 SimpleToken입니다.', async () => {
    const instance = await SimpleToken.deployed();
    const tokenName = await instance.name();
    assert.equal(
      tokenName,
      'SimpleToken',
      '토큰의 이름이 SimpleToken과 다릅니다.'
    );
  });

  it('토큰의 심볼이 SIM입니다', async () => {
    const instance = await SimpleToken.deployed();
    const tokenName = await instance.symbol();
    assert.equal(tokenName, 'SIM', '토큰의 심볼이 SIM과 다릅니다.');
  });

  it('배포 계정에서 SimpleToken 10,000개를 보유합니다', async () => {
    const instance = await SimpleToken.deployed();
    const balance = await instance.balanceOf(accounts[0]);
    assert.equal(
      balance.toNumber(),
      10000,
      '토큰 보유량이 10000개와 다릅니다.'
    );
  });

  it('서버 계정에서 SimpleToken 0개를 보유합니다', async () => {
    const instance = await SimpleToken.deployed();
    const balance = await instance.balanceOf(accounts[1]);
    assert.equal(balance.toNumber(), 0, '토큰 보유량이 0개와 다릅니다.');
  });

  it('다른 계정으로 토큰을 전송합니다.', async () => {
    const instance = await SimpleToken.deployed();

    // 계정을 설정합니다.
    const accountOne = accounts[0];
    const accountTwo = accounts[1];

    // 계정의 초기 토큰 보유량을 가져옵니다.
    const accountOneStartingBalance = (
      await instance.balanceOf(accountOne)
    ).toNumber();
    const accountTwoStartingBalance = (
      await instance.balanceOf(accountTwo)
    ).toNumber();

    // 첫번째 계정에서 두번째 계정으로 토큰을 전송합니다.
    const amount = 10;
    await instance.transfer(accountTwo, amount);

    // 전송 후 계정의 토큰 보유량을 가져옵니다.
    const accountOneEndingBalance = (
      await instance.balanceOf(accountOne)
    ).toNumber();
    const accountTwoEndingBalance = (
      await instance.balanceOf(accountTwo)
    ).toNumber();

    assert.equal(
      accountOneEndingBalance,
      accountOneStartingBalance - amount,
      '토큰이 올바르게 송신되지 않았습니다.'
    );
    assert.equal(
      accountTwoEndingBalance,
      accountTwoStartingBalance + amount,
      '토큰이 올바르게 수신되지 않았습니다.'
    );
  });

  it('배포 계정에서 서버 계정으로 SimpleToken를 양도합니다', async () => {
    const instance = await SimpleToken.deployed();

    const accountOne = accounts[0];
    const accountTwo = accounts[1];

    await instance.approve(accountTwo, 7777);
    const balance = await instance.allowance(accountOne, accountTwo);

    assert.equal(
      balance.toNumber(),
      7777,
      '양도 받은 갯수가 일치하지 않습니다.'
    );
  });

  it('서버 계정에서 양도받은 토큰을 유저 계정으로 전송합니다.', async () => {
    const instance = await SimpleToken.deployed();

    const deployAccount = accounts[0];
    const serverAccount = accounts[1];
    const userAccount = accounts[2];

    await instance.approve(serverAccount, 10000);

    await instance.transferFrom(deployAccount, userAccount, 123, {
      from: serverAccount,
    });

    const userAccountBalance = await instance.balanceOf(userAccount);

    assert.equal(
      userAccountBalance.toNumber(),
      123,
      '유저 계정의 잔고가 서버 계정에서 보낸 토큰 갯수와 일치하지 않습니다.'
    );
  });
});

NFT 컨트랙트 테스트

const SimpleToken = artifacts.require('SimpleToken');
const SimpleNFT = artifacts.require('SimpleNFT');

contract('SimpleNFT', (accounts) => {
  it('NFT의 이름이 SimpleNFTs입니다.', async () => {
    const NFTinstance = await SimpleNFT.deployed();
    const NFTName = await NFTinstance.name();
    assert.equal(NFTName, 'SimpleNFTs', 'NFT의 이름이 SimpleNFTs과 다릅니다.');
  });

  it('NFT의 심볼이 SNFT입니다', async () => {
    const NFTinstance = await SimpleNFT.deployed();
    const NFTName = await NFTinstance.symbol();
    assert.equal(NFTName, 'SNFT', 'NFT의 심볼이 SNFT과 다릅니다.');
  });

  it('Token 주소와 NFT를 발행할 수 있는 Token의 주소가 일치합니다.', async () => {
    const Tokeninstance = await SimpleToken.deployed();
    const NFTinstance = await SimpleNFT.deployed();

    await NFTinstance.setToken(Tokeninstance.address);
    const NFTtokenAdress = await NFTinstance.tokenAdress();
    assert.equal(
      Tokeninstance.address,
      NFTtokenAdress,
      '토큰 주소가 일치하지 않습니다.'
    );
  });

  it('유저 계정의 SIM 토큰 20개로 NFT를 민팅할 수 있습니다.', async () => {
    const Tokeninstance = await SimpleToken.deployed();
    const NFTinstance = await SimpleNFT.deployed();

    const deployAccount = accounts[0];
    const serverAccount = accounts[1];
    const userAccount = accounts[2];

    //메타데이터 생성
    const metaData = {
      name: 'testNFT',
      description: 'this is test NFT',
    };

    //서버 계정에 양도;
    await Tokeninstance.approve(serverAccount, 10000);

    //유저에게 토큰 전송
    await Tokeninstance.transferFrom(deployAccount, userAccount, 100, {
      from: serverAccount,
    });

    //초기 token 보유량
    const startingTokenbalance = await Tokeninstance.balanceOf(userAccount);
    //초기 NFT 보유량
    const startingNFTbalance = await NFTinstance.balanceOf(userAccount);

    //유저가 NFT 컨트랙트 계정에 토큰 양도
    await Tokeninstance.approve(NFTinstance.address, 100, {
      from: userAccount,
    });

    //ERC20 토큰 설정
    await NFTinstance.setToken(Tokeninstance.address);

    //NFT 민팅
    await NFTinstance.mintNFT(userAccount, metaData, {
      from: serverAccount,
    });

    //NFT 발행 후 token 보유량
    const endTokenbalance = await Tokeninstance.balanceOf(userAccount);
    //NFT 발행 후 NFT 보유량
    const endNFTbalance = await NFTinstance.balanceOf(userAccount);

    assert.equal(
      endTokenbalance.toNumber(),
      startingTokenbalance.toNumber() - 20,
      '유저 계정의 토큰 잔고가 일치하지 않습니다.'
    );

    assert.equal(
      endNFTbalance.toNumber(),
      startingNFTbalance.toNumber() + 1,
      '유저 계정의 NFT 민팅이 실패하였습니다.'
    );
  });

  it('유저 계정의 SAM 토큰 20개로 NFT를 민팅할 수 있습니다.2', async () => {
    const Tokeninstance = await SimpleToken.deployed();
    const NFTinstance = await SimpleNFT.deployed();

    const deployAccount = accounts[0];
    const serverAccount = accounts[1];
    const userAccount = accounts[3];

    const metaData = {
      name: 'testNFT',
      description: 'this is test NFT',
    };

    await Tokeninstance.approve(serverAccount, 10000);

    await Tokeninstance.transferFrom(deployAccount, userAccount, 100, {
      from: serverAccount,
    });

    await Tokeninstance.approve(NFTinstance.address, 100, {
      from: userAccount,
    });

    await NFTinstance.setToken(Tokeninstance.address);

    await NFTinstance.mintNFT(userAccount, metaData, {
      from: serverAccount,
    });

    const userAccountNFTBalance = await NFTinstance.balanceOf(userAccount, {
      from: userAccount,
    });

    assert.equal(
      userAccountNFTBalance.toNumber(),
      1,
      '유저 계정의 NFT 민팅에 실패하였습니다.'
    );
  });
});

주의할 점은 NFT 컨트랙트에서

        token.transferFrom(recipient, msg.sender, nftPrice);

transferFrom를 사용하는데 transferFrom은 NFT 컨트랙트가 유저 계정한테 토큰 전송 권한을 양도 받아야만 사용할 수 있습니다.

    //유저가 NFT 컨트랙트 계정에 토큰 양도
    await Tokeninstance.approve(NFTinstance.address, 100, {
      from: userAccount,
    });

테스트 코드에서는 이 부분 입니다.
유저 계정NFT 컨트랙트의 CA를 대상으로 토큰 100개의 전송 권한을 위임 한 것을 확인 할 수 있습니다.

또한 NFT 컨트랙트의 setToken함수의 파라미터로는 토큰 컨트랙트의 CA 주소를 넣어 주시면 됩니다.

    //ERC20 토큰 설정
    await NFTinstance.setToken(Tokeninstance.address);

마치며

OpenZeppelin의 ERC-20과 ERC-721를 활용하여 간단하게 토큰을 이용한 NFT 민팅을 구현할 수 있었습니다.
직접 발행한 토큰을 활용해서 NFT를 민팅한다는게 매우 흥미로운 부분 같습니다.
이를 응용하여 간단한 DApp의 토큰 이코노미의 생태계를 구축할 수도 있을 것 같습니다.
혹시 질문이 필요하시면 댓글이나 이메일로 보내주시면 감사드리겠습니다.
읽어주셔서 감사합니다.

profile
생각하는 대로 살지 않으면, 사는 대로 생각하게 된다.

0개의 댓글