# 源码解读：你买的NFT到底是什么？

By [shisi.eth](https://paragraph.com/@shisi-eth) · 2022-10-13

---

内容概要
----

如果你是WEB3加密界的新手，面对众多概念无从入手，那么欢迎你，来对地方了！！

本文围绕标准 ERC721协议，描述了Mint、 safeMint、 transfer等是如何实现资产管理的，并通过解读代码来了解它的安全性设计和以太坊数据上链成本构成。

### 目录大纲

1.所谓NFT资产是什么？

2.Mint和safeMint的差别

3.交易时会发生什么？有哪些细节设计

4.NFT哪些数据也存储在链上？

5.以太坊上存储有多贵？

### 面向对象

Web3新手，有无技术背景均可：

*   研发——可无障碍阅读，理解精美的合约设计
    
*   非研发——可能读不懂列举的代码，但能体会标准协议的设计思路
    

1.所谓NFT资产是什么？
-------------

在opensea上，可看到每个NFT都有个唯一的编号。

比如azuki系列中第4132号，在页面的Details栏目可以看到其合约地址，ID编号，部署所在公链等信息，而Properties栏目则是其设定的具备各种属性，对应的稀有度（非azuki本身携带，而是opensea整合计算的）。

![](https://storage.googleapis.com/papyrus_images/2374f770537c67234af4268aef13c654f703693909ec28e59b80ea54cff4bdc6.png)

### 1.1 资产在标准ERC721协议里是什么？

而咱们回顾到源代码（此处取ERC721标准库openzepplin代码），会发现程序记录了全局性的两个字典类型的变量，通过 \_owners中用数字映射地址的方式记录每一个ID 当前对应的所有者，同时也附带用\_balances 记录了当前所有者总计持有的NFT数量

    mapping(uint256 => address) private _owners;
    mapping(address => uint256) private _balances;
    

并且由于ERC721创新性的赋予了一个ID对应地址的变量 \_owners，从而与ERC20仅\_balances 进行地址与余额的管理，区分出了FT（同质化）与NFT（非同质化）的差别。

2.Mint和safeMint的差别
------------------

### 2.1 Mint是如何进行的

Mint 意思为铸造，即每个NFT的创造过程，例如之前的：[当奈飞的NFT忘记了web2的业务安全](https://mp.weixin.qq.com/s?__biz=MzIyMTQ5MTg5Mw==&mid=2247483761&idx=1&sn=0ebe04a3611d8ad2bde5045a862324e3&chksm=e83aa6abdf4d2fbd476a402c6fb697b6d3086721a66f217d659e45ddd1835a45885b969b5161&scene=21#wechat_redirect)

Mint 获取到该NFT的资产证明。

从源代码中可以看到，Mint 主要是进行了安全判断：

*   判断1：确保转入的不是0x00地址（黑洞地址无法转出，转入则资产损失）
    
*   判断2：确保此交易所操作的NFTID是不存在的
    

最终代码执行的操作是：

*   操作1：将转入地址的\_balances 所持有总数加1
    
*   操作2：将对应 NFTID 的所有者修改为转入的地址
    
*   操作3：完成交易则发出emit 事件，可以让链下监听到这次交易的数据
    

    /**
     * @dev Mints `tokenId` and transfers it to `to`.
     * Emits a {Transfer} event.
     */
    function _mint(address to, uint256 tokenId) internal virtual {
        require(to != address(0), "ERC721: mint to the zero address");
        require(!_exists(tokenId), "ERC721: token already minted");
        _beforeTokenTransfer(address(0), to, tokenId);
        _balances[to] += 1;
        _owners[tokenId] = to;
        emit Transfer(address(0), to, tokenId);
        _afterTokenTransfer(address(0), to, tokenId);
    }
    

中间有\_beforeTokenTransfer和\_afterTokenTransfer 属于虚函数，作为标准，是让项目方可以再不修改标准协议的情况下增加一些特定的逻辑代码用的。

### 2.2 为何safeMint更安全

safeMint 意为安全的铸造，从代码实现中可以看到他本身也是调用了MInt 但是他额外增加了\_checkOnERC721Received 的判断，这点是属于ERC165的标准，相当于在完成转入操作后，则判断对方地址，是否是黑洞地址（即无法发起交易NFT操作的地址）是防止转入对象为合约地址时候，其合约没有预设置好转出的函数，导致资产在内无法被转走，从而造成永久损失。

    /**
     * @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is
     * forwarded in {IERC721Receiver-onERC721Received} to contract recipients.
     */
    function _safeMint(address to,uint256 tokenId,bytes memory _data) 
        internal virtual {
        _mint(to, tokenId);
        require(
            _checkOnERC721Received(address(0), to, tokenId, _data),
            "ERC721: transfer to non ERC721Receiver implementer"
        );
    }
    

### 2.3 ERC165是如何防止资产转入黑洞的？

设计初衷可见：[https://eips.ethereum.org/EIPS/eip-165](https://eips.ethereum.org/EIPS/eip-165)

是让合约接口标准化的提案，在编程语法中interface 是接口的意思，在其中定义的函数可以不实现仅仅放上函数名字相关参数，在程序复杂的时候，相当于目录一般告诉别人我都有什么功能。

但是接口的写法各有千秋，名字定义参数类型，甚至是否存在都有不同，

所以此提案最终形成了ERC165标准，规范了接口的识别规则。

    interface ERC165 {
        /// @notice 查询合约所实现的接口
        /// @param interfaceID  参数：接口ID
        /// @return true 如果函数实现了 interfaceID (interfaceID 不为 0xffffffff )返回true, 否则为 false
        function supportsInterface(bytes4 interfaceID) external view returns (bool);
    }
    

使用流程是：

**STEP 1** 判断是否存在 supportsInterface 函数，并且其符合165标准

**STEP 2**通过 supportsInterface 函数，判断是否具有转出NFT的函数

_（PS：让合约具备NFT接收转出功能，可通过引入 IERC721Receiver.sol 拓展包来实现）_

3.交易时会发生什么？有哪些细节？
-----------------

标准协议设计有两种转移方式，transfer 和 transferFrom，作用于两种场景：

*   transfer 转移：由用户调用，将本消息发送的钱包所持有的NFTID转移到指定地址
    
*   transferFrom 从转移：用某机构调用，需要用户先授权某地址，让其有权可转移。
    

类比一下：

*   transfer 就是现金交易，从自己口袋里拿钱支付
    
*   transferFrom 就是扫码扣款，由店家申请扣款，受制于用户是否开通小额代扣权限
    

接下来咱们从代码来看看，其中可能有会意想不到的细节。

### 3.1 transfer 是如何进行的

他会检测当前交易的 from 方是否是此NFTID的持有者，并且限制该NFT转入0x00地址。其次进行 from 转出地址和 to 转入地址的余额刷新，修改 \_balances全局变量并且重新设置\_owners此NFTID的所有者地址修改为to。

**这里有个防护的细节会先执行\_approve(address(0), tokenId); 清空历史授权，如果没有这一步，则资产完成了转移，但是其NFTID的转移授权依旧在，细思极恐：**

    function _transfer(address from,address to,uint256 tokenId) internal virtual {
            require(ERC721.ownerOf(tokenId) == from, "ERC721: transfer from incorrect owner");
            require(to != address(0), "ERC721: transfer to the zero address");
            _beforeTokenTransfer(from, to, tokenId);
            _approve(address(0), tokenId);// Clear approvals from the previous owner
            _balances[from] -= 1;
            _balances[to] += 1;
            _owners[tokenId] = to;
            emit Transfer(from, to, tokenId);
            _afterTokenTransfer(from, to, tokenId);
        }
    

### 3.2 transferFrom是如何进行的

这里的交易本质调用的是\_safeTransfer 所以他的核心逻辑是require 部分，

这的一大细节是：\_msgSender() 这是openzepplin的标准库Context.sol 中的方法。

其实就是获取当前交易的发送者地址，但这里使用了封装版本，而不是直接使用msg.sender

是考虑到，可能存在一种交易类型“元交易” ，即交易的付费gas方和交易发起方不相同的情况。

所以一些处于中间环节的，类似library的合约需要考虑这种特殊情况。其余部分判断是确定是否有授权记录，易于理解，不作赘述

    function safeTransferFrom(address from,address to,uint256 tokenId,bytes memory _data) public virtual override {      require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: transfer caller is not owner nor approved");      _safeTransfer(from, to, tokenId, _data);}
    

4.还有哪些数据可扩展存储在链上？
-----------------

交易的环节也看完后，其实很多新同学也顿感奇怪，原来我买的NFT只有一个ID的归属地址指向了我，从而达成了唯一性。那就算如此，稀有度信息放在哪里？我的NFT图像本身在那里？

这就是涉及到ERC721的元数据拓展IERC721Metadata.sol

要放什么都可以，但是项目方往往在链上只存储最基础的ID+IPFS的地址。

咱们可以通过之前Etherscan教程方法来看看一些项目数据有什么？

Azuki上合约地址是：**0xed5af388653567af2f388e6224dc7c4b3241c544**

通过Read Contract可以查阅到，其元数据只存放了ipfs上的指向地址

而近期兴起的Metaverse项目元宇宙土地sandbox和**Decentraland ，以及去年火热的**Axie Infinity ，基本链上存储元数据也只是ID+网址。

![](https://storage.googleapis.com/papyrus_images/c8485a050c2961d5203f11f4de72300f72d32113d374959876d57e6957b7baea.png)

像mirror那些是专门设计低费用可进行高存储，一个块常规都是30M起步，大约是以太坊的1000倍。

5.以太坊上存储有多贵？
------------

这里是本文稍难的地方。咱们从源码来分析链上存储的成本构成以及金额换算

成本产生将有2个方面，按执行流程来看

*   用户发起一笔交易，将要写链上数据作为参数传入，其大小是一笔成本
    
*   交易执行合约代码，依据修改和使用，EVM计算消耗的gas成本。
    

### 5.1 交易发起的成本

咱们可以核对下以太坊黄皮书，里对交易数据大小所消耗gas有清晰的定义

![](https://storage.googleapis.com/papyrus_images/03c9c736a1429099379827827090a86bf83449b50195b55c91b9579c4d63758d.png)

可以看到交易所附带的参数的价格：

*   每笔交易都有21000 GAS需要支付
    
*   为交易的每个非零字节数据或代码支付68 GAS
    
*   为交易的每个零字节数据或代码支付4 GAS
    

所以如果是再 Mint 的时候，登记上若干NFT属性信息，交易的data部分会将abc等字符转成2个十六进制表示，而每个字符为一个八位二进制，等于一个byte。所以可以约等于将data的长度除以2作为byte数。

而1kb的数据，如果都是非0的有信息量的文本信息，则等于增加是68\*1000=6.8W 的gas消耗。按20gwei的gas价格和2000的eth兑换美元价格，可以估算出，每上链1kb数据在交易发起端就要：

20\*(21000+68000)\*1e9/1e18 \* 2000 = 3.5美金

### 5.2合约存储的成本

由于交易发起后，还有智能合约上存储的逻辑，咱们从以太坊go源代码中（EIP1283），来分析具体的消费量，代码具体在函数内，太长了不全粘来：

    func gasSStore(evm *EVM, contract *Contract, stack *Stack, mem *Memory, memorySize uint64)(uint64, error)
    

历史上GAS消耗的估算有经过若干迭代，如果是Petersburg或者 Constantinople 未激活的话，则不按下面逻辑进行计算

gas消耗计算，依赖3个种数据的管理形式（增删改）

*   从零值地址到非零值（NEW VALUE），每个存储槽需消耗2Wgas
    
*   从非零值地址到零值地址（DELETE），每个存储槽需消耗5Kgas，但会有奖励1.5W gas退回
    
*   从非零到非零（CHANGE），每个存储槽需消耗 200 gas
    

**注意，上述每一个存储槽算32byte，1kb存储则是32个存储槽**。Mint 的过程是新增存储，所以如果新增1kb的数据存储在链上代价将是64Wgas，换算成金额则是：

20\*(640000)\*1e9/1e18 \* 2000 = 25 美金

真可谓寸土寸金！

欢迎各位同学从后台提交有趣的合约或交易哈希。

关注十四，用技术的眼光发现价值。

![](https://storage.googleapis.com/papyrus_images/b48f70bbbea572ee015d69b3cc0cb9b004349b41bd23b1e91ba285c352944d08.png)

---

*Originally published on [shisi.eth](https://paragraph.com/@shisi-eth/nft)*
