현재 NFT를 거래할 수 있는 NFT Marketplace중 최대 규모는 OpenSea입니다.
이번에는 디지털 자산의 거래를 지원하는 P2P 방식의 OpenSea 와 같은 NFT Marketplace의 주요 기능들을 직접 구현해보고, 스마트 컨트랙트상에서 작성한 함수들의 기능인 민팅, 구매, 판매등 여러 기능을 어떻게 구현하는 지 알아보겠습니다.
GitHub Repository 바로가기
약 4일간의 시간동안 3명의 팀으로 구성되어 기획부터 개발까지 진행하였습니다.
제가 팀장을 맡게되어 팀원의 업무를 각각 프론트엔드, 백엔드, 스마트컨트랙트로 나눠서 분배하고, 팀원들이 git
에 익숙하지 않아서 git flow
에 대해 한 번 설명하고 진행하였습니다.
프로젝트 시작 전 Opensea의 기능을 다시 한 번 살펴보고 Marketplace에 필수로 필요한 기능들을 다시 한 번 정리하였습니다.
따라서 아래와 같이 구현할 필수 기능들을 정의하였습니다.
메타마스크 연결 전 | 메타마스크 연결 후 |
---|---|
먼저 스마트컨트랙트를 사용하기 위해서는 지갑 연결이 필요합니다. 여기에서는 가장 많이 사용되는 지갑 중 MetaMask
를 이용해서 개발하였습니다. 따라서 해당 지갑을 연결해주는 작업을 하였습니다.
- 백엔드 계정 정보 저장 로직
saveAccount: (user_account, user_balance, chain, callback) => {
const getAccount = `SELECT user_account FROM Users WHERE user_account = "${user_account}" AND user_chain = "${chain}"`;
const updateAccount = `UPDATE Users SET user_account="${user_account}", user_balance="${user_balance}", user_chain="${chain}" WHERE user_account="${user_account}" AND user_chain="${chain}"`;
const insertAccount = `INSERT INTO Users (user_account, user_balance, user_chain) VALUES ("${user_account}","${user_balance}" , "${chain}")`;
db.query(getAccount, (error, result) => {
isAccount = JSON.stringify(result); //현재 계정과 네트워크에 데이터가 존재하는지 확인
if (!isAccount) {
db.query(insertAccount, (error, result) => {
callback(error, result); //계정이 존재하지 않는 경우
});
} else {
db.query(updateAccount, (error, result) => {
callback(error, result); //이미 계정이 존재하는 않는 경우
});
}
});
},
- 프론트 메타마스크 연결 로직
const connectWallet = async () => {
if (typeof window.ethereum === 'undefined') {
//메타마스크 미설치 상태
} else {
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts', //계정 주소 가져오기
});
const networkId = await window.ethereum.request({
method: 'net_version', //현재 연결된 체인 정보 가져오기
});
//계정 및 체인정보 세팅
}
};
위 코드는 크게 두 부분으로 나눌 수 있습니다. 계정을 가져오는 부분과 현재 연결된 체인을 가져오는 부분입니다. 또한 계정이 변경되거나 연결된 체인 정보가 변경되는 경우 지난 글인 Metamask에서 계정 및 체인 네트워크 전환 감지하는 방법에서 다룬 듯이 아래와 같이 코드를 작성하시면 됩니다.
- 계정 전환 감지
const handleAccountChange = (...args) => {
const account = args[0][0];
if (!account) {
} else if (account !== currentAccount) {
//변경된 계정이 현재 계정과 다름
//즉, 계정 변경 완료
}
};
useEffect(() => {
window.ethereum?.on('accountsChanged', handleAccountChange);
return () => {
window.ethereum?.removeListener('accountsChanged', handleAccountChange);
};
});
- 네트워크 전환 감지
const handleNetworkChanged = (...args) => {
const networkId = args[0];
window.location.reload();
};
useEffect(() => {
window.ethereum?.on('networkChanged', handleNetworkChanged);
return () => {
window.ethereum?.removeListener('networkChanged', handleNetworkChanged);
};
});
더 많은 작업을 하고싶으신 분들은 MetaMask Docs를 참조하시면 됩니다.
민팅 페이지 | 민팅 기능 구현 |
---|---|
먼저 NFT에 필요한 정보를 입력할 수 있는 민팅 페이지를 만들어주었고, 내용을 입력 후 Create
버튼을 누르게 되면 연동된 메타마스크에서 NFT를 민팅하기 위한 과정이 이루어집니다. 참고로 컨트랙트 로직 상 계좌에 잔고가 충분하지 않으면 민팅이 이루어 지지않습니다.
민팅에 성공하게 되면 다음과 같은 정보를 얻을 수 있습니다.
infura에서 제공하는 IPFS public gateway를 이용하여 민팅을 구현하던 중 infura에서 제공 중인 public gateway가 8월 10일자로 지원을 중단하여 nft storage 서비스로 바꿔서 구현을 하였습니다.
- 민팅 기능 구현
const onMint = async () => {
const client = new NFTStorage({ token: NFT_STORAGE_TOKEN });
const metadata = await client.store({
name: name,
external_link: externalLink,
description: description,
collection: collection,
blockchain: 'Ethereum',
supply: supply,
image: image,
});
const metadataUrl = `https://ipfs.io/ipfs/${metadata.data.image.pathname}`;
const tokenContract = await new web3.eth.Contract(erc721Abi, contract_addr, {
from: account,
});
tokenContract.methods
.mintNFT(account, `https://ipfs.io/ipfs/${metadata.url.split('//')[1]}`)
.send({
from: account,
});
};
-Smart Contract MintNFT 함수
function mintNFT(address recipient, string memory tokenURI) public returns (uint256) {
_tokenIds.increment();
uint256 newItemId = _tokenIds.current();
_mint(recipient, newItemId);
_setTokenURI(newItemId, tokenURI);
return newItemId;
}
NFT를 생성하고나서 컨트랙트 주소를 이더스캔에서 확인해보면 NFT가 정상적으로 생성된 것을 확인 할 수 있습니다.
또한 민팅한 NFT를 조회할 수도 있습니다.
-Smart Contract getNftTokens 함수
function getNftTokens(address _nftTokenOwner) view public returns (NftTokenData[] memory) {
uint256 balanceLength = balanceOf(_nftTokenOwner);
NftTokenData[] memory nftTokenData = new NftTokenData[](balanceLength);
for(uint256 i = 0; i < balanceLength; i++) {
uint256 nftTokenId = tokenOfOwnerByIndex(_nftTokenOwner, i);
string memory nftTokenURI = tokenURI(nftTokenId);
nftTokenData[i] = NftTokenData(nftTokenId , nftTokenURI);
}
return nftTokenData;
마이페이지에서 자신의 보유한 NFT를 클릭하게 되면 해당 NFT의 상세페이지로 이동합니다.
자신의 NFT를 선물할 수도 있고, 마켓에 등록할 수도 있습니다.
만약 NFT를 다른 유저에게 선물하고싶을 경우 해당 유저의 지갑 주소를 적고 선물하기 버튼을 클릭하게 되면 해당 컨트랙트를 실행하기 위한 가스비가 메타마스크상에서 청구됩니다.
선물 받은 계정에서 확인해보면 NFT의 Owner가 잘 변경이 됐음을 알 수 있습니다.
이더 스캔상에서도 정상적으로 트랜잭션이 실행된 것을 확인 할 수 있습니다.
이는 openZepplin의 ERC-721 컨트랙트의 transferFrom
함수를 사용한 것으로 쉽게 구현 할 수 있었습니다.
본인이 소유하고 있는 NFT라면 선물하기 외에도 마켓에 판매 등록을 할 수 있습니다.
Make Offer
버튼을 클릭하게 되면 마켓에 등록하기 위한 컨트랙트가 실행이 되고 해당 컨트랙트가 성공적으로 완료된 뒤 Explore 탭에서 확인을 해보면 NFT가 마켓에 등록된 것을 확인 할 수 있습니다.
-Smart Contract addToMarket 함수
function addToMarket(uint256 _tokenId) public {
// nft를 소유한 계정에서만 판매등록 가능
address nftTokenOwner = ownerOf(_tokenId);
// 마켓에 올리기 위한 조건들
require(nftTokenOwner == msg.sender, "Caller is not nft token owner.");
setApprovalForAll(address(this),true);
onSaleNftTokenArray.push(_tokenId); //판매중인 nft list
Explore 탭에서 판매 중인 NFT를 구매할 수도 있습니다. NFT의 상세 페이지에서 Buy Now
버튼을 클릭하게 되면 구매를 위한 컨트랙트가 실행이 됩니다.
const onBuyNow = async () => {
const tokenContract = await new web3.eth.Contract(erc721Abi, contract_addr, {
from: account,
});
tokenContract.methods.buyNft(pa.token_id).send({
from: account,
value: 100,
gas: 250000,
});
};
//구매함수, payable 사용!!
function buyNft(uint256 _tokenId) public payable {
// 금액
uint256 price = nftTokenPrices[_tokenId];
// 해당 nft 소유자
address nftTokenOwner = ownerOf(_tokenId);
// 제한사항, owner는 구매하지 못함.
require(price > 0, "nft token not sale.");
// require(price <= msg.value, "caller sent lower than price.");
require(nftTokenOwner != msg.sender,"caller is nft token owner.");
// 상태가 false일때는 구매 못함.
require(isApprovedForAll(nftTokenOwner, address(this)), "nft token owner did not approve token.");
// 구매가 이루어지면 nft를 transfer
payable(nftTokenOwner).transfer(msg.value);
// 소유권 이전. IERC721에 정의된 스펙
IERC721(address(this)).safeTransferFrom(nftTokenOwner, msg.sender, _tokenId);
//판매 리스트에서 삭제
removeToken(_tokenId);
}
기존 체인에 올라가있는 NFT의 정보도 확인 할 수 있습니다.
NFT의 조회는 해당 NFT의 컨트랙트의 순서로 확인 할 수 있습니다. 이 부분을 OpenSea API를 이용해 구현하였습니다.
- 컨트랙트 주소와 토큰의 순서값으로 NFT 조회
const nftDetail = await axios
.get(
`https://api.opensea.io/api/v1/asset/${req.query.contractaddress}/${req.query.tokenId}`,
{
headers: {
'x-api-key': '',
},
}
)
.then((response) => response.data);
이 처럼 한개의 NFT를 조회할 수도 있고, 여러개의 NFT List를 가져올 수도 있습니다.
아래는 특정한 Collection에 속해 있는 NFT List를 가져오는 API 요청입니다.
const nftList = await axios
.get(
`https://api.opensea.io/api/v1/assetscollection_slug=${req.query.collection_slug}`,
{
headers: {
'x-api-key': '',
},
}
)
.then((response) => response.data.assets);
위에서 가져온 정보로 아래와 같은 Collection 페이지를 구성할 수 있었습니다.
Collection 페이지에서는 검색 할 Collection의 slug를 입력하게되면 Opensea에서 확인 할 수 있는 모든 Colletion들을 조회 할 수가 있습니다.
일주일도 안되는 짧은 시간동안 진행한 프로젝트라서 모든 것을 완벽하게 구현하지는 못했지만 그래도 목표했던 기능들을 구현할 수 있었습니다.
이렇게 할 수 있었던 이유는 팀원이 정해지자마자 디스코드, 깃헙의 프로젝트, 위키등의 툴을 최대한 활용하여 소통을 활발하게하여 생산성을 높힐 수 있었고,
각자 맡은 파트 중 해결이 어려운 부분이 있으면 팀원간의 페어프로그래밍을 적극적으로 활용했기 때문입니다.
예를 들면 아래와 같이 프로젝트의 ToDo 리스트를 칸반보드 형식으로 관리해서 한 눈에 진행 사항을 확인 할 수 있었고,
백엔드에서 개발한 API의 명세서를 작성해서 프론트엔드 개발자가 API 연동을 하기 편하게 했습니다.
추가적으로 이번 프로젝트에서 다들 처음 뵌 분들이기 때문에 개발에 집중만 하는 것이 아닌 일상적인 소통을 하는 시간을 따로 가짐으로써 개발 중에도 언제든지 부담없이 커뮤니케이션을 할 수 있도록 노력하였습니다.
이 프로젝트는 단기적으로 완료가 되었지만 시간 나는대로 완성도를 높히기 위한 추가적인 업데이트를 할 예정입니다. 감사합니다.
The article is really helpful. I am waiting for your next post. Let's continue to promote. we become what we behold.