创建一个基于链上实时数据的动态SVG NFT
在过去的一年里,NFT是一个令人惊讶的突破性使用场景,它使数百万的新用户加入了加密货币和web3。目前,大多数NFT由静态图片组成,有时这些图片由某个预定义规则 揭示
出来(如盲盒形式)。但作为可编程的智能合约,ERC-721s能够做得更多。
对NFT的一个常见的批评是,它们 只是一个甚至不在区块链上的图片链接
。对于许多著名的项目,如Bored Ape Yacht Club,的确是如此。
<center>OpenSea上的Bored Ape #969</center>
ERC-721 标准的标准接口 tokenURI()用来返回元数据(metadata),其中包括一个图像链接。在Bored Apes的案例中,元数据被存储在IPFS上。我们可以通过在Etherscan上直接查询Bored Ape合约的tokenURI来看到这一点。
该链接返回NFT的完整元数据,包括图片也在IPFS上。
这个链接也托管在IPFS上,
一个侧面说法,也是相当哲学的观点:NFT是收据,而不是图像本身,请看EveryNFTEver,它有一个很好的简洁解释。
虽然IPFS托管元数据和图像更常见,但存在另一种类型的NFT,其中数据直接在智能合约中完全存储在链上。代替返回链接,tokenURI 返回一个编码的json数据,包含可以在浏览器中呈现的svg数据。
SVG NFT最有名的例子是Loot:
黑色背景上的白色文字。这个图片不是来自IPFS,而是一个浏览器可以渲染的编码过的svg文件。其完全在链上的,不依赖任何外部链接。完整的合约可以在Etherscan上找到,但下面是相关部分:
SVG数据是以编程方式生成、编码并由合约返回。
Loot是一个简单的例子,但它说明了与IPFS托管图片的区别。因为确定SVG的逻辑是在链上执行的,所以它开启了一系列的可能性。
我们可以从其他智能合约中读取数据并将其包含在SVG中,每次调用渲染函数时,这些数据都会自动更新读取! 这使得SVG图片可以合成,并对链上的数据变化做出反应。
作为一个概念证明,我为BuidlGuidl的成员写了一个简单的动态SVG NFT。BuidlGuidl NFT演示-Youtube视频
这个想法是一个徽章NFT,它读取钱包的ENS名称、余额和工作流合约余额。并以一种漂亮的简约方式显示它们。
ENS名称和余额在每次NFT被渲染时都会更新,在OpenSea上查看它。
完整的合约可以在这里找到:
//SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
/**
* @title BuidlGuidl Tabard
* @author Daniel Khoo
* @notice A dynamic NFT for BuidlGuidl members. Image is a fully-onchain SVG with tied to the bound address i.e. the minter.
* Dynamic elements are: ENS reverse resolution, stream and wallet balance updates.
* @dev Mintable if wallet is toAddress of a BuidlGuidl stream.
*/
contract BuidlGuidlTabard is ERC721 {
// ENS Reverse Record Contract for address => ENS resolution
// NOTE: Address of ENS Reverse Record Contract differs across testnets/mainnet
IReverseRecords ensReverseRecords =
IReverseRecords(0x3671aE578E63FdF66ad4F3E12CC0c0d71Ac7510C);
mapping(address => address) public streams; // Store individual stream addresses so they can be referenced post-mint
constructor() ERC721("BuidlGuidl Tabard", "BGT") {}
function mintItem(address streamAddress) public {
// Minimal check that wallet is the recipient of a Stream
// Someone could deploy a decoy stream to bypass this, but it's easier to just join the BuidlGuidl :)
ISimpleStream stream = ISimpleStream(streamAddress);
require(
msg.sender == stream.toAddress(),
"You are not the recipient of the stream"
);
streams[msg.sender] = streamAddress;
// Set the token id to the address of minter.
// Inspired by https://gist.github.com/z0r0z/6ca37df326302b0ec8635b8796a4fdbb
_mint(msg.sender, uint256(uint160(msg.sender)));
}
function tokenURI(uint256 id) public view override returns (string memory) {
return _buildTokenURI(id);
}
// Constructs the encoded svg string to be returned by tokenURI()
function _buildTokenURI(uint256 id) internal view returns (string memory) {
bool minted = _exists(id);
// Bound address from tokenId
address boundAddress = address(uint160(id));
string memory streamBalance = "";
// Don't include stream in URI until token is minted
if (minted) {
// Get stream address, to check it's current balance
address streamAddress = streams[boundAddress];
ISimpleStream stream = ISimpleStream(streamAddress);
streamBalance = string(
abi.encodePacked(
unicode'<text x="20" y="305">Stream Ξ',
weiToEtherString(stream.streamBalance()),
"</text>"
)
);
}
bytes memory image = abi.encodePacked(
"data:image/svg+xml;base64,",
Base64.encode(
bytes(
abi.encodePacked(
'<?xml version="1.0" encoding="UTF-8"?>',
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 400 400" preserveAspectRatio="xMidYMid meet">',
'<style type="text/css"><![CDATA[text { font-family: monospace; font-size: 21px;} .h1 {font-size: 40px; font-weight: 600;}]]></style>',
'<rect width="400" height="400" fill="#ffffff" />',
'<text class="h1" x="50" y="70">Knight of the</text>',
'<text class="h1" x="80" y="120" >BuidlGuidl</text>',
unicode'<text x="70" y="240" style="font-size:100px;">🏗️ 🏰</text>',
streamBalance,
unicode'<text x="210" y="305">Wallet Ξ',
weiToEtherString(boundAddress.balance),
"</text>",
'<text x="20" y="350" style="font-size:28px;"> ',
lookupENSName(boundAddress),
"</text>",
'<text x="20" y="380" style="font-size:14px;">0x',
addressToString(boundAddress),
"</text>",
"</svg>"
)
)
)
);
return
string(
abi.encodePacked(
"data:application/json;base64,",
Base64.encode(
bytes(
abi.encodePacked(
'{"name":"BuidlGuidl Tabard", "image":"',
image,
unicode'", "description": "This NFT marks the bound address as a member of the BuidlGuidl. The image is a fully-onchain dynamic SVG reflecting current balances of the bound wallet and builder work stream."}'
)
)
)
)
);
}
/* ========== HELPER FUNCTIONS ========== */
/// @notice Checks ENS reverse records if address has an ens name, else returns blank string
function lookupENSName(address addr) public view returns (string memory) {
address[] memory t = new address[](1);
t[0] = addr;
string[] memory results = ensReverseRecords.getNames(t);
return results[0];
}
/// @notice Converts wei to ether string with 2 decimal places
function weiToEtherString(uint256 amountInWei)
public
pure
returns (string memory)
{
uint256 amountInFinney = amountInWei / 1e15; // 1 finney == 1e15
return
string(
abi.encodePacked(
Strings.toString(amountInFinney / 1000), //left of decimal
".",
Strings.toString((amountInFinney % 1000) / 100), //first decimal
Strings.toString(((amountInFinney % 1000) % 100) / 10) // first decimal
)
);
}
function addressToString(address x) internal pure returns (string memory) {
bytes memory s = new bytes(40);
for (uint256 i = 0; i < 20; i++) {
bytes1 b = bytes1(uint8(uint256(uint160(x)) / (2**(8 * (19 - i)))));
bytes1 hi = bytes1(uint8(b) / 16);
bytes1 lo = bytes1(uint8(b) - 16 * uint8(hi));
s[2 * i] = char(hi);
s[2 * i + 1] = char(lo);
}
return string(s);
}
function char(bytes1 b) internal pure returns (bytes1 c) {
if (uint8(b) < 10) return bytes1(uint8(b) + 0x30);
else return bytes1(uint8(b) + 0x57);
}
}
/* ========== EXTERNAL CONTRACT INTERFACES ========== */
/// @notice Minimal contract interfaces for dynamic reading of data for SVG
/// @notice SimpleStream that each buidlguidl member has
/// https://github.com/scaffold-eth/scaffold-eth/blob/simple-stream/packages/hardhat/contracts/SimpleStream.sol
interface ISimpleStream {
function toAddress() external view returns (address);
function streamBalance() external view returns (uint256);
}
/// @notice ENS reverse record contract for resolving address to ENS name
/// https://github.com/ensdomains/reverse-records/blob/master/contracts/ReverseRecords.sol
interface IReverseRecords {
function getNames(address[] calldata addresses)
external
view
returns (string[] memory r);
}
BuidlGuidl Tabard NFT v1在Eth主网上的合约,合约地址是:https://etherscan.io/address/0x06a13a0fcb0fa92fdb7359c1dbfb8c8addee0424
以上大部分的代码都是不言自明的。一个有趣的部分是使用接口与两个外部合约进行交互。这对其他类型的智能合约来说非常常见,但对NFT来说却不是。
第一个外部合约是一个ETH流合约,每个BuidlGuidl成员都有相应的流合约。mint函数 mintItem(address streamAddress) 期望一个合约地址,此合约可以取款到铸币者的钱包,这个合约的余额显示在SVG中。
第二个是以太坊域名服务的反向记录合约,它可以解析与之相关的ENS名称(如果有的话)。
另一个怪癖之处是代币的ID。受Ross Campbell的Soulbound NFT想法的启发,tokenId不是普通的整数,而是其地址对应的uint256
表示。因此,即使代币被转移到另一个钱包,相关的地址和它在链上查找的数据仍将保持与铸造者的地址相联系。这种绑定的NFT很有趣,尽管对于转售来说没有价值(因为绑定的是原原始铸币者),但对于持有者来说非常有价值,因为链上的凭证是不能买到的,只能赚到。
Soulbound NFT 代码在: https://gist.github.com/z0r0z/6ca37df326302b0ec8635b8796a4fdbb
我们讨论了很多话题:
希望这个例子能说明NFT在静态图片之外的潜力,并激励你建立自己的NFT。他有很多可能性:过期的可兑换票据、链上凭证、会员徽章、成就......
本翻译由 Duet Protocol 赞助支持。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!