作为一个持续看衰传统互联网行业 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 代码的简明语义化版:
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 的实现代码如下:
// 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 的实现代码:
// 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()
添加元素,只能用索引:
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
的子模块:
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
:
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 合约地址,可直接本地运行操作,欢迎参考,探讨和指点。