智能合约纯小白是如何完成自己的第一个 dApp——NFT market？

作为一个持续看衰传统互联网行业 Web 前端开发的大龄前端工程师，我一直在寻找一个能最大化利用自己已有知识与技能的出路 —— 选择了向 Web3 领域的全栈开发转型。

在做出此决定之时，我对 Web3 还不甚了解，无论是站在从业者或是普通用户的立场，我急需一个能够让我快速入门的途径！

机缘巧合之下，我知道了 OpenBuild 的《Web3 前端训练营》，从内容介绍来看应该能满足我的需求，就毫不犹豫地报名了 —— 都免费的了，还有「玛尼」赚，犹豫个鬼啊！

本文内容是训练营课程的实战笔记，围绕着「有前端开发基础的智能合约纯小白如何开发出自己的第一个 NFT market dApp」去写，也就是说，会涵盖 task 3、task 4 和 task 5。

由于我是刚向 Web3 转型的初学者，很多东西不太懂，以下内容仅代表个人理解，有错漏谬误欢迎指出。

我认为「智能合约」这个名字源于它所起到的业务作用，而对于开发者来说，它仅仅是软件程序而已，需要用某种编程语言去写代码。

所以，要想编写以太坊的智能合约，就得学习并了解 Solidity 语法、ERC 及链上交互流程，这几个理解了代码就能写对了，剩下的是部署。

学习 Solidity

编程经验丰富的人只要搂一眼就知道 Solidity 是面向对象的静态类型语言，虽说有一些陌生的关键字，但不妨碍我把它整体看作是披着「合约」外衣的「类」。

因而，对 TS、Java 等有类型的基于类的编程语言熟悉的话，能够通过建立映射关系很快地初步了解 Solidity。

contract 关键字可认为是 class 关键字的领域特定变形，更加语义化地表达「合约」这个概念，因而写一个合约相当于写类。

状态变量用于存储合约内的数据，相当于类的成员变量，即类属性。

函数既可定义在合约内部，也可在外部 —— 前者相当于类的成员函数，即类方法；后者则是普通函数，通常是一些工具函数。

不像 TS 和 Java，在 Solidity 中访问可见性标识不是在最前面，而且对变量与函数来说位置是不一致的，这有点反直觉。

private 与 public 的语义跟其他语言是一样的，但没有 protected，取而代之的是 internal，另外还多了一个表示仅供外部调用的 external。

函数修饰符相当于 TS 装饰器或 Java 注解，可以进行面向切面编程，即 AOP；函数与函数修饰符都可被衍生的合约覆盖。

以下几种类型都可看作是 ES 中的对象，但使用场景有所不同：

结构体（struct）用于定义实体；

枚举（enum）是有限选项的集合；

映射（mapping）则是无限的选项。

Solidity 支持多重继承与函数多态性，能够更好地组合复用；由于合约的开发有 ERC 驱动的倾向，多重继承的副作用应该不会像在其他语言中那么严重。

鉴于 Solidity 是为区块链而生，以及区块链本身及应用场景的特性，通过事件与外部通信和遇到错误时回滚之前的操作可以说是「刚需」，所以在语法层面支持事件与错误相关处理。

require() 这个函数的用法对我来说也是有点特别的，require(initialValue > 999, "Initial supply must be greater than 999."); 就相当于以下 ES 代码的简明语义化版：

Copy
if (initialValue <= 999) {
throw new Error('Initial supply must be greater than 999.');
}

了解 ERC

在以太坊中，「ERC」的全称为「Ethereum Request for Comments」，是 EIP（Ethereum Improvement Proposal）的一个类型，定义了智能合约应用程序相关标准和约定。

由于 Web3 所推崇的是去中心化与开放性，保障智能合约应用程序的互操作性就成了基本要求，因此作为这方面标准的 ERC 就显得十分重要。

以太坊智能合约应用程序开发中最基本的 ERC 有以下两个：

ERC-20—— 同质化代币，作为类金融系统的基础设施，如虚拟货币、贡献积分；

