Q1ngying

今朝梦醒与君别,遥盼春风寄相思

0%

Foundry作弊码

Foundry 作弊码

大多数时候,仅仅测试您的智能合约输出是不够的。 为了操纵区块链的状态,以及测试特定的 reverts 和事件 Events,Foundry 附带了一组作弊码(Cheatcodes)。

在使用 Foundry 对合约进行调试的过程中,可以使用 Foundry 内置的强大的作弊码(CheatCodes)功能。

可以通过使用 Forge 标准库里面的 Test 合约中提供的 vm 示例来使用操作码。

断言部分(Assertions)

expectRevert(下面的一行代码必须回滚)

使用 Foundry 对合约进行调试时,有些时候要检验不满足设定条件时,交易是否能正确回滚revert,这个时候,就可以使用 expectRevert 作弊码

expectRevert作弊码要求其下一行的代码**必须发生revert**(忽略vm(虚拟机)里面的代码^2),否则,在终端进行调试时,调试报错。

Signature

1
2
function expectRevert() external;
// 没有参数,断言下一次调用会回滚,无论消息如何
1
2
function expectRevert(bytes4 message) external;
// 带有 `bytes4`: 断言下一次调用将以指定的 4 字节回滚
1
2
function expectRevert(bytes calldata message) external;
// 带有 `bytes`: 断言下一次调用将以指定的字节回滚

使用方法举例:

1
2
3
4
5
function testRevert() public{
vm.expectRevert(); // 下面的代码必须回滚,否则 forge test 报错
// assret(This tx fails/reverts)
target.fun();
}

可以使用在命令行使用下面的命令:

1
forge test --mt testRevert

