MorpheusAI

MorpheusAI
Foundry
22,500 USDC
View results
Submission Details
Severity: medium
Invalid

`Distribution::claim` `pendingRewards_` can get updated with undesirable pool rate

Summary

Distribution::claim function allows users to claim rewards on behalf of other users. The pending rewards are calculated at the time of the function call using the currentPoolRate_ value obtained with the _getCurrentPoolRate() function.

https://github.com/Cyfrin/2024-01-Morpheus/blob/main/contracts/Distribution.sol#L299

return poolData.rate + (rewards_ * PRECISION) / poolData.totalDeposited;

The return value of the _getCurrentPoolRate() function depends on the totalDeposited value of the pool. A user, "user1" may want to wait until the totalDeposited value is lower to claim rewards, as the rewards will be higher. However, any other user can call the claim function on behalf of "user1" at a time when the currentPoolRate_ is not favorable for "user1".

Vulnerability Details

The following two test cases demonstrate how the rewards are different in a scenario where "user1" claims his rewards at the time of his choosing and a scenario where "user2" claims the rewards on behalf of "user1".

User1 claims at a time of his choosing
function testClaimNormal() public createPool {
uint256 poolId = 0;
vm.warp(2 hours);
vm.roll(2 hours);
vm.deal(user1, 1000 ether);
vm.deal(user2, 1000 ether);
vm.prank(user2); // user2 stakes
(bool success,) =
address(distributionProxy).call(abi.encodeWithSelector(Distribution.stake.selector, poolId, 1e18));
vm.startPrank(user1); // user1 stakes
(success,) = address(distributionProxy).call(abi.encodeWithSelector(Distribution.stake.selector, poolId, 1e18));
vm.warp(2 days);
vm.roll(2 days);
// "user1" claims for the first time
(success,) = address(distributionProxy).call{value: 0.5 ether}(
abi.encodeWithSelector(Distribution.claim.selector, poolId, user1)
);
console.log("balance of user1, first claim %s", mor.balanceOf(user1));
vm.stopPrank();
//user2 withdraws
vm.prank(user2);
(success,) =
address(distributionProxy).call(abi.encodeWithSelector(Distribution.withdraw.selector, poolId, 1e18));
//claim after 1 day, "user1" waited for a more favorable rate
vm.warp(2 days + 1 days);
vm.roll(2 days + 1 days);
//user1 claims
vm.startPrank(user1);
(success,) = address(distributionProxy).call{value: 0.5 ether}(
abi.encodeWithSelector(Distribution.claim.selector, poolId, user1)
);
console.log("balance of user1, second claim %s", mor.balanceOf(user1));
}
User2 claims on behalf of User1
function testClaimOnBehalf() public createPool {
uint256 poolId = 0;
vm.warp(2 hours);
vm.roll(2 hours);
vm.deal(user1, 1000 ether);
vm.deal(user2, 1000 ether);
vm.prank(user2); // user2 stakes
(bool success,) =
address(distributionProxy).call(abi.encodeWithSelector(Distribution.stake.selector, poolId, 1e18));
vm.startPrank(user1); // user1 stakes
(success,) = address(distributionProxy).call(abi.encodeWithSelector(Distribution.stake.selector, poolId, 1e18));
vm.warp(2 days);
vm.roll(2 days);
// "user1" claims for the first time
(success,) = address(distributionProxy).call{value: 0.5 ether}(
abi.encodeWithSelector(Distribution.claim.selector, poolId, user1)
);
console.log("balance of user1, first claim %s", mor.balanceOf(user1));
vm.stopPrank();
vm.startPrank(user2);
//user2 claims after 1 day on behalf of user1
vm.warp(2 days + 1 days);
vm.roll(2 days + 1 days);
(success,) = address(distributionProxy).call{value: 0.5 ether}(
abi.encodeWithSelector(Distribution.claim.selector, poolId, user1)
);
console.log("balance of user1, second claim %s", mor.balanceOf(user1));
}

Logs

Running 2 tests for test/DistributionTest.t.sol:DistributionTest
[PASS] testClaimNormal() (gas: 838197)
Logs:
balance of user1, first claim 50000000000000000000
balance of user1, second claim 148000000000000000000
[PASS] testClaimOnBehalf() (gas: 803711)
Logs:
balance of user1, first claim 50000000000000000000
balance of user1, second claim 99000000000000000000

To reproduce the tests, copy paste the full test in a new file (DistributionTest.t.sol) and run forge test -vv in the terminal.

To install foundry:

inside 2024-01-Morpheus folder, run:

npm i --save-dev @nomicfoundation/hardhat-foundry - Install the hardhat-foundry plugin.
Add require("@nomicfoundation/hardhat-foundry"); to the top of the hardhat.config.js file.
Run npx hardhat init-foundry in the terminal.

