Ajna Protocol - Dug's results

A peer to peer, oracleless, permissionless lending protocol with no governance, accepting both fungible and non fungible tokens as collateral.

General Information

Platform: Code4rena

Start Date: 03/05/2023

Pot Size: $60,500 USDC

Total HM: 25

Participants: 114

Period: 8 days

Judge: Picodes

Total Solo HM: 6

Id: 234

League: ETH

Ajna Protocol

Findings Distribution

Researcher Performance

Rank: 29/114

Findings: 3

Award: $326.09

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: Kenshin

Also found by: 0xRobocop, Dug, REACH, Ruhum, hyh, nobody2018, rbserver

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
edited-by-warden
duplicate-450

Awards

187.2333 USDC - $187.23

External Links

Lines of code

https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-grants/src/grants/base/StandardFunding.sol#L217

Vulnerability details

Impact

In StandardFunding.sol, when _updateTreasury() is called, it incorrectly adds the delegate rewards to the treasury as surplus. Over time, this additional balance causes more tokens to be distributed than intended.

Proof of Concept

In _updateTreasury(), the total tokensRequested is summed across all the proposals in the funded slate. The difference between this value and the total funds available for the distribution is added back to the treasury, enabling unspent funds to be distributed in future distributions.

function _updateTreasury(uint24 distributionId_) private {
    bytes32 fundedSlateHash = _distributions[distributionId_].fundedSlateHash;
    uint256 fundsAvailable = _distributions[distributionId_].fundsAvailable;

    uint256[] memory fundingProposalIds = _fundedProposalSlates[fundedSlateHash];

    uint256 totalTokensRequested;
    uint256 numFundedProposals = fundingProposalIds.length;

    for (uint256 i = 0; i < numFundedProposals;) {
        Proposal memory proposal = _standardFundingProposals[fundingProposalIds[i]];

        totalTokensRequested += proposal.tokensRequested;

        unchecked {
            ++i;
        }
    }

    treasury += (fundsAvailable - totalTokensRequested);

    _isSurplusFundsUpdated[distributionId_] = true;
}

However, the issue is that 10% of the funds available in a distribution is always reserved and used as delegate rewards. The maximum total tokens that can be requested is 90% of the total funds available. This is enforced in _validateSlate().

// check if slate of proposals exceeded budget constraint ( 90% of GBC )
if (totalTokensRequested > ((gbc * 9) / 10)) {
    revert InvalidProposalSlate();
}

and in _getDelegateReward().

rewards_ = Maths.wdiv(
    Maths.wmul(currentDistribution_.fundsAvailable, votingPowerAllocatedByDelegatee),
    currentDistribution_.fundingVotePowerCast
) / 10;

Therefore, when the treasury is updated, you can see that the 10% delegate rewards are not accounted for, and always rolled back into the calculated treasury balance.

treasury += (fundsAvailable - totalTokensRequested);

Over time this miscalculated treasury balance will compound as distribution rounds are executed, resulting in corrupted accounting across the entirety of the grant system. The calculated treasury balance will drift further and further from the actual amount, causing the system to overspend, and potentially breaking future distributions entirely as transactions are unable to be executed.

Tools Used

Manual review

Account for delegate rewards when updating the treasury balance.

+   uint256 delegateRewards = fundsAvailable / 10;
+   treasury += (fundsAvailable - delegateRewards - totalTokensRequested);
-   treasury += (fundsAvailable - totalTokensRequested);

Assessed type

Other

#0 - c4-judge

2023-05-18T17:35:06Z

Picodes marked the issue as duplicate of #263

#1 - c4-judge

2023-05-30T18:09:15Z

Picodes marked the issue as duplicate of #263

#2 - c4-judge

2023-05-30T18:11:38Z

Picodes changed the severity to 3 (High Risk)

#3 - c4-judge

2023-05-30T18:14:48Z

Picodes marked the issue as satisfactory

Awards

15.5756 USDC - $15.58

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-251

External Links

Lines of code

https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L811-L821

Vulnerability details

Impact

In the event that the RewardsManager token balance is not maintained, a staker can attempt to claim their rewards and receive no tokens, but be unable to claim them again should the balance ever be restored.

This results in a loss of funds for the staker.

Proof of Concept

In the _transferAnjaRewards() function, you can see that even if the earned rewards are not transferred, the call still successfully resolves.

function _transferAjnaRewards(uint256 rewardsEarned_) internal {
    // check that rewards earned isn't greater than remaining balance
    // if remaining balance is greater, set to remaining balance
    uint256 ajnaBalance = IERC20(ajnaToken).balanceOf(address(this));
    if (rewardsEarned_ > ajnaBalance) rewardsEarned_ = ajnaBalance;

    if (rewardsEarned_ != 0) {
        // transfer rewards to sender
        IERC20(ajnaToken).safeTransfer(msg.sender, rewardsEarned_);
    }
}

