当前位置: 首页 > news >正文

快速掌握Hardhat与Solidity智能合约开发

一、引言

在当今数字化时代,区块链技术以其去中心化、不可篡改、分布式等特性,正逐渐改变着我们的生活和工作方式。作为区块链技术的核心应用之一,智能合约在金融、供应链、医疗等众多领域展现出了巨大的潜力。

智能合约是一种基于区块链技术的自动化合约,它以代码的形式定义了合约的规则和条件,当这些条件被满足时,合约会自动执行,无需第三方干预。这种特性使得智能合约具有高效、透明、安全等优势,能够有效降低交易成本,提高交易效率,增强交易的可信度。

在智能合约的开发中,Solidity 是目前最为广泛使用的编程语言之一。它是一种面向合约的高级语言,语法类似于 JavaScript,易于学习和掌握。Solidity 专门为以太坊虚拟机(EVM)设计,能够充分发挥以太坊区块链的功能,实现各种复杂的智能合约逻辑。

而 Hardhat 则是一个功能强大的以太坊智能合约开发环境,它提供了一系列工具和功能,帮助开发者更高效地进行智能合约的开发、测试和部署。Hardhat 支持 Solidity 语言,并且集成了编译器、调试器、测试框架等,使得开发者可以在一个统一的环境中完成智能合约开发的整个流程。

本文将详细介绍如何基于 Hardhat 编写 Solidity 智能合约,从环境搭建开始,逐步深入到智能合约的编写、测试、部署以及与前端的交互,帮助读者快速掌握基于 Hardhat 的智能合约开发技术。

二、开发前准备

2.1 安装 Node.js 和 NPM

要使用 Hardhat 3,需要在系统上安装 Node.js v22 或更高版本,以及 npm 或 pnpm 等包管理器。

Node.js 是一款基于 Chrome V8 引擎构建的 JavaScript 运行环境。许多工具和框架依赖于 Node.js 来运行,例如 Hardhat,它是以太坊网络的开发环境,用于构建智能合约和去中心化应用,而 Hardhat 是基于 Node.js 开发的,只有安装了 Node.js ,才能运行 Hardhat 并使用其功能 。Node.js 能够使开发者在服务器端执行 JavaScript 代码,突破了 JavaScript 仅能在浏览器环境运行的局限,从而实现更为广泛的应用场景。NPM(Node Package Manager)作为 Node.js 的包管理器,主要负责 JavaScript 模块的安装、管理以及共享操作。它是 Node.js 生态系统的关键构成部分,为开发者提供了丰富多样的工具与库资源,极大地提升了开发效率。

