BlockChain

(BlockChain) NFT-Market (2)

JJeongHyun 2023. 3. 17. 20:31
반응형

https://developerjjh.tistory.com/179

 

(BlockChain) NFT-Market (1)

NFT를 거래할 수 있는 조그마한 Market을 만들어 보려고 한다 일단, 시작에 앞서 파일 및 폴더 환경설정부터 하면서 천천히 진행하려 한다 0. TS 설치 npm list -g npm i -g typescript ts-node 이전에 TS를 사용

developerjjh.tistory.com

이전 게시물에 앞서 코드를 이어가 보려고 한다

 

nft-market/back 추가 라이브러리 설치

npm i axios web3 web3-utils
mkdir contracts

 

back/contracts

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

import "./NftToken.sol";

contract SaleToken {
  NftToken public Token;

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

  struct TokenInfo {
    uint tokenId;
    uint price;
    string tokenURI;
  }
  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);
    require(Token.isApprovedForAll(msg.sender, address(this)));

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

  function PurchaseToken(uint _tokenId) public payable {
    address tokenOwner = Token.ownerOf(_tokenId);

    require(tokenOwner != msg.sender);
    require(tokenPrices[_tokenId] > 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) {
        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 price = tokenPrices[tokenId];
      string memory tokenURI = Token.tokenURI(tokenId);

      list[i] = TokenInfo(tokenId, price, tokenURI);
    }
    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);
      string memory tokenURI = Token.tokenURI(tokenId);
      uint price = tokenPrices[tokenId];

      list[i] = TokenInfo(tokenId, price, tokenURI);
    }
    return list;
  }

  function getLatestToken(
    address _tokenOwner
  ) public view returns (TokenInfo memory) {
    uint balance = Token.balanceOf(_tokenOwner);
    uint tokenId = Token.tokenOfOwnerByIndex(_tokenOwner, balance - 1);
    uint price = tokenPrices[tokenId];
    string memory tokenURI = Token.tokenURI(tokenId);

    return TokenInfo(tokenId, price, tokenURI);
  }
}

이 전에 사용했던 NFT 거래 컨트랙트 SaleToken.sol 파일을 가져온다

import "../node_modules/@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "../node_modules/@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "../node_modules/@openzeppelin/contracts/access/Ownable.sol";
import "../node_modules/@openzeppelin/contracts/utils/Counters.sol";
  • SaleToken.sol을 가져와서 해당 폴더(back/contracts) 내 넣어준다
  • NftToken.sol을 만들어서 ERC721Enumerable, ERC721URIStorage, Ownable을 상속

두 개의 solidity파일을 정상적으로 완성하고 remix 명령어를 실행하기 전에

 

react와 express환경부터 실행한다

# react
yarn start

# npm 
npm run start:dev

# remix
npx remixd -s . -u https://remix.ethereum.org

 

remix 웹페이지에서 각각의 solidity파일에 대해 deploy을 하여 CA값을 뽑아낸다

(좌) localhost 연결 후 NftToken.sol compiled / (우) NftToken deploy
SaleToken.sol 파일을 선택하고 컴파일 후 NftToken의 CA로 deploy
각각의 solidity 파일 deployed 후 화면

CA 값들을 .env 파일에 저장해 둔다

 

src/index.ts

import Web3 from "web3";
import { AbiItem } from "web3-utils";
import axios from "axios";

import { abi as NftAbi } from "../contracts/artifacts/NftToken.json";
import { abi as SaleAbi } from "../contracts/artifacts/SaleToken.json";

const web3 = new Web3("http://ganache.test.errorcode.help:8545");

const deployed = new web3.eth.Contract(NftAbi as AbiItem[], process.env.NFT_CA);

const obj: { nonce: number; to: string; from: string; data: string } = {
  nonce: 0,
  to: "",
  from: "",
  data: "",
};
obj.nonce = await web3.eth.getTransactionCount(req.body.from);
obj.to = process.env.NFT_CA;
obj.from = req.body.from;
obj.data = deployed.methods.safeMint(jsonResult.IpfsHash).encodeABI();

res.send(obj);
  • abi정보가 담겨있는 JSON파일은 remix환경에서 각각의 solidity 파일을 컴파일하면 artifacts폴더 내에 생긴다
  • 넘어온 obj를 front에서 result로 응답
    • 이후 해당 값으로 트랜잭션을 보내기 위해 web3을 받아온다

 

front/src/components/Mint.tsx

