头图

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

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

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

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

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

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

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

学习 Solidity

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

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

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

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

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

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

privatepublic 的语义跟其他语言是一样的,但没有 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.senderconstructor() 中时实际上是部署合约的账户地址,如果是用自己的账户地址部署,那初始代币就全进自己账户中了。

由于自己的 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.tsRaiE.tsRaiGallery.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。

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

在开始联调之前,需将 Hardhat 本地节点添加到 MetaMask 钱包中,可参考油管视频《Metamask 添加本地测试网络》。

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

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

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

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

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

联调也结束了,终于,到了最后一个环节——部署到 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 可通过注册 InfuraAlchemy 账号获得。

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

结语

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

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


欧雷
124 声望15 粉丝

致力于前端标准化、工业化,让前端开发更加有序且可快速装配,为新型开发方式及协作模式做支撑。