Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
034461a
fv: add ERC4626 harness skeleton
EperezOk Aug 25, 2026
8e102c2
fv: add ERC4626 summary layer and smoke rules
EperezOk Aug 25, 2026
5344cef
fv: add ERC4626 solvency property (P1)
EperezOk Aug 25, 2026
ab3b299
fv: add ERC4626 round-trip and rounding-gap rules (P2)
EperezOk Aug 27, 2026
33dfd37
fv: add ERC4626 max/preview boundary rules (P3)
EperezOk Aug 27, 2026
b51a3ae
fv: add ERC4626 conservation and isolation rules
EperezOk Aug 27, 2026
e65a5a0
fv: shard the ERC4626 config with a complement
EperezOk Aug 27, 2026
d5dd165
fv: add ERC4626 anti-splitting rules (P4)
EperezOk Aug 27, 2026
bc9f572
fv: add ERC4626 rate monotonicity rule (P5)
EperezOk Aug 28, 2026
83560b1
fv: add ERC4626 donation-safety rules (P6)
EperezOk Aug 28, 2026
ad173c6
fv: add ERC4626 donation-sandwich rule (P7)
EperezOk Aug 28, 2026
52f2478
fv: record the ERC4626 suite's ledgers and fix FV CI selection
EperezOk Aug 28, 2026
d4f0d49
fv: add the symbolic decimals-offset tier for ERC4626
EperezOk Aug 28, 2026
acc8113
fv: rename ERC4626Sandwich to ERC4626Donation and tighten the suite
EperezOk Aug 28, 2026
a568aa9
fv: clarify the offset-pricing note in ERC4626Offset.spec
EperezOk Aug 28, 2026
3079569
fv: link the timeout ledger without line anchors
EperezOk Aug 28, 2026
3500ada
fv: point the timeout-ledger links at fv-certora.yml
EperezOk Aug 29, 2026
b71e1cc
ci: fail on a changed shared spec instead of running every config
EperezOk Aug 29, 2026
664d961
ci: select Certora configs by following spec imports
EperezOk Aug 29, 2026
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
57 changes: 50 additions & 7 deletions .github/workflows/fv-certora.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ jobs:
java: 'on'
python: 'on'
python-requirements: 'fv-requirements.txt'
# certoraRun reads `forge remappings` when forge is present, which emits a `hardhat`
# key that package.json also provides, and it rejects the duplicate. This job has no
# use for forge.
foundry: 'off'
- name: identify specs that need to be run
id: arguments
env:
Expand All @@ -52,13 +56,32 @@ jobs:
run: |
if [[ ${{ github.event_name }} = 'pull_request_target' && ${{ contains(github.event.pull_request.labels.*.name, 'formal-verification-force-all') }} = 'false' ]];
then
# Spec names come from the file names of a pull request that may be untrusted, and end
# up on a command line downstream. Anything that is not a plain name is dropped here.
RESULT=$(git diff "$HEAD_SHA".."$BASE_SHA" --name-only -- 'fv/specs/*.spec' | while IFS= read -r file; do
[[ -f $file ]] || continue
name=$(basename "${file%.spec}")
[[ $name =~ ^[A-Za-z0-9_-]+$ ]] && echo "$name" || echo "Ignoring spec with unexpected name: $name" >&2
done | tr "\n" " ")
# Map each changed spec to the configs that verify it, following imports: a spec that
# no config names (a base, a helper, a methods file) is verified through the specs that
# import it, transitively. Imports are relative to the importing file's directory.
importers() {
find fv/specs -name '*.spec' | while IFS= read -r spec; do
dir=${spec%/*}
sed -n 's/^import "\([^"]*\)".*/\1/p' "$spec" | while IFS= read -r imp; do
if [[ "$dir/${imp#./}" == "$1" ]]; then echo "$spec"; fi
done
done
}
specs=$(git diff "$HEAD_SHA".."$BASE_SHA" --name-only -- 'fv/specs/*.spec' | while IFS= read -r file; do if [[ -f $file ]]; then echo "$file"; fi; done)
while :; do
next=$({ echo "$specs"; while IFS= read -r file; do if [[ -n $file ]]; then importers "$file"; fi; done <<< "$specs"; } | sort -u)
if [[ $next == "$specs" ]]; then break; fi
specs=$next
done
confs=$(while IFS= read -r file; do if [[ -n $file ]]; then grep -lF "$file" fv/specs/*.conf || true; fi; done <<< "$specs" | sort -u)
if [[ -n $specs && -z $confs ]]; then echo "Changed specs are verified by no config, directly or through an importer." >&2; fi
# Config names come from the file names of a pull request that may be untrusted, and
# end up on a command line downstream. Anything that is not a plain name is dropped.
RESULT=$(while IFS= read -r conf; do
if [[ -z $conf ]]; then continue; fi
name=${conf##*/}; name=${name%.conf}
if [[ $name =~ ^[A-Za-z0-9_-]+$ ]]; then echo "$name"; else echo "Ignoring config with unexpected name: $name" >&2; fi
done <<< "$confs" | tr "\n" " ")
else
RESULT='--all'
fi
Expand All @@ -71,3 +94,23 @@ jobs:
make -C fv apply
read -r -a specs <<< "$SPEC_ARGUMENTS"
node fv/run.js "${specs[@]}" -p 1 -v >> "$GITHUB_STEP_SUMMARY"

