Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] fix: possible stuck tokens in BribeInitiative #122

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 77 additions & 12 deletions src/BribeInitiative.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pragma solidity 0.8.24;
import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol";
import {SafeERC20} from "openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

import {IGovernance} from "./interfaces/IGovernance.sol";
import {IGovernance, UNREGISTERED_INITIATIVE} from "./interfaces/IGovernance.sol";
import {IInitiative} from "./interfaces/IInitiative.sol";
import {IBribeInitiative} from "./interfaces/IBribeInitiative.sol";

Expand Down Expand Up @@ -78,6 +78,73 @@ contract BribeInitiative is IInitiative, IBribeInitiative {
bribeToken.safeTransferFrom(msg.sender, address(this), _bribeTokenAmount);
}

function _getValidatedLQTYAllocation(
DoubleLinkedList.List storage _list,
uint256 _epoch,
uint256 _prevAllocationEpoch,
string memory _errorMsg
) internal view returns (DoubleLinkedList.Item memory _item) {
if (_prevAllocationEpoch == 0) {
require(_list.isEmpty(), _errorMsg);
// Leaving _item uninitialized, as there's never been an allocation
} else {
require(_list.contains(_prevAllocationEpoch), _errorMsg);
_item = _list.getItem(_prevAllocationEpoch);
require(_prevAllocationEpoch <= _epoch && (_item.next > _epoch || _item.next == 0), _errorMsg);
}
}

// Returns true if there are no existing LQTY allocations to the initiative, and there's no possibility of
// allocating any more because the initiative's been unregistered.
function _noPresentOrFutureAllocation() internal view returns (bool) {
// Note that the initiative could already be unregisterable but not yet unregistered, which we ignore here.
// In that case, the stuck bribes can always be extracted in the next epoch _after_ unregistering the initiative.
uint256 latestTotalAllocationEpoch = getMostRecentTotalEpoch();
return (
latestTotalAllocationEpoch == 0 || totalLQTYAllocationByEpoch.getItem(latestTotalAllocationEpoch).lqty == 0
) && governance.registeredInitiatives(address(this)) == UNREGISTERED_INITIATIVE;
}

/// @inheritdoc IBribeInitiative
function redepositBribe(uint256 _originalEpoch, uint256 _prevTotalLQTYAllocationEpoch) external {
Bribe memory bribe = bribeByEpoch[_originalEpoch];
require(bribe.remainingBoldAmount != 0 || bribe.remainingBribeTokenAmount != 0, "BribeInitiative: no-bribe");

DoubleLinkedList.Item memory totalLQTYAllocation = _getValidatedLQTYAllocation(
totalLQTYAllocationByEpoch,
_originalEpoch,
_prevTotalLQTYAllocationEpoch,
"BribeInitiative: invalid-prev-total-lqty-allocation-epoch"
);

require(totalLQTYAllocation.lqty == 0, "BribeInitiative: total-lqty-allocation-not-zero");
assert(bribe.claimedVotes == 0); // Can't possibly have claimed anything

if (_noPresentOrFutureAllocation()) {
if (bribe.remainingBoldAmount != 0) {
bold.safeTransfer(msg.sender, bribe.remainingBoldAmount);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where are we validating that sender is the owner?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are intentionally letting anyone recover the bribes. Since anyone could have deposited those bribes, it doesn't feel right to only let the owner (who's not known currently, since BribeInitiative isn't Ownable) claim them. I expect the bribes to be extracted as MEV in this case.

If you as a bribe depositor couldn't even be bothered to allocate a single wei of LQTY to prevent the bribes from getting stuck in the first place, then you don't deserve to reclaim them.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this means that in the case of 0 LQTY votes, we're going from "no one can claim/redeposit" to "anyone can redeposit"? And in both cases, the issue is fully mitigated by the bribe depositor allocating 1 wei LQTY, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case there are no votes in the original epoch in which the bribes were deposited (hence why the're stuck), and the current epoch has no votes either, and the initiative has been disabled then yes, anyone can claim the stuck bribes.

Normally, they would just be moved to the current epoch, where people would be able to vote to see who gets how much. But this is no longer possible if the initiative has been disabled.

}
if (bribe.remainingBribeTokenAmount != 0) {
bribeToken.safeTransfer(msg.sender, bribe.remainingBribeTokenAmount);
}

emit ExtractUnclaimableBribe(
msg.sender, _originalEpoch, bribe.remainingBoldAmount, bribe.remainingBribeTokenAmount
);
} else {
uint256 currentEpoch = governance.epoch();
bribeByEpoch[currentEpoch].remainingBoldAmount += bribe.remainingBoldAmount;
bribeByEpoch[currentEpoch].remainingBribeTokenAmount += bribe.remainingBribeTokenAmount;

emit RedepositBribe(
msg.sender, bribe.remainingBoldAmount, bribe.remainingBribeTokenAmount, _originalEpoch, currentEpoch
);
}

