오픈씨(OpenSea) 는 현재 존재하는 NFT 마켓플레이스 중 가장 유명한 곳이다.
https://opensea.io/
이더리움 계열의 NFT 를 생성, 판매, 구매할 수 있는 곳이다~!!
(클레이튼, 폴리곤도 지원)
개인적으로도 몇 가지 NFT 를 오픈씨에서 구매한 적이 있다. 살짝 자랑해보면
이렇게 내 이더리움 지갑주소 소유의 ERC721 데이터의 이미지 URI 주소를 참조하여 화면에 보여주기도 한다.
이번 블로깅에서는 이러한 오픈씨처럼
하는 애플리케이션을 만들어 보겠다.
우리는 node.js 와 react 를 활용하여 애플리케이션을 만들 것이다.
먼저 아래 명령어로 react 프로젝트를 생성해보자.
npx create-react-app web3-practice
code web3-practice
이 파일은 딱히 할게 없다. 아래와 같이 간단히 남겨둔다.
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);
이번 실습에서 가장 중요한 파일이라 할 수 있겠다.
사용자 view 메인 화면이기도 하면서 많은 로직을 담을 것이다.
우선은 아래와 같이 뼈대를 만들어 보자.
import './App.css';
function App() {
return (
<div className="App">
</div>
);
}
export default App;
개인적으로 이 부분에서 거의 3일을 꼴딱 날려먹었다.
먼저 문제가 뭐였는지 살펴보자.
npm 으로 web3.js 라이브러리 모듈을 설치한다.
npm install web3
이 때 package.json 을 보면 v1.6.1 이 설치될 것이다.
그리고 App.js 를 다음과 같이 수정한다. web3 모듈을 불러오고 state 에 담는 로직이다.
import {useState} from 'react';
import Web3 from 'web3';
function App() {
const [web3, setWeb3] = useState();
useEffect(() => {
if (typeof window.ethereum !== "undefined") {. // window.ethereum이 있다면
try {
const web = new Web3(window.ethereum); // 새로운 web3 객체를 만든다
setWeb3(web);
} catch (err) {
console.log(err);
}
}
}, []);
}
그리고 애플리케이션의 메타마스크 지갑 연동을 위해 버튼을 하나 생성할 것이다.
버튼을 눌렀을 때 connectWallet() 함수가 실행되어 window.ethereum.request 를 수행한다.
function app() {
const [account, setAccount] = useState('');
// ...
const connectWallet = async () => {
accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
setAccount(accounts[0]);
};
return (
<div className="App">
<button
className="metaConnect"
onClick={() => {
connectWallet();
}}
>
connect to MetaMask
</button>
<div className="userInfo">주소: {account}</div> // 연결된 계정 주소를 화면에 출력합니다
</div>
);
}
그리고 npm run start 를 해보자.
polyfills, webpack.. 뭐시라???
애플리케이션이 실행이 되지 않는다.
BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default. This is no longer the case. Verify if you need this module and configure a polyfill for it.
이 문제를 해결하기 위해 내가 선택한 방법은 web3@0.20.5 이전 버전을 설치하는 것이었다.
결과적으로 아주 잘못된 선택이었다. 왜냐하면 개발을 진행할수록 다른 문제들이 계속 발생했기 때문이다.
결국 해매고 해매다가 아래와 같이 package.json 의 dependency 를 구성하니 해결이 되었다. 꼭 참고하길 바란다.
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"reactstrap": "8.9.0",
"web3": "^1.6.1"
다시 애플리케이션을 실행하면 아래와 같이 메타마스크(metamask) 지갑 연동이 정상적으로 동작함을 확인할 수 있을 것이다.
우리가 이 블로깅에서 하고자 하는 것은 오픈씨(OpenSea) 와 같이 내 지갑주소와 연결된 NFT 들을 보여주는 것이다.
따라서 아래 것들이 필요하다.
하나씩 해보자~!
먼저 특정 계정의 ERC721 토큰 목록을 가져오기 위해서는 ERC721 표준에 정의되어 있는 totalsupply 라는 함수를 이용하면 된다.
Remix 에서 아래와 같이 ERC721 스마트 컨트랙트를 작성한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
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/ERC721/extensions/ERC721Enumerable.sol";
contract cozNFTs is ERC721URIStorage, Ownable, ERC721Enumerable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
constructor() public ERC721("cozNFT", "NFT"){}
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId
) internal
override(ERC721, ERC721Enumerable) {
super._beforeTokenTransfer(from, to, tokenId);
}
function _burn(
uint256 tokenId
) internal
override(ERC721, ERC721URIStorage) {
super._burn(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721Enumerable)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
function tokenURI(
uint256 tokenId
) public view
override(ERC721, ERC721URIStorage) returns (string memory) {
return super.tokenURI(tokenId);
}
function mintNFT(address recipient, string memory tokenURI) public onlyOwner returns (uint256) {
_tokenIds.increment();
uint256 newItemId = _tokenIds.current();
_mint(recipient, newItemId);
_setTokenURI(newItemId, tokenURI);
return newItemId;
}
}
시원하게 컴파일(compile) 을 해주고, 롭스텐 네트워크에 deploy 를 수행한다.
컨트랙트를 배포하는 것이니 가스비가 들것이다. 승인도 해주고~~
이제 NFT 를 발행할 준비를 끝냈다.
내 스마트 컨트랙트가 배포되었다면 이제 mintNFT 함수로 NFT를 만들 차례이다. 나중에 애플리케이션에서 NFT 의 소유권을 변경하는 기능도 추가할 것이니 넉넉하게 NFT 를 2개 민팅해두자
remix 에서 DEPLOY 부문 하단을 보면 우리가 배포한 smart contract 의 다양한 함수를 실행할 수 있다.
mintNFT 부문의 인자들을 입력하고, 트랜잭션을 발생시키자.
2개를 mint 하였다면, 드디어 우리는 애플리케이션에서 내가 가진 NFT 를 볼 준비가 되었다.
내 지갑주소와 연결된 ERC721 토큰을 화면에 가져오려면 어떤 정보가 필요할까?
ABI 정보는 src/erc721api.js 에 아래와 같은 형식으로 담는다.
let erc721Abi = []; //ABI 정보
export default erc721Abi;
지갑주소는 메타마스크를 연동하여 그 주소를 곧장 사용할 것이다.
마지막으로 계약 주소는 text 폼을 생성하여 입력하도록 하고, 버튼을 클릭하면 조회 함수를 실행시키도록 해보자.
function App() {
const [newErc721addr, setNewErc721Addr] = useState();
// 생략
const addNewErc721Token = () => {}
return (
// 생략
<div className="newErc721">
<input
type="text"
onChange={(e) => {
setNewErc721addr(e.target.value); // 입력받을 때마다 newErc721addr 갱신
}}
></input>
<button onClick={addNewErc721Token}>add new erc721</button>
</div>
)
}
버튼을 클릭하면 실행되는 함수는 web3.js 를 이용하여 web3.eth.Contract() 라는 함수를 호출한다.
그 코드를 짜보면
function App () {
const [erc721list, setErc721list] = useState([]); // 자신의 NFT 정보를 저장할 토큰
const addNewErc721Token = async () => {
// 생략
let arr = [];
for (let i = 1; i <= totalSupply; i++) {
arr.push(i);
}
for (let tokenId of arr) {
let tokenOwner = await tokenContract.methods
.ownerOf(tokenId)
.call();
if (String(tokenOwner).toLowerCase() === account) {
let tokenURI = await tokenContract.methods
.tokenURI(tokenId)
.call();
setErc721list((prevState) => {
return [...prevState, { name, symbol, tokenId, tokenURI }];
});
}
}
}
}
이제 남은 것은 호출 후 결과값을 화면에 뿌려주는 것이다. 결과값 view 콤포넌트는 별도의 콤포넌트로 분리해주자.
src/component 디렉토리를 생성하고, TokenList.js 파일을 아래와 같이 작성한다.
import Erc721 from "./Erc721";
function TokenList({erc721list }) {
return (
<div className="tokenlist">
<Erc721 erc721list={erc721list} />
</div>
);
}
export default TokenList;
Erc721.js 파일도 함께 작성해주자. 이 파일은 TokenList.js 에서 재호출하는 콤포넌트이다. NFT 토큰이 여러개 존재할 수 있으므로 이렇게 분리하는 것이다.
function Erc721({ erc721list }) {
return (
<div className="erc721list">
{erc721list.map((token) => {
return (
<div className="erc721token">
Name: <span className="name">{token.name}</span>(
<span className="symbol">{token.symbol}</span>)
<div className="nft">id: {token.tokenId}</div>
<img src={token.tokenURI} width={300} />
</div>
);
})}
</div>
);
}
export default Erc721;
뭔가 빠뜨린 것 같다. App.js 의 return 문에 TokenList.js 를 콤포넌트로 추가해야겠져??
// 생략
import TokenList from "./components/TokenList";
function App () {
// 생략
return (
<div className="App">
// 생략
<TokenList erc721list={erc721list} />
</div>
)
}
자 이제 테스트를 해보자. 아래와 같이 동작하면 우리는 오픈씨의 기본을 만든 것이나 다름없다!!
오픈씨(OpenSea) 의 핵심은 NFT 의 소유권을 변경하는 기능에 있다. 즉, 사용자들 간에 P2P 로 NFT 를 판매하고 구매하는 것이다.
이 파트에서는 위에서 mint 했던 NFT 의 소유권을 이전(NFT 전송) 하는 코드를 짜보자.
전송을 위한 화면 구성은 TokenList.js 에서 수행한다.
그 전에 state 값을 TokenList 콤포넌트에 보내기 위해 App.js 를 수정해준다.
// App.js
function App () {
return (
//...
<TokenList web3={web3} account={account} erc721list={erc721list} />
)
}
TokenList.js 와 Erc721.js 는 아래와 같이 수정한다.
// TokenList.js
function TokenList({ web3, account, erc721list }) {
return (
<div className="tokenlist">
<Erc721 web3={web3} account={account} erc721list={erc721list} />
</div>
);
}
진짜는 Erc721.js 에 있으니.. 전체 코드를 첨부한다.
import erc721Abi from "../erc721Abi";
import {useState, useEffect} from 'react';
function Erc721({ web3, account, erc721list }) {
const [to, setTo] = useState("");
const sendToken = async (tokenAddr, tokenId) => {
const tokenContract = await new web3.eth.Contract(
erc721Abi,
tokenAddr,
{
from: account,
}
);
console.log(tokenContract);
console.log(`보내는주소: ${account}`);
console.log(`받는주소: ${to}`);
console.log(tokenContract.options.address);
tokenContract.options.address = to;
tokenContract.methods
.transferFrom(account, to, tokenId)
.send({
from: account,
})
.on("receipt", (receipt) => {
setTo("");
});
};
return (
<div className="erc721list">
{erc721list.map((token) => {
return (
<div className="erc721token">
Name: <span className="name">{token.name}</span>(
<span className="symbol">{token.symbol}</span>)
<div className="nft">id: {token.tokenId}</div>
<img src={token.tokenURI} width={300} />
/* nft 전송 관련 */
<div className="tokenTransfer">
To:{" "}
<input
type="text"
value={to}
onChange={(e) => {
setTo(e.target.value);
}}
></input>
<button
className="sendErc20Btn"
onClick={sendToken.bind(
this,
token.address,
token.tokenId
)}
>
send Token
</button>
</div>
</div>
);
})}
</div>
);
}
export default Erc721;
그런데 이것을 테스트해보면 가스 수수료 설정을 해야 한다고 뜬다.
처음에는 해당 Contract 에 address 가 설정되지 않았다고 하여 아래 코드를 추가시키니 정상적으로 동작하였다.
tokenContract.options.address = to;
아마 가스비 또한 options 에 추가해야 될 것 같다. 시작부족으로 이번 블로깅에서 마치지 못하는 것이 한이다. 작업이 완료되면 추가 블로깅을 작성토록 하겠다.
추천 받은 회고 방법론을 통해 지금까지 한 내용을 정리해 보겠다.
회고 방법론 : KPT (KEEP, PROBLEM, TRY)
보잘것 없었던 나에게 Keep 할 점이 있기는 할까? 그래도 희망의 끈을 놓지 않고 프로젝트 팀원들에게 피해를 끼치지 않으려 노력했다.
처음에 이 개발을 시작할 때 다짐했던 것이 잘하든 못하든 '커뮤니케이션' 을 잘하자였다.
그리고 삽질을 포기하지 않았기 때문에(?!) 결국 마지막에 팀원들에게 도움을 줄 수 있었다.
앞으로 남은 프로젝트에서도 꼭 '커뮤니케이션' 과 '성실한 자세' 를 잊지 말아야겠다.
web3.js 를 처음에 설치할 때 너무너무너무나 많은 시간을 소모한 것이 이번 과제를 어렵게 한 요인이 되었다. 이 문제는 모듈의 문제였는데, 괜히 라이브러리를 다운그레이드하여 진행을 한 것이 나를 더욱 어렵고 지치게 만들지 않았나 싶다.
잘못된 길을 빠르게 수정하고 옳은 길로 가려고 노력하는 태도가 필요할 것 같다.
그리고 사실 구글링으로도 찾기가 어렵다면, 같은 문제를 겪었을 가능성이 있는 다른 조 사람들에게 빨리 물어보는 것도 시간을 단축하기에 큰 도움이 되었을 것이다.
혼자 열심히 고민하는 것도 중요하지만 그 선을 유지하고, 일정에 문제가 생기지 않도록 고집을 버리는 습관을 들이자!
NFT 마켓플레이스는 현재 블록체인 업계에서 가장 핫한 영역이다. 너도나도 NFT 를 발행하고, 그 판매시장을 형성하기 위해 혈안이 되어 있다.
따라서 내가 블록체인 개발자가 되려면 NFT 마켓플레이스를 만들어보는 것은 돈으로도 살 수 없는 자산이 될 것이다.
정말 그럴싸한, 정말 오픈씨(OpenSea) 같은 애플리케이션을 만들어 봐야겠다.
읽어주셔서 감사합니다.