From 663f17eb23e3421d9088881f1fb021d1e3c15f47 Mon Sep 17 00:00:00 2001 From: parsaba Date: Thu, 21 Aug 2025 00:33:26 +0330 Subject: [PATCH 1/3] feat: add RewardsManager implementation and tests --- src/core/RewardsManager.sol | 263 ++++++--- src/interfaces/IRewardsManager.sol | 5 +- test/RewardsManagerTest.sol | 821 ++++++++++++++++++++++++++ test/mocks/MockRewardsCoordinator.sol | 30 + 4 files changed, 1024 insertions(+), 95 deletions(-) create mode 100644 test/RewardsManagerTest.sol create mode 100644 test/mocks/MockRewardsCoordinator.sol diff --git a/src/core/RewardsManager.sol b/src/core/RewardsManager.sol index eba3bf94..3246b00d 100644 --- a/src/core/RewardsManager.sol +++ b/src/core/RewardsManager.sol @@ -5,6 +5,7 @@ import {Initializable} from "@openzeppelin-upgradeable/contracts/proxy/utils/Ini import {AccessControlUpgradeable} from "@openzeppelin-upgradeable/contracts/access/AccessControlUpgradeable.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin-upgradeable/contracts/security/ReentrancyGuardUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin-upgradeable/contracts/security/PausableUpgradeable.sol"; +import {EnumerableSetUpgradeable} from "@openzeppelin-upgradeable/contracts/utils/structs/EnumerableSetUpgradeable.sol"; import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; import {IRewardsCoordinatorTypes} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -25,6 +26,7 @@ contract RewardsManager is { using SafeERC20 for IERC20; using Math for uint256; + using EnumerableSetUpgradeable for EnumerableSetUpgradeable.AddressSet; // ------------------------------------------------------------------------------ // State @@ -44,9 +46,13 @@ contract RewardsManager is mapping(address => uint256) public unsupportedAssetBalances; address[] public unsupportedAssets; - /// @notice Claimer for earners on EL + /// @notice Claimer for earners on EL - using EnumerableSet for efficient operations mapping(address => bool) public isClaimerFor; - address[] public claimerFor; + EnumerableSetUpgradeable.AddressSet private _claimerForSet; + + // ------------------------------------------------------------------------------ + // Events + // ------------------------------------------------------------------------------ // ------------------------------------------------------------------------------ // Init functions @@ -61,6 +67,7 @@ contract RewardsManager is function initialize(Init memory init) public initializer { __AccessControl_init(); __ReentrancyGuard_init(); + __Pausable_init(); if ( address(init.initialOwner) == address(0) || @@ -100,15 +107,13 @@ contract RewardsManager is function processClaims( IRewardsCoordinatorTypes.RewardsMerkleClaim[] calldata claims ) external override nonReentrant whenNotPaused { + require(claims.length <= 50, "Too many claims"); // Prevent gas exhaustion + for (uint256 i = 0; i < claims.length; i++) { _processClaim(claims[i]); } } - /// @notice For rewards that are in unsupported assets, swaps into supported assets and transfers over to LT - /// @dev OUT OF SCOPE FOR V2 - /// function swapAndTransferRewards() external override nonReentrant whenNotPaused onlyRole(DEFAULT_ADMIN_ROLE) {} - // ------------------------------------------------------------------------------ // Getter functions // ------------------------------------------------------------------------------ @@ -122,96 +127,164 @@ contract RewardsManager is return balances; } + /// @notice Get all claimers as an array (for backward compatibility) + function claimerFor() external view returns (address[] memory) { + return _claimerForSet.values(); + } + + /// @notice Get number of claimers + function claimerForLength() external view returns (uint256) { + return _claimerForSet.length(); + } + // ------------------------------------------------------------------------------ // Internal functions // ------------------------------------------------------------------------------ - /// @dev Called by `processClaim` and `processClaims` + /// @dev Called by `processClaim` and `processClaims` - Fixed for reentrancy and balance calculation function _processClaim(IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim) internal { address earner = claim.earnerLeaf.earner; if (!_verifyAndUpdateClaimerFor(earner)) revert NotClaimerFor(earner); - // Call for claim on EL - rewardsCoordinator.processClaim(claim, address(this)); + // Record balances BEFORE the external call to prevent reentrancy issues + IERC20[] memory uniqueTokens = _getUniqueTokensFromClaim(claim); - // Record current balances of all expected assets from this claim - IERC20[] memory expectedSupportedAssets = new IERC20[](claim.tokenLeaves.length); - IERC20[] memory expectedUnsupportedAssets = new IERC20[](claim.tokenLeaves.length); - uint256[] memory supportedBalances = new uint256[](claim.tokenLeaves.length); - uint256[] memory unsupportedBalances = new uint256[](claim.tokenLeaves.length); + // Create properly sized arrays instead of using assembly manipulation + // Fixed: Renamed to avoid shadowing + IERC20[] memory supportedTokens = new IERC20[](uniqueTokens.length); + IERC20[] memory unsupportedTokens = new IERC20[](uniqueTokens.length); + uint256[] memory supportedBalancesBefore = new uint256[](uniqueTokens.length); + uint256[] memory unsupportedBalancesBefore = new uint256[](uniqueTokens.length); - IERC20[] memory allSupportedAssets = liquidTokenManager.getSupportedTokens(); uint256 supportedCount = 0; uint256 unsupportedCount = 0; - for (uint256 i = 0; i < claim.tokenLeaves.length; i++) { - IERC20 currentToken = claim.tokenLeaves[i].token; - bool tokenAlreadyProcessed = false; + IERC20[] memory allSupportedAssets = liquidTokenManager.getSupportedTokens(); - for (uint256 k = 0; k < supportedCount; k++) { - if (expectedSupportedAssets[k] == currentToken) { - tokenAlreadyProcessed = true; - break; - } + // Categorize tokens and record pre-claim balances + for (uint256 i = 0; i < uniqueTokens.length; i++) { + IERC20 currentToken = uniqueTokens[i]; + bool isSupported = _isTokenSupported(currentToken, allSupportedAssets); + + if (isSupported) { + supportedTokens[supportedCount] = currentToken; + // Only count newly claimable balance, not existing contract balance + supportedBalancesBefore[supportedCount] = + currentToken.balanceOf(address(this)) - + unsupportedAssetBalances[address(currentToken)]; + supportedCount++; + } else { + unsupportedTokens[unsupportedCount] = currentToken; + unsupportedBalancesBefore[unsupportedCount] = + currentToken.balanceOf(address(this)) - + unsupportedAssetBalances[address(currentToken)]; + unsupportedCount++; } + } - if (!tokenAlreadyProcessed) { - for (uint256 k = 0; k < unsupportedCount; k++) { - if (expectedUnsupportedAssets[k] == currentToken) { - tokenAlreadyProcessed = true; - break; - } - } - } + // Resize arrays to actual counts + supportedTokens = _resizeTokenArray(supportedTokens, supportedCount); + unsupportedTokens = _resizeTokenArray(unsupportedTokens, unsupportedCount); + supportedBalancesBefore = _resizeUintArray(supportedBalancesBefore, supportedCount); + unsupportedBalancesBefore = _resizeUintArray(unsupportedBalancesBefore, unsupportedCount); - if (!tokenAlreadyProcessed) { - bool isSupported = false; + // Make the external call to EigenLayer AFTER recording pre-state + rewardsCoordinator.processClaim(claim, address(this)); - for (uint256 j = 0; j < allSupportedAssets.length; j++) { - if (allSupportedAssets[j] == currentToken) { - isSupported = true; - break; - } - } + // Calculate actual received amounts by comparing post-claim balances + uint256[] memory supportedReceivedAmounts = new uint256[](supportedCount); + uint256[] memory unsupportedReceivedAmounts = new uint256[](unsupportedCount); - if (isSupported) { - expectedSupportedAssets[supportedCount] = currentToken; - supportedBalances[supportedCount] = currentToken.balanceOf(address(this)); - supportedCount++; - } else { - expectedUnsupportedAssets[unsupportedCount] = currentToken; - unsupportedBalances[unsupportedCount] = currentToken.balanceOf(address(this)); - unsupportedCount++; - } - } + for (uint256 i = 0; i < supportedCount; i++) { + uint256 currentBalance = supportedTokens[i].balanceOf(address(this)) - + unsupportedAssetBalances[address(supportedTokens[i])]; + supportedReceivedAmounts[i] = currentBalance > supportedBalancesBefore[i] + ? currentBalance - supportedBalancesBefore[i] + : 0; } - // Trim arrays to actual sizes - assembly { - mstore(expectedSupportedAssets, supportedCount) - mstore(expectedUnsupportedAssets, unsupportedCount) - mstore(supportedBalances, supportedCount) - mstore(unsupportedBalances, unsupportedCount) + for (uint256 i = 0; i < unsupportedCount; i++) { + uint256 currentBalance = unsupportedTokens[i].balanceOf(address(this)) - + unsupportedAssetBalances[address(unsupportedTokens[i])]; + unsupportedReceivedAmounts[i] = currentBalance > unsupportedBalancesBefore[i] + ? currentBalance - unsupportedBalancesBefore[i] + : 0; } - // Update balances for unsupported tokens - // These tokens will stay in the contract until `swapAndTransferRewards` is called - _setAssetBalances(expectedUnsupportedAssets, unsupportedBalances); + // Update balances for unsupported tokens with events + _setAssetBalances(unsupportedTokens, unsupportedReceivedAmounts); - // Transfer all supported assets to `LiquidToken` - uint256[] memory netTransferredAmounts = _transferRewards(expectedSupportedAssets, supportedBalances); + // Transfer supported assets to LiquidToken + uint256[] memory netTransferredAmounts = _transferRewards(supportedTokens, supportedReceivedAmounts); emit RewardsClaimed( claim.rootIndex, earner, - expectedSupportedAssets, + supportedTokens, netTransferredAmounts, - expectedUnsupportedAssets, - unsupportedBalances + unsupportedTokens, + unsupportedReceivedAmounts ); } - /// @dev Called by `setClaimerFor` and `_processClaim` + /// @dev Get unique tokens from claim to avoid O(n²) complexity + function _getUniqueTokensFromClaim( + IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim + ) internal view returns (IERC20[] memory) { + // Fixed: Use arrays instead of mapping for uniqueness check + IERC20[] memory tempTokens = new IERC20[](claim.tokenLeaves.length); + uint256 uniqueCount = 0; + + for (uint256 i = 0; i < claim.tokenLeaves.length; i++) { + IERC20 currentToken = claim.tokenLeaves[i].token; + bool isDuplicate = false; + + // Check if token already exists in our temp array + for (uint256 j = 0; j < uniqueCount; j++) { + if (tempTokens[j] == currentToken) { + isDuplicate = true; + break; + } + } + + if (!isDuplicate) { + tempTokens[uniqueCount] = currentToken; + uniqueCount++; + } + } + + return _resizeTokenArray(tempTokens, uniqueCount); + } + + /// @dev Helper to check if token is supported + function _isTokenSupported(IERC20 token, IERC20[] memory supportedTokens) internal pure returns (bool) { + for (uint256 i = 0; i < supportedTokens.length; i++) { + if (supportedTokens[i] == token) { + return true; + } + } + return false; + } + + /// @dev Resize token array without assembly + function _resizeTokenArray(IERC20[] memory arr, uint256 newSize) internal pure returns (IERC20[] memory) { + IERC20[] memory resized = new IERC20[](newSize); + for (uint256 i = 0; i < newSize; i++) { + resized[i] = arr[i]; + } + return resized; + } + + /// @dev Resize uint array without assembly + function _resizeUintArray(uint256[] memory arr, uint256 newSize) internal pure returns (uint256[] memory) { + uint256[] memory resized = new uint256[](newSize); + for (uint256 i = 0; i < newSize; i++) { + resized[i] = arr[i]; + } + return resized; + } + + /// @dev Called by `setClaimerFor` and `_processClaim` - Fixed with EnumerableSet function _verifyAndUpdateClaimerFor(address earner) internal returns (bool) { if (address(earner) == address(0)) revert ZeroAddress(); @@ -222,57 +295,57 @@ contract RewardsManager is if (isClaimerOnEl) { if (!isClaimerFor[earner]) { isClaimerFor[earner] = true; - claimerFor.push(earner); - + _claimerForSet.add(earner); emit ClaimerForAdded(earner); } } else { if (isClaimerFor[earner]) { isClaimerFor[earner] = false; - _removeClaimerFor(earner); + _claimerForSet.remove(earner); + emit ClaimerForRemoved(earner); } } return isClaimerOnEl; } - /// @dev Called by `_verifyAndUpdateClaimerFor` - function _removeClaimerFor(address earner) private { - for (uint i = 0; i < claimerFor.length; i++) { - if (claimerFor[i] == earner) { - claimerFor[i] = claimerFor[claimerFor.length - 1]; - claimerFor.pop(); - emit ClaimerForRemoved(earner); - break; - } - } - } - - /// @dev Called by `_processClaim` + /// @dev Called by `_processClaim` - Fixed with event emissions function _setAssetBalances(IERC20[] memory assets, uint256[] memory amounts) internal { if (assets.length != amounts.length) revert ArrayLengthMismatch(); for (uint256 i = 0; i < assets.length; i++) { - if (unsupportedAssetBalances[address(assets[i])] == 0 && amounts[i] > 0) { - unsupportedAssets.push(address(assets[i])); + address assetAddr = address(assets[i]); + uint256 oldBalance = unsupportedAssetBalances[assetAddr]; + uint256 newBalance = oldBalance + amounts[i]; + + if (unsupportedAssetBalances[assetAddr] == 0 && amounts[i] > 0) { + unsupportedAssets.push(assetAddr); + } + + unsupportedAssetBalances[assetAddr] = newBalance; + + if (amounts[i] > 0) { + emit UnsupportedAssetBalanceUpdated(assetAddr, oldBalance, newBalance); } - unsupportedAssetBalances[address(assets[i])] = amounts[i]; } } - /// @dev Called by `_processClaim` - function _transferRewards(IERC20[] memory assets, uint256[] memory amounts) internal returns (uint256[] memory) { - if (assets.length != amounts.length) revert ArrayLengthMismatch(); + /// @dev Called by `_processClaim` - Fixed to use actual received amounts + function _transferRewards( + IERC20[] memory assets, + uint256[] memory receivedAmounts + ) internal returns (uint256[] memory) { + if (assets.length != receivedAmounts.length) revert ArrayLengthMismatch(); - // Transfer to `LiquidToken` and calculate actual net amounts received uint256[] memory netTransferredAmounts = new uint256[](assets.length); for (uint256 i = 0; i < assets.length; i++) { - uint256 liquidTokenBalanceBefore = assets[i].balanceOf(address(liquidToken)); - uint256 rewardsManagerBalanceBefore = amounts[i]; + if (receivedAmounts[i] > 0) { + uint256 liquidTokenBalanceBefore = assets[i].balanceOf(address(liquidToken)); + + // Transfer the actual received amount, not the stored balance + assets[i].safeTransfer(address(liquidToken), receivedAmounts[i]); - if (rewardsManagerBalanceBefore > 0) { - assets[i].safeTransfer(address(liquidToken), rewardsManagerBalanceBefore); uint256 liquidTokenBalanceAfter = assets[i].balanceOf(address(liquidToken)); netTransferredAmounts[i] = liquidTokenBalanceAfter - liquidTokenBalanceBefore; } else { @@ -280,8 +353,10 @@ contract RewardsManager is } } - // Credit `LiquidToken` asset balances with the actual net amounts recieved - liquidToken.creditAssetBalances(assets, netTransferredAmounts); + // Credit LiquidToken asset balances with the actual net amounts received + if (assets.length > 0) { + liquidToken.creditAssetBalances(assets, netTransferredAmounts); + } return netTransferredAmounts; } @@ -290,4 +365,4 @@ contract RewardsManager is function _balanceAsset(IERC20 asset) internal view returns (uint256) { return unsupportedAssetBalances[address(asset)]; } -} +} \ No newline at end of file diff --git a/src/interfaces/IRewardsManager.sol b/src/interfaces/IRewardsManager.sol index 82ebac59..caeff852 100644 --- a/src/interfaces/IRewardsManager.sol +++ b/src/interfaces/IRewardsManager.sol @@ -28,6 +28,9 @@ interface IRewardsManager { // EVENTS // ============================================================================ + /// @notice Emitted when unsupported asset balance is updated + event UnsupportedAssetBalanceUpdated(address indexed asset, uint256 oldBalance, uint256 newBalance); + /// @notice Emitted when rewards are claimed /// @dev These values tell us the actual tokens realized by the LAT after a process claim procedure /// @dev The values may differ from the corresponding EL event due to rounding/transfer loss or unexepected token transfers to this contract @@ -84,4 +87,4 @@ interface IRewardsManager { /// @param assetList The list of assets to get balances for /// @return An array of asset balances function balanceAssets(IERC20[] calldata assetList) external view returns (uint256[] memory); -} +} \ No newline at end of file diff --git a/test/RewardsManagerTest.sol b/test/RewardsManagerTest.sol new file mode 100644 index 00000000..3617e0c1 --- /dev/null +++ b/test/RewardsManagerTest.sol @@ -0,0 +1,821 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import "forge-std/console.sol"; +import {Test} from "forge-std/Test.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IRewardsCoordinator} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; +import {IRewardsCoordinatorTypes} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; + +import {MockRewardsCoordinator} from "./mocks/MockRewardsCoordinator.sol"; +import {BaseTest} from "./common/BaseTest.sol"; +import {MockERC20} from "./mocks/MockERC20.sol"; +import {RewardsManager} from "../src/core/RewardsManager.sol"; +import {IRewardsManager} from "../src/interfaces/IRewardsManager.sol"; +import {ILiquidToken} from "../src/interfaces/ILiquidToken.sol"; + +contract RewardsManagerTest is BaseTest { + MockERC20 public unsupportedToken1; + MockERC20 public unsupportedToken2; + + address public earner1 = address(0x1001); + address public earner2 = address(0x1002); + + // Track the real rewards coordinator + address public realCoordinator; + + function setUp() public override { + super.setUp(); + + // Get the real coordinator address + realCoordinator = address(rewardsManager.rewardsCoordinator()); + + // Create unsupported tokens + unsupportedToken1 = new MockERC20("Unsupported Token 1", "UNSUP1"); + unsupportedToken2 = new MockERC20("Unsupported Token 2", "UNSUP2"); + } + + // Helper function to set up mock coordinator storage + function setupMockCoordinatorStorage(address earner, IERC20 token, uint256 amount) internal { + // Set claimerFor[earner] storage slot + bytes32 claimerSlot = keccak256(abi.encode(earner, uint256(0))); + bytes32 claimerValue = bytes32(uint256(uint160(address(rewardsManager)))); + vm.store(realCoordinator, claimerSlot, claimerValue); + + // Set transferAmounts[token][recipient] storage slot + bytes32 innerSlot = keccak256(abi.encode(address(token), uint256(1))); + bytes32 transferSlot = keccak256(abi.encode(address(rewardsManager), innerSlot)); + vm.store(realCoordinator, transferSlot, bytes32(amount)); + } + + function test_ProcessClaim_AccurateBalanceTransfer() public { + // Pre-fund RewardsManager with existing balance + testToken.mint(address(rewardsManager), 50 ether); + uint256 existingBalance = testToken.balanceOf(address(rewardsManager)); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + // Create mock coordinator + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + + // Replace the coordinator code + vm.etch(realCoordinator, address(mockCoordinator).code); + + // Set up storage for mock coordinator + setupMockCoordinatorStorage(earner1, testToken, 75 ether); + + // Fund the coordinator + testToken.mint(realCoordinator, 75 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 transferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + assertEq(transferred, 75 ether, "Should transfer only actual received amount"); + assertEq(testToken.balanceOf(address(rewardsManager)), existingBalance); + } + + function test_ProcessClaim_LiquidTokenIntegration() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](2); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + tokenLeaves[1] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken2)), + cumulativeEarnings: 50 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + // Create mock coordinator + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + // Set up storage for both tokens + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + setupMockCoordinatorStorage(earner1, testToken2, 50 ether); + + // Fund the coordinator + testToken.mint(realCoordinator, 100 ether); + testToken2.mint(realCoordinator, 50 ether); + + uint256 liquidTokenAssetBalance1Before = liquidToken.assetBalances(address(testToken)); + uint256 liquidTokenAssetBalance2Before = liquidToken.assetBalances(address(testToken2)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + assertEq(liquidToken.assetBalances(address(testToken)) - liquidTokenAssetBalance1Before, 100 ether); + assertEq(liquidToken.assetBalances(address(testToken2)) - liquidTokenAssetBalance2Before, 50 ether); + assertEq(testToken.balanceOf(address(liquidToken)), 100 ether); + assertEq(testToken2.balanceOf(address(liquidToken)), 50 ether); + } + + function test_ProcessClaim_ReentrancyProtection() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + + testToken.mint(realCoordinator, 100 ether); + + uint256 balanceBefore = testToken.balanceOf(address(rewardsManager)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 balanceAfter = testToken.balanceOf(address(rewardsManager)); + assertEq(balanceAfter, balanceBefore); // All transferred to LiquidToken + assertEq(testToken.balanceOf(address(liquidToken)), 100 ether); + } + + function test_ProcessClaim_SafeArrayOperations() public { + // Create claim with duplicate tokens + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](4); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 50 ether + }); + tokenLeaves[1] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), // Duplicate + cumulativeEarnings: 30 ether + }); + tokenLeaves[2] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 25 ether + }); + tokenLeaves[3] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken2)), + cumulativeEarnings: 40 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + // Set up storage - since MockRewardsCoordinator transfers per leaf, + // we need to set the transfer amount to what we want per leaf + setupMockCoordinatorStorage(earner1, testToken, 40 ether); // Amount per leaf + setupMockCoordinatorStorage(earner1, testToken2, 40 ether); + setupMockCoordinatorStorage(earner1, unsupportedToken1, 25 ether); + + // Fund coordinator for multiple transfers of the same token + testToken.mint(realCoordinator, 80 ether); // 40 * 2 leaves + testToken2.mint(realCoordinator, 40 ether); + unsupportedToken1.mint(realCoordinator, 25 ether); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Verify results - MockRewardsCoordinator will transfer for each leaf + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 25 ether); + + // The RewardsManager should receive tokens for each leaf, but dedupe when processing + // Check the actual balances to see what happened + uint256 testTokenReceived = testToken.balanceOf(address(liquidToken)); + uint256 testToken2Received = testToken2.balanceOf(address(liquidToken)); + + // The exact amounts depend on the RewardsManager's deduplication logic + assertGt(testTokenReceived, 0, "Should receive some testToken"); + assertEq(testToken2Received, 40 ether, "Should receive testToken2"); + } + + function test_ProcessClaim_SecurityBalanceCheck() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, unsupportedToken1, 100 ether); + + unsupportedToken1.mint(realCoordinator, 100 ether); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 100 ether); + } + + function test_ClaimerManagement_EfficientOperations() public { + address[] memory earners = new address[](10); + for (uint i = 0; i < 10; i++) { + earners[i] = address(uint160(0x2000 + i)); + + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earners[i]), + abi.encode(address(rewardsManager)) + ); + } + + uint256 gasBefore = gasleft(); + + for (uint i = 0; i < 10; i++) { + vm.prank(earners[i]); + rewardsManager.updateClaimerFor(earners[i]); + } + + uint256 gasUsed = gasBefore - gasleft(); + console.log("Gas used for 10 claimer updates:", gasUsed); + + assertEq(rewardsManager.claimerForLength(), 10); + + // Test removal + for (uint i = 0; i < 5; i++) { + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earners[i]), + abi.encode(address(0)) + ); + + vm.prank(earners[i]); + rewardsManager.updateClaimerFor(earners[i]); + } + + assertEq(rewardsManager.claimerForLength(), 5); + + address[] memory remainingClaimers = rewardsManager.claimerFor(); + assertEq(remainingClaimers.length, 5); + } + + function test_ProcessClaim_UnauthorizedClaimer() public { + // Mock to return address(0) - no claimer set + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(0)) + ); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + vm.prank(earner1); + vm.expectRevert(abi.encodeWithSelector(IRewardsManager.NotClaimerFor.selector, earner1)); + rewardsManager.processClaim(claim); + } + + function test_ProcessClaims_GasLimitProtection() public { + // Create valid claims with non-zero earner + IRewardsCoordinatorTypes.RewardsMerkleClaim[] memory claims = new IRewardsCoordinatorTypes.RewardsMerkleClaim[]( + 51 + ); + for (uint i = 0; i < 51; i++) { + // Create empty token leaves array + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](0); + claims[i] = _createBasicClaim(earner1, tokenLeaves); + } + + // Try to process more than 50 claims + vm.expectRevert("Too many claims"); + rewardsManager.processClaims(claims); + + // Create 50 valid claims + claims = new IRewardsCoordinatorTypes.RewardsMerkleClaim[](50); + for (uint i = 0; i < 50; i++) { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](0); + claims[i] = _createBasicClaim(earner1, tokenLeaves); + } + + // Should work with 50 or fewer - create valid claims + // Expect NotClaimerFor since we didn't set up coordinator + vm.expectRevert(abi.encodeWithSelector(IRewardsManager.NotClaimerFor.selector, earner1)); + rewardsManager.processClaims(claims); + } + /// + // more tests for RewardsManagerTest contract + + function test_ProcessClaim_ZeroAmountClaim() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 0 // Zero amount + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 0); // No transfer + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Should handle zero amounts gracefully + assertEq(testToken.balanceOf(address(liquidToken)), 0); + } + + function test_ProcessClaim_EmptyTokenLeaves() public { + // Claim with no token leaves + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](0); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 0); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Should handle empty claims gracefully + assertEq(testToken.balanceOf(address(liquidToken)), 0); + } + + function test_ProcessClaim_MaxTokenLeaves() public { + // Test with maximum reasonable number of token leaves + uint256 maxLeaves = 20; + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](maxLeaves); + + // Create many different tokens + MockERC20[] memory manyTokens = new MockERC20[](maxLeaves); + for (uint256 i = 0; i < maxLeaves; i++) { + manyTokens[i] = new MockERC20(string(abi.encodePacked("Token", i)), string(abi.encodePacked("TK", i))); + tokenLeaves[i] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(manyTokens[i])), + cumulativeEarnings: 1 ether + }); + } + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + // Setup all tokens as unsupported with transfers + for (uint256 i = 0; i < maxLeaves; i++) { + setupMockCoordinatorStorage(earner1, IERC20(address(manyTokens[i])), 1 ether); + manyTokens[i].mint(realCoordinator, 1 ether); + } + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // All should be unsupported assets + for (uint256 i = 0; i < maxLeaves; i++) { + assertEq(rewardsManager.unsupportedAssetBalances(address(manyTokens[i])), 1 ether); + } + } + + function test_ProcessClaim_MixedSupportedUnsupportedTokens() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](3); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), // Supported + cumulativeEarnings: 100 ether + }); + tokenLeaves[1] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), // Unsupported + cumulativeEarnings: 50 ether + }); + tokenLeaves[2] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken2)), // Supported + cumulativeEarnings: 75 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + setupMockCoordinatorStorage(earner1, unsupportedToken1, 50 ether); + setupMockCoordinatorStorage(earner1, testToken2, 75 ether); + + testToken.mint(realCoordinator, 100 ether); + unsupportedToken1.mint(realCoordinator, 50 ether); + testToken2.mint(realCoordinator, 75 ether); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Supported tokens should go to LiquidToken + assertEq(testToken.balanceOf(address(liquidToken)), 100 ether); + assertEq(testToken2.balanceOf(address(liquidToken)), 75 ether); + + // Unsupported should stay in RewardsManager + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 50 ether); + } + + function test_ProcessClaim_ClaimerStatusChangeDuringExecution() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + // Mock the processClaim to do nothing for simplicity + vm.mockCall(realCoordinator, abi.encodeWithSelector(IRewardsCoordinator.processClaim.selector), abi.encode()); + + // Initially set as claimer + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(rewardsManager)) + ); + + // First call should work + vm.prank(earner1); + rewardsManager.processClaim(claim); + + // Verify the claimer was added to local storage + assertTrue(rewardsManager.isClaimerFor(earner1)); + assertEq(rewardsManager.claimerForLength(), 1); + + // Test the failed case - change claimer status to address(0) + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(0)) // No longer a claimer + ); + + // This call should fail with NotClaimerFor + vm.prank(earner1); + vm.expectRevert(abi.encodeWithSelector(IRewardsManager.NotClaimerFor.selector, earner1)); + rewardsManager.processClaim(claim); + + // The claimer should still be in local storage because the transaction reverted + assertTrue(rewardsManager.isClaimerFor(earner1)); + + // Now test successful claimer removal by calling updateClaimerFor directly + vm.prank(earner1); + rewardsManager.updateClaimerFor(earner1); + + // Now the claimer should be removed + assertFalse(rewardsManager.isClaimerFor(earner1)); + assertEq(rewardsManager.claimerForLength(), 0); + } + + function test_ProcessClaim_BalanceCalculationWithExistingBalance() public { + // Pre-fund RewardsManager with existing balance + testToken.mint(address(rewardsManager), 200 ether); + uint256 existingBalance = testToken.balanceOf(address(rewardsManager)); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + testToken.mint(realCoordinator, 100 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 transferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + // Should only transfer the newly received amount, not existing balance + assertEq(transferred, 100 ether); + assertEq(testToken.balanceOf(address(rewardsManager)), existingBalance); + } + + function test_ProcessClaim_UnsupportedTokenAccumulation() public { + // First claim + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves1 = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves1[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 50 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim1 = _createBasicClaim(earner1, tokenLeaves1); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, unsupportedToken1, 50 ether); + unsupportedToken1.mint(realCoordinator, 50 ether); + + vm.prank(earner1); + rewardsManager.processClaim(claim1); + + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 50 ether); + + // Second claim - should accumulate + setupMockCoordinatorStorage(earner1, unsupportedToken1, 30 ether); + unsupportedToken1.mint(realCoordinator, 30 ether); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves2 = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves2[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 30 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim2 = _createBasicClaim(earner1, tokenLeaves2); + + vm.prank(earner1); + rewardsManager.processClaim(claim2); + + // Should accumulate to 80 ether + assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 80 ether); + } + + function test_ProcessClaim_CoordinatorReturnsLessThanClaimed() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 1000 ether // Claim 1000 + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 100 ether); // Only transfer 100 + testToken.mint(realCoordinator, 100 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 transferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + // Should only transfer what was actually received + assertEq(transferred, 100 ether); + } + + function test_ProcessClaim_CoordinatorTransfersNothing() public { + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 0); // No transfer + // Don't mint any tokens to coordinator + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + + // Should handle zero transfers gracefully + assertEq(liquidTokenBalanceAfter, liquidTokenBalanceBefore); + } + + function test_ProcessClaim_MultipleEarnersSequentially() public { + // Setup claims for multiple earners + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim1 = _createBasicClaim(earner1, tokenLeaves); + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim2 = _createBasicClaim(earner2, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + // Setup both earners + setupMockCoordinatorStorage(earner1, testToken, 100 ether); + setupMockCoordinatorStorage(earner2, testToken, 100 ether); + testToken.mint(realCoordinator, 200 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + // Process first claim + vm.prank(earner1); + rewardsManager.processClaim(claim1); + + // Process second claim + vm.prank(earner2); + rewardsManager.processClaim(claim2); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 totalTransferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + // Should transfer total from both claims + assertEq(totalTransferred, 200 ether); + } + + function test_ProcessClaim_ExtremelyLargeDuplicates() public { + // Create claim with many duplicates of the same token + uint256 duplicateCount = 50; + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](duplicateCount); + + for (uint256 i = 0; i < duplicateCount; i++) { + tokenLeaves[i] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 1 ether + }); + } + + IRewardsCoordinatorTypes.RewardsMerkleClaim memory claim = _createBasicClaim(earner1, tokenLeaves); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + setupMockCoordinatorStorage(earner1, testToken, 1 ether); + testToken.mint(realCoordinator, duplicateCount * 1 ether); + + uint256 gasBefore = gasleft(); + + vm.prank(earner1); + rewardsManager.processClaim(claim); + + uint256 gasUsed = gasBefore - gasleft(); + console.log("Gas used for", duplicateCount, "duplicates:", gasUsed); + + // Should handle duplicates efficiently + assertGt(testToken.balanceOf(address(liquidToken)), 0); + } + + function test_ProcessClaims_BatchProcessing() public { + // Create multiple claims + uint256 claimCount = 10; + IRewardsCoordinatorTypes.RewardsMerkleClaim[] memory claims = new IRewardsCoordinatorTypes.RewardsMerkleClaim[]( + claimCount + ); + + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + for (uint256 i = 0; i < claimCount; i++) { + address earner = address(uint160(0x3000 + i)); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(testToken)), + cumulativeEarnings: 10 ether + }); + + claims[i] = _createBasicClaim(earner, tokenLeaves); + setupMockCoordinatorStorage(earner, testToken, 10 ether); + } + + testToken.mint(realCoordinator, claimCount * 10 ether); + + uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); + + vm.prank(earner1); // Any caller can process batch + rewardsManager.processClaims(claims); + + uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); + uint256 totalTransferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + + assertEq(totalTransferred, claimCount * 10 ether); + } + + function test_UpdateClaimerFor_EdgeCases() public { + // Test with zero address should revert + vm.expectRevert(abi.encodeWithSelector(IRewardsManager.ZeroAddress.selector)); + rewardsManager.updateClaimerFor(address(0)); + + // Test claimer addition and removal cycle + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(rewardsManager)) + ); + + vm.prank(earner1); + rewardsManager.updateClaimerFor(earner1); + assertTrue(rewardsManager.isClaimerFor(earner1)); + assertEq(rewardsManager.claimerForLength(), 1); + + // Remove claimer + vm.mockCall( + realCoordinator, + abi.encodeWithSelector(IRewardsCoordinator.claimerFor.selector, earner1), + abi.encode(address(0)) + ); + + vm.prank(earner1); + rewardsManager.updateClaimerFor(earner1); + assertFalse(rewardsManager.isClaimerFor(earner1)); + assertEq(rewardsManager.claimerForLength(), 0); + } + + function test_BalanceAssets_MultipleAssets() public { + // Setup some unsupported asset balances + MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); + vm.etch(realCoordinator, address(mockCoordinator).code); + + setupMockCoordinatorStorage(earner1, unsupportedToken1, 100 ether); + setupMockCoordinatorStorage(earner1, unsupportedToken2, 200 ether); + unsupportedToken1.mint(realCoordinator, 100 ether); + unsupportedToken2.mint(realCoordinator, 200 ether); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves1 = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves1[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken1)), + cumulativeEarnings: 100 ether + }); + + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] + memory tokenLeaves2 = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); + tokenLeaves2[0] = IRewardsCoordinatorTypes.TokenTreeMerkleLeaf({ + token: IERC20(address(unsupportedToken2)), + cumulativeEarnings: 200 ether + }); + + vm.prank(earner1); + rewardsManager.processClaim(_createBasicClaim(earner1, tokenLeaves1)); + + vm.prank(earner1); + rewardsManager.processClaim(_createBasicClaim(earner1, tokenLeaves2)); + + // Test balanceAssets function + IERC20[] memory assets = new IERC20[](3); + assets[0] = unsupportedToken1; + assets[1] = unsupportedToken2; + assets[2] = testToken; // Should be 0 + + uint256[] memory balances = rewardsManager.balanceAssets(assets); + + assertEq(balances[0], 100 ether); + assertEq(balances[1], 200 ether); + assertEq(balances[2], 0); + } + + //// + function _createBasicClaim( + address earner, + IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] memory tokenLeaves + ) internal pure returns (IRewardsCoordinatorTypes.RewardsMerkleClaim memory) { + IRewardsCoordinatorTypes.EarnerTreeMerkleLeaf memory earnerLeaf = IRewardsCoordinatorTypes + .EarnerTreeMerkleLeaf({earner: earner, earnerTokenRoot: bytes32(0)}); + + bytes memory emptyProof = new bytes(0); + bytes[] memory tokenTreeProofs = new bytes[](tokenLeaves.length); + for (uint i = 0; i < tokenLeaves.length; i++) { + tokenTreeProofs[i] = new bytes(0); + } + + return + IRewardsCoordinatorTypes.RewardsMerkleClaim({ + rootIndex: 1, + earnerIndex: 0, + earnerTreeProof: emptyProof, + earnerLeaf: earnerLeaf, + tokenIndices: new uint32[](tokenLeaves.length), + tokenTreeProofs: tokenTreeProofs, + tokenLeaves: tokenLeaves + }); + } +} \ No newline at end of file diff --git a/test/mocks/MockRewardsCoordinator.sol b/test/mocks/MockRewardsCoordinator.sol new file mode 100644 index 00000000..83a7c6a6 --- /dev/null +++ b/test/mocks/MockRewardsCoordinator.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IRewardsCoordinatorTypes} from "@eigenlayer/contracts/interfaces/IRewardsCoordinator.sol"; + +contract MockRewardsCoordinator { + mapping(address => address) public claimerFor; + mapping(address => mapping(address => uint256)) public transferAmounts; + + function setClaimerFor(address earner, address claimer) external { + claimerFor[earner] = claimer; + } + + function setupClaimTransfer(address token, address to, uint256 amount) external { + transferAmounts[token][to] = amount; + } + + function processClaim(IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim, address recipient) external { + // Simulate transferring tokens for each token leaf + for (uint256 i = 0; i < claim.tokenLeaves.length; i++) { + address tokenAddr = address(claim.tokenLeaves[i].token); + uint256 transferAmount = transferAmounts[tokenAddr][recipient]; + + if (transferAmount > 0) { + IERC20(tokenAddr).transfer(recipient, transferAmount); + } + } + } +} \ No newline at end of file From b7069c7cff719a46fb72da50525149d666927ac4 Mon Sep 17 00:00:00 2001 From: parsaba Date: Fri, 22 Aug 2025 10:10:01 +0330 Subject: [PATCH 2/3] Changed in balances from accumulated to full --- src/core/RewardsManager.sol | 172 +++++++++++------------------ src/interfaces/IRewardsManager.sol | 12 +- test/RewardsManagerTest.sol | 22 ++-- 3 files changed, 80 insertions(+), 126 deletions(-) diff --git a/src/core/RewardsManager.sol b/src/core/RewardsManager.sol index 3246b00d..60fd299e 100644 --- a/src/core/RewardsManager.sol +++ b/src/core/RewardsManager.sol @@ -17,6 +17,8 @@ import {ILiquidToken} from "../interfaces/ILiquidToken.sol"; import {ILiquidTokenManager} from "../interfaces/ILiquidTokenManager.sol"; /// @title RewardsManager +/// @notice Manages reward claims from EigenLayer and distributes them to the Liquid Token +/// @dev Handles both supported and unsupported tokens, transferring all value to LAT holders contract RewardsManager is IRewardsManager, Initializable, @@ -54,6 +56,8 @@ contract RewardsManager is // Events // ------------------------------------------------------------------------------ + // Events inherited from IRewardsManager interface + // ------------------------------------------------------------------------------ // Init functions // ------------------------------------------------------------------------------ @@ -114,6 +118,16 @@ contract RewardsManager is } } + /// @inheritdoc IRewardsManager + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + /// @inheritdoc IRewardsManager + function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) { + _unpause(); + } + // ------------------------------------------------------------------------------ // Getter functions // ------------------------------------------------------------------------------ @@ -141,43 +155,34 @@ contract RewardsManager is // Internal functions // ------------------------------------------------------------------------------ - /// @dev Called by `processClaim` and `processClaims` - Fixed for reentrancy and balance calculation + /// @dev Process a single claim - transfers all token balances to LAT + /// @dev These values tell us the actual tokens realized by the LAT after a process claim procedure + /// @dev The values may differ from the corresponding EL event due to rounding/transfer loss or unexpected token transfers to this contract + /// @dev We are only concerned with actual value accrued to LAT, exact EL data can be found via corresponding EL events function _processClaim(IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim) internal { address earner = claim.earnerLeaf.earner; if (!_verifyAndUpdateClaimerFor(earner)) revert NotClaimerFor(earner); - // Record balances BEFORE the external call to prevent reentrancy issues + // Get unique tokens from the claim IERC20[] memory uniqueTokens = _getUniqueTokensFromClaim(claim); - - // Create properly sized arrays instead of using assembly manipulation - // Fixed: Renamed to avoid shadowing + + // Categorize tokens into supported and unsupported + IERC20[] memory allSupportedAssets = liquidTokenManager.getSupportedTokens(); + IERC20[] memory supportedTokens = new IERC20[](uniqueTokens.length); IERC20[] memory unsupportedTokens = new IERC20[](uniqueTokens.length); - uint256[] memory supportedBalancesBefore = new uint256[](uniqueTokens.length); - uint256[] memory unsupportedBalancesBefore = new uint256[](uniqueTokens.length); - uint256 supportedCount = 0; uint256 unsupportedCount = 0; - IERC20[] memory allSupportedAssets = liquidTokenManager.getSupportedTokens(); - - // Categorize tokens and record pre-claim balances for (uint256 i = 0; i < uniqueTokens.length; i++) { IERC20 currentToken = uniqueTokens[i]; bool isSupported = _isTokenSupported(currentToken, allSupportedAssets); if (isSupported) { supportedTokens[supportedCount] = currentToken; - // Only count newly claimable balance, not existing contract balance - supportedBalancesBefore[supportedCount] = - currentToken.balanceOf(address(this)) - - unsupportedAssetBalances[address(currentToken)]; supportedCount++; } else { unsupportedTokens[unsupportedCount] = currentToken; - unsupportedBalancesBefore[unsupportedCount] = - currentToken.balanceOf(address(this)) - - unsupportedAssetBalances[address(currentToken)]; unsupportedCount++; } } @@ -185,53 +190,63 @@ contract RewardsManager is // Resize arrays to actual counts supportedTokens = _resizeTokenArray(supportedTokens, supportedCount); unsupportedTokens = _resizeTokenArray(unsupportedTokens, unsupportedCount); - supportedBalancesBefore = _resizeUintArray(supportedBalancesBefore, supportedCount); - unsupportedBalancesBefore = _resizeUintArray(unsupportedBalancesBefore, unsupportedCount); - // Make the external call to EigenLayer AFTER recording pre-state + // Process the claim on EigenLayer rewardsCoordinator.processClaim(claim, address(this)); - // Calculate actual received amounts by comparing post-claim balances - uint256[] memory supportedReceivedAmounts = new uint256[](supportedCount); - uint256[] memory unsupportedReceivedAmounts = new uint256[](unsupportedCount); - + // Transfer ALL balances of supported tokens to LiquidToken + uint256[] memory supportedAmounts = new uint256[](supportedCount); for (uint256 i = 0; i < supportedCount; i++) { - uint256 currentBalance = supportedTokens[i].balanceOf(address(this)) - - unsupportedAssetBalances[address(supportedTokens[i])]; - supportedReceivedAmounts[i] = currentBalance > supportedBalancesBefore[i] - ? currentBalance - supportedBalancesBefore[i] - : 0; + uint256 fullBalance = supportedTokens[i].balanceOf(address(this)); + if (fullBalance > 0) { + supportedTokens[i].safeTransfer(address(liquidToken), fullBalance); + supportedAmounts[i] = fullBalance; + } } + // Record full balances for unsupported tokens (not additive - we set the full balance) + uint256[] memory unsupportedAmounts = new uint256[](unsupportedCount); for (uint256 i = 0; i < unsupportedCount; i++) { - uint256 currentBalance = unsupportedTokens[i].balanceOf(address(this)) - - unsupportedAssetBalances[address(unsupportedTokens[i])]; - unsupportedReceivedAmounts[i] = currentBalance > unsupportedBalancesBefore[i] - ? currentBalance - unsupportedBalancesBefore[i] - : 0; + address assetAddr = address(unsupportedTokens[i]); + uint256 fullBalance = unsupportedTokens[i].balanceOf(address(this)); + unsupportedAmounts[i] = fullBalance; + + // Update storage + uint256 oldBalance = unsupportedAssetBalances[assetAddr]; + + // Add to unsupported assets array if new + if (oldBalance == 0 && fullBalance > 0) { + unsupportedAssets.push(assetAddr); + } + + // Set the full balance (not additive) + unsupportedAssetBalances[assetAddr] = fullBalance; + + // Emit event if balance changed + if (oldBalance != fullBalance) { + emit UnsupportedAssetBalanceUpdated(assetAddr, oldBalance, fullBalance); + } } - // Update balances for unsupported tokens with events - _setAssetBalances(unsupportedTokens, unsupportedReceivedAmounts); - - // Transfer supported assets to LiquidToken - uint256[] memory netTransferredAmounts = _transferRewards(supportedTokens, supportedReceivedAmounts); + // Credit LiquidToken with the transferred amounts + if (supportedCount > 0 && supportedAmounts.length > 0) { + liquidToken.creditAssetBalances(supportedTokens, supportedAmounts); + } emit RewardsClaimed( claim.rootIndex, earner, supportedTokens, - netTransferredAmounts, + supportedAmounts, unsupportedTokens, - unsupportedReceivedAmounts + unsupportedAmounts ); } - /// @dev Get unique tokens from claim to avoid O(n²) complexity + /// @dev Get unique tokens from claim to avoid processing duplicates function _getUniqueTokensFromClaim( IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim - ) internal view returns (IERC20[] memory) { - // Fixed: Use arrays instead of mapping for uniqueness check + ) internal pure returns (IERC20[] memory) { IERC20[] memory tempTokens = new IERC20[](claim.tokenLeaves.length); uint256 uniqueCount = 0; @@ -266,7 +281,7 @@ contract RewardsManager is return false; } - /// @dev Resize token array without assembly + /// @dev Resize token array to actual size function _resizeTokenArray(IERC20[] memory arr, uint256 newSize) internal pure returns (IERC20[] memory) { IERC20[] memory resized = new IERC20[](newSize); for (uint256 i = 0; i < newSize; i++) { @@ -275,16 +290,7 @@ contract RewardsManager is return resized; } - /// @dev Resize uint array without assembly - function _resizeUintArray(uint256[] memory arr, uint256 newSize) internal pure returns (uint256[] memory) { - uint256[] memory resized = new uint256[](newSize); - for (uint256 i = 0; i < newSize; i++) { - resized[i] = arr[i]; - } - return resized; - } - - /// @dev Called by `setClaimerFor` and `_processClaim` - Fixed with EnumerableSet + /// @dev Verify and update claimer status for an earner function _verifyAndUpdateClaimerFor(address earner) internal returns (bool) { if (address(earner) == address(0)) revert ZeroAddress(); @@ -309,59 +315,7 @@ contract RewardsManager is return isClaimerOnEl; } - /// @dev Called by `_processClaim` - Fixed with event emissions - function _setAssetBalances(IERC20[] memory assets, uint256[] memory amounts) internal { - if (assets.length != amounts.length) revert ArrayLengthMismatch(); - - for (uint256 i = 0; i < assets.length; i++) { - address assetAddr = address(assets[i]); - uint256 oldBalance = unsupportedAssetBalances[assetAddr]; - uint256 newBalance = oldBalance + amounts[i]; - - if (unsupportedAssetBalances[assetAddr] == 0 && amounts[i] > 0) { - unsupportedAssets.push(assetAddr); - } - - unsupportedAssetBalances[assetAddr] = newBalance; - - if (amounts[i] > 0) { - emit UnsupportedAssetBalanceUpdated(assetAddr, oldBalance, newBalance); - } - } - } - - /// @dev Called by `_processClaim` - Fixed to use actual received amounts - function _transferRewards( - IERC20[] memory assets, - uint256[] memory receivedAmounts - ) internal returns (uint256[] memory) { - if (assets.length != receivedAmounts.length) revert ArrayLengthMismatch(); - - uint256[] memory netTransferredAmounts = new uint256[](assets.length); - - for (uint256 i = 0; i < assets.length; i++) { - if (receivedAmounts[i] > 0) { - uint256 liquidTokenBalanceBefore = assets[i].balanceOf(address(liquidToken)); - - // Transfer the actual received amount, not the stored balance - assets[i].safeTransfer(address(liquidToken), receivedAmounts[i]); - - uint256 liquidTokenBalanceAfter = assets[i].balanceOf(address(liquidToken)); - netTransferredAmounts[i] = liquidTokenBalanceAfter - liquidTokenBalanceBefore; - } else { - netTransferredAmounts[i] = 0; - } - } - - // Credit LiquidToken asset balances with the actual net amounts received - if (assets.length > 0) { - liquidToken.creditAssetBalances(assets, netTransferredAmounts); - } - - return netTransferredAmounts; - } - - /// @dev Called by `balanceAssets` + /// @dev Get balance of unsupported asset function _balanceAsset(IERC20 asset) internal view returns (uint256) { return unsupportedAssetBalances[address(asset)]; } diff --git a/src/interfaces/IRewardsManager.sol b/src/interfaces/IRewardsManager.sol index caeff852..31b77856 100644 --- a/src/interfaces/IRewardsManager.sol +++ b/src/interfaces/IRewardsManager.sol @@ -30,11 +30,11 @@ interface IRewardsManager { /// @notice Emitted when unsupported asset balance is updated event UnsupportedAssetBalanceUpdated(address indexed asset, uint256 oldBalance, uint256 newBalance); - + /// @notice Emitted when rewards are claimed /// @dev These values tell us the actual tokens realized by the LAT after a process claim procedure - /// @dev The values may differ from the corresponding EL event due to rounding/transfer loss or unexepected token transfers to this contract - /// @dev We are only concerned with actual value accured to LAT, exact EL data can be found via corresponding EL events + /// @dev The values may differ from the corresponding EL event due to rounding/transfer loss or unexpected token transfers to this contract + /// @dev We are only concerned with actual value accrued to LAT, exact EL data can be found via corresponding EL events event RewardsClaimed( uint32 indexed rootIndex, address indexed earner, @@ -87,4 +87,10 @@ interface IRewardsManager { /// @param assetList The list of assets to get balances for /// @return An array of asset balances function balanceAssets(IERC20[] calldata assetList) external view returns (uint256[] memory); + + /// @notice Pauses the contract + function pause() external; + + /// @notice Unpauses the contract + function unpause() external; } \ No newline at end of file diff --git a/test/RewardsManagerTest.sol b/test/RewardsManagerTest.sol index 3617e0c1..af43d008 100644 --- a/test/RewardsManagerTest.sol +++ b/test/RewardsManagerTest.sol @@ -64,14 +64,8 @@ contract RewardsManagerTest is BaseTest { // Create mock coordinator MockRewardsCoordinator mockCoordinator = new MockRewardsCoordinator(); - - // Replace the coordinator code vm.etch(realCoordinator, address(mockCoordinator).code); - - // Set up storage for mock coordinator setupMockCoordinatorStorage(earner1, testToken, 75 ether); - - // Fund the coordinator testToken.mint(realCoordinator, 75 ether); uint256 liquidTokenBalanceBefore = testToken.balanceOf(address(liquidToken)); @@ -82,8 +76,9 @@ contract RewardsManagerTest is BaseTest { uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); uint256 transferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; - assertEq(transferred, 75 ether, "Should transfer only actual received amount"); - assertEq(testToken.balanceOf(address(rewardsManager)), existingBalance); + // CHANGED: Should transfer FULL balance (existing + new) + assertEq(transferred, 125 ether, "Should transfer full balance"); + assertEq(testToken.balanceOf(address(rewardsManager)), 0, "RewardsManager should have 0 balance"); } function test_ProcessClaim_LiquidTokenIntegration() public { @@ -491,7 +486,6 @@ contract RewardsManagerTest is BaseTest { function test_ProcessClaim_BalanceCalculationWithExistingBalance() public { // Pre-fund RewardsManager with existing balance testToken.mint(address(rewardsManager), 200 ether); - uint256 existingBalance = testToken.balanceOf(address(rewardsManager)); IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[] memory tokenLeaves = new IRewardsCoordinatorTypes.TokenTreeMerkleLeaf[](1); @@ -515,9 +509,9 @@ contract RewardsManagerTest is BaseTest { uint256 liquidTokenBalanceAfter = testToken.balanceOf(address(liquidToken)); uint256 transferred = liquidTokenBalanceAfter - liquidTokenBalanceBefore; - // Should only transfer the newly received amount, not existing balance - assertEq(transferred, 100 ether); - assertEq(testToken.balanceOf(address(rewardsManager)), existingBalance); + // CHANGED: Should transfer FULL balance + assertEq(transferred, 300 ether, "Should transfer full balance"); + assertEq(testToken.balanceOf(address(rewardsManager)), 0, "RewardsManager should have 0 balance"); } function test_ProcessClaim_UnsupportedTokenAccumulation() public { @@ -541,7 +535,7 @@ contract RewardsManagerTest is BaseTest { assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 50 ether); - // Second claim - should accumulate + // Second claim - balance is set, not accumulated setupMockCoordinatorStorage(earner1, unsupportedToken1, 30 ether); unsupportedToken1.mint(realCoordinator, 30 ether); @@ -557,7 +551,7 @@ contract RewardsManagerTest is BaseTest { vm.prank(earner1); rewardsManager.processClaim(claim2); - // Should accumulate to 80 ether + // CHANGED: Should set to full balance (80 ether), not accumulate separately assertEq(rewardsManager.unsupportedAssetBalances(address(unsupportedToken1)), 80 ether); } From a3056b2c02c1d01b8ad0e6bd824acc1f115a0a20 Mon Sep 17 00:00:00 2001 From: Gowtham S Date: Sun, 24 Aug 2025 23:06:25 +0100 Subject: [PATCH 3/3] fix: credit actual balances to lt --- src/core/RewardsManager.sol | 170 +++++++++++++++----------- src/interfaces/IRewardsManager.sol | 18 ++- test/RewardsManagerTest.sol | 2 +- test/mocks/MockAVSRegistrar.sol | 23 ++++ test/mocks/MockRewardsCoordinator.sol | 2 +- 5 files changed, 139 insertions(+), 76 deletions(-) create mode 100644 test/mocks/MockAVSRegistrar.sol diff --git a/src/core/RewardsManager.sol b/src/core/RewardsManager.sol index 60fd299e..00a34352 100644 --- a/src/core/RewardsManager.sol +++ b/src/core/RewardsManager.sol @@ -46,18 +46,12 @@ contract RewardsManager is /// @notice Unsupported rewards which remain in the contract until swapped out mapping(address => uint256) public unsupportedAssetBalances; - address[] public unsupportedAssets; + EnumerableSetUpgradeable.AddressSet private _unsupportedAssets; - /// @notice Claimer for earners on EL - using EnumerableSet for efficient operations + /// @notice Claimer for earners on EL mapping(address => bool) public isClaimerFor; EnumerableSetUpgradeable.AddressSet private _claimerForSet; - // ------------------------------------------------------------------------------ - // Events - // ------------------------------------------------------------------------------ - - // Events inherited from IRewardsManager interface - // ------------------------------------------------------------------------------ // Init functions // ------------------------------------------------------------------------------ @@ -118,6 +112,10 @@ contract RewardsManager is } } + /// @notice For rewards that are in unsupported assets, swaps into supported assets and transfers over to LT + /// @dev OUT OF SCOPE FOR V2 + /// function swapAndTransferRewards() external override nonReentrant whenNotPaused onlyRole(DEFAULT_ADMIN_ROLE) {} + /// @inheritdoc IRewardsManager function pause() external onlyRole(PAUSER_ROLE) { _pause(); @@ -141,34 +139,40 @@ contract RewardsManager is return balances; } - /// @notice Get all claimers as an array (for backward compatibility) + /// @inheritdoc IRewardsManager function claimerFor() external view returns (address[] memory) { return _claimerForSet.values(); } - /// @notice Get number of claimers + /// @inheritdoc IRewardsManager function claimerForLength() external view returns (uint256) { return _claimerForSet.length(); } + /// @inheritdoc IRewardsManager + function unsupportedAssets() external view returns (address[] memory) { + return _unsupportedAssets.values(); + } + + function unsupportedAssetsLength() external view returns (uint256) { + return _unsupportedAssets.length(); + } + // ------------------------------------------------------------------------------ // Internal functions // ------------------------------------------------------------------------------ - /// @dev Process a single claim - transfers all token balances to LAT - /// @dev These values tell us the actual tokens realized by the LAT after a process claim procedure - /// @dev The values may differ from the corresponding EL event due to rounding/transfer loss or unexpected token transfers to this contract - /// @dev We are only concerned with actual value accrued to LAT, exact EL data can be found via corresponding EL events + /// @dev Called by `processClaim` and `processClaims` function _processClaim(IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim) internal { address earner = claim.earnerLeaf.earner; if (!_verifyAndUpdateClaimerFor(earner)) revert NotClaimerFor(earner); // Get unique tokens from the claim IERC20[] memory uniqueTokens = _getUniqueTokensFromClaim(claim); - + // Categorize tokens into supported and unsupported IERC20[] memory allSupportedAssets = liquidTokenManager.getSupportedTokens(); - + IERC20[] memory supportedTokens = new IERC20[](uniqueTokens.length); IERC20[] memory unsupportedTokens = new IERC20[](uniqueTokens.length); uint256 supportedCount = 0; @@ -194,56 +198,51 @@ contract RewardsManager is // Process the claim on EigenLayer rewardsCoordinator.processClaim(claim, address(this)); - // Transfer ALL balances of supported tokens to LiquidToken - uint256[] memory supportedAmounts = new uint256[](supportedCount); - for (uint256 i = 0; i < supportedCount; i++) { - uint256 fullBalance = supportedTokens[i].balanceOf(address(this)); - if (fullBalance > 0) { - supportedTokens[i].safeTransfer(address(liquidToken), fullBalance); - supportedAmounts[i] = fullBalance; - } - } - - // Record full balances for unsupported tokens (not additive - we set the full balance) - uint256[] memory unsupportedAmounts = new uint256[](unsupportedCount); - for (uint256 i = 0; i < unsupportedCount; i++) { - address assetAddr = address(unsupportedTokens[i]); - uint256 fullBalance = unsupportedTokens[i].balanceOf(address(this)); - unsupportedAmounts[i] = fullBalance; - - // Update storage - uint256 oldBalance = unsupportedAssetBalances[assetAddr]; - - // Add to unsupported assets array if new - if (oldBalance == 0 && fullBalance > 0) { - unsupportedAssets.push(assetAddr); - } - - // Set the full balance (not additive) - unsupportedAssetBalances[assetAddr] = fullBalance; - - // Emit event if balance changed - if (oldBalance != fullBalance) { - emit UnsupportedAssetBalanceUpdated(assetAddr, oldBalance, fullBalance); - } - } + // Update balances for unsupported tokens + // These tokens will stay in the contract until `swapAndTransferRewards` is called + uint256[] memory unsupportedAmounts = _setAssetBalances(unsupportedTokens); - // Credit LiquidToken with the transferred amounts - if (supportedCount > 0 && supportedAmounts.length > 0) { - liquidToken.creditAssetBalances(supportedTokens, supportedAmounts); - } + // Transfer all supported assets to `LiquidToken` + uint256[] memory netTransferredAmounts = _transferRewards(supportedTokens); emit RewardsClaimed( claim.rootIndex, earner, supportedTokens, - supportedAmounts, + netTransferredAmounts, unsupportedTokens, unsupportedAmounts ); } + /// @dev Verify and update claimer status for an earner + /// @dev Called by `_processClaim` + function _verifyAndUpdateClaimerFor(address earner) internal returns (bool) { + if (address(earner) == address(0)) revert ZeroAddress(); + + // Check on EL if this contract is set as a claimer for the earner + bool isClaimerOnEl = (rewardsCoordinator.claimerFor(earner) == address(this)); + + // Update claimer storage vars if required + if (isClaimerOnEl) { + if (!isClaimerFor[earner]) { + isClaimerFor[earner] = true; + _claimerForSet.add(earner); + emit ClaimerForAdded(earner); + } + } else { + if (isClaimerFor[earner]) { + isClaimerFor[earner] = false; + _claimerForSet.remove(earner); + emit ClaimerForRemoved(earner); + } + } + + return isClaimerOnEl; + } + /// @dev Get unique tokens from claim to avoid processing duplicates + /// @dev Called by `_processClaim` function _getUniqueTokensFromClaim( IRewardsCoordinatorTypes.RewardsMerkleClaim calldata claim ) internal pure returns (IERC20[] memory) { @@ -272,6 +271,7 @@ contract RewardsManager is } /// @dev Helper to check if token is supported + /// @dev Called by `_processClaim` function _isTokenSupported(IERC20 token, IERC20[] memory supportedTokens) internal pure returns (bool) { for (uint256 i = 0; i < supportedTokens.length; i++) { if (supportedTokens[i] == token) { @@ -282,6 +282,7 @@ contract RewardsManager is } /// @dev Resize token array to actual size + /// @dev Called by `_processClaim` function _resizeTokenArray(IERC20[] memory arr, uint256 newSize) internal pure returns (IERC20[] memory) { IERC20[] memory resized = new IERC20[](newSize); for (uint256 i = 0; i < newSize; i++) { @@ -290,33 +291,56 @@ contract RewardsManager is return resized; } - /// @dev Verify and update claimer status for an earner - function _verifyAndUpdateClaimerFor(address earner) internal returns (bool) { - if (address(earner) == address(0)) revert ZeroAddress(); + /// @dev Called by `_processClaim` + function _setAssetBalances(IERC20[] memory assets) internal returns (uint256[] memory) { + uint256[] memory amounts = new uint256[](assets.length); + for (uint256 i = 0; i < assets.length; i++) { + IERC20 asset = assets[i]; + uint256 oldBalance = unsupportedAssetBalances[address(asset)]; + uint256 newBalance = asset.balanceOf(address(this)); + + if (!_unsupportedAssets.contains(address(asset)) && newBalance > 0) { + // Asset doesn't exist in set yet + _unsupportedAssets.add(address(asset)); + } + if (oldBalance != newBalance) { + unsupportedAssetBalances[address(asset)] = newBalance; + amounts[i] = newBalance; + emit UnsupportedAssetBalanceUpdated(address(asset), oldBalance, newBalance); + } + } - // Check on EL if this contract is set as a claimer for the earner - bool isClaimerOnEl = (rewardsCoordinator.claimerFor(earner) == address(this)); + return amounts; + } - // Update claimer storage vars if required - if (isClaimerOnEl) { - if (!isClaimerFor[earner]) { - isClaimerFor[earner] = true; - _claimerForSet.add(earner); - emit ClaimerForAdded(earner); - } - } else { - if (isClaimerFor[earner]) { - isClaimerFor[earner] = false; - _claimerForSet.remove(earner); - emit ClaimerForRemoved(earner); + /// @dev Called by `_processClaim` + function _transferRewards(IERC20[] memory assets) internal returns (uint256[] memory) { + // Transfer to `LiquidToken` and calculate actual net amounts received + uint256[] memory netTransferredAmounts = new uint256[](assets.length); + + for (uint256 i = 0; i < assets.length; i++) { + IERC20 asset = assets[i]; + uint256 rewardsManagerBalanceBefore = asset.balanceOf(address(this)); + + if (rewardsManagerBalanceBefore > 0) { + uint256 liquidTokenBalanceBefore = asset.balanceOf(address(liquidToken)); + assets[i].safeTransfer(address(liquidToken), rewardsManagerBalanceBefore); + uint256 liquidTokenBalanceAfter = assets[i].balanceOf(address(liquidToken)); + netTransferredAmounts[i] = liquidTokenBalanceAfter - liquidTokenBalanceBefore; + } else { + netTransferredAmounts[i] = 0; } } - return isClaimerOnEl; + // Credit `LiquidToken` asset balances with the actual net amounts recieved + liquidToken.creditAssetBalances(assets, netTransferredAmounts); + + return netTransferredAmounts; } /// @dev Get balance of unsupported asset + /// @dev Called by `balanceAssets` function _balanceAsset(IERC20 asset) internal view returns (uint256) { return unsupportedAssetBalances[address(asset)]; } -} \ No newline at end of file +} diff --git a/src/interfaces/IRewardsManager.sol b/src/interfaces/IRewardsManager.sol index 31b77856..aef1bc4d 100644 --- a/src/interfaces/IRewardsManager.sol +++ b/src/interfaces/IRewardsManager.sol @@ -88,9 +88,25 @@ interface IRewardsManager { /// @return An array of asset balances function balanceAssets(IERC20[] calldata assetList) external view returns (uint256[] memory); + /// @notice Get all claimers as an array + /// @return Array of all claimer addresses + function claimerFor() external view returns (address[] memory); + + /// @notice Get number of claimers + /// @return The total number of claimers + function claimerForLength() external view returns (uint256); + + /// @notice Get all unsupported assets as an array + /// @return Array of all unsupported assets addresses + function unsupportedAssets() external view returns (address[] memory); + + /// @notice Get number of unsupported assets + /// @return The total number of unsupported assets + function unsupportedAssetsLength() external view returns (uint256); + /// @notice Pauses the contract function pause() external; /// @notice Unpauses the contract function unpause() external; -} \ No newline at end of file +} diff --git a/test/RewardsManagerTest.sol b/test/RewardsManagerTest.sol index af43d008..2c433ddd 100644 --- a/test/RewardsManagerTest.sol +++ b/test/RewardsManagerTest.sol @@ -812,4 +812,4 @@ contract RewardsManagerTest is BaseTest { tokenLeaves: tokenLeaves }); } -} \ No newline at end of file +} diff --git a/test/mocks/MockAVSRegistrar.sol b/test/mocks/MockAVSRegistrar.sol new file mode 100644 index 00000000..05063c32 --- /dev/null +++ b/test/mocks/MockAVSRegistrar.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import {IAVSRegistrar} from "eigenlayer-contracts/src/contracts/interfaces/IAVSRegistrar.sol"; + +contract MockAVSRegistrar is IAVSRegistrar { + function registerOperator( + address operator, + address avs, + uint32[] calldata operatorSetIds, + bytes calldata data + ) external override { + // Do nothing - stub implementation + } + + function deregisterOperator(address operator, address avs, uint32[] calldata operatorSetIds) external override { + // Do nothing - stub implementation + } + + function supportsAVS(address avs) external pure override returns (bool) { + return true; + } +} diff --git a/test/mocks/MockRewardsCoordinator.sol b/test/mocks/MockRewardsCoordinator.sol index 83a7c6a6..d43ac2e5 100644 --- a/test/mocks/MockRewardsCoordinator.sol +++ b/test/mocks/MockRewardsCoordinator.sol @@ -27,4 +27,4 @@ contract MockRewardsCoordinator { } } } -} \ No newline at end of file +}