Use Case
In AMMs and other DeFi protocols, trading fees are a critical security parameter that directly impacts user returns and protocol revenue. Unexpected fee changes can lead to:
- Unauthorized fee manipulation by malicious actors
- Protocol revenue loss through fee bypasses
- User losses from unexpected fee increases
- Market manipulation through fee changes
This assertion is particularly important because:
- Fees are a primary revenue source for many DeFi protocols
- Fee changes can be used as an attack vector to drain protocol value
- Users rely on consistent fee structures for trading strategies
- Protocol sustainability depends on predictable fee mechanisms
Applicable Protocols
- AMMs (Automated Market Makers) like Velodrome, Aerodrome, and Curve
- DEX aggregators that charge routing fees
- Lending protocols with origination fees
- Yield aggregators with performance fees
- Cross-chain bridges with transfer fees
Each protocol type needs this assertion because:
- AMMs: Fee changes can be used to manipulate price impact and drain liquidity
- DEX aggregators: Fee manipulation could bypass routing security
- Lending protocols: Fee changes could affect interest calculations
- Yield aggregators: Performance fee changes could drain protocol value
- Bridges: Fee changes could be used to manipulate cross-chain arbitrage
Explanation
The assertion uses a whitelist approach to control fee changes:
-
Trigger Registration:
- Uses
registerStorageChangeTrigger
to detect any changes to the fee storage slot
- When triggered, we know a fee change has occurred
-
Whitelist Validation:
- Maintains hardcoded allowed fee values for both stable and non-stable pools
- For stable pools: allows 0.1% (1) and 0.15% (15)
- For non-stable pools: allows 0.25% (25) and 0.30% (30)
- When a change occurs, verifies the new fee matches one of the allowed values
- Prevents unauthorized fee modifications
The assertion uses the following cheatcodes and functions:
registerStorageChangeTrigger()
: Detects changes to the fee storage slot
ph.load()
: Retrieves the new fee value after a change
getStateChangesUint()
: Gets all state changes for the fee storage slot to verify intermediate changes
This approach ensures that:
- Only whitelisted fee values are allowed
- Protocol can still update fees through proper channels
- Unauthorized fee modifications are prevented
- All intermediate fee changes in the callstack are also validated
For more information about cheatcodes, see the Cheatcodes Documentation.
Code Example
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Assertion} from "credible-std/Assertion.sol";
import {PhEvm} from "credible-std/PhEvm.sol";
// Aerodrome style interface
interface IPool {
function fee() external view returns (uint256);
function stable() external view returns (bool);
}
// Check that fee invariants are maintained
contract AmmFeeVerificationAssertion is Assertion {
IPool public pool;
constructor(address _pool) {
pool = IPool(_pool);
}
// Hardcoded whitelist of allowed fee values
uint256 private constant STABLE_POOL_FEE_1 = 1; // 0.1%
uint256 private constant STABLE_POOL_FEE_2 = 15; // 0.15%
uint256 private constant NON_STABLE_POOL_FEE_1 = 25; // 0.25%
uint256 private constant NON_STABLE_POOL_FEE_2 = 30; // 0.30%
function triggers() external view override {
// Register trigger for changes to the fee storage slot
registerStorageChangeTrigger(this.assertFeeVerification.selector, bytes32(uint256(1))); // Assuming fee is in slot 1
}
// Verify that any fee change is to an allowed value
function assertFeeVerification() external {
// Get the new fee value and pool type
bool isStable = pool.stable();
uint256 newFee = uint256(ph.load(address(pool), bytes32(uint256(1))));
// Check if the new fee is in the whitelist
bool isAllowed = isStable
? (newFee == STABLE_POOL_FEE_1 || newFee == STABLE_POOL_FEE_2)
: (newFee == NON_STABLE_POOL_FEE_1 || newFee == NON_STABLE_POOL_FEE_2);
require(isAllowed, "Fee change to unauthorized value");
// If the simple check passes, verify no unauthorized changes in the callstack
uint256[] memory changes = getStateChangesUint(address(pool), bytes32(uint256(1)));
// Check each change against the whitelist
for (uint256 i = 0; i < changes.length; i++) {
isAllowed = isStable
? (changes[i] == STABLE_POOL_FEE_1 || changes[i] == STABLE_POOL_FEE_2)
: (changes[i] == NON_STABLE_POOL_FEE_1 || changes[i] == NON_STABLE_POOL_FEE_2);
require(isAllowed, "Unauthorized fee change detected in callstack");
}
}
}
Note: This code example is maintained in the Phylax Assertion Examples Repository. For a full examples with mock protocol code and tests please refer to the repository.
Testing
To test this assertion:
- Deploy the assertion contract with a test pool
- Execute transactions that should maintain fees
- Attempt transactions that would modify fees
- Verify the assertion catches unauthorized changes
Assertion Best Practices
- Use whitelists to explicitly define allowed fee values