Skip to main content
Liquity v2 ~ Governance (3rd audit) - Dec 22, 2024

Liquity v2 ~ Governance (3rd audit)

Smart Contract Security Assessment

December 22, 2024

Fixes (4th) Re-Audit: January 17,2025

Liquity

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 RiR_i be the amount of bribe remaining to be distributed after the ithi-th user claimed his bribe, ViV_i be the amount of votes of the ithi-th user, VtotalV_{\text{total}} be the total amount of votes, and R0=bribetotalR_0 = bribe_{total} is the total amount of bribe to be distributed.

Ri=Ri1bribei      where      bribei=Ri1Vi(Vtotal1i1Vk)R_i = R_{i-1} - bribe_i \text{~~~~~~where~~~~~~} bribe_i = \frac{R_{i-1} * V_i}{(V_{total} - \sum_1^{i-1} V_{k})}

Notice that:

Ri=Ri1bribeiR_i = R_{i-1} - bribe_i

    Ri=Ri1Ri1Vi(Vtotal1i1Vk)\implies R_i = R_{i-1} - \frac{R_{i-1} * V_i}{(V_{total} - \sum_1^{i-1} V_{k})}

    Ri=Ri1×(1Vi(Vtotal1i1Vk))\implies R_i = R_{i-1} \times (1 - \frac{V_i}{(V_{total} - \sum_1^{i-1} V_{k})})

Therefore,

Ri=R0j=1i(1VjVtotal1j1Vk)R_{i} = R_0 \prod_{j=1}^{i} \left( 1 - \frac{V_j}{V_{\text{total}} - \sum_{1}^{j-1} V_k} \right)

Ri=R0j=1i(Vtotal1jVkVtotal1j1Vk)R_{i} = R_0 \prod_{j=1}^{i} \left( \frac{V_{\text{total}} - \sum_{1}^{j} V_k}{V_{\text{total}} - \sum_{1}^{j-1} V_k} \right)


Fact 1: If 1nVi=Vtotal\sum_1^n V_i = V_{total}, then Rn=0R_n = 0 for all possible values of ViV_i and bribetotalbribe_{total}

Proof:

Ri=Ri1×(1Vi(Vtotal1i1Vk))R_i = R_{i-1} \times (1 - \frac{V_i}{(V_{total} - \sum_1^{i-1} V_{k})})

    Rn=Rn1×(1Vn(Vtotal1n1Vk))\implies R_n = R_{n-1} \times (1 - \frac{V_n}{(V_{total} - \sum_1^{n-1} V_{k})})

    Rn=Rn1×(1VnVn)      because      1nVi=Vtotal\implies R_n = R_{n-1} \times (1 - \frac{V_n}{V_n}) \text{~~~~~ because ~~~~~} \sum_1^n V_i = V_{total}

    Rn=0\implies R_n = 0


Fact 2: If 1nVi=Vtotal\sum_1^n V_i = V_{total}, no undistributed bribes can remain due to rounding errors

Proof:

briben=Rn1Vn(Vtotal1n1Vk)=Rn1×VnVn=Rn1bribe_n = \frac{R_{n-1} * V_n}{(V_{total} - \sum_1^{n-1} V_{k})} = \frac{R_{n-1} \times V_n}{V_{n} } = R_{n-1}

So, the computation of bribenbribe_n does not require any floating point arithmetic.


Fact 3: If 1nVi=Vtotal\sum_1^n V_i = V_{total}, then the system is fair

Fairness Definition:

For every ii, bribei=Vi×bribetotalVtotalbribe_i = \frac{V_i \times bribe_{total}}{V_{total}}

Proof:

Let us show that for every ii, jj, bribei/Vi=bribej/Vjbribe_i/V_{i} = {bribe_j}/{V_{j}}

for i=1i = 1:

bribe1/V1=R0V1Vtotal/V1=R0Vtotal {bribe_1}/{V_{1}} = \frac{R_{0} * V_1}{V_{total}} / {V_1} = \frac{R_{0}}{V_{total}}

for i=2i = 2:

bribe2/V2=R1V2VtotalV1/V2=R0×(1V1(Vtotal))(VtotalV1)=R0Vtotal=bribe1/V1{bribe_2}/{V_{2}} = \frac{R_{1} * V_2}{V_{total} -V_1} / V_2 = \frac{R_{0} \times (1 - \frac{V_1}{(V_{total})})}{(V_{total} - V_1)} = \frac{R_{0} }{V_{total}} = {bribe_1}/{V_{1}}

Now suppose that:

bribei/Vi=bribei1/Vi1{bribe_i}/{V_{i}} = {bribe_{i-1}}/{V_{{i-1}}}

    Ri1Vtotal1i1Vk=bribei1/Vi1\implies \frac{R_{i-1}}{V_{total} - \sum_1^{i-1} V_{k}} = {bribe_{i-1}}/{V_{{i-1}}}

for i+1i+1:

bribei+1/Vi+1=Ri(Vtotal1iVk){bribe_{i+1}}/{V_{i+1}} = \frac{R_{i}}{(V_{total} - \sum_1^{i} V_{k})}

=R0j=1i(1VjVtotal1j1Vk)(Vtotal1iVk)= \frac{R_0 \prod_{j=1}^{i} \left( 1 - \frac{V_j}{V_{\text{total}} - \sum_{1}^{j-1} V_k} \right) }{(V_{total} - \sum_1^{i} V_{k})}

=R0(1ViVtotal1i1Vk)j=1i1(1VjVtotal1j1Vk)(Vtotal1iVk) = \frac{R_0 \left( 1 - \frac{V_{i}}{V_{\text{total}} - \sum_{1}^{i-1} V_k} \right)\prod_{j=1}^{i-1} \left( 1 - \frac{V_j}{V_{\text{total}} - \sum_{1}^{j-1} V_k} \right)}{(V_{total} - \sum_1^{i} V_{k})}

=R0(Vtotal1iVkVtotal1i1Vk)j=1i1(1VjVtotal1j1Vk)(Vtotal1iVk) = \frac{R_0 \left( \frac{V_{total} - \sum_1^{i} V_{k}}{V_{\text{total}} - \sum_{1}^{i-1} V_k} \right)\prod_{j=1}^{i-1} \left( 1 - \frac{V_j}{V_{\text{total}} - \sum_{1}^{j-1} V_k} \right)}{(V_{total} - \sum_1^{i} V_{k})}

=R0j=1i1(1VjVtotal1j1Vk)(Vtotal1i1Vk) = \frac{R_0 \prod_{j=1}^{i-1} \left( 1 - \frac{V_j}{V_{\text{total}} - \sum_{1}^{j-1} V_k} \right)}{(V_{total} - \sum_1^{i-1} V_{k})}