# ┌────────────────────────────────────────────────────────────────────────────────────────┐
# │ Timeout ledger: rules that are NOT proved. Excluded from every config, so their │
# │ absence from a green run is not a pass. Re-attempt manually with the commands below. │
# └────────────────────────────────────────────────────────────────────────────────────────┘
#
# ERC4626 -- both hold under exhaustive small-value and large random sampling, so these are
# solver cost rather than defects. Last attempted with nonlinear arithmetic forced, splitting
# off, a ten-seed solver race, smt_timeout 1500 and global_timeout 3600.
#
# certoraRun fv/specs/ERC4626_split.conf --rule withdrawIsNotSplittable
# https://prover.certora.com/output/1392759/b8f698631bf44d2692643a24910ab356?anonymousKey=4665bdc5b6546cb042145a333d271d06c89e44a4
# timed out at 3003s; its strictness witness verifies in 9s
#
# certoraRun fv/specs/ERC4626_split.conf --rule holdingBackAcrossDonationDoesNotPay
# https://prover.certora.com/output/1392759/b8f698631bf44d2692643a24910ab356?anonymousKey=4665bdc5b6546cb042145a333d271d06c89e44a4
# timed out at 1504s; its arithmetic core verifies in ~1s in closed form, so the cost is
# the five-operation replay rather than the share math
#
# What the suite assumes rather than proves is recorded at the top of fv/specs/ERC4626Base.spec.
15 changes: 15 additions & 0 deletions fv/harnesses/ERC4626Harness.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

import {ERC4626, ERC20, IERC20} from "../patched/token/ERC20/extensions/ERC4626.sol";

contract ERC4626Harness is ERC4626 {
constructor(IERC20 asset_, string memory name_, string memory symbol_) ERC20(name_, symbol_) ERC4626(asset_) {}

/// @dev Not part of ERC-4626. Models an asset donation: an external actor sending assets to the vault without
/// minting shares. Present so parametric rules and invariants range over it.
function donate(uint256 assets) public {
_transferIn(_msgSender(), assets);
}
}
42 changes: 42 additions & 0 deletions fv/harnesses/ERC4626OffsetHarness.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.24;

import {ERC4626, ERC20, IERC20} from "../patched/token/ERC20/extensions/ERC4626.sol";

/// @dev ERC4626Harness with the decimals offset left symbolic instead of pinned to zero. The offset
/// is an immutable the prover treats as an unconstrained constant, so rules verified here hold for
/// every offset a deployment could choose, not just the default.
contract ERC4626OffsetHarness is ERC4626 {
uint8 private immutable _offset;

constructor(
IERC20 asset_,
string memory name_,
string memory symbol_,
uint8 offset_
) ERC20(name_, symbol_) ERC4626(asset_) {
_offset = offset_;
}

function _decimalsOffset() internal view override returns (uint8) {
return _offset;
}

/// @dev Exposed so rules can constrain the offset and state the range they hold over.
function decimalsOffset() public view returns (uint8) {
return _offset;
}

/// @dev The virtual share supply, 10**offset. Exposed so rules can state bounds in terms of the
/// quantity the conversions actually use, rather than re-deriving the exponentiation in CVL.
function virtualShares() public view returns (uint256) {
return 10 ** _decimalsOffset();
}

/// @dev Not part of ERC-4626. Models an asset donation: an external actor sending assets to the
/// vault without minting shares. Present so parametric rules and invariants range over it.
function donate(uint256 assets) public {
_transferIn(_msgSender(), assets);
}
}
21 changes: 21 additions & 0 deletions fv/specs/ERC4626.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"build_cache": true,
"exclude_rule": [
"depositIsNotSplittable",
"withdrawIsNotSplittable",
"holdingBackAcrossDonationDoesNotPay"
],
"files": [
"fv/harnesses/ERC4626Harness.sol"
],
"global_timeout": 600,
"msg": "ERC4626 tier 1: everything but the anti-splitting rules",
"optimistic_loop": true,
"parametric_contracts": [
"ERC4626Harness"
],
"process": "emv",
"rule_sanity": "basic",
"url_visibility": "public",
"verify": "ERC4626Harness:fv/specs/ERC4626.spec"
}
Loading
Loading