Skip to content

Commit

Permalink
test: Add basic invariant testing (SC-459) (#7)
Browse files Browse the repository at this point in the history
* feat: first test working

* feat: use larger numbers:

* feat: test with initial burn amount passing

* feat: update tests to work with updated burn logic, move conversion functions around and use previews

* feat: remove todos

* fix: update to remove console and update comment

* feat: get swap tests working

* feat: get all swap tests working

* fix: update for three assets in logic

* feat: all tests passing

* fix: rm commented out test

* feat: add preview swap tests

* feat: move logic out of single use internal and use conversion rate everywhere

* feat: move divRoundUp out of single use internal

* feat: add full coverage for conversion tests

* feat: add more preview cases

* feat: refactor PSM to use three assets

* fix: rm comment

* feat: add interface, natspec, events, referral code, tests passing

* fix: update to rm consolegp

* feat: add events testing

* feat: make precisions internal and add state var natspec

* feat: finish natspec

* feat: add readme

* feat: add referral code note

* fix: update constructor test

* fix: update links

* fix: reformatting

* fix: update testing section

* fix: improve overview

* feat: add emojis

* feat: remove all share burn logic, get all non inflation attack tests to pass

* fix: cleanup diff

* fix: update to use initial deposit instead of burn

* feat: add readme section explaining attack

* fix: minimize diff

* fix: address bartek comments

* feat: update all tests to work with new interfaces

* feat: add deposit failure mode tests

* feat: update to add assertions for return in deposit

* feat: add withdraw failure tests

* feat: update to address comments outside sharesToBurn

* feat: update inflation attack test and readme

* fix: update readme

* feat: update test to constrain deposit/withdraw

* feat: update to add both cases

* feat: update per review

* feat: update to use underscore bound, fix test

* fix: typo

* feat: add overrides, remove referrals, update referral type

* fix: update expect emit

* feat: update name and remove todos

* feat: move files and set up structure

* feat: update to rename files, contracts, and errors

* fix: rm dup file, update toml

* feat: get deposits working

* chore: refactor into proper inheritance structure

* feat: get all functions working with reverts

* feat: update conversion

* feat: get swaps working without reverts

* feat: add fully working deposit/withdraw/swaps, invariant_B failing

* ci: update for ci

* fix: update name

* chore: rm basly cased file

* chore: re add

* fix: re add invariant

* ci: experiment with 2 million total calls

* ci: add show progress flag

* fix: move file back

* ci: update verbosity

* ci: add PR profile

* fix: rm redundant files

* feat: update from review changes

* fix: update invariant

* fix: add fuzz failure

* chore: rm indexing comment

* fix: rm redundant files from merge

* feat: update to add seeding as part of invariants
  • Loading branch information
lucas-manuel authored Jun 26, 2024
1 parent dc40383 commit 427a41b
Show file tree
Hide file tree
Showing 20 changed files with 381 additions and 13 deletions.
7 changes: 2 additions & 5 deletions .github/workflows/ci.yml → .github/workflows/master.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
name: CI

on:
workflow_dispatch:
pull_request:
push:
branches:
- master
branches: [master]

env:
FOUNDRY_PROFILE: ci
Expand Down Expand Up @@ -40,7 +37,7 @@ jobs:
ARBITRUM_NOVA_RPC_URL: ${{secrets.ARBITRUM_NOVA_RPC_URL}}
GNOSIS_CHAIN_RPC_URL: ${{secrets.GNOSIS_CHAIN_RPC_URL}}
BASE_RPC_URL: ${{secrets.BASE_RPC_URL}}
run: FOUNDRY_PROFILE=ci forge test
run: FOUNDRY_PROFILE=master forge test -vv --show-progress

# coverage:
# runs-on: ubuntu-latest
Expand Down
87 changes: 87 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: CI

on: [pull_request]

env:
FOUNDRY_PROFILE: ci

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

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

- name: Build contracts
run: |
forge --version
forge build --sizes
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

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

- name: Run tests
env:
MAINNET_RPC_URL: ${{secrets.MAINNET_RPC_URL}}
OPTIMISM_RPC_URL: ${{secrets.OPTIMISM_RPC_URL}}
ARBITRUM_ONE_RPC_URL: ${{secrets.ARBITRUM_ONE_RPC_URL}}
ARBITRUM_NOVA_RPC_URL: ${{secrets.ARBITRUM_NOVA_RPC_URL}}
GNOSIS_CHAIN_RPC_URL: ${{secrets.GNOSIS_CHAIN_RPC_URL}}
BASE_RPC_URL: ${{secrets.BASE_RPC_URL}}
run: FOUNDRY_PROFILE=pr forge test -vv --show-progress

# coverage:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v3

# - name: Install Foundry
# uses: foundry-rs/foundry-toolchain@v1

# - name: Run coverage
# env:
# MAINNET_RPC_URL: ${{secrets.MAINNET_RPC_URL}}
# OPTIMISM_RPC_URL: ${{secrets.OPTIMISM_RPC_URL}}
# ARBITRUM_ONE_RPC_URL: ${{secrets.ARBITRUM_ONE_RPC_URL}}
# ARBITRUM_NOVA_RPC_URL: ${{secrets.ARBITRUM_NOVA_RPC_URL}}
# GNOSIS_CHAIN_RPC_URL: ${{secrets.GNOSIS_CHAIN_RPC_URL}}
# BASE_RPC_URL: ${{secrets.BASE_RPC_URL}}
# run: forge coverage --report summary --report lcov

# # To ignore coverage for certain directories modify the paths in this step as needed. The
# # below default ignores coverage results for the test and script directories. Alternatively,
# # to include coverage in all directories, comment out this step. Note that because this
# # filtering applies to the lcov file, the summary table generated in the previous step will
# # still include all files and directories.
# # The `--rc lcov_branch_coverage=1` part keeps branch info in the filtered report, since lcov
# # defaults to removing branch info.
# - name: Filter directories
# run: |
# sudo apt update && sudo apt install -y lcov
# lcov --remove lcov.info 'test/*' 'script/*' --output-file lcov.info --rc lcov_branch_coverage=1

# # This step posts a detailed coverage report as a comment and deletes previous comments on
# # each push. The below step is used to fail coverage if the specified coverage threshold is
# # not met. The below step can post a comment (when it's `github-token` is specified) but it's
# # not as useful, and this action cannot fail CI based on a minimum coverage threshold, which
# # is why we use both in this way.
# - name: Post coverage report
# if: github.event_name == 'pull_request' # This action fails when ran outside of a pull request.
# uses: romeovs/[email protected]
# with:
# delete-old-comments: true
# lcov-file: ./lcov.info
# github-token: ${{ secrets.GITHUB_TOKEN }} # Adds a coverage summary comment to the PR.

# - name: Verify minimum coverage
# uses: zgosalvez/github-actions-report-lcov@v2
# with:
# coverage-files: ./lcov.info
# minimum-coverage: 90 # Set coverage threshold.
18 changes: 18 additions & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ optimizer_runs = 200
[fuzz]
runs = 1000

[invariant]
runs = 20
depth = 1000

[profile.pr.invariant]
runs = 200
depth = 1000

[profile.pr.fuzz]
runs = 100_000

[profile.master.invariant]
runs = 200
depth = 10_000

[profile.master.fuzz]
runs = 1_000_000

# See more config options https://github.com/foundry-rs/foundry/tree/master/config

remappings = [
Expand Down
2 changes: 0 additions & 2 deletions src/interfaces/IPSM3.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { IERC20 } from "erc20-helpers/interfaces/IERC20.sol";

interface IPSM3 {

// TODO: Determine priority for indexing

/**********************************************************************************************/
/*** Events ***/
/**********************************************************************************************/
Expand Down
80 changes: 80 additions & 0 deletions test/invariant/Invariants.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.13;

import "forge-std/Test.sol";

import { PSMTestBase } from "test/PSMTestBase.sol";

import { LpHandler } from "test/invariant/handlers/LpHandler.sol";
import { SwapperHandler } from "test/invariant/handlers/SwapperHandler.sol";

contract PSMInvariantTests is PSMTestBase {

LpHandler public lpHandler;
SwapperHandler public swapperHandler;

address BURN_ADDRESS = makeAddr("burn-address");

// NOTE [CRITICAL]: All invariant tests are operating under the assumption that the initial seed
// deposit of 1e18 shares has been made. This is a key requirement and
// assumption for all invariant tests.
function setUp() public override {
super.setUp();

// Seed the pool with 1e18 shares (1e18 of value)
_deposit(address(dai), BURN_ADDRESS, 1e18);

lpHandler = new LpHandler(psm, dai, usdc, sDai, 3);
swapperHandler = new SwapperHandler(psm, dai, usdc, sDai, 3);

// TODO: Add rate updates
rateProvider.__setConversionRate(1.25e27);

targetContract(address(lpHandler));
targetContract(address(swapperHandler));
}

function invariant_A() public view {
assertEq(
psm.shares(address(lpHandler.lps(0))) +
psm.shares(address(lpHandler.lps(1))) +
psm.shares(address(lpHandler.lps(2))) +
1e18, // Seed amount
psm.totalShares()
);
}

function invariant_B() public view {
assertApproxEqAbs(
psm.getPsmTotalValue(),
psm.convertToAssetValue(psm.totalShares()),
2
);
}

function invariant_C() public view {
assertApproxEqAbs(
psm.convertToAssetValue(psm.shares(address(lpHandler.lps(0)))) +
psm.convertToAssetValue(psm.shares(address(lpHandler.lps(1)))) +
psm.convertToAssetValue(psm.shares(address(lpHandler.lps(2)))) +
psm.convertToAssetValue(1e18), // Seed amount
psm.getPsmTotalValue(),
4
);
}

function invariant_logs() public view {
console.log("depositCount ", lpHandler.depositCount());
console.log("withdrawCount ", lpHandler.withdrawCount());
console.log("swapCount ", swapperHandler.swapCount());
console.log("zeroBalanceCount", swapperHandler.zeroBalanceCount());
console.log(
"sum ",
lpHandler.depositCount() +
lpHandler.withdrawCount() +
swapperHandler.swapCount() +
swapperHandler.zeroBalanceCount()
);
}

}
39 changes: 39 additions & 0 deletions test/invariant/handlers/HandlerBase.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.13;

import { MockERC20 } from "erc20-helpers/MockERC20.sol";

import { CommonBase } from "forge-std/Base.sol";
import { StdCheatsSafe } from "forge-std/StdCheats.sol";
import { StdUtils } from "forge-std/StdUtils.sol";

import { PSM3 } from "src/PSM3.sol";

contract HandlerBase is CommonBase, StdCheatsSafe, StdUtils {

PSM3 public psm;

MockERC20[3] public assets;

constructor(
PSM3 psm_,
MockERC20 asset0,
MockERC20 asset1,
MockERC20 asset2
) {
psm = psm_;

assets[0] = asset0;
assets[1] = asset1;
assets[2] = asset2;
}

function _getAsset(uint256 indexSeed) internal view returns (MockERC20) {
return assets[indexSeed % assets.length];
}

function _hash(uint256 number_, string memory salt) internal pure returns (uint256 hash_) {
hash_ = uint256(keccak256(abi.encode(number_, salt)));
}

}
63 changes: 63 additions & 0 deletions test/invariant/handlers/LpHandler.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.13;

import { MockERC20 } from "erc20-helpers/MockERC20.sol";

import { HandlerBase } from "test/invariant/handlers/HandlerBase.sol";

import { PSM3 } from "src/PSM3.sol";

contract LpHandler is HandlerBase {

address[] public lps;

uint256 public depositCount;
uint256 public withdrawCount;

uint256 public constant TRILLION = 1e12;

constructor(
PSM3 psm_,
MockERC20 asset0,
MockERC20 asset1,
MockERC20 asset2,
uint256 lpCount
) HandlerBase(psm_, asset0, asset1, asset2) {
for (uint256 i = 0; i < lpCount; i++) {
lps.push(makeAddr(string(abi.encodePacked("lp-", i))));
}
}

function _getLP(uint256 indexSeed) internal view returns (address) {
return lps[indexSeed % lps.length];
}

function deposit(uint256 assetSeed, uint256 lpSeed, uint256 amount) public {
MockERC20 asset = _getAsset(assetSeed);
address lp = _getLP(lpSeed);

amount = _bound(amount, 1, TRILLION * 10 ** asset.decimals());

vm.startPrank(lp);
asset.mint(lp, amount);
asset.approve(address(psm), amount);
psm.deposit(address(asset), lp, amount);
vm.stopPrank();

depositCount++;
}

function withdraw(uint256 assetSeed, uint256 lpSeed, uint256 amount) public {
MockERC20 asset = _getAsset(assetSeed);
address lp = _getLP(lpSeed);

amount = _bound(amount, 1, TRILLION * 10 ** asset.decimals());

vm.prank(lp);
psm.withdraw(address(asset), lp, amount);
vm.stopPrank();

withdrawCount++;
}

}
Loading

0 comments on commit 427a41b

Please sign in to comment.