上面的 -mt--match-test 的简写,详情参考[FoundryTest调试](./FoundryTest调试.md##forge test 参数)

对于使用后面两种方法较为复杂:

举例:

1
2
3
4
5
function testLowLevelCallRevert() public {
vm.expectRevert(bytes("error message")); // 第三种
(bool status, ) = address(myContract).call(myCalldata);
assertTrue(status, "expectRevert: call did not revert");
}

要使用expectRevert和字符串,将其转换为字节数组。

1
vm.expectRevert(bytes("error message"));

要使用expectRevert和没有参数的自定义错误类型,使用其选择器。

1
vm.expectRevert(MyContract.CustomError.selector);

要使用expectRevert和带有参数的自定义错误类型,请对错误类型进行ABI编码。

1
2
3
vm.expectRevert(
abi.encodeWithSelector(MyContract.CustomError.selector, 1, 2)
);

如果需要断言函数在没有消息的情况下回滚,可以使用expectRevert(bytes(""))

1
2
3
4
5
function testExpectRevertNoReason() public {
Reverter reverter = new Reverter();
vm.expectRevert(bytes(""));
reverter.revertWithoutReason();
}

如果需要断言函数回滚了一个四字符的消息,例如AAAA,可以这样做:

1
2
3
function testFourLetterMessage() public {
vm.expectRevert(bytes("AAAA"));
}

如果使用expectRevert("AAAA"),则使用了expectRevert(bytes4 msg)的重载,导致行为不同。

您还可以在单个测试中有多个expectRevert()检查。

1
2
3
4
5
6
7
function testMultipleExpectReverts() public {
vm.expectRevert(abi.encodePacked("INVALID_AMOUNT"));
vault.send(user, 0);

vm.expectRevert(abi.encodePacked("INVALID_ADDRESS"));
vault.send(address(0), 200);
}
1
2
3
4
5
function testTest() public {
vm.expectRevert();
vm.prank(USER); // vm(虚拟机),expectRevert 会进行忽略
fundMe.withdrow; // 上面的 expectRevert 检测的是该行 是否会滚
}

expectEmit(测试事件是否触发)

签名:

1
2
3
4
5
6
function expectEmit (
bool checkTopic1,
bool checkTopic2,
bool checkTopic3,
bool checkData
) external;
1
2
3
4
5
6
7
function expectEmit(
bool checkTopic1,
bool checkTopic2,
bool checkTopic3,
bool checkData,
address emitter
)

在当前函数结束之前,断言特定的日志(event)被触发(emit)

  1. 调用作弊码时,指定我们应该检查第一个、第二个、第三个主题(topic)以及日志数据(log data1)。作弊码失踪检查topic0
  2. 在当前函数执行结束之前,触发我们应该触发的事件
  3. 执行调用

如果事件在当前范围内不可用(例如,我们使用接口或外部合约),可以使用相同的事件签名自定义事件

这里由两种签名:

  • 不检查发送者地址:断言主题是否匹配,但是不检查触发地址
  • 带有地址:断言主题匹配并断言地址匹配

顺序很重要:

如果我们调用expectEmit并触发了一个事件,那么下一个被触发的事件必须与我们的预期一致

示例:

  • 没有地址的签名:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    event Transfer(address indexed from, address indexed to, uint256 ammount);

    function testERC20EmitsTransfer() public {
    // 只有`from`和`to`在ERC20的“Transfer”事件中是 inded,
    // 所以我们专门检查topics 1和2(默认情况下总是检查topic 0),
    // 以及数据(`amount`)。
    vm.expectEmit(true, true, false, true);

    // 我们出发的事件:我们预期触发的的事件:
    emit MyToken.Transfer(address(this), address(1), 10);

    // 执行调用
    myToken.transfer(address(1). 10);
    }
  • 有签名的地址:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    event Transfer(address indexed from, address indexed to, uint256 amount);

    function testERC20EmitsTransfer() public {
    // 通过将地址作为第五个参数传递来检测时间的触发者是否为预期的触发者
    vm.expectEmit(true, true, false, true, address(myToken));
    emit myToken.Transfer(address(this), address(1), 10);

    myToken.transfer(address(1), 10);
    }
  • 断言在单次调用中触发多个事件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    function testERC20EmitsBatchTransfer() public {
    // 声明多个预期的转账事件
    for (uint256 i = 0; i< users.length; i++) {
    vm.expectEmit(true, true, true, true);
    emit Transfer(address(this), users[i], 10);
    }

    // 我们还预期一个自定义的 `BatchTransfer(uint256 numverOfTransfers)` event。
    vm.expectEmit(false, false, false, false);
    emit BatchTransfer(users.length);

    // 执行调用
    myToken.batchTransfer(users, 10);
    }

Environment(环境)

prank

可以将msg.sender设置为指定地址,用于下一次调用。所以在使用 Foundry 调试合约的时候,可以通过prank来明确知道是谁发起了交易

Signature

1
2
function prank(address) external;
function prank(address sender, address origin) external;

注意:这只有在调试中生效,并且只适用于 foundry

需要设置origin的时候,也可以使用上面的prank(传入两个地址,第一个是msg.sender,后一个为tx.origin

在下面有一个例子,联合dealmakeAddr使用[^1]

还存在类似于startBroadcast()stopBroadcast()类似用法的startPrank()stopPrank()一组函数,这组函数之间的交易,都会被视作是传入的虚假地址发起的交易:

1
2
3
4
5
function testTest() public {
vm.startPrank(addr); // 想要指定作为 msg.sender 的地址
,,, // 要进行的操作
vm.stopPrank();
}

warp

Signature

1
function warp(uint256) external;

描述

设置block.timestamp

举例

1
2
vm.warp(1641070800);
emit log_uiint(block.timestamp); // 1641070800

rool

Signature

1
function roll(uint256) external;

描述

设置当前的区块高度

举例

1
2
vm.roll(100);
emit log_uint(block.number); // 100

deal(交易)

将一个地址的余额设置为一个新的余额

signature

1
function deal(address who, uint256 newBalance) external;
1
function deal(address token, address to, uint256 give) external; // 貌似弃用
1
function deal(address token, address to, uint256 give, bool adjust) external; // 貌似弃用

同样可以联合 prank使用[^1]

recordLogs

告诉虚拟机开始记录所有发出的事件。要访问它们,需要使用getRecordedLogs

signature

1
function recordLogs() external;

举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// event LogCompleted(
/// uint256 indexed topic1,
/// bytes data
/// );

vm.recordLogs();

emit LogCompleted(10, "operation completed");

Vm.Log[] memory entries = vm.getRecordedLogs();

assertEq(entries.length, 1);
assertEq(entries[0].topics[0], keccak256("LogCompleted(uint256,bytes)"));
assertEq(entries[0].topics[1], bytes32(uint256(10)));
assertEq(abi.decode(entries[0].data, (string)), "operation completed");

getRecordedLogs

Signature

1
2
3
4
5
6
7
struct Log {
bytes32[] topics;
bytes data;
address emitter;
}

function getRecordedLogs() external returns(Log[] memory);

描述

获取recordLogs记录的触发的事件。该函数在调用时会消耗记录的日志。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/// event LogTopic1(
/// uint256 indexed topic1,
/// bytes data
/// );

/// event LogTopic12(
/// uint256 indexed topic1,
/// uint256 indexed topic2,
/// bytes data
/// );

/// bytes memory testData0 = "Some data";
/// bytes memory testData1 = "Other data";


// Start the recorder
vm.recordLogs();

emit LogTopic1(10, testData0);
emit LogTopic12(20, 30, testData1);

// Notice that your entries are <Interface>.Log[]
// as opposed to <instance>.Log[]
Vm.Log[] memory entries = vm.getRecordedLogs();

assertEq(entries.length, 2);

// Recall that topics[0] is the event signature
assertEq(entries[0].topics.length, 2);
assertEq(entries[0].topics[0], keccak256("LogTopic1(uint256,bytes)"));
assertEq(entries[0].topics[1], bytes32(uint256(10)));
// assertEq won't compare bytes variables. Try with strings instead.
assertEq(abi.decode(entries[0].data, (string)), string(testData0));

assertEq(entries[1].topics.length, 3);
assertEq(entries[1].topics[0], keccak256("LogTopic12(uint256,uint256,bytes)"));
assertEq(entries[1].topics[1], bytes32(uint256(20)));
assertEq(entries[1].topics[2], bytes32(uint256(30)));
assertEq(abi.decode(entries[1].data, (string)), string(testData1));

// Emit another event
emit LogTopic1(40, testData0);

// Your last read consumed the recorded logs,
// you will only get the latest emitted even after that call
entries = vm.getRecordedLogs();

assertEq(entries.length, 1);

assertEq(entries[0].topics.length, 2);
assertEq(entries[0].topics[0], keccak256("LogTopic1(uint256,bytes)"));
assertEq(entries[0].topics[1], bytes32(uint256(40)));
assertEq(abi.decode(entries[0].data, (string)), string(testData0));


txGasPrice

设置 tx.gasprice 的值(实际交易中的 Gas 价格)

Signature

1
function txGasPrice(uint256) external;
1
function txGasPrice(uint256 newGasPrice) external;

External(外部)

envUint

将环境变量读取为uint256或者uint256[]

Signature

1
function envUint(string calldata key) external returns (uint256 value);
1
function envUint(string calldata key, string calldata delimiter) external returns(uint256[] memory values);

Tips:

  • 如果值以0x开头,它将被解释为十六进制,否则,将其视为十六进制。
  • 对于数组,可以指定用于分割参数与值的分隔符delimiter

例子:

  • 单个值

    已拥有环境变量:

    1
    UINT_VALUES=115792089237316195423570985008687907853269984665640564039457584007913129639935
    1
    2
    3
    4
    strinf memory key = "UINT_VALUE";
    uint256 expected = type(uint256).max;
    uint256 output = vm.envUint(key);
    assert(output == expected);
  • 数组

    已拥有环境变量:

    1
    UINT_VALUES=0,0x0000000000000000000000000000000000000000000000000000000000000000
    1
    2
    3
    4
    5
    string memory key = "UINT_VALUES";
    string memory delimiter = ",";
    uint256[2] memory expected = [type(uint256).min, type(uint256).min];
    uint256[] memory output = cheats.envUint(key, delimiter);
    assert(keccak256(abi.encodePacked((output))) == keccak256(abi.encodePacked((expected))));

Files(文件)

描述:

forge-std提供的作弊码可以用于访问系统文件。

注意:默认情况下,不允许访问文件系统,需要设置foundry.toml:

1
fs_permission = [{access = "read", path = "./images/"}]

来自Foundry官方文档:

1
2
3
4
5
6
7
8
9
10
11
# 配置允许触及文件系统的作弊码的权限,例如 `vm.writeFile`
# `access` 限制了通过作弊码可以访问 `path` 的方式
# `read-write` | `true` => 允许读取和写入访问(`vm.readFile` + `vm.writeFile`)
# `none`| `false` => 没有访问权限
# `read` => 只允许读取访问(`vm.readFile`)
# `write` => 只允许写入访问 (`vm.writeFile`)
# 通过列表列出了被认为是路径的路径,例如:'./' 表示项目根目录
# 默认情况下,不授予任何文件系统访问权限,并且不允许任何路径
以下示例仅启用对项目目录的读取权限:
'fs_permissions = [{ access = "read", path = "./"}]'
fs_permissions = [] # 默认值:禁用所有文件作弊码

Signature

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 将文件的整个内容读取为字符串,(路径) => (数据) 
function readFile(string calldata) external returns (string memory);
// 读取文件的下一行并将其作为字符串返回,(路径) => (行)
function readLine(string calldata) external returns (string memory);
// 将数据写入文件,如果文件不存在则创建新文件,并完全替换其内容。 // (路径, 数据) => ()
function writeFile(string calldata, string calldata) external;
// 将行写入文件,如果文件不存在则创建新文件。
// (路径, 数据) => ()
function writeLine(string calldata, string calldata) external;
// 关闭正在读取的文件,重置偏移量,并允许使用 readLine 从头开始读取。
// (路径)=>()
function closeFile(string calldata )external;
// 删除指定的 文件 。此作弊码在以下情况下会还原 ,但不仅限于这些情况:
// - 路径指向一个目录。
// - 文件不存在。
//- 用户没有删除该文件的权限。
//(path)=>()
function removeFile(string call data)external;
// 如果给定的路径指向现有实体,则返回 true ,否则返回 false
//(path)=>(bool)
function exists(string call data)external returns (bool);
// 如果磁盘上存在该路径并且它指向常规 文件,则返回 true ,否则返回 false
//(path)=>(bool)
function isFile(string call data)external returns (bool);
// 如果磁盘上存在该路径并且它指向目录,则返回 true ,否则返回 false
//(path)=>(bool)
function isDir(string call data)external returns (bool);

Std Cheats(非 cheatCode,可以独立使用)

makeAddr

创造一个用户来发起交易,我们可以传入一个名称,他会返回一个地址,来自forge-std文件夹

Signature

1
function makeAddr(string memory name) internal returns(address addr);

使用方法:

1
address alice = makeAddr('alice');

[^1]: 可以配合上面的 prank 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
address USER = makeAddr("user");
uint256 constant STARTING_BALANCE = 10 ether;

function setUp() external {
DepolyTest depolyTest = new DepolyTest();
test = depolyTest.run();
// 上面两行是 Test 测试时都要写的
vm.deal(USER, STARTING_BALANCE); // 给 USER 设置新的余额
}

function testTest() public {
vm.prank(USER); // 下一笔交易的 msg.sender 为 USER
}

hoax

合并了 prankdeal 功能,将一个地址设置为prank然后deal赋予一些ETH

Signature

1
function hoax(address who) public;
1
function hoax(address who, uint256 give) public;
1
function hoax(address who, address origin) public;
1
function hoax(address who, address origin, uint256 give) public;

–未完待续–