Appearance
L2-19: SVG NFTs(链上 SVG NFT)
1. 问题
创建完全存储在链上的 SVG NFT——不依赖 IPFS 或任何链下存储。每个 NFT 的图片(SVG XML)和 metadata(JSON)都通过 Solidity 字符串拼接动态生成,并用 Base64 编码为 Data URI,直接返回给浏览器渲染。
核心挑战:在 Solidity 中生成带有随机属性的 SVG 图形(圆形、矩形、三角形),通过伪随机种子为每个 token 生成独特的颜色和形状组合。
2. 原因
大多数 NFT 项目将图片存放在 IPFS 上。IPFS 虽然去中心化,但依赖 pinning 服务(如 Pinata、Infura)来保持文件可用。如果 pinning 服务下线或文件被垃圾回收,NFT 变为"空白图片"。
完全链上 NFT 将图片数据直接编码在合约的 tokenURI() 返回值中:
- 永久持久性:只要以太坊存在,NFT 就可以渲染
- 无需外部依赖:不需要 IPFS/Arweave/中心化服务器
- 可组合性:其他合约可以直接读取和操作 SVG 数据
- 真正的去中心化:没有任何链下组件可能失效
这也是理解 Solidity 字符串操作和 Base64 编码的绝佳练习。
3. 方案
架构设计
mint() → tokenId + seed (pseudo-random)
↓
tokenURI(tokenId) → generateSVG(seed) → Base64 encode
↓ ↓
├── SVG XML (color, shape, size) ├── JSON metadata
└── 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 Metadata:
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 metadata 再次 Base64 编码。data:image/svg+xml;base64,<Base64(SVG)> → 这个 Data URI 嵌入到 JSON 的 "image" 字段中 → 整个 JSON 再 Base64 编码 → 加上 data:application/json;base64, 前缀。
4.3 字符串拼接的 Gas 成本
每次 abi.encodePacked 在内存中创建新的字节数组。对于包含大量文本的 SVG(如 800+ 字节),多次拼接的 Gas 成本可能达到 50K-100K gas。虽然 tokenURI 是 view 函数不消耗用户 gas,但它影响合约的读取性能。
4.4 Solidity 浮点数不支持
SVG 中的 opacity 是 0 到 1 的小数(如 opacity="0.8"),但 Solidity 没有浮点数。必须用整数运算模拟小数(如 opacity = 80 → "0.80")。
5. 陷阱的原因
5.1
Solidity 的 string 类型没有原生转义机制。开发者必须手动在字符串中嵌入 \"。这是最常见的 bug 来源——少一个反斜杠,整个 JSON 就无效。
5.2
Data URI 格式:data:[<mediatype>][;base64],<data>。SVG 的 mediatype 是 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 颜色 + 大小 |
| Gas 优化 | abi.encodePacked 和 constant 模板 |