Skip to content
TwitterGithub

Ajna audit result

Audit result, Sherlock6 min read

ajna

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

The code was reviewed as part of the Sherlock contest. It was the first audit contest I seriously participated in, spending a large amount of my time on the contest.

I ranked 11th out of 255 participants in this audit. I found 1 solo medium vulnerabiliy. I present my finding here.

Solo Medium: Quadratic voting tally done wrong

Summary

Quadratic voting tally is done wrong, which results in a risk of hijacking the grant distribution with relatively low centralization/control over tokens.

Vulnerability Detail

Regarding grant coordination voting of the funding stage, the technical spec states (page24):

Each address that holds Ajna tokens can vote as much as they want on every proposal, including negative votes, subject to the constraint that the sum of the squares of their votes cannot exceed the square of the number of Ajna tokens held.

The code enforces that the sum of all votes of a user (in absolute value) is lower or equal than the square of its token holdings. The votes are not squared before being summed.

Impact

This leads to a higher centralization/control risk than should be. Alice can deploy a smart contract that proposes a funding of all the available tokens for that round towards itself. The contract can reward people delegating to it in case of a successful funding.

Due to the incorrect tally, if 100 token holders each have 1 token, Alice only needs to convince 10 of them to join her cause to successfully gain the funds. She would receive 10^2 = 100 votes, while all other voters could only produce 90 * 1^2 = 90 votes.

If the technical spec were respected and with the same token distribution of 100 tokens split among 100 holders, Alice would need to bribe 50 token holders to join her cause to gain the funding. She has 50^2 voting power and can vote once with50 while all other can vote 50 * 1^2 = 50.

I consider this difference significant enough to be considered medium severity.

Code Snippet

In StandardFunding.sol in _fundingVote() the votes for a proposal are counted by adding budgetAllocation_: https://github.com/sherlock-audit/2023-01-ajna/blob/main/ecosystem-coordination/src/grants/base/StandardFunding.sol#L373

This budget allocation is withdrawn from the voter's voting budget: https://github.com/sherlock-audit/2023-01-ajna/blob/main/ecosystem-coordination/src/grants/base/StandardFunding.sol#L358-L368

The initial voting budget is the square of the token holdings at past snapshot: https://github.com/sherlock-audit/2023-01-ajna/blob/main/ecosystem-coordination/src/grants/GrantFund.sol#L126-L141

Tool used

Manual Review

Testing with one person holding 2 tokens beating three persons holding 1 token each:

StandardFundingAttack.t.sol

