From c2969a6275a6f65ee2f19b0a8af57c7c53df3058 Mon Sep 17 00:00:00 2001 From: Daejun Park Date: Mon, 28 Jul 2025 17:32:45 -0700 Subject: [PATCH 1/5] feat: add asset balance check to round-trip properties --- ERC4626.prop.sol | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/ERC4626.prop.sol b/ERC4626.prop.sol index c34512b..061149d 100644 --- a/ERC4626.prop.sol +++ b/ERC4626.prop.sol @@ -245,8 +245,19 @@ abstract contract ERC4626Prop is Test { // round trip properties // + modifier checkNoFreeProfit(address caller) { + uint256 assetsBefore = _getTotalAssets(caller); + _; + uint256 assetsAfter = _getTotalAssets(caller); + assertApproxLeAbs(assetsAfter, assetsBefore, _delta_); + } + + function _getTotalAssets(address account) internal returns (uint256) { + return vault_convertToAssets(IERC20(_vault_).balanceOf(account)) + IERC20(_underlying_).balanceOf(account); + } + // redeem(deposit(a)) <= a - function prop_RT_deposit_redeem(address caller, uint assets) public { + function prop_RT_deposit_redeem(address caller, uint assets) public checkNoFreeProfit(caller) { if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint shares = vault_deposit(assets, caller); vm.prank(caller); uint assets2 = vault_redeem(shares, caller, caller); @@ -256,7 +267,7 @@ abstract contract ERC4626Prop is Test { // s = deposit(a) // s' = withdraw(a) // s' >= s - function prop_RT_deposit_withdraw(address caller, uint assets) public { + function prop_RT_deposit_withdraw(address caller, uint assets) public checkNoFreeProfit(caller) { if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint shares1 = vault_deposit(assets, caller); vm.prank(caller); uint shares2 = vault_withdraw(assets, caller, caller); @@ -264,7 +275,7 @@ abstract contract ERC4626Prop is Test { } // deposit(redeem(s)) <= s - function prop_RT_redeem_deposit(address caller, uint shares) public { + function prop_RT_redeem_deposit(address caller, uint shares) public checkNoFreeProfit(caller) { vm.prank(caller); uint assets = vault_redeem(shares, caller, caller); if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint shares2 = vault_deposit(assets, caller); @@ -274,7 +285,7 @@ abstract contract ERC4626Prop is Test { // a = redeem(s) // a' = mint(s) // a' >= a - function prop_RT_redeem_mint(address caller, uint shares) public { + function prop_RT_redeem_mint(address caller, uint shares) public checkNoFreeProfit(caller) { vm.prank(caller); uint assets1 = vault_redeem(shares, caller, caller); if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint assets2 = vault_mint(shares, caller); @@ -282,7 +293,7 @@ abstract contract ERC4626Prop is Test { } // withdraw(mint(s)) >= s - function prop_RT_mint_withdraw(address caller, uint shares) public { + function prop_RT_mint_withdraw(address caller, uint shares) public checkNoFreeProfit(caller) { if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint assets = vault_mint(shares, caller); vm.prank(caller); uint shares2 = vault_withdraw(assets, caller, caller); @@ -292,7 +303,7 @@ abstract contract ERC4626Prop is Test { // a = mint(s) // a' = redeem(s) // a' <= a - function prop_RT_mint_redeem(address caller, uint shares) public { + function prop_RT_mint_redeem(address caller, uint shares) public checkNoFreeProfit(caller) { if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint assets1 = vault_mint(shares, caller); vm.prank(caller); uint assets2 = vault_redeem(shares, caller, caller); @@ -300,7 +311,7 @@ abstract contract ERC4626Prop is Test { } // mint(withdraw(a)) >= a - function prop_RT_withdraw_mint(address caller, uint assets) public { + function prop_RT_withdraw_mint(address caller, uint assets) public checkNoFreeProfit(caller) { vm.prank(caller); uint shares = vault_withdraw(assets, caller, caller); if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint assets2 = vault_mint(shares, caller); @@ -310,7 +321,7 @@ abstract contract ERC4626Prop is Test { // s = withdraw(a) // s' = deposit(a) // s' <= s - function prop_RT_withdraw_deposit(address caller, uint assets) public { + function prop_RT_withdraw_deposit(address caller, uint assets) public checkNoFreeProfit(caller) { vm.prank(caller); uint shares1 = vault_withdraw(assets, caller, caller); if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint shares2 = vault_deposit(assets, caller); From c728fd39c23e62490454aaf33c32cfb05f763598 Mon Sep 17 00:00:00 2001 From: Daejun Park Date: Mon, 28 Jul 2025 17:42:53 -0700 Subject: [PATCH 2/5] feat: add optional flag to skip round trip shares validation --- ERC4626.prop.sol | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ERC4626.prop.sol b/ERC4626.prop.sol index 061149d..8d59c85 100644 --- a/ERC4626.prop.sol +++ b/ERC4626.prop.sol @@ -47,6 +47,7 @@ abstract contract ERC4626Prop is Test { bool internal _vaultMayBeEmpty; bool internal _unlimitedAmount; + bool internal _skipRoundTripShares; // // asset @@ -271,7 +272,7 @@ abstract contract ERC4626Prop is Test { if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint shares1 = vault_deposit(assets, caller); vm.prank(caller); uint shares2 = vault_withdraw(assets, caller, caller); - assertApproxGeAbs(shares2, shares1, _delta_); + if (!_skipRoundTripShares) assertApproxGeAbs(shares2, shares1, _delta_); } // deposit(redeem(s)) <= s @@ -279,7 +280,7 @@ abstract contract ERC4626Prop is Test { vm.prank(caller); uint assets = vault_redeem(shares, caller, caller); if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint shares2 = vault_deposit(assets, caller); - assertApproxLeAbs(shares2, shares, _delta_); + if (!_skipRoundTripShares) assertApproxLeAbs(shares2, shares, _delta_); } // a = redeem(s) @@ -297,7 +298,7 @@ abstract contract ERC4626Prop is Test { if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint assets = vault_mint(shares, caller); vm.prank(caller); uint shares2 = vault_withdraw(assets, caller, caller); - assertApproxGeAbs(shares2, shares, _delta_); + if (!_skipRoundTripShares) assertApproxGeAbs(shares2, shares, _delta_); } // a = mint(s) @@ -325,7 +326,7 @@ abstract contract ERC4626Prop is Test { vm.prank(caller); uint shares1 = vault_withdraw(assets, caller, caller); if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); vm.prank(caller); uint shares2 = vault_deposit(assets, caller); - assertApproxLeAbs(shares2, shares1, _delta_); + if (!_skipRoundTripShares) assertApproxLeAbs(shares2, shares1, _delta_); } // From b601286c9b2c91a1799f086c0c980173f3972f0c Mon Sep 17 00:00:00 2001 From: Daejun Park Date: Mon, 28 Jul 2025 17:47:03 -0700 Subject: [PATCH 3/5] update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 13cb7d8..8d6ac23 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ contract ERC4626StdTest is ERC4626Test { _delta_ = 0; _vaultMayBeEmpty = false; _unlimitedAmount = false; + _skipRoundTripShares = false; } } ``` @@ -85,6 +86,7 @@ Specifically, set the state variables as follows: - `_delta_`: the maximum approximation error size to be passed to [`assertApproxEqAbs()`]. It must be given as an absolute value (not a percentage) in the smallest unit (e.g., Wei or Satoshi). Note that all the tests are expected to pass with `__delta__ == 0` as long as your vault follows the [preferred rounding direction] as specified in the standard. If your vault doesn't follow the preferred rounding direction, you can set `__delta__` to a reasonable size of rounding errors where the adversarial profit of exploiting such rounding errors stays sufficiently small compared to the gas cost. (You can read our [post] for more about the adversarial profit.) - `_vaultMayBeEmpty`: when set to false, fuzz inputs that empties the vault are ignored. - `_unlimitedAmount`: when set to false, fuzz inputs are restricted to the currently available amount from the caller. Limiting the amount can speed up fuzzing, but may miss some edge cases. +- `_skipRoundTripShares`: when set to true, shares inequality assertions in round trip property tests are skipped. This is useful for testing vaults where shares comparison may not yield expected results due to implementation-specific behavior (see issue #13 for examples). [`assertApproxEqAbs()`]: From cc52384a4ce94571fb2332ea6fbc5001ae063f96 Mon Sep 17 00:00:00 2001 From: Daejun Park Date: Mon, 28 Jul 2025 17:49:53 -0700 Subject: [PATCH 4/5] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d6ac23..830f775 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Specifically, set the state variables as follows: - `_delta_`: the maximum approximation error size to be passed to [`assertApproxEqAbs()`]. It must be given as an absolute value (not a percentage) in the smallest unit (e.g., Wei or Satoshi). Note that all the tests are expected to pass with `__delta__ == 0` as long as your vault follows the [preferred rounding direction] as specified in the standard. If your vault doesn't follow the preferred rounding direction, you can set `__delta__` to a reasonable size of rounding errors where the adversarial profit of exploiting such rounding errors stays sufficiently small compared to the gas cost. (You can read our [post] for more about the adversarial profit.) - `_vaultMayBeEmpty`: when set to false, fuzz inputs that empties the vault are ignored. - `_unlimitedAmount`: when set to false, fuzz inputs are restricted to the currently available amount from the caller. Limiting the amount can speed up fuzzing, but may miss some edge cases. -- `_skipRoundTripShares`: when set to true, shares inequality assertions in round trip property tests are skipped. This is useful for testing vaults where shares comparison may not yield expected results due to implementation-specific behavior (see issue #13 for examples). +- `_skipRoundTripShares`: when set to true, shares inequality assertions in round trip properties are skipped. This is useful for testing vaults where shares comparison may not yield expected results due to implementation-specific behavior (see issue [#13](https://github.com/a16z/erc4626-tests/issues/13) for examples). [`assertApproxEqAbs()`]: From b96c4f1798671cd4ed97147545dc68f4cc4768c1 Mon Sep 17 00:00:00 2001 From: Daejun Park Date: Mon, 28 Jul 2025 17:52:19 -0700 Subject: [PATCH 5/5] org --- ERC4626.prop.sol | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ERC4626.prop.sol b/ERC4626.prop.sol index 8d59c85..ea57c5e 100644 --- a/ERC4626.prop.sol +++ b/ERC4626.prop.sol @@ -246,17 +246,6 @@ abstract contract ERC4626Prop is Test { // round trip properties // - modifier checkNoFreeProfit(address caller) { - uint256 assetsBefore = _getTotalAssets(caller); - _; - uint256 assetsAfter = _getTotalAssets(caller); - assertApproxLeAbs(assetsAfter, assetsBefore, _delta_); - } - - function _getTotalAssets(address account) internal returns (uint256) { - return vault_convertToAssets(IERC20(_vault_).balanceOf(account)) + IERC20(_underlying_).balanceOf(account); - } - // redeem(deposit(a)) <= a function prop_RT_deposit_redeem(address caller, uint assets) public checkNoFreeProfit(caller) { if (!_vaultMayBeEmpty) vm.assume(IERC20(_vault_).totalSupply() > 0); @@ -329,6 +318,17 @@ abstract contract ERC4626Prop is Test { if (!_skipRoundTripShares) assertApproxLeAbs(shares2, shares1, _delta_); } + modifier checkNoFreeProfit(address caller) { + uint256 assetsBefore = _getTotalAssets(caller); + _; + uint256 assetsAfter = _getTotalAssets(caller); + assertApproxLeAbs(assetsAfter, assetsBefore, _delta_); + } + + function _getTotalAssets(address account) internal returns (uint256) { + return vault_convertToAssets(IERC20(_vault_).balanceOf(account)) + IERC20(_underlying_).balanceOf(account); + } + // // utils //