若需安装 Node.js 和 NPM,可访问 Node.js 官方网站(https://nodejs.org/),于下载页面中根据自身操作系统选择适配的安装包进行下载操作。下载完成后,运行安装程序,并依据安装提示完成整个安装流程。在安装过程中,建议勾选 “Add to PATH” 选项,以便 Node.js 和 NPM 能够在命令行中实现全局访问。

安装完成后,打开命令提示符或终端,输入以下命令以验证安装是否成功:

node -v
npm -v

若安装成功,上述两个命令将分别输出版本号,例如 “v22.15.1” 和 “10.9.2”。

2.2 开发工具推荐

推荐使用 Visual Studio Code(VS Code)作为智能合约开发的代码编辑器。VS Code 是一款轻量级但功能强大的跨平台代码编辑器,拥有丰富的插件生态系统,能够满足各种开发需求。

要在 VS Code 中进行 Solidity 开发,需要安装 Solidity 插件。打开 VS Code,点击左侧的插件图标(Extensions),在搜索框中输入 “Solidity”,找到 “Solidity” 插件并点击安装。安装完成后,VS Code 就可以识别和编辑 Solidity 代码,提供语法高亮、代码提示、智能补全等功能,大大提高开发效率。

另外,还有一些其他的编辑器也支持 Solidity 开发,如 Atom、Sublime Text 等,你可以根据自己的使用习惯选择适合的编辑器。

三、Solidity 基础语法回顾

在开始基于 Hardhat 编写智能合约之前,先来回顾一下 Solidity 的基础语法,这将为后续的开发工作奠定坚实的基础。

3.1 数据类型

Solidity 是一种静态类型语言,这意味着变量的类型在编译时就已经确定。以下是一些常用的数据类型:

  • 布尔类型(bool):用于表示真或假,只有两个取值:truefalse。常用于条件判断,如if语句、while循环的条件表达式等。

  • 整型(int/uint)int表示有符号整数,uint表示无符号整数。它们可以指定不同的位数,如uint8表示 8 位无符号整数,取值范围是 0 到 255;int256表示 256 位有符号整数,取值范围是−2255-2^{255}22552255−12^{255}-122551。默认情况下,uintint分别是uint256int256。在智能合约中,整型常用于存储数量、金额、计数器等数值数据。

  • 地址类型(address):用于存储以太坊地址,长度为 20 字节。地址类型可以分为普通地址和可支付地址(address payable),可支付地址可以接收以太币(ETH),并且拥有balancetransfer()等成员,方便查询 ETH 余额以及转账。在涉及到账户操作、资金转移等场景时,会用到地址类型。

  • 字符串类型(string):用于存储文本数据。但需要注意的是,对string类型的操作相对复杂且消耗较多的 Gas,所以在实际应用中,应尽量避免频繁对string进行操作。如果需要存储较短的文本,可以考虑使用定长字节数组(如bytes32)来代替,以减少 Gas 消耗。

  • 字节数组(bytes/bytes32 等)bytes表示可变长度的字节数组,bytes32表示固定长度为 32 字节的字节数组。字节数组常用于存储二进制数据、哈希值等。其中,定长字节数组属于数值类型,不定长字节数组是引用类型。定长字节数组可以存储一些数据,消耗的 Gas 相对较少。

  • 枚举类型(enum):允许开发者定义一个自定义类型,该类型由一组命名常量组成。例如,可以定义一个枚举类型来表示不同的状态:

enum Status { Pending, Approved, Rejected }

在上述代码中,定义了一个名为Status的枚举类型,它包含三个常量:PendingApprovedRejected。枚举类型在需要表示有限个离散值的场景中非常有用,比如订单状态、交易状态等。

3.2 变量和常量

  • 变量:在 Solidity 中有三种主要类型的变量,分别是局部变量、状态变量和全局变量。

    • 局部变量:在函数内部声明的变量,作用域仅限于函数内部,当函数执行结束后,局部变量会被销毁。例如:

      function calculate() public {uint localVar = 10;// 这里可以使用localVar进行计算
      }
      

      在上述代码中,localVar就是一个局部变量,它只能在calculate函数内部被访问和使用。

    • 状态变量:在合约级别声明的变量,其生命周期与合约相同,并且在整个合约中都是可访问的。状态变量存储在区块链的存储中,这意味着它们的状态在事务之间也会持久化。例如:

      contract MyContract {uint stateVar;function setVar(uint value) public {stateVar = value;}function getVar() public view returns (uint) {return stateVar;}
      }
      

      在这个例子中,stateVar是一个状态变量,setVar函数用于设置它的值,getVar函数用于获取它的值。由于状态变量存储在区块链上,对其进行读写操作会消耗 Gas,因此在设计合约时,应尽量减少不必要的状态变量使用。

    • 全局变量:在所有合约中都是可访问的,它们通常用于获取有关区块链本身或特定交易的信息。虽然它们被称为 “全局” 变量,但实际上它们是一组预定义的变量,不能被更改,只能用于读取。例如,block.number表示当前区块的编号,msg.sender表示当前函数调用的发送者地址,now表示当前区块的时间戳(等同于block.timestamp)等。下面是一个使用全局变量的示例:

      function showInfo() public view returns (address, uint) {return (msg.sender, now);
      }
      

      在上述代码中,通过msg.sender获取当前函数调用的发送者地址,通过now获取当前区块的时间戳。

  • 常量:在 Solidity 中有两种方式可以声明不变的值,即常量(constant)和不可变变量(immutable)。

    • 常量(constant):使用constant关键字声明,其值在编译时就被确定,且在整个合约生命周期中不会改变。常量不会占用存储空间,每次访问常量时,编译器会将常量的值直接内联到代码中。常量只能是值类型,例如uintaddressstring字面量等,不能是引用类型,例如数组、结构体或映射。例如:

      uint constant MAX_SUPPLY = 1000000;
      string constant GREETING = "Hello, world!";
      

      在上述代码中,MAX_SUPPLYGREETING都是常量,它们的值在编译时就已经确定,并且在合约运行过程中不能被修改。

    • 不可变变量(immutable):使用immutable关键字声明,这种类型的变量在创建合约时可以被赋值一次,此后其值在合约生命周期内不可更改。与constant不同,immutable变量的值可以在构造函数中进行计算并在运行时赋值,它们保存在合约的代码中,不占用存储空间。例如:

      address immutable owner;constructor() {owner = msg.sender;
      }
      

      在这个例子中,owner是一个不可变变量,它在构造函数中被赋值为合约创建者的地址,并且在合约的整个生命周期内都不能被修改。如果在合约部署后尝试修改owner的值,会导致编译错误。

3.3 函数

函数是 Solidity 合约中执行特定任务的代码块,它可以接受参数,并且可以返回值。函数在智能合约中起着核心作用,用于实现合约的逻辑。

  • 函数定义:函数的基本定义语法如下:
function [functionName]([parameterList]) [visibilityModifier] [stateMutability] returns ([returnType]) {// 函数体
}

其中:

  • functionName是函数的名称,用于标识函数,应遵循命名规范,具有描述性,以便于理解函数的功能。

  • parameterList是参数列表,用于指定函数接受的参数,每个参数都需要指定类型和名称,多个参数之间用逗号分隔。例如:(uint x, uint y)表示函数接受两个uint类型的参数,分别名为xy

  • visibilityModifier是函数可见性修饰符,有四种类型:publicprivateinternalexternal

    • public:函数可以在任何地方被调用,包括合约内部和外部。当状态变量被声明为public时,会自动生成一个getter函数,用于外部查询该变量的值。

    • private:函数只能在当前合约中被调用,继承的合约也不能访问。常用于封装一些内部逻辑,防止外部直接调用,提高合约的安全性和封装性。

    • internal:函数可以在当前合约和继承的合约中被调用。与private不同,internal函数可以被继承的合约访问,适合用于定义一些通用的内部逻辑,供子类复用。

    • external:函数只能从合约外部被调用,在合约内部可以使用this.functionName()的方式调用。通常用于定义一些外部接口,供其他合约或外部应用调用。

  • stateMutability是函数状态修饰符,有四种类型:pureviewpayablenonpayable(默认)。

    • pure:表示函数不会读取也不会修改状态,即不会读取或写入合约的状态变量,也不会调用任何非pure的函数。这样的函数只依赖于其输入参数,并返回一个值,对于相同的输入,pure函数总是返回相同的结果。例如,一个简单的加法函数可以定义为pure

      function add(uint x, uint y) public pure returns (uint) {return x + y;
      }
      
    • view:表示函数可以读取但不能修改状态,即可以读取合约的状态变量,但不能修改它们,也不能调用任何修改状态的函数。通常用于返回合约的状态变量或计算基于状态变量的结果。例如:

      contract MyContract {uint stateVar;function getVar() public view returns (uint) {return stateVar;}
      }
      

      在上述代码中,getVar函数用于获取状态变量stateVar的值,它不会修改状态,因此可以声明为view

    • payable:表示函数可以接收以太币(ETH),并且可以修改状态。在以太坊中,当一个函数被标记为payable时,它可以在调用时附带 ETH。例如,一个简单的捐赠函数可以定义为payable

      contract Donation {uint totalDonation;event Received(address sender, uint256 amount);function donate() public payable {totalDonation += msg.value;emit Received(msg.sender, msg.value);}
      }
      

      在这个例子中,donate函数可以接收捐赠的 ETH,msg.value表示本次调用发送的 ETH 数量,将其累加到totalDonation状态变量中,并通过事件Received记录捐赠信息。

    • nonpayable:表示函数不能接收 ETH,但可以修改状态,这是函数的默认状态,如果没有指定其他修饰符,函数就是nonpayable的。

    • returns (returnType)用于指定函数的返回值类型,如果函数有返回值,需要在这里声明返回值的类型,也可以返回多个值,用逗号分隔。例如:returns (uint, string)表示函数返回一个uint类型和一个string类型的值。如果函数没有返回值,可以省略returns关键字。

  • 参数传递:函数参数可以是值类型(如uintbool等),也可以是引用类型(如数组、结构体等)。值类型参数传递时,会复制参数的值,函数内部对参数的修改不会影响到外部变量。引用类型参数传递时,传递的是参数的引用(类似指针),函数内部对参数的修改会影响到外部变量。例如:

    function modifyValue(uint num) public pure {num = num + 1; // 这里修改的是num的副本,不会影响外部变量
    }function modifyArray(uint[] storage arr) public {arr[0] = arr[0] + 1; // 这里修改的是数组的第一个元素,会影响到外部数组
    }
    

    在上述代码中,modifyValue函数接收一个uint类型的值类型参数num,在函数内部对num的修改不会影响到外部传入的变量。而modifyArray函数接收一个uint类型数组的引用类型参数arr,在函数内部对arr的修改会影响到外部的数组。

  • 返回值:函数可以返回一个或多个值。返回值在函数声明中用returns关键字指定。有两种声明返回值的方式:

    • 未命名返回值:类似于大多数编程语言,需要使用return语句显式返回值。例如:

      function getValue() public pure returns (uint256) {return 100;
      }
      
    • 命名返回值:在函数声明中为返回变量命名,函数执行完毕后会自动返回这些变量的值,不需要显式的return语句。例如:

      function getValues() public pure returns (uint256 a, uint256 b) {a = 100;b = 200;
      }
      

      在这种情况下,ab是命名返回值,它们会在函数执行完成后自动返回。命名返回值会被初始化为默认值(数值类型为 0,布尔类型为false,地址类型为address(0)等)。使用命名返回值可以提高代码的可读性,特别是当函数返回多个值时。

  • 函数修饰符:修饰符用于定义函数的访问控制和执行条件等。常见的修饰符有onlyOwnerrequire等。

    • onlyOwner:通常用于限制某个函数只能由合约的拥有者调用。可以通过如下方式定义和使用onlyOwner修饰符:

      contract Ownable {address owner;constructor() {owner = msg.sender;}modifier onlyOwner() {require(msg.sender == owner, "Only owner can call this function");_;}function someFunction() public onlyOwner {// 只有合约拥有者可以执行这里的代码}
      }
      

      在上述代码中,首先在合约Ownable中定义了一个状态变量owner,并在构造函数中将其初始化为合约创建者的地址。然后定义了一个onlyOwner修饰符,在修饰符内部使用require语句判断当前调用者是否为合约拥有者,如果不是,则抛出异常并显示错误信息。最后在someFunction函数上使用onlyOwner修饰符,这样只有合约拥有者才能调用someFunction函数。

  • require:用于验证函数的执行条件,确保合约的安全性。require的语法为require(condition, "error message"),如果conditionfalse,则抛出异常并显示error message,同时撤销当前交易。例如:

    function divide(uint a, uint b) public pure returns (uint) {require(b != 0, "Division by zero is not allowed");return a / b;
    }
    

    在这个例子中,使用require语句确保除数b不为 0,如果b为 0,则抛出异常并显示错误信息 “Division by zero is not allowed”,交易将被撤销。通过使用require修饰符,可以有效地防止一些错误操作,提高合约的健壮性。

四、在Hardhat中创建Solidity项目

4.1 初始化Hardhat项目

在完成环境搭建并熟悉Solidity基础语法后,接下来就可以在Hardhat中创建Solidity项目了。初始化Hardhat项目是整个开发流程的第一步,它将为我们生成一个基本的项目结构,包含一些必要的文件和文件夹,方便我们后续进行智能合约的开发、测试和部署。

首先,打开命令行工具(如 PowerShell、msys2、Git Bash 等),为项目创建一个新目录:

mkdir hardhat-example
cd hardhat-example

执行以下命令初始化Hardhat项目:

npx hardhat --init

初始化命令会提示选择 Hardhat 3.0 版本还是 Hardhat 2.0 版本。

下面的过程以 Hardhat 2.0 版本为例进行讲解。

执行该命令后,会弹出一系列选项供你选择,这些选项决定了项目的初始配置:

? What type of project would you like to initialize? ...
> A Javascript project using Mocha and Ethers.jsA Javascript project using Mocha and Ethers.js (ESM)A Typescript project using Mocha and Ethers.jsA Typescript project using Mocha and ViemAn empty config file (hardhat.config.js)
  • An empty config file (hardhat.config.js):选择此选项将创建一个空的hardhat.config.js配置文件。如果你对 Hardhat 的配置非常熟悉,想要完全自定义项目配置,那么这个选项比较适合你。你可以根据自己的需求在这个空文件中添加各种配置,如设置 Solidity 编译器版本、配置网络连接、添加插件等。

  • A Typescript project using Mocha and Viem:如果你熟悉 TypeScript,并且希望在项目中使用 TypeScript 进行开发,那么可以选择此选项。TypeScript 是 JavaScript 的超集,它提供了更严格的类型检查和代码提示功能,有助于提高代码的质量和可维护性。选择这个选项后,Hardhat 会为你生成一个基于 TypeScript 的项目结构,包括hardhat.config.ts配置文件、contracts目录下的 TypeScript 合约文件模板、test目录下的 TypeScript 测试文件模板以及ignition目录下的 TypeScript 部署脚本模板等。

  • A Javascript project using Mocha and Ethers.js:这是最常用的选项,它会创建一个基于 JavaScript 的 Hardhat 项目。大多数开发者在初次接触 Hardhat 时会选择这个选项,因为 JavaScript 是一种广泛使用的编程语言,学习曲线相对较低。创建的项目中,hardhat.config.js配置文件、合约文件、测试文件和部署脚本文件都是以 JavaScript 编写的,方便开发者快速上手和进行开发。

在选择选项时,需要根据自己的实际情况和需求进行判断。如果你是初学者,建议选择 “Create a basic sample project” 或 “Create a JavaScript project”,这样可以快速开始开发,并且有示例代码可供参考。在后续的开发过程中,你可以根据项目的实际需求对项目配置和代码进行修改和扩展。

选择完选项后,按照提示完成初始化过程。初始化完成后,你的项目目录下会生成一系列文件和文件夹,这些文件和文件夹构成了 Hardhat 项目的基本结构,每个文件和文件夹都有其特定的作用。

4.2 项目目录结构介绍

初始化完成后,让我们来详细了解一下 Hardhat 项目的目录结构,以及各个目录和文件的作用:

  • contracts 目录:该目录用于存放 Solidity 智能合约文件,是项目中存放智能合约代码的核心目录。我们编写的所有 Solidity 合约都应放置在此目录下。Hardhat 在执行编译、测试、部署等操作时,会默认读取该目录下的合约文件。例如,我们创建的SimpleStorage.sol合约文件就存放在这个目录中,它定义了一个简单的存储合约,包含一个uint类型的状态变量和用于设置、获取该变量值的函数。如果需要更改合约存放目录,可以在hardhat.config.js配置文件中进行设置,具体方法可以参考 Hardhat 官方文档。
  • ignition 目录:主要用于存放 Hardhat Ignition 部署模块,专门用于智能合约的部署和管理。它允许开发者通过声明式的方式定义部署流程,将复杂的部署过程(如依赖关系、参数配置和分步执行)编写为可重复使用的部署脚本。Ignition 会自动处理事务顺序、依赖解析和部署状态跟踪,支持多链部署和模块化设计,显著简化了以太坊智能合约的部署工作流,尤其适合需要频繁更新或复杂部署逻辑的项目。
  • scripts 目录:主要用于存放部署脚本文件,这些脚本文件用于将智能合约部署到区块链网络上。部署脚本通常使用 JavaScript 或 TypeScript 编写,通过调用 Hardhat 提供的 API 来实现合约的部署操作。例如,在scripts目录下的deploy.js文件中,我们可以编写代码来获取合约工厂对象,然后使用该对象部署合约,并获取合约的地址。在部署过程中,还可以设置一些参数,如部署者的账户、合约的构造函数参数等。除了部署合约,脚本文件还可以用于执行其他与部署相关的操作,如初始化合约状态、调用合约函数等。如果需要更改脚本存放目录,同样可以在hardhat.config.js配置文件中进行修改。
  • test 目录:用于存放智能合约的测试脚本文件,通过编写测试脚本来验证智能合约的功能是否正确。测试脚本可以使用多种测试框架,如 Mocha、Chai 等,Hardhat 对这些测试框架都有很好的支持。在测试脚本中,可以编写各种测试用例,对合约的不同功能进行测试,包括调用合约函数、验证函数返回值、检查事件触发等。例如,对于SimpleStorage.sol合约,我们可以在测试脚本中编写测试用例来验证set函数是否能够正确设置状态变量的值,以及get函数是否能够正确返回状态变量的值。通过运行测试脚本,可以及时发现合约中的问题,确保合约的质量和可靠性。如果需要更改测试脚本存放目录,可以在hardhat.config.js配置文件中进行调整。
  • hardhat.config.js(或 hardhat.config.ts,取决于项目类型):这是 Hardhat 项目的核心配置文件,用于配置项目的各种参数和选项。在这个文件中,可以设置 Solidity 编译器的版本,指定要使用的区块链网络(如本地测试网络、以太坊主网、测试网等),配置网络连接参数(如 RPC URL、账户私钥等),添加和配置插件等。例如,通过在hardhat.config.js中配置solidity字段,可以指定使用的 Solidity 编译器版本,确保合约能够在正确的编译器环境下进行编译。通过配置networks字段,可以设置不同网络的连接参数,以便在不同的网络上进行合约的部署和测试。此外,还可以在这个文件中定义一些自定义任务,方便在开发过程中执行特定的操作。这个配置文件对于项目的正常运行和功能实现至关重要,需要根据项目的具体需求进行合理配置。
  • package.json:这是一个标准的 npm 包描述文件,用于管理项目的依赖关系和脚本命令。在项目初始化时,Hardhat 会自动生成这个文件,并在其中添加一些初始的依赖项和脚本命令。通过package.json,我们可以使用npm install命令安装项目所需的依赖包,这些依赖包会被下载到node_modules目录中。例如,Hardhat 本身以及一些相关的插件(如@nomicfoundation/hardhat-toolbox)都会作为依赖项记录在package.json中。此外,package.json还可以定义一些自定义脚本命令,方便在命令行中执行一些常用的操作,如编译合约、运行测试、部署合约等。例如,可以在scripts字段中添加"compile": "npx hardhat compile",这样在命令行中执行npm run compile就相当于执行npx hardhat compile命令。
  • artifacts 目录:当我们使用 Hardhat 编译智能合约时,编译后的结果会生成在这个目录中。该目录包含编译后的合约二进制文件(bytecode)、应用程序二进制接口(ABI)文件以及其他一些与编译相关的元数据。ABI 文件非常重要,它定义了合约的接口,包括合约中所有函数的签名、参数类型和返回值类型等信息。通过 ABI,外部应用程序(如前端应用)可以与智能合约进行交互,调用合约的函数。例如,在前端应用中,我们可以使用 Web3 库结合合约的 ABI 和地址,来调用合约的函数,实现与智能合约的通信。此外,artifacts目录中的编译结果还可以用于部署合约时的验证和调试。
  • cache 目录:主要用于存储编译过程中的缓存信息,以提高编译效率。Hardhat 在编译合约时,会将一些中间结果和编译信息缓存到这个目录中。当下次编译时,如果合约文件没有发生变化,Hardhat 可以直接使用缓存中的信息,避免重复编译,从而加快编译速度。例如,合约的编译依赖项、编译选项等信息都会被缓存起来。这个目录对于大型项目或者频繁进行编译操作的项目来说,能够显著提高开发效率。在某些情况下,如果编译出现问题,也可以尝试删除cache目录中的内容,然后重新编译,以解决可能由于缓存导致的问题。
  • node_modules 目录:该目录用于存放项目的所有依赖包,当我们使用npm install命令安装依赖时,这些依赖包就会被下载到这个目录中。例如,Hardhat 及其相关插件、测试框架、Web3 库等依赖包都会存储在这里。node_modules目录中的文件和文件夹结构比较复杂,包含了各个依赖包的源代码、文档以及其他相关资源。在项目开发过程中,一般不需要直接修改node_modules目录中的内容,而是通过package.json文件来管理依赖关系。如果项目中某个依赖包出现问题,可以通过更新package.json中的依赖版本,然后重新执行npm install命令来解决。
  • .gitignore 文件:用于指定哪些文件和文件夹不需要被 Git 版本控制系统跟踪。在 Hardhat 项目中,通常会将node_modules目录、artifacts目录、cache目录等添加到.gitignore文件中,因为这些目录中的文件大多是自动生成的或者是依赖包,不需要进行版本控制。此外,还可以根据项目的具体需求,将一些敏感信息文件(如包含私钥的配置文件)或者临时文件添加到.gitignore文件中,以确保这些文件不会被误提交到代码仓库中。例如,在开发过程中,我们可能会在项目根目录下创建一个.env文件来存储一些环境变量,如私钥、API 密钥等,为了避免这些敏感信息被提交到代码仓库,就可以将.env文件添加到.gitignore文件中。通过合理配置.gitignore文件,可以使 Git 版本控制系统更加高效地管理项目代码。

五、编写 Solidity 智能合约

5.1 简单合约示例 - 计数器合约

接下来,让我们通过一个简单的计数器合约来深入了解 Solidity 智能合约的编写。计数器合约是一个基础的智能合约示例,它实现了一个简单的计数功能,通过调用合约的函数,可以对计数器进行增加操作,并获取当前的计数值。这个示例有助于我们熟悉 Solidity 的基本语法和合约结构,为编写更复杂的智能合约打下基础。

contracts目录下创建一个名为Counter.sol的文件,输入以下代码:

// SPDX-License-Identifier: MITpragma solidity ^0.8.0;contract Counter {// 定义一个状态变量,用于存储计数值,初始值为0uint private count; // 构造函数,在合约部署时执行,初始化count为0constructor() {count = 0;}// 增加计数器的函数function increment() public {count++;}// 获取当前计数值的函数,view修饰符表示该函数不会修改状态,只用于读取数据function getCount() public view returns (uint) {return count;}
}

下面对上述代码进行详细解释:

  • SPDX-License-Identifier:这是一个 SPDX(Software Package Data Exchange)许可证标识符,用于指定合约的许可证类型。在这个例子中,MIT许可证是一种宽松的开源许可证,允许其他人自由使用、修改和分发代码,只要保留版权声明。它有助于明确代码的使用权限和法律责任,在开源项目中被广泛应用。

  • pragma solidity ^0.8.0pragma指令用于指定 Solidity 编译器的版本。这里^0.8.0表示使用 0.8.0 及以上版本的编译器,但版本号小于 0.9.0。这种版本指定方式可以确保合约在不同版本的编译器中保持兼容性,同时也能利用新版本编译器的特性和优化。如果使用的编译器版本不符合要求,可能会导致编译错误或合约行为异常。

  • contract Counter:定义了一个名为Counter的合约,合约是 Solidity 中代码和数据的集合,是智能合约的基本单元。每个合约都有自己的状态变量和函数,用于实现特定的业务逻辑。在以太坊区块链上,合约以字节码的形式部署,并在以太坊虚拟机(EVM)中运行。

  • uint private count:声明了一个uint类型(无符号整数)的状态变量count,并使用private关键字修饰。private表示该变量只能在当前合约内部访问,外部合约无法直接访问,这有助于保护合约的内部状态,提高合约的安全性和封装性。状态变量的值会永久存储在区块链上,即使合约调用结束后也不会丢失。

  • constructor():这是合约的构造函数,在合约部署时会自动执行一次。构造函数通常用于初始化合约的状态变量,在这个例子中,将count初始化为 0。构造函数的名称必须与合约名称相同,并且不能有返回值。在部署合约时,可以向构造函数传递参数,以便根据不同的参数值初始化合约状态。

  • function increment() public:定义了一个名为increment的公共函数,用于增加计数器的值。public修饰符表示该函数可以被外部合约和用户调用。在函数体中,使用count++语句将count的值增加 1。每次调用这个函数,count的值都会在原来的基础上增加 1。

  • function getCount() public view returns (uint):定义了一个名为getCount的公共函数,用于获取当前的计数值。view修饰符表示该函数不会修改合约的状态,只用于读取数据,这样的函数在调用时不会消耗 Gas(以太坊的交易费用)。函数返回类型为uint,即返回当前的计数值。当外部合约或用户调用这个函数时,会返回当前count的值。

这个计数器合约实现了一个简单的计数功能,通过increment函数可以增加计数值,通过getCount函数可以获取当前计数值。它展示了 Solidity 合约的基本结构和常用语法,包括状态变量的声明、构造函数的使用、函数的定义以及函数修饰符的应用等。在实际应用中,我们可以根据具体需求对这个合约进行扩展和修改,例如添加更多的功能函数、增加权限控制等。

5.2 复杂合约示例 - ERC20 代币合约

在区块链的应用中,代币是一种常见的数字资产,而 ERC20 是以太坊上最常用的代币标准之一。接下来,我们将借助 OpenZeppelin 库来编写一个 ERC20 代币合约,深入了解复杂智能合约的编写和实现。OpenZeppelin 是一个开源的智能合约库,提供了许多经过审计和安全验证的合约模板和工具,能够帮助我们快速、安全地开发智能合约,减少重复劳动和潜在的安全风险。

首先,需要安装 OpenZeppelin 库。在项目根目录下的命令行中运行以下命令:

npm install @openzeppelin/contracts

安装完成后,在contracts目录下创建一个名为MyToken.sol的文件,编写如下代码:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;// 引入OpenZeppelin的ERC20合约
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";// MyToken合约继承自ERC20合约
contract MyToken is ERC20 {// 构造函数,用于初始化代币名称、符号和初始供应量constructor(uint256 initialSupply) ERC20("MyToken", "MTK") {// _mint函数用于铸造代币,将初始供应量的代币铸造给合约部署者_mint(msg.sender, initialSupply);}
}

下面对上述代码进行详细解析:

  • import "@openzeppelin/contracts/token/ERC20/ERC20.sol":这行代码用于引入 OpenZeppelin 库中的 ERC20 合约。通过import语句,我们可以在当前合约中使用ERC20.sol中定义的功能和接口,避免从头开始编写 ERC20 代币合约的所有代码,提高开发效率和代码的安全性。OpenZeppelin 库中的合约经过了严格的审计和广泛的测试,能够有效减少合约中的安全漏洞。

  • contract MyToken is ERC20:定义了一个名为MyToken的合约,它继承自ERC20合约。继承使得MyToken合约可以复用ERC20合约的功能和属性,同时可以根据需要添加自定义的功能。在这个例子中,MyToken合约继承了ERC20合约的基本功能,如代币转账、余额查询、授权等。

  • constructor(uint256 initialSupply) ERC20("MyToken", "MTK"):这是MyToken合约的构造函数,用于初始化代币的相关信息。构造函数接受一个参数initialSupply,表示初始供应量。ERC20("MyToken", "MTK")是对父类ERC20构造函数的调用,用于设置代币的名称为MyToken,符号为MTK。在构造函数中,首先调用父类构造函数设置代币的基本信息,然后通过_mint函数将初始供应量的代币铸造给合约部署者。

  • _mint(msg.sender, initialSupply)_mint函数是ERC20合约中提供的一个内部函数,用于铸造新的代币。它接受两个参数,第一个参数是接收代币的地址,这里使用msg.sender表示合约部署者的地址;第二个参数是铸造的代币数量,即initialSupply。通过_mint函数,将初始供应量的代币铸造并分配给合约部署者。

ERC20 代币合约的关键函数主要包括以下几个:

  • transfer(address to, uint256 amount):用于将指定数量的代币从调用者的账户转移到目标地址。在调用这个函数时,会检查调用者的余额是否足够,如果余额不足,会抛出异常。如果转移成功,会触发Transfer事件,记录转账的相关信息,包括转账的发送方、接收方和转账金额。

    function transfer(address to, uint256 amount) public virtual override returns (bool) {_transfer(_msgSender(), to, amount);return true;
    }
    
  • transferFrom(address from, address to, uint256 amount):允许授权的第三方从from地址转移指定数量的代币到to地址。在调用这个函数时,会检查from地址对调用者的授权额度是否足够,以及from地址的余额是否足够。如果授权额度或余额不足,会抛出异常。如果转移成功,会触发Transfer事件,同时更新授权额度。

    function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) {_transfer(from, to, amount);_approve(from, _msgSender(), _allowance[from][_msgSender()] - amount);return true;
    }
    
  • approve(address spender, uint256 amount):调用者授权spender地址可以使用其账户中指定数量的代币。在调用这个函数时,会更新授权额度,并触发Approval事件,记录授权的相关信息,包括授权者、被授权者和授权金额。

    function approve(address spender, uint256 amount) public virtual override returns (bool) {_approve(_msgSender(), spender, amount);return true;
    }
    
  • allowance(address owner, address spender):用于查询owner地址对spender地址的授权额度。这个函数会返回owner地址授权给spender地址可以使用的代币数量。

    function allowance(address owner, address spender) public view virtual override returns (uint256) {return _allowance[owner][spender];
    }
    
  • balanceOf(address account):返回指定地址的代币余额。通过这个函数,可以查询任何地址当前持有的代币数量。

    function balanceOf(address account) public view virtual override returns (uint256) {return _balances[account];
    }
    
  • totalSupply():返回代币的总供应量。这个函数会返回当前已经铸造的代币总数。

    function totalSupply() public view virtual override returns (uint256) {return _totalSupply;
    }
    

