在Moonbeam上使用Foundry¶
作者:Jeremy Boetticher
概览¶
Foundry已成为越来越受欢迎的用于开发智能合约的开发环境,因其只需要一种语言(Solidity)即可使用。建议您在开始使用Foundry之前,先阅读在Moonbeam网络上使用Foundry的介绍文章。在本教程中,我们将深入代码库,以全面了解如何正确开发、测试和部署。
在本次操作演示中,我们将部署两个智能合约。一个是Token,另一个将基于此Token上。我们也将编写单元测试以确保合约如预期运作。要部署合约,我们要先编写脚本,Foundry将使用此脚本来决定部署逻辑。最后,我们要在Moonbeam网络的区块浏览器上验证智能合约。
查看先决条件¶
开始之前,您将需要准备以下内容:
- 拥有资金的账户 您可以每24小时一次从Moonbase Alpha水龙头上获取DEV代币以在Moonbase Alpha上进行测试
- 要在Moonbeam或Moonriver网络上测试本指南中的示例,您可以从受支持的网络端点提供商之一获取您自己的端点和API密钥。
- 安装Foundry
- 一个Moonscan API密钥
创建Foundry项目¶
首先,创建一个Foundry项目。如果您已安装Foundry,您可以运行以下命令:
forge init foundry && cd foundry
这将使forge
实用程序初始化一个名为foundry
的新文件夹,并在其中初始化一个Foundry项目。script
、src
和test
文件夹中可能已经有文件。请确保将其删除,因为我们会在之后编写自己的文件。
在编写任何代码之前,您需要先做一些事情。首先,我们要添加对OpenZeppelin的智能合约的依赖,因其包含一些有用的合约,将用于编写Token智能合约。为此,使用其GitHub代码库名称来完成添加:
forge install OpenZeppelin/openzeppelin-contracts
这会将OpenZeppelin git子模块添加到您的lib
文件夹中。 为确保此依赖项已映射,您可以覆盖特殊文件remappings.txt
中的映射:
forge remappings > remappings.txt
该文件中的每一行都是可以在项目的智能合约中引用的依赖项之一。可以编辑和重命名依赖项,以便在处理智能合约时更容易引用不同的文件夹和文件。如果正确安装了OpenZeppelin,将显示与下方类似的输出:
ds-test/=lib/forge-std/lib/ds-test/src/
forge-std/=lib/forge-std/src/
openzeppelin-contracts/=lib/openzeppelin-contracts/
最后,打开foundry.toml
文件。在准备Etherscan验证和部署时,将此添加到文件中:
[profile.default]
src = 'src'
out = 'out'
libs = ['lib']
solc_version = '0.8.17'
[rpc_endpoints]
moonbase = "https://rpc.api.moonbase.moonbeam.network"
moonbeam = "https://rpc.api.moobeam.network"
[etherscan]
moonbase = { key = "${MOONSCAN_API_KEY}" }
moonbeam = { key = "${MOONSCAN_API_KEY}" }
第一个添加的是profile.default
下面的solc_version
的规范。rpc_endpoints
标签允许您定义在部署到命名网络时要使用的RPC端点,在本例中为Moonbase Alpha和Moonbeam。etherscan
标签允许您添加用于智能合约验证的Etherscan API密钥,我们将在之后展开讨论。
添加智能合约¶
Foundry中默认部署的智能合约属于src
文件夹。 在本教程中,我们将编写两个智能合约。 首先从Token开始:
touch MyToken.sol
打开文件并添加以下内容:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// Import OpenZeppelin Contract
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
// This ERC-20 contract mints the specified amount of tokens to the contract creator
contract MyToken is ERC20 {
constructor(uint256 initialSupply) ERC20("MyToken", "MYTOK") {
_mint(msg.sender, initialSupply);
}
// An external minting function allows anyone to mint as many tokens as they want
function mint(uint256 toMint, address to) external {
require(toMint <= 1 ether);
_mint(to, toMint);
}
}
如您所见,OpenZeppelin ERC20
智能合约是通过remappings.txt
中定义的映射导入的。
第二个智能合约(我们将其命名为Container.sol
)将依赖于这个Token合约。这是一个简单的合约,包含我们将要部署的ERC-20 Token。您可以通过执行以下命令来创建文件:
touch Container.sol
打开文件并添加以下内容:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// Import OpenZeppelin Contract
import {MyToken} from "./MyToken.sol";
enum ContainerStatus {
Unsatisfied,
Full,
Overflowing
}
contract Container {
MyToken token;
uint256 capacity;
ContainerStatus public status;
constructor(MyToken _token, uint256 _capacity) {
token = _token;
capacity = _capacity;
status = ContainerStatus.Unsatisfied;
}
// Updates the status value based on the number of tokens that this contract has
function updateStatus() public {
address container = address(this);
uint256 balance = token.balanceOf(container);
if (balance < capacity) {
status = ContainerStatus.Unsatisfied;
} else if (balance == capacity) {
status = ContainerStatus.Full;
} else if (_isOverflowing(balance)) {
status = ContainerStatus.Overflowing;
}
}
// Returns true if the contract should be in an overflowing state, false if otherwise
function _isOverflowing(uint256 balance) internal view returns (bool) {
return balance > capacity;
}
}
Container
智能合约可以根据其持有的Token数量及其设置的初始容量值来更新其状态。如果只有的Token数量大于其容量,则状态可以更新为Overflowing
。如果持有的Token数量等于容量,则状态可以更新为Full
。否则,合约将开始并保持Unsatisfied
状态。
Container
需要一个MyToken
智能合约实例才能运行,因此当我们部署时,我们需要逻辑来确保其与MyToken
智能合约实例一起部署。
编写测试¶
在我们部署任何合约至测试网或主网前,建议您先测试您的智能合约。多种测试类型如下所示:
- 单元测试(Unit tests) — 允许您测试智能合约功能的特定部分。当您编写自己的智能合约时,建议您将功能分成不同的部分,以便进行单元测试
- 模糊测试(Fuzz tests) — 允许您使用各种输入测试智能合约以检查边缘情况
- 集成测试(Integration tests) — 允许您在智能合约与其他智能合约一起工作时对其进行测试,以便您了解其能够在已部署的环境中按预期运作
- 分叉测试(Forking tests) - 允许您创建分叉(网络的副本)的集成测试,以便您可以在预先存在的网络上模拟一系列交易
Foundry中的单元测试¶
要开始为此教程编写测试,先在test
文件中创建一个新的文件:
cd test
touch MyToken.t.sol
根据惯例,所有的测试需要以.t.sol
结尾并以正在测试的智能合约名称开始。实际上,测试可以存储在任何地方,如果有以"test"开始的函数,则被视为测试。
接下来,开始为Token智能合约编写测试。打开MyToken.t.sol
并添加以下内容:
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/MyToken.sol";
contract MyTokenTest is Test {
MyToken public token;
// Runs before each test
function setUp() public {
token = new MyToken(100);
}
// Tests if minting during the constructor happens properly
function testConstructorMint() public {
assertEq(token.balanceOf(address(this)), 100);
}
}
我们来分析一下此处的代码:第一行是典型的Solidity文件:设置Solidity版本。接下来的两行是导入。forge-std/Test.sol
是Forge(也就是Foundry)包含的用于帮助测试的标准库。这包括Test
智能合约,某些断言(assertion)和forge cheatcodes。
如果您查看MyTokenTest
智能合约,您将看到两个函数。第一个是setUp
,在每个测试之前运行。因此在此测试合约中,每次运行测试函数时都会部署MyToken
新实例。如果函数以"test" 开头,则该函数为测试函数。所以第二个函数testConstructorMint
是一个测试函数。
现在,我们再写一些关于Container
的测试:
touch Container.t.sol
然后添加以下内容:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import {MyToken} from "../src/MyToken.sol";
import {Container, ContainerStatus} from "../src/Container.sol";
contract ContainerTest is Test {
MyToken public token;
Container public container;
uint256 constant CAPACITY = 100;
// Runs before each test
function setUp() public {
token = new MyToken(1000);
container = new Container(token, CAPACITY);
}
// Tests if the container is unsatisfied right after constructing
function testInitialUnsatisfied() public {
assertEq(token.balanceOf(address(container)), 0);
assertTrue(container.status() == ContainerStatus.Unsatisfied);
}
// Tests if the container will be "full" once it reaches its capacity
function testContainerFull() public {
token.transfer(address(container), CAPACITY);
container.updateStatus();
assertEq(token.balanceOf(address(container)), CAPACITY);
assertTrue(container.status() == ContainerStatus.Full);
}
}
此测试合约有两个测试,所以在运行测试时,会有MyToken
和Container
的两次部署,总共为4个智能合约。您可以运行以下命令来查看测试结果:
forge test
测试时,您将看到以下输出:
Foundry中的测试套件(Test Harness)¶
有时候您会希望在智能合约中对internal
函数进行单元测试。为此,您需要编写测试套件智能合约,其继承自智能合约并将内部函数公开作为公共函数。
举例来说,在Container
中有一个名为_isOverflowing
的内部函数,用于检查智能合约是否有比容量更多的Token。要测试此函数,请添加以下测试套件智能合约至Container.t.sol
文件中:
contract ContainerHarness is Container {
constructor(MyToken _token, uint256 _capacity) Container(_token, _capacity) {}
function exposed_isOverflowing(uint256 balance) external view returns(bool) {
return _isOverflowing(balance);
}
}
在ContainerTest
智能合约里面,您可以添加一个新测试,用于测试之前不可读取的_isOverflowing
合约:
// Tests for negative cases of the internal _isOverflowing function
function testIsOverflowingFalse() public {
ContainerHarness harness = new ContainerHarness(token , CAPACITY);
assertFalse(harness.exposed_isOverflowing(CAPACITY - 1));
assertFalse(harness.exposed_isOverflowing(CAPACITY));
assertFalse(harness.exposed_isOverflowing(0));
}
现在,当您用forge test
运行测试时,您将看到testIsOverflowingFalse
已经通过!
Foundry中的模糊测试¶
当您编写单元测试时,您只能使用一些输入进行测试。您可以尝试测试边缘情况,选择一些值,可以是一个或者两个随机值。但是在处理输入时,有无数种不同的输入需要测试。如何保证其适用于每个值?如果把少于10个的输入替换成10000个不同的输入,会不会更安全呢?
开发者可以测试很多输入的最佳方式之一是通过模糊测试(fuzzing或fuzz测试)。当测试函数中包含输入时,Foundry会自动进行模糊测试。为了说明这一点,将以下测试添加到MyToken.t.sol
中的MyTokenTest
合约。
// Fuzz tests for success upon minting tokens one ether or below
function testMintOneEtherOrBelow(uint256 amountToMint) public {
vm.assume(amountToMint <= 1 ether);
token.mint(amountToMint, msg.sender);
assertEq(token.balanceOf(msg.sender), amountToMint);
}
这些测试包含uint256 amountToMint
作为输入,告知Foundry使用uint256
输入进行模糊测试。默认情况下,Foundry将输入256个不同的输入,但可以使用FOUNDRY_FUZZ_RUNS
环境变量配置。
此外,该函数的第一行使用vm.assume
以仅使用小于等于1 ether的输入,如果有人试图一次铸造超过1 ether,则mint
函数将返还。此cheatcode可以帮助您将模糊测试引导到正确的范围。
我们来看一下另一个放入MyTokenTest
合约中的模糊测试,但是我们预计会失败:
// Fuzz tests for failure upon minting tokens above one ether
function testFailMintAboveOneEther(uint256 amountToMint) public {
vm.assume(amountToMint > 1 ether);
token.mint(amountToMint, msg.sender);
}
在Foundry中,当你预想到测试会失败,您无需以"test"开始,可以直接以"testFail"开始测试函数名。在此测试中,我们假设amountToMint
超过1 ether(即结果会失败)。
现在运行测试:
forge test
您将在控制台看到以下类似输出:
Foundry中的分叉测试¶
在Foundry中,您可以在本地分叉网络,从而您可以测试合约如何在已部署合约的环境中运行。举例来说,如果有人在Moonbeam上已部署需要Token智能合约的智能合约A
,您可以分叉Moonbeam网络并在分叉上部署您自己的Token以测试智能合约A
如何反应。
注意事项
Moonbeam的自定义预编译合约目前暂不可在Foundry分叉中使用,因为预编译是基于Substrate,而典型的合约是完全基于EVM。更多内容请参考:Moonbeam上分叉和Moonbeam和以太坊之间的异同之处.
在本教程中,您将测试Container
智能合约如何在Moonbase Alpha上与已部署的MyToken
合约交互。
将名为testAlternateTokenOnMoonbaseFork
的新测试函数添加至Container.t.sol
中的ContainerTest
智能合约。
// Fork tests in the Moonbase Alpha environment
function testAlternateTokenOnMoonbaseFork() public {
// Creates and selects a fork, returns a fork ID
uint256 moonbaseFork = vm.createFork("moonbase");
vm.selectFork(moonbaseFork);
assertEq(vm.activeFork(), moonbaseFork);
// Get token that's already deployed & deploys a container instance
token = MyToken(0x359436610E917e477D73d8946C2A2505765ACe90);
container = new Container(token, CAPACITY);
// Mint tokens to the container & update container status
token.mint(CAPACITY, address(container));
container.updateStatus();
// Assert that the capacity is full, just like the rest of the time
assertEq(token.balanceOf(address(container)), CAPACITY);
assertTrue(container.status() == ContainerStatus.Full);
}
此函数的第一步(也是第一行)是让测试函数使用vm.createFork
分叉网络。vm
是由Forge标准库提供的cheatcode。创建分叉需要的是一个RPC URL或者存储在foundry.toml
文件中的RPC URL别名。在本示例中,我们在设置步骤中为"moonbase"添加了一个RPC URL,因此在测试函数中我们将只需传递"moonbase"
。此cheatcode函数返回创建分叉的ID,该ID存储在uint256
中,是激活分叉所必需的。
在第二行中,即分叉创建后,将通过vm.selectFork
在测试环境中选择和使用分叉。第三行只是为了证明当前的分叉,通过vm.activeFork
检索,与Moonbase Alpha分叉相同。
第四行代码检索已部署的MyToken
实例,这就是分叉的用武之地:您可以使用已经部署的合约。
剩下的代码测试容量,就像您期望的本地测试一样。如果您运行测试(使用-vvvv
标签可以得到额外的日志),您将看到测试通过了:
forge test -vvvv
这就是测试的步骤!您可以在GitHub上查看完整的Container.t.sol
文件和MyToken.t.sol
文件。
使用Solidity脚本在Foundry中部署¶
Foundry中的测试和脚本均以Solidity编写。与其他开发者环境一样,可以编写脚本来帮助与已部署智能合约交互,或协助完成手动难以实现的复杂部署流程。即使脚本是用Solidity编写,但是这些脚本不会部署到链上。相反,大部分的逻辑实际上是在链下运行的,因此使用Foundry无需担心像Hardhat这类JavaScript环境会产生任何额外的gas费用。
在Moonbase Alpha上部署¶
在本教程中,我们将使用Foundry的脚本部署MyToken
和Container
智能合约。要创建部署脚本,在script
文件夹中创建一个新文件:
cd script
touch Container.s.sol
按照惯例,脚本应该以s.sol
结尾,并且名称要与相关的脚本相似。在本示例中,我们要部署Container
智能合约,因此我们将脚本命名为Container.s.sol
,但如果您使用其他合适的或更具描述性的名称,也不会是世界末日。
在脚本中,添加以下内容:
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import {MyToken} from "../src/MyToken.sol";
import {Container} from "../src/Container.sol";
contract ContainerDeployScript is Script {
// Runs the script; deploys MyToken and Container
function run() public {
// Get the private key from the .env
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
// Make a new token
MyToken token = new MyToken(1000);
// Make a new container
new Container(token, 500);
vm.stopBroadcast();
}
}
我们来分析一下此处的代码。第一行是标配:说明Solidity版本。导入内容包含之前添加的两个智能合约,我们即将部署这两个合约。导入Script
合约包括了在脚本中使用的附加功能。
接下来,我们来看一下合约中的逻辑。只有一个函数run
,脚本逻辑都在里面。在此run
函数中,经常使用到vm
对象。所有Forge cheatcode存储在里面,其决定了运行Solidity的虚拟机的状态。
在run
函数的第一行中,vm.envUint
用于从系统环境变量获取私钥(我们将在后续完成此操作)。在第二行中,vm.startBroadcast
开始播报,即表示后续逻辑会发生在链上。因此,当使用new
关键字实例化MyToken
和Container
合约时,这两个合约会在链上实例化。最后一行,vm.stopBroadcast
结束播报。
在我们运行此脚本之前,先要设置一些环境变量。创建一个新的.env
文件:
touch .env
在此文件中,添加以下内容:
PRIVATE_KEY=YOUR_PRIVATE_KEY
MOONSCAN_API_KEY=YOUR_MOONSCAN_API_KEY
注意事项
Foundry提供用于处理私钥的其他选项。根据个人选择决定是否在控制台使用该私钥,私钥是否存储在设备环境中,是否使用硬件钱包或者使用keystore。
要添加这些环境变量,请运行以下命令:
source .env
现在,您的脚本和项目已经可以准备部署了!使用以下命令进行操作:
forge script Container.s.sol:ContainerDeployScript --broadcast --verify -vvvv --rpc-url moonbase
此命令将ContainerDeployScript
合约作为脚本运行。--broadcast
选项告知Forge允许交易播报,--verify
选项告知Forge在部署时向Moonscan验证,-vvvv
使命令输出更详细,--rpc-url moonbase
将网络设置为在foundry.toml
中设置的moonbase
。
您将看到类似以下输出:
您应该能够看到您的合约已成功部署并且已在Moonscan上得到验证。可以查看我部署Container.sol
合约的地方。
您可以在GitHub上查看整个部署脚本。
在Moonbeam主网上部署¶
现在您对您的智能合约已经感到满意,并且想在Moonbeam主网上进行部署。此过程的操作与上述操作类似,因为您已在foundry.toml
文件中添加了Moonbeam主网的信息,您只需将rpc-url从moonbase
改成moonbeam
即可:
forge script Container.s.sol:ContainerDeployScript --broadcast --verify -vvvv --rpc-url moonbeam
请注意,虽然这比较复杂,但是还有其他在Foundry中处理私钥的方法。 其中一些方法可以被认为比将生产私钥存储在环境变量中更安全。
这样就可以了!您已经从无到有,完成了一个完全经过测试、部署和验证的Foundry项目。现在您可以稍作调整将Foundry用于您自己的项目!
本教程仅用于教育目的。 因此,不应在生产环境中使用本教程中创建的任何合约或代码。
| Created: March 29, 2023