1// SPDX-License-Identifier: MIT
2pragma solidity 0.8.16;
3
4import { IGovernor } from "@oz/governance/IGovernor.sol";
5import { IVotes } from "@oz/governance/utils/IVotes.sol";
6import { SafeCast } from "@oz/utils/math/SafeCast.sol";
7
8import { Funding } from "../src/grants/base/Funding.sol";
9import { GrantFund } from "../src/grants/GrantFund.sol";
10import { IStandardFunding } from "../src/grants/interfaces/IStandardFunding.sol";
11import { Maths } from "../src/grants/libraries/Maths.sol";
12
13import { GrantFundTestHelper } from "./utils/GrantFundTestHelper.sol";
14import { IAjnaToken } from "./utils/IAjnaToken.sol";
15
16import { console } from "forge-std/console.sol";
17
18contract StandardFundingGrantFundTest is GrantFundTestHelper {
19
20 // used to cast 256 to uint64 to match emit expectations
21 using SafeCast for uint256;
22
23 IAjnaToken internal _token;
24 IVotes internal _votingToken;
25 GrantFund internal _grantFund;
26
27 // Ajna token Holder at the Ajna contract creation on mainnet
28 address internal _tokenDeployer = 0x666cf594fB18622e1ddB91468309a7E194ccb799;
29 address internal _tokenHolder1 = makeAddr("_tokenHolder1");
30 address internal _tokenHolder2 = makeAddr("_tokenHolder2");
31 address internal _tokenHolder3 = makeAddr("_tokenHolder3");
32 address internal _tokenHolder4 = makeAddr("_tokenHolder4");
33
34 address[] internal _votersArr = [
35 _tokenHolder1,
36 _tokenHolder2,
37 _tokenHolder3,
38 _tokenHolder4
39 ];
40
41 uint256 _initialAjnaTokenSupply = 2_000_000_000 * 1e18;
42
43 // at this block on mainnet, all ajna tokens belongs to _tokenDeployer
44 uint256 internal _startBlock = 16354861;
45
46 mapping (uint256 => uint256) internal noOfVotesOnProposal;
47 uint256[] internal topTenProposalIds;
48 uint256[] internal potentialProposalsSlate;
49 uint256 treasury = 500_000_000 * 1e18;
50
51 function setUp() external {
52 vm.createSelectFork(vm.envString("ETH_RPC_URL"), _startBlock);
53
54 vm.startPrank(_tokenDeployer);
55
56 // Ajna Token contract address on mainnet
57 _token = IAjnaToken(0x9a96ec9B57Fb64FbC60B423d1f4da7691Bd35079);
58
59 // deploy voting token wrapper
60 _votingToken = IVotes(address(_token));
61
62 // deploy growth fund contract
63 _grantFund = new GrantFund(_votingToken, treasury);
64
65 // initial minter distributes tokens to test addresses
66 // _transferAjnaTokens(_token, _votersArr, 50_000_000 * 1e18, _tokenDeployer);
67 changePrank(_tokenDeployer);
68 _token.transfer(_tokenHolder1, 2 * 1e18);
69 _token.transfer(_tokenHolder2, 1 * 1e18);
70 _token.transfer(_tokenHolder3, 1 * 1e18);
71 _token.transfer(_tokenHolder4, 1 * 1e18);
72
73 // initial minter distributes treasury to grantFund
74 _token.transfer(address(_grantFund), treasury);
75 }
76
77 /*************/
78 /*** Tests ***/
79 /*************/
80
81 function testQuadraticVotingTally() external {
82 _selfDelegateVoters(_token, _votersArr);
83
84 vm.roll(_startBlock + 50);
85
86 // start distribution period
87 _startDistributionPeriod(_grantFund);
88 uint256 distributionId = _grantFund.getDistributionId();
89 (, , , , uint256 gbc, ) = _grantFund.getDistributionPeriodInfo(distributionId);
90
91 // generate proposal targets
92 address[] memory ajnaTokenTargets = new address[](1);
93 ajnaTokenTargets[0] = address(_token);
94
95 // generate proposal values
96 uint256[] memory values = new uint256[](1);
97 values[0] = 0;
98
99 // generate proposal calldata
100 bytes[] memory proposalCalldata = new bytes[](1);
101 proposalCalldata[0] = abi.encodeWithSignature(
102 "transfer(address,uint256)",
103 _tokenHolder1,
104 gbc * 8/10
105 );
106 bytes[] memory proposalCalldata2 = new bytes[](1);
107 proposalCalldata2[0] = abi.encodeWithSignature(
108 "transfer(address,uint256)",
109 _tokenHolder2,
110 gbc * 7/10
111 );
112
113 // create and submit proposal
114 TestProposal memory proposal = _createProposalStandard(_grantFund, _tokenHolder1, ajnaTokenTargets, values, proposalCalldata, "Proposal for Ajna token transfer to tester address");
115 TestProposal memory proposal2 = _createProposalStandard(_grantFund, _tokenHolder2, ajnaTokenTargets, values, proposalCalldata2, "Proposal 2 for Ajna token transfer to tester address");
116
117 vm.roll(_startBlock + 200);
118
119 // screening period votes
120 _vote(_grantFund, _tokenHolder1, proposal.proposalId, voteYes, 1);
121 _vote(_grantFund, _tokenHolder2, proposal2.proposalId, voteYes, 1);
122
123 // skip forward to the funding stage
124 vm.roll(_startBlock + 600_000);
125
126 GrantFund.Proposal[] memory screenedProposals = _getProposalListFromProposalIds(_grantFund, _grantFund.getTopTenProposals(distributionId));
127 assertEq(screenedProposals.length, 2);
128 assertEq(screenedProposals[0].proposalId, proposal.proposalId);
129 assertEq(screenedProposals[0].votesReceived, 2 * 1e18);
130 assertEq(screenedProposals[1].proposalId, proposal2.proposalId);
131 assertEq(screenedProposals[1].votesReceived, 1 * 1e18);
132
133 // check initial voting power
134 uint256 votingPower = _grantFund.getVotesWithParams(_tokenHolder1, block.number, "Funding");
135 assertEq(votingPower, 4 * 1e18);
136 votingPower = _grantFund.getVotesWithParams(_tokenHolder2, block.number, "Funding");
137 assertEq(votingPower, 1 * 1e18);
138 votingPower = _grantFund.getVotesWithParams(_tokenHolder3, block.number, "Funding");
139 assertEq(votingPower, 1 * 1e18);
140 votingPower = _grantFund.getVotesWithParams(_tokenHolder4, block.number, "Funding");
141 assertEq(votingPower, 1 * 1e18);
142
143 _fundingVote(_grantFund, _tokenHolder1, proposal.proposalId, voteYes, 4 * 1e18);
144 _fundingVote(_grantFund, _tokenHolder2, proposal2.proposalId, voteYes, 1 * 1e18);
145 _fundingVote(_grantFund, _tokenHolder3, proposal2.proposalId, voteYes, 1 * 1e18);
146 _fundingVote(_grantFund, _tokenHolder4, proposal2.proposalId, voteYes, 1 * 1e18);
147
148 // check voting power after voting
149 votingPower = _grantFund.getVotesWithParams(_tokenHolder1, block.number, "Funding");
150 assertEq(votingPower, 0 * 1e18);
151 votingPower = _grantFund.getVotesWithParams(_tokenHolder2, block.number, "Funding");
152 assertEq(votingPower, 0 * 1e18);
153 votingPower = _grantFund.getVotesWithParams(_tokenHolder3, block.number, "Funding");
154 assertEq(votingPower, 0 * 1e18);
155 votingPower = _grantFund.getVotesWithParams(_tokenHolder4, block.number, "Funding");
156 assertEq(votingPower, 0 * 1e18);
157
158 // skip to the DistributionPeriod
159 vm.roll(_startBlock + 650_000);
160
161 uint256[] memory winningSlate = new uint256[](1);
162 winningSlate[0] = proposal.proposalId;
163 uint256[] memory losingSlate = new uint256[](1);
164 losingSlate[0] = proposal2.proposalId;
165
166 // The losing slate is valid
167 assertTrue(_grantFund.checkSlate(losingSlate, distributionId));
168 // The winning slate is valid and has more votes than the losing slate
169 assertTrue(_grantFund.checkSlate(winningSlate, distributionId));
170 }
171}

Recommendation

Subtract the square of the votes from the voting budget when voting:

1@@ -357,17 +361,10 @@ abstract contract StandardFunding is Funding, IStandardFunding {
2 // case where voter is voting against the proposal
3 if (budgetAllocation_ < 0) {
4 support = 0;
5-
6- // update voter budget remaining
7- voter_.budgetRemaining += budgetAllocation_;
8- }
9- // voter is voting in support of the proposal
10- else {
11- // update voter budget remaining
12- voter_.budgetRemaining -= budgetAllocation_;
13 }
14+ voter_.budgetRemaining -= int256(Maths.wpow(uint256(Maths.abs(budgetAllocation_)), 2));
15 // update total vote cast
16 currentDistribution.quadraticVotesCast += uint256(Maths.abs(budgetAllocation_));
17
18 // update proposal vote tracking
19 proposal_.qvBudgetAllocated += budgetAllocation_;