Truffle Suite에 있는거 다해보기(2): Marketplace

Brown Lee·2023년 4월 12일
0

https://trufflesuite.com/guides/nft-marketplace/

위 링크에서는 optimism-goerli 네트워크에서 테스트를 진행했지만 이 글에서는 Ganache 로컬 네트워크에서 진행합니다.
기본 세팅 및 환경 설정은 위 페이지를 참고하시면 됩니다.

ERC-721은 NFT의 표준입니다. NFT는 대체불가 토큰의 약자로 모두 제 각각의 가치를 갖고 있습니다. 이를 통해 디지털 자산에 대한 소유권을 보장합니다
https://docs.openzeppelin.com/contracts/2.x/api/token/erc721

민팅하는 스마트 계약 작성

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
// storage 내 체인에 tokenURI를 저장해서 나중에 IPFS에 off-chain 형식으로 올릴 때 필요한 메타데이터를 저장합니다
import "@openzeppelin/contracts/utils/Counters.sol";
// nft의 개수와 각 nft에 할당된 고유한 토큰 아이디를 추적하기 위해 카운터를 사용합니다
contract BoredPetsNFT is ERC721URIStorage {
  using Counters for Counters.Counter;
  Counters.Counter private _tokenIds; // 토큰의 고유한 아이디
  address marketplaceContract; // 시장 스마트 계약의 주소
  event NFTMinted(uint256); // nft가 민트되면 event를 통해 블록(transaction's log)에 기록하고 front에 알리기 위해 사용합니다.


  constructor(address _marketplaceContract) ERC721("Bored Pets Yacht Club", "BPYC") {
    marketplaceContract = _marketplaceContract;
  }
  
  // IPFS에 NFT의 메타데이터가 저장되어 있는 JSON 메타데이터 -> _tokenURI
  function mint(string memory _tokenURI) public {
    _tokenIds.increment(); // 토큰의 개수 증가
    uint256 newTokenId = _tokenIds.current(); // 현재 토큰 번호 가져오기
    _safeMint(msg.sender, newTokenId); // 민트
    _setTokenURI(newTokenId, _tokenURI); // 토큰 메타데이터 설정
    setApprovalForAll(marketplaceContract, true); // 거래를 할 수 있는 자격을 준다-> 내가 민트한 nft를 marketplace 스마트 계약이 거래를 할 수 있게 자격을 준다는 뜻입니다. give rights to the given operator
    emit NFTMinted(newTokenId);
  }
}

마켓플레이스 스마트 계약

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

import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