=Ri1Vtotal1i1Vk=bribei/Vi = \frac{R_{i-1}}{V_{total} - \sum_1^{i-1} V_{k}} = {bribe_{i}}/{V_{{i}}}

So by induction, for every i,j, bribei/Vi=bribej/Vj=R0Vtotalbribe_i/V_{i} = {bribe_j}/{V_{j}} = \frac{R_{0}}{V_{total}}.

Therefore, bribei=bribetotal×Vi/Vtotalbribe_i = bribe_{total} \times {V_i }/{V_{total}} and thus the system is fair.


Rounding errors for each user

Let ReiRe_i be the rounding error for the remaining bribes to be distributed. By definition, Re0=0Re_0 = 0, and as proved above Ren=0Re_n = 0. 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.

Rei=Rei1Ri1Vimod(Vtotal1i1Vk) Re_i = Re_{i-1} - {R_{i-1} * V_i} \mod {(V_{total} - \sum_1^{i-1} V_{k})}

for i=1i = 1:

Re1=R0V1mod(Vtotal)Re_1 = {R_0 * V_1} \mod {(V_{total})}

for i=2i = 2:

Re2=R0V1mod(Vtotal)Re_2 = {R_0 * V_1} \mod {(V_{total})}

             R1V2mod(VtotalV1)~~~~~~~~~~~~~ {R_1 * V_2} \mod {(V_{total} - V_1 )}

for i=ni = n:

Ren1=R0V1mod(Vtotal)Re_{n-1} = {R_0 * V_1} \mod {(V_{total} )}

                R1V2mod(VtotalV1)~~~~~~~~~~~~~~~~ {R_1 * V_2} \mod {(V_{total} - V_1)}

                ...~~~~~~~~~~~~~~~~ ...

                Rn2Vn1mod(VtotalV1V2...Vn2)~~~~~~~~~~~~~~~~ {R_{n-2} * V_{n-1}} \mod {(V_{total} - V_1 - V_2 - ... - V_{n-2})}

We can clearly see that Ren1ReiRe_{n-1} \ge Re_i for all i<n1i \lt n-1. Additionally, we can find an upper bound for ReiRe_i as follows:

Rei<k=1i(k)Vk+k=i+1n1(i)Vk Re_i < \sum_{k=1}^{i} (k) V_k + \sum_{k=i+1}^{n-1} (i) V_k

The highest error happens for Rn1R_{n-1} where Ren1<(n1)Vn+k=1n1kVkRe_{n-1} < (n-1) V_n + \sum_{k=1}^{n-1} kV_k


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:

src/
  • BribeInitiative.sol
  • CurveV2GaugeRewards.sol
  • ForwardBribe.sol
  • Governance.sol
  • UniV4Donations.sol
  • UserProxy.sol
  • UserProxyFactory.sol
  • interfaces/
    • utils/

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:

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

P1

Initiative hooks can be called twice during an allocation

PROTOCOL-LEVEL-CONSIDERATION
info
P1
Initiative hooks can be called twice during an allocation

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.

P2

Time sensitive Initiatives could experience issues with the resetting mechanism

PROTOCOL-LEVEL-CONSIDERATION
info
P2
Time sensitive Initiatives could experience issues with the resetting mechanism

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:

Category
Description
CRITICAL
Can be profitably exploited by any knowledgeable third-party attacker to drain a portion of the system’s or users’ funds OR the contract does not function as intended and severe loss of funds may result.
HIGH
Third-party attackers or faulty functionality may block the system or cause the system or users to lose funds. Important system invariants can be violated.
MEDIUM
Examples:
  • 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.
LOW
Examples:
  • 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

H1

Wrong calculation of the bribes given to each user

HIGH
resolved
H1
Wrong calculation of the bribes given to each user

Resolved

The correct variable was used to perform the calculations resolving the issue.

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 BnB_n amount of bribes deposited for epoch nn and two users with some large amount of LQTY deposited at t0t_0.

  • Epoch n: user_1 allocates xx LQTY for Initiative2 and another yy LQTY for initiative bribeInitiative1 by calling allocateLqty([], [Initiative2, bribeInitiative1], [x,y], [0,0]). This call triggers the hook of bribeInitiative1.onAfterAllocateLQTY() with _userState.allocatedOffset=(x+y)t0\text{\_userState.allocatedOffset} = (x+y) * t_0 (the correct offset should be yt0y * t_0).

  • Epoch n: user_2 allocates yy LQTY for bribeInitiative1.

  • Epoch n+1: Both users claim their bribes for epoch n by calling claimBribes({n, n, n}). user_1 receives ytn(x+y)t02ytn2yt0×Bn\frac{yt_n - (x+y)t_0}{2yt_n - 2yt_0}\times B_n and user_2 receives ytnyt02ytn2yt0×Bn\frac{yt_n - yt_0}{2yt_n - 2yt_0}\times B_n. This results in user_1 losing xt02ytn2yt0×Bn\frac{xt_0}{2yt_n - 2yt_0}\times B_n of his bribe which gets stuck in bribeInitiative1 forever as no one can claim them.

Note: Please, also refer to the corresponding PoC below: Proof of Concept - H1


MEDIUM SEVERITY

M1

Voting can be made financially unfair by manipulating the gas cost of hooks

MEDIUM
partially resolved
M1
Voting can be made financially unfair by manipulating the gas cost of hooks

Partially resolved

The team deemed that it is significantly more important to allow users to veto malicious Initiatives than notifying the benevolent ones of a veto event, hence the fix was to skip the onAfterAllocateLQTY hook on vetos to prevent any voting manipulation. The alternative expressions of the issue were acknowledged by the team.
Note: This issue also existed in the previous version and was not yet resolved”

