Skip to main content
Liquity v2 ~ Governance (2nd audit) - Nov 11, 2024

Liquity v2 ~ Governance (2nd audit)

Smart Contract Security Assessment

Nov 11, 2024

Liquity

SUMMARY

ID
Description
STATUS
P1
Important considerations about the rounding errors
info
P2
Differences in the new implementation
info
P3
Initiative hooks can be called twice during an allocation
info
P4
Time sensitive Initiatives could experience issues with the resetting mechanism
info
P5
Vetos can be withdrawn after the voting cutoff period
info
H1
New Initiatives can still be unregistered right after the warm-up period ends
resolved
H2
Initiatives can be unregistered even when they are not stale
resolved
H3
Race condition can break an Initiative’s accounting
resolved
H4
Deallocating from unregistered Initiatives breaks the global state of the protocol
resolved
H5
Bribe claiming can be blocked in ForwardBribe
resolved
M1
Edge case when the new LQTY balance is comparable to the accumulated voting power
resolved
M2
Voting can be made financially unfair by manipulating the gas cost of hooks
partially resolved
M3
Initiative registration fees are used as rewards for the previous epoch
resolved
M4
Race condition could prevent users with sufficient voting power from registering new Initiatives
acknowledged
M5
The new reset logic can lead to gas-based DoS locking user funds
resolved
M6
Insufficient check when claiming bribes from an Initiative
resolved
L1
Old checks are no longer valid after the code refactoring
resolved
L2
The allocation hook can be called on unregistered Initiatives
acknowledged
L3
Bribe tokens could end up locked in the BribeInitiative contract
partially resolved
A1
Several gas optimizations could improve the allocation updates on Initiatives
info
A2
Misleading parameter types in allocateLQTY
info
A3
Gas optimizations when querying the state of an Initiative
info
A4
Gas optimizations when registering a new Initiative
info
A5
Unnecessary use of the return variable
info
A6
Possible stale specification
info
A7
Redundant checks
info
A8
Wrong comment in _allocateLQTY
info
A9
Compiler bugs
info

ABSTRACT

Dedaub was commissioned to perform a security re-audit of the Liquity v2 Governance protocol. Dedaub has audited the first version of the codebase in a previous assignment the report of which can be found at (Liquity v2 ~ Governance (1st audit) - Aug 12, 2024). In the current version, several core parts of the protocol have been refactored to address issues found during audits. Because of the extent of the changes, this review considered the codebase in its entirety and not only the delta of the changes between the two versions.

The code was improved in several important parts compared to the previous version, but some of the reported issues remained while others were introduced by the new changes which led to a few High and Medium severity issues.

The test suite was also extended with more test cases to better cover the most important parts of the protocol. However, we strongly recommend extending the suite with even more complex and realistic scenarios involving several users, custom Initiatives, random timestamps and scenarios that would stress test the protocol.


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 #17 at commit 549877d976781b390207e0faacbc567a614c9681.

Two auditors worked on the codebase for 8 days on the following contracts:

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

The fixes of the issues included in the report were mostly reviewed as part of a separate full re-audit of the codebase which can be found here: Liquity v2 ~ Governance (3rd audit) - Dec 22, 2024

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

Important considerations about the rounding errors

PROTOCOL-LEVEL-CONSIDERATION
info
P1
Important considerations about the rounding errors

During the audits, issues with the protocol’s calculations affecting its bookkeeping were discovered. They concern rounding issues in a couple of places with the most important and impactful being in the _calculateAverageTimestamp function which is responsible for moving the users’, the Initiatiaves’ or the global average timestamp back and forth in time so that it properly reflects the virtual point in time in which the entity should have ideally deposited their LQTY such that if multiplied with the new LQTY balance it would result in the voting power they are currently entitled to.

However, in some cases, these calculations were found to introduce significant rounding errors that desync the properties and the invariants which should otherwise apply as long as the protocol operates.

Some representative factors that seem to contribute to the manifestation of these errors, which of course are only a subset of all the possible conditions, are the following:

  • The time elapsed since the users deposited their LQTY until they allocate on Initiatives

  • The time elapsed between an allocation and a deallocation on the same Initiative

  • The amount of LQTY staked by a user or allocated on Initiatives compared to the amounts accumulated by individual Initiatives so far or in total for the global state.

  • The order in which the allocations of an Initiative are made, especially when some of them are of minute amounts compared to the total amounts.

All the examples above affect either the correlation between the global state and the summation of the states of all the Initiatives, the state of the individual Initiatives, the users’ voting powers or other properties that combine the states.

The test suite contains examples and cases trying to investigate and possibly mitigate those issues, but the extent of their dependencies and the numerous execution flows with random user inputs make this attempt quite complex and difficult to resolve.

We also conducted some tests on our end trying to investigate more some of the most affected cases. A simple example that resolved one of them was to increase the precision of the division operations in the Governance::_calculateAverageTimestamp function, but, on the other hand, it did not resolve other expressions of the issue that originated from factors like the order of the allocations or time-sensitive events like multiple allocations and deallocations of the same user in the same or different blocks.

It is unclear if these issues can be eliminated entirely, but a lot more testing and investigation is certainly required to first help identify all the possible affected scenarios and then to find the best possible solution or mitigation such that the protocol does not become insolvent or fall into unrecoverable conditions.

P2

Differences in the new implementation

PROTOCOL-LEVEL-CONSIDERATION
info
P2
Differences in the new implementation

The refactoring of the code introduced changes in the way core parts of the code are used as well as differences in the specification that was initially defined which are listed below for visibility:

  • The REGISTRATION_WARM_UP_PERIOD constant is no longer used in the Governance contract. However, it is still being initialized in the constructor. The check that used that constant and previously was in the unregisterInitiative function was moved to the new getInitiativeState function which now uses the hardcoded constraint that the warm-up period lasts 1 epoch.

  • In the previous version, the unregistered Initiatives could be registered again in the future by depositing the 100 BOLD registration fee. However, in the current version the Initiatives can only be registered once.

  • In the current version, bribes are possible to be deposited for the current epoch when in the previous version bribes could be deposited only for future epochs.

P3