This can result in a claim being made, but inadequate or no earned rewards being paid out. This makes it impossible for the staker to later claim their earned rewards should token balance be restored to the RewardsManager in the future.

Tools Used

Manual review

A claim should not successfully resolve unless the earned rewards are actually transferred to the staker. This allows them to claim their rewards at a later date should the token balance become available.

Assessed type

Rug-Pull

#0 - c4-judge

2023-05-12T10:33:46Z

Picodes marked the issue as duplicate of #361

#1 - c4-judge

2023-05-29T20:55:38Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2023-05-29T20:55:46Z

Picodes changed the severity to 3 (High Risk)

Findings Information

🌟 Selected for report: 0xRobocop

Also found by: Dug, ktg, rvierdiiev, sces60107

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
duplicate-164

Awards

123.2812 USDC - $123.28

External Links

Lines of code

https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-grants/src/grants/base/ExtraordinaryFunding.sol#L173

Vulnerability details

Impact

The vote threshold is miscalculated in _extraordinaryProposalSucceeded() creating invariants that result in proposals being drastically easier or more difficult to pass depending on context.

Proof of Concept

The Anja whitepaper describes how the vote threshold is determined for extraordinary proposals. The following example is from the whitepaper, showing how the vote threshold is calculated when the minimum threshold is at 50%:

a. A proposer requests tokens equivalent to 10% of the treasury
  i. 50% + 10% = 60%
  ii. If 65% of non-treasury tokens vote affirmatively, 10% of the treasury is released
  iii. If 59.9% of non-treasury tokens vote affirmatively, 0% of the treasury is released

The percentage of the treasury balance requested is added to the minimum threshold to determine the vote threshold.

However, the actual implementation of _extraordinaryProposalSucceeded() does not match this description. The following is the actual implementation of how votes are validated:

return
// succeeded if proposal's votes received doesn't exceed the minimum threshold required
(votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage))
// succeeded if tokens requested are available for claiming from the treasury
&& (tokensRequested_ <= _getSliceOfTreasury(Maths.WAD - minThresholdPercentage));

Instead of the percentage of the treasury balance being added to the minimum threshold, the tokens requested is added to the vote threshold directly. This results in the vote threshold being drastically different than the whitepaper describes. The protocol team was unable to give input on how they expected tokens to be distributed, so I present two scenarios to consider below with differing token distributions, showing how this creates issues with both.

One scenario where the majority of tokens are held by the treasury:

- Minimum threshold: 50%
- Total supply: 100,000 tokens
- Treasury balance: 80,000 tokens
- Non-treasury balance: 20,000 tokens
- Tokens requested: 20,000 tokens

According to the whitepaper, because the requested amount is 25% of the treasury balance, the vote threshold should be 75% (50% + 25%) of non-treasury tokens, or 15,000 tokens. However, the actual implementation results in the vote threshold being impossible at 30,000 tokens (50% + 20,000 tokens).

Note that this proposal for 25% of the treasury balance is considered valid and is verified in proposeExtraordinary():

// check tokens requested are available for claiming from the treasury
if (uint256(totalTokensRequested) > _getSliceOfTreasury(Maths.WAD - _getMinimumThresholdPercentage())) {
    revert InvalidProposal();
}

However, because of the miscalculation in _extraordinaryProposalSucceeded(), the proposal will never pass because the vote threshold is calculated to be higher than the total non-treasury balance.

Another scenario, where the majority of tokens are in circulation:

- Minimum threshold: 50%
- Total supply: 100,000 tokens
- Treasury balance: 20,000 tokens
- Non-treasury balance: 80,000 tokens
- Tokens requested: 10,000 tokens

As the request is for 50% of the treasury balance, the vote threshold should be 100% (50% + 50%) of non-treasury tokens, or 80,000 tokens. However, the implementation results in the vote threshold being 60,000 tokens (50% + 20,000 tokens). This request represents taking the maximum possible amount from the treasury, which is intended to take 100% support from the circulating supply. However, because of the miscalculation, the proposal will succeed with only 75% support from the circulating supply, making it much easier to pass than intended.

Tools Used

Manual review

Add the percentage of the treasury balance requested to the minimum threshold to determine the vote threshold, as described in the whitepaper.

+   uint256 percentageRequested = tokensRequested_.wadDiv(treasury);
    return
    // succeeded if proposal's votes received doesn't exceed the minimum threshold required
-   (votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage))
+   (votesReceived >= percentageRequested + _getSliceOfNonTreasury(minThresholdPercentage))
    // succeeded if tokens requested are available for claiming from the treasury
    && (tokensRequested_ <= _getSliceOfTreasury(Maths.WAD - minThresholdPercentage));

Assessed type

Other

#0 - c4-judge

2023-05-17T12:32:50Z

Picodes marked the issue as duplicate of #184

#1 - c4-judge

2023-05-30T22:40:50Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2023-05-30T22:42:02Z

Picodes changed the severity to 2 (Med Risk)

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