本文介绍了 Foundry 各个组件的使用,不单纯重复文档的内容,而是关注最常使用的部分。
Foundry 是一个用 Rust 编写的以太坊应用开发工具包,具有极速、可移植和模块化的特点。
Foundry 包括以下组件:
我们这篇文章不会单纯重复文档的内容,而是关注最常使用的部分。
curl -L https://foundry.paradigm.xyz | bash
foundryup
使用 forge -h
或手册查看相关命令:
新建项目:
forge init
里面的 foundry.toml 是 Forge 项目中的配置文件。在 foundry.toml 中使用 solc 配置编译器版本和优化次数:
[profile.default]
src = 'src'
out = 'out'
libs = ['lib']
solc = "0.8.x"
optimizer = true
optimizer_runs = 200
编译:
forge build
测试:
# 运行所有测试
forge test
# 单独运行匹配前缀为 `CounterTest` 的单元测试
forge test --match-contract CounterTest
# 单独运行 `CounterTest` 的单元测试中的测试用例 `test_Increment` (match 同样是匹配前缀)
forge test --match-contract CounterTest --match-test test_Increment
# 当合约内容有变动时,就会重新运行所有的单元测试
forge test --watch
依赖包: 比如 transmissions11/solmate 依赖包:
# 安装
forge install transmissions11/solmate
# 移除
forge remove lib/solmate
# 更新
forge update lib/solmate
如何让 VSCode 能识别到依赖包里的合约,使用:
forge remappings > remappings.txt
如果还是不能识别,尝试重启 VSCode。
再比如 openzeppelin
:
openzeppelin-contracts
openzeppelin-contracts-upgradeable
$ forge install OpenZeppelin/openzeppelin-contracts
Add `@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/` in `remappings.txt.`
$ forge install OpenZeppelin/openzeppelin-contracts-upgradeable
Add `@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/` in `remappings.txt.`
如果你在 Foundry 项目中运行 forge coverage
,会看到一张表,显示代码的行覆盖率和分支覆盖率。
想更直观的显示对应代码行,可以使用以下方法:
安装 lcov
:
brew install lcov
在 Foundry 项目中创建 coverage 目录:
mkdir coverage
运行以下命令:
forge coverage --report lcov
genhtml lcov.info --branch-coverage --output-dir coverage
如果有错误,可尝试:
genhtml --ignore-errors inconsistent,corrupt lcov.info --branch-coverage --output-dir coverage
最终打开 coverage/index.html
中的网页:
另外,也可以安装 VSCode 插件 Coverage Gutters
。
使用命令生成覆盖测试报告:
forge coverage --report lcov
然后在 VSCode 右键选择 Coverage Gutters
中的 Display Coverage
。
在单元测试中,往往需要使用断言。以下是调用 forge init
后提供的默认测试文件。
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
Counter public counter;
function setUp() public {
counter = new Counter();
counter.setNumber(0);
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1);
}
function testFuzz_SetNumber(uint256 x) public {
counter.setNumber(x);
assertEq(counter.number(), x);
}
}
setUp()
函数部署了正在测试的合约,每次测试用例的执行会默认执行 setUp()
。任何以 test
开头的函数都会被执行作为单元测试。
以下是可以使用的断言:
assertEq
:断言相等assertLt
:断言小于assertLe
:断言小于或等于assertGt
:断言大于assertGe
:断言大于或等于assertTrue
:断言为真断言的前两个参数是要进行比较的值,但你也可以添加一个错误消息作为第三个参数,建议总是这么做。以下是编写断言的推荐方式:
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 1, "expect x to equal to 1");
}
vm.prank
改变 msg.sender
Foundry 通过一种“欺骗码” vm.prank
提供了一种方法来更改 sender。
下面是一个简单示例:
function test_ChangeOwner() public {
vm.prank(owner);
contractToTest.changeOwner(newOwner);
assertEq(contractToTest.owner(), newOwner);
}
vm.prank
仅对其后立即发生的交易有效。如果你希望之后的一系列交易都使用相同的地址,可以使用 vm.startPrank
开始,并用 vm.stopPrank
结束。
function testMultipleTransactions() public {
vm.startPrank(owner);
// 作为 owner 地址发送的交易
vm.stopPrank();
}
上面的 owner
变量可以通过几种方式定义:
address owner = address(1234);
address owner = 0x0d8dA6BF26964aF9D7eEd9e03E53415D37aA96045;
address owner = vm.addr(privateKey);
msg.sender
和 tx.origin
如果不只需要更改 msg.sender
还需要更改 tx.origin
,那么 vm.prank
和 vm.startPrank
可以选择性地接受两个参数,其中第二个参数是 tx.origin
。
vm.prank(msgSender, txOrigin);
Foundry 中检查余额非常简单,因为它是用 Solidity 编写的。
比如以下合约:
contract Deposit {
event Deposited(address indexed);
function buyerDeposit() external payable {
require(msg.value == 1 ether, "incorrect amount");
emit Deposited(msg.sender);
}
}
测试函数如下:
function test_BuyerDeposit() public {
uint256 balanceBefore = address(depositContract).balance;
depositContract.buyerDeposit{value: 1 ether}();
uint256 balanceAfter = address(depositContract).balance;
assertEq(balanceAfter - balanceBefore, 1 ether, "expect increase of 1 ether");
}
vm.expectRevert
预期 revert上面的测试在当前形式下存在的问题是,如果您删除 require
语句,测试仍会通过。让我们改进测试,使得删除 require
语句会导致测试失败。
还是使用以上的例子,需要测试 buyerDeposit()
函数中的 require
导致 revert 的情况。vm.expectRevert
在期望发生 revert 之前调用。
function test_BuyerDepositWrongPrice() public {
vm.expectRevert("incorrect amount");
depositContract.buyerDeposit{value: 1 ether + 1 wei}();
vm.expectRevert("incorrect amount");
depositContract.buyerDeposit{value: 1 ether - 1 wei}();
}
以下是使用了自定义 error 的合约。
contract CustomErrorContract {
error SomeError(uint256);
function revertError(uint256 x) public pure {
revert SomeError(x);
}
}
测试函数如下:
error SomeError(uint256);
function test_Revert() public {
vm.expectRevert(abi.encodeWithSelector(SomeError.selector, 6));
customErrorContract.revertError(6);
}
vm.expectEmit
测试事件还是使用以上的例子,需要测试 buyerDeposit()
函数中的 Deposited
事件。vm.expectEmit
的使用有些反直觉,你需要在测试文件中写一个同样的事件,并且按照以下例子中的顺序编写。
event Deposited(address indexed);
function test_BuyerDepositEvent() public {
vm.expectEmit();
emit Deposited(buyer);
depositContract.buyerDeposit{value: 1 ether}();
}
vm.warp
调整 block.timestamp
现在我们考虑一个带有时间锁的提现场景:买家存款,卖家可以在 3 天后提取付款。
contract Deposit {
address public seller;
mapping(address => uint256) public depositTime;
event Deposited(address indexed);
event SellerWithdraw(address indexed, uint256 indexed);
constructor(address _seller) {
seller = _seller;
}
function buyerDeposit() external payable {
require(msg.value == 1 ether, "incorrect amount");
uint256 _depositTime = depositTime[msg.sender];
require(_depositTime == 0, "already deposited");
depositTime[msg.sender] = block.timestamp;
emit Deposited(msg.sender);
}
function sellerWithdraw(address buyer) external {
require(msg.sender == seller, "not the seller");
uint256 _depositTime = depositTime[buyer];
require(_depositTime != 0, "buyer did not deposit");
require(block.timestamp - _depositTime > 3 days, "refund period not passed");
delete depositTime[buyer];
emit SellerWithdraw(buyer, block.timestamp);
(bool ok, ) = msg.sender.call{value: 1 ether}("");
require(ok, "seller did not withdraw");
}
}
我们想要测试卖家不能在存款后的 3 天内提取资金。
注意,block.timestamp
默认从 1 开始。所以我们应该首先使用 vm.warp(x)
调整到当前时间。
这是使用 vm.warp
调整时间的方式,但因为每个测试用例一般都需要修改时间,我们可以使用修饰符:
modifier startAtPresentDay() {
vm.warp(1729072888);
_;
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Deposit} from "../src/Deposit.sol";
contract DepositTest is Test {
Deposit public deposit;
Deposit public faildeposit;
address constant SELLER = address(0x5E11E7);
RejectTransaction private rejector;
event Deposited(address indexed);
event SellerWithdraw(address indexed, uint256 indexed);
function setUp() public {
deposit = new Deposit(SELLER);
rejector = new RejectTransaction();
faildeposit = new Deposit(address(rejector));
}
modifier startAtPresentDay() {
vm.warp(1729072888);
_;
}
address public buyer = address(this);
// 测试卖家不能在买家存款后的 3 天之内提款。
function testBuyerDepositSellerWithdrawBefore3days() public startAtPresentDay {
vm.startPrank(buyer);
deposit.buyerDeposit{value: 1 ether}();
assertEq(address(deposit).balance, 1 ether, "Contract balance did not increase");
vm.stopPrank();
vm.startPrank(SELLER);
vm.warp(1729072888 + 2 days);
vm.expectRevert();
deposit.sellerWithdraw(address(this));
}
}
vm.roll
调整 block.number
如果你想要调整 block.number
,可以使用:
vm.roll(blockNumber)
要向前推进一定数量的区块,可以这样做:
vm.roll(block.number() + numberOfBlocks)
在之前的例子中,卖家在 3 天后可以提取 ETH,如果我们要测试合约转 ETH 转账失败的情况,需要一定的技巧来达到完整的代码覆盖率。
我们可以编写一个 RejectTransaction
合约,使得不能接收 ETH 的转入。
contract RejectTransaction {
receive() external payable {
revert("Revert");
}
}
以下是测试 require(ok...)
失败的测试用例:
function testRejectedWithdrawl() public startAtPresentDay {
vm.startPrank(buyer); // msg.sender == buyer
faildeposit.buyerDeposit{value: 1 ether}();
vm.stopPrank();
assertEq(address(faildeposit).balance, 1 ether, "assertion failed");
vm.warp(1729072888 + 3 days + 1 seconds); // 3 days and 1 second later...
vm.startPrank(address(rejector)); // msg.sender == rejector
vm.expectRevert();
faildeposit.sellerWithdraw(buyer);
vm.stopPrank();
}
虽然我们可以使用错误的卖家地址来调用 sellerWithdraw()
,但是如果系统可以自动尝试不同的值会更好。当测试用例有传参时,Foundry 会自动测试多个不同的值。为了防止随机到不合适的值,可以使用 vm.assume
。使用 testFuzz
作为名称开头。
function testFuzzInvalidSellerAddress(address notSeller) public {
vm.assume(notSeller != SELLER);
vm.startPrank(notSeller);
vm.expectRevert("not the seller");
deposit.sellerWithdraw(buyer);
vm.stopPrank();
}
console.log
调试需要确保导入了 console
:
import {console} from "forge-std/Test.sol";
然后用以下命令运行测试:
forge test -vv
以下是使用 OZ 库,创建和验证 ECDSA 签名的例子。
Verifier.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract Verifier {
using ECDSA for bytes32;
address public verifyingAddress;
constructor(address _verifyingAddress) {
verifyingAddress = _verifyingAddress;
}
function verifyV1(
string calldata message,
bytes32 r,
bytes32 s,
uint8 v
) public view {
bytes32 signedMessageHash = keccak256(abi.encode(message))
.toEthSignedMessageHash();
require(
signedMessageHash.recover(v, r, s) == verifyingAddress,
"signature not valid v1"
);
}
function verifyV2(
string calldata message,
bytes calldata signature
) public view {
bytes32 signedMessageHash = keccak256(abi.encode(message))
.toEthSignedMessageHash();
require(
signedMessageHash.recover(signature) == verifyingAddress,
"signature not valid v2"
);
}
}
Verifier.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {Test, console} from "forge-std/Test.sol";
import {Verifier} from "../src/Verifier.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract TestSigs1 is Test {
using ECDSA for bytes32;
Verifier verifier;
address owner;
uint256 privateKey =
0x1010101010101010101010101010101010101010101010101010101010101010;
function setUp() public {
owner = vm.addr(privateKey);
verifier = new Verifier(owner);
}
function testVerifyV1andV2() public {
string memory message = "attack at dawn";
bytes32 msgHash = keccak256(abi.encode(message))
.toEthSignedMessageHash();
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, msgHash);
bytes memory signature = abi.encodePacked(r, s, v);
assertEq(signature.length, 65);
console.logBytes(signature);
verifier.verifyV1(message, r, s, v);
verifier.verifyV2(message, signature);
}
}
要测试 internal
函数,只能再写一个合约继承原来的合约,使用 external
函数调用 internal
函数。比如,以下的例子:
contract InternalFunction {
uint256 private constant REWARD_RATE_PER_SECOND = 1e18;
function calculateReward(uint256 depositTime) internal view returns (uint256 reward) {
reward = (block.timestamp - depositTime) * REWARD_RATE_PER_SECOND;
}
}
contract InternalFunctionHarness is InternalFunction {
function calculateReward_HARNESS(uint256 depositTime) external view returns (uint256 reward) {
reward = calculateReward(depositTime);
}
}
如果想要测试 private
函数,可以考虑修改成 internal
函数,这对 gas 并无影响。
vm.deal
和 vm.hoax
设置地址余额vm.hoax
允许你同时设置地址余额并伪造调用者身份。
vm.hoax(addressToPrank, balanceToGive);
// next call is a prank for addressToPrank
vm.deal(alice, balanceToGive);
有时我们需要主网上的数据,我们可以 fork 指定的链,再跑测试。
在 .env 中保存主网 RPC。
MAINNET_RPC=
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
uint256 mainnetFork;
string mainnetRPC = vm.envString("MAINNET_RPC");
Counter public counter;
function setUp() public {
mainnetFork = vm.createFork(mainnetRPC);
vm.selectFork(mainnetFork);
counter = new Counter();
counter.setNumber(0);
}
function testActiveFort() public {
assertEq(vm.activeFork(), mainnetFork);
}
}
也可以使用命令,指定 fork 的网络和指定区块号
forge test --match-contract CounterTest --fork-url <RPC_URL> --fork-block-number <BLOCK_NUMBER>
启动本地节点:
anvil
// 获取 rpc http://127.0.0.1:8545
新建终端,再部署合约:
forge create --rpc-url http://127.0.0.1:8545 --constructor-args 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 src/Deposit.sol:Deposit
// --constructor-args 为构造函数参数
forge create
--rpc-url <RPC_URL>
--constructor-args <CONSTRUCTOR_ARGS>
--private-key <PK>
--etherscan-api-key <ETHERSCAN_API_KEY>
--verify src/Deposit.sol:Deposit
或者在 foundry.toml 文件中配置 etherscan 的 API KEY:
[etherscan]
sepolia = { key = "${ETHERSCAN_API_KEY}" }
并且在 .env 中存放 ETHERSCAN_API_KEY:
ETHERSCAN_API_KEY=
在 .env 中保存 RPC 和 私钥:
SEPOLIA_RPC_URL=
PRIVATE_KEY=0x...
编辑 foundry.toml 文件:
[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
local = "http://127.0.0.1:8545"
在 script 目录下创建脚本 Deposit.s.sol:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console} from "forge-std/Script.sol";
import {Deposit} from "../src/Deposit.sol";
contract DepositScript is Script {
Deposit public deposit;
function setUp() public {}
function run() public {
uint256 privateKey = vm.envUint("PRIVATE_KEY");
address deployerAddress = vm.addr(privateKey);
vm.startBroadcast(privateKey);
deposit = new Deposit(deployerAddress);
console.log("Deposit deployed on %s", address(deposit));
vm.stopBroadcast();
}
}
运行部署脚本:
forge clean && forge script script/Deposit.s.sol --rpc-url sepolia --broadcast
# 获取余额
cast balance --rpc-url <RPC_URL> <address>
# 获取 chain ID
cast chain-id --rpc-url <RPC_URL>
# 获取该节点使用的客户端软件的类型和版本信息
cast client --rpc-url <RPC_URL>
export mainnet=https://eth.llamarpc.com
# 获取最新区块号
cast block-number --rpc-url $mainnet
# 获取区块出块时间,默认最新,也可指定区块
cast age --rpc-url $mainnet
cast age --rpc-url $mainnet 1
# 根据时间戳获取区块
cast find-block --rpc-url $mainnet 1729072888
# 获取区块内容
cast block --rpc-url $mainnet
cast block --rpc-url $mainnet 20990795
cast block --rpc-url $mainnet --json
cast block --rpc-url $mainnet --field number
cast block --rpc-url $mainnet --field hash
cast block --rpc-url $mainnet pending
cast block --rpc-url $mainnet --full
# 获取当前 gas 价格
cast gas-price --rpc-url $mainnet
# 获取 basefee
cast base-fee --rpc-url $mainnet
cast base-fee --rpc-url $mainnet 20990795
export mainnet=https://eth.llamarpc.com
# 对参数进行编码和解码
cast abi-encode "transfer(address, uint256)" 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 10000
cast --abi-decode "transfer()(address, uint256)" 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000002710
# 对调用方法和参数编码和解码
cast calldata "transfer(address, uint256)" 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 10000
cast --calldata-decode "transfer(address, uint256)" 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000002710
cast pretty-calldata 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000002710
cast 4byte-decode 0xa9059cbb000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb922660000000000000000000000000000000000000000000000000000000000002710
# 对调用方法解码
cast 4byte 0xa9059cbb
# 对事件 Topics 进行解码
cast 4byte-event 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
# 将字符串转换成 bytes32
cast --format-bytes32-string "hello world"
# 将字节转换成字符串 string
cast --parse-bytes32-string "0x68656c6c6f20776f726c64000000000000000000000000000000000000000000"
# 将字符串转换成 utf8
cast --from-utf8 "hello world"
# 将 ascii 转换成字符串
cast --to-ascii "0x68656c6c6f20776f726c64"
# 将整数转换成 32 字节
cast --to-uint256 10
# 获取余额
cast balance --rpc-url $mainnet vitalik.eth
cast balance --rpc-url $mainnet --ether 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
# 获取 nonce 值
cast nonce --rpc-url $mainnet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
# 获取 ENS
cast lookup-address --rpc-url $mainnet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045
# 获取 ENS 对应地址
cast resolve-name --rpc-url $mainnet vitalik.eth
# 获取存储槽 slot 数据
cast storage --rpc-url $mainnet 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 0
# 获取合约的 bytescode
cast code --rpc-url $mainnet 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2
# 获取合约源代码
export ETHERSCAN_API_KEY=
cast etherscan-source 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
cast etherscan-source -d weth 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
开启 anvil
,以下命令是在本地节点运行。如果需要对测试网或者主网进行操作,需要增加 --rpc-url。
export privateKey=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# 转账
cast send --private-key $privateKey 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --value 10ether
# 获取账号余额
cast balance 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --ether
# 创建合约
forge create --private-key $privateKey src/Counter.sol:Counter
# 调用合约方法
cast send --private-key $privateKey 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 "setNumber(uint256)" 100
# 调用 static 合约方法
cast call 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 "number()(uint256)"
# 获取 Transation 信息
cast tx 0x914610aa6c9bb7659a9b5b8ae1c0575b4dfce51226a01ecd1180d7553977d0e1
# 创建钱包方式一
cast wallet new
# 创建钱包方式二
mkdir keystore
cast wallet new keystore
# 根据 json 钱包获取地址
cast wallet address --keystore 1c0ac11a-d844-4bf6-be36-81f2da400f19
# 签名
export privateKey=0x113d463b15d61eb6df9182a7c45c8b952a9768b7a84e1d4e471731c271360ca6
cast wallet sign --private-key $privateKey "hello"
# 验签
cast wallet verify --address 0xEF9D0359bD4Ade81386C49e91D3dB75c2b75A1C8 "hello" 0x8b3c393dcea9794ad7aebe10436b9cdaea6efef3841cee59aae38e5ccaf1fd33105aa97a838b92e50d28e65828e7483f14f857bdb1e10d51e05a8769bc50f20b1c
# 生成靓号
cast wallet vanity --starts-with 00 --ends-with 00
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!