NFT(Non-Fungible Token)은 복사, 대체 또는 세분화할 수 없는 고유한 디지털 식별자입니다. NFT는 진위 여부와 소유권을 인증하기 위해 블록체인에 기록됩니다.
이러한 NFT를 사고 파는 가장 좋은 방법은 NFT 마켓플레이스를 이용하는 것입니다.
이번 튜토리얼에서는 BLOCKSDK API, Next.js 프레임워크, 클레이튼 메인넷을 활용하여 NFT 마켓플레이스를 구축합니다.
git clone https://github.com/Block-Chen/nextjs-nft-demo.git
BLOCKSDK 홈페이지에 회원가입하여 WEB3 API 토큰 발급
Project
├── app/ - 프로젝트 코드
| ├── api/ - api 코드
| | ├── nft/ - nft 관련 api.
| | └── solc/ - 메타마스크 전달 데이터 abi.
| ├── components/ - 모달창 및 헤더
| └── nft/ - 프로젝트 페이지
├── public/
| ├── tmp/ - NFT 이미지 업로드 폴더
| └── data.json/ - NFT 정보 로컬 저장
└── .env - 컨트랙트 및 API 토큰 설정 파일
npm i && npm run dev
npm run build : Nextjs 어플리케이션을 빌드한다.
npm run start : build로 생성된 빌드 파일을 사용하여 어플리케이션을 실행한다.
npm run dev : Next.js 애플리케이션을 개발 환경에서 실행한다.
개발 모드에서는 페이지가 변경될 때마다 자동으로 빌드되어 브라우저에서 새로고침된다.
또한 정적 파일들이 요청될 때마다 다시 생성되어 더 빠른 개발을 가능하게 한다.
NEXT_PUBLIC_BLOCKSDK_TOKEN // blocksdk api 토큰
NEXT_PUBLIC_URL // WEB3 테스트넷 or 메인넷 엔드포인트
NEXT_PUBLIC_NET // 사용할 메인넷(eth,bsc,klay,matic)
NEXT_PUBLIC_CONTRACT // 사용할 NFT_CONTRACT(eth,bsc,klay,matic)
/nft/create/page.tsx
에서 NFT로 만들 이미지를 제목과 설명과 함께 업로드 할 수 있습니다.
제목, 설명, 이미지를 입력하고 Submit하면 아래 코드와 같이 업로드가 진행됩니다.
// /nft/create/page.tsx
const handleSubmit = async (e : any) => {
e.preventDefault();
try {
// 폼 데이터를 FormData 객체로 만듦
const formData = new FormData();
formData.append('name', name);
formData.append('subject', subject);
formData.append('file', file);
// 메타마스크에서 지갑 주소 가져오기
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
//메타마스크 서명
const sign = await window.ethereum.request({
method: 'personal_sign',
params: [`Sign this request`,accounts[0]],
});
if (accounts.length > 0) {
formData.append('walletAddress', accounts[0]);
}else {
console.error('MetaMask not connected');
return;
}
// API에 POST 요청 보냄
const response = await fetch('/api/nft/create', {
method: 'POST',
cache: 'no-store', //캐싱처리 X
body: formData,
});
if (response.ok) {
const result = await response.json();
const page = JSON.parse(result.jsonString)
// 성공 메시지 표시
setSuccessMessage('Form submitted successfully.');
// 메인 페이지로 이동
router.push(`/nft/details/${page.id}`);
} else {
console.error('API Error:', response.statusText);
}
} catch (error) {
console.error('Error submitting form:', error);
}
};
window.ethereum
MetaMask 공급자 개체를 사용하여 웹 사이트에 JS API를 삽입하여 다양한 dapp 요청을 수행합니다.
자세한 설명은 메타마스크 공식사이트를 참고해 주세요
NFT를 업로드하면 /api/nft/create/route.ts
에서 /public/data.json
에 업로드 정보를 기록합니다.
// /api/nft/create/route.ts
import { writeFile } from 'fs/promises'
import { NextRequest, NextResponse } from 'next/server'
import { createHash } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
export async function POST(request: NextRequest) {
//프론트에서 받은 폼 데이터를 받아옴
const data = await request.formData()
//이미지 정보 추출
const file: File | null = data.get('file') as unknown as File
//이미지가 존재하지 않으면 에러 반환
if (!file) {
return NextResponse.json({ success: false })
}
const randomString = Math.random().toString(36).substring(2, 8) // 무작위 문자열 생성
const hash = createHash('sha256').update(randomString).digest('hex') // 무작위 문자열을 해시화
const fileExtension = file.name.split('.').pop() // 파일 확장자 추출
const randomName = `${hash}.${fileExtension}` //생성된 무작위 문자열과 확장자로 파일이름 생성
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const path = join('./', 'public/tmp', randomName)
await writeFile(path, buffer)
const formDataJson: { [key: string]: any } = {};
for (const [key, value] of data.entries()) {
formDataJson[key] = value;
}
formDataJson['id'] = uuidv4()
formDataJson['file'] = randomName
const filePath = join(process.cwd(), 'public', 'data.json');
const jsonString = JSON.stringify(formDataJson);
return NextResponse.json({ success: true, jsonString })
}
이미지를 업로드하면 상세페이지로 이동되며 이곳에서 NFT 민팅 및 판매등록을 진행할 수 있습니다.
민팅에 필요한 데이터를 abi 인코딩 api로 전달하여 받은 값으로 메타마스크에 전달하여 nft를발행합니다.
발행 완료 후 로컬 환경에 nft 데이터가 저장됩니다.
abi에 대한 설명은 여기를 참고해 주세요
// /nft/details/[id]/page.tsx
const handlePriceSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
//입력된 가격정보를 암호화폐 decimal값에 맞춰 변환
const hex_price = Math.round(parseFloat(priceInput) * Math.pow(10,18))
let abi;
if (typeof data === 'string') {
}else {
if(data.tx_hash){
{/* 민팅이 완료된 nft 판매등록 abi ... */}
}else{
//nft 민팅 및 판매 abi
abi = await fetch(`/api/solc`, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
method : 'mint',
parameter_type : ['string','address','uint256'],
parameter_data : [data.id,accounts[0],'0x' + hex_price.toString(16)]
}),
}).then(abi=>abi.json())
}
}
// abi 데이터를 이용하여 메타마스크에 데이터 전달
const txHash = await window.ethereum.request({
method: 'eth_sendTransaction',
params: [
{
"to": contractAddress,
"from": accounts[0],
"value": "0x0",
"data": '0x' + abi.data.payload.data
}
]
})
if (txHash) {
//api에 nft데이터 전달
const response = await fetch(`/api/nft/sale/${id}`, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
price: priceInput,
tx_hash: txHash
}),
});
if (response.ok) {
console.log('Price submitted successfully.');
window.location.reload();
} else {
console.error('Error submitting price:', response.statusText);
}
} else {
console.error('Error submitting price:', txHash);
}
} catch (error) {
console.error('Error submitting price:', error);
}
};
판매가격을 입력하면 메타마스크를 통해 NFT민팅 및 판매등록이 완료됩니다. nft민팅 후 nft 판매 정보가 /public/data.json
파일에 기록됩니다.
// /api/sale/[id]/route.ts
import fs from 'fs';
import path, { join } from 'path';
import { NextResponse, NextRequest } from 'next/server';
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const id = params.id;
const data = await req.json()
//.env에 설정한 정보 호출
const url = process.env.NEXT_PUBLIC_URL as string;
const net = process.env.NEXT_PUBLIC_NET;
const api_token = process.env.NEXT_PUBLIC_BLOCKSDK_TOKEN;
const filePath = join(process.cwd(), 'public', 'data.json');
const jsonData = fs.readFileSync(filePath, 'utf-8');
const jsonArray = [];
const regex = /{[^}]*}/g;
const matches = jsonData.match(regex);
if (matches) {
for (const match of matches) {
jsonArray.push(JSON.parse(match));
}
}
// 특정 ID에 해당하는 데이터 찾기
const specificData = jsonArray.find((item) => item.id === id);
if (data.tx_hash) {
try {
//nft 생성 트랜젝션으로 nft 정보 조회
const res = await fetch(url + net + `/transaction/${data.tx_hash}?api_token=` + api_token, {
method: 'GET',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
})
// 여기서 apiResponse를 활용하여 필요한 로직을 수행합니다.
const tx_data = await res.json()
const token_id = tx_data.payload.logs[0].topics
specificData.token_id = parseInt(token_id[token_id.length - 1]);
} catch (error) {
console.error('Error calling API:', error);
}
}
specificData.price = data.price;
specificData.tx_hash = data.tx_hash;
//nft 데이터 data.json에 작성
const updatedJsonData = jsonArray.map((item) => JSON.stringify(item)).join('\n');
fs.writeFileSync(filePath, updatedJsonData, 'utf-8');
if (specificData) {
return NextResponse.json({ specificData });
} else {
return NextResponse.json({ error: 'Data not found' });
}
}
NFT 상세페이지에서 NFT에 대한 상세정보를 확인할 수 있습니다.
// /nft/details/[id]/page.tsx
<div>
<h1>NFT 상세 정보</h1>
<Link href={{pathname: '/nft/list',}}>전체 NFT 리스트</Link>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ marginRight: '20px' }}>
<img
src={process.cwd() + 'tmp/' + data.file}
alt={`Image ${data.id}`}
style={{ maxWidth: '200px', maxHeight: '200px' }}
/>
</div>
<div>
{/* 제작자 주소 */}
{data.createrAddress ? (
<p><strong>Creator Address</strong>: <a href={`/nft/list/${data.createrAddress}`}>{data.createrAddress}</a></p>
) : data.walletAddress ? (
<p><strong>Creator Address</strong>: <a href={`/nft/list/${data.walletAddress}`}>{data.walletAddress}</a></p>
) : null}
{/* 소유자 주소 */}
{data.walletAddress && <p><strong>Owner Address</strong>: <a href={`/nft/list/${data.walletAddress}`}>{data.walletAddress}</a></p>}
<p>
<strong>NFT ID:</strong> {data.id}
</p>
<p>
<strong>Name:</strong> {data.name}
</p>
<p>
<strong>Subject:</strong> {data.subject}
</p>
<p>
<strong>Price:</strong> {data.price}
</p>
{isWalletMatch ? (
<>
{/* 이미 가격이 존재하는지 확인 */}
{data.price ? (
<>
{/* 가격 업데이트 및 판매 중지 버튼을 표시 */}
<button onClick={handleOpenUpdateModal}>가격 변경</button>
{/* 모달 컴포넌트 */}
<PriceUpdateModal
isOpen={isUpdateModalOpen}
onClose={handleCloseUpdateModal}
onUpdatePrice={handleUpdatePrice}
/>
<button onClick={handleOpenStopSaleModal}>판매 중지</button>
{/* 모달 컴포넌트 */}
<SaleStopModal
isOpen={isStopSaleModalOpen}
onClose={handleCloseStopSaleModal}
onStopSale={handleStopSale}
/>
</>
) : (
<>
{/* 가격 입력 폼 */}
<form onSubmit={handlePriceSubmit}>
<label>
Enter Price:
<input
type="text"
value={priceInput}
onChange={(e) => setPriceInput(e.target.value)}
/>
</label>
<button type="submit">Submit Price</button>
</form>
{/* 삭제 버튼 */}
<button onClick={handleDelete}>Delete Data</button>
<DeleteModal
isOpen={isDeleteModalOpen}
onClose={handleCancelDelete}
onConfirm={handleConfirmDelete}
/>
</>
)}
</>
) : (
<>
{data.price ? (
<button onClick={handleBuy}>Buy NFT</button>
) : (
<p style={{ color: 'red' }}>판매중인 NFT가 아닙니다.</p>
)}
</>
)}
</div>
</div>
</div>
자신이 판매중인 NFT의 경우 가격 변경과 판매중단 기능이 활성화됩니다.
NFT의 판매 확인은 로컬 데이터와 실제 NFT데이터를 조회합니다.
// /nft/details/[id]/page.tsx
const handleUpdatePrice = async (newPrice: string) => {
// 가격을 업데이트하는 로직 구현
try {
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
const hex_price = Math.round(parseFloat(newPrice) * Math.pow(10,18))
let abi
if (typeof data === 'string') {
}else {
abi = await fetch(`/api/solc`, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
method : 'updatePrice',
parameter_type : ['uint256','uint256'],
parameter_data : [data.token_id,'0x' + hex_price.toString(16)]
}),
}).then(abi=>abi.json())
}
//메타마스크를 통해 nft 정보 변경
const txHash = await window.ethereum.request({
method: 'eth_sendTransaction',
params: [
{
"to": contractAddress,
"from": accounts[0],
"value": "0x0",
"data": '0x' + abi.data.payload.data
}
]
})
//api로 로컬 데이터 변경
const response = await fetch(`/api/nft/sale/${id}`, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
price: newPrice,
tx_hash: txHash
}),
});
if (response.ok) {
console.log('가격이 성공적으로 업데이트되었습니다.');
window.location.reload();
} else {
console.error('가격 업데이트 중 오류 발생:', response.statusText);
}
} catch (error) {
console.error('가격 업데이트 중 오류 발생:', error);
}
};
NFT 소유자가 판매 중단을 하면 판매중단 데이터를 abi인코딩하여 메타마스크에 전달합니다.
그 후 로컬에서도 가격 정보를 지움으로써 판매를 중단시킵니다.
// /nft/details/[id]/page.tsx
const handleStopSale = async () => {
// 판매 중지 로직 구현
try {
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
let abi
if (typeof data === 'string') {
}else {
abi = await fetch(`/api/solc`, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
method : 'updateListingStatus',
parameter_type : ['uint256','bool'],
parameter_data : [data.token_id,0]
}),
}).then(abi=>abi.json())
}
const txHash = await window.ethereum.request({
method: 'eth_sendTransaction',
params: [
{
"to": contractAddress,
"from": accounts[0],
"value": "0x0",
"data": '0x' + abi.data.payload.data
}
]
})
const response = await fetch(`/api/nft/sale/stop/${id}`, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
console.log('판매가 성공적으로 중지되었습니다.');
window.location.reload();
} else {
console.error('판매 중지 중 오류 발생:', response.statusText);
}
} catch (error) {
console.error('판매 중지 중 오류 발생:', error);
}
};
// /api/nft/sale/stop/[id]/routes.tsx
import fs from 'fs';
import path, { join } from 'path';
import { NextResponse, NextRequest } from 'next/server';
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const id = params.id;
const url = process.env.NEXT_PUBLIC_URL as string;
const net = process.env.NEXT_PUBLIC_NET;
const api_token = process.env.NEXT_PUBLIC_BLOCKSDK_TOKEN;
const filePath = join(process.cwd(), 'public', 'data.json');
const jsonData = fs.readFileSync(filePath, 'utf-8');
const jsonArray = [];
const regex = /{[^}]*}/g;
const matches = jsonData.match(regex);
if (matches) {
for (const match of matches) {
jsonArray.push(JSON.parse(match));
}
}
// 특정 ID에 해당하는 데이터 찾기
const specificData = jsonArray.find((item) => item.id === id);
//해당 NFT 가격 정보 삭제
delete specificData.price
const updatedJsonData = jsonArray.map((item) => JSON.stringify(item)).join('\n');
fs.writeFileSync(filePath, updatedJsonData, 'utf-8');
if (specificData) {
return NextResponse.json({ specificData });
} else {
return NextResponse.json({ error: 'Data not found' });
}
}
업로드된 이미지 목록을 확인할 수 있습니다. 지갑 정보를 가져와서 특정 주소의 생성 목록을 확인 할 수도 있습니다.
NFT리스트는 로컬 정보에 저장된 내용을 호출합니다.
/nft/list/page.tsx
, nft/list/[address]/page.tsx
// /nft/list/page.tsx
"use client";
import React, { useState, FormEvent, useEffect } from 'react'
import Header from '../../components/header';
interface Data {
id: string
name: string
price: number | string
subject: string
file: string
tx_hash : string
token_id : number
walletAddress: string
}
function Home() {
const [data, setData] = useState<Data[]>([])
useEffect(() => {
// API로부터 데이터를 가져오는 함수
const fetchData = async () => {
try {
const response = await fetch('/api/nft/list' , {
method: 'GET',
cache: 'no-store',
})
const result = await response.json();
// 데이터가 배열인지 확인
if (Array.isArray(result.jsonArray)) {
setData(result.jsonArray);
} else {
console.error('Fetched data is not an array:', result.jsonArray);
}
} catch (error) {
console.error('Error fetching data:', error);
}
}
fetchData() // 데이터 가져오기
}, [])
return (
<div>
<Header />
<h1>NFT 리스트</h1>
<ul>
{data.map((item, index) => (
<li key={index}>
<strong>ID:</strong> <a href={`/nft/details/${item.id}`}>{item.id}</a>,{' '}
<strong>Name:</strong> {item.name}, <strong>Subject:</strong> {item.subject}, <strong>File:</strong>{' '}
<img src={process.cwd()+'tmp/'+item.file} alt={`Image ${index}`} style={{ maxWidth: '200px', maxHeight: '200px' }} />
</li>
))}
</ul>
</div>
)
}
export default Home
위와 같이 로컬에서 생성된 이미지들을 확인하고 상세한 정보를 확인할 수 있습니다.
판매중인 NFT는 다른 이용자에게 NFT구매 버튼이 활성화되며 연결된 메타마스크를 통해 구매할 수 있습니다.
// /nft/details/[id]/page.tsx
const handleBuy = async () => {
try {
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
let abi : any
let nft_price : any
if (typeof data === 'string') {
}else {
const abi = await fetch(`/api/solc`, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
method : 'buy',
parameter_type : ['uint256'],
parameter_data : [data.token_id]
}),
}).then(abi=>abi.json())
nft_price = data?.price * Math.pow(10,18)
}
const txHash = await window.ethereum.request({
method: 'eth_sendTransaction',
params: [
{
"to": contractAddress,
"from": accounts[0],
"value": '0x' + nft_price.toString(16),
"data": '0x' + abi.data.payload.data
}
]
})
const response = await fetch(`/api/nft/buy/${id}`, {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
account: accounts[0]
}),
});
if (response.ok) {
console.log('구매가 성공적으로 완료되었습니다..');
//window.location.reload();
} else {
console.error('구매 중 오류 발생:', response.statusText);
}
} catch (error) {
console.error('구매 중 오류 발생:', error);
}
};
메타마스크에서 트랜잭션이 완료되면 api를 통해 로컬 데이터에서도 소유권 변경이 이뤄진다.
// /api/nft/buy/[id]/route.ts
import fs from 'fs';
import path, { join } from 'path';
import { NextResponse, NextRequest } from 'next/server';
//NFT 리스트
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const id = params.id;
const data = await req.json()
const url = process.env.NEXT_PUBLIC_URL as string;
const net = process.env.NEXT_PUBLIC_NET;
const api_token = process.env.NEXT_PUBLIC_BLOCKSDK_TOKEN;
const filePath = join(process.cwd(), 'public', 'data.json');
const jsonData = fs.readFileSync(filePath, 'utf-8');
const jsonArray = [];
const regex = /{[^}]*}/g;
const matches = jsonData.match(regex);
if (matches) {
for (const match of matches) {
jsonArray.push(JSON.parse(match));
}
}
// 특정 ID에 해당하는 데이터 찾기
const specificData = jsonArray.find((item) => item.id === id);
delete specificData.price
console.log(data)
specificData.walletAddress = data.account;
const updatedJsonData = jsonArray.map((item) => JSON.stringify(item)).join('\n');
fs.writeFileSync(filePath, updatedJsonData, 'utf-8');
if (specificData) {
return NextResponse.json({ specificData });
} else {
return NextResponse.json({ error: 'Data not found' });
}
}
소유권이전이 완료되면 아래와 같이 소유자 주소가 변경된다.