Paladin - Warden Pledges contest - Chom's results

A governance lending protocol transforming users voting power into a new money lego.

General Information

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

Paladin

Findings Distribution

Researcher Performance

Rank: 5/96

Findings: 3

Award: $1,314.86

QA:
grade-b

🌟 Selected for report: 1

πŸš€ Solo Findings: 0

Findings Information

🌟 Selected for report: Chom

Also found by: Jeiwan, KingNFT, Picodes

Labels

bug
help wanted
2 (Med Risk)
judge review requested
primary issue
sponsor confirmed
selected for report
M-03

Awards

980.8955 USDC - $980.90

External Links

Lines of code

https://github.com/code-423n4/2022-10-paladin/blob/d6d0c0e57ad80f15e9691086c9c7270d4ccfe0e6/contracts/WardenPledge.sol#L325-L335 https://github.com/code-423n4/2022-10-paladin/blob/d6d0c0e57ad80f15e9691086c9c7270d4ccfe0e6/contracts/WardenPledge.sol#L259-L268

Vulnerability details

Impact

Pledge may be out of reward due to the decay in veCRV balance. The receiver may lose his reward given to boosters but get nothing in return since her targetVotes is never reached.

Proof of Concept

According to Curve documentation at https://curve.readthedocs.io/dao-vecrv.html

A user’s veCRV balance decays linearly as the remaining time until the CRV unlock decreases. For example, a balance of 4000 CRV locked for one year provides the same amount of veCRV as 2000 CRV locked for two years, or 1000 CRV locked for four years.

On creation, targetVotes = 100, balance = 20 -> votesDifference = 80 -> reward is allocated for 80 votes

        // Get the missing votes for the given receiver to reach the target votes
        // We ignore any delegated boost here because they might expire during the Pledge duration
        // (we can have a future version of this contract using adjusted_balance)
        vars.votesDifference = targetVotes - votingEscrow.balanceOf(receiver);

        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);

Then 1 week passed, receiver's balance decay to 10

On creation, targetVotes = 100, balance = 10 but votesDifference stays 80, and reward has only allocated for 80 votes.

        // Rewards are set in the Pledge as reward/veToken/sec
        // To find the total amount of veToken delegated through the whole Boost duration
        // based on the Boost bias & the Boost duration, to take in account that the delegated amount decreases
        // each second of the Boost duration
        uint256 totalDelegatedAmount = ((bias * boostDuration) + bias) / 2;
        // Then we can calculate the total amount of rewards for this Boost
        uint256 rewardAmount = (totalDelegatedAmount * pledgeParams.rewardPerVote) / UNIT;

        if(rewardAmount > pledgeAvailableRewardAmounts[pledgeId]) revert Errors.RewardsBalanceTooLow();
        pledgeAvailableRewardAmounts[pledgeId] -= rewardAmount;

A booster boosts 80 votes and takes all rewards in the pool. However, only 80 (From booster) + 10 (From receiver) = 90 votes is active. Not 100 votes that receiver promise in the targetVotes.

Then, if another booster tries to boost 10 votes, it will be reverted with RewardsBalanceTooLow since the first booster has taken all reward that is allocated for only 80 votes.

You should provide a way for the creator to provide additional rewards after the pledge creation. Or provide some reward refreshment function that recalculates votesDifference and transfers the required additional reward.

#0 - Kogaroshi

2022-11-02T23:39:13Z

Changed the logic in PR 2, commit Now the whole amount of votes needed for each second of the Pledge duration is calculated, taking in account the receiver potential veCRV balance, and the veCRV decay.

This should allow to add only the exact amount of reward needed to the Pledge reward pool, and have always the correct amount of rewards to achieve the vote target of the Pledge at all times

#1 - Kogaroshi

2022-11-02T23:40:11Z

If possible, a feedback on the new calculation and logic would be appreciated.

#2 - c4-judge

2022-11-10T22:41:20Z

kirk-baird marked the issue as primary issue

#3 - c4-judge

2022-11-10T22:54:24Z

kirk-baird marked the issue as selected for report

Findings Information

🌟 Selected for report: Jeiwan

Also found by: 0xDjango, Aymen0909, Chom, Lambda, Ruhum, Trust

Labels

bug
2 (Med Risk)
satisfactory
edited-by-warden
duplicate-163

Awards

314.3177 USDC - $314.32

External Links

Lines of code

https://github.com/code-423n4/2022-10-paladin/blob/d6d0c0e57ad80f15e9691086c9c7270d4ccfe0e6/contracts/WardenPledge.sol#L385-L396 https://github.com/code-423n4/2022-10-paladin/blob/d6d0c0e57ad80f15e9691086c9c7270d4ccfe0e6/contracts/WardenPledge.sol#L428-L440

Vulnerability details

Impact

extendPledge and increasePledgeRewardPerVote are wasting rewardToken from creator more than necessary. The creator may need to pay more rewards which are temporarily unnecessary locking her fund.

Proof of Concept

On creation, targetVotes = 100, balance = 20 -> votesDifference = 80 -> reward is allocated for 80 votes

A booster boosts 50 votes -> adjusted balance = 70 votes and the target is 100 votes (Remaining 30 votes)

        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();

        // Pull all the rewards in this contract
        IERC20(pledgeParams.rewardToken).safeTransferFrom(creator, address(this), totalRewardAmount);
        // And transfer the fees from the Pledge creator to the Chest contract
        IERC20(pledgeParams.rewardToken).safeTransferFrom(creator, chestAddress, feeAmount);