// ReentrancyGuard는 재진입 공격을 막기 위해 사용합니다
// 다른 동작을 하기 전 신뢰되지 않은 외부 스마트 계약을 호출하는 공격입니다
contract Marketplace is ReentrancyGuard {
  using Counters for Counters.Counter;
  Counters.Counter private _nftsSold; // 팔린 토큰의 개수
  Counters.Counter private _nftCount; // 총 토큰의 수
  uint256 public LISTING_FEE = 0.0001 ether; // 마켓에 올릴 때 내는 수수료
  address payable private _marketOwner; // 마켓 주인
  mapping(uint256 => NFT) private _idToNFT; // counter를 통해 토큰에 접근
  struct NFT { // nft 구조체
    address nftContract;
    uint256 tokenId;
    address payable seller;
    address payable owner;
    uint256 price;
    bool listed;
  }
  event NFTListed( // nft가 마켓에 올라갈 때의 이벤트
    address nftContract,
    uint256 tokenId,
    address seller,
    address owner,
    uint256 price
  );
  event NFTSold( // nft가 팔릴 때의 이벤트
    address nftContract,
    uint256 tokenId,
    address seller,
    address owner,
    uint256 price
  );

  constructor() { // 마켓플레이스를 배포한 유저가 마켓의 주인입니다
    _marketOwner = payable(msg.sender);
  }

  // List the NFT on the marketplace
  function listNft(address _nftContract, uint256 _tokenId, uint256 _price) public payable nonReentrant {
    require(_price > 0, "Price must be at least 1 wei");
    require(msg.value == LISTING_FEE, "Not enough ether for listing fee");

    // IERC721은 ERC721 토큰의 인터페이스가 기술되어 있습니다.
    // 토큰(_tokenId)을 msg.sender에서 address(this):marketplace로 전송합니다
    IERC721(_nftContract).transferFrom(msg.sender, address(this), _tokenId);
    _marketOwner.transfer(LISTING_FEE); // 마켓에 수수료를 전송합니다
    _nftCount.increment(); // 총 토큰의 개수를 하나 올립니다

    _idToNFT[_tokenId] = NFT(
      _nftContract,
      _tokenId, 
      payable(msg.sender),
      payable(address(this)),
      _price,
      true
    );

    emit NFTListed(_nftContract, _tokenId, msg.sender, address(this), _price);
  }

  // Buy an NFT
  function buyNft(address _nftContract, uint256 _tokenId) public payable nonReentrant {
    NFT storage nft = _idToNFT[_tokenId]; // storage에 저장된 _idToNFT 맵을 가지고 옵니다
    require(msg.value >= nft.price, "Not enough ether to cover asking price");

    address payable buyer = payable(msg.sender); // 토큰을 사고자하는 사람 -> 이 함수를 호출한 사람
    payable(nft.seller).transfer(msg.value); // 판매자에게 값을 지불
    IERC721(_nftContract).transferFrom(address(this), buyer, nft.tokenId); // 현재 토큰의 소유자는 마켓플레이스이므로 마켓에서 구매자에게 토큰을 전송
    nft.owner = buyer;
    nft.listed = false;

    _nftsSold.increment();
    emit NFTSold(_nftContract, nft.tokenId, nft.seller, buyer, msg.value);
  }

  // Resell an NFT purchased from the marketplace
  function resellNft(address _nftContract, uint256 _tokenId, uint256 _price) public payable nonReentrant {
    require(_price > 0, "Price must be at least 1 wei");
    require(msg.value == LISTING_FEE, "Not enough ether for listing fee");

    IERC721(_nftContract).transferFrom(msg.sender, address(this), _tokenId);

    // 재 판매를 할 때는 NFT구조체를 변경만 해주면 됩니다
    NFT storage nft = _idToNFT[_tokenId];
    nft.seller = payable(msg.sender);
    nft.owner = payable(address(this));
    nft.listed = true;
    nft.price = _price;

    _nftsSold.decrement();
    emit NFTListed(_nftContract, _tokenId, msg.sender, address(this), _price);
  }

  function getListedNfts() public view returns (NFT[] memory) {
    uint256 nftCount = _nftCount.current();
    uint256 unsoldNftsCount = nftCount - _nftsSold.current();

    NFT[] memory nfts = new NFT[](unsoldNftsCount);
    uint nftsIndex = 0;
    for (uint i = 0; i < nftCount; i++) {
      if (_idToNFT[i + 1].listed) {
        nfts[nftsIndex] = _idToNFT[i + 1]; // 카운터는 1부터 시작하므로 1을 더해줍니다
        nftsIndex++;
      }
    }
    return nfts;
  }

  // 내가 소유하고 있는 토큰들을 반환
  function getMyNfts() public view returns (NFT[] memory) {
    uint nftCount = _nftCount.current();
    uint myNftCount = 0;
    for (uint i = 0; i < nftCount; i++) {
      if (_idToNFT[i + 1].owner == msg.sender) { // 해당 함수를 호출한 사람과 토큰의 소유자가 동일하면 자신의 소유 토큰
        myNftCount++;
      }
    }

    NFT[] memory nfts = new NFT[](myNftCount);
    uint nftsIndex = 0;
    for (uint i = 0; i < nftCount; i++) {
      if (_idToNFT[i + 1].owner == msg.sender) {
        nfts[nftsIndex] = _idToNFT[i + 1];
        nftsIndex++;
      }
    }
    return nfts;
  }

  // 내가 판매하고 있는 토큰들을 반환
  function getMyListedNfts() public view returns (NFT[] memory) {
    uint nftCount = _nftCount.current();
    uint myListedNftCount = 0;
    for (uint i = 0; i < nftCount; i++) {
      if (_idToNFT[i + 1].seller == msg.sender && _idToNFT[i + 1].listed) {
        myListedNftCount++;
      }
    }

    NFT[] memory nfts = new NFT[](myListedNftCount);
    uint nftsIndex = 0;
    for (uint i = 0; i < nftCount; i++) {
      if (_idToNFT[i + 1].seller == msg.sender && _idToNFT[i + 1].listed) {
        nfts[nftsIndex] = _idToNFT[i + 1];
        nftsIndex++;
      }
    }
    return nfts;
  }
}

배포하기

// in migrations/1_deploy_contracts/js
// artifacts를 통해 스마트 계약들을 불러오고
var BoredPetsNFT = artifacts.require("BoredPetsNFT");
var Marketplace = artifacts.require("Marketplace");

