Platform: Code4rena
Start Date: 27/10/2022
Pot Size: $33,500 USDC
Total HM: 8
Participants: 96
Period: 3 days
Judge: kirk-baird
Total Solo HM: 1
Id: 176
League: ETH
Rank: 3/96
Findings: 5
Award: $3,013.36
π Selected for report: 1
π Solo Findings: 0
π Selected for report: ladboy233
Also found by: 0x52, 0xDecorativePineapple, 0xhunter, Aymen0909, Bnke0x0, Dravee, JTJabba, Jeiwan, Lambda, Nyx, Picodes, Trust, cccz, cryptonue, csanuragjain, dic0de, hansfriese, horsefacts, imare, minhtrng, pashov, peritoflores, rbserver, rvierdiiev, wagmi, yixxas
9.9073 USDC - $9.91
https://github.com/code-423n4/2022-10-paladin/blob/d6d0c0e57ad80f15e9691086c9c7270d4ccfe0e6/contracts/WardenPledge.sol#L653 https://github.com/code-423n4/2022-10-paladin/blob/d6d0c0e57ad80f15e9691086c9c7270d4ccfe0e6/contracts/WardenPledge.sol#L585
WardenPledge contract has a sweeping function (recoverERC20) to handle mistakenly sent ERC20 tokens:
function recoverERC20(address token) external onlyOwner returns(bool) { if(minAmountRewardToken[token] != 0) revert Errors.CannotRecoverToken(); uint256 amount = IERC20(token).balanceOf(address(this)); if(amount == 0) revert Errors.NullValue(); IERC20(token).safeTransfer(owner(), amount); return true; }
The code will not allow registered reward tokens to be transfered due to the rugging potential. It checks if minAmountRewardToken[token] != 0
, which is True for registered tokens.
However, owner can easily bypass this check using removeRewardToken function:
function removeRewardToken(address token) external onlyOwner { if(token == address(0)) revert Errors.ZeroAddress(); if(minAmountRewardToken[token] == 0) revert Errors.NotAllowedToken(); minAmountRewardToken[token] = 0; emit RemoveRewardToken(token); }
This function sets any minAmountRewardToken[token]
that is not 0, to 0.
Therefore, owner can instantly call:
It's very important to not allow owner such dangerous operation because:
Owner can steal all ERC20 tokens including rewards, of the contract.
Manual audit
This threat can be fully mitigated using these steps:
mapping(IERC20 => bool) public disabledTokens;
state memberdisabledTokens[token] == False
disabledTokens[token] == True
This solution will limit only reward tokens from being claimed by the owner, which is the desired behavior.
#0 - Kogaroshi
2022-10-31T00:27:52Z
Duplicate of #17
#1 - c4-judge
2022-11-10T06:41:04Z
kirk-baird marked the issue as not a duplicate
#2 - c4-judge
2022-11-10T06:41:13Z
kirk-baird marked the issue as duplicate
#3 - c4-judge
2022-11-10T21:19:55Z
kirk-baird marked the issue as satisfactory
#4 - c4-judge
2022-11-10T21:20:00Z
kirk-baird marked the issue as not a duplicate
#5 - c4-judge
2022-11-10T21:20:06Z
kirk-baird marked the issue as duplicate of #17
#6 - c4-judge
2022-12-06T17:32:42Z
Simon-Busch marked the issue as duplicate of #68
314.3177 USDC - $314.32
In Warden Pledge, creators can extend the life span of an existing pledge using extendPledge
.
Here's the implementation:
uint256 addedDuration = newEndTimestamp - oldEndTimestamp; if(addedDuration < minDelegationTime) revert Errors.DurationTooShort(); uint256 totalRewardAmount = (pledgeParams.rewardPerVote * pledgeParams.votesDifference * addedDuration) / UNIT; uint256 feeAmount = (totalRewardAmount * protocalFeeRatio) / MAX_PCT ; if(totalRewardAmount > maxTotalRewardAmount) revert Errors.IncorrectMaxTotalRewardAmount(); if(feeAmount > maxFeeAmount) revert Errors.IncorrectMaxFeeAmount();
The issue is that totalRewardAmount, and more importantly, the feeAmount, are generated using the previously calculated votesDifference. It was defined in createPledge as:
vars.votesDifference = targetVotes - votingEscrow.balanceOf(receiver);
votesDifference
was used correctly in createPledge because during creation time, theoretically the reward amount required to pay a fulfillement until the end depends on amount of votes to transfer, which is targetVotes - votingEscrow.balanceOf(receiver);
When extending the pledge, it is entirely reasonable that balanceOf(receiver)
is much higher than before, and therefore the maximum votes that could be transferred while still under targetVotes is much lower. Since fee % is taken linearly from totalRewardAmount, it is also unrealistically high.
Protocol demands much more reward amount and fees than correct amount.
uint256 totalRewardAmount = (pledgeParams.rewardPerVote * pledgeParams.votesDifference * addedDuration) / UNIT;
Actual calculation:
totalRewardAmount = 10 * 100 * (9 * 86400) / 1e18
Correct calculation:
totalRewardAmount = 10 * 20 * (9 * 86400) / 1e18
The total is 5 X more than it should be. Pledgers cannot theoretically pledge to Bob 100 votes, because his 180 current balance will only lower very gradually.
Fee amount will also be 5x higher than should be:
uint256 feeAmount = (totalRewardAmount * protocalFeeRatio) / MAX_PCT ;
Manual audit
voteDifference should be supplied as a parameter to the extendPledge / increasePledgeRewardPerVote APIs. Creator pays linearly to voteDifference, so it does not introduce any threates to give control to user of this parameter, which is effectively set in stone in createPledge().
#0 - Kogaroshi
2022-10-31T00:32:57Z
#1 - c4-judge
2022-11-10T23:06:46Z
kirk-baird marked the issue as satisfactory
#2 - c4-judge
2022-11-10T23:06:50Z
kirk-baird marked the issue as not a duplicate
#3 - c4-judge
2022-11-10T23:06:59Z
kirk-baird marked the issue as duplicate of #163
2421.9641 USDC - $2,421.96
Paladin receives a 5% cut from Boost purchases, as documented on the website
"Warden takes a 5% fee on Boost purchases, and 5% on Quest incentives. However, there are various pricing tiers for Quest creators. Contact the Paladin team for more info."
Here's how fee calculation looks at createPledge
function:
vars.totalRewardAmount = (rewardPerVote * vars.votesDifference * vars.duration) / UNIT; vars.feeAmount = (vars.totalRewardAmount * protocalFeeRatio) / MAX_PCT ; if(vars.totalRewardAmount > maxTotalRewardAmount) revert Errors.IncorrectMaxTotalRewardAmount(); if(vars.feeAmount > maxFeeAmount) revert Errors.IncorrectMaxFeeAmount(); // Pull all the rewards in this contract IERC20(rewardToken).safeTransferFrom(creator, address(this), vars.totalRewardAmount); // And transfer the fees from the Pledge creator to the Chest contract IERC20(rewardToken).safeTransferFrom(creator, chestAddress, vars.feeAmount);
The issue is that the fee is taken up front, assuming totalRewardAmount
will actually be rewarded by the pledge. In practice, the rewards actually utilized can be anywhere from zero to totalRewardAmount
. Indeed, reward will only be totalRewardAmount
if, in the entire period from pledge creation to pledge expiry, the desired targetVotes will be fulfilled, which is extremly unlikely.
As a result, if pledge expires with no pledgers, protocol will still take 5%. This behavior is both unfair and against the docs, as it's not "Paladin receives a 5% cut from Boost purchases".
Paladin fee collection assumes pledges will be matched immediately and fully, which is not realistic. Therefore far too much fees are collected at user's expense.
Manual audit
Fee collection should be done after the pledge completes, in one of the close functions or in a newly created pull function for owner to collect fees. Otherwise, it is a completely unfair system.
#0 - Kogaroshi
2022-11-01T12:37:54Z
The issue is acknowledge, and we do calculate fee on the basis of all rewards, and not only the one that are gonna be used to reward users. The fee ratio is gonna be of 1% to start with (might change before deploy based on market estimations), and the Core team will be able to change the ratio quickly to adapt it to market and Pledge creators needs (with also considering the Paladin DAO revenues). The Paladin team will also considers Pledge creators that are in specific cases and overpay fees (because they already have delegated boost that will last through the whole Pledge and more), and will be able to refund a part of those fees to the creator if the DAO agrees And if this system does not fit in the current market, and is a blocker to potential Pledge creators, we will be able to modify the way fees are handled, and deploy a new iteration of Pledge pretty fast to answer the issue.
#1 - c4-judge
2022-11-11T21:47:42Z
kirk-baird marked the issue as satisfactory
#2 - c4-judge
2022-11-11T21:47:45Z
kirk-baird marked the issue as selected for report
#3 - c4-judge
2022-11-11T21:47:49Z
kirk-baird marked the issue as primary issue
π Selected for report: rbserver
Also found by: 0x1f8b, 0xSmartContract, Trust, cccz, codexploder, ctf_sec, hansfriese
247.5252 USDC - $247.53
The retrievePledgeRewards() function is used by pledge creator, only after pledge endTimestamp has passed. It will collect for the creator all unused reward tokens. Since it can only operate after endTimestamp, the pledge has for all intents and purposes finished, and no pledging API except retrievePledgeRewards can work.
There is therefore no justification for this function to be gated behind the whenNotPaused
modifier. It creates a needless centralization risk of freeze of funds, when those funds belong to the creator at this stage.
Owner can freeze pledge creator's funds after pledging period completed.
Manual audit
Remove the whenNotPaused
modifier from retrievePledgeRewards
Note that this submission is similar in spirit to VTVL finding 475, in that owner is able to freeze / delete funds after maturity period.
#0 - Kogaroshi
2022-10-30T23:14:28Z
Duplicate of #70
#1 - c4-judge
2022-11-11T08:05:07Z
kirk-baird marked the issue as not a duplicate
#2 - c4-judge
2022-11-11T08:05:14Z
kirk-baird marked the issue as duplicate of #70
#3 - c4-judge
2022-11-11T08:05:22Z
kirk-baird marked the issue as satisfactory
#4 - c4-judge
2022-11-11T08:05:26Z
kirk-baird marked the issue as not a duplicate
#5 - c4-judge
2022-11-11T08:05:35Z
kirk-baird marked the issue as duplicate of #70
#6 - c4-judge
2022-12-06T17:36:20Z
Simon-Busch marked the issue as duplicate of #269
π Selected for report: robee
Also found by: 0x007, 0x1f8b, 0x52, 0xDjango, 0xNazgul, 0xSmartContract, 8olidity, Awesome, B2, Bnke0x0, Chom, Diana, Dravee, JTJabba, Jeiwan, Josiah, Lambda, Mathieu, Picodes, RaoulSchaffranek, RaymondFam, RedOneN, ReyAdmirado, Rolezn, Ruhum, Sm4rty, Tricko, Trust, Waze, __141345__, a12jmx, adriro, ajtra, brgltd, c3phas, carlitox477, cccz, ch0bu, chaduke, chrisdior4, corerouter, cryptonue, csanuragjain, ctf_sec, cylzxje, delfin454000, dic0de, djxploit, horsefacts, imare, jayphbee, jwood, ktg, ladboy233, leosathya, lukris02, minhtrng, neko_nyaa, oyc_109, pashov, peritoflores, rbserver, rvierdiiev, shark, tnevler, yixxas
19.6449 USDC - $19.64
retrievePledgeRewards can only be called on expired rewards. The issue is that if there are no remaining rewards, no event is emitted even though an important state has changed - the pledge is closed. Fix by emitting a ClosePledge similarly to closePledge
function.
// Set the Pledge as Closed if(!pledgeParams.closed) pledgeParams.closed = true; if(remainingAmount > 0) { // Transfer the non used rewards and reset storage pledgeAvailableRewardAmounts[pledgeId] = 0; IERC20(pledgeParams.rewardToken).safeTransfer(receiver, remainingAmount); emit RetrievedPledgeRewards(pledgeId, receiver, remainingAmount); }
pledgePercent contains this code:
uint256 amount = (votingEscrow.balanceOf(msg.sender) * percent) / MAX_PCT; _pledge(pledgeId, msg.sender, amount, endTimestamp);
While _pledge has this code:
if(delegationBoost.delegable_balance(user) < amount) revert Errors.CannotDelegate();
In the exported function the % is amount of actual veCRV balance. But _pledge checks the amount is over delegable_balance. This is veBalance + received delegations - sent delegations.
This creates confusion for the user:
#0 - trust1995
2022-10-30T21:20:01Z
Point 2 is dup of #164. If it ends up as M, this finding should be upgraded to match.
#1 - c4-judge
2022-11-12T01:01:29Z
kirk-baird marked the issue as grade-b