通过继承 OpenZeppelin 的 ERC20 合约,我们可以快速实现一个功能完备的 ERC20 代币合约。在实际应用中,还可以根据具体需求对合约进行进一步的扩展和定制,例如添加权限控制、铸造和销毁功能、实现特殊的代币经济模型等。同时,在使用 OpenZeppelin 库时,需要注意库的版本兼容性和安全性,确保合约的稳定运行。

六、编译与部署智能合约

6.1 编译合约

在 Hardhat 项目中,编译智能合约是将 Solidity 代码转换为以太坊虚拟机(EVM)可执行字节码的重要步骤。通过编译,我们可以生成合约的应用程序二进制接口(ABI),这是外部应用与智能合约进行交互的关键。

使用 Hardhat 编译合约非常简单,只需在项目根目录下的命令行中运行以下命令:

npx hardhat compile

执行上述命令后,Hardhat 会自动查找contracts目录下的所有 Solidity 合约文件,并使用配置文件hardhat.config.js中指定的 Solidity 编译器版本进行编译。如果编译成功,你将在控制台看到类似以下的输出信息:

Compiling 2 Solidity files successfully

这表示 Hardhat 成功编译了指定目录下的两个 Solidity 合约文件。编译后的结果会生成在artifacts目录中,该目录下包含编译后的合约二进制文件(bytecode)、应用程序二进制接口(ABI)文件以及其他一些与编译相关的元数据。其中,ABI 文件非常重要,它定义了合约的接口,包括合约中所有函数的签名、参数类型和返回值类型等信息。通过 ABI,外部应用程序(如前端应用)可以与智能合约进行交互,调用合约的函数。例如,在前端应用中,我们可以使用 Web3 库结合合约的 ABI 和地址,来调用合约的函数,实现与智能合约的通信。此外,artifacts目录中的编译结果还可以用于部署合约时的验证和调试。

