Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: formal verification & fuzz campaign #69

Merged
merged 35 commits into from
May 31, 2024
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
8afcd40
chore: setup
simon-something May 16, 2024
3c27899
feat: echidna
simon-something May 16, 2024
ed45d89
feat: symb exec halmos
simon-something May 16, 2024
d9eed8c
fix: invariant + optim
simon-something May 16, 2024
1601c6c
chore: dir reorg
simon-something May 17, 2024
4c17acf
feat: bulloak and .tree for unit tests
simon-something May 17, 2024
fcadbe4
feat: slither CI
simon-something May 17, 2024
fdc7473
chore: ci test and cov prototype
simon-something May 17, 2024
9a51d1f
feat: coverage check CI
simon-something May 18, 2024
35351b3
fix: ci trigger
simon-something May 18, 2024
a2a98b9
fix: coverage action
simon-something May 18, 2024
1631680
fix: typo
simon-something May 18, 2024
9f24c9b
feat: coverage check on push
simon-something May 20, 2024
322c317
chore: slither config file
simon-something May 20, 2024
0a812ac
chore: no empty block in tests
simon-something May 20, 2024
a5f2328
chore: naming
simon-something May 20, 2024
acc98d4
chore: ci dep
simon-something May 20, 2024
ac3d438
fix: typo echidna
simon-something May 20, 2024
f7884a0
fix: echidna test mode
simon-something May 20, 2024
9d512ba
fix: test mode
simon-something May 20, 2024
55f839c
chore: lock bump
simon-something May 20, 2024
db361f3
fix: rm slither from ci
simon-something May 20, 2024
53d12b9
feat: empty unit test example
simon-something May 20, 2024
c15ac7a
Update .github/workflows/coverage_check.yml
simon-something May 20, 2024
e764354
Update .github/workflows/coverage_check.yml
simon-something May 20, 2024
494dec8
Update .github/workflows/coverage_check.yml
simon-something May 20, 2024
8f46e6b
Update .github/workflows/tests.yml
simon-something May 20, 2024
380d7ab
chore: commit lint
simon-something May 27, 2024
b408a95
fix: solhint wrong rule naming
simon-something May 28, 2024
37581e6
chore: style
simon-something May 28, 2024
7ada60b
chore: styling
simon-something May 28, 2024
35ef998
chore: comment fix
simon-something May 28, 2024
9cccb64
fix: ci cov check path
simon-something May 29, 2024
a979f99
fix: ci cov second attempt
simon-something May 29, 2024
254ca83
chore: underscores
simon-something May 31, 2024
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
73 changes: 73 additions & 0 deletions .github/workflows/coverage_check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Coverage check on main push

on: [push]

env:
COVERAGE_SENSITIVITY_PERCENT: 1

jobs:
upload-coverage:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly

- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
cache: 'yarn'

- name: Install dependencies
run: yarn --frozen-lockfile --network-concurrency 1

- name: Run coverage
shell: bash
run: forge coverage --report summary --report lcov
simon-something marked this conversation as resolved.
Show resolved Hide resolved

- name: Setup LCOV
uses: hrishikesh-kadam/setup-lcov@v1

- name: Filter directories
run: lcov --remove lcov.info 'test/*' 'script/*' --output-file lcovNew.info --rc lcov_branch_coverage=1 --rc derive_function_end_line=0 --ignore-errors unused

- name: Capture coverage output
id: new-coverage
uses: zgosalvez/github-actions-report-lcov@v4
with:
coverage-files: lcovNew.info

- name: Retrieve previous coverage
uses: actions/download-artifact@v2
with:
name: coverage
path: coverage.info
continue-on-error: true

- name: Check if a previous coverage exists
run: |
if [ ! -f coverage.info ]; then
echo "Artifact not found. Initializing at 0"
echo "0" >> coverage.info
fi

- name: Compare previous coverage
run: |
old=$(cat coverage.info)
new=$(( ${{ steps.new-coverage.outputs.total-coverage }} + ${{ env.COVERAGE_SENSITIVITY_PERCENT }} ))
if [ "$new" -lt "$old" ]; then
echo "Coverage decreased from $old to $new"
exit 1
fi
mv lcovNew.info coverage.info

