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>)はプレビュー用。 baseURIをipfs://<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 概念(何を実装するか)
baseURIをipfs://<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の戻り値が壊れない(末尾スラッシュを吸収する)。supportsInterfaceがIERC2981を返す(ロイヤリティ対応の最小確認)。
3.4 よくある失敗
tokenURIと、IPFS 上のパス/ファイル名(1.json/1.png)が一致していない。NFT_BASEをipfs://<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) オーナーがMyNFTでsetApprovalForAll(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. 提出物
MyNFTとFixedPriceMarketのアドレス、VerifyリンクtokenURI(1)の戻り値と、IPFS Gatewayで開いたメタデータ/画像のスクリーンショット- IPFSのCID、
1.jsonの最終版
11. 実行例
- 実行ログ例:
docs/reports/Day11.md