ERC-721—— 非同质化代币（NFT），作为身份系统的基础设施，如勋章、证书、门票。

实际上，可把 ERC 看作是权威的 API 文档。

编写智能合约

开发智能合约应用程序时，需要选择一个框架来辅助，貌似用 Hardhat 和 Foundry 的比较多 —— 我选用前者，因为它对 JS 技术栈友好，即对从前端开发转型的人友好。

在 IDE 的选择上，很多人会去使用以太坊官方提供的 Remix，而我则继续使用 VS Code，主要是想在刚入门时尽量减少学习成本。

对 Hardhat 不了解的话，可按照官方教程选择性地一步步搭建运行环境，所生成的目录结构中除了 hardhat.config.ts 这个配置文件外，基本只需关注 4 个文件夹及其文件：

contracts—— 智能合约源码；

artifacts—— 通过 hardhat compile 生成的编译后文件；

ignition—— 基于 Hardhat Ignition 部署智能合约用的；

test—— 智能合约功能测试代码。

在 ignition 中也会生成编译后的文件，但与 artifacts 不同，是跟被部署的目标链绑定的，也就是生成到要部署的链 ID 的文件夹下。

作为训练营作业的那 3 个 task，都涉及到 ERC-20 代币、ERC-721 代币和 NFT 市场这 3 个合约，其中前两个代币合约可借助经过验证的 OpenZeppelin Contracts，以其为基础进行扩展。

我的 ERC-20 代币 RaiCoin 的实现代码如下：

Copy
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract RaiCoin is ERC20("RaiCoin", "RAIC") {
constructor(uint256 initialValue) {
require(initialValue > 999, "Initial supply must be greater than 999.");
_mint(msg.sender, initialValue * 10 ** 2);
}

function decimals() public view virtual override returns (uint8) {
return 2;
}
}

最好是在初始化时就 mint 一定量的代币（通常数目很大），并把拥有者设为自己的账户地址，否则在过后进行交易时会提示没有余额，处理起来更麻烦。

上面代码中的 msg.sender 在 constructor() 中时实际上是部署合约的账户地址，如果是用自己的账户地址部署，那初始代币就全进自己账户中了。

由于自己的 ERC-20 代币只是随便玩玩的性质，并不会增值，可以考虑覆盖 OpenZeppelin 中的 decimals() 而把数值设置小点。

下面是 ERC-721 代币 RaiE 的实现代码：

Copy
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract RaiE is ERC721 {
uint256 private _totalCount;

constructor() ERC721("RaiE", "RAIE") {
_totalCount++;
}

function mint() external returns (uint256) {
uint256 tokenId = _totalCount;

_safeMint(msg.sender, tokenId);

_totalCount++;

return tokenId;
}
}

我只额外实现了一个 mint()，且不带任何参数，只是单纯地发币，这是为什么呢？NFT 不是该有相应的图片吗？具体原因下文会说。

这两个代币合约算是白给的，自己无需写多少代码，真正需要思考的地方主要集中在 NFT 市场合约当中，比如 ——

市场中的 NFT 列表是否要分页？

分页的话，每次翻页时的延迟会比较明显，前端的用户体验不好；但不分页的话，NFT 数量多时也会有这种问题。

NFT 的图片 URL 该存哪里？是 NFT 合约还是市场合约中？

理论上该存进 NFT 合约，但若如此，获取 NFT 列表时就会频繁通过外部调用的方式访问 NFT 合约，影响性能与用户体验。

应该在 NFT 合约中维护一个「谁拥有哪些代币」的可被外部获取的列表吗？

若要有，数据与市场合约中相比是冗余的，会显得 NFT 合约很是臃肿；若没有，就无法显性地知道都有哪些代币，分别属于谁。

可以看出，仅依赖区块链相关技术去做一个产品级的应用，就目前而言是有很大局限性的，用户体验会很差！

也就是说，产品的性能和体验还是得靠以往的应用架构去支撑，区块链仅作为身份验证及部分数据的「备份」用。

