diff --git a/contracts/staking/.gitignore b/contracts/staking/.gitignore index 86bfe8f..f31cb77 100644 --- a/contracts/staking/.gitignore +++ b/contracts/staking/.gitignore @@ -62,6 +62,7 @@ contracts-exposed # Foundry /out /cache_forge +/lib/forge-std yarn.lock fhevmTemp diff --git a/contracts/staking/foundry.toml b/contracts/staking/foundry.toml index 10075a6..6cbc000 100644 --- a/contracts/staking/foundry.toml +++ b/contracts/staking/foundry.toml @@ -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 = 296 +depth = 100 +fail_on_revert = true diff --git a/contracts/staking/package-lock.json b/contracts/staking/package-lock.json index d8206e4..493190f 100644 --- a/contracts/staking/package-lock.json +++ b/contracts/staking/package-lock.json @@ -2649,7 +2649,6 @@ "integrity": "sha512-jx6fw3Ms7QBwFGT2MU6ICG292z0P81u6g54JjSV105+FbTZOF4FJqPksLfDybxkkOeq28eDxbqq7vpxRYyIlxA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.1.1", "lodash.isequal": "^4.5.0" @@ -2949,8 +2948,7 @@ "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.4.0.tgz", "integrity": "sha512-eCYgWnLg6WO+X52I16TZt8uEjbtdkgLC0SUX/xnAksjjrQI4Xfn4iBRoI5j55dmlOhDv1Y7BoR3cU7e3WWhC6A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@openzeppelin/contracts-upgradeable": { "version": "5.4.0", @@ -4153,7 +4151,6 @@ "integrity": "sha512-F+GklO8jBWlsaVV+9oHaPh5NJdd6rAKN4tklGfInX1Q7h0xPgVLP39Jl3eCulPB5qexI71ZFHwbljx4ZXNfouA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.15", "ts-essentials": "^7.0.1" @@ -4300,7 +4297,6 @@ "integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -4385,7 +4381,6 @@ "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", @@ -4620,7 +4615,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4715,7 +4709,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5596,7 +5589,6 @@ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -6350,6 +6342,31 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6579,7 +6596,6 @@ "integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -6922,7 +6938,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", @@ -7860,7 +7875,6 @@ "integrity": "sha512-gBfjbxCCEaRgMCRgTpjo1CEoJwqNPhyGMMVHYZJxoQ3LLftp2erSVf8ZF6hTQC0r2wst4NcqNmLWqMnHg1quTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ethereumjs/util": "^9.1.0", "@ethersproject/abi": "^5.1.2", @@ -10768,7 +10782,6 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -12993,7 +13006,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13273,7 +13285,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13391,7 +13402,6 @@ "integrity": "sha512-x/sQYr5w9K7yv3es7jo4KTX05CLxOf7TRWwoHlrjRh8H82G64g+k7VuWPJlgMo6qrjfCulOdfBjiaDtmhFYD/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prettier": "^2.1.1", "debug": "^4.3.1", @@ -13526,7 +13536,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14211,7 +14220,6 @@ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -15144,7 +15152,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/contracts/staking/package.json b/contracts/staking/package.json index 379c1d3..42d4ca2 100644 --- a/contracts/staking/package.json +++ b/contracts/staking/package.json @@ -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/*", diff --git a/contracts/staking/test/foundry/FUZZING.md b/contracts/staking/test/foundry/FUZZING.md new file mode 100644 index 0000000..b18bf5b --- /dev/null +++ b/contracts/staking/test/foundry/FUZZING.md @@ -0,0 +1,209 @@ +# ProtocolStaking Invariant Fuzz Testing + +Invariant and stateful fuzz testing for the `ProtocolStaking` contract using Foundry. + +## Prerequisites + +- [Foundry](https://book.getfoundry.sh/getting-started/installation) +- Node.js (v20+) +- npm + +## Installation & Setup + +From the repository root, install the required Node dependencies (OpenZeppelin, etc.): + +```bash +cd contracts/staking +npm install +forge install foundry-rs/forge-std --no-git +``` + +## Test Structure + +Tests use a **handler pattern**: a handler contract wraps ProtocolStaking, bounds fuzzed inputs, and tracks ghost state. Invariants run after each handler call in a fuzz sequence. + +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. + +2. **Transition Invariants**: Checked via the `assertTransitionInvariants` modifier directly inside the Handler contract. These compare State A (before an action) to State B (after an action) to ensure monotonicity (values only going up/down as expected). + +### Handler + +[`handlers/ProtocolStakingHandler.sol`](handlers/ProtocolStakingHandler.sol) + +- Wraps ProtocolStaking actions: `stake`, `unstake`, `claimRewards`, `release`, `warp`, `setRewardRate`, `addEligibleAccount`, `removeEligibleAccount`, `setUnstakeCooldownPeriod`, `unstakeThenWarp` +- Bounds inputs (e.g. `amount ≤ balance`, `actorIndex ∈ [0, actors.length)`) +- Tracks ghost state: `ghost_totalStaked`, `ghost_accumulatedRewardCapacity`, `ghost_eligibleAccounts`, `ghost_claimed`, etc. +- Exposes equivalence scenarios: `stakeEquivalenceScenario`, `unstakeEquivalenceScenario` + +### Invariant Test Contract + +[`ProtocolStakingInvariantTest.t.sol`](ProtocolStakingInvariantTest.t.sol) + +- Defines invariants via `invariant_*` functions +- Uses `targetContract` and `targetSelector` to limit which handler methods are fuzzed +- Invariants are checked after every handler call in the fuzz sequence + +## Invariants + +We separate our testing rules into three distinct categories: + +### 1. Global Invariants + +Checked via `invariant_*` functions in the main test contract after every handler call. + +- Total supply bounded by reward rate: +``` +totalSupply ≤ initialTotalSupply + Σ(δT_i × rewardRate_i) +``` + +- Total staked weight: +``` +totalStakedWeight() == Σ weight(balanceOf(account)) +``` + +- Reward debt conservation: +``` +Σ _paid[account] + Σ earned(account) == _totalVirtualPaid + historicalRewards(). +``` + +- Pending withdrawals solvency: +``` +balanceOf(protocolStaking) ≥ Σ awaitingRelease(account) +``` + +- Staked funds solvency: +``` +totalStaked == balanceOf(account) + awaitingRelease(account) + released +``` + +### 2. Transition Invariants + +Because Foundry reverts the EVM state after evaluating `invariant_*` functions, transition checks (State A vs. State B) are executed natively inside the Handler using the `assertTransitionInvariants` modifier. + +- Claimed + claimable never decreases: +``` +claimed + earned is strictly non-decreasing per account across any action (incorporating a tolerance for division rounding). +``` + +- Awaiting release never decreases: +``` +awaitingRelease(account) is non-decreasing until release() is explicitly called by that account. +``` + +- Unstake queue monotonicity: +``` +_unstakeRequests checkpoints strictly enforce non-decreasing timestamps and cumulative amounts. +``` + +- Earned is zero after claim: +``` +earned(account) is always zero after a claim +``` + +### 3. Equivalence Scenarios + +These ensure that complex or batched actions result in the exact same mathematical state as singular actions. They utilize vm.snapshotState() and are checked inline inside the Handler. + +- Stake equivalence: +``` +stake(a1 + a2) results in the exact same shares, weight, and (approx) earned rewards as stake(a1) followed by stake(a2). +``` + +- 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. +``` + +## Running Tests + +### 0. Install dependencies + +From the repository root: + +```bash +cd contracts/staking +npm install +forge install foundry-rs/forge-std --no-git +``` + +### 1. Run all invariant tests + +From `contracts/staking`: + +```bash +npm test:fuzz +``` + +Or directly with forge: + +```bash +forge test +``` + +### 2. Run with verbose output + +```bash +npm test:fuzz:verbose +``` + +Or: + +```bash +forge test -vvv +``` + +### 3. Run a single invariant + +```bash +forge test --match-contract ProtocolStakingInvariantTest --match-test invariant_UnstakeEquivalence +``` + +Replace `invariant_UnstakeEquivalence` with any invariant name (e.g. `invariant_RewardDebtConservation`, `invariant_StakeEquivalence`). + +### Configuration + +[`foundry.toml`](../../foundry.toml) + +## Coverage + +### 1. Generate coverage (Foundry) + +From `contracts/staking`: + +```bash +forge coverage +``` + +### 2. Generate LCOV report + +```bash +forge coverage --report lcov +``` + +This writes `lcov.info` in the current directory. + +### 3. Generate HTML coverage report + +Install `genhtml` (from `lcov` package): + +- **macOS:** `brew install lcov` +- **Ubuntu/Debian:** `apt install lcov` + +Then: + +```bash +forge coverage --report lcov +genhtml lcov.info -o coverage +``` + +### 4. View coverage report + +Open the HTML report: + +```bash +open coverage/index.html +``` + +The report is in `contracts/staking/coverage/index.html` when run from `contracts/staking`. diff --git a/contracts/staking/test/foundry/ProtocolStakingInvariantTest.t.sol b/contracts/staking/test/foundry/ProtocolStakingInvariantTest.t.sol new file mode 100644 index 0000000..71c224e --- /dev/null +++ b/contracts/staking/test/foundry/ProtocolStakingInvariantTest.t.sol @@ -0,0 +1,600 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/* solhint-disable func-name-mixedcase */ // Foundry discovers invariant tests by invariant_* prefix + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Test, console} from "forge-std/Test.sol"; +import {ZamaERC20} from "token/contracts/ZamaERC20.sol"; +import {ProtocolStakingHandler} from "./handlers/ProtocolStakingHandler.sol"; +import {ProtocolStakingHarness} from "./harness/ProtocolStakingHarness.sol"; + +// Invariant fuzz test for ProtocolStaking +contract ProtocolStakingInvariantTest is Test { + ProtocolStakingHarness internal protocolStaking; + ZamaERC20 internal zama; + ProtocolStakingHandler internal handler; + + address internal governor = makeAddr("governor"); + address internal manager = makeAddr("manager"); + address internal admin = makeAddr("admin"); + + uint256 internal constant MIN_ACTOR_COUNT = 5; + uint256 internal constant MAX_ACTOR_COUNT = 20; + + uint256 internal constant MIN_INITIAL_DISTRIBUTION = 1 ether; + uint256 internal constant MAX_INITIAL_DISTRIBUTION = 1_000_000_000 ether; + + uint256 internal constant MIN_UNSTAKE_COOLDOWN_PERIOD = 1 seconds; + uint256 internal constant MAX_UNSTAKE_COOLDOWN_PERIOD = 365 days; + + uint256 internal constant MIN_REWARD_RATE = 0; + uint256 internal constant MAX_REWARD_RATE = 1e24; + + 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) + ); + uint256 initialRewardRate = uint256(vm.randomUint(MIN_REWARD_RATE, MAX_REWARD_RATE)); + uint256 actorCount = uint256(vm.randomUint(MIN_ACTOR_COUNT, MAX_ACTOR_COUNT)); + + address[] memory actorsList = new address[](actorCount); + for (uint256 i = 0; i < actorCount; i++) { + actorsList[i] = makeAddr(string(abi.encodePacked("actor", i))); + } + + // Deploy ZamaERC20, mint to all actors, admin is DEFAULT_ADMIN + address[] memory receivers = new address[](actorCount); + uint256[] memory amounts = new uint256[](actorCount); + for (uint256 i = 0; i < actorCount; i++) { + receivers[i] = actorsList[i]; + amounts[i] = initialDistribution; + } + + 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, + initialUnstakeCooldownPeriod, + initialRewardRate + ) + ); + 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 < actorCount; 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)); + + for (uint256 i = 0; i < actorCount; i++) { + targetSender(actorsList[i]); + } + } + + function invariant_TotalStakedWeightEqualsEligibleWeights() public view { + assertEq( + protocolStaking.totalStakedWeight(), + handler.computeExpectedTotalWeight(), + "totalStakedWeight does not match sum of eligible weights" + ); + } + + function invariant_TotalSupplyBoundedByRewardRate() public view { + assertLe( + zama.totalSupply(), + // TODO: Account for tolerance in the invariant due to phantom wei minting, + // see test_FractionalDustPrinter for the proof of concept. + handler.ghost_initialTotalSupply() + handler.ghost_accumulatedRewardCapacity(), + "totalSupply exceeds piecewise rewardRate bound" + ); + } + + function invariant_RewardDebtConservation() public view { + uint256 tolerance = handler.computeRewardDebtTolerance(); + 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"); + } + } + + // ---------- Phantom Wei & Rounding Tests ---------- + + /// @dev Helper to quickly spin up an isolated protocol instance with specific token distributions + function _setupIsolatedStaking( + address[] memory users, + uint256[] memory amounts + ) internal returns (ZamaERC20 token, ProtocolStakingHarness staking) { + token = new ZamaERC20("Zama", "ZAMA", users, amounts, address(this)); + + ProtocolStakingHarness impl = new ProtocolStakingHarness(); + bytes memory initData = abi.encodeCall( + impl.initialize, + ("Staked ZAMA", "stZAMA", "1", address(token), address(this), manager, 1 days, 0) + ); + staking = ProtocolStakingHarness(address(new ERC1967Proxy(address(impl), initData))); + + token.grantRole(token.MINTER_ROLE(), address(staking)); + + for (uint256 i = 0; i < users.length; i++) { + vm.prank(users[i]); + token.approve(address(staking), type(uint256).max); + } + } + + /// @dev Demonstrates the "phantom wei" lock-in: claiming rewards, suffering ratio dilution, + /// and unstaking leaves an unbacked 1 wei in the user's _paid tracker. + function test_DilutionTrap() public { + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address charlie = makeAddr("charlie"); + + address[] memory users = new address[](3); + users[0] = alice; + users[1] = bob; + users[2] = charlie; + + uint256[] memory amounts = new uint256[](3); + amounts[0] = 1; + amounts[1] = 81; + amounts[2] = 1; + + ZamaERC20 token; + ProtocolStakingHarness staking; + (token, staking) = _setupIsolatedStaking(users, amounts); + + vm.startPrank(manager); + staking.addEligibleAccount(alice); + staking.addEligibleAccount(bob); + staking.addEligibleAccount(charlie); + vm.stopPrank(); + + // Setup initial pool + vm.prank(alice); + staking.stake(1); + vm.prank(bob); + staking.stake(81); + + // Accrue rewards + vm.prank(manager); + staking.setRewardRate(29); + vm.warp(block.timestamp + 1); + vm.prank(manager); + staking.setRewardRate(0); + + // 1. Claim: Locks Bob's _paid at 26 (29 pool * 9 weight / 10 total = 26.1 -> 26). + vm.prank(bob); + staking.claimRewards(bob); + + // 2. Dilute: Charlie adds 1 weight. Total pool becomes 31. Total weight becomes 11. + // Bob's new theoretical allocation drops to 25 (31 * 9 / 11 = 25.36 -> 25). + vm.prank(charlie); + staking.stake(1); + + // 3. Unstake: Subtracts Bob's current allocation (25) from his _paid (26). + // Bob's weight becomes 0, but 1 wei remains permanently locked in his _paid. + vm.prank(bob); + staking.unstake(81); + + // Evaluate invariant + int256 rhs = staking._harness_getTotalVirtualPaid() + SafeCast.toInt256(staking._harness_getHistoricalReward()); + int256 lhs = 0; + for (uint256 i = 0; i < users.length; i++) { + lhs += staking._harness_getPaid(users[i]) + SafeCast.toInt256(staking.earned(users[i])); + } + + assertEq(lhs - rhs, 1, "Invariant broken: Phantom wei locked in LHS"); + } + + /// @dev Validates the maximum expected truncation dust (N - 1) for active users. + function test_MaxNormalTruncationDust() public { + uint256 n = 20; + + address[] memory users = new address[](n); + uint256[] memory amounts = new uint256[](n); + + for (uint256 i = 0; i < n; i++) { + users[i] = makeAddr(string(abi.encodePacked("user", vm.toString(i)))); + amounts[i] = 1; + } + + ZamaERC20 token; + ProtocolStakingHarness staking; + (token, staking) = _setupIsolatedStaking(users, amounts); + + vm.startPrank(manager); + for (uint256 i = 0; i < n; i++) { + staking.addEligibleAccount(users[i]); + } + vm.stopPrank(); + + // 1. Setup weights: Every user has weight 1. (Total Weight = 20) + for (uint256 i = 0; i < n; i++) { + vm.prank(users[i]); + staking.stake(1); + } + + // 2. Generate exactly 39 wei of reward capacity. + // Formula to maximize dust: RewardRate % TotalWeight == TotalWeight - 1 + // 39 % 20 == 19 + vm.prank(manager); + staking.setRewardRate(39); + vm.warp(block.timestamp + 1); + vm.prank(manager); + staking.setRewardRate(0); + + // 3. Mathematical result: + // Each User: 39 * 1 / 20 = 1.95 -> floors to 1 (loses 0.95) + // Total allocated = 20 * 1 = 20. Total pool = 39. Resulting dust = 19 (N - 1). + + // Evaluate invariant without any claims or unstakes + int256 rhs = staking._harness_getTotalVirtualPaid() + SafeCast.toInt256(staking._harness_getHistoricalReward()); + int256 lhs = 0; + for (uint256 i = 0; i < n; i++) { + lhs += staking._harness_getPaid(users[i]) + SafeCast.toInt256(staking.earned(users[i])); + } + + assertEq(rhs - lhs, int256(n - 1), "Truncation dust exceeds N - 1 expectation"); + } + + /// @dev Dust Printing PoC: Demonstrates that downward rounding on exit abandons fractional dust + /// in the virtual pool, allowing remaining users to mint unauthorized tokens. + function test_FractionalDustPrinter() public { + address alice = makeAddr("alice"); // Target Weight: 2 + address bob = makeAddr("bob"); // Target Weight: 3 + + address[] memory users = new address[](2); + users[0] = alice; + users[1] = bob; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = 4; + amounts[1] = 9; + + ZamaERC20 token; + ProtocolStakingHarness staking; + (token, staking) = _setupIsolatedStaking(users, amounts); + + // Initial state: Bob eligible, Alice ineligible + vm.prank(manager); + staking.addEligibleAccount(bob); + vm.prank(alice); + staking.stake(4); + vm.prank(bob); + staking.stake(9); + + // Generate exactly 10 wei of capacity + vm.prank(manager); + staking.setRewardRate(10); + vm.warp(block.timestamp + 1); + vm.prank(manager); + staking.setRewardRate(0); + + uint256 authorizedRewards = staking._harness_getHistoricalReward(); + + assertEq(authorizedRewards, 10, "Authorized rewards should be 10"); + + // Bob extracts maximum theoretical value (10 wei) + vm.prank(bob); + staking.claimRewards(bob); + + // Alice enters, Bob exits (abandoning fractional dust) + vm.prank(manager); + staking.addEligibleAccount(alice); + vm.prank(manager); + staking.removeEligibleAccount(bob); + + assertEq(token.balanceOf(alice), 0, "Alice should have no tokens"); + + // Alice claims the abandoned dust + vm.prank(alice); + staking.claimRewards(alice); + + assertEq(token.balanceOf(alice), 1, "Alice should have 1 token after claiming abandoned dust"); + + uint256 totalMinted = token.balanceOf(alice) + token.balanceOf(bob); + + assertGt(totalMinted, authorizedRewards, "Protocol minted unbacked tokens"); + assertEq(totalMinted, 11, "Printer failed to extract exactly 1 wei over cap"); + } + + function test_BatchUnstakePrintsGlobalDust_18Decimals() public { + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + + ZamaERC20 token; + ProtocolStakingHarness staking; + + { + address[] memory users = new address[](2); + users[0] = alice; + users[1] = bob; + + uint256[] memory amounts = new uint256[](2); + amounts[0] = 2999999998188649249; // ~sqrt(3e18) + amounts[1] = 999999999965065000000; // ~sqrt(1000e18) + + (token, staking) = _setupIsolatedStaking(users, amounts); + } + + vm.startPrank(manager); + staking.addEligibleAccount(alice); + staking.addEligibleAccount(bob); + vm.stopPrank(); + + vm.prank(alice); + staking.stake(2999999998188649249); + vm.prank(bob); + staking.stake(999999999965065000000); + + uint256 initialTotalSupply = token.totalSupply(); + uint256 expectedTotalRewards = 10_000 * 1e18 * 10; + + vm.startPrank(manager); + staking.setRewardRate(10_000 * 1e18); + vm.warp(block.timestamp + 10); + staking.setRewardRate(0); + vm.stopPrank(); + + // Lock in positive debt before unstaking + vm.prank(bob); + staking.claimRewards(bob); + + { + uint256 currentWeight = staking.weight(staking.balanceOf(bob)); + uint256 weightStep = currentWeight / 20; + + vm.startPrank(bob); + for (uint256 j = 0; j < 20; j++) { + uint256 nextWeight = currentWeight - weightStep; + uint256 amountToUnstake; + + if (j == 19) { + nextWeight = 0; + amountToUnstake = staking.balanceOf(bob); + } else { + amountToUnstake = (currentWeight * currentWeight) - (nextWeight * nextWeight); + } + + staking.unstake(amountToUnstake); + currentWeight = nextWeight; + } + vm.stopPrank(); + } + + vm.prank(alice); + staking.claimRewards(alice); + + int256 globalDrift = int256(token.totalSupply() - initialTotalSupply) - int256(expectedTotalRewards); + + console.log("globalDrift", globalDrift); + console.log("expectedTotalRewards", expectedTotalRewards); + console.log("initialTotalSupply", initialTotalSupply); + console.log("token.totalSupply()", token.totalSupply()); + + assertGt(globalDrift, 0, "Protocol failed to over-mint unbacked dust"); + assertLe(globalDrift, 20, "Over-minting exceeded the 1-wei-per-op bound"); + } + + /// @dev Demonstrates unbounded dust extraction + function test_SybilRelayDustPrinter_18Decimals() public { + uint256 WAD = 1e18; + uint256 relayCount = 20; + + address[] memory users = new address[](relayCount); + uint256[] memory amounts = new uint256[](relayCount); + + // Setup Chaotic Sybil Weights + for (uint256 i = 0; i < relayCount; i++) { + users[i] = address(uint160(uint256(keccak256(abi.encode("sybil", i))))); + amounts[i] = ((i * 13) + 7) * WAD; + } + + ZamaERC20 token; + ProtocolStakingHarness staking; + (token, staking) = _setupIsolatedStaking(users, amounts); + + // Initial State + for (uint256 i = 0; i < relayCount; i++) { + vm.prank(users[i]); + staking.stake(amounts[i]); + } + + vm.prank(manager); + staking.addEligibleAccount(users[0]); + + uint256 initialTotalSupply = token.totalSupply(); + + // Generate 10 tokens of reward capacity + uint256 rate = 1 * WAD; + uint256 duration = 10; + uint256 expectedTotalRewards = rate * duration; + + vm.prank(manager); + staking.setRewardRate(rate); + vm.warp(block.timestamp + duration); + vm.prank(manager); + staking.setRewardRate(0); + + vm.prank(users[0]); + staking.claimRewards(users[0]); + + // The Extraction Loop + for (uint256 i = 0; i < relayCount - 1; i++) { + address currentSybil = users[i]; + address nextSybil = users[i + 1]; + + // Pass the baton: Next enters, Current exits + vm.startPrank(manager); + staking.addEligibleAccount(nextSybil); + staking.removeEligibleAccount(currentSybil); + vm.stopPrank(); + + // NextSybil claims as the lone account, bypassing claim truncation (W/W = 1) + vm.prank(nextSybil); + staking.claimRewards(nextSybil); + } + + // Measure the final physical drift + uint256 actualRewardsMinted = token.totalSupply() - initialTotalSupply; + int256 totalDrift = int256(actualRewardsMinted) - int256(expectedTotalRewards); + + console.log("Expected Total Rewards: ", expectedTotalRewards); + console.log("Actual Rewards Minted: ", actualRewardsMinted); + console.log("Total Unbacked Wei Printed: ", uint256(totalDrift)); + + // Final Assertions + assertGt(uint256(totalDrift), 0, "Failed to print dust"); + assertGt(uint256(totalDrift), relayCount / 3, "Dust did not scale continuously"); + } + + function test_SpongeAndMartyr_NoManagerPrivileges() public { + address alice = makeAddr("alice"); // Honest User + address sponge = makeAddr("sponge"); // Attacker Account 1 + address martyr = makeAddr("martyr"); // Attacker Account 2 + + ZamaERC20 token; + ProtocolStakingHarness staking; + + // Isolate the setup arrays so they drop off the stack immediately + { + address[] memory users = new address[](3); + users[0] = alice; + users[1] = sponge; + users[2] = martyr; + + uint256[] memory amounts = new uint256[](3); + amounts[0] = 3 * 1e18; // Alice Weight: sqrt(3e18) + amounts[1] = 1 * 1e18; // Sponge Weight: 1e9 + amounts[2] = 1000 * 1e18; // Martyr Weight: sqrt(1000e18) + + (token, staking) = _setupIsolatedStaking(users, amounts); + } + + // Initial Setup + vm.prank(manager); + staking.addEligibleAccount(alice); + vm.prank(alice); + staking.stake(3 * 1e18); + + vm.prank(manager); + staking.addEligibleAccount(sponge); + vm.prank(sponge); + staking.stake(1 * 1e18); + + vm.prank(manager); + staking.addEligibleAccount(martyr); + vm.prank(martyr); + staking.stake(1000 * 1e18); + + // Generate Rewards (Block Scoped) + { + vm.prank(manager); + staking.setRewardRate(10_000 * 1e18); + vm.warp(block.timestamp + 10); + vm.prank(manager); + staking.setRewardRate(0); + } + + // Snapshot legitimate baselines before the attack + uint256 aliceExpected = staking.earned(alice); + uint256 spongeExpectedBaseline = staking.earned(sponge); + + // The Attack Step 1: Martyr claims legitimate rewards first + vm.prank(martyr); + staking.claimRewards(martyr); + + // The Attack Step 2: Martyr executes chunked unstake to print dust + uint256 currentWeight = staking.weight(1000 * 1e18); + uint256 weightStep = currentWeight / 10; + + vm.startPrank(martyr); + for (uint256 j = 0; j < 10; j++) { + uint256 nextWeight = currentWeight - weightStep; + + // On the final chunk, ensure weight zeroes out completely + if (j == 9) nextWeight = 0; + + staking.unstake((currentWeight * currentWeight) - (nextWeight * nextWeight)); + currentWeight = nextWeight; + } + vm.stopPrank(); + + // Measure Results + // Alice (Honest User) claims + vm.prank(alice); + staking.claimRewards(alice); + + // Sponge (Attacker) claims + vm.prank(sponge); + staking.claimRewards(sponge); + + console.log("--- The Results ---"); + + int256 aliceGain = int256(token.balanceOf(alice)) - int256(aliceExpected); + console.log("Alice (Honest) Unexpected Gain: %s wei", uint256(aliceGain)); + + int256 spongeGain = int256(token.balanceOf(sponge)) - int256(spongeExpectedBaseline); + if (spongeGain >= 0) { + console.log("Sponge (Attacker) Gain: +%s wei", uint256(spongeGain)); + } else { + console.log("Sponge (Attacker) Loss: -%s wei", uint256(-spongeGain)); + } + + // Alice received free dust printed by the Martyr + assertGt(aliceGain, 0, "Alice should have received the abandoned dust"); + + // Attacker's Sponge failed to extract a meaningful amount + // because the claim truncation and proportional sharing swallowed it. + assertLe(spongeGain, 0, "Attacker should not profit from this vector"); + } +} diff --git a/contracts/staking/test/foundry/handlers/ProtocolStakingHandler.sol b/contracts/staking/test/foundry/handlers/ProtocolStakingHandler.sol new file mode 100644 index 0000000..2974acf --- /dev/null +++ b/contracts/staking/test/foundry/handlers/ProtocolStakingHandler.sol @@ -0,0 +1,423 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/* solhint-disable var-name-mixedcase */ // ghost_variables prefix + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {Test} from "forge-std/Test.sol"; +import {ZamaERC20} from "token/contracts/ZamaERC20.sol"; +import {ProtocolStakingHarness} from "./../harness/ProtocolStakingHarness.sol"; + +/** + * @title ProtocolStakingHandler + * @notice Handler for invariant tests: wraps ProtocolStaking actions, bounds inputs, and tracks ghost state. + */ +contract ProtocolStakingHandler is Test { + ProtocolStakingHarness public protocolStaking; + ZamaERC20 public zama; + + address public manager; + address[] public actors; + mapping(address => bool) public isOutgroup; + + // @dev Maximum duration to warp the block timestamp by. + uint256 public constant MAX_PERIOD_DURATION = 365 days * 3; + // @dev Maximum unstake cooldown period. Must be <= 365 days for required checks. + uint256 public constant MAX_UNSTAKE_COOLDOWN_PERIOD = 365 days; + // @dev Maximum reward rate. + uint256 public constant MAX_REWARD_RATE = 1e24; + + // The 2-step path (Path B) incurs up to 2 wei of compounding truncation drift + // compared to a 1-step action (Path A) due to intermediate virtual pool updates. + // See: test_MaxNormalTruncationDust in ProtocolStakingInvariantTest.t.sol for more details. + uint256 internal constant EQUIVALENCE_EARNED_TOLERANCE = 2; + + // A single protocol action can update the virtual pool using truncated math. + // The continuous loss is strictly < 1 wei, meaning a user's floored `earned()` + // balance can drop by a maximum of exactly 1 wei across a single state transition. + uint256 internal constant TRANSITION_EARNED_TOLERANCE = 1; + + uint256 public ghost_maxEligibleAccounts; + + uint256 public ghost_accumulatedRewardCapacity; + uint256 public ghost_currentRate; + uint256 public ghost_initialTotalSupply; + + mapping(address => uint256) public ghost_claimed; + mapping(address => uint256) public ghost_totalStaked; + mapping(address => uint256) public ghost_totalReleased; + + // Flag to exempt an account from the awaitingRelease monotonicity check + address public ghost_releasedAccount; + + constructor(ProtocolStakingHarness _protocolStaking, ZamaERC20 _zama, address _manager, address[] memory _actors) { + require(_actors.length > 0, "need at least one actor"); + protocolStaking = _protocolStaking; + zama = _zama; + manager = _manager; + actors = _actors; + ghost_currentRate = _protocolStaking.rewardRate(); + ghost_initialTotalSupply = _zama.totalSupply(); + + uint256 outgroupCount = _actors.length / 5; + uint256 outgroupStartIndex = _actors.length - outgroupCount; + for (uint256 i = outgroupStartIndex; i < _actors.length; i++) { + isOutgroup[_actors[i]] = true; + } + ghost_maxEligibleAccounts = outgroupStartIndex; + } + + // **************** Transition Invariant Modifiers **************** + + /// @dev Master modifier to check all transition invariants (State A -> State B) + modifier assertTransitionInvariants() { + uint256 actorsLen = actors.length; + + // Allocate memory for pre-states + uint256[] memory preClaimedEarned = new uint256[](actorsLen); + uint256[] memory preAwaitingRelease = new uint256[](actorsLen); + uint48[] memory preKeys = new uint48[](actorsLen); + uint208[] memory preValues = new uint208[](actorsLen); + bool[] memory hadCheckpoint = new bool[](actorsLen); + + // Capture pre-states: Awaiting Release, Claimed + Earned, and Unstake Queue. + for (uint256 i = 0; i < actorsLen; i++) { + address account = actors[i]; + preAwaitingRelease[i] = protocolStaking.awaitingRelease(account); + preClaimedEarned[i] = ghost_claimed[account] + protocolStaking.earned(account); + + uint256 count = _getUnstakeRequestCheckpointCount(account); + if (count > 0) { + (preKeys[i], preValues[i]) = _getUnstakeRequestCheckpointAt(account, count - 1); + hadCheckpoint[i] = true; + } + } + + _; // Execute the handler action + + // Assert post-states for all transition invariants. + for (uint256 i = 0; i < actorsLen; i++) { + address account = actors[i]; + _assertClaimedPlusEarnedTransition(account, preClaimedEarned[i]); + _assertAwaitingReleaseTransition(account, preAwaitingRelease[i]); + _assertUnstakeQueueMonotonicityTransition(account, hadCheckpoint[i], preKeys[i], preValues[i]); + } + + _resetTransitionFlags(); + } + + // **************** Transition invariant assertions **************** + + function _assertClaimedPlusEarnedTransition(address account, uint256 preClaimedEarned) internal view { + uint256 postClaimedEarned = ghost_claimed[account] + protocolStaking.earned(account); + // Tolerance accounts for truncation in the earned() calculation. + assertGe( + postClaimedEarned + TRANSITION_EARNED_TOLERANCE, + preClaimedEarned, + "claimed+claimable must not decrease" + ); + } + + function _assertAwaitingReleaseTransition(address account, uint256 preAwaitingRelease) internal view { + // Skip the monotonicity check if this specific account was just released. + if (account == ghost_releasedAccount) return; + + // inherent check that awaiting release does not revert + // _released[account] is always inferior or equal to the latest unstake request in _unstakeRequest[account].latest() + uint256 postAwaitingRelease = protocolStaking.awaitingRelease(account); + assertGe(postAwaitingRelease, preAwaitingRelease, "awaitingRelease must not decrease except after release"); + } + + function _assertUnstakeQueueMonotonicityTransition( + address account, + bool hadCheckpoint, + uint48 preKey, + uint208 preValue + ) internal view { + uint256 count = _getUnstakeRequestCheckpointCount(account); + if (count == 0) return; + + (uint48 postKey, uint208 postValue) = _getUnstakeRequestCheckpointAt(account, count - 1); + + if (hadCheckpoint) { + assertGe(postKey, preKey, "unstake request keys must be non-decreasing"); + if (postKey == preKey) { + assertGe(postValue, preValue, "unstake request values must be non-decreasing for same key"); + } + } + } + + // **************** Helper functions **************** + + function actorsLength() external view returns (uint256) { + return actors.length; + } + + function actorAt(uint256 index) external view returns (address) { + if (index >= actors.length) return address(0); + return actors[index]; + } + + function _resetTransitionFlags() internal { + // Reset the released account flag for the next fuzz step. + ghost_releasedAccount = address(0); + } + + // **************** Storage reading functions **************** + + /// @dev Reads the paid amount for an account through the ProtocolStakingHarness + function _readPaid(address account) internal view returns (int256) { + return protocolStaking._harness_getPaid(account); + } + + /// @dev Reads the total virtual paid amount through the ProtocolStakingHarness + function _readTotalVirtualPaid() internal view returns (int256) { + return protocolStaking._harness_getTotalVirtualPaid(); + } + + /// @dev Reads the historical reward through the ProtocolStakingHarness + function _readHistoricalReward() internal view returns (uint256) { + return protocolStaking._harness_getHistoricalReward(); + } + + /// @dev Reads the length of _unstakeRequests[account]._checkpoints for an actor through the ProtocolStakingHarness + function _getUnstakeRequestCheckpointCount(address account) internal view returns (uint256) { + return protocolStaking._harness_getUnstakeRequestCheckpointCount(account); + } + + /// @dev Reads the checkpoint at index for _unstakeRequests[account] through the ProtocolStakingHarness + function _getUnstakeRequestCheckpointAt( + address account, + uint256 index + ) internal view returns (uint48 key, uint208 value) { + return protocolStaking._harness_getUnstakeRequestCheckpointAt(account, index); + } + + // **************** Invariant functions **************** + + function computeRewardDebtLHS() external view returns (int256) { + int256 sumPaid; + uint256 sumEarned; + for (uint256 i = 0; i < actors.length; i++) { + address account = actors[i]; + sumPaid += _readPaid(account); + sumEarned += protocolStaking.earned(account); + } + return sumPaid + SafeCast.toInt256(sumEarned); + } + + function computeRewardDebtRHS() external view returns (int256) { + int256 totalVirtualPaid = _readTotalVirtualPaid(); + uint256 histReward = _readHistoricalReward(); + return totalVirtualPaid + SafeCast.toInt256(histReward); + } + + function computeExpectedTotalWeight() external view returns (uint256 total) { + for (uint256 i = 0; i < actors.length; i++) { + address account = actors[i]; + if (protocolStaking.isEligibleAccount(account)) { + total += protocolStaking.weight(protocolStaking.balanceOf(account)); + } + } + } + + /** + * @notice Calculates the maximum acceptable wei deviation for the reward debt invariant. + * @dev Calculates the theoretical upper bound for rounding errors in the protocol. + * There are two opposing forces of rounding error: + * 1. Truncation Dust: Integer division causes active users to lose fractions of a wei, + * pulling the user sum (LHS) DOWN by a maximum of (N - 1) wei. + * 2. Phantom Wei: The `max(0)` clause in `earned()` allows inactive users to lock in +1 wei + * after ratio dilution, pulling the user sum (LHS) UP by a maximum of N wei. + * Because these forces pull in opposite directions, they cancel each other out rather + * than stacking. The absolute maximum divergence in either direction is N wei. + * See: test_DilutionTrap and test_MaxNormalTruncationDust in ProtocolStakingInvariantTest.t.sol for more details. + * @return The maximum allowable rounding error in wei based on the maximum number of eligible accounts. + */ + function computeRewardDebtTolerance() external view returns (uint256) { + return ghost_maxEligibleAccounts; + } + + // **************** ProtocolStaking actions **************** + + /// @dev Move the block timestamp forward by a given duration. + function warp(uint256 duration) public assertTransitionInvariants { + duration = bound(duration, 1, MAX_PERIOD_DURATION); + + // If there are no staked tokens, the accumulated reward capacity is not updated + if (protocolStaking.totalStakedWeight() > 0) { + ghost_accumulatedRewardCapacity += ghost_currentRate * duration; + } + vm.warp(block.timestamp + duration); + } + + function setRewardRate(uint256 rate) external assertTransitionInvariants { + rate = bound(rate, 0, MAX_REWARD_RATE); + vm.prank(manager); + protocolStaking.setRewardRate(rate); + ghost_currentRate = rate; + } + + function addEligibleAccount() public assertTransitionInvariants { + address account = msg.sender; + + // Outgroup accounts are not ever eligible to earn rewards + if (isOutgroup[account]) return; + + vm.prank(manager); + protocolStaking.addEligibleAccount(account); + } + + function removeEligibleAccount() external assertTransitionInvariants { + address account = msg.sender; + vm.prank(manager); + protocolStaking.removeEligibleAccount(account); + } + + function setUnstakeCooldownPeriod(uint256 cooldownPeriod) external assertTransitionInvariants { + cooldownPeriod = bound(cooldownPeriod, 1, MAX_UNSTAKE_COOLDOWN_PERIOD - 1); + vm.prank(manager); + protocolStaking.setUnstakeCooldownPeriod(SafeCast.toUint48(cooldownPeriod)); + } + + function stake(uint256 amount) public assertTransitionInvariants { + address actor = msg.sender; + uint256 balance = zama.balanceOf(actor); + if (balance == 0) return; + amount = bound(amount, 1, balance); + vm.prank(actor); + protocolStaking.stake(amount); + ghost_totalStaked[actor] += amount; + } + + function unstake(uint256 amount) public assertTransitionInvariants { + address actor = msg.sender; + uint256 stakedBalance = protocolStaking.balanceOf(actor); + if (stakedBalance == 0) return; + amount = bound(amount, 1, stakedBalance); + vm.prank(actor); + protocolStaking.unstake(amount); + } + + function claimRewards() external assertTransitionInvariants { + address account = msg.sender; + uint256 amount = protocolStaking.earned(account); + protocolStaking.claimRewards(account); + assertEq(protocolStaking.earned(account), 0, "earned(account) must be 0 after claimRewards"); + ghost_claimed[account] += amount; + } + + function release() external assertTransitionInvariants { + address account = msg.sender; + uint256 awaitingBefore = protocolStaking.awaitingRelease(account); + protocolStaking.release(account); + uint256 awaitingAfter = protocolStaking.awaitingRelease(account); + ghost_totalReleased[account] += (awaitingBefore - awaitingAfter); + ghost_releasedAccount = account; + } + + /// @notice Unstake then warp past cooldown to allow for valid release() calls. + function unstakeThenWarp() external assertTransitionInvariants { + address account = msg.sender; + uint256 stakedBalance = protocolStaking.balanceOf(account); + if (stakedBalance == 0) return; + + unstake(stakedBalance); + + uint256 cooldown = protocolStaking.unstakeCooldownPeriod(); + warp(cooldown + 1); + } + + // **************** Equivalence scenario handlers **************** + + // Compare stake(amount1+amount2) once vs stake(amount1) then stake(amount2). + function stakeEquivalenceScenario(uint256 amount1, uint256 amount2, uint256 duration) external { + address account = msg.sender; + + addEligibleAccount(); + + uint256 balance = zama.balanceOf(account); + if (balance < 2) return; + amount1 = bound(amount1, 1, balance - 1); + amount2 = bound(amount2, 1, balance - amount1); + uint256 totalAmount = amount1 + amount2; + + duration = bound(duration, 1, MAX_PERIOD_DURATION); + + uint256 snapshot = vm.snapshotState(); + + // Path A: single stake + stake(totalAmount); + uint256 sharesSingle = protocolStaking.balanceOf(account); + uint256 weightSingle = protocolStaking.weight(protocolStaking.balanceOf(account)); + + // Warp past the duration to allow for valid earned() calls. + warp(duration); + uint256 earnedSingle = protocolStaking.earned(account); + + vm.revertToState(snapshot); + + // Path B: double stake + stake(amount1); + stake(amount2); + uint256 sharesDouble = protocolStaking.balanceOf(account); + uint256 weightDouble = protocolStaking.weight(protocolStaking.balanceOf(account)); + + warp(duration); + uint256 earnedDouble = protocolStaking.earned(account); + + assertEq(sharesDouble, sharesSingle, "stake equivalence: shares"); + // TODO: Weight is not expected to be strictly equal, might want to try to break the equivalence invariant + // have not found a counter example for now + assertEq(weightDouble, weightSingle, "stake equivalence: weight"); + assertApproxEqAbs(earnedDouble, earnedSingle, EQUIVALENCE_EARNED_TOLERANCE, "stake equivalence: earned"); + } + + // Compare partial unstake (to targetStake) vs unstake all then stake(targetStake). + function unstakeEquivalenceScenario(uint256 initialStake, uint256 targetStake, uint256 duration) external { + address account = msg.sender; + + addEligibleAccount(); + + uint256 balance = zama.balanceOf(account); + // Need at least 2 to stake, and leave at least 1 for path B restake (unstaked tokens are queued until release) + if (balance < 3) return; + initialStake = bound(initialStake, 2, balance - 1); + // targetStake must be <= balance - initialStake so path B can restake + targetStake = bound(targetStake, 1, Math.min(initialStake - 1, balance - initialStake)); + uint256 unstakeAmount = initialStake - targetStake; + duration = bound(duration, 1, MAX_PERIOD_DURATION); + + uint256 snapshot = vm.snapshotState(); + + stake(initialStake); + warp(duration); + + // Path A: partial unstake + unstake(unstakeAmount); + uint256 sharesPartial = protocolStaking.balanceOf(account); + uint256 weightPartial = protocolStaking.weight(protocolStaking.balanceOf(account)); + + warp(duration); + uint256 earnedPartial = protocolStaking.earned(account); + + vm.revertToState(snapshot); + + // Path B: unstake all then restake target + stake(initialStake); + warp(duration); + + unstake(initialStake); + stake(targetStake); + uint256 sharesRestaked = protocolStaking.balanceOf(account); + uint256 weightRestaked = protocolStaking.weight(protocolStaking.balanceOf(account)); + + warp(duration); + uint256 earnedRestaked = protocolStaking.earned(account); + + assertEq(sharesRestaked, sharesPartial, "unstake equivalence: shares"); + assertEq(weightRestaked, weightPartial, "unstake equivalence: weight"); + assertApproxEqAbs(earnedRestaked, earnedPartial, EQUIVALENCE_EARNED_TOLERANCE, "unstake equivalence: earned"); + } +} diff --git a/contracts/staking/test/foundry/harness/ProtocolStakingHarness.sol b/contracts/staking/test/foundry/harness/ProtocolStakingHarness.sol new file mode 100644 index 0000000..cf0378d --- /dev/null +++ b/contracts/staking/test/foundry/harness/ProtocolStakingHarness.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/* solhint-disable func-name-mixedcase */ // _harness_ prefix + +import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol"; +import {ProtocolStaking} from "./../../../contracts/ProtocolStaking.sol"; + +/** + * @title ProtocolStakingHarness + * @dev Inherits from ProtocolStaking purely to expose internal storage for testing. + */ +contract ProtocolStakingHarness is ProtocolStaking { + function _harness_getPaid(address account) external view returns (int256) { + return _getProtocolStakingStorage()._paid[account]; + } + + function _harness_getTotalVirtualPaid() external view returns (int256) { + return _getProtocolStakingStorage()._totalVirtualPaid; + } + + function _harness_getHistoricalReward() external view returns (uint256) { + return _historicalReward(); + } + + function _harness_getUnstakeRequestCheckpointCount(address account) external view returns (uint256) { + return _getProtocolStakingStorage()._unstakeRequests[account]._checkpoints.length; + } + + function _harness_getUnstakeRequestCheckpointAt( + address account, + uint256 index + ) external view returns (uint48 key, uint208 value) { + Checkpoints.Checkpoint208 memory cp = _getProtocolStakingStorage()._unstakeRequests[account]._checkpoints[ + index + ]; + return (cp._key, cp._value); + } +}