For voting to be fair, it is important that both positive and negative votes have similar costs. Apart from the cost in terms of voting power obtained by staking, we should also consider the actual gas cost of executing the voting transaction. However, the gas cost can be manipulated by a malicious user to make vetos substantially more expensive than votes, exploiting two aspects of the voting design:

  • Every call to allocateLQTY calls the onAfterAllocateLQTY which is controlled by the adversary.

  • allocateLQTY requires resetting all previous votes and re-casting them, which essentially means that onAfterAllocateLQTY will be called 2N+12 * N + 1 times for every voting operation, where NN is the number of previously voted Initiatives.

  • Now consider a malicious user submitting Initiatives whose onAfterAllocateLQTY consumes all available gas in case of a veto, but a tiny amount of gas in case of a vote. Even though the gas is capped by MIN_GAS_TO_HOOK, the current value of 350.000 is large enough to allow a substantial manipulation. With a gas cost of 20 Gwei (not uncommon), calling the hook alone currently costs around $18. There is a scenario in which an adversary can submit MM such Initiatives and make the total hook calls when casting vetos for all of them to be N=0M12N+1\sum_{N=0}^{M-1} 2 * N + 1 which is equal to M2M^2. With 10 malicious Initiatives we need $1800 just to call the hooks (not counting the cost of allocateLQTY itself).

  • The idea is that the adversary submits the Initiatives on different epochs and votes for them so that they become eligible to get rewards or just stay active in the system for as long as required (for simplicity we do not consider the warm-up periods and similar mechanisms, as they do not directly affect the scenario).

  • On epoch_a, the first malicious Initiative becomes active for voting and the users veto it paying the expensive hook call once.

  • On epoch_b > epoch_a the adversary’s 2nd Initiative becomes active and the users veto this as well, but also keep their vetos active for the 1st malicious Initiative. Thus, they pay they will have to pay 2 + 1 times the expensive hooks since they have to reset all their allocations (1 call) and reallocate them back (1 call veto of the 1st Initiative) along with their new allocations (1 call veto for the new (2nd) Initiative).

  • As can be seen, each new Initiative requires 2(M1)+12 * (M - 1) + 1 calls to the expensive hooks resulting in the quadratic cost.

  • Since 1 Initiative becomes active every epoch, otherwise if all of them were in the same epoch the users could veto them at once and avoid the quadratic cost, the complexity could be also expressed in epochs and be O(E2)O(E^2) where EE is the number of epochs passed.


Alternative Expressions of the Issue:

The direct manipulation of the vetos could be deemed as the most important, but other expressions of the issue also exist that could be used to manipulate specific execution paths. For example:

  • Users who have voted for an Initiative that turned out to be malicious or compromised could experience the same issue. Malicious Initiatives detecting vote reduction could similarly treat it as vetoing and consume all the available gas making the hooks expensive.

  • Similarly, every resetting operation that even temporarily zeros all the votes before the reallocation could also be used to abuse the system. Any voted malicious Initiative could make any resetting operation quite expensive.

  • As a side effect, the vote removal from unregistered Initiatives (as described in L2) could also be used to make the deallocation unfair and expensive for the users who wish to remove their votes from the inactive Initiative.

M2

Initiative registration fees are used as rewards for the previous epoch

MEDIUM
resolved
M2
Initiative registration fees are used as rewards for the previous epoch

Resolved

The registration fee payment was moved after taking the epoch’s snapshots.
Note: This issue also existed in the previous version and was not yet resolved”

In the Governance contract, every time an epoch changes, calls to _snapshotVotes update the global state including the boldAccrued which stores the amount of BOLD tokens to be distributed among the Initiatives that became CLAIMABLE in the previous epoch.

Governance::_snapshotVotes:297
function _snapshotVotes() internal returns (
VoteSnapshot memory snapshot,
GlobalState memory state
) {
bool shouldUpdate;
(snapshot, state, shouldUpdate) = getTotalVotesAndState();

if (shouldUpdate) {
votesSnapshot = snapshot;
uint256 boldBalance = bold.balanceOf(address(this));
boldAccrued = (boldBalance < MIN_ACCRUAL) ? 0 : boldBalance;
emit SnapshotVotes(snapshot.votes, snapshot.forEpoch);
}
}

However, if we assume that the very first operation of the current epoch is an Initiative registration, the BOLD tokens paid as registration fees will be used by _snapshotVotes and will be considered as part of the rewards for the previous epoch. This happens because, inside the registerInitiative function, the fees are transferred to the contract before the snapshots are taken.

Governance::registerInitiative:477
function registerInitiative(
address _initiative
) external nonReentrant {
// Dedaub:
// The registration fee is transferred before accruing the BOLD tokens
// for the current epoch
bold.safeTransferFrom(msg.sender, address(this), REGISTRATION_FEE);

require(_initiative != address(0), "Governance: zero-address");

// Dedaub:
// This function calls _snapshotVotes which updates the accrued BOLD
// based on the current balance of the contract
(InitiativeStatus status,,) = getInitiativeState(_initiative);

require(status == InitiativeStatus.NONEXISTENT,
"Governance: initiative-already-registered");

address userProxyAddress = deriveUserProxyAddress(msg.sender);
(VoteSnapshot memory snapshot,) = _snapshotVotes();
UserState memory userState = userStates[msg.sender];
...
}

The snapshots should always be the first operation of functions that update the state to ensure that the previous epoch has been properly concluded before any updates for the current epoch are applied.

M3

Race condition could prevent users with sufficient voting power from registering new Initiatives

MEDIUM
acknowledged
M3
Race condition could prevent users with sufficient voting power from registering new Initiatives

Acknowledged

The team acknowledged the issue and it was deemed as contained since it only affects the registration of the Initiatives with user controlled parameters that only affect them. Thus, it is left on the users’ side to account for such a scenario if they are willing to register new Initiatives by avoiding depositing amounts that could make the issue manifest itself before they process any Initiative registrations.
Note: This issue also existed in the previous version and was not yet resolved. We also rephrased it so that it uses the new mechanism of the offsets instead of the average timestamps.”

For a user to be able to register a new Initiative, they need to pay a flat fee of 100 BOLD tokens and have a voting power greater than a percentage of the total positive (“YES”) votes that all the existing Initiatives have gathered. This was done to prevent system abuse by spamming Initiative registrations as the Governance::registerInitiative function is permissionless.

However, even if a user has sufficient voting power to register an Initiative, there exists a race condition in which they can be blocked from registering following the scenario below:

  • The user has staked enough LQTY in a previous epoch such that their voting power before the start of the current epoch surpasses the registration threshold.

  • In the running epoch, the user stakes an amount of LQTY that increases their offset (newUserOffset).

  • Then in the same block, the user tries registering a new Initiative. Following the invariant that when a user stakes more LQTY their voting power at that exact moment remains the same, the user should still be able to perform the registration operation.

  • However, this does not happen as the voting power calculation uses the epochStart() to calculate the user’s offset.

  • As a result, since totalUserLQTY * epochStart() - newUserOffset < Voting threshold, the user is not able to register a new initiative.

A demonstration of the problem above can be seen in the following graph:

Ethereum_Foundation

In this scenario, the user deposits 4 LQTY at t0t_0. The user’s voting power is V0(t)=4t4t0V_0(t) = 4t - 4t_0. Now in the next epoch (nn), if the user wants to register a new initiative his voting power allows him to do so because V0(tne)>VTV_0(t^e_{n}) > V_T.