Initiative hooks can be called twice during an allocation

PROTOCOL-LEVEL-CONSIDERATION
info
P3
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 which means that during a single allocation the hook can be called twice for the same address possibly breaking the implemented checks.

P4

Time sensitive Initiatives could experience issues with the resetting mechanism

PROTOCOL-LEVEL-CONSIDERATION
info
P4
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.

P5

Vetos can be withdrawn after the voting cutoff period

PROTOCOL-LEVEL-CONSIDERATION
info
P5
Vetos can be withdrawn after the voting cutoff period

In Governance::allocateLQTY function, the specifications suggest that during the voting cutoff period, no one can vote for an Initiative, but only veto or remove their votes. This was enforced in the new implementation, but the withdrawal of the vetos is still possible during that period. If withdrawing vetos could be interpreted as voting for an Initiative it may be considered as a deviation from the specification.



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

New Initiatives can still be unregistered right after the warm-up period ends

HIGH
resolved
H1
New Initiatives can still be unregistered right after the warm-up period ends

Resolved

The lastEpochClaim variable is set to the current epoch on registration to avoid this issue.

In the previous version, there was an issue that allowed anyone to unregister any new Initiative right after its registration abusing the system and permissionlessly censoring specific Initiatives from entering the voting process. A REGISTRATION_WARM_UP_PERIOD constant was added in an attempt to prevent immediate unregistrations, but this only prevented them during the warm-up period. After that period ended, the unregistration was possible again due to the uninitialized values of the Initiative’s state. In this version, although new logic was introduced to determine the state in which an Initiative is, the issue still exists.

More specifically, for an Initiative to be marked as UNREGISTERABLE any of the following should apply:

  • Be inactive for more than UNREGISTRATION_AFTER_EPOCHS epochs
  • Have more vetos than votes and the vetos be above a threshold (3x more than votes)
Governance::getInitiativeState:463
function getInitiativeState(...) public view returns (...) {
...
// == Unregister Condition == //
// e.g. if `UNREGISTRATION_AFTER_EPOCHS` is 4, the 4th epoch flip
// that would result in SKIP, will result in the initiative being
// `UNREGISTERABLE`
if (
(_initiativeState.lastEpochClaim + UNREGISTRATION_AFTER_EPOCHS <
epoch() - 1)
|| _votesForInitiativeSnapshot.vetos >
_votesForInitiativeSnapshot.votes
&& _votesForInitiativeSnapshot.vetos >
votingTheshold * UNREGISTRATION_THRESHOLD_FACTOR / WAD
) {
return (InitiativeStatus.UNREGISTERABLE, lastEpochClaim, 0);
}

// == Not meeting threshold Condition == //
return (InitiativeStatus.SKIP, lastEpochClaim, 0);
}

However, the inactivity is determined by the lastEpochClaim which is the epoch in which the Initiative last claimed its rewards indicating that it is not stale. For newly registered Initiatives this value is initialized to 0 which means that if the protocol has operated for more than UNREGISTRATION_AFTER_EPOCHS epochs, then the Initiative becomes immediately UNREGISTERABLE when the warm-up period ends.

A side effect of this issue is that all Initiatives after the warm-up period will immediately become UNREGISTERABLE blocking any allocation on them to be made since the only allowed states for increasing an allocation are: SKIP, CLAIMABLE and CLAIMED.

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

H2

Initiatives can be unregistered even when they are not stale

HIGH
resolved
H2
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.

Considering what was discussed in H1, there is another way that breaks one of the Initiative unregistration invariants. The invariant that breaks is that an Initiative can be unregistered if it has been stale (i.e. in a SKIP state) for more than UNREGISTRATION_AFTER_EPOCHS epochs.

However, in the following scenario a non-stale Initiative can become UNREGISTERABLE:

  • Epoch 0: Assume that UNREGISTRATION_AFTER_EPOCHS = 4 and an Initiative has just been registered.

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

  • Epoch 4: 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 5: In this epoch, it finally receives enough votes to become CLAIMABLE. Shouldn’t it had been voted during that epoch, the Initiative would have become UNREGISTERABLE as expected.

  • Epoch 6: In this epoch, the Initiative is CLAIMABLE, but, for any reason, it does not claim its rewards. This means that the rewards will be reused in this epoch and Initiative’s lastEpochClaim will remain 0. Moreover, during that epoch, all voters remove their votes from the Initiative so that it is not longer CLAIMABLE.

  • Epoch 7: 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 - H2

H3

Race condition can break an Initiative’s accounting

HIGH
resolved
H3
Race condition can break an Initiative’s accounting

Resolved

The new version of the protocol removed the average timestamps and introduced a new mechanism which also solves this issue as the (de)allocations of the users are not affected directly by the moving average timestamps. Moreover, the users are enforced to reset all their allocations before allocating to any Initiative which is also resolves the issue.

In the Governance::_allocateLQTY function, there is a race condition which can break the accounting of the Initiatives when deallocating. More specifically, consider the following scenario:

  • A user has staked LQTY and has accumulated a voting power PP which corresponds to a timestamp tusert_{user} for their staked amount of muserm_{user} LQTY.

  • They allocate all their votes to an Initiative. For simplicity, assume that this is the only voter of the Initiative and this is the first allocation it has received. This means that at the time of allocation, the Initiative receives the exact same vote power as the user currently has. The Initiative’s timestamp becomes tinit=tusert_{init} = t_{user} and both the user’s and the Initiative’s lines grow linearly with the same rate.

  • At some point in the future the user stakes more LQTY which forwards their average timestamp towards block.timestamp (tusernewt_{user_{new}}), but their voting power at the time of staking remains the same.

  • In the same block, the user decides to deallocate almost the entire amount of their votes from the Initiative above, but not all of them so that the timestamps are not zeroed.

  • The _allocateLQTY function will try to recalculate the Initiative’s average timestamp and push it back in time so that it reflects the remaining vote power correctly. However, for this operation, it uses the user’s average timestamp which has just been moved forward and is different than the one with which the user allocated their LQTY on the Initiative in the first place.

  • As a result, after the deallocation, the Initiative gets a reduced vote power than the one it should have since the line that will be subtracted is: (muserx)(ttusernew)(m_{user} - x)(t - t_{user_{new}}) with tusernew>tusert_{user_{new}} > t_{user} and xx the LQTY that will be left allocated to the Initiative. The correct line to subtract would be: (muserx)(ttuser)(m_{user} - x)(t - t_{user}).

