5/5
## Optimizing Fuzz Test Inputs: `bound` vs. `vm.assume` When writing fuzz tests in Foundry, choosing the right mechanism to constrain input values is crucial for test effectiveness. We often encounter scenarios where fuzzed inputs need to fall within specific ranges to be valid. Two common approaches are `vm.assume` and `bound`. `vm.assume(condition)` instructs Foundry to discard any test run where the fuzzed inputs do not satisfy the given `condition`. While useful, if the condition is too restrictive, it can lead to a significantly reduced number of effective test runs, potentially missing edge cases. In contrast, `variable = bound(variable, min, max)` offers a more inclusive approach. If a fuzzed `variable` falls outside the specified `min` or `max` bounds, Foundry modifies the `variable` (typically using a modulus operation) to fit within these bounds instead of discarding the run. This ensures that more test runs are executed with inputs that are valid or coerced into validity, thereby maximizing the chances of uncovering bugs across a wider spectrum of values. For robust rebase token testing, `bound` is generally preferred to preserve test runs and explore a comprehensive input space. The `bound` function is straightforward to use, accepting three arguments: 1. The variable to be bounded (e.g., `amount`). 2. The minimum allowed value for that variable (e.g., `1e5`). 3. The maximum allowed value for that variable (e.g., `type(uint96).max`). For instance, to ensure an `amount` variable remains within a practical range for token operations, we can use: ```solidity amount = bound(amount, 1e5, type(uint96).max); ``` This constrains `amount` to be at least `100,000` and no more than the maximum value representable by a `uint96`. This approach is also applied to other fuzzed inputs like `time` parameters to ensure they are within sensible, yet broad, boundaries. ## Verifying Linear Interest Accrual with Fuzz Tests A core feature of this rebase token is its ability to accrue interest linearly over time. The `testDepositLinear` fuzz test is designed to rigorously verify this behavior. The test follows these steps: 1. **Bound Input**: The fuzzed `amount` for deposit is bounded: ```solidity // In testDepositLinear(uint256 amount) amount = bound(amount, 1e5, type(uint96).max); ``` 2. **User Setup and Deposit**: A test `user` is given ETH and then deposits the fuzzed `amount` into the `Vault`: ```solidity vm.startPrank(user); vm.deal(user, amount); // Provide ETH for the deposit vault.deposit{value: amount}(); ``` 3. **Initial Balance Check**: Immediately after deposit, the user's rebase token balance (`startBalance`) is checked. Since no time has elapsed, it should theoretically equal the deposited `amount`. ```solidity uint256 startBalance = rebaseToken.balanceOf(user); // assertEq(startBalance, amount); // Initial attempt ``` 4. **First Time Warp**: Time is advanced by 1 hour using `vm.warp(block.timestamp + 1 hours);`. 5. **Balance Check After First Warp**: The balance (`middleBalance`) is fetched again. It must be greater than `startBalance` due to interest accrual. ```solidity uint256 middleBalance = rebaseToken.balanceOf(user); assertGt(middleBalance, startBalance); ``` 6. **Second Time Warp**: Time is advanced by another hour. 7. **Balance Check After Second Warp**: The new balance (`endBalance`) is checked and must be greater than `middleBalance`. ```solidity uint256 endBalance = rebaseToken.balanceOf(user); assertGt(endBalance, middleBalance); ``` 8. **Linearity Assertion**: The critical check: the interest accrued during the first hour (`middleBalance - startBalance`) should be equal to the interest accrued during the second hour (`endBalance - middleBalance`). ```solidity // assertEq(endBalance - middleBalance, middleBalance - startBalance); // Initial attempt ``` **Addressing Truncation Issues:** Initial runs of this test revealed failures in both `assertEq(startBalance, amount)` and the linearity assertion (`assertEq(endBalance - middleBalance, middleBalance - startBalance)`) by a small margin, typically 1 wei. This discrepancy arises from integer division truncation within the `RebaseToken.sol` contract's `balanceOf` function: ```solidity // In RebaseToken.sol function balanceOf(address _user) public view override returns (uint256) { return super.balanceOf(_user) + _calculateUserAccumulatedInterestSinceLastUpdate(_user) / PRECISION_FACTOR; } ``` The division by `PRECISION_FACTOR` (e.g., `1e18`) discards any remainder, leading to these minor precision losses. To accommodate this inherent behavior of integer arithmetic, the assertions are modified to use `assertApproxEqAbs(value1, value2, delta)`. This function passes the test if the absolute difference between `value1` and `value2` is less than or equal to `delta`. A `delta` of `1` (wei) is an acceptable margin of error for these calculations. The corrected assertions become: ```solidity // Corrected assertions in testDepositLinear assertApproxEqAbs(startBalance, amount, 1); // For initial deposit assertApproxEqAbs(endBalance - middleBalance, middleBalance - startBalance, 1); // For linearity ``` ## Testing `redeem` Operations: Immediate and Delayed Robust testing of the `redeem` functionality in the `Vault.sol` contract is essential. This involves scenarios where users redeem immediately after depositing and after allowing time for interest to accrue. **`testRedeemStraightAway(uint256 amount)`:** This test verifies the scenario where a user deposits and then immediately redeems their entire balance. 1. User deposits a fuzzed `amount`. 2. User calls `vault.redeem(type(uint256).max)` to redeem their full balance. 3. Assertions: * The user's rebase token balance should be 0 after redemption. * The user's ETH balance should be approximately equal to the initially deposited amount (gas costs are not precisely accounted for in this test for simplicity). **Debugging `Vault_RedeemFailed`:** This test initially failed with a `Vault_RedeemFailed()` error, accompanied by an `OutOfFunds` EVM error. The root cause was traced to the `redeem` function in `Vault.sol`. While the contract correctly determined the user's actual rebase token balance when `type(uint256).max` was passed as the redemption amount, the subsequent ETH transfer `payable(msg.sender).call{value: _amount}("")` was still attempting to send the raw input `_amount` (i.e., `type(uint256).max` ETH), which the vault did not possess. The fix involved ensuring that the `_amount` variable used for the ETH transfer correctly reflects the actual quantity of tokens being burned, especially after it's updated for the `type(uint256).max` case: ```solidity // Inside Vault.sol redeem function uint256 amountToRedeem = _amount; // Use a new variable to store the actual redeem amount if (_amount == type(uint256).max) { amountToRedeem = i_rebaseToken.balanceOf(msg.sender); } // ... logic to burn 'amountToRedeem' rebase tokens ... (bool success, ) = payable(msg.sender).call{value: amountToRedeem}(""); // Use amountToRedeem for ETH transfer if (!success) { revert Vault_RedeemFailed(); } ``` *(Note: The summary indicates the original `_amount` variable was modified and used. The above snippet offers a slightly safer pattern by using a new variable `amountToRedeem` for clarity and to avoid potential misuse of the input `_amount` if it were needed later in its original form, though directly modifying `_amount` as implied by the summary also works.)* **`testRedeemAfterTimePassed(uint256 depositAmount, uint256 time)`:** This test covers depositing, waiting for a fuzzed `time` period to allow interest accrual, and then redeeming. 1. A helper function, `addRewardsToVault(uint256 rewardAmount)`, is introduced to simulate external rewards being added to the vault. This ensures the vault has sufficient ETH to pay out accrued interest. ```solidity function addRewardsToVault(uint256 rewardAmount) public { (bool success, ) = payable(address(vault)).call{value: rewardAmount}(""); // vm.assume(success); // Optionally, assume the transfer succeeds } ``` 2. User deposits `depositAmount`. 3. Time is warped forward by `time` seconds. 4. Before redemption, `addRewardsToVault` is called. The `rewardAmount` is calculated as `balanceAfterSomeTime - depositAmount` to ensure enough funds are available for the increased balance due to interest. 5. User redeems their full balance. 6. Assertion: The user's final ETH balance should be greater than the initial `depositAmount` due to the accrued interest. **Debugging Arithmetic Overflow in Interest Calculation:** This test initially failed due to an arithmetic overflow within `_calculateUserAccumulatedInterestSinceLastUpdate` in `RebaseToken.sol`. The investigation revealed that `PRECISION_FACTOR` had been inadvertently set to a very high value (e.g., `1e27`), likely from a copy-paste or experimentation. The `linearInterest` calculation: `PRECISION_FACTOR + (s_userInterestRate[_user] * timeElapsed)` would overflow because `s_userInterestRate` (scaled by `1e27` if `PRECISION_FACTOR` was `1e27`) multiplied by a potentially large fuzzed `timeElapsed` would exceed `uint256.max`. The solution was to revert `PRECISION_FACTOR` in `RebaseToken.sol` to its intended value, `1e18`. Concurrently, the global `s_interestRate` was confirmed to be a more realistic value like `5e10` (representing a per-second rate when considering the `1e18` precision factor). The fuzzed `time` input was bounded using `type(uint96).max`, which is a large duration, but manageable with the corrected `PRECISION_FACTOR`. It was also noted that `depositAmount` should also be bounded to `type(uint96).max` for consistency with balance types. ## Testing `transfer` and Interest Rate Mechanics The `testTransfer(uint256 amount, uint256 amountToSend)` fuzz test evaluates the rebase token's `transfer` functionality and a specific design choice regarding interest rate inheritance. 1. `user` deposits an `amount` of tokens. 2. The owner (deployer) reduces the global interest rate (`s_interestRate`) from its initial value (e.g., `5e10`) to a lower value (e.g., `4e10`). 3. `user` transfers `amountToSend` rebase tokens to `user2`. 4. Assertions: * Balances of `user` and `user2` are updated correctly. * Critically, `user2` should inherit the interest rate that `user` had *at the time of user's initial deposit/interaction*, not the new, lower global rate. This means both `user` and `user2` should effectively have an interest rate of `5e10`, as their user-specific rates (`s_userInterestRate`) were set when the global rate was `5e10`. **Debugging `RebaseToken_InterestRateCanOnlyDecrease`:** During the setup for this test, or a related test for `setInterestRate`, an issue was found with the interest rate setting logic. The `setInterestRate` function in `RebaseToken.sol` initially had a faulty check: `if (_newInterestRate == s_interestRate) { revert ... }` This incorrectly prevented setting the rate to its current value and didn't properly enforce the "decrease only" rule. The fix was to change the condition to correctly implement the desired behavior: ```solidity // In RebaseToken.sol setInterestRate function if (_newInterestRate > s_interestRate) { revert RebaseToken__InterestRateCanOnlyDecrease(s_interestRate, _newInterestRate); } // s_interestRate = _newInterestRate; // Update the rate ``` This ensures the global interest rate can only be decreased or kept the same, preventing arbitrary increases. ## Ensuring Correct Principal Balance Reporting The `testGetPrincipleAmount(uint256 amount)` test (later aligned to test the function `principleBalanceOf`) verifies that the `principleBalanceOf(address _user)` function in `RebaseToken.sol` correctly returns the original amount of tokens minted or deposited by a user, excluding any accrued interest. 1. User deposits a fuzzed `amount`. 2. The principal balance is checked immediately: `assertEq(rebaseToken.principleBalanceOf(user), amount);` 3. Time is warped forward to allow interest to accrue. 4. The principal balance is checked again. It should remain unchanged and still be equal to the original `amount`, despite changes in the regular `balanceOf`. ```solidity // After warping time assertEq(rebaseToken.principleBalanceOf(user), amount); ``` This test confirms that the contract accurately tracks the user's base contribution, distinct from their interest-adjusted balance. ## Leveraging `forge coverage` for Comprehensive Testing Achieving high test coverage is paramount for smart contract security and reliability. Foundry's `forge coverage` command is an invaluable tool for identifying untested code paths. After initial test development, running `forge coverage` might reveal: * `Vault.sol`: 81.82% coverage * `RebaseToken.sol`: 68.57% coverage By systematically adding more tests, such as those for access control and edge cases identified by fuzzing, coverage can be significantly improved. For example, subsequent runs might show: * `Vault.sol`: 90.91% coverage * `RebaseToken.sol`: 77.14% coverage The remaining uncovered lines should be scrutinized. For instance, a path in `Vault.sol` related to the failure of `payable(msg.sender).call{value: _amount}("")` during a `redeem` operation might remain. Testing such a scenario often requires setting up a recipient contract that intentionally reverts on receiving ETH, which can be complex. While aiming for 100% coverage is ideal, a pragmatic approach involves weighing the complexity of testing certain rare external failure conditions. ## Writing Access Control Tests for Security Verifying access control mechanisms is critical. Tests should ensure that privileged functions can only be called by authorized accounts. **`testCannotSetInterestRate(uint256 newInterestRate)`:** This test ensures that only the `owner` (as defined by OpenZeppelin's `Ownable`) can call `setInterestRate`. 1. Use `vm.prank(user)` to simulate a call from a non-owner account (`user`). 2. Attempt to call `rebaseToken.setInterestRate(newInterestRate)`. 3. Expect the call to revert with the specific `OwnableUnauthorizedAccount` error from OpenZeppelin's `Ownable.sol`. Importantly, when dealing with custom errors that may or may not have arguments, or standard errors from libraries like OpenZeppelin, `vm.expectRevert()` might not be sufficient if it expects an exact revert data match. `vm.expectPartialRevert(bytes4(Ownable.OwnableUnauthorizedAccount.selector))` is more robust as it checks for the correct error selector, ignoring any arguments. ```solidity // Import Ownable.sol or its interface to access the error selector // import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; vm.prank(user); // User is not the owner vm.expectPartialRevert(bytes4(Ownable.OwnableUnauthorizedAccount.selector)); rebaseToken.setInterestRate(newInterestRate); ``` **`testCannotCallMintAndBurn()`:** This test ensures that only accounts possessing the `MINT_AND_BURN_ROLE` (as defined by OpenZeppelin's `AccessControl`) can call `mint` and `burn` on `RebaseToken.sol`. 1. Use `vm.prank(user)` where `user` does not have the `MINT_AND_BURN_ROLE`. 2. Attempt to call `rebaseToken.mint(user, 1 ether)` and `rebaseToken.burn(user, 1 ether)` in separate checks. 3. Expect the calls to revert with the `AccessControlUnauthorizedAccount` error. Similar to the `Ownable` test, use `vm.expectPartialRevert` with the error selector. ```solidity // Import IAccessControl.sol or AccessControl.sol to access the error selector // import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; vm.prank(user); // User does not have MINT_AND_BURN_ROLE vm.expectPartialRevert(bytes4(IAccessControl.AccessControlUnauthorizedAccount.selector)); rebaseToken.mint(user, 1 ether); vm.prank(user); vm.expectPartialRevert(bytes4(IAccessControl.AccessControlUnauthorizedAccount.selector)); rebaseToken.burn(user, 1 ether); // Assuming user has some balance to burn for this part ``` Consistent use of `vm.prank` (for single calls) or `vm.startPrank` / `vm.stopPrank` (for multiple calls) is important for correctly setting `msg.sender` in these tests. Also, ensure interfaces like `IRibaseToken` are complete; for example, adding `balanceOf` if it was initially missing is crucial for tests involving balance checks.
A strategic guide to Bulletproofing Rebase Tokens with Advanced Foundry Fuzzing - Explore advanced Foundry fuzz testing techniques to ensure rebase token robustness, covering input optimization with `bound`, rigorous testing of interest accrual, and validation of redeem/transfer logic. Learn to effectively debug common pitfalls like truncation and overflows, and enhance security through comprehensive access control tests and `forge coverage`.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025
Solidity Developer
Advanced FoundryDuration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Web3 engineer, educator, and Cyfrin co-founder. Patrick's smart contract development and security courses have helped hundreds of thousands of engineers kickstarting their careers into web3.
Guest lecturers:
Last updated on May 20, 2025