Skip to content
Closed
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
19 changes: 19 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,25 @@ jobs:
run: just forge-build
id: build

- name: Run Forge coverage
run: just coverage-lcov
id: coverage

- name: Check coverage threshold
run: |
LH=$(grep "^LH:" lcov.info | cut -d: -f2 | paste -sd+ | bc)
LF=$(grep "^LF:" lcov.info | cut -d: -f2 | paste -sd+ | bc)
echo "Lines covered: $LH / $LF"
PERCENT=$((LH * 100 / LF))
echo "Coverage: $PERCENT% (threshold: 70%)"
if [ "$PERCENT" -lt 70 ]; then echo "FAIL: coverage below 70%"; exit 1; fi

- name: Run semgrep
uses: returntocorp/semgrep-action@v1
with:
config: p/security-audit p/severity-high
continue-on-error: false

- name: Validate semver-lock
id: semver-lock
run: |
Expand Down
53 changes: 53 additions & 0 deletions docs/upgrade-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Upgrade Testing Guide

This document describes how to run and maintain upgrade tests for the Base contracts.

## Overview

Upgrade tests use pinned block numbers to fork the chain at a specific point and run contract tests against that historical state. This ensures upgrades are tested against real onchain data.

## Block Number Configuration

Block numbers are configured in `justfile`:

```justfile
export sepoliaBlockNumber := "9366100"
export mainnetBlockNumber := "23530400"
```

These values are used by the `pinned-block-tests` and `coverage` targets.

## Running Upgrade Tests

```bash
# Run tests against Sepolia fork
just test-fork sepolia

# Run tests against Mainnet fork
just test-fork mainnet

# Run coverage against pinned blocks
just coverage-upgrade
```

## Updating Block Numbers

When to update:
- The pinned block is older than 90 days
- A major upgrade has been deployed
- Tests start failing due to chain state changes

How to update:
1. Find the current block number:
```bash
cast block-number --rpc-url <RPC_URL>
```
2. Update the value in `justfile`
3. Run `just test-fork <network>` to verify tests still pass
4. Commit with message: `test: update <network>BlockNumber to <new_number>`

## Staleness Check

A CI check enforces that block numbers are refreshed regularly. If a block is older than 90 days, the CI will fail with a warning to update.

This prevents tests from running against significantly outdated chain state, which could miss issues present in newer contract versions.
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ validate-spacers: build validate-spacers-no-build

# Runs semgrep on the contracts.
semgrep:
cd ../../ && semgrep scan --config .semgrep/rules/ ./packages/contracts-bedrock
cd ../../ && semgrep scan --config .semgrep/rules/ ./src

# Runs semgrep tests.
semgrep-test:
Expand Down
107 changes: 107 additions & 0 deletions test/invariants/OptimismPortal2.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;

import { CommonTest } from "test/setup/CommonTest.sol";

/// @title OptimismPortal2_Invariants
/// @notice Invariant tests for OptimismPortal2. These tests verify critical security
/// properties of the portal's configuration and access controls.
contract OptimismPortal2_Invariants is CommonTest {
/// @notice Handler that performs deposits on the OptimismPortal2.
PortalActor actor;

function setUp() public override {
super.setUp();
actor = new PortalActor(address(optimismPortal2), vm);

// Target the actor contract for fuzzing
targetContract(address(actor));

// Limit to depositTransaction selector
bytes4[] memory selectors = new bytes4[](1);
selectors[0] = actor.deposit.selector;
targetSelector(FuzzSelector({ addr: address(actor), selectors: selectors }));

// Fund the actor for deposits
vm.deal(address(actor), 1000 ether);
}

/// @custom:invariant The minimum gas limit for any calldata size must be greater than 0.
/// A zero gas limit would make the portal unusable for deposits.
function invariant_minimum_gas_limit_nonzero() external view {
// Test various calldata sizes to ensure minimumGasLimit never returns 0
for (uint64 i = 0; i < 128; i++) {
uint64 minGas = optimismPortal2.minimumGasLimit(i);
assertTrue(minGas > 0, "minimumGasLimit returned zero");
}
}

/// @custom:invariant The portal's paused state must be consistent with the system config.
/// If the system config says the system is paused, the portal must also
/// report itself as paused.
function invariant_paused_state_consistency() external view {
assertEq(optimismPortal2.paused(), systemConfig.paused());
}

/// @custom:invariant The portal must always have sufficient ETH balance to cover at minimum
/// the deposits that have been made. This is a sanity check that the
/// portal's balance is not artificially depleted.
function invariant_portal_has_minimum_balance() external view {
// The portal should always have at least some minimal balance from initial funding
// This is a sanity check - in production the portal should have significant balance
assertTrue(address(optimismPortal2).balance >= 1 wei, "portal balance is zero");
}
}

/// @title PortalActor
/// @notice Actor contract that performs calls to OptimismPortal2 for invariant testing.
contract PortalActor {
IOptimismPortal2 public portal;
Vm public vm;

constructor(address _portal, Vm _vm) {
portal = IOptimismPortal2(_portal);
vm = _vm;
}

/// @notice Perform a deposit transaction to the portal.
/// @param _to Target address on L2.
/// @param _value ETH value to send.
/// @param _gasLimit Amount of L2 gas to purchase.
/// @param _isCreation Whether the transaction is a contract creation.
/// @param _data Data to trigger the recipient with.
function deposit(
address _to,
uint256 _value,
uint64 _gasLimit,
bool _isCreation,
bytes memory _data
) external payable {
// Bound values to prevent extreme cases
_value = bound(_value, 0, 100 ether);
_gasLimit = uint64(bound(_gasLimit, 21000, 10_000_000));

// Ensure actor has enough ETH
if (address(this).balance < _value) {
vm.deal(address(this), _value + 1 ether);
}

portal.depositTransaction{ value: _value }(_to, _value, _gasLimit, _isCreation, _data);
}
}

/// @notice Interface for OptimismPortal2 external functions used in testing.
/// @dev This duplicates the interface to avoid import complexity in the actor.
interface IOptimismPortal2 {
function depositTransaction(
address _to,
uint256 _value,
uint64 _gasLimit,
bool _isCreation,
bytes memory _data
) external payable;

function minimumGasLimit(uint64 _byteCount) external pure returns (uint64);

function paused() external view returns (bool);
}