Another issue that appears from a scenario similar to this one can also be found in M4.

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

H4

Deallocating from unregistered Initiatives breaks the global state of the protocol

HIGH
resolved
H4
Deallocating from unregistered Initiatives breaks the global state of the protocol

Resolved

The global state updates were moved inside the if statement.

When an Initiative gets unregistered users that had previously voted on it are only allowed to fully deallocate their votes from that Initiative.

In an attempt to address accounting issues originating from rounding errors in the average timestamps calculation, the Governance::unregisterInitiative was refactored so that the Initiative’s votes are immediately removed from the global state. Following this change, the _allocateLQTY function was also modified to prevent double removing the Initiative’s allocations from the global state once the users deallocate their votes.

Governance::_allocateLQTY:719
function _allocateLQTY(...) internal {
...
for (uint256 i = 0; i < _initiatives.length; i++) {
...
// Dedaub:
// This check prevents double removing the Initiative's votes
// from the global state when the users deallocate their votes
if (status != InitiativeStatus.DISABLED) {
state.countedVoteLQTYAverageTimestamp =
_calculateAverageTimestamp(
state.countedVoteLQTYAverageTimestamp,
prevInitiativeState.averageStakingTimestampVoteLQTY,
state.countedVoteLQTY,
state.countedVoteLQTY - prevInitiativeState.voteLQTY
);
assert(state.countedVoteLQTY >= prevInitiativeState.voteLQTY);
state.countedVoteLQTY -= prevInitiativeState.voteLQTY;
}

// Dedaub:
// This segment adds back to the global state the Initiative's
// votes that were previously removed breaking the accounting,
// with no way to recover, when the Initiative has been
// previously unregistered
state.countedVoteLQTYAverageTimestamp =
_calculateAverageTimestamp(
state.countedVoteLQTYAverageTimestamp,
initiativeState.averageStakingTimestampVoteLQTY,
state.countedVoteLQTY,
state.countedVoteLQTY + initiativeState.voteLQTY
);
state.countedVoteLQTY += initiativeState.voteLQTY;
...
}
...
}

The main idea behind the logic of the segment above is to remove the previous allocation of the Initiative and add back the new allocation with the updated values and timestamps. However, the second part of this logic was left out of the condition that prevents an unregistered Initiative from updating the global state leading to unrecoverable accounting issues.

The issue is that for every user who deallocates their LQTY from that unregistered Initiative the remaining LQTY is added back to the global state when it should not. For example:

  • Assume that 5 users had allocated to an Initiative that was unregistered. One of them had allocated, say, 100e18 LQTY and the other four 1e18 LQTY each. This makes the Initiative have a total of 104e18 LQTY allocated.

  • The 4 with the small allocations start deallocating their LQTY. For each deallocation the remainder of the total LQTY - user's allocation gets re-added to the global state.

  • This results in inflating the global votes by: 103 + 102 + 101 + 100 LQTY = 406 LQTY more than supposed with no ability to restore the global state.

  • This affects the voting threshold which is used to determine whether a user has sufficient voting power to register a new Initiative and most importantly is used to determine whether an Initiative is eligible for receiving rewards or not.

As can be seen, in the long term this could completely disable the purpose of the protocol which is to distribute accrued BOLD tokens to voted Initiatives.

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

H5

Bribe claiming can be blocked in ForwardBribe

HIGH
resolved
H5
Bribe claiming can be blocked in ForwardBribe

Resolved

The file was removed from the codeabse as it was only another example implementation extension of the BribeInitiative module.

The ForwardBribe contract was introduced in the current version and it is another implementation of the base BribeInitiative contract. The only function it implements claims the BOLD tokens from Governance, if eligible, and transfers all the available balance of the contract to the prespecified receiver.

ForwardBribe::forwardBribe:20
function forwardBribe() external {
governance.claimForInitiative(address(this));
// Dedaub:
// This function permissionlessly transfers the entire balance of the
// Initiative to the predefined receiver without considering the
// tokens that should be left for the bribes
uint boldAmount = bold.balanceOf(address(this));
uint bribeTokenAmount = bribeToken.balanceOf(address(this));

if (boldAmount != 0) bold.transfer(receiver, boldAmount);
if (bribeTokenAmount != 0) bribeToken.transfer(
receiver, bribeTokenAmount);
}

However, this does not account for the tokens that should be left behind to cover the bribes entitled to be given to users who may have voted for that Initiative. The function can be permissionlessly called and could make the contract insolvent and unable to cover the debt to the users.



MEDIUM SEVERITY

M1

Edge case when the new LQTY balance is comparable to the accumulated voting power

MEDIUM
resolved
M1
Edge case when the new LQTY balance is comparable to the accumulated voting power

Resolved

The deprecation of the average timestamp and the introduction of the new offset mechanism resolved that issue also ensuring that no value can go beyond the current block.timestamp.

In the Governance contract, the _calculateAverageTimestamp function adjusts the average timestamps according to the previous and the new balances. However, there is a race condition in which a non-zero voting power gets zeroed after the increment of the LQTY balance of the user/Initiative.

As long as there are no changes in the LQTY balance, the voting power grows linearly and can be expressed by the following line: m0(tt0)m_0 * (t - t_0) (with m0m_0 the amount of LQTY and t0t_0 the average timestamp).

When we increase the amount of the allocated LQTY, the line: m1(tt1)m_1 * (t - t_1) is added to the original line resulting in the following: (m0+m1)(ttnew)(m_0 + m_1) * (t - t_{new}) with tnew=m0t0age+m1t1agem0+m1t_{new} = \frac{m_0 * t_{0_{age}} + m_1 * t_{1_{age}}}{m_0 + m_1} with txage=(ttx)t_{x_{age}} = (t - t_x). The new amount of LQTY staked starts with 0 voting power which means that in the calculation above: t1age=0t1=t=block.timestampt_{1_{age}} = 0 \Leftrightarrow t_1 = t = block.timestamp.

