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ギャラリー)を構築します。

関連記事