Skip to content

Commit

Permalink
feat: ability to redeposit or extract unclaimable bribes
Browse files Browse the repository at this point in the history
  • Loading branch information
danielattilasimon committed Jan 1, 2025
1 parent 8db397c commit a1f6a1e
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 18 deletions.
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);
}
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;

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`.
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

0 comments on commit a1f6a1e

Please sign in to comment.