Full test file
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {Distribution} from "../contracts/Distribution.sol";
import {IDistribution} from "../contracts/interfaces/IDistribution.sol";
import {LinearDistributionIntervalDecrease} from "../contracts/libs/LinearDistributionIntervalDecrease.sol";
import {MOR} from "../contracts/MOR.sol";
import {StETHMock} from "../contracts/mock/tokens/StETHMock.sol";
import {WStETHMock} from "../contracts/mock/tokens/WStETHMock.sol";
import {LZEndpointMock} from "@layerzerolabs/solidity-examples/contracts/lzApp/mocks/LZEndpointMock.sol";
import {L1Sender} from "../contracts/L1Sender.sol";
import {IL1Sender} from "../contracts/interfaces/IL1Sender.sol";
import {L2MessageReceiver} from "../contracts/L2MessageReceiver.sol";
import {IL2MessageReceiver} from "../contracts/interfaces/IL2MessageReceiver.sol";
import {L2TokenReceiver} from "../contracts/L2TokenReceiver.sol";
import {IL2TokenReceiver} from "../contracts/interfaces/IL2TokenReceiver.sol";
import {GatewayRouterMock} from "../contracts/mock/GatewayRouterMock.sol";
import {SwapRouterMock} from "../contracts/mock/SwapRouterMock.sol";
import {NonfungiblePositionManagerMock} from "../contracts/mock/NonfungiblePositionManagerMock.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
contract DistributionTest is Test {
Distribution distribution;
MOR mor;
StETHMock stETH;
WStETHMock wstETH;
LZEndpointMock lZEndpointMockSender;
LZEndpointMock lZEndpointMockReceiver;
L1Sender l1Sender;
L2MessageReceiver l2MessageReceiver;
L2TokenReceiver l2TokenReceiver;
NonfungiblePositionManagerMock nonfungiblePositionManager;
SwapRouterMock swapRouterMock;
GatewayRouterMock gatewayRouter;
ERC1967Proxy l2MessageReceiverProxy;
ERC1967Proxy l2TokenReceiverProxy;
ERC1967Proxy l1SenderProxy;
ERC1967Proxy distributionProxy;
uint16 public senderChainId = 101;
uint16 public receiverChainId = 110;
address public owner = makeAddr("owner");
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public rewardToken;
address public depositToken;
address public l2TokenReceiverImplementation;
address public l2MessageReceiverImplementation;
address public l1SenderImplementation;
function setUp() public {
vm.warp(1 hours);
distribution = new Distribution();
mor = new MOR(1000000000 ether);
rewardToken = address(mor);
stETH = new StETHMock();
depositToken = address(stETH);
wstETH = new WStETHMock(depositToken);
lZEndpointMockSender = new LZEndpointMock(senderChainId);
lZEndpointMockReceiver = new LZEndpointMock(receiverChainId);
l1Sender = new L1Sender();
l2MessageReceiver = new L2MessageReceiver();
l2TokenReceiver = new L2TokenReceiver();
l2TokenReceiverImplementation = address(l2TokenReceiver);
l2MessageReceiverImplementation = address(l2MessageReceiver);
l1SenderImplementation = address(l1Sender);
swapRouterMock = new SwapRouterMock();
nonfungiblePositionManager = new NonfungiblePositionManagerMock();
gatewayRouter = new GatewayRouterMock();
Distribution.Pool[] memory pools;
L2TokenReceiver.SwapParams memory swapParams = IL2TokenReceiver.SwapParams({
tokenIn: depositToken,
tokenOut: depositToken,
fee: 3000,
sqrtPriceLimitX96: 0
});
l2MessageReceiverProxy =
new ERC1967Proxy(l2MessageReceiverImplementation, abi.encodeWithSignature("L2MessageReceiver__init()"));
l2TokenReceiverProxy = new ERC1967Proxy(
l2TokenReceiverImplementation,
abi.encodeWithSignature(
"L2TokenReceiver__init(address,address,(address,address,uint24,uint160))",
address(swapRouterMock),
address(nonfungiblePositionManager),
swapParams
)
);
IL1Sender.RewardTokenConfig memory rewardTokenConfig = IL1Sender.RewardTokenConfig({
gateway: address(lZEndpointMockSender),
receiver: address(l2MessageReceiverProxy),
receiverChainId: receiverChainId
});
IL1Sender.DepositTokenConfig memory depositTokenConfig = IL1Sender.DepositTokenConfig({
token: address(wstETH),
gateway: address(gatewayRouter),
receiver: address(l2TokenReceiverProxy)
});
distributionProxy = new ERC1967Proxy(address(distribution), bytes(""));
l1SenderProxy = new ERC1967Proxy(
l1SenderImplementation,
abi.encodeWithSignature(
"L1Sender__init(address,(address,address,uint16),(address,address,address))",
address(distributionProxy),
rewardTokenConfig,
depositTokenConfig
)
);
(bool success,) = address(distributionProxy).call(
abi.encodeWithSelector(Distribution.Distribution_init.selector, depositToken, address(l1SenderProxy), pools)
);
mor.transferOwnership(address(l2MessageReceiverProxy));
L2MessageReceiver.Config memory config = IL2MessageReceiver.Config({
gateway: address(lZEndpointMockReceiver),
sender: address(l1SenderProxy),
senderChainId: senderChainId
});
(success,) = address(l2MessageReceiverProxy).call(
abi.encodeWithSelector(IL2MessageReceiver.setParams.selector, rewardToken, config)
);
lZEndpointMockSender.setDestLzEndpoint(address(l2MessageReceiverProxy), address(lZEndpointMockReceiver));
stETH.mint(owner, 1000e18);
stETH.mint(user1, 1000e18);
stETH.mint(user2, 1000e18);
stETH.approve(address(distributionProxy), 1000e18);
vm.prank(user1);
stETH.approve(address(distributionProxy), 1000e18);
vm.prank(user2);
stETH.approve(address(distributionProxy), 1000e18);
(success,) = address(l1SenderProxy).call(
abi.encodeWithSelector(l1Sender.transferOwnership.selector, address(distributionProxy))
);
}
modifier createPool() {
uint256 poolId = 0;
Distribution.Pool memory pool = IDistribution.Pool({
payoutStart: 1 days,
decreaseInterval: 1 days,
withdrawLockPeriod: 12 hours,
claimLockPeriod: 12 hours,
withdrawLockPeriodAfterStake: 1 days,
initialReward: 100e18,
rewardDecrease: 2e18,
minimalStake: 0.1e18,
isPublic: true
});
(bool success,) =
address(distributionProxy).call(abi.encodeWithSelector(Distribution.createPool.selector, pool));
_;
}
function testClaimOnBehalf() public createPool {
uint256 poolId = 0;
vm.warp(2 hours);
vm.roll(2 hours);
vm.deal(user1, 1000 ether);
vm.deal(user2, 1000 ether);
vm.prank(user2); // user2 stakes
(bool success,) =
address(distributionProxy).call(abi.encodeWithSelector(Distribution.stake.selector, poolId, 1e18));
vm.startPrank(user1); // user1 stakes
(success,) = address(distributionProxy).call(abi.encodeWithSelector(Distribution.stake.selector, poolId, 1e18));
vm.warp(2 days);
vm.roll(2 days);
// "user1" claims for the first time
(success,) = address(distributionProxy).call{value: 0.5 ether}(
abi.encodeWithSelector(Distribution.claim.selector, poolId, user1)
);
console.log("balance of user1, first claim %s", mor.balanceOf(user1));
vm.stopPrank();
vm.startPrank(user2);
//user2 claims after 1 day on behalf of user1
vm.warp(2 days + 1 days);
vm.roll(2 days + 1 days);
(success,) = address(distributionProxy).call{value: 0.5 ether}(
abi.encodeWithSelector(Distribution.claim.selector, poolId, user1)
);
console.log("balance of user1, second claim %s", mor.balanceOf(user1));
}
function testClaimNormal() public createPool {
uint256 poolId = 0;
vm.warp(2 hours);
vm.roll(2 hours);
vm.deal(user1, 1000 ether);
vm.deal(user2, 1000 ether);
vm.prank(user2); // user2 stakes
(bool success,) =
address(distributionProxy).call(abi.encodeWithSelector(Distribution.stake.selector, poolId, 1e18));
vm.startPrank(user1); // user1 stakes
(success,) = address(distributionProxy).call(abi.encodeWithSelector(Distribution.stake.selector, poolId, 1e18));
vm.warp(2 days);
vm.roll(2 days);
// "user1" claims for the first time
(success,) = address(distributionProxy).call{value: 0.5 ether}(
abi.encodeWithSelector(Distribution.claim.selector, poolId, user1)
);
console.log("balance of user1, first claim %s", mor.balanceOf(user1));
vm.stopPrank();
//user2 withdraws
vm.prank(user2);
(success,) =
address(distributionProxy).call(abi.encodeWithSelector(Distribution.withdraw.selector, poolId, 1e18));
//claim after 1 day, "user1" waited for a more favorable rate
vm.warp(2 days + 1 days);
vm.roll(2 days + 1 days);
//user1 claims
vm.startPrank(user1);
(success,) = address(distributionProxy).call{value: 0.5 ether}(
abi.encodeWithSelector(Distribution.claim.selector, poolId, user1)
);
console.log("balance of user1, second claim %s", mor.balanceOf(user1));
}
}

Impact

Users can get rewards at a unfavorable rate if another user claims the rewards on their behalf.

Tools Used

Manual review

Recommendations

  1. Consider removing the user_ parameter from the claim() function and using msg.sender instead.

  2. If there is need to allow users or contracts to claim rewards on behalf of others, consider implementing a mechanism to allow users to approve specific addresses to claim rewards on their behalf.

Updates

Lead Judging Commences

inallhonesty Lead Judge
over 1 year ago
inallhonesty Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.