Now consider that the user deposits 1 more LQTY at t2t_2 in the same epoch. The user’s new voting power changes to V2(t)=5t(t0+t2)V_2(t) = 5t - (t_0+t_2). After this deposit the user cannot register a new initiative because V2(tne)<VTV_2(t^e_{n}) < V_T.

Governance::registerInitiative:506
function registerInitiative(address _initiative) external nonReentrant {
...
uint256 upscaledSnapshotVotes = snapshot.votes;

uint256 totalUserOffset =
userState.allocatedOffset + userState.unallocatedOffset;

require(
lqtyToVotes(
stakingV1.stakes(userProxyAddress),
epochStart(),
totalUserOffset
)
>= upscaledSnapshotVotes * REGISTRATION_THRESHOLD_FACTOR / WAD,
"Governance: insufficient-lqty"
);
...
}

In the first version, the require statement above used the block.timestamp, but this was changed to epochStart() as part of the changes in the previous version.

Note: Please, also refer to the corresponding PoC below Proof of Concept - M3

M4

Insufficient check when claiming bribes from an Initiative

MEDIUM
resolved
M4
Insufficient check when claiming bribes from an Initiative

Resolved

These checks had remained as part of broader fixes for rounding errors in the bribe calculations which are, nevertheless, fixed in the current version rendering the checks redundant for which reason they were removed.
Note: This issue also existed in the previous version and was not yet resolved”

In BribeInitiative, the claimBribes function was adjusted to help mitigate rounding errors in the average timestamp calculations from Governance. The last user to claim their bribes may be eligible for more rewards than the existing funds in the contract due to those errors and the function was made so that it adjusts the amount to be given according to the available balance expecting that only the user’s funds will be available.

BribeInitiative::claimBribes:129
function claimBribes(
ClaimData[] calldata _claimData
) external returns (uint256 boldAmount, uint256 bribeTokenAmount) {
for (uint256 i = 0; i < _claimData.length; i++) {
...
(uint256 boldAmount_, uint256 bribeTokenAmount_) =
_claimBribe(...);
boldAmount += boldAmount_;
bribeTokenAmount += bribeTokenAmount_;
}

if (boldAmount != 0) {
// Dedaub:
// This adjustment is not sufficient to prevent the last user
// from claiming the excessive amount of rewards that may
// originate from the rounding errors of Governance as more
// BOLD tokens may exist in the contract

uint256 max = bold.balanceOf(address(this));
if (boldAmount > max) {
boldAmount = max;
}
bold.safeTransfer(msg.sender, boldAmount);
}
if (bribeTokenAmount != 0) {
uint256 max = bribeToken.balanceOf(address(this));
if (bribeTokenAmount > max) {
bribeTokenAmount = max;
}
bribeToken.safeTransfer(msg.sender, bribeTokenAmount);
}
}

However, this assumption is not always valid as anyone could have deposited more tokens for future bribes inflating the available balance. Moreover, the Initiative could have also claimed their rewards from Governance which would have increased the BOLD balance. As a result, the user’s amount will not be adjusted down as it should, but the amount with the rounding errors will be transferred consuming funds originating from other sources.

M5

Users could have their allocations degrade when they reallocate after depositing more LQTY

MEDIUM
acknowledged
M5
Users could have their allocations degrade when they reallocate after depositing more LQTY

Acknowledged

The team acknowledged the issue but it was found quite complex to be sufficiently fixed. The users can adjust their allocations so that they do not degrade their voting weight on the Initiatives, but they should also be aware that this cannot happen during the EPOCH_VOTING_CUTOFF period, during which they should refrain from depositing more LQTY or keep their allocations unchanged if they deposited should they want to keep their voting weights the same.

In the Governance contract, the new offset mechanism is used to account for various issues caused by the average timestamps of the previous versions. When a user stakes LQTY their state gets updated in the following way:

Governance::_increaseUserVoteTrackers:157
function _increaseUserVoteTrackers(
uint256 _lqtyAmount
) private returns (UserProxy) {
...
// update the vote power trackers
userState.unallocatedLQTY += _lqtyAmount;
userState.unallocatedOffset += block.timestamp * _lqtyAmount;
...
}

As can be seen, the offsets encode both the staked amount and the staking timestamp which could alternatively be interpreted as the offset encapsulating the idea of the average timestamps, but in a way that does not introduce the rounding errors of the previous versions.

The following describes the behaviour of the new offset mechanism:

Assuming the above, consider the following scenario which could make users experience more complexity in their interactions or even degradation in their allocations:

Ethereum_Foundation
  • At the time of the first deposit (t0t_0), the user allocates all their staked LQTY (m0m_0) on Initiative_A. This means that the Initiative’s offset becomes: m0t0m_0*t_0.

  • At a later point in time (t1t_1), the user decides to stake more LQTY, as described above, moving their offset to the value of: (t0+e2)2m0(t_0 + \frac{e}{2}) * 2m_0.

  • At the time of the second deposit (t1t_1), the user allocates their remaining LQTY (m0m_0) on Initiative_B. For this process, the protocol requires the user to deallocate all their existing allocations and then re-allocate them back along with the new allocations (this is enforced to account for some broader issues caused by users who (de)allocated their LQTY after depositing or withdrawing affecting their average timestamps and their voting power).

  • However, after the user’s second deposit, their voting weight has been uniformly distributed over the new staked LQTY balance which is 2m02m_0. This means that when the user allocates back the first m0m_0 LQTY to Initiative_A, it will not receive the same voting power as before because the user’s offset has been increased leading to a degraded voting weight for the same amount of m0m_0 LQTY.

For the above scenario to be prevented, the users should be aware of this property and keep track of their allocations and their staking operations so that they can adjust the LQTY amounts re-allocated to the Initiatives after a new deposit so that they give them the same voting weight as before by increasing the allocated LQTY for those Initiatives which nevertheless introduces some more complexity on the user’s side. For example, in the previous scenario, the user needs to allocate tn+1et0tn+1e(t0+e2)m0\frac{t^e_{n+1}-t_0}{t^e_{n+1}-(t_0+\frac{e}{2})}*m_0 for Initiative_A to preserve the same voting power at the end of the epoch (at tn+1et^e_{n+1}).

However, there is also a case in which the users cannot rebalance the difference as it becomes impossible to adjust their allocations. This happens during the EPOCH_VOTING_CUTOFF period, in which they are only allowed to allocate back to the Initiatives up to the amount they had previously allocated. This prevents them from distributing their new voting weight so that they do not degrade their votes from previously voted Initiatives, which nevertheless can make them lose part of their bribes due to the degraded votes.

Note: Please, also refer to the corresponding PoC below: Proof of Concept - M5

M6

Initiatives can be unregistered even when they are not stale

