Day11:NFT実装(IPFS・メタデータ)と表示確認(tokenURI/IPFS Gateway)

← 目次 前: Day10 次: Day12

学習目的

  • ERC‑721の tokenURI 設計とIPFSメタデータのベストプラクティスを理解し、簡単に説明できるようになる。
  • 画像→IPFS→baseURI→ミント→tokenURI/Gatewayで表示確認までを一連で実行できるようになる。
  • EIP‑2981(ロイヤリティ)と固定価格販売の最小例を実装し、動作確認できるようになる。

まず docs/curriculum/index.md の「共通の前提」を確認してから進める。


0. 前提

  • PinataまたはInfura IPFS(Project ID/Secret)を用意。
  • 画像ファイル(例:assets/1.png)。
  • 先に読む付録:docs/appendix/glossary.md(用語に迷ったとき)
  • 触るファイル(主なもの):contracts/MyNFT.sol / scripts/deploy-nft.ts / scripts/mint-nft.ts / contracts/FixedPriceMarket.sol / test/mynft.ts
  • 今回触らないこと:NFTマーケットの本格実装(まずはtokenURI/IPFSの流れを固める)
  • 最短手順(迷ったらここ):2章でIPFSに配置 → 3章の MyNFT をデプロイ → 4章でミント → tokenURI/Gatewayで表示確認

.env.example(項目は同梱してあるので、.env に値を入れる):

NFT_BASE=ipfs://<CID>/
NFT_ROYALTY_BPS=500   # 5% = 500 basis points

1. メタデータ設計(教科書)

  • metadata.json 必須キー:name, description, image。拡張:attributes[], animation_url
  • 画像はipfs://<CID>/1.png のように内容アドレスで参照。HTTP Gateway(https://ipfs.io/ipfs/<CID>)はプレビュー用。
  • baseURIipfs://<CID>/ に固定し、tokenURI(id)baseURI + id + .json とする。
  • メタデータは凍結(フリーズ)方針を採用。差し替えが必要ならバージョンを変えて再発行。

2. IPFS へのアップロード

2.1 ディレクトリ構成

ipfs/
├── 1.png
├── 1.json
└── _metadata_schema.md

2.2 1.json 雛形

{
  "name": "Sample #1",
  "description": "Demo NFT",
  "image": "ipfs://REPLACE_IMAGE_CID/1.png",
  "attributes": [
    { "trait_type": "tier", "value": "basic" },
    { "trait_type": "series", "value": 1 }
  ]
}

画像CIDとメタデータCIDは異なる可能性がある。Pinataでフォルダ単位アップロードするとルートCIDが付く。

2.3 CLI例(Pinata)

Web UIでipfs/フォルダをアップロード→取得したルートCIDをNFT_BASEに設定。


3. コントラクト実装

この章の MyNFT は、まず「tokenURI が IPFS 上のメタデータを指す」状態を最小で作る。

3.1 概念(何を実装するか)

  • baseURIipfs://<CID>/ に固定し、tokenURI(id)baseURI + id + .json を返す。
  • ミントは onlyOwner にして、まずは手順の再現性を優先する(権限制御の深掘りは Day12 で扱う)。
  • ロイヤリティは EIP‑2981 の royaltyInfo で「受取人 + 割合(BPS)」を返す。

