Deep EVM #20: CI/CD for Smart Contracts — Testing, Gas Regression, and Safety
Engineering Team
Why Smart Contracts Need Their Own CI/CD
Smart contracts are not like regular software. Once deployed, they are immutable. A bug in a web app means a hotfix and a redeployment. A bug in a smart contract means lost funds, permanent state corruption, or a costly proxy upgrade. This changes the entire CI/CD philosophy: the pipeline is not just about catching regressions — it is the last line of defense before irreversible deployment.
A production-grade smart contract CI/CD pipeline must include: compilation and type checking, comprehensive test suites (unit, fuzz, invariant), gas regression tracking, static analysis for vulnerability patterns, deployment simulation, and automated contract verification.
Pipeline Architecture
Here is a complete GitHub Actions workflow for a Foundry-based smart contract project:
# .github/workflows/ci.yml
name: Smart Contract CI
on:
push:
branches: [main]
pull_request:
env:
FOUNDRY_PROFILE: ci
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
with:
version: nightly
- name: Install Huff
run: |
curl -L get.huff.sh | bash
echo "$HOME/.huff/bin" >> $GITHUB_PATH
- name: Build
run: forge build --sizes
- name: Check contract sizes
run: |
forge build --sizes 2>&1 | while read line; do
size=$(echo "$line" | grep -oP '\d+\.\d+' | head -1)
if [ ! -z "$size" ] && (( $(echo "$size > 24.0" | bc -l) )); then
echo "::error::Contract exceeds 24KB limit: $line"
exit 1
fi
done
test:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
- name: Unit tests
run: forge test --match-path "test/unit/*" -vvv
- name: Fuzz tests
run: |
forge test --match-path "test/fuzz/*" \
--fuzz-runs 50000 -vv
- name: Invariant tests
run: |
forge test --match-path "test/invariant/*" \
--invariant-runs 512 \
--invariant-depth 256 -vv
- name: Fork tests
run: |
forge test --match-path "test/fork/*" \
--fork-url ${{ secrets.ETH_RPC_URL }} -vvv
env:
ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }}
gas:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
- name: Gas snapshot
run: forge snapshot
- name: Compare gas
run: forge snapshot --check --tolerance 2
- name: Upload snapshot
uses: actions/upload-artifact@v4
with:
name: gas-snapshot
path: .gas-snapshot
static-analysis:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Slither analysis
uses: crytic/slither-action@v0.4.0
with:
sarif: results.sarif
fail-on: medium
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
- name: Aderyn analysis
run: |
cargo install aderyn
aderyn . --output report.md
- name: Upload Aderyn report
uses: actions/upload-artifact@v4
with:
name: aderyn-report
path: report.md
Gas Regression Tracking with forge snapshot
Gas regression is a critical metric for smart contracts. A 5% gas increase in a DEX router’s swap function costs real money at scale. Track it systematically:
# Generate baseline (commit to repo)
forge snapshot > .gas-snapshot
git add .gas-snapshot
git commit -m "chore: update gas snapshot"
# In CI, compare against baseline
forge snapshot --check .gas-snapshot --tolerance 2
The --tolerance 2 flag allows up to 2% gas increase before failing. For hot paths like MEV bot operations, set tolerance to 0%.
For PR comments showing gas diffs, create a custom action:
gas-report:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
- name: Generate gas diff
run: |
forge snapshot --diff .gas-snapshot > gas-diff.txt 2>&1 || true
echo "## Gas Report" > comment.md
echo '```' >> comment.md
cat gas-diff.txt >> comment.md
echo '```' >> comment.md
- name: Post PR comment
uses: marocchino/sticky-pull-request-comment@v2
with:
path: comment.md
Static Analysis: Slither and Aderyn
Slither
Slither is the industry-standard static analyzer for Solidity. It detects vulnerability patterns like reentrancy, unchecked external calls, and access control issues:
slither . --filter-paths "test/|script/|lib/" \
--exclude naming-convention,solc-version \
--fail-on medium
Common findings and how to handle them:
| Finding | Severity | Action |
|---|---|---|
| Reentrancy | High | Fix immediately, use CEI pattern |
| Unchecked return | Medium | Add return value check |
| Naming convention | Info | Suppress with --exclude |
| Unused state variable | Low | Remove or suppress |
For Huff contracts, Slither cannot analyze the bytecode directly. Instead, analyze the Solidity interface contracts and integration tests that call the Huff contracts.
Aderyn
Aderyn is a Rust-based static analyzer that complements Slither with additional checks:
aderyn . --output report.json --format json
Aderyn is particularly good at catching gas optimization opportunities and detecting centralization risks.
Deployment Checklists
Automate pre-deployment checks with a script:
// script/DeployChecklist.s.sol
contract DeployChecklist is Script {
function run() external view {
// 1. Verify compiler settings
require(block.chainid == 1, "Wrong chain!");
// 2. Verify constructor args
address expectedOwner = vm.envAddress("OWNER");
require(expectedOwner != address(0), "Owner not set");
// 3. Verify all dependencies are deployed
require(
IWETH(WETH).totalSupply() > 0,
"WETH not deployed"
);
console.log("All checks passed");
}
}
# Dry run against mainnet fork
forge script script/DeployChecklist.s.sol \
--fork-url $ETH_RPC_URL -vvv
Automated Verification on Etherscan
Post-deployment, automatically verify contracts on Etherscan so users can read the source:
forge verify-contract \
--chain mainnet \
--num-of-optimizations 200 \
--watch \
--compiler-version v0.8.24 \
--etherscan-api-key $ETHERSCAN_KEY \
$CONTRACT_ADDRESS \
src/MyContract.sol:MyContract
For contracts with constructor arguments:
# Encode constructor args
cast abi-encode "constructor(address,uint256)" $OWNER $INITIAL_SUPPLY
# Pass encoded args to verify
forge verify-contract \
--constructor-args $(cast abi-encode "constructor(address,uint256)" $OWNER $INITIAL_SUPPLY) \
$CONTRACT_ADDRESS \
src/MyContract.sol:MyContract
Automate this in CI by adding a deployment job that triggers on tag pushes:
deploy-verify:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
needs: [test, gas, static-analysis]
environment: mainnet
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: foundry-rs/foundry-toolchain@v1
- name: Deploy
run: |
forge script script/Deploy.s.sol \
--rpc-url ${{ secrets.ETH_RPC_URL }} \
--broadcast \
--verify \
--etherscan-api-key ${{ secrets.ETHERSCAN_KEY }} \
-vvv
env:
PRIVATE_KEY: ${{ secrets.DEPLOYER_KEY }}
Deployment Simulation
Before deploying to mainnet, simulate the entire deployment against a fork:
# Simulate deployment
forge script script/Deploy.s.sol \
--fork-url $ETH_RPC_URL \
--sender $DEPLOYER \
-vvvv
# If simulation passes, broadcast
forge script script/Deploy.s.sol \
--rpc-url $ETH_RPC_URL \
--broadcast \
--slow \
--verify
The --slow flag submits transactions one at a time and waits for confirmation, which is safer for multi-step deployments.
Monitoring Post-Deployment
After deployment, set up automated monitoring:
post-deploy-check:
runs-on: ubuntu-latest
needs: deploy-verify
steps:
- name: Verify deployment
run: |
# Check contract is verified on Etherscan
STATUS=$(curl -s "https://api.etherscan.io/api?module=contract&action=getabi&address=$CONTRACT" | jq -r '.status')
[ "$STATUS" = "1" ] || exit 1
# Check contract responds correctly
OWNER=$(cast call $CONTRACT "owner()" --rpc-url $RPC)
[ "$OWNER" = "$EXPECTED_OWNER" ] || exit 1
echo "Deployment verified successfully"
Security Tooling Summary
| Tool | Type | Best For |
|---|---|---|
| Slither | Static analysis | Vulnerability patterns, Solidity |
| Aderyn | Static analysis | Gas, centralization risks |
| Foundry Fuzz | Dynamic testing | Edge cases, boundary values |
| Foundry Invariant | Stateful fuzzing | State-dependent bugs |
| Echidna | Fuzzer | Property testing (alternative) |
| Mythril | Symbolic execution | Path exploration |
| Certora | Formal verification | Mathematical proofs |
Conclusion
A production-grade smart contract CI/CD pipeline is fundamentally different from traditional software CI. Every stage serves as a safety gate before irreversible deployment. Gas snapshots catch performance regressions, static analyzers flag vulnerability patterns, fuzzers explore edge cases, and deployment simulations verify behavior against real state. The pipeline’s job is simple: make it harder to deploy a bug than to catch one.