因此，我暂时放弃了以做产品为导向的思维方式，不去纠结哪里是否合理之类的事情，转变为先满足作业要求为主 —— 只要有相关功能就行。

这样一来，决策就很容易做了 —— 怎样能更快地完成作业就怎么来！于是，上面的 3 个疑惑很快就消除了：

市场中的 NFT 列表不进行分页 —— 只会有不几个 NFT；

NFT 的图片 URL 存在市场合约中 ——NFT 合约只被自己的市场合约使用；

NFT 合约中不维护代币归属的列表 —— 临时操作时能记住是哪个账户 mint 了哪个代币。

在实现 NFT 市场 RaiGallery 时我发现，只有数组是可被遍历的，mapping 不行，并且初始化时指定长度的数组不能用 .push() 添加元素，只能用索引：

Copy
contract RaiGallery {
struct NftItem { address seller; address nftContract; uint tokenId; string tokenUrl; uint256 price; uint256 listedAt; bool listing; }

struct NftIndex { address nftContract; uint tokenId; }

NftIndex[] private _allNfts;

function getAll() external view returns (NftItem[] memory) {
// 初始化时指定了数组长度
NftItem[] memory allItem = new NftItem[](_allNfts.length);
NftIndex memory nftIdx;

for (uint256 i = 0; i < _allNfts.length; i++) {
nftIdx = _allNfts[i];
// 这里用 `allItem.push(_listedNfts[nftIdx.nftContract][nftIdx.tokenId])` 的话会报错
allItem[i] = _listedNfts[nftIdx.nftContract][nftIdx.tokenId];
}

return allItem;
}
}

调试智能合约

写完智能合约源码，就得先写测试代码过一遍，把一些基础的问题暴露出来并解决掉。

如上文所述，在 Hardhat 项目中测试代码是放在 test 文件夹下的，基本是每个文件对应一个合约，当然也可将不同文件间的可复用逻辑提取出来放到额外的文件中，如 helper.ts。

测试代码是基于 Mocha 和 Chai 的 API 去写，在真正开始测试合约功能之前，需要先部署合约到本地环境中，可以是内置的 hardhat，也可启动一个本地节点 localhost，我暂且选择前者。

这时，部署的方式能够复用 Hardhat Ignition 模块，但我还没搞懂它是怎么用的，就采用更容易理解的 loadFixture()。

搞测试还挺费劲的，感觉差不多一天的时间都耗进去了，但在这个过程中我对 ERC-20 代币、ERC-721 代币、NFT 市场及用户这四方之间该如何交互有了更深的了解，如：

直接用合约实例去调方法的话，那调用者就是合约本身，得用 合约实例.connect(某个账户) 后再去调用才能模拟与用户间的操作；

NFT 的拥有者得通过 .setApprovalForAll(市场合约地址, true) 把自己的全部 NFT 授权给 NFT 市场后才能在市场中上架出售。

觉得智能合约的单方测试差不多了，就该部署到本地节点与前端进行联调了，这回要用到 Hardhat Ignition 模块了。

在去看文档学习时，感觉有点晦涩难懂，看着看着就想睡觉的那种；但现在再回过头看，每个模块实际上就是在描述部署该模块对应的合约时该如何初始化。

Hardhat Ignition 支持子模块，通过 .useModule() 使用，能够在编译并部署模块时把子模块一同处理了，也就是说 ——

假设我有 RaiCoin.ts、 RaiE.ts 和 RaiGallery.ts 三个模块，其中 RaiGallery.ts 在部署时需要 RaiCoin.ts 部署后返回的地址，那就可将 RaiCoin.ts 作为 RaiGallery.ts 的子模块：

Copy
import { buildModule } from '@nomicfoundation/hardhat-ignition/modules';
import RaiCoin from './RaiCoin';

export default buildModule('RaiGallery', m => {
const { coin } = m.useModule(RaiCoin);
const gallery = m.contract('RaiGallery', [coin]);

return { gallery };
});

这样的话，RaiE.ts 是单独部署，而在部署 RaiGallery.ts 时会级联部署 RaiCoin.ts，所以只执行两次部署命令即可。