如果在编译过程中出现错误,Hardhat 会在控制台详细输出错误信息,帮助我们定位和解决问题。错误信息通常会包含错误的位置、类型和具体描述,例如:

Error HH600: Compilation failed--> contracts/Counter.sol:9:9|9 |         count++;|         ^^^^^|| ParserError: Expected primary expression.

在这个例子中,错误信息表明在contracts/Counter.sol文件的第 9 行出现了语法错误,具体是count++这一行,预期是一个主表达式,但出现了其他问题。根据这些错误信息,我们可以检查代码,进行相应的修改,然后再次编译,直到编译成功为止。

6.2 配置部署网络

在将智能合约部署到区块链网络之前,需要在 Hardhat 项目中配置目标网络的参数。以以太坊测试网 Ropsten 为例,下面介绍如何在hardhat.config.js文件中进行网络配置。

首先,打开项目根目录下的hardhat.config.js文件,找到networks字段。如果没有该字段,可以自行添加。然后,在networks字段中添加 Ropsten 网络的配置信息,如下所示:

module.exports = {// 其他配置项...networks: {ropsten: {url: `https://ropsten.infura.io/v3/YOUR_INFURA_PROJECT_ID`,accounts: [process.env.PRIVATE_KEY]}},// 其他配置项...
};