import Web3 from "web3";

export const Mint = ({ web3, account }: { web3: Web3; account: string }) => {
  formData.append("from", account);

  web3.eth.sendTransaction(result);
};
  • Mint 컴포넌트에 account 정보를 props로 넘겨준다

back/src/index.ts

app.post(
  "/api/mint",
  upload.single("file"),
  async (req: Request, res: Response) => {
    const { name, description }: { name: string; description: string } =
      req.body;
    const imgResult: {
      IpfsHash: string;
      PinSize: number;
      Timestamp: string;
      isDuplicate?: boolean;
    } = await pinata.pinFileToIPFS(Readable.from(req.file.buffer), {
      pinataMetadata: { name: Date.now().toString() },
      pinataOptions: { cidVersion: 0 },
    });
    if (imgResult.isDuplicate) console.log("img Duplicated");
    const jsonResult = await pinata.pinJSONToIPFS(
      {
        name,
        description,
        image: `https://gateway.pinata.cloud/ipfs/${imgResult.IpfsHash}`,
      },
      {
        pinataMetadata: { name: Date.now().toString() + ".json" },
        pinataOptions: { cidVersion: 0 },
      }
    );
    const deployed = new web3.eth.Contract(
      NftAbi as AbiItem[],
      process.env.NFT_CA
    );

    const obj: { nonce: number; to: string; from: string; data: string } = {
      nonce: 0,
      to: "",
      from: "",
      data: "",
    };
    obj.nonce = await web3.eth.getTransactionCount(req.body.from);
    obj.to = process.env.NFT_CA;
    obj.from = req.body.from;
    obj.data = deployed.methods.safeMint(jsonResult.IpfsHash).encodeABI();

    res.send(obj);
  }
);
  • multer로 업로드한 이미지 데이터를 pinata(라이브러리)를 사용해서 CID를 뽑아내서 pinata ipfs JSON파일 또한 CID를 접근해서 트랜잭션을 보낼 데이터의 객체를 생성하여 front의 axios 응답 해준다

 

Token List 출력

// back/src/index.ts

app.post("/api/list", async (req: Request, res: Response) => {
  const deployed = new web3.eth.Contract(
    SaleAbi as AbiItem[],
    process.env.SALE_CA
  );
  let data: Array<{
    tokenId?: string;
    price?: string;
    name: string;
    description: string;
    image: string;
  }> = [];
  if (req.body.from) {
    try {
      const tempArr = await deployed.methods
        .getOwnerTokens(req.body.from)
        .call();

      for (let i = 0; i < tempArr.length; i++) {
        try {
          const { name, description, image } = (
            await axios.get(tempArr[i].tokenURI)
          ).data;
          data.push({
            tokenId: tempArr[i].tokenId,
            price: tempArr[i].price,
            name,
            description,
            image,
          });
        } catch (err) {}
      }
    } catch (err) {}
  } else {
    try {
      const tempArr = await deployed.methods.getSaleTokenList().call();
      for (let i = 0; i < tempArr.length; i++) {
        try {
          const { name, description, image } = (
            await axios.get(tempArr[i].tokenURI)
          ).data;

          data.push({
            tokenId: tempArr[i].tokenId,
            price: tempArr[i].price,
            name,
            description,
            image,
          });
        } catch (err) {}
      }
    } catch (err) {}
  }
  res.send(data);
});
  • 기존에 get방식으로 axios통신을 했었지만, 계정의 주소값을 보내기에 post방식으로 변경해 준다
  • abi는 Contract SaleToken의 abi값을 가져오고 CA는 env에 저장된 SALE_CA를 넣어줘서 새로운 Contract를 생성
  • 메타마스크에 로그인이 됐을 때랑 안 됐을 때랑 나눠서 코드를 구현
    • 메타마스크에 연결돼서 계정이 있을 때는 해당 계정을 기준으로 생성한 토큰정보들을 가져와서 목록들을 출력
    • 메타마스크에 연결이 되어있지 않으면 계정에 대한 정보가 없으므로 판매 토큰 목록들을 출
// front/src/components/List.tsx

import axios from "axios";
import { useEffect, useState } from "react";

interface nftData {
  name: string;
  description: string;
  image: string;
}

export const List = ({ account }: { account: string }) => {
  const [list, setList] = useState<Array<nftData>>([]);

  useEffect(() => {
    (async () => {
      setList(
        (await axios.post("http://localhost:8080/api/list", { from: account }))
          .data
      );
    })();
  }, [account]);

  return (
    <ul>
      {list.map((item, index) => (
        <Item item={item} key={`item-${index}`} />
      ))}
    </ul>
  );
};

