Ajna audit result
— Audit result, Sherlock — 6 min read

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: MIT2pragma 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 expectations21 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 mainnet28 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 _tokenHolder439 ];40
41 uint256 _initialAjnaTokenSupply = 2_000_000_000 * 1e18;42
43 // at this block on mainnet, all ajna tokens belongs to _tokenDeployer44 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 mainnet57 _token = IAjnaToken(0x9a96ec9B57Fb64FbC60B423d1f4da7691Bd35079);58
59 // deploy voting token wrapper60 _votingToken = IVotes(address(_token));61
62 // deploy growth fund contract63 _grantFund = new GrantFund(_votingToken, treasury);64
65 // initial minter distributes tokens to test addresses66 // _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 grantFund74 _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 period87 _startDistributionPeriod(_grantFund);88 uint256 distributionId = _grantFund.getDistributionId();89 (, , , , uint256 gbc, ) = _grantFund.getDistributionPeriodInfo(distributionId);90
91 // generate proposal targets92 address[] memory ajnaTokenTargets = new address[](1);93 ajnaTokenTargets[0] = address(_token);94
95 // generate proposal values96 uint256[] memory values = new uint256[](1);97 values[0] = 0;98
99 // generate proposal calldata100 bytes[] memory proposalCalldata = new bytes[](1);101 proposalCalldata[0] = abi.encodeWithSignature(102 "transfer(address,uint256)",103 _tokenHolder1,104 gbc * 8/10105 );106 bytes[] memory proposalCalldata2 = new bytes[](1);107 proposalCalldata2[0] = abi.encodeWithSignature(108 "transfer(address,uint256)",109 _tokenHolder2,110 gbc * 7/10111 );112
113 // create and submit proposal114 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 votes120 _vote(_grantFund, _tokenHolder1, proposal.proposalId, voteYes, 1);121 _vote(_grantFund, _tokenHolder2, proposal2.proposalId, voteYes, 1);122
123 // skip forward to the funding stage124 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 power134 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 voting149 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 DistributionPeriod159 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 valid167 assertTrue(_grantFund.checkSlate(losingSlate, distributionId));168 // The winning slate is valid and has more votes than the losing slate169 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 proposal3 if (budgetAllocation_ < 0) {4 support = 0;5-6- // update voter budget remaining7- voter_.budgetRemaining += budgetAllocation_;8- }9- // voter is voting in support of the proposal10- else {11- // update voter budget remaining12- voter_.budgetRemaining -= budgetAllocation_;13 }14+ voter_.budgetRemaining -= int256(Maths.wpow(uint256(Maths.abs(budgetAllocation_)), 2));15 // update total vote cast16 currentDistribution.quadraticVotesCast += uint256(Maths.abs(budgetAllocation_));17
18 // update proposal vote tracking19 proposal_.qvBudgetAllocated += budgetAllocation_;