- name: Upload the new coverage
uses: actions/upload-artifact@v2
with:
name: coverage
path: coverage.info
gas1cent marked this conversation as resolved.
Show resolved Hide resolved
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml → .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,68 @@ jobs:
- name: Run tests
run: yarn test:integration

echidna-tests:
name: Echidna Test
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
submodules: recursive

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly

- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.x
cache: 'yarn'

- name: Install dependencies
run: yarn --frozen-lockfile --network-concurrency 1

- name: Compile contracts
run: |
forge build --build-info

- name: Run Echidna
uses: crytic/echidna-action@v2
with:
files: .
contract: InvariantGreeter
test-mode: assertion
crytic-args: --ignore-compile
Comment on lines +95 to +101
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would the action look for multiple invariant contracts? Each would have its own step?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can pass a directory (in files), contract is optional

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can pass a directory

@drgorillamd let's do that? Will be one less thing for whoever starts with the boilerplate to modify.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

til: when passing a directory, only the first contract is run, including the import (so here, it tries to run it on IERC20...). For multiple contracts, people are actually using a proxy


halmos-tests:
name: Run symbolic execution tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly

- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 18.x
cache: 'yarn'

- name: Install dependencies
run: yarn --frozen-lockfile --network-concurrency 1

- name: Precompile with via-ir=false
run: yarn build

- name: Run tests
run: yarn test:integration

lint:
name: Lint Commit Messages
runs-on: ubuntu-latest
Expand Down
6 changes: 3 additions & 3 deletions .solhint.tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@
"quotes": ["error", "single"],
"func-visibility": ["warn", { "ignoreConstructors": true }],
"not-rely-on-time": "off",
"func-name-mixedcase": "off",
"style-guide-casing": "off",
"var-name-mixedcase": "off",
"const-name-snakecase": "off",
"no-inline-assembly": "off",
"no-empty-blocks": "off",
"no-empty-blocks": "error",
"definition-name-capwords": "off",
"named-parameters-function": "off",
"no-global-import": "off",
"max-states-count": "off",
"private-vars-leading-underscore": ["warn", { "strict": false }],
"ordering": "warn",
"ordering": "off",
"immutable-name-snakecase": "warn",
"avoid-low-level-calls": "off",
"one-contract-per-file": "off",
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
<dt>Deployment scripts</dt>
<dd>Sample scripts to deploy contracts on both mainnet and testnet.</dd>

<dt>Sample Integration & Unit tests</dt>
<dt>Sample Integration, Unit, Property-based fuzzed and symbolic tests</dt>
<dd>Example tests showcasing mocking, assertions and configuration for mainnet forking. As well it includes everything needed in order to check code coverage.</dd>
<dd>Unit tests are built based on the <a href="https://twitter.com/PaulRBerg/status/1682346315806539776">Branched-Tree Technique</a>, using <a href="https://github.com/alexfertel/bulloak">Bulloak</a>.
<dd>Formal verification and property-based fuzzing are achieved with <a href="https://github.com/a16z/halmos">Halmos</a> and <a href="https://github.com/crytic/echidna">Echidna</a> (resp.).

<dt>Linter</dt>
<dd>Simple and fast solidity linting thanks to forge fmt.</dd>
Expand Down Expand Up @@ -78,6 +80,18 @@ In order to just run integration tests, run:
yarn test:integration
```

In order to just run the echidna fuzzing campaign (requires [Echidna](https://github.com/crytic/building-secure-contracts/blob/master/program-analysis/echidna/introduction/installation.md) installed), run:

```bash
yarn test:fuzz
```

In order to just run the symbolic execution tests (requires [Halmos](https://github.com/a16z/halmos/blob/main/README.md#installation) installed), run:

```bash
yarn test:symbolic
```

In order to check your current code coverage, run:

```bash
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
"lint:sol-tests": "solhint -c .solhint.tests.json 'test/**/*.sol'",
"prepare": "husky install",
"test": "forge test -vvv",
"test:fuzz": "echidna test/invariants/fuzz/Greeter.t.sol --contract GreeterInvariant --corpus-dir test/invariants/fuzz/echidna_coverage/ --test-mode assertion",
Copy link
Contributor

@0xmoebius 0xmoebius May 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to use wildcards to run against all contracts under fuzz/ ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check the previous comment from gas @0xmoebius ;)