const Item = ({ item: { name, description, image } }: { item: nftData }) => {
  return (
    <li>
      <div>{name}</div>
      <div>{description}</div>
      <div>
        <img src={image} alt="" />
      </div>
    </li>
  );
};

useWeb3.ts Custom Hook 수정

window.ethereum?.on("accountsChanged", async () => {
  if (window.ethereum) {
    const [_account] = (await window.ethereum.request({
      method: "eth_requestAccounts",
    })) as Array<string>;
    setAccount(_account);
  }
});

 

전체 코드

// front/src/App.tsx

import { useWeb3 } from "./modules/useWeb3";
import Mint from "./components/Mint";
import List from "./components/List";

function App() {
  const { web3, account, chainId, logIn } = useWeb3();
  return (
    <div>
      <div>
        {account && web3 ? (
          <div>
            <div>ChainId : {chainId}</div>
            <div>Account : {account}</div>
            <Mint web3={web3} account={account} />
          </div>
        ) : (
          <div>
            <button
              onClick={() => {
                logIn();
              }}
            >
              LogIn MetaMask
            </button>
          </div>
        )}
      </div>
      <List account={account} />
    </div>
  );
}

export default App;

useWeb3.ts

// front/src/modules/useWeb3.ts

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

export const useWeb3 = (): {
  web3?: Web3;
  account: string;
  chainId: string | null | undefined;
  logIn: () => void;
} => {
  const [web3, setWeb3] = useState<Web3 | undefined>();
  const [account, setAccount] = useState<string>("");
  const [chainId, setChainId] = useState<string | null | undefined>("");

  const logIn = useCallback(async () => {
    try {
      if (!window.ethereum) console.log("MetaMask is not exist");
      const _web3 = new Web3((window as any).ethereum);
      setWeb3(_web3);

      const [_account] = (await window.ethereum?.request({
        method: "eth_requestAccounts",
      })) as Array<string>;

      if (_account) setAccount(_account);

      window.ethereum?.on("accountsChanged", async () => {
        if (window.ethereum) {
          const [_account] = (await window.ethereum.request({
            method: "eth_requestAccounts",
          })) as Array<string>;
          setAccount(_account);
        }
      });

      setChainId(window.ethereum?.networkVersion);
    } catch (err) {
      console.log(err);
    }
  }, []);
  return { web3, account, chainId, logIn };
};

Mint.tsx

front/src/components/Mint.tsx

import axios from "axios";
import { useCallback, useState, FormEvent, ChangeEvent } from "react";
import Web3 from "web3";

const Mint = ({ web3, account }: { web3: Web3; account: string }) => {
  const [NftName, setName] = useState<string>("");
  const [NftDescription, setDescription] = useState<string>("");
  const [file, setFile] = useState<File | undefined>();
  const [img, setImg] = useState<string | ArrayBuffer>("");

  const settingName = useCallback((e: FormEvent<HTMLInputElement>) => {
    setName(e.currentTarget.value);
  }, []);
  const settingDescription = useCallback((e: FormEvent<HTMLInputElement>) => {
    setDescription(e.currentTarget.value);
  }, []);

  const fileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    if (e.currentTarget.files && e.currentTarget.files.length > 0) {
      setFile(e.currentTarget.files[0]);

      const reader = new FileReader();
      reader.readAsDataURL(e.currentTarget.files[0]);
      reader.onload = () => {
        if (reader.result) setImg(reader.result);
      };
    }
  }, []);

  const mint = async () => {
    if (!NftName || !NftDescription || !file) return;

    const formData = new FormData();
    formData.append("file", file);
    formData.append("name", NftName);
    formData.append("description", NftDescription);
    formData.append("from", account);

    const result = (
      await axios.post("http://localhost:8080/api/mint", formData)
    ).data;
    console.log(result);
    web3.eth.sendTransaction(result);
  };

  return (
    <div>
      <input type={"text"} placeholder={"NftName"} onInput={settingName} />
      <input
        type={"text"}
        placeholder={"NftDescription"}
        onInput={settingDescription}
      />
      <input type={"file"} onChange={fileChange} />
      {img && (
        <div>
          <img src={img.toString()} alt="" />
        </div>
      )}
      <button onClick={mint}>Start Minting</button>
    </div>
  );
};

