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.
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:
The variable to be bounded (e.g., amount
).
The minimum allowed value for that variable (e.g., 1e5
).
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:
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.
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:
Bound Input: The fuzzed amount
for deposit is bounded:
User Setup and Deposit: A test user
is given ETH and then deposits the fuzzed amount
into the Vault
:
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
.
First Time Warp: Time is advanced by 1 hour using vm.warp(block.timestamp + 1 hours);
.
Balance Check After First Warp: The balance (middleBalance
) is fetched again. It must be greater than startBalance
due to interest accrual.
Second Time Warp: Time is advanced by another hour.
Balance Check After Second Warp: The new balance (endBalance
) is checked and must be greater than middleBalance
.
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
).
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:
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:
redeem
Operations: Immediate and DelayedRobust 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.
User deposits a fuzzed amount
.
User calls vault.redeem(type(uint256).max)
to redeem their full balance.
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:
(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.
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.
User deposits depositAmount
.
Time is warped forward by time
seconds.
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.
User redeems their full balance.
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.
transfer
and Interest Rate MechanicsThe testTransfer(uint256 amount, uint256 amountToSend)
fuzz test evaluates the rebase token's transfer
functionality and a specific design choice regarding interest rate inheritance.
user
deposits an amount
of tokens.
The owner (deployer) reduces the global interest rate (s_interestRate
) from its initial value (e.g., 5e10
) to a lower value (e.g., 4e10
).
user
transfers amountToSend
rebase tokens to user2
.
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:
This ensures the global interest rate can only be decreased or kept the same, preventing arbitrary increases.
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.
User deposits a fuzzed amount
.
The principal balance is checked immediately: assertEq(rebaseToken.principleBalanceOf(user), amount);
Time is warped forward to allow interest to accrue.
The principal balance is checked again. It should remain unchanged and still be equal to the original amount
, despite changes in the regular balanceOf
.
This test confirms that the contract accurately tracks the user's base contribution, distinct from their interest-adjusted balance.
forge coverage
for Comprehensive TestingAchieving 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.
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
.
Use vm.prank(user)
to simulate a call from a non-owner account (user
).
Attempt to call rebaseToken.setInterestRate(newInterestRate)
.
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.
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
.
Use vm.prank(user)
where user
does not have the MINT_AND_BURN_ROLE
.
Attempt to call rebaseToken.mint(user, 1 ether)
and rebaseToken.burn(user, 1 ether)
in separate checks.
Expect the calls to revert with the AccessControlUnauthorizedAccount
error. Similar to the Ownable
test, use vm.expectPartialRevert
with the error selector.
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 July 22, 2025
Duration: 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 July 22, 2025