The protocol defines a hard constraint (invariant) which states that the voting power of a user remains the same the moment they stake more LQTY. Their average timestamp is recalculated such that the multiplication with the new increased LQTY amount results in the same voting power. However, this only applies to the moment of staking since every second that passes afterwards increases the voting power faster due to the new increased LQTY amount (m0+m1m_0 + m_1).

Considering the above, the edge case may manifest itself should the _newLQTYBalance become greater than the previous voting power. In such a scenario the new average timestamp will become equal to block.timestamp and will effectively reset the voting power to 0 even though a non-zero voting power previously existed breaking the invariant.

Governance::_calculateAverageTimestamp:143
function _calculateAverageTimestamp(
uint32 _prevOuterAverageTimestamp,
uint32 _newInnerAverageTimestamp,
uint88 _prevLQTYBalance,
uint88 _newLQTYBalance
) internal view returns (uint32) {
if (_newLQTYBalance == 0) return 0;
// prevOuterAverageAge = t0_age = (t - t0)
uint32 prevOuterAverageAge =
_averageAge(uint32(block.timestamp), _prevOuterAverageTimestamp);
// newInnerAverageAge = t1_age = (t - t1)
uint32 newInnerAverageAge =
_averageAge(uint32(block.timestamp), _newInnerAverageTimestamp);

uint88 newOuterAverageAge;
if (_prevLQTYBalance <= _newLQTYBalance) {
// deltaLQTY = m1
uint88 deltaLQTY = _newLQTYBalance - _prevLQTYBalance;
// prevVotes = m0 * t0_age
uint240 prevVotes =
uint240(_prevLQTYBalance) * uint240(prevOuterAverageAge);
// newVotes = m1 * t1_age
uint240 newVotes =
uint240(deltaLQTY) * uint240(newInnerAverageAge);
uint240 votes = prevVotes + newVotes;
// newOuterAverageAge = m0 * t0_age + m1 * t1_age / (m0 + m1)
newOuterAverageAge = uint32(votes / uint240(_newLQTYBalance));
} else {
...
}

if (newOuterAverageAge > block.timestamp) return 0;
return uint32(block.timestamp - newOuterAverageAge);
}

For the example we consider the scenario were a user has staked some LQTY at t0t_0 and at t1>t0t_1 > t_0 stakes more LQTY.

Notations:

  • txage=(ttx)t_{x_{age}} = (t - t_x) (1)(1)
  • t1age=(tt1)=0t_{1_{age}} = (t - t_1) = 0 (applies the moment the user staked LQTY as the new amount added starts with a zero voting power which means that t1=t=block.timestampt_1 = t = block.timestamp) (2)(2)

The conditions that need to apply for this edge case to occur are as follows:

  • m0t0age+m1t1age<m0+m1(1)m_0 * t_{0_{age}} + m_1 * t_{1_{age}} < m_0 + m_1 \Leftrightarrow (1)
  • m0t0age<m0+m1(2)m_0 * t_{0_{age}} < m_0 + m_1 \Leftrightarrow (2)
  • m0(tt0)<m0+m1m_0 * (t - t_0) < m_0 + m_1 \Leftrightarrow
  • m0(tt0)<m0+m1m_0 * (t - t_0) < m_0 + m_1 \Leftrightarrow
  • m1>m0(tt0)m0m_1 > m_0 * (t - t_0) - m_0 \Leftrightarrow
  • m1>m0(tt01)m_1 > m_0 * (t - t_0 - 1) \Leftrightarrow
  • m1>m0seconds passed since previous stakem_1 > m_0 * \text{seconds passed since previous stake}

As can be seen, since each second that passes requires m0m_0 more LQTY to be staked for this edge case to manifest itself, the scenario could be considered of low likelihood but it is still a race condition that could cause accounting issues and break the voting power invariant.

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

M2

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

MEDIUM
partially resolved
M2
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.

M3

Initiative registration fees are used as rewards for the previous epoch

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

Resolved

The registration fee payment was moved after taking the epoch’s snapshots.

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.

M4

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

MEDIUM
acknowledged
M4
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.

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 makes their average timestamp greater than the epochStart(), the timestamp at which the current epoch started.

  • 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().

  • Since epochStart() < user's average timestamp, the user’s voting power will become 0 reverting the registration operation.

Governance::registerInitiative:490
function registerInitiative(address _initiative) external nonReentrant {
...
// an initiative can be registered if the registrant has more voting
// power (LQTY * age) than the registration threshold derived from the
// previous epoch's total global votes
require(
lqtyToVotes(
uint88(stakingV1.stakes(userProxyAddress)),
epochStart(),
userState.averageStakingTimestamp
)
>= snapshot.votes * REGISTRATION_THRESHOLD_FACTOR / WAD,
"Governance: insufficient-lqty"
);
...
}

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

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

M5

The new reset logic can lead to gas-based DoS locking user funds

MEDIUM
resolved
M5
The new reset logic can lead to gas-based DoS locking user funds

Resolved

A new function (resetAllocations) was introduced to allow users reset Initiatives individually without the hard constraint of resetting all of them at once helping resolve the issue.

As discussed in M2, the new logic of resetting all previously voted Initiatives in allocateLQTY can lead to a large amount of consumed gas, especially given that the onAfterAllocateLQTY hooks can be controlled by the adversary. The amount of gas is in fact unbounded, since there is no bound on the number of simultaneously voted Initiatives.

This behavior can lead to a gas-based DoS situation if the gas needed to reset a vote is larger than that of allocating the vote. Although this is unlikely to happen under normal operation, a maliciously constructed onAfterAllocateLQTY can consume more gas in case of deallocations (see also M2 for a discussion on malicious gas usage of hooks).

If a user votes for several such Initiatives, they could reach a state in which they have successfully allocated votes for these Initiatives, but the gas needed to reset them surpasses Ethereum’s 30 million block gas limit. This means that the votes will be impossible to reset, and since resetting all initiatives is obligatory in each call, no further calls to allocateLQTY will be possible. Moreover, since the user will be unable to deallocate, all their allocated funds will remain locked and impossible to be unstaked.