export default Mint;

List.tsx

// front/src/components/List.tsx

import axios from "axios";
import { useCallback, useState, useEffect } from "react";

interface nftData {
  name: string;
  description: string;
  image: string;
}

const List = ({ account }: { account: string }) => {
  const [list, setList] = useState<Array<nftData>>([]);

  useEffect(() => {
    (async () => {
      setList(
        (await axios.post("http://localhost:8080/api/list", { from: account }))
          .data
      );
    })();
  }, [account]);

  return (
    <ul>
      {list.map((item, index) => (
        <Item item={item} key={`item-${index}`} />
      ))}
    </ul>
  );
};

export default List;

const Item = ({ item: { name, description, image } }: { item: nftData }) => {
  return (
    <li>
      <div>{name}</div>
      <div>{description}</div>
      <div>
        <img src={image} alt="" />
      </div>
    </li>
  );
};

back/src/index.ts

back/src/index.ts

import express, { Express, Request, Response } from "express";
import dotenv from "dotenv";
import multer from "multer";
import cors from "cors";
import pinataSDK from "@pinata/sdk";
import { Readable } from "stream";
import Web3 from "web3";
import { AbiItem } from "web3-utils";
import axios from "axios";

import { abi as NftAbi } from "../contracts/artifacts/NftToken.json";
import { abi as SaleAbi } from "../contracts/artifacts/SaleToken.json";

const app: Express = express();

dotenv.config();

const web3 = new Web3("http://ganache.test.errorcode.help:8545");

const pinata = new pinataSDK(process.env.API_Key, process.env.API_Secret);

app.use(cors({ origin: true, credential: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));

const upload: multer.Multer = multer();

app.post("/api/list", async (req: Request, res: Response) => {
  const deployed = new web3.eth.Contract(
    SaleAbi as AbiItem[],
    process.env.SALE_CA
  );
  let data: Array<{
    tokenId?: string;
    price?: string;
    name: string;
    description: string;
    image: string;
  }> = [];
  if (req.body.from) {
    try {
      const tempArr = await deployed.methods
        .getOwnerTokens(req.body.from)
        .call();

      for (let i = 0; i < tempArr.length; i++) {
        try {
          const { name, description, image } = (
            await axios.get(tempArr[i].tokenURI)
          ).data;
          data.push({
            tokenId: tempArr[i].tokenId,
            price: tempArr[i].price,
            name,
            description,
            image,
          });
        } catch (err) {}
      }
    } catch (err) {}
  } else {
    try {
      const tempArr = await deployed.methods.getSaleTokenList().call();
      for (let i = 0; i < tempArr.length; i++) {
        try {
          const { name, description, image } = (
            await axios.get(tempArr[i].tokenURI)
          ).data;

          data.push({
            tokenId: tempArr[i].tokenId,
            price: tempArr[i].price,
            name,
            description,
            image,
          });
        } catch (err) {}
      }
    } catch (err) {}
  }
  res.send(data);
});

app.post(
  "/api/mint",
  upload.single("file"),
  async (req: Request, res: Response) => {
    const { name, description }: { name: string; description: string } =
      req.body;
    const imgResult: {
      IpfsHash: string;
      PinSize: number;
      Timestamp: string;
      isDuplicate?: boolean;
    } = await pinata.pinFileToIPFS(Readable.from(req.file.buffer), {
      pinataMetadata: { name: Date.now().toString() },
      pinataOptions: { cidVersion: 0 },
    });

    if (imgResult.isDuplicate) console.log("img Duplicated");

    const jsonResult = await pinata.pinJSONToIPFS(
      {
        name,
        description,
        image: `https://gateway.pinata.cloud/ipfs/${imgResult.IpfsHash}`,
      },
      {
        pinataMetadata: { name: Date.now().toString() },
        pinataOptions: { cidVersion: 0 },
      }
    );

    const deployed = new web3.eth.Contract(
      NftAbi as AbiItem[],
      process.env.NFT_CA
    );
    const obj: { nonce: number; to: string; from: string; data: string } = {
      nonce: 0,
      to: "",
      from: "",
      data: "",
    };
    obj.nonce = await web3.eth.getTransactionCount(req.body.from);
    obj.to = process.env.NFT_CA;
    obj.from = req.body.from;
    obj.data = deployed.methods.safeMint(jsonResult.IpfsHash).encodeABI();
    res.send(obj);
  }
);

app.listen(8080, () => {
  console.log("Server Opend");
});