Platform: Code4rena
Start Date: 29/03/2022
Pot Size: $50,000 USDC
Total HM: 16
Participants: 42
Period: 5 days
Judge: 0xean
Total Solo HM: 9
Id: 105
League: ETH
Rank: 9/42
Findings: 4
Award: $2,322.30
🌟 Selected for report: 2
🚀 Solo Findings: 0
560.4852 USDC - $560.49
The README.md
states:
If the user has a Lock, and delegates to someone, then the bonus voting power is not counted.
Accounts are still able to claim bonus voting power even if they delegate to someone else, and any operations that rely on the public functions getPastVotes()
and getPastDelegate()
for the hPAL holder account, as long as they first delegate to another account, then in a new block, delegate to themselves and then to the account to which they ultimately want to delegate. This chain of operations may or may not be intentional, leading to incorrect vote results.
Calling delegate()
always adds a new entry to the delegateCheckpoints[delegator]
array, regardless of the block.number
:
function _delegate(address delegator, address delegatee) internal { // Move delegation from the old delegate to the given delegate address oldDelegatee = delegates[delegator]; uint256 delegatorBalance = balanceOf(delegator); delegates[delegator] = delegatee; // update the the Delegate chekpoint for the delegatee delegateCheckpoints[delegator].push(DelegateCheckpoint(safe32(block.number), delegatee));
Calling delegate()
to the hPAL holder in a new block ensures that it is the first entry in the array with that block.number
. Calling delegate()
in the same block but to another account causes a new entry to be added, but with the same block.number
.
The code that looks up whether an account has been delegated to, only checks that the block number of the entry in the array matches, and does not consider multiple entries with the same block number:
if (delegateCheckpoints[account][mid].fromBlock == blockNumber) { return delegateCheckpoints[account][mid].delegate; }
Code inspection
Update existing delegateCheckpoints[delegator]
if it has the same block number as the current block
#0 - Kogaroshi
2022-04-03T06:56:29Z
Duplicate of https://github.com/code-423n4/2022-03-paladin-findings/issues/20 See in this issue for PR with changes
#1 - 0xean
2022-04-11T15:42:12Z
closing as dupe of #20
🌟 Selected for report: IllIllI
Also found by: 0xDjango, csanuragjain
560.4852 USDC - $560.49
If an account has a large cooldown, that account can grief other accounts that are waiting for their own cooldowns, by sending small amounts to them.
Every transfer to an account increases the cooldown
/** @dev Hook called before any transfer */ function _beforeTokenTransfer( address from, address to, uint256 amount ) internal virtual override { if(from != address(0)) { //check must be skipped on minting // Only allow the balance that is unlocked to be transfered require(amount <= _availableBalanceOf(from), "hPAL: Available balance too low"); } // Update user rewards before any change on their balance (staked and locked) _updateUserRewards(from); uint256 fromCooldown = cooldowns[from]; //If from is address 0x00...0, cooldown is always 0 if(from != to) { // Update user rewards before any change on their balance (staked and locked) _updateUserRewards(to); // => we don't want a self-transfer to double count new claimable rewards // + no need to update the cooldown on a self-transfer uint256 previousToBalance = balanceOf(to); cooldowns[to] = _getNewReceiverCooldown(fromCooldown, amount, to, previousToBalance); }
The amount of the increase is proportional to the sender's cooldown:
// Default new cooldown, weighted average based on the amount and the previous balance return ((amount * _senderCooldown) + (receiverBalance * receiverCooldown)) / (amount + receiverBalance);
Code inspection
Only allow a total of one cooldown increase when the sender is not the recipient
#0 - Kogaroshi
2022-04-03T06:53:35Z
As explained in the documentation & the comments for this method, this is required to prevent users to game the system and unstake by skipping the cooldown period. As stated in another Issue of the same kind, this type of behavior, to have an impact on another user cooldown, would require to send an amount of token consequent compared to the receiver balance, acting as a "financial safeguard" against this type of scenario being used frequently.
For another example of this logic, see the stkAAVE system, using the same logic and the same cooldown imapct calculation on transfers
#1 - 0xean
2022-04-11T12:32:16Z
2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
I am going to side with the warden here. I do see the sponsors argument that this attack is expensive to execute, but is certainly feasible. I think this qualifies as a hypothetical attack path with stated assumptions, but external requirements
. The external requirements being someone with enough malice to waste their own money to do so.
While there may not be an easy solution to solve this, it's still a valid risk to raise and for the sponsors to (potentially) disclose to users if there is in fact no way to mitigate it without undesired side effects.
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0xDjango, 0xkatana, 0xmint, Czar102, Dravee, Funen, Ruhum, TerrierLover, defsec, gzeon, hake, hyh, jah, kenta, minhquanym, okkothejawa, pmerkleplant, rayn, reassor, robee, sorrynotsorry, sseefried, teryanarmen
997.7719 USDC - $997.77
PaladinRewardReserve
's approvals break if the same contract is in charge of two tokens (e.g. a PalPool)The approvedSpenders
mapping only takes in a spender, rather than both a spender and a token. Approval for one token means approval for all tokens the account controls. Removal for one means removal for all.
function setNewSpender(address token, address spender, uint256 amount) external onlyOwner { require(!approvedSpenders[spender], "Already Spender"); approvedSpenders[spender] = true; IERC20(token).safeApprove(spender, amount);
function updateSpenderAllowance(address token, address spender, uint256 amount) external onlyOwner { require(approvedSpenders[spender], "Not approved Spender");
function removeSpender(address token, address spender) external onlyOwner { require(approvedSpenders[spender], "Not approved Spender"); approvedSpenders[spender] = false;
require()
/revert()
statements should have descriptive reason stringsrequire(palToken != address(0));
require(_admin != address(0));
require(user != address(0)); //Never supposed to happen, but security check
require(user != address(0)); //Never supposed to happen, but security check
constant
s should be defined rather than using magic numbersif(newKickRatioPerWeek == 0 || newKickRatioPerWeek > 5000) revert IncorrectParameters();
nonReentrant
modifier
should occur before all other modifiersThis is a best-practice to protect against reentrancy in other modifiers
function transferToken(address token, address receiver, uint256 amount) external onlyOwner nonReentrant {
safeApprove()
is deprecatedDeprecated in favor of safeIncreaseAllowance()
and safeDecreaseAllowance()
IERC20(token).safeApprove(spender, amount);
IERC20(token).safeApprove(spender, 0);
IERC20(token).safeApprove(spender, amount);
IERC20(token).safeApprove(spender, 0);
address
mappings can be combined into a single mapping
of an address
to a struct
, where appropriatemapping(address => address) public delegates; /** @notice List of Vote checkpoints for each user */ mapping(address => Checkpoint[]) public checkpoints; /** @notice List of Delegate checkpoints for each user */ mapping(address => DelegateCheckpoint[]) public delegateCheckpoints;
mapping(address => uint256) public userRewardIndex; /** @notice Current amount of rewards claimable for the user */ mapping(address => uint256) public claimableRewards; /** @notice Timestamp of last update for user rewards */ mapping(address => uint256) public rewardsLastUpdate;
mapping(address => uint256) public userCurrentBonusRatio; /** @notice Value by which user Bonus Ratio decrease each second */ mapping(address => uint256) public userBonusRatioDecrease;
pragma solidity ^0.8.10;
pragma solidity ^0.8.4;
pragma solidity ^0.8.10;
pragma solidity ^0.8.4;
uint256 public constant WEEK = 604800; /** @notice Seconds in a Month */ uint256 public constant MONTH = 2629800; /** @notice 1e18 scale */ uint256 public constant UNIT = 1e18; /** @notice Max BPS value (100%) */ uint256 public constant MAX_BPS = 10000; /** @notice Seconds in a Year */ uint256 public constant ONE_YEAR = 31557600; /** @notice Period to wait before unstaking tokens */ uint256 public constant COOLDOWN_PERIOD = 864000; // 10 days /** @notice Duration of the unstaking period After that period, unstaking cooldown is expired */ uint256 public constant UNSTAKE_PERIOD = 432000; // 5 days /** @notice Period to unlock/re-lock tokens without possibility of punishement */ uint256 public constant UNLOCK_DELAY = 1209600; // 2 weeks /** @notice Minimum duration of a Lock */ uint256 public constant MIN_LOCK_DURATION = 7889400; // 3 months /** @notice Maximum duration of a Lock */ uint256 public constant MAX_LOCK_DURATION = 63115200; // 2 years
/** @notice Period to unlock/re-lock tokens without possibility of punishement */
punishement
/** @notice Struct trancking the total amount locked */
trancking
/** @notice Timstamp of last update for global reward index */
Timstamp
/** @notice Amount of rewards distriubted per second at the start */
distriubted
* @param amount amount ot withdraw
ot
// If the user does not deelegate currently, automatically self-delegate
deelegate
* @param receiver address fo the receiver
fo
// Find the user available balance (staked - locked) => the balance that can be transfered
transfered
// (using avaialable balance to count the locked balance with the multiplier later in this function)
avaialable
// a ratio based on the rpevious one and the newly calculated one
rpevious
// update the the Delegate chekpoint for the delegatee
chekpoint
/** @notice Emitted when the allowance of a spander is updated */
spander
indexed
fieldsEach event
should use three indexed
fields if there are three or more fields
event Stake(address indexed user, uint256 amount);
event Unstake(address indexed user, uint256 amount);
event Unlock(address indexed user, uint256 amount, uint256 totalLocked);
event Kick(address indexed user, address indexed kicker, uint256 amount, uint256 penalty, uint256 totalLocked);
event ClaimRewards(address indexed user, uint256 amount);
event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance);
event EmergencyUnstake(address indexed user, uint256 amount);
event NewSpender(address indexed token, address indexed spender, uint256 amount);
event UpdateSpender(address indexed token, address indexed spender, uint256 amount);
🌟 Selected for report: TerrierLover
Also found by: 0v3rf10w, 0xDjango, 0xNazgul, 0xkatana, 0xmint, Cityscape, Czar102, Dravee, Funen, IllIllI, Tomio, antonttc, defsec, gzeon, hake, kenta, minhquanym, pmerkleplant, rayn, rfa, robee, saian, securerodd, teryanarmen
203.5455 USDC - $203.55
uint256 high = nbCheckpoints - 1; // last checkpoint already checked uint256 low = 0; uint256 mid; while (low < high) { mid = Math.average(low, high); if (userLocks[account][mid].fromBlock == blockNumber) { return userLocks[account][mid]; } if (userLocks[account][mid].fromBlock > blockNumber) { high = mid; } else { low = mid + 1; } } return high == 0 ? emptyLock : userLocks[account][high - 1];
uint256 high = nbCheckpoints - 1; // last checkpoint already checked uint256 low = 0; uint256 mid; while (low < high) { mid = Math.average(low, high); if (checkpoints[account][mid].fromBlock == blockNumber) { return checkpoints[account][mid].votes; } if (checkpoints[account][mid].fromBlock > blockNumber) { high = mid; } else { low = mid + 1; } } return high == 0 ? 0 : checkpoints[account][high - 1].votes;
uint256 high = nbCheckpoints - 1; // last checkpoint already checked uint256 low = 0; uint256 mid; while (low < high) { mid = Math.average(low, high); if (delegateCheckpoints[account][mid].fromBlock == blockNumber) { return delegateCheckpoints[account][mid].delegate; } if (delegateCheckpoints[account][mid].fromBlock > blockNumber) { high = mid; } else { low = mid + 1; } } return high == 0 ? address(0) : delegateCheckpoints[account][high - 1].delegate;
Use a solidity version of at least 0.8.10 to have external calls skip contract existence checks if the external call has a return value
pragma solidity ^0.8.4;
bool
s for storage incurs overhead// Booleans are more expensive than uint256 or any type that takes up a full // word because each write operation emits an extra SLOAD to first read the // slot's contents, replace the bits taken up by the boolean, and then write // back. This is the compiler's defense against contract upgrades and // pointer aliasing, and it cannot be disabled.
bool public emergency = false;
mapping(address => bool) public approvedSpenders;
> 0
costs more gas than != 0
when used on a uint
in a require()
statementrequire(amount > 0, "hPAL: incorrect amount");
require(amount > 0, "hPAL: Null amount");
require(amount > 0, "hPAL: Null amount");
require(amount > 0, "hPAL: Null amount");
uint256 low = 0;
uint256 low = 0;
uint256 userLockedBalance = 0;
uint256 lockingRewards = 0;
uint256 low = 0;
uint256 low = 0;
uint256 low = 0;
internal
functions only called once can be inlined to save gasfunction _updateDropPerSecond() internal returns (uint256){
function _getPastVotes(address account, uint256 blockNumber) internal view returns (uint256){
function _unstake(address user, uint256 amount, address receiver) internal returns(uint256) {
function _unlock(address user) internal {
function _kick(address user, address kicker) internal {
internal
functions not called by the contract should be removed to save deployment gasfunction _getPastDelegate(address account, uint256 blockNumber) internal view returns (address) {
The instances below point to the second access of a state variable within a function. Less obvious optimizations include having local storage variables of mappings within state variable mappings or mappings within state variable structs, having local storage variables of structs within mappings, or having local caches of state variable contracts/addresses.
uint256 currentUserLockIndex = userLocks[msg.sender].length - 1;
uint256 currentUserLockIndex = userLocks[msg.sender].length - 1;
uint256 currentUserLockIndex = userLocks[msg.sender].length - 1;
uint256 lastUserLockIndex = userLocks[user].length - 1;
uint256 lastUserLockIndex = userLocks[user].length - 1;
uint256 lockAmount = userLocks[user][nbLocks - 1].amount;
uint256 lastUserLockIndex = userLocks[user].length - 1;
vars.lastUserLockIndex = userLocks[user].length - 1;
if (userLocks[account][nbCheckpoints - 1].fromBlock <= blockNumber) {
userLocks[user].push(UserLock(
uint256 currentUserLockIndex = userLocks[user].length - 1;
uint256 currentUserLockIndex = userLocks[user].length - 1;
uint256 currentUserLockIndex = userLocks[msg.sender].length - 1;
safe224(currentTotalLocked),
safe224(currentTotalLocked),
safe224(currentTotalLocked),
safe224(currentTotalLocked),
return totalLocks[totalLocks.length - 1];
if (totalLocks[nbCheckpoints - 1].fromBlock <= blockNumber) {
totalLocks.push(TotalLock(
_moveDelegates(delegates[from], delegates[to], amount);
uint256 currentVotes = nbCheckpoints == 0 ? 0 : checkpoints[user][nbCheckpoints - 1].votes;
if (checkpoints[account][nbCheckpoints - 1].fromBlock <= blockNumber) {
uint256 oldVotes = nbCheckpoints == 0 ? 0 : checkpoints[from][nbCheckpoints - 1].votes;
if (pos > 0 && checkpoints[delegatee][pos - 1].fromBlock == block.number) {
if (delegateCheckpoints[account][nbCheckpoints - 1].fromBlock <= blockNumber) {
if (delegateCheckpoints[account][nbCheckpoints - 1].fromBlock <= blockNumber) {
currentDropPerSecond = endDropPerSecond;
if(block.timestamp < lastDropUpdate + MONTH) return currentDropPerSecond; // Update it once a month
if(block.timestamp < lastDropUpdate + MONTH) return currentDropPerSecond; // Update it once a month
uint256 dropDecreasePerMonth = (startDropPerSecond - endDropPerSecond) / (dropDecreaseDuration / MONTH);
uint256 claimAmount = amount < claimableRewards[msg.sender] ? amount : claimableRewards[msg.sender];
uint256 estimatedClaimableRewards = claimableRewards[user];
userBonusRatioDecrease[user] = (userLockBonusRatio - baseLockBonusRatio) / duration;
uint256 userLockBonusRatio = minLockBonusRatio + (((maxLockBonusRatio - minLockBonusRatio) * durationRatio) / UNIT);
uint256 userLockBonusRatio = minLockBonusRatio + (((maxLockBonusRatio - minLockBonusRatio) * durationRatio) / UNIT);
require()
statements that use &&
saves gasSee this issue for an example
require(user != address(0) && kicker != address(0), "hPAL: Address Zero");
uints
/ints
smaller than 32 bytes (256 bits) incurs overheadWhen using elements that are smaller than 32 bytes, your contract’s gas usage may be higher. This is because the EVM operates on 32 bytes at a time. Therefore, if the element is smaller than that, the EVM must use more operations in order to reduce the size of the element from 32 bytes to the desired size.
https://docs.soliditylang.org/en/v0.8.11/internals/layout_in_storage.html Use a larger size then downcast where needed
uint128 amount; // safe because PAL max supply is 10M tokens
uint48 startTimestamp;
uint48 duration;
uint32 fromBlock; // because we want to search by block number
uint224 total;
uint32 fromBlock;
uint32 fromBlock;
uint224 votes;
uint32 fromBlock;
uint32 blockNumber = safe32(block.number);
function safe32(uint n) internal pure returns (uint32) {
function safe48(uint n) internal pure returns (uint48) {
function safe128(uint n) internal pure returns (uint128) {
function safe224(uint n) internal pure returns (uint224) {
private
rather than public
for constants, saves gasIf needed, the value can be read from the verified contract source code
uint256 public constant WEEK = 604800;
uint256 public constant MONTH = 2629800;
uint256 public constant UNIT = 1e18;
uint256 public constant MAX_BPS = 10000;
uint256 public constant ONE_YEAR = 31557600;
uint256 public constant COOLDOWN_PERIOD = 864000; // 10 days
uint256 public constant UNSTAKE_PERIOD = 432000; // 5 days
uint256 public constant UNLOCK_DELAY = 1209600; // 2 weeks
uint256 public constant MIN_LOCK_DURATION = 7889400; // 3 months
uint256 public constant MAX_LOCK_DURATION = 63115200; // 2 years
uint256 public immutable startDropPerSecond;
uint256 public immutable dropDecreaseDuration;
uint256 public immutable startDropTimestamp;
uint256 public immutable baseLockBonusRatio;
uint256 public immutable minLockBonusRatio;
uint256 public immutable maxLockBonusRatio;
require()
/revert()
checks should be refactored to a modifier or functionrequire(userLocks[msg.sender].length != 0, "hPAL: No Lock");
require( blockNumber < block.number, "hPAL: invalid blockNumber" );
require(amount > 0, "hPAL: Null amount");
require(receiver != address(0), "hPAL: Address Zero");
require(user != address(0)); //Never supposed to happen, but security check
require(userLocks[user].length > 0, "hPAL: No Lock");
require(block.timestamp > userCurrentLockEnd, "hPAL: Not expired");
require(currentUserLock.amount > 0, "hPAL: No Lock");
require(approvedSpenders[spender], "Not approved Spender");
<x> * 2
is equivalent to <x> << 1
and <x> / 2
is the same as <x> >> 1
vars.periodBonusRatio = newBonusRatio + ((vars.userRatioDecrease + vars.bonusRatioDecrease) / 2);
require()
or revert()
statements that check input arguments should be at the top of the functionChecks that involve constants should come before checks that involve state variables
require(msg.sender != user, "hPAL: cannot kick yourself");
require(amount > 0, "hPAL: incorrect amount");
require(duration >= MIN_LOCK_DURATION, "hPAL: Lock duration under min");
require(duration <= MAX_LOCK_DURATION, "hPAL: Lock duration over max");
require(amount > 0, "hPAL: Null amount");
require(receiver != address(0), "hPAL: Address Zero");
revert()
/require()
strings to save deployment gaspayable
If a function modifier such as onlyOwner
is used, the function will revert if a normal user tries to pay the function. Marking the function as payable
will lower the gas cost for legitimate callers because the compiler will not include checks for whether a payment was provided.
function setKickRatio(uint256 newKickRatioPerWeek) external onlyOwner {
function triggerEmergencyWithdraw(bool trigger) external onlyOwner {
function setEndDropPerSecond(uint256 newEndDropPerSecond) external onlyOwner {
function setNewSpender(address token, address spender, uint256 amount) external onlyOwner {
function updateSpenderAllowance(address token, address spender, uint256 amount) external onlyOwner {
function removeSpender(address token, address spender) external onlyOwner {
function transferToken(address token, address receiver, uint256 amount) external onlyOwner nonReentrant {
return( balanceOf(user), 0, balanceOf(user) );
return( balanceOf(user), uint256(userLocks[user][lastUserLockIndex].amount), balanceOf(user) - uint256(userLocks[user][lastUserLockIndex].amount) );
return( balanceOf(user), 0, balanceOf(user) );
return( balanceOf(user), uint256(userLocks[user][lastUserLockIndex].amount), balanceOf(user) - uint256(userLocks[user][lastUserLockIndex].amount) );
return( balanceOf(user), 0, balanceOf(user) );
return( balanceOf(user), uint256(userLocks[user][lastUserLockIndex].amount), balanceOf(user) - uint256(userLocks[user][lastUserLockIndex].amount) );
#0 - Kogaroshi
2022-04-03T07:00:41Z
QA & gas optimizations changes are done in the PR: https://github.com/PaladinFinance/Paladin-Tokenomics/pull/6 (some changes/tips were implemented, others are noted but won't be applied)