To prevent such catastrophic scenarios, we strongly recommend that all operations become able to be performed with a bounded amount of gas. For instance, the contract could provide a reset-only function that allows resetting the votes of a single Initiative, without the constraint that all initiatives must be reset. This will allow users to recover from any gas-based issue by resetting all Initiatives one at a time in separate transactions.

M6

Insufficient check when claiming bribes from an Initiative

MEDIUM
resolved
M6
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.

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.



LOW SEVERITY

L1

Old checks are no longer valid after the code refactoring

LOW
resolved
L1
Old checks are no longer valid after the code refactoring

In BribeInitiative::_claimBribe function, there are two require statements that revert if the total allocated LQTY or the user’s allocation is 0 blocking any claimings.

BribeInitiative::claimBribes:129
function _claimBribe(...) internal returns (...) {
...
DoubleLinkedList.Item memory lqtyAllocation =
lqtyAllocationByUserAtEpoch[_user].getItem(
_prevLQTYAllocationEpoch
);

require(
lqtyAllocation.value != 0
&& _prevLQTYAllocationEpoch <= _epoch
&& (lqtyAllocation.next > _epoch || lqtyAllocation.next == 0),
"BribeInitiative: invalid-prev-lqty-allocation-epoch"
);
DoubleLinkedList.Item memory totalLQTYAllocation =
totalLQTYAllocationByEpoch.getItem(_prevTotalLQTYAllocationEpoch);
require(
totalLQTYAllocation.value != 0
&& _prevTotalLQTYAllocationEpoch <= _epoch
&& (totalLQTYAllocation.next > _epoch ||
totalLQTYAllocation.next == 0),
"BribeInitiative: invalid-prev-total-lqty-allocation-epoch"
);
...
}

Even though this check worked in the previous version, it is no longer valid in the current code, because the value field now packs more items in the same slot which means that the above checks are performed on the packed value. More specifically, the new code packs the allocated LQTY amount along with the timestamp in the same uint224 variable when previously the LQTY amount was only stored in that field.

BribeInitiative::_setTotalLQTYAllocationByEpoch:153
function _setTotalLQTYAllocationByEpoch(
uint16 _epoch, uint88 _lqty, uint32 _averageTimestamp, bool _insert
) private {
uint224 value = (uint224(_lqty) << 32) | _averageTimestamp;
if (_insert) {
totalLQTYAllocationByEpoch.insert(_epoch, value, 0);
} else {
totalLQTYAllocationByEpoch.items[_epoch].value = value;
}
emit ModifyTotalLQTYAllocation(_epoch, _lqty, _averageTimestamp);
}

Currently, when the new LQTY balance of an entity is zeroed, its average timestamp gets zeroed too which means that, most probably, the packed value will also be 0 when the LQTY amount is 0. However, if this changes in future versions a scenario in which the LQTY amount is 0, but the timestamp is not could exist breaking the assumption and allowing the checks to pass when they should not.

Moreover, this difference does not produce direct issues to the code, as the 0 LQTY amounts will results in 0 products later in the execution, but it should be considered for future changes in that part as well that could be affected by it.

L2

The allocation hook can be called on unregistered Initiatives

LOW
acknowledged
L2
The allocation hook can be called on unregistered Initiatives

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.

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

Several gas optimizations could improve the allocation updates on Initiatives

ADVISORY
info
A1
Several gas optimizations could improve the allocation updates on Initiatives

As discussed in M5, the new functionality around updating Initiative allocations can be proven gas-intensive and even lead to unrecoverable situations in edge cases. However, apart from any possible refactoring that could help resolve that issue, some possible ways that could help save some gas are the following:

  • A separate function that allows users allocate LQTY on new Initiatives could be added:

  • The logic of resetting the allocations only to recast the votes back was mostly introduced for the cases in which users wanted to increase or decrease an existing allocation. However, for new Initiatives there is no need to reset all the others before allocating and such a function could help decouple the different execution paths.

  • Similarly, a separate function for fully deallocating from Initiatives is also important to exist as it could help mitigating the issues described in M5. It also could allow for more straightforward deallocations without the need to completely deallocate from all the Initiatives and then allocate back those desired to remain active.

  • Moreover, the reset operations are bundled with several other unnecessary for the deallocation operations and storage readings that otherwise are important when updating the allocations.

  • The allocateLQTY array parameters _initiativesToReset and _initiatives are checked for duplicate values using a double loop look up. However, you could expect them to be given sorted, reduce the complexity to O(n)O(n) and the gas consumption.

  • The array length checks inside Governance::_allocateLQTY are not necessary as the top-level calls from allocateLQTY have already checked or enforced the same requirement.

A2

Misleading parameter types in allocateLQTY

ADVISORY
info
A2
Misleading parameter types in allocateLQTY

In Governance, the allocateLQTY function was refactored. Currently, the only meaningful values for the _absoluteLQTYVotes and _absoluteLQTYVetos are positive amounts of LQTY to be allocated to Initiatives. This is because all allocations are cleared before any (re)allocation.

As a result, these two parameters should be of type uint88 instead of int88 to avoid the confusion during pure deallocations. When fully deallocating, users need to provide all the arrays empty except from the _initiativesToReset, compared to the previous version in which a deallaction was expecting a negative value to be provided.

A3

Gas optimizations when querying the state of an Initiative

ADVISORY
info
A3
Gas optimizations when querying the state of an Initiative

In Governance, the getInitiativeState function is used to determine the state of an Initiative. However, there are some possible gas optimizations that could be implemented:

  • The first three checks reference the same value in storage all reading it separately. You could cache the value of registeredInitiatives instead to save 2 SLOADs per call.

  • The lastEpochClaim value is extracted from the storage mapping initiativeStates but the same information also exists in the _initiativeState parameter which is given as an argument to the function. All the functions that call the getInitiativeState have previously copied the values from the same mapping and they pass them to the function. Thus, you could save 1 SLOAD by using the value of the argument like in the UNREGISTERABLE check.

A4

