diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f29732..23ea649 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,3 +49,18 @@ jobs: env: ETH_RPC_URL: ${{ secrets.ETH_RPC_URL || vars.ETH_RPC_URL || 'https://rpc.gnosis.gateway.fm' }} id: test + + fuzz: + name: Fuzz tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Run fuzz tests + run: | + forge test --match-path 'test/fuzz/**' -vvv diff --git a/foundry.toml b/foundry.toml index 25b918f..ea1eeb8 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,4 +3,7 @@ src = "src" out = "out" libs = ["lib"] +# If `forge test` gets slow, lower this and/or add a dedicated CI profile with fewer runs. +fuzz = { runs = 1024 } + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/src/abstract/AbstractDistributionStrategy.sol b/src/abstract/AbstractDistributionStrategy.sol index 619f292..e9f9370 100644 --- a/src/abstract/AbstractDistributionStrategy.sol +++ b/src/abstract/AbstractDistributionStrategy.sol @@ -92,19 +92,18 @@ abstract contract AbstractDistributionStrategy is Initializable, IDistributionSt /// @param _yieldToken Address of the yield token to distribute /// @param _distributionManager Address of the distribution manager authorized to call distribute /// @param _owner Address that will own this contract (receives onlyOwner privileges) - function __AbstractDistributionStrategy_init( - address _yieldToken, - address _distributionManager, - address _owner - ) internal onlyInitializing { + function __AbstractDistributionStrategy_init(address _yieldToken, address _distributionManager, address _owner) + internal + onlyInitializing + { __Ownable_init(_owner); __AbstractDistributionStrategy_init_unchained(_yieldToken, _distributionManager); } - function __AbstractDistributionStrategy_init_unchained( - address _yieldToken, - address _distributionManager - ) internal onlyInitializing { + function __AbstractDistributionStrategy_init_unchained(address _yieldToken, address _distributionManager) + internal + onlyInitializing + { if (_yieldToken == address(0)) revert ZeroAddress(); if (_distributionManager == address(0)) revert ZeroAddress(); diff --git a/src/base/BasisPointsVotingModule.sol b/src/base/BasisPointsVotingModule.sol index b6c805f..277c2d8 100644 --- a/src/base/BasisPointsVotingModule.sol +++ b/src/base/BasisPointsVotingModule.sol @@ -174,7 +174,7 @@ contract BasisPointsVotingModule is AbstractVotingModule { /// @param voter Address of the voter /// @param points Array of points allocated to each recipient /// @param votingPower Total voting power of the voter - function _processVote(address voter, uint256[] calldata points, uint256 votingPower) internal override virtual { + function _processVote(address voter, uint256[] calldata points, uint256 votingPower) internal virtual override { AbstractVotingModuleStorage storage base = _getAbstractVotingModuleStorage(); BasisPointsVotingModuleStorage storage $ = _getBasisPointsVotingModuleStorage(); uint256 currentCycle = base.cycleModule.getCurrentCycle(); diff --git a/src/implementation/VotingStreakNFTModule.sol b/src/implementation/VotingStreakNFTModule.sol index a51aecb..69663de 100644 --- a/src/implementation/VotingStreakNFTModule.sol +++ b/src/implementation/VotingStreakNFTModule.sol @@ -33,11 +33,7 @@ contract VotingStreakNFTModule is BasisPointsVotingModule { bytes32 private constant VOTING_STREAK_NFT_MODULE_STORAGE = 0xa65dee7b045e43a11600ba41b419f3aad18025b3c1b3b7c19daa6c12d6462c00; - function _getVotingStreakNFTModuleStorage() - internal - pure - returns (VotingStreakNFTModuleStorage storage $) - { + function _getVotingStreakNFTModuleStorage() internal pure returns (VotingStreakNFTModuleStorage storage $) { assembly { $.slot := VOTING_STREAK_NFT_MODULE_STORAGE } @@ -68,11 +64,7 @@ contract VotingStreakNFTModule is BasisPointsVotingModule { /// @param user The user address to query /// @return streak Current consecutive voting streak /// @return lastVoteCycle Last cycle in which the user successfully voted - function userActivity(address user) - external - view - returns (uint256 streak, uint256 lastVoteCycle) - { + function userActivity(address user) external view returns (uint256 streak, uint256 lastVoteCycle) { VotingStreakNFTModuleStorage storage $ = _getVotingStreakNFTModuleStorage(); UserActivity storage activity = $.userActivity[user]; return (activity.streak, activity.lastVoteCycle); @@ -135,10 +127,7 @@ contract VotingStreakNFTModule is BasisPointsVotingModule { /// @param voter Address of the voter /// @param points Array of basis points for allocation across recipients /// @param votingPower Total voting power of the voter - function _processVote(address voter, uint256[] calldata points, uint256 votingPower) - internal - override - { + function _processVote(address voter, uint256[] calldata points, uint256 votingPower) internal override { // Step 1: Execute standard voting math via parent super._processVote(voter, points, votingPower); @@ -186,6 +175,5 @@ contract VotingStreakNFTModule is BasisPointsVotingModule { $.nftContract.mint(user); } } - } diff --git a/src/implementation/strategies/EqualDistributionStrategy.sol b/src/implementation/strategies/EqualDistributionStrategy.sol index 52c3035..c98a695 100644 --- a/src/implementation/strategies/EqualDistributionStrategy.sol +++ b/src/implementation/strategies/EqualDistributionStrategy.sol @@ -22,10 +22,7 @@ contract EqualDistributionStrategy is AbstractDistributionStrategy { /// @param _yieldToken Address of the yield token to distribute /// @param _distributionManager Address of the distribution manager /// @param _owner Address that will own this contract (receives onlyOwner privileges) - function initialize(address _yieldToken, address _distributionManager, address _owner) - external - initializer - { + function initialize(address _yieldToken, address _distributionManager, address _owner) external initializer { __AbstractDistributionStrategy_init(_yieldToken, _distributionManager, _owner); } diff --git a/src/implementation/strategies/VotingDistributionStrategy.sol b/src/implementation/strategies/VotingDistributionStrategy.sol index b9f460f..eec9c36 100644 --- a/src/implementation/strategies/VotingDistributionStrategy.sol +++ b/src/implementation/strategies/VotingDistributionStrategy.sol @@ -44,11 +44,7 @@ contract VotingDistributionStrategy is AbstractDistributionStrategy { /// @param _yieldToken Address of the yield token to distribute /// @param _distributionManager Address of the distribution manager /// @param _owner Address that will own this contract (receives onlyOwner privileges) - function initialize( - address _yieldToken, - address _distributionManager, - address _owner - ) external initializer { + function initialize(address _yieldToken, address _distributionManager, address _owner) external initializer { __AbstractDistributionStrategy_init(_yieldToken, _distributionManager, _owner); } diff --git a/src/interfaces/ICrowdstakeNFT.sol b/src/interfaces/ICrowdstakeNFT.sol index 8b69273..77ef962 100644 --- a/src/interfaces/ICrowdstakeNFT.sol +++ b/src/interfaces/ICrowdstakeNFT.sol @@ -10,5 +10,4 @@ interface ICrowdstakeNFT is IERC721 { /// @param to The address that will receive the NFT. /// @return tokenId The unique ID of the newly minted NFT. function mint(address to) external returns (uint256); - -} \ No newline at end of file +} diff --git a/test/DistributionCycleIntegration.t.sol b/test/DistributionCycleIntegration.t.sol index 5c3b61a..33c4a8a 100644 --- a/test/DistributionCycleIntegration.t.sol +++ b/test/DistributionCycleIntegration.t.sol @@ -33,8 +33,7 @@ contract DistributionCycleIntegrationTest is Test { // Deploy cycle module CycleModule cycleImpl = new CycleModule(); - bytes memory cycleInit = - abi.encodeWithSelector(AbstractCycleModule.initialize.selector, CYCLE_LENGTH, owner); + bytes memory cycleInit = abi.encodeWithSelector(AbstractCycleModule.initialize.selector, CYCLE_LENGTH, owner); cycleModule = CycleModule(address(new ERC1967Proxy(address(cycleImpl), cycleInit))); // Deploy base distribution manager @@ -83,14 +82,10 @@ contract DistributionCycleIntegrationTest is Test { abi.encode(dist) ); vm.mockCall( - mockRegistry, - abi.encodeWithSelector(IRecipientRegistry.getRecipientCount.selector), - abi.encode(uint256(1)) + mockRegistry, abi.encodeWithSelector(IRecipientRegistry.getRecipientCount.selector), abi.encode(uint256(1)) ); vm.mockCall( - mockBaseToken, - abi.encodeWithSelector(IYieldModule.yieldAccrued.selector), - abi.encode(uint256(1000)) + mockBaseToken, abi.encodeWithSelector(IYieldModule.yieldAccrued.selector), abi.encode(uint256(1000)) ); vm.mockCall( mockBaseToken, @@ -102,11 +97,7 @@ contract DistributionCycleIntegrationTest is Test { abi.encodeWithSelector(IERC20.transfer.selector, mockStrategy, uint256(1000)), abi.encode(true) ); - vm.mockCall( - mockStrategy, - abi.encodeWithSelector(IDistributionStrategy.distribute.selector, uint256(1000)), - "" - ); + vm.mockCall(mockStrategy, abi.encodeWithSelector(IDistributionStrategy.distribute.selector, uint256(1000)), ""); // Execute baseManager.claimAndDistribute(); diff --git a/test/FactoryModuleDeployment.t.sol b/test/FactoryModuleDeployment.t.sol index 04943e5..a2ce003 100644 --- a/test/FactoryModuleDeployment.t.sol +++ b/test/FactoryModuleDeployment.t.sol @@ -132,11 +132,7 @@ contract FactoryModuleDeploymentTest is Test { vm.etch(mockDistManager, hex"00"); // Mock the distribution manager to return the registry - vm.mockCall( - mockDistManager, - abi.encodeWithSignature("recipientRegistry()"), - abi.encode(registry) - ); + vm.mockCall(mockDistManager, abi.encodeWithSignature("recipientRegistry()"), abi.encode(registry)); bytes memory payload = abi.encodeWithSelector( EqualDistributionStrategy.initialize.selector, mockYieldToken, mockDistManager, owner @@ -162,22 +158,11 @@ contract FactoryModuleDeploymentTest is Test { vm.etch(mockDistManager, hex"00"); // Mock the distribution manager to return registry and voting module - vm.mockCall( - mockDistManager, - abi.encodeWithSignature("recipientRegistry()"), - abi.encode(registry) - ); - vm.mockCall( - mockDistManager, - abi.encodeWithSignature("votingModule()"), - abi.encode(mockVotingModule) - ); + vm.mockCall(mockDistManager, abi.encodeWithSignature("recipientRegistry()"), abi.encode(registry)); + vm.mockCall(mockDistManager, abi.encodeWithSignature("votingModule()"), abi.encode(mockVotingModule)); bytes memory payload = abi.encodeWithSelector( - VotingDistributionStrategy.initialize.selector, - mockYieldToken, - mockDistManager, - owner + VotingDistributionStrategy.initialize.selector, mockYieldToken, mockDistManager, owner ); address module = factory.create(votingStrategyBeacon, payload, keccak256("voting-strat-salt")); diff --git a/test/VotingStreakNFTModule.t.sol b/test/VotingStreakNFTModule.t.sol index a9b2556..e847874 100644 --- a/test/VotingStreakNFTModule.t.sol +++ b/test/VotingStreakNFTModule.t.sol @@ -144,7 +144,7 @@ contract VotingStreakNFTModuleTest is Test { // Act - Cycle 1: User votes harness.exposed_processVote(user, points, VOTING_POWER); - (uint256 streak1, ) = harness.userActivity(user); + (uint256 streak1,) = harness.userActivity(user); assertEq(streak1, 1, "Streak should be 1 after first vote"); // Act - Cycle 2: Advance cycle but don't vote (user misses this cycle) @@ -180,22 +180,12 @@ contract VotingStreakNFTModuleTest is Test { } // Assert - assertEq( - mockNft.balanceOf(user), - 1, - "User should have 1 NFT after 10 consecutive votes" - ); + assertEq(mockNft.balanceOf(user), 1, "User should have 1 NFT after 10 consecutive votes"); (uint256 streak, uint256 lastVoteCycle) = harness.userActivity(user); assertEq(streak, 10, "Streak should be 10"); assertEq(lastVoteCycle, 10, "lastVoteCycle should be 10"); } - - - - - - /// @notice Test: Recasting vote in same cycle does not increment streak /// @dev User votes twice in cycle 1; streak should remain 1 function test_ReCastVoteDoesNotIncrementStreak() public { @@ -217,11 +207,7 @@ contract VotingStreakNFTModuleTest is Test { // Assert after recast - streak should NOT increment (uint256 streakAfterRecast, uint256 lastVoteCycleAfterRecast) = harness.userActivity(user); - assertEq( - streakAfterRecast, - 1, - "Streak should remain 1 after recasting vote in same cycle" - ); + assertEq(streakAfterRecast, 1, "Streak should remain 1 after recasting vote in same cycle"); assertEq(lastVoteCycleAfterRecast, 1, "lastVoteCycle should still be 1"); } @@ -260,7 +246,7 @@ contract VotingStreakNFTModuleTest is Test { harness.exposed_processVote(user, points, VOTING_POWER); if (i < 2) cycleModule.advanceCycle(); } - (uint256 streak1, ) = harness.userActivity(user); + (uint256 streak1,) = harness.userActivity(user); assertEq(streak1, 3, "Streak should be 3"); // Act - Miss a cycle to break streak @@ -269,7 +255,7 @@ contract VotingStreakNFTModuleTest is Test { // Act - Vote again, streak resets to 1 harness.exposed_processVote(user, points, VOTING_POWER); - (uint256 streak2, ) = harness.userActivity(user); + (uint256 streak2,) = harness.userActivity(user); assertEq(streak2, 1, "Streak should reset to 1 after gap"); // Act - Build streak again to 5 @@ -277,7 +263,7 @@ contract VotingStreakNFTModuleTest is Test { cycleModule.advanceCycle(); harness.exposed_processVote(user, points, VOTING_POWER); } - (uint256 streak3, ) = harness.userActivity(user); + (uint256 streak3,) = harness.userActivity(user); assertEq(streak3, 5, "Streak should build to 5 again"); } @@ -291,11 +277,11 @@ contract VotingStreakNFTModuleTest is Test { // Act & Assert - user1 votes in cycle 1 harness.exposed_processVote(user1, points, VOTING_POWER); - (uint256 streak1_c1, ) = harness.userActivity(user1); + (uint256 streak1_c1,) = harness.userActivity(user1); assertEq(streak1_c1, 1, "user1 streak should be 1 in cycle 1"); // Act & Assert - user2 doesn't vote in cycle 1 - (uint256 streak2_c1, ) = harness.userActivity(user2); + (uint256 streak2_c1,) = harness.userActivity(user2); assertEq(streak2_c1, 0, "user2 streak should be 0 if never voted"); // Act - Advance to cycle 2 @@ -303,19 +289,18 @@ contract VotingStreakNFTModuleTest is Test { // Act & Assert - user1 votes again in cycle 2 (builds streak to 2) harness.exposed_processVote(user1, points, VOTING_POWER); - (uint256 streak1_c2, ) = harness.userActivity(user1); + (uint256 streak1_c2,) = harness.userActivity(user1); assertEq(streak1_c2, 2, "user1 streak should be 2 in cycle 2"); // Act & Assert - user2 votes for first time in cycle 2 (starts at 1) harness.exposed_processVote(user2, points, VOTING_POWER); - (uint256 streak2_c2, ) = harness.userActivity(user2); + (uint256 streak2_c2,) = harness.userActivity(user2); assertEq(streak2_c2, 1, "user2 streak should be 1 (first vote)"); // Assert final state - (uint256 finalStreak1, ) = harness.userActivity(user1); - (uint256 finalStreak2, ) = harness.userActivity(user2); + (uint256 finalStreak1,) = harness.userActivity(user1); + (uint256 finalStreak2,) = harness.userActivity(user2); assertEq(finalStreak1, 2, "user1 should maintain streak of 2"); assertEq(finalStreak2, 1, "user2 should have streak of 1"); } - } diff --git a/test/fuzz/EqualDistributionStrategy.t.sol b/test/fuzz/EqualDistributionStrategy.t.sol new file mode 100644 index 0000000..6483fb3 --- /dev/null +++ b/test/fuzz/EqualDistributionStrategy.t.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {EqualDistributionStrategy} from "../../src/implementation/strategies/EqualDistributionStrategy.sol"; +import {AbstractDistributionStrategy} from "../../src/abstract/AbstractDistributionStrategy.sol"; +import {AdminRecipientRegistry} from "../../src/implementation/registries/AdminRecipientRegistry.sol"; +import {IRecipientRegistry} from "../../src/interfaces/IRecipientRegistry.sol"; +import {MockERC20} from "./helpers/MockERC20.sol"; +import {MockDistManagerForFuzz} from "./helpers/MockDistManagerForFuzz.sol"; + +/// @notice Fuzz tests for EqualDistributionStrategy — equal split math, dust retention, and revert paths (no fork). +contract EqualDistributionStrategy_FuzzTest is Test { + uint256 internal constant MAX_RECIPIENTS = 48; + + /// @notice Happy-path invariants: + /// - each recipient receives exactly `amount / n` + /// - total paid out equals `(amount / n) * n` + /// - dust (`amount % n`) remains on the strategy + /// - `distributionId` increments by exactly 1 + function testFuzz_distribute_equalSplitAndDust(uint8 recipientCountRaw, uint256 amountRaw) public { + uint256 n = bound(uint256(recipientCountRaw), 1, MAX_RECIPIENTS); + // Lower bound = n so the strategy has enough yield for 1 wei per recipient. + uint256 amount = bound(amountRaw, n, n * 1e24); + + ( + MockERC20 token, + AdminRecipientRegistry registry, + MockDistManagerForFuzz distManager, + EqualDistributionStrategy strategy + ) = _deploySystem(n); + + // Pre-fund the strategy. In production the manager calls `safeTransfer` before `distribute`; + // we shortcut by minting directly since `distribute` is what we are fuzzing. + token.mint(address(strategy), amount); + uint256 idBefore = strategy.distributionId(); + + // Act + vm.prank(address(distManager)); + strategy.distribute(amount); + + // Invariant: distributionId incremented by 1 + assertEq(strategy.distributionId(), idBefore + 1, "distributionId must increment"); + + uint256 per = amount / n; + uint256 dust = amount % n; + address[] memory recipients = registry.getRecipients(); + assertEq(recipients.length, n); + + // Invariant: each recipient got `per`; sum equals `per * n` + uint256 paidOut; + for (uint256 i; i < n; i++) { + assertEq(token.balanceOf(recipients[i]), per, "recipient share must be amount / n"); + paidOut += token.balanceOf(recipients[i]); + } + assertEq(paidOut, per * n, "sum of shares must equal per * n"); + + // Invariant: leftover dust stays on the strategy + assertEq(token.balanceOf(address(strategy)), dust, "dust must remain on strategy"); + } + + /// @notice Revert when `amount < n` — cannot fund at least 1 wei per recipient. + function testFuzz_distribute_reverts_insufficientYield(uint256 nRaw, uint256 amountRaw) public { + uint256 n = bound(nRaw, 2, MAX_RECIPIENTS); + uint256 amount = bound(amountRaw, 1, n - 1); + + (,, MockDistManagerForFuzz distManager, EqualDistributionStrategy strategy) = _deploySystem(n); + + // Revert fires before any transfer, so no pre-fund needed. + vm.prank(address(distManager)); + vm.expectRevert(AbstractDistributionStrategy.InsufficientYieldForRecipients.selector); + strategy.distribute(amount); + } + + /// @notice Revert on zero amount regardless of recipient count. + function test_distribute_reverts_zeroAmount() public { + (,, MockDistManagerForFuzz distManager, EqualDistributionStrategy strategy) = _deploySystem(1); + + vm.prank(address(distManager)); + vm.expectRevert(AbstractDistributionStrategy.ZeroAmount.selector); + strategy.distribute(0); + } + + /// @notice Only the configured distribution manager can call `distribute`. + function testFuzz_distribute_reverts_notDistributionManager(address caller, uint256 amountRaw) public { + uint256 amount = bound(amountRaw, 1, 1e24); + + (,, MockDistManagerForFuzz distManager, EqualDistributionStrategy strategy) = _deploySystem(2); + vm.assume(caller != address(distManager)); + + vm.prank(caller); + vm.expectRevert(AbstractDistributionStrategy.OnlyDistributionManager.selector); + strategy.distribute(amount); + } + + function _deploySystem(uint256 n) + internal + returns ( + MockERC20 token, + AdminRecipientRegistry registry, + MockDistManagerForFuzz distManager, + EqualDistributionStrategy strategy + ) + { + address admin = address(this); + token = new MockERC20(); + + // Deploy registry behind ERC1967 proxy (matches production tests). + AdminRecipientRegistry regImpl = new AdminRecipientRegistry(); + bytes memory regInit = abi.encodeWithSelector(AdminRecipientRegistry.initialize.selector, admin); + registry = AdminRecipientRegistry(address(new ERC1967Proxy(address(regImpl), regInit))); + + // Labeled, deterministic recipient addresses — avoids duplicate-queue reverts and reads nicely in traces. + address[] memory batch = new address[](n); + for (uint256 i; i < n; i++) { + batch[i] = makeAddr(string.concat("recipient-", vm.toString(i))); + } + registry.queueRecipientsAddition(batch); + registry.processQueue(); + + // Mock manager exists only to satisfy `initialize()` and be the `onlyDistributionManager` caller. + distManager = new MockDistManagerForFuzz(IRecipientRegistry(address(registry))); + + // Deploy strategy behind ERC1967 proxy (matches production tests). + EqualDistributionStrategy stratImpl = new EqualDistributionStrategy(); + bytes memory stratInit = abi.encodeWithSelector( + EqualDistributionStrategy.initialize.selector, address(token), address(distManager), admin + ); + strategy = EqualDistributionStrategy(address(new ERC1967Proxy(address(stratImpl), stratInit))); + + // Sanity-check wiring before test body runs. + assertEq(registry.getRecipientCount(), n); + assertEq(address(strategy.recipientRegistry()), address(registry)); + assertEq(strategy.distributionManager(), address(distManager)); + assertEq(address(strategy.yieldToken()), address(token)); + } +} diff --git a/test/fuzz/README.md b/test/fuzz/README.md new file mode 100644 index 0000000..9de42dd --- /dev/null +++ b/test/fuzz/README.md @@ -0,0 +1,22 @@ +# Fuzz tests (Phase 1) + +These tests use Foundry `testFuzz_*` against real protocol contracts with **local mocks only** (no `vm.createSelectFork` here). + +## Run + +```bash +# Fuzz tests only — uses root `foundry.toml` `[profile.default]` fuzz settings (same locally and in CI) +forge test --match-path 'test/fuzz/**' -vv +``` + +## CI + +Workflow **`env: FOUNDRY_PROFILE: ci`** is kept for the repo’s convention. Root **`foundry.toml` has no `[profile.ci]`**, so jobs use the same **`[profile.default]`** fuzz run count as local. + +The parallel **`fuzz`** job runs only `forge test --match-path 'test/fuzz/**'` (same profile as `check`). + +Vendored `lib/**/foundry.toml` files are not modified. + +## Fork tests + +Fork-based suites live in `test/TestWrapper.sol` and friends, not under `test/fuzz/`. diff --git a/test/fuzz/helpers/MockDistManagerForFuzz.sol b/test/fuzz/helpers/MockDistManagerForFuzz.sol new file mode 100644 index 0000000..7977c9a --- /dev/null +++ b/test/fuzz/helpers/MockDistManagerForFuzz.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IRecipientRegistry} from "../../../src/interfaces/IRecipientRegistry.sol"; + +/// @notice Minimal stub so `EqualDistributionStrategy` can read `recipientRegistry()` at init and act as caller. +contract MockDistManagerForFuzz { + /// @notice Auto-generated getter matches IDistributionManager.recipientRegistry() selector. + IRecipientRegistry public immutable recipientRegistry; + + constructor(IRecipientRegistry registry_) { + recipientRegistry = registry_; + } +} diff --git a/test/fuzz/helpers/MockERC20.sol b/test/fuzz/helpers/MockERC20.sol new file mode 100644 index 0000000..827e8d8 --- /dev/null +++ b/test/fuzz/helpers/MockERC20.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Minimal mintable ERC20 for fuzz tests (no fork). +contract MockERC20 is ERC20 { + constructor() ERC20("MockYield", "MYLD") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/test/mocks/MockCrowdstakeNFT.sol b/test/mocks/MockCrowdstakeNFT.sol index 32e671b..052ae89 100644 --- a/test/mocks/MockCrowdstakeNFT.sol +++ b/test/mocks/MockCrowdstakeNFT.sol @@ -18,5 +18,4 @@ contract MockCrowdstakeNFT is ERC721, ICrowdstakeNFT { _safeMint(to, tokenId); return tokenId; } - -} \ No newline at end of file +}