Skip to content
Draft
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
cc8b238
chore: add foundry to package deps
Seth-Schmidt Feb 26, 2026
eb8322c
feat: initial simple invariant test for protocol staking
Seth-Schmidt Mar 3, 2026
32dae45
chore: use multiple periods for total supply invariant test
Seth-Schmidt Mar 4, 2026
e3d8529
feat: add ProtocolStakingHandler and use built-in invariant testing
Seth-Schmidt Mar 4, 2026
3c55b59
chore: cleanup old invariant tests
Seth-Schmidt Mar 4, 2026
f06d916
chore: rename protocol staking invariant test file
Seth-Schmidt Mar 4, 2026
8d0e8ea
feat: add invariant for total staked weight
Seth-Schmidt Mar 4, 2026
7ffc002
feat: support multiple actors for invariant tests
Seth-Schmidt Mar 4, 2026
0e3e79e
chore: remove debugging test
Seth-Schmidt Mar 4, 2026
49186e0
chore: add invariant for reward debt conservation; support storage reads
Seth-Schmidt Mar 4, 2026
086ae97
chore: add monotonicity fuzz tests for claimed, claimable, and awaiti…
Seth-Schmidt Mar 4, 2026
af3703c
fix: improper handling of the awaiting release invariant
Seth-Schmidt Mar 4, 2026
9c78c71
chore: add invariant for ending withdrawals solvency
Seth-Schmidt Mar 4, 2026
5830bf5
chore: add invariant for staking funds solvency
Seth-Schmidt Mar 4, 2026
5142a29
chore: add unstake queue monotonicity invariant test
Seth-Schmidt Mar 5, 2026
da83054
feat: add scenario handling for complex invariants
Seth-Schmidt Mar 5, 2026
2d2aa1b
chore: add handler for setUnstakeCooldownPeriod
Seth-Schmidt Mar 5, 2026
de6e463
chore: add TESTING.md for testing documentation
Seth-Schmidt Mar 5, 2026
f05331a
chore: protocol staking handler cleanup
Seth-Schmidt Mar 5, 2026
c07b8cc
fix: use modifier pattern for transitional invariants in favor of sta…
Seth-Schmidt Mar 6, 2026
5a5eceb
chore: update TESTING.md
Seth-Schmidt Mar 6, 2026
f36f141
chore: fail on revert enabled for handler modifier
Seth-Schmidt Mar 6, 2026
1b2a8a6
chore: comment cleanup
Seth-Schmidt Mar 6, 2026
45197eb
chore: bump invariant runs
Seth-Schmidt Mar 6, 2026
036cebd
chore: use harness to expose internal storage for testing
Seth-Schmidt Mar 6, 2026
1ef6f04
chore: remove unnecessary ghost_eligibleAccounts and simplify
Seth-Schmidt Mar 6, 2026
97d102f
chore: move scenario assertions inside scenario functions
Seth-Schmidt Mar 6, 2026
f1cd625
chore: assert 0 reward debt when staking is empty
Seth-Schmidt Mar 6, 2026
a21a879
chore: cleanup post-state transition invariant assertions
Seth-Schmidt Mar 6, 2026
987f8a4
chore: use forge-std from foundry installation
Seth-Schmidt Mar 6, 2026
61d738b
fix: apply tolerance to net reward debt on 0 stake weight
Seth-Schmidt Mar 6, 2026
b0b880b
fix: file linking in TESTING.md
Seth-Schmidt Mar 7, 2026
188ab6a
chore: prettier formatting
Seth-Schmidt Mar 7, 2026
c505307
fix: rename TESTING.md to FUZZING.md for clarity
Seth-Schmidt Mar 9, 2026
32ed0f5
chore: reduce runs for fuzz testing
Seth-Schmidt Mar 9, 2026
6bc32da
chore: fuzz initial values in invariant test setup
Seth-Schmidt Mar 9, 2026
931517a
fix: title in FUZZING.md
Seth-Schmidt Mar 9, 2026
c1d7182
fix: use makeAddr for actor accounts
Seth-Schmidt Mar 9, 2026
18e7bc9
chore: cleanup unnecessary selector targeting
Seth-Schmidt Mar 9, 2026
dece594
chore: use targetSender in favor of actor index bounding
Seth-Schmidt Mar 9, 2026
1bea63a
chore: allow for certain accounts to never be eligible
Seth-Schmidt Mar 9, 2026
6a28a1f
fix: only increment ghost accumulated reward capacity when staked
Seth-Schmidt Mar 9, 2026
5e54e3a
fix: increase MAX_PERIOD_DURATION and decouple MAX_UNSTAKE_COOLDOWN_P…
Seth-Schmidt Mar 9, 2026
7394a67
chore: remove wei buffer for invariant_TotalSupplyBoundedByRewardRate…
Seth-Schmidt Mar 9, 2026
14a9a04
fix: update invariant tolerances with concrete bounds
Seth-Schmidt Mar 10, 2026
ed53d1e
fix: linting errors
Seth-Schmidt Mar 10, 2026
4e3b748
test: add unit tests for minting unbacked wei using unstake
Seth-Schmidt Mar 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/staking/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ contracts-exposed
# Foundry
/out
/cache_forge
/lib/forge-std