Gas optimizations when registering a new Initiative

ADVISORY
info
A4
Gas optimizations when registering a new Initiative

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

Unnecessary use of the return variable

ADVISORY
info
A5
Unnecessary use of the return variable

In Governance::getInitiativeState function, the check for the CLAIMED state uses the 0-initialized return variable in the return clause, but there is no need for that as no value is assigned to it. You could return directly 0 like in all the other similar checks which would also make the code more specific on the expected return values.

A6

Possible stale specification

ADVISORY
info
A6
Possible stale specification

In the new version of the codebase, several adjustments were made in the specification of the protocol. One of them is the requirement for the users to reset all their allocated LQTY before allocating more or deallocating. A note above the Governance::_resetInitiatives function suggests that the ResetInitiativeData is desired to be populated only when secondsWithinEpoch() > EPOCH_VOTING_CUTOFF. However, this does not seem to be followed by the code as the resetting is made on every call to allocateLQTY at any point during an epoch.

A7

Redundant checks

ADVISORY
info
A7
Redundant checks

In Governance::unregisterInitiative function, new checks were added to follow the new model of the Initiatives’ states. An Initiative can be unregistered if its state becomes UNREGISTERABLE. The relevant require statement to enforce this exists, but there are also two more redundant require statements using other possible states of the Initiative. They could have been added for custom error handling or for testing purposes, but we mention it just for visibility.

A8

Wrong comment in _allocateLQTY

ADVISORY
info
A8
Wrong comment in _allocateLQTY

In Governance::_allocateLQTY, there is a comment which suggests that an Initiative can be voted or vetoed when it is in SKIP, CLAIMABLE, CLAIMED or UNREGISTERABLE state. However, an UNREGISTERABLE Initiative cannot be voted or vetoed and only complete deallocations are allowed.

A9

Compiler bugs

ADVISORY
info
A9
Compiler bugs

The code is compiled with Solidity ^0.8.24. For deployment, we recommend no floating pragmas, but a specific version, to be confident about the baseline guarantees offered by the compiler. Version 0.8.24, in particular, 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_unregisterInitiativeAfterWarmUpPeriod() public {
vm.startPrank(user);

address userProxy = governance.deployUserProxy();

IGovernance.VoteSnapshot memory snapshot =
IGovernance.VoteSnapshot(1e18, 1);
vm.store(
address(governance),
bytes32(uint256(2)),
bytes32(
abi.encodePacked(
uint16(snapshot.forEpoch),
uint240(snapshot.votes)
)
)
);
(uint240 votes, uint16 forEpoch) = governance.votesSnapshot();
assertEq(votes, 1e18);
assertEq(forEpoch, 1);

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 + governance.EPOCH_DURATION() * 4);

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

vm.warp(block.timestamp + governance.EPOCH_DURATION());
// Dedaub:
// This call should revert and fail the test,
// but it passes showcasing the issue
governance.unregisterInitiative(baseInitiative3);
}

Proof of Concept - H2

Proof of Concept - H2
function test_PoC_H2_unregisterInitiativeWhenNotStale() public {

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);

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

vm.warp(block.timestamp + (
governance.UNREGISTRATION_AFTER_EPOCHS() - 1) *
governance.EPOCH_DURATION()
);

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

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

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

vm.warp(block.timestamp + governance.EPOCH_DURATION());

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

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

address[] memory emptyInitiatives = new address[](1);
int88[] memory emptyDeltaLQTYVotes = new int88[](1);
int88[] memory emptyDeltaLQTYVetos = new int88[](1);

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

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

vm.warp(block.timestamp + governance.EPOCH_DURATION());

governance.unregisterInitiative(baseInitiative3);
}

Proof of Concept - H3

Proof of Concept - H3

In this PoC, the following scenario has been followed:

  • user2 stakes mm LQTY at the beginning of the first epoch and gets an average timestamp equal to the current block.timestamp.

  • user stakes mm LQTY (the same amount as user2) after half of the epoch has passed.

  • After the other half of the epoch, the two users both have mm staked LQTY, but the average age of user (t0t_0) is double the average age of user2 (t1t_1 with t1=2t0t_1 = 2 * t_0). The average age is the distance of the users’ average timestamps from the current block.timestamp.