MEDIUM
resolved
M6
Initiatives can be unregistered even when they are not stale

Resolved

The team updated the protocol’s specification such that the Initiatives that are eligible for claiming rewards but never claimed them are also considered stale and be possible to be deemed as unregisterable if sufficient time has passed.
Note: This issue also existed in the previous version and was not yet resolved”

According to the defined specification the invariant that an Initiative can be unregistered if it has been stale (i.e. in a SKIP state) for more than UNREGISTRATION_AFTER_EPOCHS epochs is not followed. For example, in the following scenario a non-stale Initiative can become UNREGISTERABLE:

  • Assume that UNREGISTRATION_AFTER_EPOCHS = 4.

  • Epoch 1 - 2: Initiatives cannot yet be registered.

  • Epoch 3: Assume an Initiative has just been registered.

  • Epoch 4: The Initiative exits the warm-up period and becomes eligible for voting.

  • Epoch 6: UNREGISTRATION_AFTER_EPOCHS - 1 epochs have passed, since the warm-up period ended, with the Initiative not receiving sufficient votes to become CLAIMABLE in any of the previous epochs rendering it stale during that period.

  • Epoch 7: In this epoch, it finally receives enough votes to become CLAIMABLE. If it had not been voted for during this epoch, the Initiative would have become UNREGISTERABLE, as expected.

  • Epoch 8: In this epoch, the Initiative is CLAIMABLE, but let’s say its rewards are not claimed. This means that the rewards will be reused and Initiative’s lastEpochClaim will remain 0. Moreover, during this epoch, all voters remove their votes from the Initiative so that it is no longer CLAIMABLE.

  • Epoch 9: Now the Initiative can be immediately unregistered since lastEpochClaim + UNREGISTRATION_AFTER_EPOCHS < epoch() - 1 ⇔ 0 + 4 < 7 - 1 ⇔ 4 < 6. However, this breaks the invariant of unregistration since the Initiative was not stale for UNREGISTRATION_AFTER_EPOCHS consecutive epochs as it was CLAIMABLE in the previous epoch.

Note: Please, also refer to the corresponding PoC below: Proof of Concept - M6


LOW SEVERITY

L1

The bribe calculations in BribeInitiative may be off by 1 second

LOW
acknowledged
L1
The bribe calculations in BribeInitiative may be off by 1 second

In the BribeInitiative contract, the _claimBribe function was changed so that it supports the new offset mechanism for calculating each user’s voting contribution. Following these changes, the epochEnd variable was introduced to hold the value of the timestamp at which the epoch that we are claiming for ended.

BribeInitiative::_claimBribe:102
function _claimBribe(
address _user,
uint256 _epoch,
uint256 _prevLQTYAllocationEpoch,
uint256 _prevTotalLQTYAllocationEpoch
) internal returns (uint256 boldAmount, uint256 bribeTokenAmount) {
require(_epoch < governance.epoch(),
"BribeInitiative: cannot-claim-for-current-epoch");
...
// Dedaub:
// Seems to be off by 1 second as it calculates the epoch start
// of the next epoch rather than the end of the requested epoch
uint256 epochEnd =
governance.EPOCH_START() + _epoch * governance.EPOCH_DURATION();

uint256 totalVotes =
governance.lqtyToVotes(
totalLQTYAllocation.lqty, epochEnd, totalLQTYAllocation.offset
);
if (totalVotes != 0) {
...
uint256 votes = governance.lqtyToVotes(
lqtyAllocation.lqty, epochEnd, lqtyAllocation.offset);
boldAmount = bribe.boldAmount * votes / totalVotes;
bribeTokenAmount = bribe.bribeTokenAmount * votes / totalVotes;
}
...
}

However, its calculation is the same as the one in Governance::epochStart() which means that the epochEnd gets the value of the timestamp at which the next epoch starts and not when the requested epoch ends. As a result, the offset calculations could be off by 1 sec due to the above property.

L2

The allocation hook can be called on unregistered Initiatives

LOW
acknowledged
L2
The allocation hook can be called on unregistered Initiatives
Note: This issue also existed in the previous version and was not yet resolved”

When an Initiative gets unregistered it can still have active votes and vetos allocated to it which can be reset later. However, when the users deallocate their LQTY, the Initiative’s onAfterAllocateLQTY hook gets invoked even when this has been unregistered.

The existing Initiative implementations are not directly affected from this behavior, but one might have expected that once an Initiative gets DISABLED no more updates would have been expected to be forwarded from Governance. This could cause issues to Initiatives that depend on their registration status in Governance for their accounting in future versions.

We raise this issue here also for visibility in case this was not the intended behavior for unregistered Initiatives.

L3

Bribe tokens could end up locked in the BribeInitiative contract

LOW
partially resolved
L3
Bribe tokens could end up locked in the BribeInitiative contract

Partially resolved

The rounding errors in the calculations of the bribe rewards for each user were fixed also resolving the first scenario described below. Moreover, the team acknowledged the issue with the stuck tokens when there are no votes for an Initiative, but the possible fixes were deemed more error-prone with unnecessary complexity. Nevertheless, there is also a relatively easy workaround for the bribers to rescue their bribes by voting the Initiative with an infinitesimal amount so that they can claim the entire bribes back if there are no active votes from any other user.
Note: This issue also existed in the previous version and was not yet resolved”

There are two cases in which some of the bribe tokens could end up stuck in the BribeInitiative contract. More specifically:

  • In the BribeInitiative::_claimBribe function, the calculation of the bribe shares for a user is susceptible to rounding errors. For example, if the totalVotes do not divide exactly the user’s votes or the bribe token amount, then the remainder will be left in the contract since the resulting amount is rounded down. These tokens are not reused in the next epochs nor does a way to extract them exist which makes them locked in the contract.

  • If the epoch of a bribe passes with no allocations, the bribe’s funds will not be claimable by anyone, and at the same time there is no way to recover them, so they will remain locked in the contract. Although this does not pose any direct issues to the contract’s operation, ways to recover such funds could exist to rescue them in such a scenario.



OTHER / ADVISORY ISSUES

This section details issues that are not thought to directly affect the functionality of the project, but we recommend considering them.

A1

Comment on Governance::lqtyToVotes

ADVISORY
info
A1
Comment on Governance::lqtyToVotes

The new version of the protocol deprecated the average timestamps that previously existed for determining the voting power of an entity and offsets are now used which encodes both the LQTY amounts staked and the staking timestamps. As a result, the Governance::lqtyToVotes function was changed to calculate the voting power based on the distance between offsets.