module.exports = async function(deployer) {
  // 마켓 먼저 배포
  await deployer.deploy(Marketplace);
  const marketplace = await Marketplace.deployed();
  // 민팅 스마트 계약은 마켓의 주소가 필요하므로 마켓 먼저 배포 후 배포
  await deployer.deploy(BoredPetsNFT, marketplace.address);
}
  • truffle-config.js의 development부분을 ganache 네트워크에 맞게(http://127.0.0.1:7545) 수정한 뒤 truffle migrate를 하면 가나시 거래 내역에서 스마트 계약 2개가 배포된 것을 확인할 수 있습니다.

테스트하기

require("@openzeppelin/test-helpers/configure")({
  provider: web3.currentProvider,
  singletons: {
    abstraction: "truffle",
  },
});

const { balance, ether, expectRevert, expectEvent } = require('@openzeppelin/test-helpers');
const Marketplace = artifacts.require("Marketplace");
const BoredPetsNFT = artifacts.require("BoredPetsNFT");

function assertListing(actual, expected) {
  assert.equal(actual.nftContract, expected.nftContract, "NFT contract is not correct");
  assert.equal(actual.tokenId, expected.tokenId, "TokenId is not correct");
  assert.equal(actual.owner, expected.owner, "Owner is not correct");
  assert.equal(actual.seller, expected.seller, "Seller is not correct");
  assert.equal(actual.price, expected.price, "Price is not correct");
  assert.equal(actual.listed, expected.listed, "Listed is not correct")
}

function getListing(listings, tokenId) {
  let listing = {};
  listings.every((_listing) => {
    if (_listing.tokenId == tokenId) {
      listing = _listing;
      return false;
    } else {
      return true;
    }
  });
  return listing
}

function listingToString(listing) {
  let listingCopy = {...listing};
  listingCopy.tokenId = listing.tokenId.toString();
  listingCopy.price = listing.price.toString();
  if (listing.listed) {
    listingCopy.listed = listing.listed.toString();
  }
  return listingCopy;
}

async function mintNft(nftContract, tokenOwner) {
  return (await nftContract.mint("fakeURI", {from: tokenOwner})).logs[0].args.tokenId.toNumber()
}

contract("Marketplace", function (accounts) {
  const MARKETPLACE_OWNER = accounts[0];
  const TOKEN_OWNER = accounts[1];
  const BUYER = accounts[2];
  let marketplace;
  let boredPetsNFT;
  let nftContract;
  let listingFee;

  before('should reuse variables', async () => {
    marketplace = await Marketplace.deployed();
    boredPetsNFT = await BoredPetsNFT.deployed();
    nftContract = boredPetsNFT.address;
    listingFee = (await marketplace.LISTING_FEE()).toString();
    console.log("marketplace %s", marketplace.address)
    console.log("token_owner %s", TOKEN_OWNER)
    console.log("buyer %s", BUYER)
  });
  it("should validate before listing", async function () {
    await expectRevert(
      marketplace.listNft(nftContract, 1, ether(".005"), {from: TOKEN_OWNER}),
      "Not enough ether for listing fee"
    );
    await expectRevert(
      marketplace.listNft(nftContract, 1, 0, {from: TOKEN_OWNER, value: listingFee}),
      "Price must be at least 1 wei"
    );
  });
  it("should list nft", async function () {
    let tokenID = await mintNft(boredPetsNFT, TOKEN_OWNER);
    let tracker = await balance.tracker(MARKETPLACE_OWNER);
    await tracker.get();
    let txn = await marketplace.listNft(nftContract, tokenID, ether(".005"), {from: TOKEN_OWNER, value: listingFee});
    assert.equal(await tracker.delta(), listingFee, "Listing fee not transferred");
    let expectedListing = {
      nftContract: nftContract,
      tokenId: tokenID,
      seller: TOKEN_OWNER,
      owner: marketplace.address,
      price: ether(".005"),
      listed: true
    };
    assertListing(getListing(await marketplace.getListedNfts(), tokenID), expectedListing);
    assertListing(getListing(await marketplace.getMyListedNfts({from: TOKEN_OWNER}), tokenID), expectedListing);
    delete expectedListing.listed;
    expectEvent(txn, "NFTListed", listingToString(expectedListing));
  });
  it("should validate before buying", async function () {
    await expectRevert(
      marketplace.buyNft(nftContract, 1, {from: BUYER}),
      "Not enough ether to cover asking price"
    );
  });
  it("should modify listings when nft is bought", async function () {
    let tokenID = await mintNft(boredPetsNFT, TOKEN_OWNER);
    await marketplace.listNft(nftContract, tokenID, ether(".005"), {from: TOKEN_OWNER, value: listingFee});
    let expectedListing = {
      nftContract: nftContract,
      tokenId: tokenID,
      seller: TOKEN_OWNER,
      owner: marketplace.address,
      price: ether(".005"),
      listed: true
    };
    assertListing(getListing(await marketplace.getListedNfts(), tokenID), expectedListing);
    let tracker = await balance.tracker(TOKEN_OWNER);
    let txn = await marketplace.buyNft(nftContract, tokenID, {from: BUYER, value: ether(".005")});
    expectedListing.owner = BUYER;
    expectedListing.listed = false;
    assert.equal((await tracker.delta()).toString(), ether(".005").toString(), "Price not paid to seller");
    assertListing(getListing(await marketplace.getMyNfts({from: BUYER}), tokenID), expectedListing);
    delete expectedListing.listed;
    expectEvent(txn, "NFTSold", listingToString(expectedListing));
  });
  it("should validate reselling", async function () {
    await expectRevert(
      marketplace.resellNft(nftContract, 1, 0, {from: BUYER, value: listingFee}),
      "Price must be at least 1 wei"
    );
    await expectRevert(
      marketplace.resellNft(nftContract, 1, ether(".005"), {from: BUYER}),
      "Not enough ether for listing fee"
    );
  });
  it("should resell nft", async function () {
    let tokenID = await mintNft(boredPetsNFT, TOKEN_OWNER);
    await marketplace.listNft(nftContract, tokenID, ether(".005"), {from: TOKEN_OWNER, value: listingFee});
    await marketplace.buyNft(nftContract, tokenID, {from: BUYER, value: ether(".005")});
    let expectedListing = {
      nftContract: nftContract,
      tokenId: tokenID,
      seller: TOKEN_OWNER,
      owner: BUYER,
      price: ether(".005"),
      listed: false
    };
    assertListing(getListing(await marketplace.getMyNfts({from: BUYER}), tokenID), expectedListing);
    await boredPetsNFT.approve(marketplace.address, tokenID, {from: BUYER});
    let txn = await marketplace.resellNft(nftContract, tokenID, ether(".005"), {from: BUYER, value: listingFee});
    expectedListing.seller = BUYER;
    expectedListing.owner = marketplace.address;
    expectedListing.listed = true;
    assertListing(getListing(await marketplace.getListedNfts(), tokenID), expectedListing);
    assertListing(getListing(await marketplace.getMyListedNfts({from: BUYER}), tokenID), expectedListing);
    delete expectedListing.listed;
    expectEvent(txn, "NFTListed", listingToString(expectedListing));
  });
});

프론트와 연결하기

NFT metadata를 올리기 위해 infura의 ipfs 프로젝트를 생성합니다.
dedicated gateways를 enable로 변경합니다.
프론트를 구성하는 페이지는 따로 작성하지 않겠습니다. 글 맨 위의 링크로 들어가시면 확인가능합니다.
프론트에서 제가 중요하다고 생각한 부분만 작성해보겠습니다.

cd client
npm install axios // nodejs와 브라우저를 위한 promise기반 http client
npm install web3modal // Library for manage multi-chain wallet connection-flow
npm install web3 // ehtereum java scripts api
npm install ipfs-http-client // library for ipfs http api
해당 폴더로 들어가보면 배포된 스마트 계약들의 함수들이 json 형식으로 변환된 것을 확인할 수 있습니다
이를 사용하여 스마트계약의 기능들을 사용합니다
import Marketplace from '../contracts/optimism-contracts/Marketplace.json'
import BoredPetsNFT from '../contracts/optimism-contracts/BoredPetsNFT.json'

// 지갑연결을 위해 web3modal을 사용
const web3Modal = new Web3Modal()
// provider is a website running geth or parity node which talks to ehtereum network
const provider = await web3Modal.connect()
const web3 = new Web3(provider)
// 이더리움을 사용하는 네트워크의 아이디(ganache로 하면 들어가보면 networkId가 있는데 console.log로 찍어보면 똑같이 나옵니다)
const networkId = await web3.eth.net.getId()

// 스마트 계약을 불러오는 코드인데 input으로 마켓의 abi(json)과 해당 계약 주소를 받습니다
// Marketplace.networks[networkId].address는 Marketplace.json파일에 들어가보면 찾을 수 있습니다
const marketPlaceContract = new web3.eth.Contract(Marketplace.abi, Marketplace.networks[networkId].address)

스마트계약의 함수를 사용하고 싶을 (smart contract : A)
A.methods.func_in_a()~~

맨 위 링크 튜토리얼 진행 시 IPFS_KEY와 IPFS_SECRET의 위치가 반대로 있으니(create-and-list-nft.js) 변경하시면 잘 돌아갑니다.

profile
레쭈고

0개의 댓글