Note: For illustration purposes, simplicity of the example and clearer calculations the following steps are all considered to be in the same block. A more realistic scenario with each step included in different blocks has the same issues, but the calculations would be more complex to showcase, thus we keep it simple.
  • Both user and user2 allocate their mm LQTY to the Initiative which gets the following state:

  • InitiativeState.voteLQTY=2m\text{InitiativeState.voteLQTY} = 2m

  • Initiative’s Average Age=mt0+mt12m=mt0+m2t02m=32t0\text{Initiative's Average Age} = \frac{m * t_0 + m * t_1}{2m} = \frac{m * t_0 + m * 2 * t_0}{2m} = \frac{3}{2}t_0

The valid scenario:
  • user deallocates from the Initiative all their LQTY and the Initiative state becomes:

  • InitiativeState.voteLQTY=m\text{InitiativeState.voteLQTY} = m

  • Initiative’s Average Age=2m32t0mt0m=3mt0mt0m=2t0\text{Initiative's Average Age} = \frac{2m * \frac{3}{2}t_0 - m * t_0}{m} = \frac{3m * t_0 - m * t_0}{m} = 2t_0

The invalid scenario:
  • user stakes another mm LQTY making their average age: t0new=t02t_{0_{new}} = \frac{t_0}{2}

  • user deallocates from the Initiative all their LQTY and the Initiative state becomes:

  • InitiativeState.voteLQTY=m\text{InitiativeState.voteLQTY} = m

  • Initiative’s Average Age=2m32t0mt02m=3mt0mt02m=52t0\text{Initiative's Average Age} = \frac{2m * \frac{3}{2}t_0 - m * \frac{t_0}{2}}{m} = \frac{3m * t_0 - m * \frac{t_0}{2}}{m} = \frac{5}{2}t_0

As can be seen, the only difference in the two scenarios was that user staked more LQTY before deallocating, but the results on the Initiative are different breaking its accounting.


The code snippets below should all be put inside Governance.t.sol in order for the main tests to run. Both the invalid and the valid tests pass, but the values on the invalid are different which indicates the issues described in H3.

The PoC that shows the expected outcome of the operation
function test_PoC_H3_validDeallocation() public {

(,uint32 userAvt, uint32 user2Avt,,) = _test_PoC_H3_setUp();

_test_PoC_H3_deallocate();

(,IGovernance.InitiativeState memory initiativeState,) =
governance.getInitiativeSnapshotAndState(baseInitiative1);
assertEq(initiativeState.voteLQTY, 1e18);
assertEq(
block.timestamp - initiativeState.averageStakingTimestampVoteLQTY,
block.timestamp - user2Avt
);
assertEq(
block.timestamp - initiativeState.averageStakingTimestampVoteLQTY,
2 * (block.timestamp - userAvt)
);
}
The PoC that shows the error by the return of invalid results
function test_PoC_H3_invalidDeallocation() public {

(address userProxy, uint32 userAvt,,uint240 userVP,) =
_test_PoC_H3_setUp();

// -------------------------------------------------------------------

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

(, uint32 newUserAvt) = governance.userStates(user);
uint240 newUserVP =
governance.lqtyToVotes(
uint88(governance.stakingV1().stakes(userProxy)
),
governance.epochStart(),
newUserAvt
);
vm.stopPrank();

assertEq(
block.timestamp - newUserAvt, (block.timestamp - userAvt) / 2);
assertEq(newUserVP, userVP);
// -------------------------------------------------------------------

_test_PoC_H3_deallocate();

(,IGovernance.InitiativeState memory initiativeState,) =
governance.getInitiativeSnapshotAndState(baseInitiative1);
assertEq(initiativeState.voteLQTY, 1e18);
assertEq(
block.timestamp - initiativeState.averageStakingTimestampVoteLQTY,
5 * (block.timestamp - userAvt) / 2
);
}
Helper function for the final deallocation
function _test_PoC_H3_helper_deallocate() internal {
address[] memory removeInitiatives = new address[](2);
removeInitiatives[0] = baseInitiative1;
address[] memory newInitiatives = new address[](1);
int88[] memory removeDeltaLQTYVotes = new int88[](1);
int88[] memory removeDeltaLQTYVetos = new int88[](1);

vm.startPrank(user);
governance.allocateLQTY(
removeInitiatives, newInitiatives,
removeDeltaLQTYVotes, removeDeltaLQTYVetos
);
vm.stopPrank();

(uint256 userAllLQTY,) = governance.userStates(user);
assertEq(userAllLQTY, 0);
}
Helper function for setting up the test
function test_PoC_H3_setUp() internal returns(
address userProxy, uint32 userAvt, uint32 user2Avt,
uint240 userVP, uint240 user2VP
) {

// -------------------------------------------------------------------

vm.startPrank(user2);
address user2Proxy = governance.deployUserProxy();
vm.stopPrank();

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

vm.startPrank(user2);
lusd.approve(address(governance), 1e18);
lqty.approve(address(user2Proxy), 1e18);
governance.depositLQTY(1e18);
vm.stopPrank();

// -------------------------------------------------------------------

vm.warp(block.timestamp + governance.EPOCH_DURATION() / 2);

vm.startPrank(user);
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.stopPrank();

// -------------------------------------------------------------------

vm.warp(block.timestamp + governance.EPOCH_DURATION() / 2);

vm.startPrank(user);
(, userAvt) = governance.userStates(user);
userVP =
governance.lqtyToVotes(
uint88(governance.stakingV1().stakes(userProxy)
),
governance.epochStart(),
userAvt
);
vm.stopPrank();

vm.startPrank(user2);
(, user2Avt) = governance.userStates(user2);
user2VP =
governance.lqtyToVotes(
uint88(governance.stakingV1().stakes(user2Proxy)
),
governance.epochStart(),
user2Avt
);
vm.stopPrank();

assertEq(user2VP, 2 * userVP);
assertEq(block.timestamp - user2Avt, 2 * (block.timestamp - userAvt));

// -------------------------------------------------------------------

address[] memory initiatives = new address[](1);
initiatives[0] = baseInitiative1;
int88[] memory deltaLQTYVotes = new int88[](1);
deltaLQTYVotes[0] = 1e18;
int88[] memory deltaLQTYVetos = new int88[](1);

vm.startPrank(user);
governance.allocateLQTY(
initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos);
vm.stopPrank();

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

(,IGovernance.InitiativeState memory initiativeState,) =
governance.getInitiativeSnapshotAndState(baseInitiative1);
assertEq(initiativeState.voteLQTY, 1e18);
assertEq(
initiativeState.averageStakingTimestampVoteLQTY, userAvt);
}

// -------------------------------------------------------------------

vm.startPrank(user2);
governance.allocateLQTY(
initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos);
vm.stopPrank();

(uint256 user2AllLQTY,) = governance.userStates(user2);
assertEq(user2AllLQTY, 1e18);

(,IGovernance.InitiativeState memory initiativeState,) =
governance.getInitiativeSnapshotAndState(baseInitiative1);
assertEq(initiativeState.voteLQTY, 2e18);
assertEq(
block.timestamp - initiativeState.averageStakingTimestampVoteLQTY,
3 * (block.timestamp - userAvt) / 2
);
}

Proof of Concept - H4

Proof of Concept - H4
function test_PoC_H5_deallocationOnUnregisteredInitiativesAffectsGlobalState() public {
vm.startPrank(user);
address userProxy = governance.deployUserProxy();

lqty.approve(address(userProxy), 1000e18);
governance.depositLQTY(1000e18);
vm.stopPrank();

vm.startPrank(user2);
address user2Proxy = governance.deployUserProxy();

lqty.approve(address(user2Proxy), 1000e18);
governance.depositLQTY(1000e18);
vm.stopPrank();

vm.warp(block.timestamp + governance.EPOCH_DURATION());

/// Setup and vote for 2 initiatives, 0.1% vs 99.9%
address[] memory initiatives = new address[](2);
initiatives[0] = baseInitiative1;
initiatives[1] = baseInitiative2;
int88[] memory deltaLQTYVotes = new int88[](2);
deltaLQTYVotes[0] = 1e18;
deltaLQTYVotes[1] = 999e18;
int88[] memory deltaLQTYVetos = new int88[](2);

vm.startPrank(user);
governance.allocateLQTY(
initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos);
vm.stopPrank();

vm.startPrank(user2);
governance.allocateLQTY(
initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos);
vm.stopPrank();

{
vm.warp(block.timestamp + governance.EPOCH_DURATION());

(
IGovernance.VoteSnapshot memory snapshot,
IGovernance.InitiativeVoteSnapshot memory
initiativeVoteSnapshot1
) = governance.snapshotVotesForInitiative(baseInitiative1);

(,IGovernance.GlobalState memory state,) =
governance.getTotalVotesAndState();
assertEq(state.countedVoteLQTY, 2000e18);

uint256 threshold = governance.getLatestVotingThreshold();
assertLt(initiativeVoteSnapshot1.votes, threshold,
"it didn't get rewards");
}

// Roll for
vm.warp(block.timestamp + governance.UNREGISTRATION_AFTER_EPOCHS() *
governance.EPOCH_DURATION());
governance.unregisterInitiative(baseInitiative1);

{
(,IGovernance.GlobalState memory state,) =
governance.getTotalVotesAndState();
assertEq(state.countedVoteLQTY, 1998e18);
}

address[] memory removeInitiatives = new address[](2);
removeInitiatives[0] = baseInitiative1;
removeInitiatives[1] = baseInitiative2;
address[] memory newInitiatives = new address[](1);
int88[] memory removeDeltaLQTYVotes = new int88[](1);
int88[] memory removeDeltaLQTYVetos = new int88[](1);

vm.startPrank(user);
governance.allocateLQTY(
removeInitiatives, newInitiatives,
removeDeltaLQTYVotes, removeDeltaLQTYVetos
);
vm.stopPrank();

vm.startPrank(user2);
governance.allocateLQTY(
removeInitiatives, newInitiatives,
removeDeltaLQTYVotes, removeDeltaLQTYVetos
);
vm.stopPrank();

// Dedaub:
// This assertion should fail as the expected countedLQTY after all
// users have deallocated their LQTY from all the Initiatives should
// be 0. The test passes illustrating the issue.
{
(,IGovernance.GlobalState memory state,) =
governance.getTotalVotesAndState();
assertEq(state.countedVoteLQTY, 1e18);
}
}

Proof of Concept - M1

Proof of Concept - M1
function test_PoC_M1_votingPowerGetsZeroed() public {

vm.startPrank(user);
address userProxy = governance.deployUserProxy();
vm.stopPrank();

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

vm.startPrank(user);
lqty.approve(address(userProxy), 1e18);
governance.depositLQTY(1e18);

// -------------------------------------------------------------------

vm.warp(block.timestamp + 1000);

(, uint32 averageTimestamp) = governance.userStates(user);
uint240 votingPower =
governance.lqtyToVotes(
uint88(governance.stakingV1().stakes(userProxy)),
block.timestamp,
averageTimestamp
);

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

(, uint32 newAverageTimestamp) = governance.userStates(user);
uint240 newVotingPower =
governance.lqtyToVotes(
uint88(governance.stakingV1().stakes(userProxy)),
block.timestamp,
newAverageTimestamp
);

assertEq(votingPower, 1e18 * 1000);
assertEq(newVotingPower, 0);
}

Proof of Concept - M4

Proof of Concept - M4
function test_PoC_M4_blockedRegistration() public {

vm.warp(block.timestamp + governance.EPOCH_DURATION());
vm.warp(block.timestamp + governance.EPOCH_DURATION() - 86400);

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

vm.startPrank(user2);
address user2Proxy = governance.deployUserProxy();

lqty.approve(address(user2Proxy), 1e16);
governance.depositLQTY(1e16);

address[] memory initiatives = new address[](1);
initiatives[0] = baseInitiative1;
int88[] memory deltaLQTYVotes = new int88[](1);
deltaLQTYVotes[0] = 1e16;
int88[] memory deltaLQTYVetos = new int88[](1);

governance.allocateLQTY(
initiatives, initiatives, deltaLQTYVotes, deltaLQTYVetos);
vm.stopPrank();

// -------------------------------------------------------------------

vm.warp(block.timestamp + 86390);

vm.startPrank(user);
address userProxy = governance.deployUserProxy();

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

// -------------------------------------------------------------------

vm.warp(block.timestamp + 10);
vm.warp(block.timestamp + governance.EPOCH_DURATION() / 2);

governance.snapshotVotesForInitiative(baseInitiative1);

(
IGovernance.VoteSnapshot memory snapshot,
IGovernance.GlobalState memory state
,
) = governance.getTotalVotesAndState();
assertEq(state.countedVoteLQTY, 1e16);

(, uint32 averageTimestamp) = governance.userStates(user);
uint240 votingPower =
governance.lqtyToVotes(
uint88(governance.stakingV1().stakes(userProxy)),
governance.epochStart(),
averageTimestamp
);

assertGt(
votingPower, snapshot.votes * REGISTRATION_THRESHOLD_FACTOR / 1e18);
assertGt(governance.epochStart(), averageTimestamp);

// -------------------------------------------------------------------

MockInitiative mockInitiative =
new MockInitiative(address(governance));

lusd.approve(address(governance), 1e18);
governance.registerInitiative(address(mockInitiative));

// -------------------------------------------------------------------

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

// -------------------------------------------------------------------

(,averageTimestamp) = governance.userStates(user);
votingPower =
governance.lqtyToVotes(
uint88(governance.stakingV1().stakes(userProxy)),
governance.epochStart(),
averageTimestamp
);

assertLt(
votingPower, snapshot.votes * REGISTRATION_THRESHOLD_FACTOR / 1e18);
assertGt(averageTimestamp, governance.epochStart());

// -------------------------------------------------------------------

lusd.approve(address(governance), 1e18);
vm.expectRevert("Governance: insufficient-lqty");
governance.registerInitiative(baseInitiative3);
}