yarn.lock
fhevmTemp
21 changes: 19 additions & 2 deletions contracts/staking/foundry.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
[profile.default]
src = 'contracts'
libs = ['node_modules']
out = 'out'
test = 'test/foundry'
cache_path = 'cache_forge'
libs = ['lib', 'node_modules']
solc = '0.8.27'
optimizer_runs = 200
optimizer = true
optimizer_runs = 800
evm_version = 'cancun'

remappings = [
'forge-std/=lib/forge-std/src/',
'@openzeppelin/contracts/=node_modules/@openzeppelin/contracts/',
'@openzeppelin/contracts-upgradeable/=node_modules/@openzeppelin/contracts-upgradeable/',
'token/=../token/',
]

[invariant]
runs = 4096
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just tried to run fuzz tests locally and realised 4096 is actually taking a while on my machine.
Maybe it makes sense to go back to 256 runs here, and to integrate fuzz testing in the CI. If it runs regularly via CI it should be good enough to catch future (or past) bugs. Wdyt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense to me, and regular runs are preferable. My only concern with CI integration would be how the dust tolerances are handled so that they won't incorrectly interrupt a workflow. Finding a known bound as per #50 (comment) would prevent this.

depth = 100
fail_on_revert = true
45 changes: 26 additions & 19 deletions contracts/staking/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions contracts/staking/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"lint:sol": "prettier --loglevel warn '{contracts,test}/**/*.sol' --check && solhint --config solhint.config.js '{contracts,test}/**/*.sol'",
"lint:sol:fix": "solhint --config solhint.config.js '{contracts,test}/**/*.sol' --fix; prettier --loglevel warn '{contracts,test}/**/*.sol' --write",
"test": "hardhat test",
"test:fuzz": "forge test",
"test:fuzz:verbose": "forge test -vvv",
"test:tasks": "hardhat test:tasks",
"test:gas": "REPORT_GAS=true hardhat test",
"test:pragma": "npm run compile && scripts/checks/pragma-validity.js artifacts/build-info/*",
Expand Down
144 changes: 144 additions & 0 deletions contracts/staking/test/foundry/ProtocolStakingInvariantTest.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

import {Test} from "forge-std/Test.sol";
import {ProtocolStakingHarness} from "./harness/ProtocolStakingHarness.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ZamaERC20} from "token/contracts/ZamaERC20.sol";
import {ProtocolStakingHandler} from "./handlers/ProtocolStakingHandler.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