"test:integration": "forge test --match-contract Integration -vvv",
"test:symbolic": "halmos",
"test:unit": "forge test --match-contract Unit -vvv",
"test:unit:deep": "FOUNDRY_FUZZ_RUNS=5000 yarn test:unit"
},
Expand All @@ -37,6 +39,7 @@
"@commitlint/config-conventional": "19.2.2",
"@defi-wonderland/natspec-smells": "1.1.1",
"forge-std": "github:foundry-rs/forge-std#1.8.2",
"halmos-cheatcodes": "github:a16z/halmos-cheatcodes#c0d8655",
"husky": ">=8",
"lint-staged": ">=10",
"solhint-community": "4.0.0",
Expand Down
1 change: 1 addition & 0 deletions remappings.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
forge-std/=node_modules/forge-std/src
halmos-cheatcodes=node_modules/halmos-cheatcodes

contracts/=src/contracts
interfaces/=src/interfaces
4 changes: 4 additions & 0 deletions test/invariants/PROPERTIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
| Properties | Type |
|---------------------------------------------------|------------|
| Greeting should never be empty | Valid state |
| Only the owner can set the greeting | State transition |
38 changes: 38 additions & 0 deletions test/invariants/fuzz/Greeter.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.23;

import {Greeter, IERC20} from 'contracts/Greeter.sol';

interface IHevm {
function prank(address) external;
}

contract InvariantGreeter {
// See https://github.com/a16z/halmos-cheatcodes?tab=readme-ov-file
IHevm internal _hevm = IHevm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);

Greeter internal _targetContract;

constructor() {
_targetContract = new Greeter('a', IERC20(address(1)));
}

function checkGreeterNeverEmpty(string memory newGreeting) public {
// Execution
(bool success,) = address(_targetContract).call(abi.encodeCall(Greeter.setGreeting, newGreeting));

// Check output condition
assert((success && keccak256(bytes(_targetContract.greeting())) != keccak256(bytes(''))) || !success);
}

function checkOnlyOwnerSetsGreeting(address caller) public {
// Input conditions
_hevm.prank(caller);

// Execution
(bool success,) = address(this).call(abi.encodeCall(Greeter.setGreeting, 'hello'));

// Check output condition
assert((success && msg.sender == _targetContract.OWNER()) || (!success && msg.sender != _targetContract.OWNER()));
}
}
58 changes: 58 additions & 0 deletions test/invariants/symbolic/Greeter.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.23;

import {Greeter, IERC20} from 'contracts/Greeter.sol';

import {Test} from 'forge-std/Test.sol';
import {SymTest} from 'halmos-cheatcodes/src/SymTest.sol';

contract SymbolicGreeter is SymTest, Test {
Greeter public targetContract;

function setUp() public {
string memory initialGreeting = svm.createString(64, 'initial greeting');
address token = svm.createAddress('token');

targetContract = new Greeter(initialGreeting, IERC20(token));
}

function check_validState_greeterNeverEmpty(address caller) public {
// Input conditions: any caller
vm.prank(caller);

// Execution: Halmos cannot use a dynamic-sized array, iterate over multiple string lengths
bool success;
for (uint256 i = 1; i < 3; i++) {
string memory greeting = svm.createString(i, 'greeting');
(success,) = address(targetContract).call(abi.encodeCall(Greeter.setGreeting, (greeting)));

// Output condition check
vm.assume(success); // discard failing calls
assert(keccak256(bytes(targetContract.greeting())) != keccak256(bytes('')));
}

// Add the empty string (bypass the non-empty check of svm.createString)
(success,) = address(targetContract).call(abi.encodeCall(Greeter.setGreeting, ('')));

// Output condition check
vm.assume(success); // discard failing calls
assert(keccak256(bytes(targetContract.greeting())) != keccak256(bytes('')));
}

function check_setGreeting_onlyOwnerSetsGreeting(address caller) public {
// Input conditions
string memory newGreeting = svm.createString(64, 'new greeting');

// Execution
vm.prank(caller);
(bool success,) = address(targetContract).call(abi.encodeCall(Greeter.setGreeting, (newGreeting)));

// Output condition check
if (success) {
assert(caller == targetContract.OWNER());
assert(keccak256(bytes(targetContract.greeting())) == keccak256(bytes(newGreeting)));
} else {
assert(caller != targetContract.OWNER() || keccak256(bytes(newGreeting)) == keccak256(bytes('')));
}
}
}
Loading
Loading