delete bribeByEpoch[_originalEpoch].remainingBoldAmount;
delete bribeByEpoch[_originalEpoch].remainingBribeTokenAmount;
}

function _claimBribe(
address _user,
uint256 _epoch,
Expand All @@ -90,18 +157,16 @@ contract BribeInitiative is IInitiative, IBribeInitiative {
Bribe memory bribe = bribeByEpoch[_epoch];
require(bribe.remainingBoldAmount != 0 || bribe.remainingBribeTokenAmount != 0, "BribeInitiative: no-bribe");

DoubleLinkedList.Item memory lqtyAllocation =
lqtyAllocationByUserAtEpoch[_user].getItem(_prevLQTYAllocationEpoch);

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

Expand Down Expand Up @@ -188,7 +253,7 @@ contract BribeInitiative is IInitiative, IBribeInitiative {
}

/// @inheritdoc IBribeInitiative
function getMostRecentTotalEpoch() external view returns (uint256) {
function getMostRecentTotalEpoch() public view returns (uint256) {
uint256 mostRecentTotalEpoch = totalLQTYAllocationByEpoch.getHead();

return mostRecentTotalEpoch;
Expand Down
4 changes: 1 addition & 3 deletions src/Governance.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {IERC20} from "openzeppelin/contracts/interfaces/IERC20.sol";
import {SafeERC20} from "openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "openzeppelin/contracts/utils/ReentrancyGuard.sol";

import {IGovernance} from "./interfaces/IGovernance.sol";
import {IGovernance, UNREGISTERED_INITIATIVE} from "./interfaces/IGovernance.sol";
import {IInitiative} from "./interfaces/IInitiative.sol";
import {ILQTYStaking} from "./interfaces/ILQTYStaking.sol";

Expand Down Expand Up @@ -74,8 +74,6 @@ contract Governance is MultiDelegateCall, UserProxyFactory, ReentrancyGuard, Own
/// @inheritdoc IGovernance
mapping(address => uint256) public override registeredInitiatives;

uint256 constant UNREGISTERED_INITIATIVE = type(uint256).max;

constructor(
address _lqty,
address _lusd,
Expand Down
12 changes: 12 additions & 0 deletions src/interfaces/IBribeInitiative.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import {IGovernance} from "./IGovernance.sol";

interface IBribeInitiative {
event DepositBribe(address depositor, uint256 boldAmount, uint256 bribeTokenAmount, uint256 epoch);
event RedepositBribe(
address depositor, uint256 boldAmount, uint256 bribeTokenAmount, uint256 originalEpoch, uint256 newEpoch
);
event ModifyLQTYAllocation(address user, uint256 epoch, uint256 lqtyAllocated, uint256 offset);
event ModifyTotalLQTYAllocation(uint256 epoch, uint256 totalLQTYAllocated, uint256 offset);
event ClaimBribe(address user, uint256 epoch, uint256 boldAmount, uint256 bribeTokenAmount);
event ExtractUnclaimableBribe(address recipient, uint256 epoch, uint256 boldAmount, uint256 bribeTokenAmount);

/// @notice Address of the governance contract
/// @return governance Adress of the governance contract
Expand Down Expand Up @@ -70,6 +74,14 @@ interface IBribeInitiative {
/// @param _epoch Epoch at which the bribe is deposited
function depositBribe(uint256 _boldAmount, uint256 _bribeTokenAmount, uint256 _epoch) external;

/// @notice Take previously deposited BOLD and/or bribe tokens from an epoch in which nobody voted for the
/// initiative and deposit them as bribes into the current epoch. In case there are no present LQTY
/// allocations, and it's no longer possible to allocate LQTY to the initiative because it's been
/// unregistered, the bribes are paid out to the caller instead.
/// @param _originalEpoch Epoch at which the bribes were originally deposited
/// @param _prevTotalLQTYAllocationEpoch Epoch at which the total allocated LQTY became zero
function redepositBribe(uint256 _originalEpoch, uint256 _prevTotalLQTYAllocationEpoch) external;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would call this something like “recoverOrRedepositBribe”. We may even keep it simple and allow only recovery, and then it would be up to the owner to deposit them again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renaming it is fine, but I wouldn't let the owner recover bribes, see my other comment.


struct ClaimData {
// Epoch at which the user wants to claim the bribes
uint256 epoch;
Expand Down
6 changes: 5 additions & 1 deletion src/interfaces/IGovernance.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {ILQTYStaking} from "./ILQTYStaking.sol";

import {PermitParams} from "../utils/Types.sol";

uint256 constant UNREGISTERED_INITIATIVE = type(uint256).max;

interface IGovernance {
/// @notice Emitted when a user deposits LQTY
/// @param user The account depositing LQTY
Expand Down Expand Up @@ -216,7 +218,9 @@ interface IGovernance {

/// @notice Returns when an initiative was registered
/// @param _initiative Address of the initiative
/// @return atEpoch Epoch at which the initiative was registered
/// @return atEpoch If `_initiative` is an active initiative, returns the epoch at which it was registered.
/// If `_initiative` hasn't been registered, returns 0.
/// If `_initiative` has been unregistered, returns `UNREGISTERED_INITIATIVE`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌

function registeredInitiatives(address _initiative) external view returns (uint256 atEpoch);

/*//////////////////////////////////////////////////////////////
Expand Down
4 changes: 4 additions & 0 deletions src/utils/DoubleLinkedList.sol
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ library DoubleLinkedList {
return (list.items[id].prev != 0 || list.items[id].next != 0 || list.items[0].next == id);
}

function isEmpty(List storage list) internal view returns (bool) {
return list.items[0].next == 0;
}

/// @notice Inserts an item with `id` in the list before item `next`
/// - if `next` is 0, the item is inserted at the start (head) of the list
/// @dev This function should not be called with an `id` that is already in the list.
Expand Down
111 changes: 109 additions & 2 deletions test/BribeInitiative.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -802,8 +802,8 @@ contract BribeInitiativeTest is Test, MockStakingV1Deployer {
vm.startPrank(user1);
BribeInitiative.ClaimData[] memory epochs = new BribeInitiative.ClaimData[](1);
epochs[0].epoch = governance.epoch() - 1;
epochs[0].prevLQTYAllocationEpoch = governance.epoch() - 2;
epochs[0].prevTotalLQTYAllocationEpoch = governance.epoch() - 2;
epochs[0].prevLQTYAllocationEpoch = 0;
epochs[0].prevTotalLQTYAllocationEpoch = 0;
vm.expectRevert("BribeInitiative: total-lqty-allocation-zero");
(uint256 boldAmount, uint256 bribeTokenAmount) = bribeInitiative.claimBribes(epochs);
vm.stopPrank();
Expand Down Expand Up @@ -922,6 +922,113 @@ contract BribeInitiativeTest is Test, MockStakingV1Deployer {
assertEqDecimal(lqtyBribe, bribeAmount / 2, 18, "user1 didn't get their fair share of LQTY");
}

function test_RedepositBribe_Reverts_WhenNoUnclaimableBribes() external {
vm.warp(block.timestamp + EPOCH_DURATION);

uint256 currentEpoch = governance.epoch();
vm.expectRevert("BribeInitiative: no-bribe");
bribeInitiative.redepositBribe(currentEpoch - 1, 0);
}

function test_RedepositBribe_Reverts_WhenPrevTotalLQTYAllocationEpochIsWrong() external {
// No allocations initially

vm.warp(block.timestamp + EPOCH_DURATION);

// Some bribes and some allocations an epoch later
_depositBribe(1 ether, 1 ether, governance.epoch());
_stakeLQTY(user1, 1 ether);
_allocateLQTY(user1, 1 ether, 0);

vm.warp(block.timestamp + EPOCH_DURATION);

// Attempt to redeposit the previous epoch's bribes while pointing
// to the initial epoch as latest total allocation epoch
uint256 currentEpoch = governance.epoch();
vm.expectRevert("BribeInitiative: invalid-prev-total-lqty-allocation-epoch");
bribeInitiative.redepositBribe(currentEpoch - 1, currentEpoch - 2);
}

function test_RedepositBribe_Reverts_WhenBribesAreClaimable() external {
// Wait for warm-up to finish
vm.warp(block.timestamp + EPOCH_DURATION);

// Some bribes and some allocations
_depositBribe(1 ether, 1 ether, governance.epoch());
_stakeLQTY(user1, 1 ether);
_allocateLQTY(user1, 1 ether, 0);

vm.warp(block.timestamp + EPOCH_DURATION);

// Attempt to redeposit the previous epoch's bribes
uint256 currentEpoch = governance.epoch();
vm.expectRevert("BribeInitiative: total-lqty-allocation-not-zero");
bribeInitiative.redepositBribe(currentEpoch - 1, currentEpoch - 1);
}

function test_UnclaimableBribes_WhenInitiativeIsNotUnregistered_CanBeRedeposited() external {
uint256 unclaimableBoldAmount = 1e5 ether;
uint256 unclaimableBribeTokenAmount = 2e5 ether;
_depositBribe(unclaimableBoldAmount, unclaimableBribeTokenAmount, governance.epoch());

vm.warp(block.timestamp + EPOCH_DURATION);

// Redeposit bribes and allocate on the initiative
bribeInitiative.redepositBribe(governance.epoch() - 1, 0);
_stakeLQTY(user1, 1 ether);
_allocateLQTY(user1, 1 ether, 0);

vm.warp(block.timestamp + EPOCH_DURATION);

// Claim redeposited bribes an epoch later
uint256 currentEpoch = governance.epoch();
(uint256 claimedBoldAmount, uint256 claimedBribeTokenAmount) =
_claimBribe(user1, currentEpoch - 1, currentEpoch - 1, currentEpoch - 1);

assertEqDecimal(claimedBoldAmount, unclaimableBoldAmount, 18, "claimedBoldAmount != unclaimableBoldAmount");
assertEqDecimal(
claimedBribeTokenAmount,
unclaimableBribeTokenAmount,
18,
"claimedBribeTokenAmount != unclaimableBribeTokenAmount"
);
}

function test_UnclaimableBribes_WhenInitiativeIsNotUnregistered_CannotBeRedepositedTwiceFromTheSameEpoch()
external
{
_depositBribe(1e5 ether, 2e5 ether, governance.epoch());

vm.warp(block.timestamp + EPOCH_DURATION);

uint256 currentEpoch = governance.epoch();
bribeInitiative.redepositBribe(currentEpoch - 1, 0);
vm.expectRevert("BribeInitiative: no-bribe");
bribeInitiative.redepositBribe(currentEpoch - 1, 0);
}

function test_UnclaimableBribes_WhenInitiativeIsUnregistered_CanBeExtracted() external {
uint256 unclaimableBoldAmount = 1e5 ether;
uint256 unclaimableBribeTokenAmount = 2e5 ether;
_depositBribe(unclaimableBoldAmount, unclaimableBribeTokenAmount, governance.epoch());

vm.warp(block.timestamp + 5 * EPOCH_DURATION);

// Unregister initiative then extract stuck bribes
governance.unregisterInitiative(address(bribeInitiative));
bribeInitiative.redepositBribe(governance.epoch() - 5, 0);

assertEqDecimal(
lusd.balanceOf(address(this)), unclaimableBoldAmount, 18, "claimedBoldAmount != unclaimableBoldAmount"
);
assertEqDecimal(
lqty.balanceOf(address(this)),
unclaimableBribeTokenAmount,
18,
"claimedBribeTokenAmount != unclaimableBribeTokenAmount"
);
}

/**
* Helpers
*/
Expand Down
Loading