Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .gas-snapshot
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ TimeLockTest:test_OnlyAdmin_CanQueue() (gas: 16143)
TimeLockTest:test_QueueThenExecute() (gas: 55711)
TimeLockTest:test_Queue_RevertOnDuplicate() (gas: 46068)
TokenTest:testFuzz_Approve(address,uint256) (runs: 1000, μ: 36452, ~: 36831)
TokenTest:testFuzz_Transfer_PreservesTotalSupply(uint256) (runs: 1000, μ: 47212, ~: 47844)
TokenTest:test_Approve_AndTransferFrom() (gas: 72961)
TokenTest:test_InfiniteAllowance_NotDecremented() (gas: 69946)
TokenTest:testFuzz_Transfer_PreservesTotalSupply(uint256) (runs: 1000, μ: 47249, ~: 47882)
TokenTest:test_Approve_AndTransferFrom() (gas: 73017)
TokenTest:test_InfiniteAllowance_NotDecremented() (gas: 70002)
TokenTest:test_Metadata() (gas: 21459)
TokenTest:test_Transfer() (gas: 44638)
TokenTest:test_Transfer() (gas: 44676)
TokenTest:test_TransferFrom_RevertOnInsufficientAllowance() (gas: 39482)
TokenTest:test_Transfer_RevertOnInsufficientBalance() (gas: 15571)
TokenTest:test_Transfer_RevertOnZeroAddress() (gas: 11220)
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ jobs:
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1

- name: Check formatting
run: forge fmt --check

- name: Install dependencies
run: forge install

Expand All @@ -30,5 +33,5 @@ jobs:
- name: Run invariant tests
run: forge test -vvv --match-contract Invariant

- name: Gas snapshot
- name: Gas snapshot (informational)
run: forge snapshot
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
out/
cache/
broadcast/
lib/
.env
.env.*
!.env.example
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# foundry-testing

Foundry workspace built around three small contracts. The interesting part is the test suite: unit tests, fuzz tests, and a stateful invariant suite with a handler. Full suite runs in ~130ms across 13 tests, including 256 invariant runs and 3,840 random calls.
Foundry workspace built around seven small contracts. The interesting part is the test suite: unit tests, fuzz tests, and stateful invariant suites with handlers. The full suite runs in under 200ms across 51 tests, including invariant runs that exercise thousands of random call sequences.

## Stack

