Skip to content

Commit

Permalink
test: medusa testing campaign (#4)
Browse files Browse the repository at this point in the history
  • Loading branch information
0xteddybear authored Nov 1, 2024
1 parent 1404bca commit 028c189
Show file tree
Hide file tree
Showing 19 changed files with 1,561 additions and 11 deletions.
29 changes: 29 additions & 0 deletions .github/workflows/medusa.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: CI

on: [push]

jobs:
medusa-tests:
name: Medusa Test
runs-on: ubuntu-latest
container: ghcr.io/defi-wonderland/eth-security-toolbox-ci:dev

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

- name: Setup Node.js 16.x
uses: actions/setup-node@master
with:
node-version: 16.x
cache: yarn

- name: Install dependencies
working-directory: ./packages/contracts-bedrock
run: yarn --frozen-lockfile --network-concurrency 1

- name: Run Medusa
working-directory: ./packages/contracts-bedrock
run: medusa fuzz --test-limit 200000
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,4 @@ contract BalanceClaimer is Semver, IBalanceClaimer {

_canClaimTokens = MerkleProof.verify(_proof, ROOT, _leaf);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ contract BondManager_Test is Test {
unchecked {
vm.assume(block.timestamp + minClaimHold > minClaimHold);
}
assumeNoPrecompiles(owner);
assumeNotPrecompile(owner);

// Post the bond
vm.deal(address(this), amount);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ contract L1StandardBridge_WithdrawErc20Balance_Test is Bridge_Initializer {
{
uint8 _claimArraySize;
for (uint256 _i; _i < _fuzzBalances.length; _i++) {
assumeNoPrecompiles(_fuzzBalances[_i].token);
assumeNotPrecompile(_fuzzBalances[_i].token);
if (_fuzzBalances[_i].balance > 0) {
_claimArraySize++;
vm.mockCall(
Expand Down
6 changes: 3 additions & 3 deletions packages/contracts-bedrock/contracts/test/SafeCall.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ contract SafeCall_Test is CommonTest {
vm.assume(from.balance == 0);
vm.assume(to.balance == 0);
// no precompiles (mainnet)
assumeNoPrecompiles(to, 1);
assumeNotPrecompile(to, 1);
// don't call the vm
vm.assume(to != address(vm));
vm.assume(from != address(vm));
Expand Down Expand Up @@ -54,7 +54,7 @@ contract SafeCall_Test is CommonTest {
vm.assume(from.balance == 0);
vm.assume(to.balance == 0);
// no precompiles (mainnet)
assumeNoPrecompiles(to, 1);
assumeNotPrecompile(to, 1);
// don't call the vm
vm.assume(to != address(vm));
vm.assume(from != address(vm));
Expand Down Expand Up @@ -94,7 +94,7 @@ contract SafeCall_Test is CommonTest {
vm.assume(from.balance == 0);
vm.assume(to.balance == 0);
// no precompiles (mainnet)
assumeNoPrecompiles(to, 1);
assumeNotPrecompile(to, 1);
// don't call the vm
vm.assume(to != address(vm));
vm.assume(from != address(vm));
Expand Down
52 changes: 52 additions & 0 deletions packages/contracts-bedrock/contracts/test/invariants/PROPERTIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Scope

- OptimismPortal's withdrawEthBalance function: contracts/L1/OptimismPortal.sol:504
- L1StandardBridge's withdrawErc20Balance function: contracts/L1/L1StandardBridge.sol:272
- BalanceClaimer contract: contracts/L1/winddown/BalanceClaimer.sol

# Properties

| Id | Properties | Type | Checked |
| --- | --------------------------------------------------- | ------------ | --- |
| 1 | a valid claim should be redeemable once | State transition | [x] |
| 2 | a valid claim should not be redeemable more than once | State transition | [x] |
| 3 | a user should be set as claimed when they process a claim | State transition | [x] |
| 4 | an invalid claim should not be redeemable | State transition | [x] |
| 5 | for each token, token.balanceOf(L1StandardBridge) == initialBalance - sum of claims | High-level | [x] |
| 6 | OptimismPortal.balance == initialBalance - sum of claims | High-level | [x] |


## testing methodology
The fact that the state root is not writeable in the lifetime of the contract is cool from a design standpoint, but that also means the root has to be generated, and the valid claims chosen, before it makes sense to call any other handler.

As a first approach, we've meta-programmed a solidity source file with a hard-coded set of valid claims, which can be refreshed by calling `./generate-random-tree.sh`.
This is not ideal, as all fuzzing runs are going to run on the same merkle tree instead of letting the fuzzer explore new ones. Some alternatives are described below:

### mutate the state root
Idea for this is to initialize the BalanceClaimer in the campaign constructor with either

- [ ] an empty state root (for ...purity? ie allowing the fuzzer choose the inputs with the greatest variability)
- [ ] pre-filled state root (to cover code faster) and set of valid claims, with the downside of calls creating

and have handlers to _add_ valid claims to the set, overwriting the state root

This has the downside of being dissimilar to the actual production usage in a very crucial way, but also the invariant we would be breaking (the state root not changing) can be easily enforced by the compiler (ie: make the field immutable), and the upside of exploring a lot of possible trees in a simpler way

### use a modifier to ensure the first call of the sequence initializes a state root
this would involve
- [ ] not creating the balanceClaimer in the constructor
- [ ] have a modifier (and an extra param of fuzzed input in every handler/property check) which will be used to initialize the state root on the first call
- [ ] have all handlers afterwards only process claims (valid or not, obviously) and not create new ones

This has the upside of being identical to the production setup, but would yield uglier code and potentially have worse pseudorandom input since we would be having all the state as fields of structs in arrays

# nice to haves
- [ ] use tokens' actual bytecode in the fuzzing campaign
- [ ] use a full uint256 for the range of the amounts in merkle tree
- [ ] use bigger number in script
- [ ] handle fails caused by insufficient balances
- [ ] create a larger share of claims with incomplete list of tokens or zero eth
- [ ] handlers for withdraw{Erc20,Eth}Balance methods
- [ ] guided
- [ ] unguided

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

import {BalanceClaimerGuidedHandlers} from "./handlers/guided/BalanceClaimer.t.sol";
import {BalanceClaimerUnguidedHandlers} from "./handlers/unguided/BalanceClaimer.t.sol";
import {BalanceClaimerProperties} from "./properties/BalanceClaimer.t.sol";

contract FuzzTest is BalanceClaimerGuidedHandlers, BalanceClaimerUnguidedHandlers, BalanceClaimerProperties {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/bin/sh

claims_file=./setup/ClaimList.t.sol
claims_amount=100
users_amount=100

random_int() {
# 2^ 64 - 2 , max range for shuf, == 18e18, not ideal.
echo "$(shuf -n 1 -i 0-18446744073709551614)"
}

cat - > "$claims_file" <<EOF
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
contract ClaimsList {
struct ClaimEntry {
address recipient;
uint256 ethAmount;
uint256 daiAmount;
uint256 gtcAmount;
uint256 usdtAmount;
uint256 usdcAmount;
}
ClaimEntry[] internal randomClaims;
constructor() {
// ensure at least one all-zero claim
randomClaims.push(ClaimEntry(0x0000000000000000000000000000000000010000, 0, 0, 0, 0, 0));
// one zero-erc20 claim
randomClaims.push(ClaimEntry(0x0000000000000000000000000000000000020000, 1e18, 0, 0, 0, 0));
// one zero-eth claim
randomClaims.push(ClaimEntry(0x0000000000000000000000000000000000030000, 0, 100e18, 0, 0, 0));
// remaining randomly-generated claims
EOF
for i in $(seq "$claims_amount") ; do
# have some overlap with medusa's actors
recipient="address($(shuf -n 1 -i 1-${users_amount}) << 16)";
ethAmount=$(random_int)
daiAmount=$(random_int)
gtcAmount=$(random_int)
usdtAmount=$(random_int)
usdcAmount=$(random_int)
echo " randomClaims.push(ClaimEntry($recipient, $ethAmount, $daiAmount, $gtcAmount, $usdtAmount, $usdcAmount));" >> "$claims_file"

done


cat - >> "$claims_file" <<EOF
}
}
EOF
forge fmt "$claims_file"
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

import {IErc20BalanceWithdrawer} from "contracts/L1/interfaces/winddown/IErc20BalanceWithdrawer.sol";
import {BalanceClaimerSetup} from "../../setup/BalanceClaimer.t.sol";

contract BalanceClaimerGuidedHandlers is BalanceClaimerSetup {
function handler_claim(uint256 claimIndex) external {
claimIndex = bound(claimIndex, 0, ghost_validClaims.length - 1);
Claim memory claim = ghost_validClaims[claimIndex];
bytes32 hashedClaim = _hashClaim(claim);
bytes32[] memory proof = getProof(tree, getIndex(tree, hashedClaim));
vm.prank(msg.sender);
// prop-id 1
try balanceClaimer.claim(proof, claim.user, claim.ethAmount, _claimToErc20ClaimArray(claim)) {
ghost_claimed[claim.user] = true;
ghost_claimedEther += claim.ethAmount;
for (uint256 i = 0; i < claim.tokens.length; i++) {
ghost_claimedTokens[claim.tokens[i]] += claim.tokenAmounts[i];
}
} catch {
// prop-id 2
assert(ghost_claimed[claim.user]);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

import {BalanceClaimerSetup} from "../../setup/BalanceClaimer.t.sol";
import {IErc20BalanceWithdrawer} from "contracts/L1/interfaces/winddown/IErc20BalanceWithdrawer.sol";
import {IBalanceClaimer} from "contracts/L1/interfaces/winddown/IBalanceClaimer.sol";

contract BalanceClaimerUnguidedHandlers is BalanceClaimerSetup {
function handler_claim(
bytes32[] calldata _proof,
address _user,
uint256 _ethBalance,
IErc20BalanceWithdrawer.Erc20BalanceClaim[] calldata _erc20Claim,
address _caller
) external {
bytes32 hash = _hashClaim(_user, _ethBalance, _erc20Claim);
vm.prank(_caller);
try balanceClaimer.claim(_proof, _user, _ethBalance, _erc20Claim) {
//prop-id 1
assert(ghost_claimInTree[hash]);
//prop-id 2;
assert(!ghost_claimed[_user]);
ghost_claimed[_user]=true;
} catch {
assert(
!ghost_claimInTree[hash] // prop-id 4
|| ghost_claimed[_user] //prop-id 3
);
}
}

function handler_canClaim(
bytes32[] calldata _proof,
address _user,
uint256 _ethBalance,
IErc20BalanceWithdrawer.Erc20BalanceClaim[] calldata _erc20Claim
) external {
bytes32 hash = _hashClaim(_user, _ethBalance, _erc20Claim);
if (balanceClaimer.canClaim(_proof, _user, _ethBalance, _erc20Claim)) {
//prop-id 1
assert(ghost_claimInTree[hash]);
// prop-id 2
assert(!ghost_claimed[_user]);
} else {
assert(
!ghost_claimInTree[hash] // prop-id 4
|| ghost_claimed[_user] //prop-id 3
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

import {BalanceClaimerSetup} from "../setup/BalanceClaimer.t.sol";

contract BalanceClaimerProperties is BalanceClaimerSetup {
/// @custom:property-id 5
/// @custom:property for each token, token.balanceOf(L1StandardBridge) == initialBalance - sum of claims
function property_tokenBalancesSum() external view {
for (uint256 i = 0; i < supportedTokens.length; i++) {
assert(
supportedTokens[i].balanceOf(address(l1StandardBridge))
== INITIAL_BALANCE - ghost_claimedTokens[address(supportedTokens[i])]
);
}
}

/// @custom:property-id 6
/// @custom:property OptimismPortal.balance == initialBalance - sum of claims
function property_ethBalancesSum() external view {
assert(address(optimismPortal).balance == INITIAL_BALANCE - ghost_claimedEther);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

import {BalanceClaimer} from "contracts/L1/winddown/BalanceClaimer.sol";
import {OptimismPortal} from "contracts/L1/OptimismPortal.sol";
import {L1StandardBridge} from "contracts/L1/L1StandardBridge.sol";
import {L1ChugSplashProxy} from "contracts/legacy/L1ChugSplashProxy.sol";
import {L2OutputOracle} from "contracts/L1/L2OutputOracle.sol";
import {SystemConfig} from "contracts/L1/SystemConfig.sol";
import {Proxy} from "contracts/universal/Proxy.sol";

import {FuzzERC20} from "./Tokens.t.sol";
import {Claims} from "./Claims.t.sol";

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

contract BalanceClaimerSetup is CommonBase, StdUtils, Claims {
L1StandardBridge internal l1StandardBridge;
OptimismPortal internal optimismPortal;
BalanceClaimer internal balanceClaimer;

constructor() {
Proxy balanceClaimerProxy = new Proxy(address(this));

L1StandardBridge l1StandardBridgeImpl = new L1StandardBridge(payable(0), payable(address(balanceClaimerProxy)));
OptimismPortal optimismPortalImpl = new OptimismPortal({
_l2Oracle: L2OutputOracle(address(0)),
_guardian: address(0),
_paused: false,
_config: SystemConfig(address(0)),
_balanceClaimer: address(balanceClaimerProxy)
});
// Get the proxies for L1StandardBridge and OptimismPortal
L1ChugSplashProxy l1StandardBridgeProxy = new L1ChugSplashProxy(address(this));
Proxy optimismPortalProxy = new Proxy(address(this));

BalanceClaimer balanceClaimerImpl =
new BalanceClaimer(address(optimismPortalProxy), address(l1StandardBridgeProxy), tree[0]);

// Set BalanceClaimer implementation
balanceClaimerProxy.upgradeTo(address(balanceClaimerImpl));
optimismPortalProxy.upgradeTo(address(optimismPortalImpl));
l1StandardBridgeProxy.setCode(address(l1StandardBridgeImpl).code);

optimismPortal = OptimismPortal(payable(optimismPortalProxy));
l1StandardBridge = L1StandardBridge(payable(l1StandardBridgeProxy));
balanceClaimer = BalanceClaimer(address(balanceClaimerProxy));

// cant do this in Tokens because l1StandardBridge address is not set at that time
vm.deal(address(optimismPortal), INITIAL_BALANCE);
for (uint256 i = 0; i < TOKENS; i++) {
FuzzERC20(address(supportedTokens[i])).mint(address(l1StandardBridge), INITIAL_BALANCE);
}
}

/// @custom:prop-id 0
/// @custom:prop sanity checks for setup
function property_setup() external {
assert(address(optimismPortal.BALANCE_CLAIMER()) == address(balanceClaimer));
assert(address(balanceClaimer.ETH_BALANCE_WITHDRAWER()) == address(optimismPortal));
assert(address(balanceClaimer.ERC20_BALANCE_WITHDRAWER()) == address(l1StandardBridge));
assert(address(l1StandardBridge.BALANCE_CLAIMER()) == address(balanceClaimer));
assert(balanceClaimer.ROOT() == tree[0]);
}
}
Loading

0 comments on commit 028c189

Please sign in to comment.