3.2 最小コード(contracts/MyNFT.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/interfaces/IERC2981.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";

contract MyNFT is ERC721, Ownable, IERC2981 {
    using Strings for uint256;
    string private _base;
    address private _royaltyReceiver;
    uint96  private _royaltyBps; // 10000 = 100%

    constructor(string memory base_, address royaltyReceiver_, uint96 royaltyBps_)
        ERC721("MyNFT", "MNFT") Ownable(msg.sender)
    {
        _base = base_;
        _royaltyReceiver = royaltyReceiver_;
        _royaltyBps = royaltyBps_;
    }

    function _baseURI() internal view override returns (string memory) { return _base; }
    function setBase(string calldata b) external onlyOwner { _base = b; }

    function mint(address to, uint256 id) external onlyOwner { _safeMint(to, id); }

    // tokenURI: baseURI + tokenId + ".json"(IPFSで `1.json` のように保存しやすい形)
    function tokenURI(uint256 tokenId) public view override returns (string memory) {
        _requireOwned(tokenId);
        string memory base = _baseURI();
        if (bytes(base).length == 0) return "";
        string memory suffix = string.concat(tokenId.toString(), ".json");
        if (bytes(base)[bytes(base).length - 1] == bytes1("/")) return string.concat(base, suffix);
        return string.concat(base, "/", suffix);
    }

    // EIP‑2981
    function royaltyInfo(uint256, uint256 salePrice) external view override
        returns (address receiver, uint256 royaltyAmount)
    { return (_royaltyReceiver, (salePrice * _royaltyBps) / 10000); }

    // NOTE: IERC2981 は IERC165 準拠。ここでIFIDを認識し、その他は ERC721 実装へ委譲。
    function supportsInterface(bytes4 iid) public view override(ERC721, IERC165) returns (bool) {
        return iid == type(IERC2981).interfaceId || super.supportsInterface(iid);
    }
}

3.3 結果の見方(ここが確認できればOK)

  • デプロイ後に tokenURI(1)ipfs://<CID>/1.json の形で返る。
  • NFT_BASE の末尾に / があってもなくても、tokenURI の戻り値が壊れない(末尾スラッシュを吸収する)。
  • supportsInterfaceIERC2981 を返す(ロイヤリティ対応の最小確認)。

3.4 よくある失敗

  • tokenURI と、IPFS 上のパス/ファイル名1.json / 1.png)が一致していない。
  • NFT_BASEipfs://<CID>/ にしていない(末尾スラッシュなし・CID違い)。
  • royaltyBps の値が意図と違う(BPSは 10000=100% の前提で設定する)。

4. デプロイ・ミント

scripts/deploy-nft.ts

import { ethers } from "hardhat";
async function main(){
  const base = process.env.NFT_BASE!; // ipfs://<CID>/
  const bps  = Number(process.env.NFT_ROYALTY_BPS || 500);
  const [owner] = await ethers.getSigners();
  const F = await ethers.getContractFactory("MyNFT");
  const c = await F.deploy(base, owner.address, bps);
  await c.waitForDeployment();
  console.log("MyNFT:", await c.getAddress());
}
main().catch(e=>{console.error(e);process.exit(1)});
npx hardhat run scripts/deploy-nft.ts --network sepolia

ミント: このリポジトリの scripts/mint-nft.ts を使う。

NFT_ADDRESS=0x... TOKEN_ID=1 TO=0x... npx hardhat run scripts/mint-nft.ts --network sepolia

TO を省略した場合、ローカルでは2番目の署名者、そうでなければ自分宛になる。

Verify:

npx hardhat verify --network sepolia <NFT_ADDRESS> "$NFT_BASE" <OWNER_ADDRESS> $NFT_ROYALTY_BPS

Verifyで詰まったら docs/appendix/verify.md の「失敗時の切り分けルート」→「よくあるエラー表」を参照する(引数不一致が典型)。


5. 表示確認(tokenURI と IPFS Gateway)

OpenSea はテストネット表示を終了したため、次の手順で確認する。

1) tokenURI(1)ipfs://<CID>/1.json を返すこと(スクリプト、またはエクスプローラの Read Contract で確認)。
2) ipfs://<CID>/1.json を HTTP に置き換えて開く(例:https://ipfs.io/ipfs/<CID>/1.json)。
3) JSON 内の image も同様に置き換えて開き、画像が表示されることを確認。

メモ:IPFS Gateway は複数ある。表示できない場合は別Gatewayで再確認する。


6. 固定価格マーケット(最小実装)