接着，把 hardhat.config.ts 中的 defaultNetwork 配置项改为 'localhost'，在 Hardhat 项目根目录下执行 npx hardhat node 启动本地节点，再开启一个终端窗口部署智能合约：

执行 npx hardhat ignition deploy ./ignition/modules/RaiE.ts 部署 ERC-721 代币合约；

执行 npx hardhat ignition deploy ./ignition/modules/RaiGallery.ts 部署 ERC-20 代币合约和 NFT 市场合约。

全部部署成功后，会在 ignition/deployments/chain-31337 文件夹（「31337」是本地节点的链 ID）中生成编译后的合约相关文件：

deployed_addresses.json 中罗列了合约地址；

artifacts 文件夹下的 JSON 文件中包含了合约的 ABI。

上述两项关键信息需要复制粘贴到前端项目的全局共用变量中，以供联调时使用。

在开始联调之前，需在 MetaMask 钱包中做两件事：

将 Hardhat 本地节点添加进网络，可参考油管视频《Metamask 添加本地测试网络》；

按官网所示把自己正在测的 ERC-20 代币合约地址加上去以方便查看账户余额。

我在前端部分所依赖的第三方库和框架主要有 Vite、React、Ant Design Web3 和 Wagmi；由于前端是我所熟悉的，没啥心得体会，就不多赘述了。

但是，在开发前端部分时，有一个点让我纠结了一段时间 ——

虽说程序上是要先 mint 出一个新的 NFT 才能上架到市场进行交易，但在界面上的体现应该是一步到位的，即填完 NFT 相关信息点「确定」后就直接上架了。

而作业的要求是先 mint 后上架的两步操作，这让我觉得有点不合理，或者说用户体验不好。

最终还是因自己对 Wagmi 使用不熟而实在没想出实现方案，且急于交作业，就没再继续纠结下去……😂😂😂

联调时若遇到问题卡住，可按下面步骤依次排查：

上架出售 NFT 时需先调用 NFT 代币合约的 setApprovalForAll 对市场合约进行授权，以托管市场代为转移 NFT；

发送上架出售请求之前需用 viem 或 ethers 的 parseUnits 转换为符合自己 ERC-20 代币合约中定义的 decimals() 的数（默认是 18）；

购买 NFT 前在钱包中检查下当前账户自己自定义的 ERC-20 代币余额够不够，避免将以太币（ETH）看作是自己 ERC-20 代币的余额；

购买 NFT 时需先调用自己 ERC-20 代币合约的 approve 对市场合约进行授权，以托管市场代为转账。

联调也结束了，终于，到了最后一个环节 —— 部署到 Sepolia 测试网！

这需要有 Sepolia 的以太币，一般的获取方式是到那些「水龙头」一滴一滴地接，每天只能弄一丁点儿，多亏 @Mika-Lahtinen 提供了一种 PoW 的方式，详见 @zer0fire 的笔记《🚀极简拧水龙头教程 - 无需交易记录或账户余额》。

此时，将目光移回到 Hardhat 项目中，打开 hardhat.config.ts 文件，将 defaultNetwork 临时改为 'sepolia'，并在 networks 中添加一个 sepolia：

Copy
const config: HardhatUserConfig = {
defaultNetwork: 'sepolia', // 默认网络临时改成这个
networks: {
sepolia: {
url: '你的 Sepolia endpoint URL',
accounts: ['你的钱包账户私钥'],
},
},
};

其中，Sepolia endpoint 可通过注册 Infura 或 Alchemy 账号获得。

然后，按照上文中部署到本地节点的流程再走一遍，在前端把测试网环境的功能验证通过后就可以提交作业啦啦啦啦啦！

结语

我把 NFT market 这个 dApp 相关的代码全部在 ourai/my-first-nft-market 中开源了，打算日后把上文谈及所纠结的点尽量都解决掉，并打造成这类 demo 的标杆。

由于里面已经配置了 Sepolia 合约地址，可直接本地运行操作，欢迎参考，探讨和指点。