ERC721とは
ERC721は、Ethereum上で**Non-Fungible Token(NFT)**を表現する標準規格です。「Non-Fungible(非代替的)」とは、各トークンが一意であり、他のトークンと等価交換できないことを意味します。デジタルアートやゲームアイテム、さらにはデジタル文化財の管理にも活用されています。
ERC721の主要な機能は以下のとおりです。
| 関数 | 説明 |
|---|---|
ownerOf(tokenId) | トークンの所有者を返す |
balanceOf(address) | アドレスの保有トークン数を返す |
transferFrom(from, to, tokenId) | トークンを転送する |
approve(to, tokenId) | 転送許可を与える |
tokenURI(tokenId) | メタデータのURIを返す |
OpenZeppelinのERC721
ERC721をゼロから実装するのは複雑でセキュリティリスクも伴うため、OpenZeppelinのライブラリを使います。
npm install @openzeppelin/contracts
NFTコントラクトの実装
文化財のデジタルアーカイブを想定したNFTコントラクトを作成します。
// contracts/CulturalNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract CulturalNFT is
ERC721,
ERC721URIStorage,
ERC721Enumerable,
Ownable
{
uint256 private _nextTokenId;
// ミント時のイベント
event NFTMinted(
uint256 indexed tokenId,
address indexed to,
string tokenURI
);
constructor()
ERC721("Cultural Heritage Collection", "CHC")
Ownable(msg.sender)
{}
/// @notice NFTをミントする
/// @param to ミント先アドレス
/// @param uri メタデータのIPFS URI
function mint(
address to,
string memory uri
) public onlyOwner returns (uint256) {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
emit NFTMinted(tokenId, to, uri);
return tokenId;
}
/// @notice 現在のトークン数を取得する
function totalMinted() public view returns (uint256) {
return _nextTokenId;
}
// === 必須のオーバーライド ===
function _update(
address to,
uint256 tokenId,
address auth
)
internal
override(ERC721, ERC721Enumerable)
returns (address)
{
return super._update(to, tokenId, auth);
}
function _increaseBalance(
address account,
uint128 value
) internal override(ERC721, ERC721Enumerable) {
super._increaseBalance(account, value);
}
function tokenURI(
uint256 tokenId
)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(
bytes4 interfaceId
)
public
view
override(ERC721, ERC721URIStorage, ERC721Enumerable)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
ERC721URIStorage
ERC721URIStorage は、各トークンに個別のメタデータURI(tokenURI)を設定できる拡張です。NFTの画像や属性情報はこのURIの先(通常はIPFS)に保存されます。
ERC721Enumerable
ERC721Enumerable は、コントラクト内のすべてのトークンや特定アドレスのトークン一覧を列挙できる拡張です。フロントエンドでNFTギャラリーを表示する際に必要です。
NFTメタデータの規格
NFTのメタデータは、以下のJSON形式で記述するのが標準です。
{
"name": "鳥獣戯画 甲巻(部分)",
"description": "国宝・鳥獣人物戯画のデジタル複製。平安時代から鎌倉時代にかけて制作された絵巻物。",
"image": "ipfs://QmYourImageHash",
"external_url": "https://example.com/item/1",
"attributes": [
{
"trait_type": "Institution",
"value": "高山寺"
},
{
"trait_type": "Period",
"value": "平安時代後期"
},
{
"trait_type": "Medium",
"value": "紙本墨画"
},
{
"trait_type": "Designation",
"value": "国宝"
}
]
}
PinataでIPFSにアップロード
NFTの画像とメタデータをPinata IPFSにアップロードします。
// scripts/uploadToPinata.ts
import PinataSDK from "@pinata/sdk";
import * as fs from "fs";
import * as path from "path";
const pinata = new PinataSDK({
pinataJwt: process.env.PINATA_JWT!,
pinataGateway: process.env.PINATA_GATEWAY!,
});
async function uploadImageAndMetadata(
imagePath: string,
name: string,
description: string,
attributes: { trait_type: string; value: string }[]
) {
// 1. 画像をIPFSにアップロード
console.log("画像をアップロード中...");
const imageStream = fs.createReadStream(imagePath);
const imageResult = await pinata.pinFileToIPFS(imageStream, {
pinataMetadata: { name: `image-${name}` },
});
const imageUri = `ipfs://${imageResult.IpfsHash}`;
console.log("画像URI:", imageUri);
// 2. メタデータJSONを作成・アップロード
console.log("メタデータをアップロード中...");
const metadata = {
name,
description,
image: imageUri,
attributes,
};
const metadataResult = await pinata.pinJSONToIPFS(metadata, {
pinataMetadata: { name: `metadata-${name}` },
});
const metadataUri = `ipfs://${metadataResult.IpfsHash}`;
console.log("メタデータURI:", metadataUri);
return { imageUri, metadataUri };
}
// 使用例
async function main() {
const { metadataUri } = await uploadImageAndMetadata(
"./images/choju-giga.jpg",
"鳥獣戯画 甲巻(部分)",
"国宝・鳥獣人物戯画のデジタル複製",
[
{ trait_type: "Institution", value: "高山寺" },
{ trait_type: "Period", value: "平安時代後期" },
]
);
console.log("\nミント用メタデータURI:", metadataUri);
}
main().catch(console.error);
ミントスクリプト
メタデータURIを指定してNFTをミントします。
// scripts/mint.ts
import { ethers } from "hardhat";
async function main() {
const [deployer] = await ethers.getSigners();
const contract = await ethers.getContractAt(
"CulturalNFT",
process.env.CONTRACT_ADDRESS!
);
// NFTをミント
const metadataUri = "ipfs://QmYourMetadataHash";
const tx = await contract.mint(deployer.address, metadataUri);
const receipt = await tx.wait();
console.log("ミント完了!");
console.log("トランザクション:", receipt?.hash);
// 発行されたトークンIDを取得
const totalMinted = await contract.totalMinted();
const tokenId = Number(totalMinted) - 1;
console.log("トークンID:", tokenId);
// メタデータURIを確認
const uri = await contract.tokenURI(tokenId);
console.log("Token URI:", uri);
// 所有者を確認
const owner = await contract.ownerOf(tokenId);
console.log("所有者:", owner);
}
main().catch(console.error);
テスト
// test/CulturalNFT.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
describe("CulturalNFT", function () {
async function deployFixture() {
const [owner, user1, user2] = await ethers.getSigners();
const NFT = await ethers.getContractFactory("CulturalNFT");
const nft = await NFT.deploy();
return { nft, owner, user1, user2 };
}
it("NFTをミントできる", async function () {
const { nft, owner } = await deployFixture();
await nft.mint(owner.address, "ipfs://test1");
expect(await nft.ownerOf(0)).to.equal(owner.address);
expect(await nft.tokenURI(0)).to.equal("ipfs://test1");
expect(await nft.totalMinted()).to.equal(1);
});
it("複数のNFTをミントできる", async function () {
const { nft, owner, user1 } = await deployFixture();
await nft.mint(owner.address, "ipfs://test1");
await nft.mint(user1.address, "ipfs://test2");
expect(await nft.totalMinted()).to.equal(2);
expect(await nft.ownerOf(0)).to.equal(owner.address);
expect(await nft.ownerOf(1)).to.equal(user1.address);
});
it("オーナー以外はミントできない", async function () {
const { nft, user1 } = await deployFixture();
await expect(
nft.connect(user1).mint(user1.address, "ipfs://test")
).to.be.revertedWithCustomError(nft, "OwnableUnauthorizedAccount");
});
it("NFTを転送できる", async function () {
const { nft, owner, user1 } = await deployFixture();
await nft.mint(owner.address, "ipfs://test1");
await nft.transferFrom(owner.address, user1.address, 0);
expect(await nft.ownerOf(0)).to.equal(user1.address);
});
it("保有トークン一覧を取得できる", async function () {
const { nft, owner } = await deployFixture();
await nft.mint(owner.address, "ipfs://test1");
await nft.mint(owner.address, "ipfs://test2");
await nft.mint(owner.address, "ipfs://test3");
expect(await nft.balanceOf(owner.address)).to.equal(3);
// ERC721Enumerableで各トークンIDを取得
const tokenId0 = await nft.tokenOfOwnerByIndex(owner.address, 0);
const tokenId1 = await nft.tokenOfOwnerByIndex(owner.address, 1);
const tokenId2 = await nft.tokenOfOwnerByIndex(owner.address, 2);
expect(tokenId0).to.equal(0);
expect(tokenId1).to.equal(1);
expect(tokenId2).to.equal(2);
});
});
まとめ
本章では、OpenZeppelinのERC721を使ってNFTコントラクトを実装しました。メタデータの標準フォーマット、Pinata IPFSへのアップロード、ミントの実行とテストまでを実践しました。
次章では、このNFTを表示するフロントエンド(NFTギャラリー)を構築します。