The creator extends the vote 2 times, he needs to pay a reward for pledgeParams.votesDifference = 80 votes. But actually, only 30 votes remain. Wasting reward for 50 votes.

        uint256 oldRewardPerVote = pledgeParams.rewardPerVote;
        if(newRewardPerVote <= oldRewardPerVote) revert Errors.RewardsPerVotesTooLow();
        uint256 remainingDuration = pledgeParams.endTimestamp - block.timestamp;
        uint256 rewardPerVoteDiff = newRewardPerVote - oldRewardPerVote;
        uint256 totalRewardAmount = (rewardPerVoteDiff * pledgeParams.votesDifference * remainingDuration) / UNIT;
        uint256 feeAmount = (totalRewardAmount * protocalFeeRatio) / MAX_PCT ;
        if(totalRewardAmount > maxTotalRewardAmount) revert Errors.IncorrectMaxTotalRewardAmount();
        if(feeAmount > maxFeeAmount) revert Errors.IncorrectMaxFeeAmount();

        // Pull all the rewards in this contract
        IERC20(pledgeParams.rewardToken).safeTransferFrom(creator, address(this), totalRewardAmount);
        // And transfer the fees from the Pledge creator to the Chest contract
        IERC20(pledgeParams.rewardToken).safeTransferFrom(creator, chestAddress, feeAmount);

The creator increases the reward per vote 2 times, he needs to pay a reward for pledgeParams.votesDifference = 80 votes. But actually, only 30 votes remain. Wasting reward for 50 votes.

Recalculate votesDifference after each adjustment using adjusted_balance_of

        pledgeParams.votesDifference = targetVotes - delegationBoost.adjusted_balance_of(receiver);

        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();

        // Pull all the rewards in this contract
        IERC20(pledgeParams.rewardToken).safeTransferFrom(creator, address(this), totalRewardAmount);
        // And transfer the fees from the Pledge creator to the Chest contract
        IERC20(pledgeParams.rewardToken).safeTransferFrom(creator, chestAddress, feeAmount);
        pledgeParams.votesDifference = targetVotes - delegationBoost.adjusted_balance_of(receiver);

        uint256 oldRewardPerVote = pledgeParams.rewardPerVote;
        if(newRewardPerVote <= oldRewardPerVote) revert Errors.RewardsPerVotesTooLow();
        uint256 remainingDuration = pledgeParams.endTimestamp - block.timestamp;
        uint256 rewardPerVoteDiff = newRewardPerVote - oldRewardPerVote;
        uint256 totalRewardAmount = (rewardPerVoteDiff * pledgeParams.votesDifference * remainingDuration) / UNIT;
        uint256 feeAmount = (totalRewardAmount * protocalFeeRatio) / MAX_PCT ;
        if(totalRewardAmount > maxTotalRewardAmount) revert Errors.IncorrectMaxTotalRewardAmount();
        if(feeAmount > maxFeeAmount) revert Errors.IncorrectMaxFeeAmount();

        // Pull all the rewards in this contract
        IERC20(pledgeParams.rewardToken).safeTransferFrom(creator, address(this), totalRewardAmount);
        // And transfer the fees from the Pledge creator to the Chest contract
        IERC20(pledgeParams.rewardToken).safeTransferFrom(creator, chestAddress, feeAmount);

#0 - Kogaroshi

2022-10-30T23:34:22Z

Duplicate of #61

#1 - Chomtana

2022-10-31T01:45:12Z

Also dup #163

#2 - kirk-baird

2022-11-10T21:56:28Z

Nicely written up and explained however as the sponsor mentions it may be required to use the full amount of tokens transferred if existing pledges end before the desired end timestamp. I'll use Nullify so the warden is not penalised for this submission.

#3 - c4-judge

2022-11-10T21:56:32Z

kirk-baird marked the issue as nullified

#4 - c4-judge

2022-11-10T22:57:46Z

kirk-baird marked the issue as not nullified

#5 - kirk-baird

2022-11-10T23:00:47Z

I have decided this is actually a valid issue as cached voteDifference needs to be updated for each call to extendPledge() and increasePledgeRewards(). It has two impacts. a) Users are overpaying the fees b) Excess funds locked (or not enough)

This is a separate issue to voting balance decaying

Thus I'm going to mark it as a duplicate of #163

#6 - c4-judge

2022-11-10T23:00:52Z

kirk-baird marked the issue as not a duplicate

#7 - c4-judge

2022-11-10T23:01:22Z

kirk-baird marked the issue as duplicate of #163

#8 - c4-judge

2022-11-10T23:01:36Z

kirk-baird marked the issue as satisfactory

#9 - c4-judge

2022-11-10T23:01:40Z

kirk-baird marked the issue as not a duplicate

#10 - c4-judge

2022-11-10T23:01:47Z

kirk-baird marked the issue as duplicate of #163

You should take adjusted_balance_of into account on calculation votesDifference on createPledge

Although you don't take it into account, the system still has some problems with votes decay / expire during the Pledge duration as stated in my Medium findings. So, you should take adjusted_balance_of into account to get the actual voting difference.

#0 - c4-judge

2022-11-12T00:09:24Z

kirk-baird marked the issue as grade-b

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter