feat: fuzzing of staking invariants for ProtocolStaking contract with Foundry#50
feat: fuzzing of staking invariants for ProtocolStaking contract with Foundry#50Seth-Schmidt wants to merge 46 commits intomainfrom
Conversation
…ng release values
contracts/staking/test/foundry/handlers/ProtocolStakingHandler.sol
Outdated
Show resolved
Hide resolved
| selectors[9] = ProtocolStakingHandler.stakeEquivalenceScenario.selector; | ||
| selectors[10] = ProtocolStakingHandler.unstakeEquivalenceScenario.selector; | ||
| selectors[11] = ProtocolStakingHandler.setUnstakeCooldownPeriod.selector; | ||
| targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); |
There was a problem hiding this comment.
I don't think you need this whole list of selectors and a call to targetSelector here. Foundry by default will fuzz all available public/external functions from the targetContract.
| selectors[10] = ProtocolStakingHandler.unstakeEquivalenceScenario.selector; | ||
| selectors[11] = ProtocolStakingHandler.setUnstakeCooldownPeriod.selector; | ||
| targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); | ||
| } |
There was a problem hiding this comment.
On the other hand, since Foundry will use random msg.sender addresses in invariant testing, maybe it would make sense to use targetSenders cheatcode here, and to pass the 5 actors to it (ie the eligible account) + few other random addresses, this is in order to reduce bias. Because my main concern here is that most calls would be done with current setup with uneligible accounts, thus reducing the coverage of the fuzzing campaign. Wdyt?
There was a problem hiding this comment.
Actually just noticed that you are using vm.prank calls in all functions of the handler contract to handle this correctly.
But even despite this trick, I think you are restricting a bit too much the fuzzer. Technically, the ProtocolStaking allows uneligible account to call stake() function without reverting. IMO it would be best to also allow some uneligible account to call it, to improve coverage (while still biasing for eligible accounts). There are few methods to do this, I will let you decide what is best approach.
There was a problem hiding this comment.
The caller should always be a known actor through the combination of bounding the actor index actorIndex = bound(actorIndex, 0, actors.length - 1); and a subsequent prank(account) before each staking action in the handler (or the manager if it is an admin action). However, I do think it makes sense to be explicit and use targetSenders for traces and the removal of ambiguity.
There was a problem hiding this comment.
Actually just noticed that you are using vm.prank calls in all functions of the handler contract to handle this correctly. But even despite this trick, I think you are restricting a bit too much the fuzzer. Technically, the ProtocolStaking allows uneligible account to call stake() function without reverting. IMO it would be best to also allow some uneligible account to call it, to improve coverage (while still biasing for eligible accounts). There are few methods to do this, I will let you decide what is best approach.
Got it. You can ignore my previous reply, the comment had not refreshed yet. I think we came to the same conclusion on this.
There was a problem hiding this comment.
The actors have now been added as targetSender's
To enforce ineligibility, I made an isOutgroup flag that prevents ~20% of actors from ever becoming eligible, however they can still call the rest of the functions.
… to generate counter examples
| targetContract(address(handler)); | ||
|
|
||
| for (uint256 i = 0; i < actorCount; i++) { | ||
| targetSender(actorsList[i]); |
There was a problem hiding this comment.
I am a bit confused, I think you don't need to use targetSender here, since you are using vm.prank in all handler's methods?
There was a problem hiding this comment.
I have removed the vm.prank for account level actions in the handler, but the manager prank for admin actions remain. The targetSender is preferable in my opinion because of the reduction in pranks and not having to fuzz the actor index for each action.
| function setUp() public { | ||
| uint256 initialDistribution = uint256(vm.randomUint(MIN_INITIAL_DISTRIBUTION, MAX_INITIAL_DISTRIBUTION)); | ||
| uint48 initialUnstakeCooldownPeriod = uint48( | ||
| vm.randomUint(MIN_UNSTAKE_COOLDOWN_PERIOD, MAX_UNSTAKE_COOLDOWN_PERIOD) |
There was a problem hiding this comment.
I thought from your previous comment #50 (comment) that using vm.randomUint in the setup would be problematic, so I am a bit confused seeing you are using it here... Was it not an issue ultimately?
| address account = msg.sender; | ||
| uint256 amount = protocolStaking.earned(account); | ||
| protocolStaking.claimRewards(account); | ||
| assertEq(protocolStaking.earned(account), 0, "earned(account) must be 0 after claimRewards"); |
There was a problem hiding this comment.
is this assert needed ? sounds more like a unit test logic (like we already have)
I'm asking because I don't see any other assets in other functions so it looks a bit inconsistent
There was a problem hiding this comment.
This assert is necessary to check that any fuzzed state does not allow for dust in the earned calculation after claiming. I agree it should be moved outside of the handler action itself to a modifier to be inline with the other transitional invariants.
| ghost_currentRate = rate; | ||
| } | ||
|
|
||
| function addEligibleAccount() public assertTransitionInvariants { |
There was a problem hiding this comment.
for clarity I would maybe rename this function into addEligibleMsgSender or similar
same for removeEligibleAccount -> removeEligibleMsgSender
alternatively, you could add an address account input to these functions
There was a problem hiding this comment.
There initially was an account_index input for the fuzzer to use for account choosing, but foundry has a feature of targetSender that allows the passing of addresses for the fuzzer to randomly use. It's these addresses that will be used to call all of the handler functions by the fuzzing engine:
I think I will add some documentation around this, because it's not exactly intuitive, especially for testing.
The idea for leaving removeEligibleAccount is to keep the 1 to 1 relationship between handler and ProtocolStaking functions. It is more of an override/wrapper than a standalone function. I have no problem with adjusting the naming, but just want to be clear on the initial idea.
| addEligibleAccount(); | ||
|
|
||
| uint256 balance = zama.balanceOf(account); | ||
| if (balance < 2) return; |
There was a problem hiding this comment.
best practice with unmet conditions is to return instead of revert here ? just asking to be sure some scenari are not silently failing because of this !
There was a problem hiding this comment.
There seems to be two schools of thought around handling reverts: either reverts are encouraged or avoided. Too many reverts can inhibit the building of a fuzzed state. I've leaned more towards avoiding, but the only action that is gated by an early return is unstake to avoid transferFrom issues. Thinking about it now, we can probably expect and assert the revert instead to be more explicit with the expected behavior.
|
|
||
| ### 1. Global Invariants | ||
|
|
||
| Checked via `invariant_*` functions in the main test contract after every handler call. |
There was a problem hiding this comment.
I think these are great but imo we should be a bit more rigorous here, by specifically detailing what are the different variables in each invariant
for ex in Staked funds solvency:, it is not 100% clear what is totalStaked and released (a sum ? for a single account ? a function to call ?) !
|
|
||
| - Claimed + claimable never decreases: | ||
| ``` | ||
| claimed + earned is strictly non-decreasing per account across any action (incorporating a tolerance for division rounding). |
There was a problem hiding this comment.
why say "strictly non-decreasing" instead of "strictly increasing" ?
|
|
||
| - Unstake queue monotonicity: | ||
| ``` | ||
| _unstakeRequests checkpoints strictly enforce non-decreasing timestamps and cumulative amounts. |
There was a problem hiding this comment.
i think it should be strictly increasing timestamps .
Also do you think the and cumulative amounts. should be an invariant of itself ?
and in our invariant page we also had this one which I think should be checked as well, right ? :
The _released[account] is always inferior or equal to the latest unstake request in _unstakeRequest[account].latest() , ie awaitingRelease view function should never revert.
There was a problem hiding this comment.
Also do you think the and cumulative amounts. should be an invariant of itself ?
Yes maybe there can be some additional clarity around the notion invariant: "The _unstakeRequests checkpoints must have increasing timestamps and “cumulative amounts”, ie only increasing for same timestamps keys."
I took the literal interpretation of only increasing for same timestamps keys:
but I will double check how checkpoints are handled, intuitively the amounts should always be increasing.
and in our invariant page we also had this one which I think should be checked as well, right ?
This one gets handled inherently by the awaitingRelease(account) call:
which has that check directly in the function:
I can update FUZZING.md to be more explicit about this.
| _unstakeRequests checkpoints strictly enforce non-decreasing timestamps and cumulative amounts. | ||
| ``` | ||
|
|
||
| - Earned is zero after claim: |
There was a problem hiding this comment.
we should also add the same claimRewards no ?
|
|
||
| - Stake equivalence: | ||
| ``` | ||
| stake(a1 + a2) results in the exact same shares, weight, and (approx) earned rewards as stake(a1) followed by stake(a2). |
There was a problem hiding this comment.
weight is also approx no ? due to rounding errors
|
|
||
| - Unstake equivalence: | ||
| ``` | ||
| A partial unstake to a target amount results in the exact same shares, weight, and (approx) earned rewards as a full unstake followed by a new stake of the target amount. |
| } | ||
| } | ||
|
|
||
| function invariant_TotalStakedWeightEqualsEligibleWeights() public view { |
There was a problem hiding this comment.
nit: maybe best to keep same invariant order vs what you've described in the md file !
| lhs += staking._harness_getPaid(users[i]) + SafeCast.toInt256(staking.earned(users[i])); | ||
| } | ||
|
|
||
| assertEq(rhs - lhs, 2, "Truncation dust exceeds N - 1 expectation"); |
There was a problem hiding this comment.
is using N=3 enough here ? shouldn't we try with like 5 or more to be sure we actually check N-1 and not something close like N/2 or 1+1 ?
| assertEq(totalStaked, balance + awaiting + released, "staked funds solvency"); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
are we not missing equivalence invariant tests ? I can't find where stakeEquivalenceScenarioand unstakeEquivalenceScenario` functions are called
There was a problem hiding this comment.
The scenario tests were initially structured as invariants:
The fuzzer will call the scenario functions as a part of the fuzzing campaign at random points in the fuzzed state. The handler functions must be atomic to leverage the vm.snapshot cheat code that allows comparing the different terminal states against the same starting state. So, these "invariants" are not true invariants and are only checked when the function is called by the fuzzer.
I removed the invariant tests for these because it causes the engine to check the same ghost states after each function call when those variables will only change when the fuzzer calls one of the handler functions, so we can check directly in the function itself.
|
|
||
| We separate our invariant rules into two distinct categories to handle EVM state constraints: | ||
|
|
||
| 1. **Global Invariants**: Checked via invariant_* functions in the test contract after every sequence step. These check system-wide accounting rules. |
There was a problem hiding this comment.
what about equivalence invariants ?
melanciani
left a comment
There was a problem hiding this comment.
overall looks really great, but I think we are missing a few docs and tests !
Summary
Invariant fuzzing for the ProtocolStaking contract using Foundry. Uses a handler-based setup to exercise staking, rewards, and unstaking flows under randomized sequences.
Structure
How to run
Files Modified
contracts/staking/test/foundry/ProtocolStakingInvariantTest.t.solcontracts/staking/test/foundry/handlers/ProtocolStakingHandler.solcontracts/staking/test/foundry/harness/ProtocolStakingHarness.solcontracts/staking/test/foundry/TESTING.md(setup and invariants)contracts/staking/foundry.tomlcontracts/staking/package.jsoncloses: https://github.com/zama-ai/protocol-apps-internal/issues/101