Governance::lqtyToVotes:276
function lqtyToVotes(
uint256 _lqtyAmount,
uint256 _timestamp,
uint256 _offset
) public pure returns (uint256) {
uint256 prod = _lqtyAmount * _timestamp;
return prod > _offset ? prod - _offset : 0;
}

As can be seen above, the function can now be used only with the total amount of LQTY that was used to produce the _offset value with a timestamp greater than or equal to the one that the offset encodes. For example:

  • If a user staked at timestamp tt an amount of mm LQTY, then their offset would become tmt * m.

  • The lqtyToVotes for that user cannot be used for any LQTY amount less than mm because this would make prod < _offset.

We note this here because, in the previous version, the same function used the average timestamps which allowed getting voting powers with a subset of the total LQTY staked or allocated to an entity.

This functionality is not currently used anywhere in the protocol nor does it seem necessary for it. However, we highlight this difference in case future versions introduce new logic around this function.

A2

Stale checks from previous versions

ADVISORY
resolved
A2
Stale checks from previous versions

In the Governance::_allocateLQTY function, the length checks over the provided arrays have already been performed by the top-level allocateLQTY function. As a result, they can be removed from the internal function since they are also incomplete after the introduction of the offsets as the offset arrays are not checked to have the same length as the rest arrays.

Governance::_allocateLQTY:688
function _allocateLQTY(
address[] memory _initiatives,
int256[] memory _deltaLQTYVotes,
int256[] memory _deltaLQTYVetos,
int256[] memory _deltaOffsetVotes,
int256[] memory _deltaOffsetVetos
) internal {
// Dedaub:
// These checks have already been performed by allocateLQTY.
// They are also guaranteed by _resetInitiatives.
// Moreover, they are incomplete as they do not include the new
// _deltaOffsetVotes and _deltaOffsetVetos arrays
require(
_initiatives.length == _deltaLQTYVotes.length &&
_initiatives.length == _deltaLQTYVetos.length,
"Governance: array-length-mismatch"
);
}

A3

Misleading parameter types in allocateLQTY

ADVISORY
info
A3
Misleading parameter types in allocateLQTY

After several refactorings in the Governance::allocateLQTY function, the only meaningful values for the _absoluteLQTYVotes and _absoluteLQTYVetos arrays are positive amounts of LQTY to be allocated to Initiatives. This is because all allocations are reset before any (re)allocation.

As a result, these two parameters could be of type uint256 instead of int256 to avoid confusion during pure deallocations. When fully deallocating, users need to provide all the arrays empty except from the _initiativesToReset, compared to the first version in which a deallaction expected a negative value to be provided.

A4

Gas optimizations when registering a new Initiative

ADVISORY
info
A4
Gas optimizations when registering a new Initiative
Note: This advisory also existed in the previous version”

In Governance::registerInitiative function, there are several parts that could be simplified and save gas on operations that are not necessary for that functionality. For example, in order to check whether the Initiative exists or not the value of registeredInitiatives is sufficient. However, the call to getInitiativeState makes unnecessary calls to _snapshotVotesForInitiative even though the Initiative starts with an empty state. The important factor here is that old Initiatives cannot be re-registered.

In addition to that, getInitiativeState also calls _snapshotVotes which updates the global state. However, the same call is also performed by registerInitiative so that it uses the return values from memory. This means that the storage slots are read twice with no additional benefit for the operations performed.

A5

Possible gas optimization

ADVISORY
info
A5
Possible gas optimization

In the Governance::registerInitiative function in order to determine whether the user has sufficient voting power to register a new Initiative Liquity’s Staking V1 contract is queried to return the total user stakes. However, the same value can also be extracted from the sum of the allocatedLQTY and unallocatedLQTY values from the user’s state saving an external call and also some gas.

A6

Better error handling when withdrawing LQTY

ADVISORY
resolved
A6
Better error handling when withdrawing LQTY

In the Governance::withdrawLQTY function if a user requests to withdraw more LQTY than the available amount the execution will only revert when trying to subtract the requested amount from the unallocatedLQTY reverting due to an underflow exception. You could have a check earlier in the function so that you could revert the execution with a more representative error message.

A7

More representative function name

ADVISORY
info
A7
More representative function name

In Governance, the getLatestVotingThreshold function returns the voting threshold based on the cached global voting snapshot. However, this snapshot could be out-of-sync if no other operation that updates the global state has been performed in the meantime. You could maybe document this in the function, like in the previous versions, or use a more representative name for the function since the current one could be falsely interpreted as it always returns the most updated voting threshold.

A8

Remaining TODOs

ADVISORY
resolved
A8
Remaining TODOs

There are some remaining TODOs in the codebase which you may want to take care of before finalizing the codebase.

A9

Compiler bugs

ADVISORY
info
A9
Compiler bugs

The code is compiled with Solidity 0.8.24. Version 0.8.24 has no known bugs at the time of writing.



DISCLAIMER

The audited contracts have been analyzed using automated techniques and extensive human inspection in accordance with state-of-the-art practices as of the date of this report. The audit makes no statements or warranties on the security of the code. On its own, it cannot be considered a sufficient assessment of the correctness of the contract. While we have conducted an analysis to the best of our ability, it is our recommendation for high-value contracts to commission several independent audits, a public bug bounty program, as well as continuous security auditing and monitoring through Dedaub Security Suite.


ABOUT DEDAUB

Dedaub offers significant security expertise combined with cutting-edge program analysis technology to secure some of the most prominent protocols in DeFi. The founders, as well as many of Dedaub's auditors, have a strong academic research background together with a real-world hacker mentality to secure code. Protocol blockchain developers hire us for our foundational analysis tools and deep expertise in program analysis, reverse engineering, DeFi exploits, cryptography and financial mathematics.


APPENDIX

Proof of Concept - H1

Proof of Concept - H1
function test_PoC_H1_BribeMisCalculation() public
vm.warp(block.timestamp + (EPOCH_DURATION)); // We are now at epoch 1
_stakeLQTY(user1, 10e18); // user 1 stakes 10e18

_stakeLQTY(user2, 10e18); // user 2 stakes 10e18

// Deposit 1e18 bribes for bribeInitiative1
_depositBribe(
address(bribeInitiative1), 1e18, 1e18, governance.epoch());

// user 1 allocates 5e18 for bribeInitiative1 and 5e18 for
// bribeInitiative2
_allocateLQTYUser1(5e18, 5e18);

// user 2 allocates 5e18 for bribeInitiative1
_allocateLQTYUser2(5e18);

vm.warp(block.timestamp + (EPOCH_DURATION)); // We are now at epoch 2
// Should be able to claim epoch 1
uint256 prevEpoch = governance.epoch() - 1;