在上述配置中:

  • url:指定了连接到 Ropsten 测试网的 RPC(Remote Procedure Call)端点。这里使用了 Infura 提供的服务,YOUR_INFURA_PROJECT_ID是你在 Infura 平台上创建项目后获得的项目 ID。通过这个 RPC 端点,Hardhat 可以与 Ropsten 测试网进行通信,发送部署交易等操作。Infura 是一个提供以太坊节点服务的平台,它为开发者提供了便捷的方式来连接到以太坊网络,无需自己搭建和维护节点。

  • accounts:指定了用于部署合约的账户私钥。这里使用了环境变量PRIVATE_KEY来存储私钥,这样可以避免在代码中直接硬编码私钥,提高安全性。在实际部署时,需要将你的私钥设置为环境变量PRIVATE_KEY的值。例如,在 Linux 或 macOS 系统中,可以在终端中使用以下命令设置环境变量:

    export PRIVATE_KEY=your_private_key
    

在 Windows 系统中,可以通过系统设置来添加环境变量。私钥是访问以太坊账户的重要凭证,拥有私钥就可以控制该账户的资产和进行交易操作,因此务必妥善保管,避免泄露。

除了 Ropsten 测试网,还可以根据需要配置其他以太坊测试网(如 Rinkeby、Kovan 等)或主网。不同网络的配置方式类似,只需修改urlaccounts字段的值即可。例如,配置 Rinkeby 测试网的示例如下:

module.exports = {// 其他配置项...networks: {rinkeby: {url: `https://rinkeby.infura.io/v3/YOUR_INFURA_PROJECT_ID`,accounts: [process.env.PRIVATE_KEY]}},// 其他配置项...
};

通过合理配置网络参数,Hardhat 能够与不同的以太坊网络进行交互,实现智能合约在不同网络上的部署和测试。在配置网络时,需要确保提供的 RPC 端点和私钥的准确性和安全性,以保证部署过程的顺利进行。

6.3 编写部署脚本

在 Hardhat 中,部署智能合约需要编写部署脚本。部署脚本负责创建合约实例,并将其部署到指定的区块链网络上。下面以之前编写的计数器合约Counter.sol为例,展示部署脚本的代码结构和编写方法。

scripts目录下创建一个名为deployCounter.js的文件,输入以下代码:

const hre = require('hardhat');async function main() {// 获取合约工厂const Counter = await hre.ethers.getContractFactory('Counter');// 部署合约const counter = await Counter.deploy();// 等待合约部署完成await counter.deployed();console.log('Counter contract deployed to:', counter.address);
}main().then(() => process.exit(0)).catch((error) => {console.error(error);process.exit(1);});

上述代码的详细解释如下:

  • const hre = require('hardhat');:导入 Hardhat 运行时环境(Hardhat Runtime Environment),它提供了与 Hardhat 交互的各种功能和 API,通过hre对象可以访问 Hardhat 的编译器、网络、账户等功能。

  • const Counter = await hre.ethers.getContractFactory('Counter');:使用hre.ethers.getContractFactory方法获取Counter合约的工厂对象。合约工厂对象用于创建合约实例,它会读取编译后的合约信息(包括 ABI 和字节码),这些信息是在编译合约时生成并存储在artifacts目录中的。通过合约工厂对象,我们可以方便地部署合约,并对合约进行各种操作。这里的'Counter'是合约的名称,需要与contracts目录下的合约文件名和合约定义中的名称一致。

  • const counter = await Counter.deploy();:调用合约工厂对象的deploy方法来部署合约。在部署合约时,如果合约的构造函数有参数,需要在deploy方法中传入相应的参数。例如,如果Counter合约的构造函数接受一个初始计数值作为参数,那么部署代码可以修改为const counter = await Counter.deploy(initialValue);,其中initialValue是具体的初始计数值。

  • await counter.deployed();:等待合约部署完成。部署合约是一个异步操作,需要等待区块链网络确认交易后,合约才会被成功部署。这个方法会返回一个 Promise,当合约成功部署后,Promise 会被 resolve。

  • console.log('Counter contract deployed to:', counter.address);:输出合约的部署地址。合约部署成功后,可以通过counter.address获取合约在区块链上的地址。这个地址是合约在区块链上的唯一标识,后续与合约进行交互(如调用合约函数、查询合约状态等)都需要使用这个地址。

通过上述部署脚本,我们可以将Counter合约部署到指定的区块链网络上。在实际部署时,需要根据之前配置的网络参数,选择合适的网络进行部署。

6.4 执行部署

在编写好部署脚本并配置好部署网络后,就可以执行部署操作,将智能合约部署到目标区块链网络上。

在项目根目录下的命令行中运行以下命令来执行部署脚本:

npx hardhat run scripts/deployCounter.js --network ropsten

上述命令中:

  • npx hardhat run:用于运行 Hardhat 项目中的脚本文件。

  • scripts/deployCounter.js:指定要运行的部署脚本文件路径,这里是scripts目录下的deployCounter.js文件。

  • --network ropsten:指定部署的目标网络为 Ropsten 测试网,需要与hardhat.config.js文件中配置的网络名称一致。如果之前配置了其他网络,如 Rinkeby 测试网,这里可以将ropsten替换为rinkeby

执行部署命令后,Hardhat 会读取hardhat.config.js文件中的网络配置,连接到指定的 Ropsten 测试网,并执行部署脚本deployCounter.js。如果部署成功,你将在控制台看到类似以下的输出信息:

Counter contract deployed to: 0x1234567890abcdef1234567890abcdef12345678

其中,0x1234567890abcdef1234567890abcdef12345678就是合约在 Ropsten 测试网上的部署地址。这个地址是合约在区块链上的唯一标识,后续可以使用这个地址与合约进行交互,如调用合约的函数、查询合约的状态等。

如果部署过程中出现错误,Hardhat 会在控制台输出详细的错误信息,帮助我们定位和解决问题。常见的错误包括网络连接问题、私钥错误、合约编译错误等。例如,如果网络连接失败,可能会出现类似以下的错误信息:

Error: request to https://ropsten.infura.io/v3/YOUR_INFURA_PROJECT_ID failed, reason: connect ECONNREFUSED 127.0.0.1:80

这个错误提示表明无法连接到指定的 RPC 端点,可能是因为网络配置错误、Infura 服务不可用或网络故障等原因。根据错误信息,我们可以检查网络配置、确认 Infura 项目 ID 是否正确,或者尝试更换网络等方法来解决问题。

通过以上步骤,我们成功地将智能合约部署到了以太坊测试网 Ropsten 上。在实际应用中,可以根据需要将合约部署到其他测试网或主网上。

七、测试智能合约

在智能合约开发过程中,测试是确保合约功能正确性、安全性和可靠性的关键环节。通过编写测试用例并运行测试,我们可以发现合约中潜在的问题和漏洞,避免在实际应用中出现错误和损失。接下来,我们将介绍如何使用 Hardhat 测试框架对智能合约进行测试。

7.1 Hardhat 测试框架介绍

Hardhat 提供了一个强大且灵活的测试框架,它基于 Mocha 测试框架,并集成了 Chai 断言库,为智能合约测试提供了丰富的功能和便捷的使用方式。

