From 982c2fb32b3e40ccc3adebd5cd1c7e346a7725ad Mon Sep 17 00:00:00 2001 From: makemake Date: Mon, 3 Aug 2026 13:14:52 +0200 Subject: [PATCH 1/6] fix(vault): verify ERC-4626 operation state effects --- .../curve/src/LlamaLendVaultAssertion.sol | 5 + examples/spark/src/SparkVaultAssertion.sol | 10 +- .../vault/ERC4626PreviewAssertion.sol | 147 ++++++++++++++--- .../examples/MetaMorphoVaultAssertion.sol | 5 + .../vault/ERC4626PreviewAssertion.t.sol | 149 ++++++++++++++++++ 5 files changed, 289 insertions(+), 27 deletions(-) create mode 100644 test/protection/vault/ERC4626PreviewAssertion.t.sol diff --git a/examples/curve/src/LlamaLendVaultAssertion.sol b/examples/curve/src/LlamaLendVaultAssertion.sol index 30b4757..f511799 100644 --- a/examples/curve/src/LlamaLendVaultAssertion.sol +++ b/examples/curve/src/LlamaLendVaultAssertion.sol @@ -37,6 +37,11 @@ contract LlamaLendVaultAssertion is ERC4626PreviewAssertion, LlamaLendVaultProto registerTxEndTrigger(this.assertControllerCustodyCoversAvailableBalance.selector); } + /// @dev Pinned LlamaLend vault operations return the pre-state previewed amount exactly. + function _maxPreviewDeviation() internal pure override returns (uint256) { + return 0; + } + /// @notice Checks `totalAssets()` equals controller available balance plus debt minus admin fees. function assertTotalAssetsMatchesControllerAccounting() external { PhEvm.ForkId memory fork = _postTx(); diff --git a/examples/spark/src/SparkVaultAssertion.sol b/examples/spark/src/SparkVaultAssertion.sol index cae2d6c..31aa033 100644 --- a/examples/spark/src/SparkVaultAssertion.sol +++ b/examples/spark/src/SparkVaultAssertion.sol @@ -25,10 +25,7 @@ import {SparkVaultHelpers} from "./SparkVaultHelpers.sol"; /// Beyond ERC-4626, Spark's savings-rate model requires mutating accrual paths to fully /// settle pending `chi` growth for the current block, while `take()` must only move /// liquidity and `assetsOutstanding()` without changing liabilities or rate state. -contract SparkVaultAssertion is - ERC4626PreviewAssertion, - SparkVaultHelpers -{ +contract SparkVaultAssertion is ERC4626PreviewAssertion, SparkVaultHelpers { /// @param vault_ Spark vault instance whose selectors this bundle will monitor. constructor(address vault_, address asset_) ERC4626BaseAssertion(vault_, asset_) { registerAssertionSpec(AssertionSpec.Reshiram); @@ -49,6 +46,11 @@ contract SparkVaultAssertion is _registerSparkManagedLiquidityTriggers(); } + /// @dev Pinned Spark vault operations return the pre-state previewed amount exactly. + function _maxPreviewDeviation() internal pure override returns (uint256) { + return 0; + } + /// @notice Reuses the inherited share-price and preview assertions against Spark's /// referral-enabled `deposit`/`mint` overloads. /// @dev The referral forms share the leading `(assets, receiver)` / `(shares, receiver)` diff --git a/src/protection/vault/ERC4626PreviewAssertion.sol b/src/protection/vault/ERC4626PreviewAssertion.sol index 4b7add4..ef10991 100644 --- a/src/protection/vault/ERC4626PreviewAssertion.sol +++ b/src/protection/vault/ERC4626PreviewAssertion.sol @@ -23,12 +23,9 @@ import {ERC4626BaseAssertion} from "./ERC4626BaseAssertion.sol"; /// previewWithdraw rounds UP (returns more shares -> favors vault) /// previewRedeem rounds DOWN (returns fewer assets -> favors vault) /// -/// @dev Uses V2 `registerFnCallTrigger` + `ph.context()` for call-scoped triggers, -/// `ph.callinputAt()` to read call arguments, and `ph.callOutputAt()` to read the -/// actual return value — replacing the totalSupply/totalAssets delta inference from V1. -/// ERC-4626 specifies the conservative direction of previews, but it does not set a generic -/// maximum distance from the state-changing result. Concrete vaults may override -/// `_maxPreviewDeviation()` when their implementation proves a tighter bound. +/// @dev In addition to return values, every operation proves the corresponding receiver/owner +/// share delta, total-supply delta, and underlying-token movement. ERC-4626 does not define a +/// universal preview-distance bound, so every concrete supported adapter must provide one. abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { /// @notice Register the default trigger set for preview-consistency invariants. /// @dev Each ERC-4626 operation gets its own assertion function via registerFnCallTrigger. @@ -40,11 +37,8 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { } /// @notice Maximum acceptable deviation between a preview result and the actual result. - /// @dev Defaults to no generic distance cap. Override only when the concrete vault's - /// implementation proves a smaller maximum. - function _maxPreviewDeviation() internal view virtual returns (uint256) { - return type(uint256).max; - } + /// @dev Must be derived from the concrete vault implementation; no generic default is sound. + function _maxPreviewDeviation() internal view virtual returns (uint256); // --------------------------------------------------------------- // deposit: previewDeposit(assets) <= actualSharesMinted @@ -55,20 +49,32 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { /// actualSharesMinted - previewDeposit(assets) <= maxDeviation function assertDepositPreview() external { PhEvm.TriggerContext memory ctx = ph.context(); - _requireVaultConfigurationAt(_preCall(ctx.callStart)); + PhEvm.ForkId memory pre = _preCall(ctx.callStart); + PhEvm.ForkId memory post = _postCall(ctx.callEnd); + _requireVaultConfigurationAt(pre); bytes memory input = ph.callinputAt(ctx.callStart); uint256 assets = _firstUint256Arg(input); + address receiver = _receiver(input, ctx); // Preview at pre-call state - uint256 previewShares = - _readUintAt(vault, abi.encodeCall(IERC4626.previewDeposit, (assets)), _preCall(ctx.callStart)); + uint256 previewShares = _readUintAt(vault, abi.encodeCall(IERC4626.previewDeposit, (assets)), pre); // Actual return value: deposit returns shares minted uint256 actualShares = abi.decode(ph.callOutputAt(ctx.callStart), (uint256)); require(previewShares <= actualShares, "ERC4626: previewDeposit > actual shares"); require(actualShares - previewShares <= _maxPreviewDeviation(), "ERC4626: deposit preview deviates from actual"); + _requireIncrease( + _shareBalanceAt(receiver, pre), + _shareBalanceAt(receiver, post), + actualShares, + "ERC4626: deposit receiver shares mismatch" + ); + _requireIncrease(_totalSupplyAt(pre), _totalSupplyAt(post), actualShares, "ERC4626: deposit supply mismatch"); + _requireIncrease( + _assetBalanceAt(vault, pre), _assetBalanceAt(vault, post), assets, "ERC4626: deposit asset payment mismatch" + ); } // --------------------------------------------------------------- @@ -80,19 +86,34 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { /// previewMint(shares) - actualAssetsCharged <= maxDeviation function assertMintPreview() external { PhEvm.TriggerContext memory ctx = ph.context(); - _requireVaultConfigurationAt(_preCall(ctx.callStart)); + PhEvm.ForkId memory pre = _preCall(ctx.callStart); + PhEvm.ForkId memory post = _postCall(ctx.callEnd); + _requireVaultConfigurationAt(pre); bytes memory input = ph.callinputAt(ctx.callStart); uint256 shares = _firstUint256Arg(input); + address receiver = _receiver(input, ctx); - uint256 previewAssets = - _readUintAt(vault, abi.encodeCall(IERC4626.previewMint, (shares)), _preCall(ctx.callStart)); + uint256 previewAssets = _readUintAt(vault, abi.encodeCall(IERC4626.previewMint, (shares)), pre); // Actual return value: mint returns assets charged uint256 actualAssets = abi.decode(ph.callOutputAt(ctx.callStart), (uint256)); require(previewAssets >= actualAssets, "ERC4626: previewMint < actual assets"); require(previewAssets - actualAssets <= _maxPreviewDeviation(), "ERC4626: mint preview deviates from actual"); + _requireIncrease( + _shareBalanceAt(receiver, pre), + _shareBalanceAt(receiver, post), + shares, + "ERC4626: mint receiver shares mismatch" + ); + _requireIncrease(_totalSupplyAt(pre), _totalSupplyAt(post), shares, "ERC4626: mint supply mismatch"); + _requireIncrease( + _assetBalanceAt(vault, pre), + _assetBalanceAt(vault, post), + actualAssets, + "ERC4626: mint asset payment mismatch" + ); } // --------------------------------------------------------------- @@ -104,13 +125,15 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { /// previewWithdraw(assets) - actualSharesBurned <= maxDeviation function assertWithdrawPreview() external { PhEvm.TriggerContext memory ctx = ph.context(); - _requireVaultConfigurationAt(_preCall(ctx.callStart)); + PhEvm.ForkId memory pre = _preCall(ctx.callStart); + PhEvm.ForkId memory post = _postCall(ctx.callEnd); + _requireVaultConfigurationAt(pre); bytes memory input = ph.callinputAt(ctx.callStart); uint256 assets = _firstUint256Arg(input); + (address receiver, address owner) = _withdrawAccounts(input, ctx); - uint256 previewShares = - _readUintAt(vault, abi.encodeCall(IERC4626.previewWithdraw, (assets)), _preCall(ctx.callStart)); + uint256 previewShares = _readUintAt(vault, abi.encodeCall(IERC4626.previewWithdraw, (assets)), pre); // Actual return value: withdraw returns shares burned uint256 actualShares = abi.decode(ph.callOutputAt(ctx.callStart), (uint256)); @@ -119,6 +142,22 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { require( previewShares - actualShares <= _maxPreviewDeviation(), "ERC4626: withdraw preview deviates from actual" ); + _requireDecrease( + _shareBalanceAt(owner, pre), + _shareBalanceAt(owner, post), + actualShares, + "ERC4626: withdraw owner shares mismatch" + ); + _requireDecrease(_totalSupplyAt(pre), _totalSupplyAt(post), actualShares, "ERC4626: withdraw supply mismatch"); + _requireDecrease( + _assetBalanceAt(vault, pre), _assetBalanceAt(vault, post), assets, "ERC4626: withdraw vault assets mismatch" + ); + _requireIncrease( + _assetBalanceAt(receiver, pre), + _assetBalanceAt(receiver, post), + assets, + "ERC4626: withdraw receiver assets mismatch" + ); } // --------------------------------------------------------------- @@ -130,19 +169,37 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { /// actualAssetsReturned - previewRedeem(shares) <= maxDeviation function assertRedeemPreview() external { PhEvm.TriggerContext memory ctx = ph.context(); - _requireVaultConfigurationAt(_preCall(ctx.callStart)); + PhEvm.ForkId memory pre = _preCall(ctx.callStart); + PhEvm.ForkId memory post = _postCall(ctx.callEnd); + _requireVaultConfigurationAt(pre); bytes memory input = ph.callinputAt(ctx.callStart); uint256 shares = _firstUint256Arg(input); + (address receiver, address owner) = _withdrawAccounts(input, ctx); - uint256 previewAssets = - _readUintAt(vault, abi.encodeCall(IERC4626.previewRedeem, (shares)), _preCall(ctx.callStart)); + uint256 previewAssets = _readUintAt(vault, abi.encodeCall(IERC4626.previewRedeem, (shares)), pre); // Actual return value: redeem returns assets returned uint256 actualAssets = abi.decode(ph.callOutputAt(ctx.callStart), (uint256)); require(previewAssets <= actualAssets, "ERC4626: previewRedeem > actual assets"); require(actualAssets - previewAssets <= _maxPreviewDeviation(), "ERC4626: redeem preview deviates from actual"); + _requireDecrease( + _shareBalanceAt(owner, pre), _shareBalanceAt(owner, post), shares, "ERC4626: redeem owner shares mismatch" + ); + _requireDecrease(_totalSupplyAt(pre), _totalSupplyAt(post), shares, "ERC4626: redeem supply mismatch"); + _requireDecrease( + _assetBalanceAt(vault, pre), + _assetBalanceAt(vault, post), + actualAssets, + "ERC4626: redeem vault assets mismatch" + ); + _requireIncrease( + _assetBalanceAt(receiver, pre), + _assetBalanceAt(receiver, post), + actualAssets, + "ERC4626: redeem receiver assets mismatch" + ); } // --------------------------------------------------------------- @@ -167,4 +224,48 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { value := mload(add(input, 36)) } } + + function _receiver(bytes memory input, PhEvm.TriggerContext memory ctx) internal view returns (address) { + return input.length >= 68 ? _addressArg(input, 1) : _triggerCaller(ctx); + } + + function _withdrawAccounts(bytes memory input, PhEvm.TriggerContext memory ctx) + internal + view + returns (address receiver, address owner) + { + address caller = _triggerCaller(ctx); + receiver = input.length >= 68 ? _addressArg(input, 1) : caller; + owner = input.length >= 100 ? _addressArg(input, 2) : caller; + } + + function _triggerCaller(PhEvm.TriggerContext memory ctx) internal view returns (address) { + PhEvm.CallInputs[] memory calls = ph.getAllCallInputs(vault, ctx.selector); + for (uint256 i; i < calls.length; ++i) { + if (calls[i].id == ctx.callStart) return calls[i].caller; + } + revert("ERC4626: triggered call not found"); + } + + function _addressArg(bytes memory input, uint256 index) internal pure returns (address value) { + uint256 offset = 36 + index * 32; + require(input.length >= 4 + (index + 1) * 32, "ERC4626Preview: address arg missing"); + assembly ("memory-safe") { + value := mload(add(input, offset)) + } + } + + function _requireIncrease(uint256 beforeValue, uint256 afterValue, uint256 amount, string memory reason) + internal + pure + { + require(afterValue >= beforeValue && afterValue - beforeValue == amount, reason); + } + + function _requireDecrease(uint256 beforeValue, uint256 afterValue, uint256 amount, string memory reason) + internal + pure + { + require(beforeValue >= afterValue && beforeValue - afterValue == amount, reason); + } } diff --git a/src/protection/vault/examples/MetaMorphoVaultAssertion.sol b/src/protection/vault/examples/MetaMorphoVaultAssertion.sol index 1307f90..c979d59 100644 --- a/src/protection/vault/examples/MetaMorphoVaultAssertion.sol +++ b/src/protection/vault/examples/MetaMorphoVaultAssertion.sol @@ -27,4 +27,9 @@ contract MetaMorphoVaultAssertion is ERC4626PreviewAssertion { function triggers() external view override { _registerPreviewTriggers(); } + + /// @dev Pinned MetaMorpho operations return the same pre-state previewed amount. + function _maxPreviewDeviation() internal pure override returns (uint256) { + return 0; + } } diff --git a/test/protection/vault/ERC4626PreviewAssertion.t.sol b/test/protection/vault/ERC4626PreviewAssertion.t.sol new file mode 100644 index 0000000..f05672b --- /dev/null +++ b/test/protection/vault/ERC4626PreviewAssertion.t.sol @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {ERC20Mock} from "openzeppelin-contracts/contracts/mocks/token/ERC20Mock.sol"; + +import {CredibleTest} from "../../../src/CredibleTest.sol"; +import {AssertionSpec} from "../../../src/SpecRecorder.sol"; +import {ERC4626BaseAssertion} from "../../../src/protection/vault/ERC4626BaseAssertion.sol"; +import {ERC4626PreviewAssertion} from "../../../src/protection/vault/ERC4626PreviewAssertion.sol"; + +contract PreviewEffectsVault is ERC20 { + ERC20Mock public immutable underlying; + bool public skipShares; + bool public skipAssets; + + constructor(ERC20Mock underlying_) ERC20("Vault Share", "VS") { + underlying = underlying_; + } + + function setFaults(bool skipShares_, bool skipAssets_) external { + skipShares = skipShares_; + skipAssets = skipAssets_; + } + + function asset() external view returns (address) { + return address(underlying); + } + + function totalAssets() external view returns (uint256) { + return underlying.balanceOf(address(this)); + } + + function previewDeposit(uint256 assets) external pure returns (uint256) { + return assets; + } + + function previewMint(uint256 shares) external pure returns (uint256) { + return shares; + } + + function previewWithdraw(uint256 assets) external pure returns (uint256) { + return assets; + } + + function previewRedeem(uint256 shares) external pure returns (uint256) { + return shares; + } + + function deposit(uint256 assets, address receiver) external returns (uint256 shares) { + if (!skipAssets) underlying.transferFrom(msg.sender, address(this), assets); + if (!skipShares) _mint(receiver, assets); + return assets; + } + + function mint(uint256 shares, address receiver) external returns (uint256 assets) { + if (!skipAssets) underlying.transferFrom(msg.sender, address(this), shares); + if (!skipShares) _mint(receiver, shares); + return shares; + } + + function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares) { + if (!skipShares) _burn(owner, assets); + if (!skipAssets) underlying.transfer(receiver, assets); + return assets; + } + + function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets) { + if (!skipShares) _burn(owner, shares); + if (!skipAssets) underlying.transfer(receiver, shares); + return shares; + } +} + +contract PreviewEffectsAssertion is ERC4626PreviewAssertion { + constructor(address vault_, address asset_) ERC4626BaseAssertion(vault_, asset_) { + registerAssertionSpec(AssertionSpec.Reshiram); + } + function triggers() external view override { + _registerPreviewTriggers(); + } + + function _maxPreviewDeviation() internal pure override returns (uint256) { + return 0; + } +} + +contract ERC4626PreviewAssertionTest is Test, CredibleTest { + ERC20Mock internal asset; + PreviewEffectsVault internal vault; + address internal receiver = makeAddr("receiver"); + + function setUp() public { + asset = new ERC20Mock(); + vault = new PreviewEffectsVault(asset); + asset.mint(address(this), 1_000 ether); + asset.approve(address(vault), type(uint256).max); + } + + function _arm(bytes4 selector) internal { + bytes memory createData = + abi.encodePacked(type(PreviewEffectsAssertion).creationCode, abi.encode(address(vault), address(asset))); + cl.assertion(address(vault), createData, selector); + } + + function testDepositProvesAllStateEffects() public { + _arm(ERC4626PreviewAssertion.assertDepositPreview.selector); + vault.deposit(100 ether, receiver); + } + + function testMintProvesAllStateEffects() public { + _arm(ERC4626PreviewAssertion.assertMintPreview.selector); + vault.mint(100 ether, receiver); + } + + function testWithdrawProvesAllStateEffects() public { + vault.deposit(100 ether, address(this)); + _arm(ERC4626PreviewAssertion.assertWithdrawPreview.selector); + vault.withdraw(40 ether, receiver, address(this)); + } + + function testRedeemProvesAllStateEffects() public { + vault.deposit(100 ether, address(this)); + _arm(ERC4626PreviewAssertion.assertRedeemPreview.selector); + vault.redeem(40 ether, receiver, address(this)); + } + + function testFavorableReturnWithoutSharesTrips() public { + vault.setFaults(true, true); + _arm(ERC4626PreviewAssertion.assertDepositPreview.selector); + vm.expectRevert(bytes("ERC4626: deposit receiver shares mismatch")); + vault.deposit(100 ether, receiver); + } + + function testMissingShareEffectTrips() public { + vault.setFaults(true, false); + _arm(ERC4626PreviewAssertion.assertDepositPreview.selector); + vm.expectRevert(bytes("ERC4626: deposit receiver shares mismatch")); + vault.deposit(100 ether, receiver); + } + + function testMissingAssetEffectTrips() public { + vault.setFaults(false, true); + _arm(ERC4626PreviewAssertion.assertDepositPreview.selector); + vm.expectRevert(bytes("ERC4626: deposit asset payment mismatch")); + vault.deposit(100 ether, receiver); + } +} From 1593499f66feedfa5de15170cb278d6893db1d4e Mon Sep 17 00:00:00 2001 From: makemake Date: Mon, 3 Aug 2026 13:29:31 +0200 Subject: [PATCH 2/6] style(vault): satisfy formatter --- test/protection/vault/ERC4626PreviewAssertion.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/test/protection/vault/ERC4626PreviewAssertion.t.sol b/test/protection/vault/ERC4626PreviewAssertion.t.sol index f05672b..868d30d 100644 --- a/test/protection/vault/ERC4626PreviewAssertion.t.sol +++ b/test/protection/vault/ERC4626PreviewAssertion.t.sol @@ -77,6 +77,7 @@ contract PreviewEffectsAssertion is ERC4626PreviewAssertion { constructor(address vault_, address asset_) ERC4626BaseAssertion(vault_, asset_) { registerAssertionSpec(AssertionSpec.Reshiram); } + function triggers() external view override { _registerPreviewTriggers(); } From 6c2ae88ec0816eb476cb8d76e4ad23ac7e863862 Mon Sep 17 00:00:00 2001 From: makemake Date: Mon, 3 Aug 2026 14:47:39 +0200 Subject: [PATCH 3/6] fix(vault): support external ERC-4626 asset custody --- .../curve/src/LlamaLendVaultAssertion.sol | 6 ++ .../vault/ERC4626PreviewAssertion.sol | 26 ++++++-- .../vault/ERC4626PreviewAssertion.t.sol | 60 ++++++++++++++++--- 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/examples/curve/src/LlamaLendVaultAssertion.sol b/examples/curve/src/LlamaLendVaultAssertion.sol index f511799..5ab223d 100644 --- a/examples/curve/src/LlamaLendVaultAssertion.sol +++ b/examples/curve/src/LlamaLendVaultAssertion.sol @@ -42,6 +42,12 @@ contract LlamaLendVaultAssertion is ERC4626PreviewAssertion, LlamaLendVaultProto return 0; } + /// @dev LlamaLend vault operations transfer the borrowed token directly through the + /// controller; the vault contract itself never holds the ERC-4626 payment inventory. + function _assetCustodyAccount() internal view override returns (address) { + return controller; + } + /// @notice Checks `totalAssets()` equals controller available balance plus debt minus admin fees. function assertTotalAssetsMatchesControllerAccounting() external { PhEvm.ForkId memory fork = _postTx(); diff --git a/src/protection/vault/ERC4626PreviewAssertion.sol b/src/protection/vault/ERC4626PreviewAssertion.sol index ef10991..ca29d47 100644 --- a/src/protection/vault/ERC4626PreviewAssertion.sol +++ b/src/protection/vault/ERC4626PreviewAssertion.sol @@ -40,6 +40,14 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { /// @dev Must be derived from the concrete vault implementation; no generic default is sound. function _maxPreviewDeviation() internal view virtual returns (uint256); + /// @notice Account whose underlying-token balance reflects ERC-4626 payments and payouts. + /// @dev Standard vaults custody assets themselves. Managed-custody adapters such as + /// LlamaLend must override this with the controller that actually receives and sends the + /// underlying token. + function _assetCustodyAccount() internal view virtual returns (address) { + return vault; + } + // --------------------------------------------------------------- // deposit: previewDeposit(assets) <= actualSharesMinted // --------------------------------------------------------------- @@ -73,7 +81,10 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { ); _requireIncrease(_totalSupplyAt(pre), _totalSupplyAt(post), actualShares, "ERC4626: deposit supply mismatch"); _requireIncrease( - _assetBalanceAt(vault, pre), _assetBalanceAt(vault, post), assets, "ERC4626: deposit asset payment mismatch" + _assetBalanceAt(_assetCustodyAccount(), pre), + _assetBalanceAt(_assetCustodyAccount(), post), + assets, + "ERC4626: deposit asset payment mismatch" ); } @@ -109,8 +120,8 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { ); _requireIncrease(_totalSupplyAt(pre), _totalSupplyAt(post), shares, "ERC4626: mint supply mismatch"); _requireIncrease( - _assetBalanceAt(vault, pre), - _assetBalanceAt(vault, post), + _assetBalanceAt(_assetCustodyAccount(), pre), + _assetBalanceAt(_assetCustodyAccount(), post), actualAssets, "ERC4626: mint asset payment mismatch" ); @@ -150,7 +161,10 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { ); _requireDecrease(_totalSupplyAt(pre), _totalSupplyAt(post), actualShares, "ERC4626: withdraw supply mismatch"); _requireDecrease( - _assetBalanceAt(vault, pre), _assetBalanceAt(vault, post), assets, "ERC4626: withdraw vault assets mismatch" + _assetBalanceAt(_assetCustodyAccount(), pre), + _assetBalanceAt(_assetCustodyAccount(), post), + assets, + "ERC4626: withdraw vault assets mismatch" ); _requireIncrease( _assetBalanceAt(receiver, pre), @@ -189,8 +203,8 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { ); _requireDecrease(_totalSupplyAt(pre), _totalSupplyAt(post), shares, "ERC4626: redeem supply mismatch"); _requireDecrease( - _assetBalanceAt(vault, pre), - _assetBalanceAt(vault, post), + _assetBalanceAt(_assetCustodyAccount(), pre), + _assetBalanceAt(_assetCustodyAccount(), post), actualAssets, "ERC4626: redeem vault assets mismatch" ); diff --git a/test/protection/vault/ERC4626PreviewAssertion.t.sol b/test/protection/vault/ERC4626PreviewAssertion.t.sol index 868d30d..fe3929c 100644 --- a/test/protection/vault/ERC4626PreviewAssertion.t.sol +++ b/test/protection/vault/ERC4626PreviewAssertion.t.sol @@ -10,13 +10,21 @@ import {AssertionSpec} from "../../../src/SpecRecorder.sol"; import {ERC4626BaseAssertion} from "../../../src/protection/vault/ERC4626BaseAssertion.sol"; import {ERC4626PreviewAssertion} from "../../../src/protection/vault/ERC4626PreviewAssertion.sol"; +contract PreviewAssetCustodian { + function release(ERC20Mock token, address receiver, uint256 amount) external { + token.transfer(receiver, amount); + } +} + contract PreviewEffectsVault is ERC20 { ERC20Mock public immutable underlying; + address public immutable custodian; bool public skipShares; bool public skipAssets; - constructor(ERC20Mock underlying_) ERC20("Vault Share", "VS") { + constructor(ERC20Mock underlying_, address custodian_) ERC20("Vault Share", "VS") { underlying = underlying_; + custodian = custodian_ == address(0) ? address(this) : custodian_; } function setFaults(bool skipShares_, bool skipAssets_) external { @@ -49,32 +57,40 @@ contract PreviewEffectsVault is ERC20 { } function deposit(uint256 assets, address receiver) external returns (uint256 shares) { - if (!skipAssets) underlying.transferFrom(msg.sender, address(this), assets); + if (!skipAssets) underlying.transferFrom(msg.sender, custodian, assets); if (!skipShares) _mint(receiver, assets); return assets; } function mint(uint256 shares, address receiver) external returns (uint256 assets) { - if (!skipAssets) underlying.transferFrom(msg.sender, address(this), shares); + if (!skipAssets) underlying.transferFrom(msg.sender, custodian, shares); if (!skipShares) _mint(receiver, shares); return shares; } function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares) { if (!skipShares) _burn(owner, assets); - if (!skipAssets) underlying.transfer(receiver, assets); + if (!skipAssets) _release(receiver, assets); return assets; } function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets) { if (!skipShares) _burn(owner, shares); - if (!skipAssets) underlying.transfer(receiver, shares); + if (!skipAssets) _release(receiver, shares); return shares; } + + function _release(address receiver, uint256 amount) internal { + if (custodian == address(this)) underlying.transfer(receiver, amount); + else PreviewAssetCustodian(custodian).release(underlying, receiver, amount); + } } contract PreviewEffectsAssertion is ERC4626PreviewAssertion { - constructor(address vault_, address asset_) ERC4626BaseAssertion(vault_, asset_) { + address internal immutable custody; + + constructor(address vault_, address asset_, address custody_) ERC4626BaseAssertion(vault_, asset_) { + custody = custody_; registerAssertionSpec(AssertionSpec.Reshiram); } @@ -85,6 +101,10 @@ contract PreviewEffectsAssertion is ERC4626PreviewAssertion { function _maxPreviewDeviation() internal pure override returns (uint256) { return 0; } + + function _assetCustodyAccount() internal view override returns (address) { + return custody; + } } contract ERC4626PreviewAssertionTest is Test, CredibleTest { @@ -94,14 +114,15 @@ contract ERC4626PreviewAssertionTest is Test, CredibleTest { function setUp() public { asset = new ERC20Mock(); - vault = new PreviewEffectsVault(asset); + vault = new PreviewEffectsVault(asset, address(0)); asset.mint(address(this), 1_000 ether); asset.approve(address(vault), type(uint256).max); } function _arm(bytes4 selector) internal { - bytes memory createData = - abi.encodePacked(type(PreviewEffectsAssertion).creationCode, abi.encode(address(vault), address(asset))); + bytes memory createData = abi.encodePacked( + type(PreviewEffectsAssertion).creationCode, abi.encode(address(vault), address(asset), vault.custodian()) + ); cl.assertion(address(vault), createData, selector); } @@ -127,6 +148,27 @@ contract ERC4626PreviewAssertionTest is Test, CredibleTest { vault.redeem(40 ether, receiver, address(this)); } + function testDepositSupportsExternalAssetCustody() public { + PreviewAssetCustodian externalCustodian = new PreviewAssetCustodian(); + vault = new PreviewEffectsVault(asset, address(externalCustodian)); + asset.approve(address(vault), type(uint256).max); + + _arm(ERC4626PreviewAssertion.assertDepositPreview.selector); + vault.deposit(100 ether, receiver); + assertEq(asset.balanceOf(address(externalCustodian)), 100 ether); + } + + function testWithdrawSupportsExternalAssetCustody() public { + PreviewAssetCustodian externalCustodian = new PreviewAssetCustodian(); + vault = new PreviewEffectsVault(asset, address(externalCustodian)); + asset.approve(address(vault), type(uint256).max); + vault.deposit(100 ether, address(this)); + + _arm(ERC4626PreviewAssertion.assertWithdrawPreview.selector); + vault.withdraw(40 ether, receiver, address(this)); + assertEq(asset.balanceOf(address(externalCustodian)), 60 ether); + } + function testFavorableReturnWithoutSharesTrips() public { vault.setFaults(true, true); _arm(ERC4626PreviewAssertion.assertDepositPreview.selector); From 13fe43fe88383ae83dcd706dfa87999b8fc61967 Mon Sep 17 00:00:00 2001 From: makemake Date: Tue, 4 Aug 2026 14:28:08 +0200 Subject: [PATCH 4/6] test(vault): cover all ERC4626 effect branches --- .../vault/ERC4626PreviewAssertion.t.sol | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/protection/vault/ERC4626PreviewAssertion.t.sol b/test/protection/vault/ERC4626PreviewAssertion.t.sol index fe3929c..0694370 100644 --- a/test/protection/vault/ERC4626PreviewAssertion.t.sol +++ b/test/protection/vault/ERC4626PreviewAssertion.t.sol @@ -189,4 +189,50 @@ contract ERC4626PreviewAssertionTest is Test, CredibleTest { vm.expectRevert(bytes("ERC4626: deposit asset payment mismatch")); vault.deposit(100 ether, receiver); } + + function testMintMissingShareEffectTrips() public { + vault.setFaults(true, false); + _arm(ERC4626PreviewAssertion.assertMintPreview.selector); + vm.expectRevert(bytes("ERC4626: mint receiver shares mismatch")); + vault.mint(100 ether, receiver); + } + + function testMintMissingAssetEffectTrips() public { + vault.setFaults(false, true); + _arm(ERC4626PreviewAssertion.assertMintPreview.selector); + vm.expectRevert(bytes("ERC4626: mint asset payment mismatch")); + vault.mint(100 ether, receiver); + } + + function testWithdrawMissingShareEffectTrips() public { + vault.deposit(100 ether, address(this)); + vault.setFaults(true, false); + _arm(ERC4626PreviewAssertion.assertWithdrawPreview.selector); + vm.expectRevert(bytes("ERC4626: withdraw owner shares mismatch")); + vault.withdraw(40 ether, receiver, address(this)); + } + + function testWithdrawMissingAssetEffectTrips() public { + vault.deposit(100 ether, address(this)); + vault.setFaults(false, true); + _arm(ERC4626PreviewAssertion.assertWithdrawPreview.selector); + vm.expectRevert(bytes("ERC4626: withdraw vault assets mismatch")); + vault.withdraw(40 ether, receiver, address(this)); + } + + function testRedeemMissingShareEffectTrips() public { + vault.deposit(100 ether, address(this)); + vault.setFaults(true, false); + _arm(ERC4626PreviewAssertion.assertRedeemPreview.selector); + vm.expectRevert(bytes("ERC4626: redeem owner shares mismatch")); + vault.redeem(40 ether, receiver, address(this)); + } + + function testRedeemMissingAssetEffectTrips() public { + vault.deposit(100 ether, address(this)); + vault.setFaults(false, true); + _arm(ERC4626PreviewAssertion.assertRedeemPreview.selector); + vm.expectRevert(bytes("ERC4626: redeem vault assets mismatch")); + vault.redeem(40 ether, receiver, address(this)); + } } From 2d4a1e6b7e4f7f5fabefffb680b915c7f303a3f4 Mon Sep 17 00:00:00 2001 From: makemake Date: Wed, 5 Aug 2026 16:14:35 +0200 Subject: [PATCH 5/6] fix(vault): verify complete ERC4626 asset effects --- .../curve/src/LlamaLendVaultAssertion.sol | 2 +- examples/spark/src/SparkVaultAssertion.sol | 2 +- .../vault/ERC4626PreviewAssertion.sol | 108 +++++++++++------- .../examples/MetaMorphoVaultAssertion.sol | 2 +- .../vault/ERC4626PreviewAssertion.t.sol | 107 ++++++++++++++++- 5 files changed, 177 insertions(+), 44 deletions(-) diff --git a/examples/curve/src/LlamaLendVaultAssertion.sol b/examples/curve/src/LlamaLendVaultAssertion.sol index 5ab223d..c65d47b 100644 --- a/examples/curve/src/LlamaLendVaultAssertion.sol +++ b/examples/curve/src/LlamaLendVaultAssertion.sol @@ -25,7 +25,7 @@ contract LlamaLendVaultAssertion is ERC4626PreviewAssertion, LlamaLendVaultProto } /// @notice Registers preview checks plus controller-side accounting and custody checks. - function triggers() external view override { + function triggers() external view virtual override { _registerPreviewTriggers(); registerFnCallTrigger(this.assertDepositPreview.selector, DEPOSIT_DEFAULT); registerFnCallTrigger(this.assertMintPreview.selector, MINT_DEFAULT); diff --git a/examples/spark/src/SparkVaultAssertion.sol b/examples/spark/src/SparkVaultAssertion.sol index 31aa033..f0afbc8 100644 --- a/examples/spark/src/SparkVaultAssertion.sol +++ b/examples/spark/src/SparkVaultAssertion.sol @@ -39,7 +39,7 @@ contract SparkVaultAssertion is ERC4626PreviewAssertion, SparkVaultHelpers { /// state forks." The inherited `_register*Triggers()` cover the standard /// ERC-4626 invariants; the `_registerSpark*Triggers()` helpers below extend /// that wiring to Spark's non-standard surfaces. - function triggers() external view override { + function triggers() external view virtual override { _registerPreviewTriggers(); _registerSparkReferralOverloadTriggers(); _registerSparkRateAccumulationTriggers(); diff --git a/src/protection/vault/ERC4626PreviewAssertion.sol b/src/protection/vault/ERC4626PreviewAssertion.sol index ca29d47..1edd1e8 100644 --- a/src/protection/vault/ERC4626PreviewAssertion.sol +++ b/src/protection/vault/ERC4626PreviewAssertion.sol @@ -64,6 +64,7 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { bytes memory input = ph.callinputAt(ctx.callStart); uint256 assets = _firstUint256Arg(input); address receiver = _receiver(input, ctx); + address payer = _triggerCaller(ctx); // Preview at pre-call state uint256 previewShares = _readUintAt(vault, abi.encodeCall(IERC4626.previewDeposit, (assets)), pre); @@ -80,12 +81,7 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { "ERC4626: deposit receiver shares mismatch" ); _requireIncrease(_totalSupplyAt(pre), _totalSupplyAt(post), actualShares, "ERC4626: deposit supply mismatch"); - _requireIncrease( - _assetBalanceAt(_assetCustodyAccount(), pre), - _assetBalanceAt(_assetCustodyAccount(), post), - assets, - "ERC4626: deposit asset payment mismatch" - ); + _requirePaymentEffects(payer, _assetCustodyAccount(), assets, pre, post, "deposit"); } // --------------------------------------------------------------- @@ -104,6 +100,7 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { bytes memory input = ph.callinputAt(ctx.callStart); uint256 shares = _firstUint256Arg(input); address receiver = _receiver(input, ctx); + address payer = _triggerCaller(ctx); uint256 previewAssets = _readUintAt(vault, abi.encodeCall(IERC4626.previewMint, (shares)), pre); @@ -119,12 +116,7 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { "ERC4626: mint receiver shares mismatch" ); _requireIncrease(_totalSupplyAt(pre), _totalSupplyAt(post), shares, "ERC4626: mint supply mismatch"); - _requireIncrease( - _assetBalanceAt(_assetCustodyAccount(), pre), - _assetBalanceAt(_assetCustodyAccount(), post), - actualAssets, - "ERC4626: mint asset payment mismatch" - ); + _requirePaymentEffects(payer, _assetCustodyAccount(), actualAssets, pre, post, "mint"); } // --------------------------------------------------------------- @@ -160,18 +152,7 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { "ERC4626: withdraw owner shares mismatch" ); _requireDecrease(_totalSupplyAt(pre), _totalSupplyAt(post), actualShares, "ERC4626: withdraw supply mismatch"); - _requireDecrease( - _assetBalanceAt(_assetCustodyAccount(), pre), - _assetBalanceAt(_assetCustodyAccount(), post), - assets, - "ERC4626: withdraw vault assets mismatch" - ); - _requireIncrease( - _assetBalanceAt(receiver, pre), - _assetBalanceAt(receiver, post), - assets, - "ERC4626: withdraw receiver assets mismatch" - ); + _requirePayoutEffects(_assetCustodyAccount(), receiver, assets, pre, post, "withdraw"); } // --------------------------------------------------------------- @@ -202,18 +183,7 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { _shareBalanceAt(owner, pre), _shareBalanceAt(owner, post), shares, "ERC4626: redeem owner shares mismatch" ); _requireDecrease(_totalSupplyAt(pre), _totalSupplyAt(post), shares, "ERC4626: redeem supply mismatch"); - _requireDecrease( - _assetBalanceAt(_assetCustodyAccount(), pre), - _assetBalanceAt(_assetCustodyAccount(), post), - actualAssets, - "ERC4626: redeem vault assets mismatch" - ); - _requireIncrease( - _assetBalanceAt(receiver, pre), - _assetBalanceAt(receiver, post), - actualAssets, - "ERC4626: redeem receiver assets mismatch" - ); + _requirePayoutEffects(_assetCustodyAccount(), receiver, actualAssets, pre, post, "redeem"); } // --------------------------------------------------------------- @@ -248,19 +218,79 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { view returns (address receiver, address owner) { + if (input.length >= 100) return (_addressArg(input, 1), _addressArg(input, 2)); address caller = _triggerCaller(ctx); receiver = input.length >= 68 ? _addressArg(input, 1) : caller; - owner = input.length >= 100 ? _addressArg(input, 2) : caller; + owner = caller; } function _triggerCaller(PhEvm.TriggerContext memory ctx) internal view returns (address) { - PhEvm.CallInputs[] memory calls = ph.getAllCallInputs(vault, ctx.selector); + PhEvm.CallFilter memory filter = PhEvm.CallFilter({ + callType: 0, minDepth: 0, maxDepth: type(uint32).max, topLevelOnly: false, successOnly: true + }); + PhEvm.TriggerCall[] memory calls = ph.matchingCalls(vault, ctx.selector, filter, 8); for (uint256 i; i < calls.length; ++i) { - if (calls[i].id == ctx.callStart) return calls[i].caller; + if (calls[i].callId == ctx.callStart) return calls[i].caller; } revert("ERC4626: triggered call not found"); } + function _requirePaymentEffects( + address payer, + address custody, + uint256 amount, + PhEvm.ForkId memory pre, + PhEvm.ForkId memory post, + string memory operation + ) internal view { + uint256 payerBefore = _assetBalanceAt(payer, pre); + uint256 payerAfter = _assetBalanceAt(payer, post); + if (payer == custody) { + require(payerAfter == payerBefore, string.concat("ERC4626: ", operation, " asset payment mismatch")); + return; + } + _requireIncrease( + _assetBalanceAt(custody, pre), + _assetBalanceAt(custody, post), + amount, + string.concat("ERC4626: ", operation, " asset payment mismatch") + ); + _requireDecrease( + payerBefore, + payerAfter, + amount, + string.concat("ERC4626: ", operation, " payer assets mismatch") + ); + } + + function _requirePayoutEffects( + address custody, + address receiver, + uint256 amount, + PhEvm.ForkId memory pre, + PhEvm.ForkId memory post, + string memory operation + ) internal view { + uint256 custodyBefore = _assetBalanceAt(custody, pre); + uint256 custodyAfter = _assetBalanceAt(custody, post); + if (custody == receiver) { + require(custodyAfter == custodyBefore, string.concat("ERC4626: ", operation, " asset payout mismatch")); + return; + } + _requireDecrease( + custodyBefore, + custodyAfter, + amount, + string.concat("ERC4626: ", operation, " vault assets mismatch") + ); + _requireIncrease( + _assetBalanceAt(receiver, pre), + _assetBalanceAt(receiver, post), + amount, + string.concat("ERC4626: ", operation, " receiver assets mismatch") + ); + } + function _addressArg(bytes memory input, uint256 index) internal pure returns (address value) { uint256 offset = 36 + index * 32; require(input.length >= 4 + (index + 1) * 32, "ERC4626Preview: address arg missing"); diff --git a/src/protection/vault/examples/MetaMorphoVaultAssertion.sol b/src/protection/vault/examples/MetaMorphoVaultAssertion.sol index c979d59..617892b 100644 --- a/src/protection/vault/examples/MetaMorphoVaultAssertion.sol +++ b/src/protection/vault/examples/MetaMorphoVaultAssertion.sol @@ -24,7 +24,7 @@ contract MetaMorphoVaultAssertion is ERC4626PreviewAssertion { /// @notice Registers the ERC-4626 selectors against the MetaMorpho-safe assertion set. /// @dev MetaMorpho-specific managed-asset and loss accounting must be installed separately. - function triggers() external view override { + function triggers() external view virtual override { _registerPreviewTriggers(); } diff --git a/test/protection/vault/ERC4626PreviewAssertion.t.sol b/test/protection/vault/ERC4626PreviewAssertion.t.sol index 0694370..2e26258 100644 --- a/test/protection/vault/ERC4626PreviewAssertion.t.sol +++ b/test/protection/vault/ERC4626PreviewAssertion.t.sol @@ -9,6 +9,9 @@ import {CredibleTest} from "../../../src/CredibleTest.sol"; import {AssertionSpec} from "../../../src/SpecRecorder.sol"; import {ERC4626BaseAssertion} from "../../../src/protection/vault/ERC4626BaseAssertion.sol"; import {ERC4626PreviewAssertion} from "../../../src/protection/vault/ERC4626PreviewAssertion.sol"; +import {MetaMorphoVaultAssertion} from "../../../src/protection/vault/examples/MetaMorphoVaultAssertion.sol"; +import {LlamaLendVaultAssertion} from "../../../examples/curve/src/LlamaLendVaultAssertion.sol"; +import {SparkVaultAssertion} from "../../../examples/spark/src/SparkVaultAssertion.sol"; contract PreviewAssetCustodian { function release(ERC20Mock token, address receiver, uint256 amount) external { @@ -21,6 +24,8 @@ contract PreviewEffectsVault is ERC20 { address public immutable custodian; bool public skipShares; bool public skipAssets; + address public paymentSource; + address public extraChargeRecipient; constructor(ERC20Mock underlying_, address custodian_) ERC20("Vault Share", "VS") { underlying = underlying_; @@ -32,6 +37,11 @@ contract PreviewEffectsVault is ERC20 { skipAssets = skipAssets_; } + function setPaymentFaults(address paymentSource_, address extraChargeRecipient_) external { + paymentSource = paymentSource_; + extraChargeRecipient = extraChargeRecipient_; + } + function asset() external view returns (address) { return address(underlying); } @@ -57,13 +67,13 @@ contract PreviewEffectsVault is ERC20 { } function deposit(uint256 assets, address receiver) external returns (uint256 shares) { - if (!skipAssets) underlying.transferFrom(msg.sender, custodian, assets); + if (!skipAssets) _collect(assets); if (!skipShares) _mint(receiver, assets); return assets; } function mint(uint256 shares, address receiver) external returns (uint256 assets) { - if (!skipAssets) underlying.transferFrom(msg.sender, custodian, shares); + if (!skipAssets) _collect(shares); if (!skipShares) _mint(receiver, shares); return shares; } @@ -84,6 +94,12 @@ contract PreviewEffectsVault is ERC20 { if (custodian == address(this)) underlying.transfer(receiver, amount); else PreviewAssetCustodian(custodian).release(underlying, receiver, amount); } + + function _collect(uint256 amount) internal { + address payer = paymentSource == address(0) ? msg.sender : paymentSource; + underlying.transferFrom(payer, custodian, amount); + if (extraChargeRecipient != address(0)) underlying.transferFrom(payer, extraChargeRecipient, amount); + } } contract PreviewEffectsAssertion is ERC4626PreviewAssertion { @@ -107,6 +123,23 @@ contract PreviewEffectsAssertion is ERC4626PreviewAssertion { } } +contract PreviewMetaMorphoHarness is MetaMorphoVaultAssertion { + constructor(address vault_, address asset_) MetaMorphoVaultAssertion(vault_, asset_) {} + function triggers() external view override { _registerPreviewTriggers(); } +} + +contract PreviewSparkHarness is SparkVaultAssertion { + constructor(address vault_, address asset_) SparkVaultAssertion(vault_, asset_) {} + function triggers() external view override { _registerPreviewTriggers(); } +} + +contract PreviewLlamaHarness is LlamaLendVaultAssertion { + constructor(address vault_, address asset_, address controller_) + LlamaLendVaultAssertion(vault_, asset_, controller_) + {} + function triggers() external view override { _registerPreviewTriggers(); } +} + contract ERC4626PreviewAssertionTest is Test, CredibleTest { ERC20Mock internal asset; PreviewEffectsVault internal vault; @@ -190,6 +223,24 @@ contract ERC4626PreviewAssertionTest is Test, CredibleTest { vault.deposit(100 ether, receiver); } + function testDepositOverchargeTripsEvenWhenCustodyReceivesExpectedAmount() public { + vault.setPaymentFaults(address(0), makeAddr("diversion")); + _arm(ERC4626PreviewAssertion.assertDepositPreview.selector); + vm.expectRevert(bytes("ERC4626: deposit payer assets mismatch")); + vault.deposit(100 ether, receiver); + } + + function testDepositWrongPayerTrips() public { + address wrongPayer = makeAddr("wrongPayer"); + asset.mint(wrongPayer, 100 ether); + vm.prank(wrongPayer); + asset.approve(address(vault), type(uint256).max); + vault.setPaymentFaults(wrongPayer, address(0)); + _arm(ERC4626PreviewAssertion.assertDepositPreview.selector); + vm.expectRevert(bytes("ERC4626: deposit payer assets mismatch")); + vault.deposit(100 ether, receiver); + } + function testMintMissingShareEffectTrips() public { vault.setFaults(true, false); _arm(ERC4626PreviewAssertion.assertMintPreview.selector); @@ -235,4 +286,56 @@ contract ERC4626PreviewAssertionTest is Test, CredibleTest { vm.expectRevert(bytes("ERC4626: redeem vault assets mismatch")); vault.redeem(40 ether, receiver, address(this)); } + + function testWithdrawAllowsReceiverToEqualCustody() public { + vault.deposit(100 ether, address(this)); + _arm(ERC4626PreviewAssertion.assertWithdrawPreview.selector); + vault.withdraw(40 ether, address(vault), address(this)); + } + + function testRedeemAllowsReceiverToEqualCustody() public { + vault.deposit(100 ether, address(this)); + _arm(ERC4626PreviewAssertion.assertRedeemPreview.selector); + vault.redeem(40 ether, address(vault), address(this)); + } + + function testMetaMorphoAdapterHonestAndIncorrectEffects() public { + bytes memory createData = abi.encodePacked( + type(PreviewMetaMorphoHarness).creationCode, abi.encode(address(vault), address(asset)) + ); + cl.assertion(address(vault), createData, ERC4626PreviewAssertion.assertDepositPreview.selector); + vault.deposit(10 ether, receiver); + + vault.setFaults(false, true); + vm.expectRevert(bytes("ERC4626: deposit asset payment mismatch")); + vault.deposit(10 ether, receiver); + } + + function testSparkAdapterHonestAndIncorrectEffects() public { + bytes memory createData = + abi.encodePacked(type(PreviewSparkHarness).creationCode, abi.encode(address(vault), address(asset))); + cl.assertion(address(vault), createData, ERC4626PreviewAssertion.assertMintPreview.selector); + vault.mint(10 ether, receiver); + + vault.setFaults(true, false); + vm.expectRevert(bytes("ERC4626: mint receiver shares mismatch")); + vault.mint(10 ether, receiver); + } + + function testLlamaAdapterUsesControllerCustodyForHonestAndIncorrectEffects() public { + PreviewAssetCustodian controller = new PreviewAssetCustodian(); + vault = new PreviewEffectsVault(asset, address(controller)); + asset.approve(address(vault), type(uint256).max); + bytes memory createData = abi.encodePacked( + type(PreviewLlamaHarness).creationCode, + abi.encode(address(vault), address(asset), address(controller)) + ); + cl.assertion(address(vault), createData, ERC4626PreviewAssertion.assertDepositPreview.selector); + vault.deposit(10 ether, receiver); + assertEq(asset.balanceOf(address(controller)), 10 ether); + + vault.setFaults(false, true); + vm.expectRevert(bytes("ERC4626: deposit asset payment mismatch")); + vault.deposit(10 ether, receiver); + } } From 6fd39398c6d7681ef957cdc2b9c666960ff637b0 Mon Sep 17 00:00:00 2001 From: makemake Date: Wed, 5 Aug 2026 16:20:43 +0200 Subject: [PATCH 6/6] test(vault): rearm adapter effect regressions --- .../vault/ERC4626PreviewAssertion.sol | 10 ++----- .../vault/ERC4626PreviewAssertion.t.sol | 26 +++++++++++++------ 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/protection/vault/ERC4626PreviewAssertion.sol b/src/protection/vault/ERC4626PreviewAssertion.sol index 1edd1e8..63c2707 100644 --- a/src/protection/vault/ERC4626PreviewAssertion.sol +++ b/src/protection/vault/ERC4626PreviewAssertion.sol @@ -256,10 +256,7 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { string.concat("ERC4626: ", operation, " asset payment mismatch") ); _requireDecrease( - payerBefore, - payerAfter, - amount, - string.concat("ERC4626: ", operation, " payer assets mismatch") + payerBefore, payerAfter, amount, string.concat("ERC4626: ", operation, " payer assets mismatch") ); } @@ -278,10 +275,7 @@ abstract contract ERC4626PreviewAssertion is ERC4626BaseAssertion { return; } _requireDecrease( - custodyBefore, - custodyAfter, - amount, - string.concat("ERC4626: ", operation, " vault assets mismatch") + custodyBefore, custodyAfter, amount, string.concat("ERC4626: ", operation, " vault assets mismatch") ); _requireIncrease( _assetBalanceAt(receiver, pre), diff --git a/test/protection/vault/ERC4626PreviewAssertion.t.sol b/test/protection/vault/ERC4626PreviewAssertion.t.sol index 2e26258..b4bb8a2 100644 --- a/test/protection/vault/ERC4626PreviewAssertion.t.sol +++ b/test/protection/vault/ERC4626PreviewAssertion.t.sol @@ -125,19 +125,28 @@ contract PreviewEffectsAssertion is ERC4626PreviewAssertion { contract PreviewMetaMorphoHarness is MetaMorphoVaultAssertion { constructor(address vault_, address asset_) MetaMorphoVaultAssertion(vault_, asset_) {} - function triggers() external view override { _registerPreviewTriggers(); } + + function triggers() external view override { + _registerPreviewTriggers(); + } } contract PreviewSparkHarness is SparkVaultAssertion { constructor(address vault_, address asset_) SparkVaultAssertion(vault_, asset_) {} - function triggers() external view override { _registerPreviewTriggers(); } + + function triggers() external view override { + _registerPreviewTriggers(); + } } contract PreviewLlamaHarness is LlamaLendVaultAssertion { constructor(address vault_, address asset_, address controller_) LlamaLendVaultAssertion(vault_, asset_, controller_) {} - function triggers() external view override { _registerPreviewTriggers(); } + + function triggers() external view override { + _registerPreviewTriggers(); + } } contract ERC4626PreviewAssertionTest is Test, CredibleTest { @@ -300,13 +309,13 @@ contract ERC4626PreviewAssertionTest is Test, CredibleTest { } function testMetaMorphoAdapterHonestAndIncorrectEffects() public { - bytes memory createData = abi.encodePacked( - type(PreviewMetaMorphoHarness).creationCode, abi.encode(address(vault), address(asset)) - ); + bytes memory createData = + abi.encodePacked(type(PreviewMetaMorphoHarness).creationCode, abi.encode(address(vault), address(asset))); cl.assertion(address(vault), createData, ERC4626PreviewAssertion.assertDepositPreview.selector); vault.deposit(10 ether, receiver); vault.setFaults(false, true); + cl.assertion(address(vault), createData, ERC4626PreviewAssertion.assertDepositPreview.selector); vm.expectRevert(bytes("ERC4626: deposit asset payment mismatch")); vault.deposit(10 ether, receiver); } @@ -318,6 +327,7 @@ contract ERC4626PreviewAssertionTest is Test, CredibleTest { vault.mint(10 ether, receiver); vault.setFaults(true, false); + cl.assertion(address(vault), createData, ERC4626PreviewAssertion.assertMintPreview.selector); vm.expectRevert(bytes("ERC4626: mint receiver shares mismatch")); vault.mint(10 ether, receiver); } @@ -327,14 +337,14 @@ contract ERC4626PreviewAssertionTest is Test, CredibleTest { vault = new PreviewEffectsVault(asset, address(controller)); asset.approve(address(vault), type(uint256).max); bytes memory createData = abi.encodePacked( - type(PreviewLlamaHarness).creationCode, - abi.encode(address(vault), address(asset), address(controller)) + type(PreviewLlamaHarness).creationCode, abi.encode(address(vault), address(asset), address(controller)) ); cl.assertion(address(vault), createData, ERC4626PreviewAssertion.assertDepositPreview.selector); vault.deposit(10 ether, receiver); assertEq(asset.balanceOf(address(controller)), 10 ether); vault.setFaults(false, true); + cl.assertion(address(vault), createData, ERC4626PreviewAssertion.assertDepositPreview.selector); vm.expectRevert(bytes("ERC4626: deposit asset payment mismatch")); vault.deposit(10 ether, receiver); }