Liquity v2 ~ Governance (3rd audit)
Smart Contract Security Assessment
December 22, 2024
Fixes (4th) Re-Audit: January 17,2025

SUMMARY
ABSTRACT
Dedaub was commissioned to perform a 3rd security audit of the Liquity v2 Governance protocol. Dedaub has previously been commissioned to audit two earlier versions of the codebase, the reports of which can be found at (Liquity v2 ~ Governance (1st audit) - Aug 12, 2024) and (Liquity v2 ~ Governance (2nd audit) - Nov 11, 2024). In these previous versions, one important issue was the rounding errors caused by how the average timestamps were calculated. In the current version, the voting power mechanism was refactored to eliminate the rounding issues. This review considered the codebase in its entirety and not only the delta of the changes between the current and the previous versions.
The code has improved even more in several important parts compared to the previous versions and has also been simplified in several complex parts. However, some of the reported issues remained active and some new ones were found.
The test suite was also extended with more test cases to better cover the most important parts of the protocol. We highly recommend more extensive unit tests to cover complex and edge case scenarios of the protocol.
After the fixes review, the codebase was further improved addressing the rounding errors in various components of the protocol. Some new assumptions were made and the protocol’s specification was updated to reflect them properly. The test suite was also extended and most of the issues were resolved.
BRIBE DISTRIBUTION FORMULA ANALYSIS
The current version of the codebase introduced further changes in the formulas used to calculate and distribute the bribes to the eligible users in the BribeInitiative
implementation as well as to calculate and distribute the user’s allocations over a set of initiatives in Governance::allocateLQTY
implementation. The new formulas aim to account for the rounding errors that the previous mechanism had.
Below we provide a thorough explanation of the new formulas and proofs for their correctness and fairness for all the users. We perform the analysis for the formula to calculate the bribes but the same analysis can be applied for vote allocation.
Let be the amount of bribe remaining to be distributed after the user claimed his bribe, be the amount of votes of the user, be the total amount of votes, and is the total amount of bribe to be distributed.
Notice that:
Therefore,
Fact 1: If , then for all possible values of and
Proof:
Fact 2: If , no undistributed bribes can remain due to rounding errors
Proof:
So, the computation of does not require any floating point arithmetic.
Fact 3: If , then the system is fair
Fairness Definition:
For every ,
Proof:
Let us show that for every , ,
for :
for :
Now suppose that:
for :
So by induction, for every i,j, .
Therefore, and thus the system is fair.
Rounding errors for each user
Let be the rounding error for the remaining bribes to be distributed. By definition, , and as proved above . Note that the rounding error for the bribe of each user is the same as the rounding error for the remaining bribes to be distributed.
for :
for :
for :
We can clearly see that for all . Additionally, we can find an upper bound for as follows:
The highest error happens for where
SETTING & CAVEATS
This audit report mainly covers the contracts of the repository liquity/V2-gov of the Liquity v2 Governance Protocol. The audit was based on PR #97 at commit d11e15a11ebdb26c7b297572d9674a7801f50922
.
Two auditors (along with 2 junior auditors) worked on the codebase for 5 days on the following contracts and 2 extra days for reviewing the fixes:
As part of the audit, we also reviewed the fixes of the issues included in the report. The fixes were delivered in separate PRs which had already been merged by the time the re-audit started. Hence, the review of the fixes was based on the diff between the audit’s base commit (d11e15a
) and the at-the-time latest commit of the main
branch (68b7110
) and we found that they have been implemented correctly:
-
PR #107 · fix: wrong calculation of the bribes given to each user
(merged inmain
at commit90d02b5ab3252e16106b9ec06c9451b23bcafd5b
)
Addresses issue H1 -
PR #120 · chore: fix incorrect or outdated comments by danielattilasimon
(merged inmain
at commitb5ade69b7ea51db8353544e7b0a31b76b618a5e4
)
Addresses issue H3 -
PR #126 · feat: don't call
onAfterAllocateLQTY()
on vetos
(merged inmain
at commit1c379b59f184f5805cf851d3969c6d0f800626b1
)
Addresses issue M1 -
PR #114 · fix: registration fees go to previous epoch
(merged inmain
at commitaa5a7705f82968da8ae84efb7a2e880fe7e8705d
)
Addresses issue M2 -
PR #123 · fix: dust left after claiming all bribes
(merged inmain
at commitf6839c9d80e0622ef5a0ea5ae3af3070872c1c52
)
Addresses issues M4 and partially M5 -
PR #121 · refactor: remove stale and redundant check
(merged inmain
at commitffbf480150d08feea02036a31c9b67119a4c2685
)
Addresses issue A2 -
PR #110 · Reset no longer needed on staking & fix underflow
(merged inmain
at commitb5ab005ecb2ae15c63d9444dc079f2257c03039d
)
Addresses issue A6 and A8
The audit’s main target is security threats, i.e., what the community understanding would likely call "hacking", rather than the regular use of the protocol. Functional correctness (i.e. issues in "regular use") is a secondary consideration. Typically it can only be covered if we are provided with unambiguous (i.e. full-detail) specifications of what is the expected correct behavior. In terms of functional correctness, we often trusted the code’s calculations and interactions, in the absence of any other specification. Functional correctness relative to low-level calculations (including units, scaling and quantities returned from external protocols) is generally most effectively done through thorough testing rather than human auditing.
PROTOCOL-LEVEL CONSIDERATIONS
The current version introduced the logic of enforcing the reset of the users’ allocations every time they want to update them. In addition to that, in Governance::allocateLQTY
checks were added to ensure that each Initiative has been provided only once and no double processing could be done for the same address. However, both the resetting and the reallocation invoke the Initiatives’ onAfterAllocateLQTY
hook. This means that during a single allocation the hook can be called twice for the same address, possibly breaking the implemented checks in an Initiative.
The new resetting mechanism enforces the users to reset all their allocations before updating their allocations. When resetting, the Governance::_allocateLQTY
function invokes the onAfterAllocateLQTY
hook on the Initiative.
However, if time sensitive Initiatives existed in the future, which may depend on the timestamps at which the users voted or vetoed for their reward or bribe distributions, resetting and reallocating the users’ votes can inherently affect their accounting and make users lose or get more rewards than supposed to.
The existing BribeInitiative
template and the example applications (CurveV2Gauge
, UniV4Donations
and ForwardBribe
) are not time sensitive, but since the protocol aims to support any arbitrary contract as an Initiative, such scenarios are not unlikely to exist.
VULNERABILITIES & FUNCTIONAL ISSUES
This section details issues affecting the functionality of the contract. Dedaub generally categorizes issues according to the following severities, but may also take other considerations into account such as impact or difficulty in exploitation:
- User or system funds can be lost when third-party systems misbehave
- DoS, under specific conditions
- Part of the functionality becomes unusable due to a programming error
- Breaking important system invariants but without apparent consequences
- Buggy functionality for trusted users where a workaround exists
- Security issues which may manifest when the system evolves
Issue resolution includes “dismissed” or “acknowledged” but no action taken, by the client, or “resolved”, per the auditors.
CRITICAL SEVERITY
[No critical severity issues]
HIGH SEVERITY
Resolved
In the hook function BribeInitiative::onAfterAllocateLQTY
, the offset of the user's allocation is taken from _userState.allocatedOffset
instead of taking it from _allocation.voteOffset
. The value _userState.allocatedOffset
corresponds to the offset for all LQTY allocations made by the user (for all initiatives the user voted to). This leads to a wrong calculation of the number of votes from a user for the bribe Initiative. In particular, _userState.allocatedOffset
is always greater than or equal to _allocation.voteOffset
. So, the user's votes for the initiative will be undervalued (larger offset means less votes) and thus, the user receives fewer bribes than he should. Even worse, the amount of bribes that the user could not claim, due to the bug, remains stuck in the bribe Initiative as no user can claim them. One detail to mention here is that the bug is a race condition based on the position of the bribe Initiative address in the array of initiatives passed by the user to Governance::allocateLQTY
function. Having the bribe initiative further in the array leads _userState.allocatedOffset
to accumulate more offsets from the other Initiatives’ allocations resulting in a larger value than _allocation.voteOffset
and consequently more bribes lost.
BribeInitiative::onAfterAllocateLQTY:232
function onAfterAllocateLQTY(
uint256 _currentEpoch,
address _user,
IGovernance.UserState calldata _userState,
IGovernance.Allocation calldata _allocation,
IGovernance.InitiativeState calldata _initiativeState
) external virtual onlyGovernance {
...
// Dedaub:
// Should use _allocation.voteOffset instead of
// _userState.allocatedOffset
_setLQTYAllocationByUserAtEpoch(
_user,
_currentEpoch,
_allocation.voteLQTY,
_userState.allocatedOffset,
mostRecentUserEpoch != _currentEpoch
);
}
A sample demonstration of the bug can be seen in the following scenario:
-
Given a bribe initiative
bribeInitiative1
that has amount of bribes deposited for epoch and two users with some large amount of LQTY deposited at . -
Epoch n:
user_1
allocates LQTY forInitiative2
and another LQTY for initiativebribeInitiative1
by callingallocateLqty([], [Initiative2, bribeInitiative1], [x,y], [0,0]).
This call triggers the hook ofbribeInitiative1.onAfterAllocateLQTY()
with