Hardhat 测试框架具有以下特点和优势:

  • 与 Solidity 集成紧密:Hardhat 能够无缝地与 Solidity 智能合约进行交互,方便我们在测试中部署合约、调用合约函数以及验证合约状态。它提供了一系列工具函数,使得与合约的交互变得简单直观。例如,使用ethers.getContractFactory函数可以轻松获取合约工厂对象,进而部署合约实例;使用合约实例可以直接调用合约中的函数,并获取函数的返回值。

  • 丰富的断言功能:借助 Chai 断言库,Hardhat 测试框架提供了丰富的断言方法,用于验证合约的行为和状态是否符合预期。我们可以使用expectassert来编写断言,对函数返回值、事件触发、状态变量变化等进行验证。例如,expect(await contract.getValue()).to.equal(10);用于验证合约的getValue函数返回值是否为 10;expect(tx).to.emit(contract, 'EventName');用于验证交易tx是否触发了合约的EventName事件。

  • 支持异步操作:由于智能合约的部署和函数调用通常是异步操作,Hardhat 测试框架对异步操作提供了良好的支持。我们可以使用async/await语法来处理异步操作,确保测试流程的正确性和可读性。例如,在部署合约时,使用await contract.deployed();等待合约部署完成后再进行后续操作;在调用合约函数后,使用await transactionResponse.wait(1);等待交易被确认后再验证结果。

  • 模拟区块链环境:Hardhat 内置了一个本地以太坊测试网络(Hardhat Network),可以在本地模拟区块链环境,方便我们进行测试和调试。这个测试网络提供了与真实以太坊网络相似的功能和行为,包括账户管理、交易处理、区块生成等。在测试过程中,我们可以使用测试网络中的账户进行合约部署和调用,并且可以轻松地控制和模拟各种区块链场景,如不同的 Gas 价格、交易失败等。

  • 可扩展性:Hardhat 的插件系统使得测试框架具有很强的可扩展性,我们可以通过安装和使用各种插件来扩展测试功能。例如,hardhat-gas-reporter插件可以用于报告每个测试用例的 Gas 消耗,帮助我们优化合约的性能;hardhat-coverage插件可以生成测试覆盖率报告,让我们了解测试用例对合约代码的覆盖程度。

7.2 编写测试用例

下面以之前编写的计数器合约Counter.sol为例,展示如何编写测试用例来验证其功能的正确性。

test目录下创建一个名为Counter.test.js的文件,输入以下代码:

const { expect } = require('chai');
const { ethers } = require('hardhat');describe('Counter Contract', function () {let counter;// 在每个测试用例执行前,部署Counter合约实例beforeEach(async function () {const Counter = await ethers.getContractFactory('Counter');counter = await Counter.deploy();await counter.deployed();});// 测试用例1:验证初始计数值为0it('Should start with a count of 0', async function () {const count = await counter.getCount();expect(count).to.equal(0);});// 测试用例2:验证increment函数能正确增加计数值it('Should increment the count correctly', async function () {await counter.increment();const count = await counter.getCount();expect(count).to.equal(1);});// 测试用例3:多次调用increment函数,验证计数值的变化it('Should increment the count multiple times', async function () {for (let i = 0; i < 5; i++) {await counter.increment();}const count = await counter.getCount();expect(count).to.equal(5);});});

上述测试代码的详细解释如下:

  • 引入依赖
    • const { expect } = require('chai');:从 Chai 断言库中引入expect断言方法,用于编写断言语句。
    • const { ethers } = require('hardhat');:从 Hardhat 中引入ethers对象,ethers是一个功能强大的以太坊库,提供了与以太坊网络交互的各种功能,包括合约部署、函数调用等。
  • 定义测试套件
    • describe('Counter Contract', function () {... });:使用 Mocha 的describe函数定义一个测试套件,名称为 “Counter Contract”,在这个测试套件中可以包含多个测试用例。测试套件用于组织和分组相关的测试用例,方便管理和执行。
  • 定义 beforeEach 钩子函数
    • beforeEach(async function () {... });beforeEach是 Mocha 提供的钩子函数,在每个测试用例执行之前都会执行一次。在这个钩子函数中,我们首先使用ethers.getContractFactory获取Counter合约的工厂对象,然后通过工厂对象部署Counter合约实例,并等待合约部署完成。这样,每个测试用例在执行时都能使用到一个新部署的合约实例,保证测试的独立性和准确性。
  • 编写测试用例
    • it('Should start with a count of 0', async function () {... });:使用 Mocha 的it函数定义一个测试用例,名称为 “Should start with a count of 0”。在这个测试用例中,首先调用合约的getCount函数获取当前计数值,然后使用expect断言验证计数值是否为 0。
    • it('Should increment the count correctly', async function () {... });:定义另一个测试用例,名称为 “Should increment the count correctly”。在这个测试用例中,首先调用合约的increment函数增加计数值,然后再次调用getCount函数获取当前计数值,最后使用expect断言验证计数值是否为 1。
    • it('Should increment the count multiple times', async function () {... });:这个测试用例用于验证多次调用increment函数后计数值的变化。通过一个循环多次调用increment函数,然后获取计数值并使用expect断言验证计数值是否为循环次数。

7.3 运行测试

在编写好测试用例后,就可以运行测试来验证智能合约的功能了。

在项目根目录下的命令行中运行以下命令来执行测试:

npx hardhat test

执行上述命令后,Hardhat 会自动查找test目录下的所有测试文件,并执行其中的测试用例。测试结果会在控制台输出,展示每个测试用例的执行情况。

如果所有测试用例都通过,你将看到类似以下的输出信息:

Counter Contract✓ Should start with a count of 0✓ Should increment the count correctly✓ Should increment the count multiple times
3 passing (2s)

这表示 “Counter Contract” 测试套件中的三个测试用例都成功通过了测试。

如果某个测试用例失败,Hardhat 会在控制台输出详细的错误信息,帮助我们定位问题。例如,如果 “Should increment the count correctly” 测试用例失败,可能会看到类似以下的输出:

Counter Contract✓ Should start with a count of 01) Should increment the count correctly✓ Should increment the count multiple times1) Counter ContractShould increment the count correctly:AssertionError: expected 0 to equal 1at Context.<anonymous> (test/Counter.test.js:18:32)

在这个例子中,错误信息表明断言失败,预期计数值为 1,但实际计数值为 0。根据这些错误信息,我们可以检查测试代码和合约代码,找出问题所在并进行修复。

除了运行所有测试用例,Hardhat 还支持运行指定的测试文件或测试用例。例如,如果只想运行Counter.test.js文件中的测试用例,可以使用以下命令:

npx hardhat test test/Counter.test.js

如果只想运行某个特定的测试用例,可以使用--grep选项,后面跟上测试用例的名称或部分名称。例如,只想运行 “Should increment the count correctly” 测试用例,可以使用以下命令:

npx hardhat test --grep "Should increment the count correctly"

通过编写和运行测试用例,我们可以有效地验证智能合约的功能,确保合约在各种情况下都能正确运行。在实际开发中,应不断完善测试用例,覆盖更多的边界情况和异常情况,以提高智能合约的质量和可靠性。

八、常见问题与解决方法

在基于 Hardhat 编写 Solidity 智能合约的过程中,开发者可能会遇到各种问题,以下是一些常见问题及对应的解决方法。

8.1 编译错误

