Appearance
L2-19: SVG NFT(オンチェーン SVG NFT)
1. 課題
IPFS やオフチェーンストレージに一切依存せず、完全にオンチェーンで保存される SVG NFT を作成します。各 NFT の画像(SVG XML)とメタデータ(JSON)は、Solidity の文字列連結によって動的に生成され、Base64 エンコードされた Data URI としてブラウザに直接返されレンダリングされます。
中心的な課題:Solidity 内でランダムな属性を持つ SVG グラフィック(円、矩形、三角形)を生成し、擬似乱数シードを使用して各トークンに固有の色と形状の組み合わせを生成することです。
2. 理由
ほとんどの NFT プロジェクトは画像を IPFS に保存しています。IPFS は分散型ですが、ファイルの可用性を維持するためにピニングサービス(Pinata、Infura など)に依存しています。ピニングサービスが停止したり、ファイルがガベージコレクションされたりすると、NFT は「空白の画像」になってしまいます。
完全にオンチェーンの NFT は、画像データをコントラクトの tokenURI() の戻り値に直接エンコードします:
- 永続性:イーサリアムが存在する限り、NFT はレンダリング可能
- 外部依存なし:IPFS/Arweave/中央集権サーバーが不要
- コンポーザビリティ:他のコントラクトが SVG データを直接読み取り・操作可能
- 真の分散化:障害が発生しうるオフチェーンコンポーネントが存在しない
これは Solidity の文字列操作と Base64 エンコーディングを理解するための優れた演習でもあります。
3. 解決策
アーキテクチャ設計
mint() → tokenId + seed(擬似乱数)
↓
tokenURI(tokenId) → generateSVG(seed) → Base64 エンコード
↓ ↓
├── SVG XML(色、形状、サイズ) ├── JSON メタデータ
└── Base64(SVG) └── Base64(JSON)
↓
data:application/json;base64,...
コア実装
SVG 生成(動的属性):
solidity
function _generateSVG(uint256 tokenId, uint256 seed) private pure returns (string memory) {
// シードから色を導出
uint256 r = uint256(keccak256(abi.encodePacked(seed, "red"))) % 256;
uint256 g = uint256(keccak256(abi.encodePacked(seed, "green"))) % 256;
uint256 b = uint256(keccak256(abi.encodePacked(seed, "blue"))) % 256;
// シードから形状パラメータを導出
uint256 shape = seed % 3; // 0=円, 1=矩形, 2=三角形
uint256 cx = (seed % 200) + 50;
uint256 cy = ((seed >> 16) % 200) + 50;
uint256 size = ((seed >> 32) % 60) + 20;
uint256 opacity = ((seed >> 48) % 30) + 70; // 70-99%
string memory shapeSvg = _generateShape(shape, cx, cy, size, r, g, b, opacity);
return string(abi.encodePacked(
'<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">',
'<rect width="400" height="400" fill="#1a1a2e"/>',
shapeSvg,
'<text x="20" y="380" font-size="10" fill="#ffffff" opacity="0.3">OnChainSVGNFT</text>',
'</svg>'
));
}
Base64 JSON メタデータ:
solidity
function _buildJSON(uint256 tokenId, string memory svg) private pure returns (string memory) {
return string(abi.encodePacked(
Base64.encode(bytes(abi.encodePacked(
'{"name":"OnChainSVG #', tokenId.toString(),
'","description":"Fully on-chain SVG NFT","image":"data:image/svg+xml;base64,',
Base64.encode(bytes(svg)),
'"}'
)))
));
}
擬似乱数シード
solidity
uint256 seed = uint256(keccak256(abi.encodePacked(
block.timestamp, msg.sender, tokenId
)));
注意:これは擬似乱数(予測可能)であり、真のランダム性が必要なシナリオには適していません。本番環境では Chainlink VRF を使用してください。
4. 遭遇した落とし穴
4.1 JSON 内の引用符のエスケープ
tokenURI() が返す JSON にはネストされた引用符が含まれます。Solidity の文字列は二重引用符を使用するため、JSON 内部の二重引用符はバックスラッシュでエスケープする必要があります。誤ったエスケープは JSON の解析失敗を引き起こし、NFT がマーケットプレイスで表示されなくなります。
4.2 Base64 エンコードの二重ネスト
SVG XML 自体を Base64 エンコードし、次に JSON メタデータ全体を再度 Base64 エンコードします。data:image/svg+xml;base64,<Base64(SVG)> → この Data URI が JSON の "image" フィールドに埋め込まれます → JSON 全体が Base64 エンコードされます → data:application/json;base64, プレフィックスが付加されます。
4.3 文字列連結のガスコスト
abi.encodePacked は毎回メモリ内に新しいバイト配列を作成します。大量のテキストを含む SVG(例:800 バイト以上)の場合、複数回の連結で 50K〜100K ガスのコストがかかる可能性があります。tokenURI は view 関数でありユーザーにガスコストは発生しませんが、コントラクトの読み取りパフォーマンスに影響します。
4.4 Solidity に浮動小数点がない
SVG の opacity は 0 から 1 の小数(例:opacity="0.8")ですが、Solidity には浮動小数点型がありません。整数演算で小数をシミュレートする必要があります(例:opacity = 80 → "0.80")。
5. 落とし穴の原因
5.1
Solidity の string 型にはネイティブなエスケープ機構がありません。開発者は手動で文字列に \" を埋め込む必要があります。これが最も一般的なバグの原因です。バックスラッシュが 1 つ欠けるだけで、JSON 全体が無効になります。
5.2
Data URI の形式:data:[<mediatype>][;base64],<data>。SVG のメディアタイプは image/svg+xml、JSON のメディアタイプは application/json です。二重の Base64 エンコードにより、特殊文字が URL 構造を破壊しないことが保証されます。
5.3
Solidity のメモリ操作のコストはデータサイズに依存します。各 abi.encodePacked 呼び出しは新しいメモリを割り当てます。複雑な SVG の場合、複数の小さな連結ステップを使用してメモリ使用量を最適化することを検討してください。
6. 落とし穴の解決方法
- OpenZeppelin の
Strings.toString()とBase64.encode()ライブラリを使用して実装を簡素化する opacityについては、整数値(0〜100)を保存し、SVG 生成時に"0.XX"形式に変換するtokenURIの戻り値をテストする:出力をブラウザにコピーして JSON の有効性と SVG のレンダリングを確認する- 本番環境では、SVG テンプレートを
constant文字列に保存することを検討する(コンパイル時に決定、実行時コストゼロ)
7. 技術的要点
| 要点 | 説明 |
|---|---|
| オンチェーン SVG | Solidity による XML 文字列連結 + Base64 エンコード |
| Data URI | data:image/svg+xml;base64,... |
| Base64 二重ネスト | SVG → Base64 → JSON → Base64 |
| OpenZeppelin ユーティリティ | Strings.toString() + Base64.encode() |
| 擬似乱数シード | keccak256(block.timestamp, msg.sender, tokenId) |
| 動的属性 | 円/矩形/三角形 + RGB 色 + サイズ |
| ガス最適化 | abi.encodePacked と constant テンプレート |