(uint256 bribeTotal, ) = bribeInitiative1.bribeByEpoch(prevEpoch);
console.log(
"Epoch %d: Amount of bribes in bribeInitiative1 for Epoch %d is %e",
governance.epoch(), prevEpoch, bribeTotal );

uint256 boldAmount = _claimBribe(
user1, prevEpoch, prevEpoch, prevEpoch);

console.log(
"Epoch %d: user 1 allocated %d% of the total votes of
bribeInitiative1 and claims %e bribes", governance.epoch(),
_getUserShareOfAllocationAsPercentage(user1 ,prevEpoch),
boldAmount
);

boldAmount = _claimBribe(user2, prevEpoch, prevEpoch, prevEpoch);

console.log(
"Epoch %d: user 2 allocated %d% of the total votes of
bribeInitiative1 and claims %e bribes", governance.epoch(),
_getUserShareOfAllocationAsPercentage(user2 ,prevEpoch),
boldAmount
);

console.log("Epoch %d: Amount of unclaimed bribes is %e",
governance.epoch(), lusd.balanceOf(address(bribeInitiative1)) );
console.log("Epoch %d: Can user 1 claim more bribes for Epoch %d?",
governance.epoch(), prevEpoch);

console.logBool(
!bribeInitiative1.claimedBribeAtEpoch(user1, prevEpoch));

console.log(
"Epoch %d: Can user 2 claim more bribes for Epoch %d?",
governance.epoch(), prevEpoch);
console.logBool(
!bribeInitiative1.claimedBribeAtEpoch(user2, prevEpoch));
}

/**
* Helpers
*/
function _stakeLQTY(address staker, uint256 amount) internal {
vm.startPrank(staker);
address userProxy = governance.deriveUserProxyAddress(staker);
lqty.approve(address(userProxy), amount);
governance.depositLQTY(amount);
vm.stopPrank();
}

function _allocateLQTYUser2(int256 absoluteVoteLQTYAmt) internal {
vm.startPrank(user2);
address[] memory initiativesToReset;

address[] memory initiatives = new address[](1);
initiatives[0] = address(bribeInitiative1);

int256[] memory absoluteVoteLQTY = new int256[](1);
absoluteVoteLQTY[0] = absoluteVoteLQTYAmt;

int256[] memory absoluteVetoLQTY = new int256[](1);
absoluteVetoLQTY[0] = 0;

governance.allocateLQTY(
initiativesToReset, initiatives,
absoluteVoteLQTY, absoluteVetoLQTY
);
vm.stopPrank();
}

function _allocateLQTYUser1(
int256 absoluteVoteLQTYAmt1,
int256 absoluteVoteLQTYAmt2
) internal {
vm.startPrank(user1);
address[] memory initiativesToReset;

address[] memory initiatives = new address[](2);
initiatives[0] = address(bribeInitiative2);
initiatives[1] = address(bribeInitiative1);

int256[] memory absoluteVoteLQTY = new int256[](2);
absoluteVoteLQTY[0] = absoluteVoteLQTYAmt2;
absoluteVoteLQTY[1] = absoluteVoteLQTYAmt1;

int256[] memory absoluteVetoLQTY = new int256[](2);
absoluteVetoLQTY[0] = 0;
absoluteVetoLQTY[1] = 0;

governance.allocateLQTY(
initiativesToReset, initiatives,
absoluteVoteLQTY, absoluteVetoLQTY
);
vm.stopPrank();
}

function _depositBribe(
address _initiative,
uint256 boldAmount,
uint256 bribeAmount,
uint256 epoch
) public {
vm.startPrank(lusdHolder);
lqty.approve(_initiative, boldAmount);
lusd.approve(_initiative, bribeAmount);
BribeInitiative(_initiative).depositBribe(
boldAmount, bribeAmount, epoch);
vm.stopPrank();
}

function _claimBribe(
address claimer,
uint256 epoch,
uint256 prevLQTYAllocationEpoch,
uint256 prevTotalLQTYAllocationEpoch
) public returns (uint256 boldAmount) {
vm.startPrank(claimer);
BribeInitiative.ClaimData[] memory epochs =
new BribeInitiative.ClaimData[](1);
epochs[0].epoch = epoch;
epochs[0].prevLQTYAllocationEpoch = prevLQTYAllocationEpoch;
epochs[0].prevTotalLQTYAllocationEpoch = prevTotalLQTYAllocationEpoch;
(boldAmount,) = bribeInitiative1.claimBribes(epochs);
vm.stopPrank();
}

function _getUserShareOfAllocationAsPercentage(
address user,
uint256 epoch
) internal returns (uint256 userShareOfTotalAllocated) {
(uint256 userLqtyAllocated,) =
bribeInitiative1.lqtyAllocatedByUserAtEpoch(user, epoch);

(uint256 totalLqtyAllocated,) =
bribeInitiative1.totalLQTYAllocatedByEpoch(epoch);

userShareOfTotalAllocated =
(uint256(userLqtyAllocated) * 100) / uint256(totalLqtyAllocated);
}

Proof of Concept - M3

Proof of Concept - M3
function test_PoC_M3_DepositAndLoseVotingPower() public {

// =================== EPOCH 3 ======================= //
vm.warp(block.timestamp + 2 * EPOCH_DURATION);

// Give some lusd to both users
vm.startPrank(lusdHolder);
lusd.transfer(user, 2e18);
lusd.transfer(user2, 2e18);
vm.stopPrank();

vm.startPrank(user2);

address userProxy2 = governance.deployUserProxy();
lusd.approve(address(governance), 2e18);
lqty.approve(address(userProxy2), 1e18);

// User_2 deposits 1e18 lqty for staking
governance.depositLQTY(1e18);

// User_2 registers an initiative
governance.registerInitiative(baseInitiative);

// =================== EPOCH 4 ======================= //
vm.warp(block.timestamp + EPOCH_DURATION);

// User_2 votes for his initiative with 1e18 lqty
address[] memory initiativesToReset1;
address[] memory initiatives1 = new address[](1);
initiatives1[0] = baseInitiative;
int256[] memory deltaLQTYVotes1 = new int256[](1);
deltaLQTYVotes1[0] = 1e18;
int256[] memory deltaLQTYVetos1 = new int256[](1);
governance.allocateLQTY(
initiativesToReset1, initiatives1,
deltaLQTYVotes1, deltaLQTYVetos1
);

vm.stopPrank();
vm.startPrank(user);

// =================== EPOCH 5 ======================= //
vm.warp(block.timestamp + EPOCH_DURATION);
address userProxy = governance.deployUserProxy();
lusd.approve(address(governance), 2e18);
lqty.approve(address(userProxy), 1_000e18);

// User_1 deposits 1e18 lqty for staking
governance.depositLQTY(1e18);


// =================== EPOCH 6 ======================= //
vm.warp(block.timestamp + EPOCH_DURATION);

// User_1 registers a new initiative
// It works because he has more voting power than the threshold
governance.registerInitiative(baseInitiative2);


// =================== EPOCH 6.5 ======================= //
vm.warp(block.timestamp + EPOCH_DURATION/2);

// User_1 deposits 2e18 lqty for staking
governance.depositLQTY(2e18);

// User_1 registers another initiative
// It fails because the by depositing more lqty the user reduced his
// voting power at EpochStart()
vm.expectRevert("Governance: insufficient-lqty");
governance.registerInitiative(baseInitiative3);

vm.stopPrank();
}