Expand Down Expand Up @@ -29,15 +29,26 @@ forge snapshot
forge test --gas-report
```

Formatting and the gas baseline are enforced in CI:

```bash
forge fmt --check
forge snapshot --check
```

## What's in here

| Contract | Test types |
|---|---|
| `src/Counter.sol` | unit, fuzz |
| `src/Vault.sol` | unit, fuzz, invariant (with `VaultHandler.sol`) |
| `src/Staking.sol` | unit, fuzz |
| Contract | Description | Test types |
|---|---|---|
| `src/Counter.sol` | Trivial counter | unit, fuzz |
| `src/Token.sol` | Minimal ERC20-style token | unit, fuzz |
| `src/NFT.sol` | Minimal ERC721-style NFT | unit, fuzz |
| `src/Vault.sol` | ETH vault | unit, fuzz, invariant |
| `src/Staking.sol` | ETH staking | unit, fuzz, invariant |
| `src/TimeLock.sol` | Queue/execute/cancel timelock | unit, fuzz |
| `src/Ownable.sol` | Two-step ownership mixin | unit |

Tests live in `test/`. The invariant handler in `test/VaultHandler.sol` constrains the random call surface so invariants converge instead of bouncing off reverts.
Tests live in `test/`. The invariant handler in `test/VaultInvariant.t.sol` constrains the random call surface (bounded amounts, a fixed actor set) so invariants converge instead of bouncing off reverts, and it tracks ghost variables to cross-check the contract's own accounting.

## Why Foundry over Hardhat

Expand Down
8 changes: 8 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"lib/forge-std": {
"tag": {
"name": "v1.16.1",
"rev": "620536fa5277db4e3fd46772d5cbc1ea0696fb43"
}
}
}
1 change: 1 addition & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.24"
evm_version = "cancun"
optimizer = true
optimizer_runs = 200

Expand Down
1 change: 1 addition & 0 deletions lib/forge-std
Submodule forge-std added at 620536
8 changes: 4 additions & 4 deletions src/NFT.sol
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ contract NFT {
require(to != address(0), "NFT: mint to zero");
require(_owners[tokenId] == address(0), "NFT: already minted");
_owners[tokenId] = to;
unchecked { _balances[to] += 1; }
unchecked {
_balances[to] += 1;
}
emit Transfer(address(0), to, tokenId);
}

Expand All @@ -56,9 +58,7 @@ contract NFT {
address owner = ownerOf(tokenId);
require(owner == from, "NFT: from is not owner");
require(
msg.sender == owner ||
isApprovedForAll[owner][msg.sender] ||
getApproved[tokenId] == msg.sender,
msg.sender == owner || isApprovedForAll[owner][msg.sender] || getApproved[tokenId] == msg.sender,
"NFT: not authorized"
);

Expand Down
13 changes: 11 additions & 2 deletions src/TimeLock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ contract TimeLock {
return keccak256(abi.encode(target, value, data, salt));
}

function queue(address target, uint256 value, bytes calldata data, bytes32 salt) external onlyAdmin returns (bytes32 id) {
function queue(address target, uint256 value, bytes calldata data, bytes32 salt)
external
onlyAdmin
returns (bytes32 id)
{
id = hashOp(target, value, data, salt);
if (queuedAt[id] != 0) revert AlreadyQueued();
uint256 eta = block.timestamp + delay;
Expand All @@ -47,7 +51,12 @@ contract TimeLock {
emit Cancelled(id);
}

function execute(address target, uint256 value, bytes calldata data, bytes32 salt) external payable onlyAdmin returns (bytes memory) {
function execute(address target, uint256 value, bytes calldata data, bytes32 salt)
external
payable
onlyAdmin
returns (bytes memory)
{
bytes32 id = hashOp(target, value, data, salt);
uint256 eta = queuedAt[id];
if (eta == 0) revert NotQueued();
Expand Down
10 changes: 5 additions & 5 deletions test/Token.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ contract TokenTest is Test {
function setUp() public {
token = new Token("Test", "TST", INITIAL);
// Move the entire supply to alice so tests start from a known state
token.transfer(alice, INITIAL);
assertTrue(token.transfer(alice, INITIAL));
}

function test_Metadata() public view {
Expand All @@ -26,7 +26,7 @@ contract TokenTest is Test {

function test_Transfer() public {
vm.prank(alice);
token.transfer(bob, 100 ether);
assertTrue(token.transfer(bob, 100 ether));

assertEq(token.balanceOf(alice), INITIAL - 100 ether);
assertEq(token.balanceOf(bob), 100 ether);
Expand All @@ -50,7 +50,7 @@ contract TokenTest is Test {
assertEq(token.allowance(alice, bob), 50 ether);

vm.prank(bob);
token.transferFrom(alice, bob, 30 ether);
assertTrue(token.transferFrom(alice, bob, 30 ether));

assertEq(token.balanceOf(bob), 30 ether);
assertEq(token.allowance(alice, bob), 20 ether);
Expand All @@ -70,7 +70,7 @@ contract TokenTest is Test {
token.approve(bob, type(uint256).max);

vm.prank(bob);
token.transferFrom(alice, bob, 100 ether);
assertTrue(token.transferFrom(alice, bob, 100 ether));

// Infinite allowance should be preserved across transfers
assertEq(token.allowance(alice, bob), type(uint256).max);
Expand All @@ -79,7 +79,7 @@ contract TokenTest is Test {
function testFuzz_Transfer_PreservesTotalSupply(uint256 amount) public {
amount = bound(amount, 0, INITIAL);
vm.prank(alice);
token.transfer(bob, amount);
assertTrue(token.transfer(bob, amount));

assertEq(token.balanceOf(alice) + token.balanceOf(bob), INITIAL);
assertEq(token.totalSupply(), INITIAL);
Expand Down
5 changes: 1 addition & 4 deletions test/VaultInvariant.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,7 @@ contract VaultInvariantTest is Test {

/// Ghost accounting: net flow (deposits - withdrawals) must equal contract balance
function invariant_netFlowEqualsBalance() public view {
assertEq(
address(vault).balance,
handler.ghostTotalDeposits() - handler.ghostTotalWithdrawals()
);
assertEq(address(vault).balance, handler.ghostTotalDeposits() - handler.ghostTotalWithdrawals());
}

/// totalAssets() is a thin view but should never lie
Expand Down
Loading