Solidity 版本不匹配:当出现类似 “The Solidity version pragma statement in these files doesn’t match any of the configured compilers in your config. Change the pragma or configure additional compiler versions in your hardhat config.” 的错误提示时,意味着 .sol 文件的版本与 hardhat.config.js 中配置的编译器版本不匹配。
解决方案

  1. 配置多个编译器版本:如果项目中不同的合约依赖不同的 Solidity 版本,可以在 hardhat.config.js 中配置多个编译器版本。例如:

    require("@nomicfoundation/hardhat-toolbox");module.exports = {solidity: {compilers: [{ version: "0.8.30" },{ version: "0.8.28" } ]}
    };
    
  2. 统一版本:若想简化配置,可统一使用一个 Solidity 版本。比如将合约文件中的版本声明和 hardhat.config.js 中的配置都改为相同版本,如 0.8.28。

    先修改合约文件,如contracts/FundMe.sol

    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.28;
    

    再修改 hardhat.config.js:

    require("@nomicfoundation/hardhat-toolbox");module.exports = {solidity: "0.8.28"
    };
    

    修改后,执行npx hardhat clean清理缓存,然后npx hardhat compile重新编译。


语法错误:语法错误是常见的编译问题,如拼写错误、缺少分号、括号不匹配等。错误信息通常会指出错误所在的文件和行号。例如:

Error HH600: Compilation failed--> contracts/Counter.sol:9:9|9 |         count++;|         ^^^^^|| ParserError: Expected primary expression.

解决方案:根据错误提示,仔细检查代码中对应的行,修正语法错误。在上述例子中,检查contracts/Counter.sol文件的第 9 行,确保count++语句的正确性,可能是因为少了分号或者其他语法错误导致。

8.2 部署失败

网络连接问题:部署时可能出现无法连接到指定区块链网络的情况,例如出现 “Error: request to https://ropsten.infura.io/v3/YOUR_INFURA_PROJECT_ID failed, reason: connect ECONNREFUSED 127.0.0.1:80” 这样的错误。

解决方案

  1. 检查网络配置,确保hardhat.config.js中配置的 RPC URL 正确无误,并且网络可达。如果使用 Infura,检查YOUR_INFURA_PROJECT_ID是否正确,是否过期。
  2. 确认本地网络连接正常,可以尝试访问其他网站或使用 ping 命令测试网络连通性。
  3. 如果是使用代理上网,可能需要配置代理服务器,在命令行中设置HTTP_PROXYHTTPS_PROXY环境变量。

私钥错误:如果私钥不正确或格式有误,会导致部署失败。

解决方案

  1. 仔细检查hardhat.config.js中配置的私钥,确保私钥的准确性。私钥是访问以太坊账户的重要凭证,务必妥善保管,避免泄露。
  2. 私钥格式必须正确,以太坊私钥通常是 64 位的十六进制字符串。如果私钥是从其他地方复制过来的,注意不要包含多余的空格或换行符。

Gas 不足:部署合约需要消耗一定数量的 Gas,如果账户中的 Gas 不足,会导致部署失败。

解决方案

  1. 可以通过向账户中充值 Gas 来解决,在测试网中,可以使用水龙头(Faucet)获取免费的测试币,以增加账户的 Gas 余额。不同的测试网有不同的水龙头,例如 Ropsten 测试网的水龙头可以在一些以太坊社区网站上找到。

  2. 调整 Gas 价格和 Gas 限制。在部署脚本中,可以设置更高的 Gas 价格,以提高交易的优先级,确保交易能够更快被打包。同时,合理调整 Gas 限制,确保合约部署过程中有足够的 Gas 可用。例如,在部署脚本中可以这样设置:

    const counter = await Counter.deploy({gasPrice: ethers.utils.parseUnits('50', 'gwei'), gasLimit: 500000 
    });
    

8.3 测试失败

断言失败:测试用例中的断言失败,表明智能合约的实际行为与预期行为不一致。例如:

AssertionError: expected 0 to equal 1at Context.<anonymous> (test/Counter.test.js:18:32)

解决方案

  1. 检查测试用例中的断言逻辑,确认预期值是否正确。在上述例子中,检查test/Counter.test.js文件的第 18 行,确认expect(count).to.equal(1);中的预期值 1 是否符合合约的设计逻辑。
  2. 检查合约代码,确保合约的功能实现正确。可能是合约中的函数逻辑出现错误,导致返回值与预期不符。例如,在计数器合约中,如果increment函数没有正确增加计数值,就会导致断言失败。

测试环境问题:测试环境配置不正确,可能导致测试失败。例如,测试框架依赖未正确安装,或者测试网络配置错误。

解决方案

  1. 确保测试框架的依赖已经正确安装。在 Hardhat 项目中,通常需要安装 Mocha 和 Chai 等测试依赖。可以通过npm install --save-dev mocha chai命令进行安装。
  2. 检查测试网络的配置,确保测试环境与合约部署的网络一致。如果在测试中需要使用本地测试网络(Hardhat Network),确保网络配置正确,并且网络能够正常运行。例如,在测试脚本中可以使用await hre.network.provider.request({ method: "hardhat_reset" });来重置测试网络状态,确保每次测试都是在干净的环境中进行。

九、总结

通过本文,我们全面深入地探讨了基于 Hardhat 编写 Solidity 智能合约的完整流程,从开发前的环境准备,到 Solidity 基础语法的回顾,再到 Hardhat 项目的创建、智能合约的编写、编译、部署以及测试,每一个环节都为构建安全、高效的智能合约系统奠定了基础。

参考链接

  • Solidity 编程语言
  • Hardhat 框架
  • Writing Solidity tests 编写 Solidity 测试
  • Using Viem with Hardhat 将 Viem 与 Hardhat 一起使用
  • Deploying contracts 部署合约
  • Configuring the compiler 配置编译器
http://www.xdnf.cn/news/1316413.html

相关文章:

  • SCAI采用公平发射机制成功登陆LetsBonk,60%代币供应量已锁仓
  • Houdini 粒子学习笔记
  • C# Newtonsoft.Json 反序列化子类数据丢失问题
  • 音频分类标注工具
  • 矿物分类案列 (一)六种方法对数据的填充
  • Java零基础笔记20(Java高级技术:单元测试、反射、注解、动态代理)
  • RAC环境redo在各节点本地导致数据库故障恢复---惜分飞
  • 勾股数-洛谷B3845 [GESP样题 二级]
  • 平行双目视觉-动手学计算机视觉18
  • Linux应用软件编程---多任务(线程)(线程创建、消亡、回收、属性、与进程的区别、线程间通信、函数指针)
  • (一)React企业级后台(Axios/localstorage封装/动态侧边栏)
  • Android 对话框 - 基础对话框补充(不同的上下文创建 AlertDialog、AlertDialog 的三个按钮)
  • WPFC#超市管理系统(6)订单详情、顾客注册、商品销售排行查询和库存提示、LiveChat报表
  • C#WPF实战出真汁13--【营业查询】
  • [辩论] TDD(测试驱动开发)
  • ZKmall开源商城的移动商城搭建:Uni-app+Vue3 实现多端购物体验
  • Collections.synchronizedList是如何将List变为线程安全的
  • Trae 辅助下的 uni-app 跨端小程序工程化开发实践分享
  • 李宏毅NLP-11-语音合成
  • 在 Element UI 的 el-table 中实现某行标红并显示删除线
  • 【PHP】Hyperf:接入 Nacos
  • Centos中内存CPU硬盘的查询
  • vscode无法检测到typescript环境解决办法
  • OpenCV 图像处理核心技术:边界填充、算术运算与滤波处理实战
  • 大模型应用发展与Agent前沿技术趋势(中)
  • JVM常用工具:jstat、jmap、jstack
  • 【Linux】IO多路复用
  • 17-线程
  • Python自学10-常用数据结构之字符串
  • Python异常、模块与包(五分钟小白从入门)