Proof of Concept - M5

Proof of Concept - M5
function test_PoC_M5_DepositAndLoseVotesForInitiative() public {

// =================== EPOCH 2 ======================= //
vm.warp(block.timestamp + 2*EPOCH_DURATION);

// give usd to user and register initiatives
vm.startPrank(lusdHolder);
lusd.transfer(user, 2e18);
lusd.approve(address(governance), 3e18);
governance.registerInitiative(baseInitiative2);
governance.registerInitiative(baseInitiative3);
vm.stopPrank();

// =================== EPOCH 3 ======================= //
vm.warp(block.timestamp + EPOCH_DURATION);


vm.startPrank(user);

// Deposit 1e18 lqty for staking
address userProxy = governance.deployUserProxy();
lqty.approve(address(userProxy), 1_000e18);
governance.depositLQTY(1e18);


// =================== EPOCH 4 ======================= //
vm.warp(block.timestamp + EPOCH_DURATION);

// Vote for baseInitiative2 with 1e18 lqty
address[] memory initiativesToReset1;
address[] memory initiatives1 = new address[](1);
initiatives1[0] = baseInitiative2;
int256[] memory deltaLQTYVotes1 = new int256[](1);
deltaLQTYVotes1[0] = 1e18;
int256[] memory deltaLQTYVetos1 = new int256[](1);

governance.allocateLQTY(
initiativesToReset1, initiatives1,
deltaLQTYVotes1, deltaLQTYVetos1
);

(IGovernance.InitiativeVoteSnapshot memory initiativeSnapshot,
IGovernance.InitiativeState memory initiativeState,
) = governance.getInitiativeSnapshotAndState(baseInitiative2);

uint256 votesOld = governance.lqtyToVotes(
initiativeState.voteLQTY,
(governance.epochStart() + EPOCH_DURATION),
initiativeState.voteOffset
);

console.log(
"After the first vote, the expected votes to
baseInitiative2 will be: ", votesOld);


// =================== EPOCH 4 (5th day) ======================= //
vm.warp(block.timestamp + 5*EPOCH_DURATION/7);

// Deposit 1e18 lqty for staking (need to reset his votes first)
governance.resetAllocations(initiatives1, false);
governance.depositLQTY(1e18);

// Votes for baseInitiative2 and baseInitiative3 with 1e18 lqty each
address[] memory initiativesToReset2;
address[] memory initiatives2 = new address[](2);
initiatives2[0] = baseInitiative2;
initiatives2[1] = baseInitiative3;
int256[] memory deltaLQTYVotes2 = new int256[](2);
deltaLQTYVotes2[0] = 1e18;
deltaLQTYVotes2[1] = 1e18;
int256[] memory deltaLQTYVetos2 = new int256[](2);

governance.allocateLQTY(
initiativesToReset2, initiatives2,
deltaLQTYVotes2, deltaLQTYVetos2
);

(initiativeSnapshot, initiativeState, ) =
governance.getInitiativeSnapshotAndState(baseInitiative2);

uint256 votesNew = governance.lqtyToVotes(
initiativeState.voteLQTY,
(governance.epochStart() + EPOCH_DURATION),
initiativeState.voteOffset
);

console.log("After the second vote,
the votes to baseInitiative2 will be: ", votesNew);

console.log("The amount of votes for baseInitiative2
is degraded by %s%", (votesOld-votesNew)*100/votesOld);

vm.stopPrank();
}

Proof of Concept - M6

Proof of Concept - M6
function test_PoC_M6_unregisterInitiativeWhenNotStale() public {

// assuming governance has UNREGISTRATION_AFTER_EPOCHS = 4
vm.startPrank(user);
address userProxy = governance.deployUserProxy();
vm.stopPrank();

vm.startPrank(lusdHolder);
lusd.transfer(user, 1e18);
vm.stopPrank();

vm.startPrank(user);

lusd.approve(address(governance), 1e18);
lqty.approve(address(userProxy), 1e18);
governance.depositLQTY(1e18);

vm.warp(block.timestamp + EPOCH_DURATION + EPOCH_DURATION
); // go to epoch 3, when initiatives can first be registered

governance.registerInitiative(baseInitiative3);

uint256 atEpoch = governance.registeredInitiatives(baseInitiative3);
assertEq(atEpoch, governance.epoch());

vm.warp(block.timestamp + (
governance.UNREGISTRATION_AFTER_EPOCHS()) *
governance.EPOCH_DURATION()
); // go to epoch 7

address[] memory initiatives_to_reset = new address[](0);
address[] memory initiatives = new address[](1);
initiatives[0] = baseInitiative3;
int256[] memory deltaLQTYVotes = new int256[](1);
deltaLQTYVotes[0] = 1e18;
int256[] memory deltaLQTYVetos = new int256[](1);
deltaLQTYVetos[0] = 0;

governance.allocateLQTY(
initiatives_to_reset, initiatives, deltaLQTYVotes, deltaLQTYVetos);

(,, uint256 allocatedLQTY,) = governance.userStates(user);
assertEq(allocatedLQTY, 1e18);

// go to epoch 8
vm.warp(block.timestamp + governance.EPOCH_DURATION());

(IGovernance.InitiativeStatus status,,) =
governance.getInitiativeState(baseInitiative3);

assertEq(
uint8(status), uint8(IGovernance.InitiativeStatus.CLAIMABLE));

address[] memory emptyInitiatives = new address[](0);
int256[] memory emptyDeltaLQTYVotes = new int256[](0);
int256[] memory emptyDeltaLQTYVetos = new int256[](0);

governance.allocateLQTY(
initiatives, emptyInitiatives,
emptyDeltaLQTYVotes, emptyDeltaLQTYVetos
);

(,, allocatedLQTY,) = governance.userStates(user);
assertEq(allocatedLQTY, 0);

// go to epoch 9
vm.warp(block.timestamp + governance.EPOCH_DURATION());

governance.unregisterInitiative(baseInitiative3);
}