// Invariant fuzz test for ProtocolStaking
contract ProtocolStakingInvariantTest is Test {
ProtocolStakingHarness internal protocolStaking;
ZamaERC20 internal zama;
ProtocolStakingHandler internal handler;

address internal governor = address(1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I would prefer to use here the vm.makeAddr cheatcode, which is cleaner (since address(1), 2 and 3, are precompiles addresses).
Something like: address internal governor = vm.makeAddr("alice");, manager = vm.makeAddr("bob"); , etc.

address internal manager = address(2);
address internal admin = address(3);

uint256 internal constant ACTOR_COUNT = 5;
uint256 internal constant INITIAL_TOTAL_SUPPLY = 1_000_000 ether;
uint256 internal constant INITIAL_REWARD_RATE = 1e18; // 1 token/second
uint48 internal constant INITIAL_UNSTAKE_COOLDOWN_PERIOD = 1 seconds;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally those 3 variables: INITIAL_TOTAL_SUPPLY, INITIAL_REWARD_RATE and INITIAL_UNSTAKE_COOLDOWN_PERIOD constant values should not be constants, but could be fuzzed as well...
You can update the setup() method by leveraging vm.randomUint(min, max) cheatcode.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern here is that using random values in the setUp will prevent test replays for failures. It should be possible to log the global fuzz seed that foundry uses on a failure to later be used, but that is turning out to be non-trivial.

One option is to wrap the test runtime with a function to generate a random seed and pass it via --fuzz-seed which could then be logged if a replay is needed.

I'm not convinced this is the best way to do this, but I'm not sure we have many options available. wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok maybe using randomUint is not the best here, but I am concerned using INITIAL_UNSTAKE_COOLDOWN_PERIOD = 1 seconds is too specific (this is an unrealistic edge case) i.e it is limiting coverage for sure here...

Copy link
Collaborator

@melanciani melanciani Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be possible to log the global fuzz seed that foundry uses on a failure to later be used, but that is turning out to be non-trivial.

I'm not fully familiar with foundry but it's a shame that there is no easy seeding mechanism for random tests indeed

Ok maybe using randomUint is not the best here, but I am concerned using INITIAL_UNSTAKE_COOLDOWN_PERIOD = 1 seconds is too specific (this is an unrealistic edge case) i.e it is limiting coverage for sure here...

would an alternative be to have a fix set of (log) scales to try ? like [1, 10, 100, 1000] seconds ?


function setUp() public {
address[] memory actorsList = new address[](ACTOR_COUNT);
for (uint256 i = 0; i < ACTOR_COUNT; i++) {
actorsList[i] = address(uint160(4 + i));
}

// Deploy ZamaERC20, mint to all actors, admin is DEFAULT_ADMIN
uint256 initialActorBalance = INITIAL_TOTAL_SUPPLY / ACTOR_COUNT;
address[] memory receivers = new address[](ACTOR_COUNT);
uint256[] memory amounts = new uint256[](ACTOR_COUNT);
for (uint256 i = 0; i < ACTOR_COUNT; i++) {
receivers[i] = actorsList[i];
amounts[i] = initialActorBalance;
}

zama = new ZamaERC20("Zama", "ZAMA", receivers, amounts, admin);

// Deploy ProtocolStaking behind ERC1967 proxy
ProtocolStakingHarness impl = new ProtocolStakingHarness();
bytes memory initData = abi.encodeCall(
protocolStaking.initialize,
(
"Staked ZAMA",
"stZAMA",
"1",
address(zama),
governor,
manager,
INITIAL_UNSTAKE_COOLDOWN_PERIOD,
INITIAL_REWARD_RATE
)
);
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData);
protocolStaking = ProtocolStakingHarness(address(proxy));

// Grant MINTER_ROLE on Zama to ProtocolStaking
vm.startPrank(admin);
zama.grantRole(zama.MINTER_ROLE(), address(protocolStaking));
vm.stopPrank();

// Approve ProtocolStaking for all actors
for (uint256 i = 0; i < ACTOR_COUNT; i++) {
vm.prank(actorsList[i]);
zama.approve(address(protocolStaking), type(uint256).max);
}

// Deploy handler with actors list
handler = new ProtocolStakingHandler(protocolStaking, zama, manager, actorsList);
targetContract(address(handler));

bytes4[] memory selectors = new bytes4[](12);
selectors[0] = ProtocolStakingHandler.warp.selector;
selectors[1] = ProtocolStakingHandler.setRewardRate.selector;
selectors[2] = ProtocolStakingHandler.addEligibleAccount.selector;
selectors[3] = ProtocolStakingHandler.removeEligibleAccount.selector;
selectors[4] = ProtocolStakingHandler.stake.selector;
selectors[5] = ProtocolStakingHandler.unstake.selector;
selectors[6] = ProtocolStakingHandler.claimRewards.selector;
selectors[7] = ProtocolStakingHandler.release.selector;
selectors[8] = ProtocolStakingHandler.unstakeThenWarp.selector;
selectors[9] = ProtocolStakingHandler.stakeEquivalenceScenario.selector;
selectors[10] = ProtocolStakingHandler.unstakeEquivalenceScenario.selector;
selectors[11] = ProtocolStakingHandler.setUnstakeCooldownPeriod.selector;
targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed here: 18e7bc9

}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1bea63a

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.


function invariant_TotalStakedWeightEqualsEligibleWeights() public view {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe best to keep same invariant order vs what you've described in the md file !

assertEq(
protocolStaking.totalStakedWeight(),
handler.computeExpectedTotalWeight(),
"totalStakedWeight does not match sum of eligible weights"
);
}

function invariant_TotalSupplyBoundedByRewardRate() public view {
assertLe(
zama.totalSupply(),
// TODO: Occasional Off-by-one error in the ghost total supply calculation, need to locate the source of the error
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kind of rounding error should be investigated, because it is precisely this kind of issue which could lead to vulnerabilities. I remember already have found this kind of error when writing the Notion page for invariants, I believe many of them stem from the fact that sqrt function is an approximation here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have tracked down the cause of this error, outlined in the test_FractionalDustPrinter unit test.

This particular error is unbounded and the variance is limited by the number of executed transaction sequences that result in this error. What sort of tolerance would you recommend here? Should we attempt to track the sequence and increment an allowance? That seems rather brittle and doesn't necessarily accomplish much, but a set tolerance could always be exceeded with enough runs unless we make it unreasonable.

// adding small buffer of 1 wei to account for this for now
handler.ghost_initialTotalSupply() + handler.ghost_accumulatedRewardCapacity() + 1,
"totalSupply exceeds piecewise rewardRate bound"
);
}

function invariant_RewardDebtConservation() public view {
uint256 tolerance = handler.REWARD_DEBT_CONSERVATION_TOLERANCE();
int256 lhs = handler.computeRewardDebtLHS();
// When the system is empty, net debt across all users should net out to 0
// Σ _paid[account] + Σ earned(account) = 0
// Using ApproxEqAbs per contract comment: "Accounting rounding may have a marginal impact on earned rewards (dust)."
if (protocolStaking.totalStakedWeight() == 0) {
assertApproxEqAbs(lhs, 0, tolerance, "Net reward debt must be 0 when no one is staked");
return;
}
int256 rhs = handler.computeRewardDebtRHS();
assertApproxEqAbs(lhs, rhs, tolerance, "reward debt conservation");
}

function invariant_PendingWithdrawalsSolvency() public view {
address token = protocolStaking.stakingToken();
uint256 balance = IERC20(token).balanceOf(address(protocolStaking));
uint256 sumAwaitingRelease;
for (uint256 i = 0; i < handler.actorsLength(); i++) {
sumAwaitingRelease += protocolStaking.awaitingRelease(handler.actorAt(i));
}
assertGe(balance, sumAwaitingRelease, "pending withdrawals solvency");
}

function invariant_StakedFundsSolvency() public view {
for (uint256 i = 0; i < handler.actorsLength(); i++) {
address account = handler.actorAt(i);
uint256 totalStaked = handler.ghost_totalStaked(account);
uint256 balance = protocolStaking.balanceOf(account);
uint256 awaiting = protocolStaking.awaitingRelease(account);
uint256 released = handler.ghost_totalReleased(account);
assertEq(totalStaked, balance + awaiting + released, "staked funds solvency");
}
}
}
Loading
Loading