contracts/FixedPriceMarket.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
// NOTE: DEMO ONLY / NOT FOR PRODUCTION
// 本契約は教材用の最小例。実運用不可の主な理由:
// - ReentrancyGuard/CEI等の再入防御なし
// - クリエイターロイヤリティ未対応(EIP-2981非連動)
// - キャンセル/有効期限/再出品の整合性なし
// - 手数料・スリッページ・会計処理の考慮なし
// 実運用時は監査済み実装と包括的テストを使用すること。
interface IERC721X { function safeTransferFrom(address,address,uint256) external; function ownerOf(uint256) external view returns(address); }
contract FixedPriceMarket {
    event Listed(address indexed nft, uint256 indexed id, address indexed seller, uint256 price);
    event Purchased(address indexed nft, uint256 indexed id, address indexed buyer, uint256 price);
    struct Listing { address seller; uint256 price; }
    mapping(address=>mapping(uint256=>Listing)) public listings;

    function list(address nft, uint256 id, uint256 price) external {
        require(IERC721X(nft).ownerOf(id) == msg.sender, "owner");
        require(price > 0, "price=0");
        require(listings[nft][id].seller == address(0), "already listed");
        listings[nft][id] = Listing(msg.sender, price);
        emit Listed(nft,id,msg.sender,price);
    }
    function buy(address nft, uint256 id) external payable {
        Listing memory L = listings[nft][id];
        require(L.seller != address(0), "no list");
        require(msg.value == L.price, "price");
        delete listings[nft][id];
        (bool ok,) = L.seller.call{value: msg.value}(""); require(ok, "pay");
        IERC721X(nft).safeTransferFrom(L.seller, msg.sender, id);
        emit Purchased(nft,id,msg.sender,msg.value);
    }
}

デモ用途。手数料、ロイヤリティ分配、再入保護、キャンセル、タイムロック等は省略。実運用不可。

6.1 流れ

1) オーナーがMyNFTsetApprovalForAll(market,true)。 2) list(nft,id,price) 呼び出し。 3) 購入者が buy(nft,id)msg.value=price で送金。


7. テスト(抜粋)

test/mynft.ts

import { expect } from 'chai';
import { ethers } from 'hardhat';

describe('MyNFT', () => {
  it('mints and returns tokenURI', async () => {
    const [owner, alice] = await ethers.getSigners();
    const F = await ethers.getContractFactory('MyNFT');
    const base = 'ipfs://cid/';
    const c = await F.deploy(base, owner.address, 500);
    await c.waitForDeployment();
    await (await c.mint(alice.address, 1)).wait();
    expect(await c.tokenURI(1)).to.eq(`${base}1.json`);
  });
});

8. つまずきポイント

| 症状 | 原因 | 対応 | |—|—|—| | Gatewayで開けない | Gateway側の障害/レート制限、またはURIのパス不一致 | 別Gatewayで再確認。tokenURI とファイル名(1.json 等)が一致しているか確認 | | 画像が表示されない | imageがHTTP/HTTPSや拡張子誤り | ipfs://CID/...png を再確認 | | Verify失敗 | コンストラクタ引数不一致 | 引数順序・型・設定を確認。詰まったら docs/appendix/verify.md | | safeTransferFrom失敗 | approve不足 | setApprovalForAll または approve(id) 実行 |


9. まとめ

  • tokenURI とIPFSメタデータの設計(CID/パス/凍結方針)を、実装と表示確認の流れで整理した。
  • デプロイ→ミント→Gatewayで表示確認までをつなぎ、tokenURI とファイル名の一致が重要だと分かった。
  • 固定価格マーケットの最小例を通して、実運用で必要な防御(再入対策等)を明確化した。

理解チェック(3問)

  • Q1. NFTの tokenURI が指しているものは何か?オンチェーン/オフチェーンで分けて説明してみる。
  • Q2. IPFSのCIDとGatewayのURLは、どちらが「安定」しやすいか?理由も添える。
  • Q3. 固定価格マーケットで「購入できる状態」にするために、最低限必要な手順を2つ挙げる。

解答例(短く)

  • A1. tokenURI はメタデータ(JSON等)への参照だ。オンチェーンでは参照先(文字列)を返し、オフチェーンでその参照先から名前/画像などを取得して表示する。
  • A2. CIDの方が内容に紐づく識別子で安定しやすい。Gatewayは提供元やパスで変わり得るため、複数候補を持つと事故が減る。
  • A3. 例:NFTをミントする、マーケットに移転/出品できるよう approve(または setApprovalForAll)する、価格を指定してlistする。

確認コマンド(最小)

npx hardhat test test/mynft.ts
npx hardhat test test/market.ts

# 任意(テストネット:要 .env / IPFS)
npx hardhat run scripts/deploy-nft.ts --network sepolia
NFT=0x... npx hardhat run scripts/mint-nft.ts --network sepolia

10. 提出物

  • MyNFTFixedPriceMarket のアドレス、Verifyリンク
  • tokenURI(1) の戻り値と、IPFS Gatewayで開いたメタデータ/画像のスクリーンショット
  • IPFSのCID、1.json の最終版

11. 実行例