Skip to content

Commit bb41bc0

Browse files
authored
docs: add erc20 invariant testing examples (#548)
1 parent c6aa169 commit bb41bc0

19 files changed

Lines changed: 336 additions & 112 deletions

.github/workflows/test-long.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ jobs:
2222
testname:
2323
- "tests/solver"
2424
- "examples/simple"
25+
- "examples/invariants"
2526
- "examples/tokens/ERC20"
2627
- "examples/tokens/ERC721"
2728

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#### Usage Examples
44

55
- [Simple examples](simple/test/)
6+
- [Invariant testing](invariants/test/)
67
- [ERC20](tokens/ERC20/test/): verifies OpenZeppelin, Solady, Solmate ERC20 tokens, and CurveTokenV3.
78
- Includes identifying the DEI token bug exploited in the [Deus DAO hack](https://rekt.news/deus-dao-r3kt/).
89
- [ERC721](tokens/ERC721/test/): verifies OpenZeppelin, Solady, and Solmate ERC721 tokens.

examples/invariants/foundry.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[profile.default]
2+
src = "src"
3+
out = "out"
4+
libs = ["../../tests/lib", "lib"]
5+
6+
evm_version = 'cancun'

examples/invariants/remappings.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
openzeppelin/=../../tests/lib/openzeppelin-contracts/contracts/

examples/invariants/src/ERC20.sol

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// SPDX-License-Identifier: AGPL-3.0
2+
pragma solidity >=0.8.0 <0.9.0;
3+
4+
// from https://github.com/aviggiano/halmos-stateful-erc20/blob/main/src/ERC20.sol
5+
6+
import {ERC20 as OpenZeppelinERC20} from "openzeppelin/token/ERC20/ERC20.sol";
7+
import {Ownable} from "openzeppelin/access/Ownable.sol";
8+
9+
contract ERC20 is OpenZeppelinERC20, Ownable {
10+
constructor(string memory name, string memory symbol) OpenZeppelinERC20(name, symbol) Ownable(msg.sender) { }
11+
12+
function mint(address to, uint256 amount) public virtual onlyOwner {
13+
_mint(to, amount);
14+
}
15+
16+
function burn(address from, uint256 amount) public virtual onlyOwner {
17+
_burn(from, amount);
18+
}
19+
}

examples/invariants/src/ERC721.sol

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// SPDX-License-Identifier: AGPL-3.0
2+
pragma solidity >=0.8.0 <0.9.0;
3+
4+
import {ERC721 as OpenZeppelinERC721} from "openzeppelin/token/ERC721/ERC721.sol";
5+
import {Ownable} from "openzeppelin/access/Ownable.sol";
6+
7+
contract ERC721 is OpenZeppelinERC721, Ownable {
8+
constructor(string memory name, string memory symbol) OpenZeppelinERC721(name, symbol) Ownable(msg.sender) { }
9+
10+
function mint(address to, uint256 tokenId) public virtual onlyOwner {
11+
_mint(to, tokenId);
12+
}
13+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// SPDX-License-Identifier: AGPL-3.0
2+
pragma solidity >=0.8.0 <0.9.0;
3+
4+
// inspired by https://github.com/aviggiano/halmos-stateful-erc20
5+
6+
import {ERC20} from "../src/ERC20.sol";
7+
import {Test} from "forge-std/Test.sol";
8+
9+
/// @custom:halmos --solver bitwuzla-abs --loop 3 --solver-timeout-assertion 0
10+
contract ERC20Test is Test {
11+
// token address
12+
ERC20 internal token;
13+
14+
// token holders
15+
address[] internal holders;
16+
17+
function setUp() public virtual {
18+
// deploy token
19+
token = new ERC20("ERC20", "ERC20");
20+
21+
// declare token holders
22+
holders = new address[](3);
23+
holders[0] = address(0x1001);
24+
holders[1] = address(0x1002);
25+
holders[2] = address(0x1003);
26+
27+
// setup initial balances
28+
for (uint i = 0; i < holders.length; i++) {
29+
token.mint(holders[i], 1_000_000e18);
30+
}
31+
32+
// register this contract as the target to call the handler functions
33+
targetContract(address(this));
34+
}
35+
36+
/*
37+
* handlers
38+
*
39+
* handlers are used to track token holders, allowing us to iterate over
40+
* them when calculating the sum of balances for specifying invariants.
41+
* for simplicity, in this example, only the initial token holders are
42+
* allowed as recipients of tokens.
43+
*/
44+
45+
function transfer(address to, uint256 amount) public asCaller assumeHolder(to) returns (bool) {
46+
return token.transfer(to, amount);
47+
}
48+
49+
function transferFrom(address from, address to, uint256 amount) public asCaller assumeHolder(to) returns (bool) {
50+
return token.transferFrom(from, to, amount);
51+
}
52+
53+
function mint(address to, uint256 amount) public asCaller assumeHolder(to) {
54+
token.mint(to, amount);
55+
}
56+
57+
function burn(address from, uint256 amount) public asCaller {
58+
token.burn(from, amount);
59+
}
60+
61+
function approve(address spender, uint256 amount) public asCaller returns (bool) {
62+
return token.approve(spender, amount);
63+
}
64+
65+
/*
66+
* helpers
67+
*/
68+
69+
modifier asCaller() {
70+
vm.startPrank(msg.sender);
71+
_;
72+
vm.stopPrank();
73+
}
74+
75+
modifier assumeHolder(address account) {
76+
vm.assume(_contains(holders, account));
77+
_;
78+
}
79+
80+
function _contains(address[] storage array, address value) internal view returns (bool) {
81+
for (uint256 i = 0; i < array.length; i++) {
82+
if (array[i] == value) {
83+
return true;
84+
}
85+
}
86+
return false;
87+
}
88+
89+
// invariant: the sum of balances must equal the total supply
90+
function invariant_sumOfBalancesEqualsTotalSupply() public view {
91+
uint256 sumOfBalances = 0;
92+
for (uint256 i = 0; i < holders.length; i++) {
93+
sumOfBalances += token.balanceOf(holders[i]);
94+
}
95+
assertEq(sumOfBalances, token.totalSupply());
96+
}
97+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// SPDX-License-Identifier: AGPL-3.0
2+
pragma solidity >=0.8.0 <0.9.0;
3+
4+
import {ERC721} from "../src/ERC721.sol";
5+
import {Test} from "forge-std/Test.sol";
6+
7+
/// @custom:halmos --solver bitwuzla-abs --solver-timeout-assertion 0
8+
contract ERC721Test is Test {
9+
// ERC721 token address
10+
ERC721 internal token;
11+
12+
function setUp() public virtual {
13+
// deploy ERC721 token
14+
token = new ERC721("ERC721", "ERC721");
15+
16+
// mint initial tokens
17+
token.mint(address(0x1001), 1);
18+
token.mint(address(0x1001), 2);
19+
token.mint(address(0x1002), 3);
20+
token.mint(address(0x1003), 4);
21+
22+
// register the token contract as the target for invariant testing
23+
targetContract(address(token));
24+
}
25+
26+
// invariant: the owner of any token ID must have at least one token
27+
function invariant_ownerHasAtLeastOneToken() public {
28+
uint256 tokenId = vm.randomUint();
29+
try token.ownerOf(tokenId) returns (address owner) {
30+
assertGe(token.balanceOf(owner), 1);
31+
} catch {
32+
// nothing to do if the token does not exist
33+
}
34+
}
35+
}

0 commit comments

Comments
 (0)