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

Governed Lottery Hook #97

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
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
188 changes: 188 additions & 0 deletions GovernedLotteryHook.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// SPDX-License-Identifier: GPL-3.0-or-later

pragma solidity ^0.8.24;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol";
import { IRouterCommon } from "@balancer-labs/v3-interfaces/contracts/vault/IRouterCommon.sol";
import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import {
AfterSwapParams,
LiquidityManagement,
SwapKind,
TokenConfig,
HookFlags
} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol";

import { EnumerableMap } from "@balancer-labs/v3-solidity-utils/contracts/openzeppelin/EnumerableMap.sol";
import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol";
import { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol";
import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol";

contract GovernedLotteryHook is BaseHooks, VaultGuard, Ownable {
using FixedPoint for uint256;
using EnumerableMap for EnumerableMap.IERC20ToUint256Map;
using SafeERC20 for IERC20;

// Governance Proposal Struct
struct Proposal {
uint256 proposalId;
string description;
uint64 newSwapFeePercentage;
uint8 newLuckyNumber;

Choose a reason for hiding this comment

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

I don't think it makes any sense to update the lucky number. Updating the MAX_NUMBER would change the odds, which is meaningful. Maybe allow that to be updated, and generate a new lucky number based on the max (would have to use an Oracle or some other more random means).

uint256 votesFor;
uint256 votesAgainst;
uint256 votingDeadline;
}

// State variables for proposals
Proposal[] public proposals;
mapping(uint256 => mapping(address => bool)) public hasVoted;

// Lottery and fee variables
uint8 public LUCKY_NUMBER = 10;
uint8 public constant MAX_NUMBER = 20;
uint64 public hookSwapFeePercentage;

EnumerableMap.IERC20ToUint256Map private _tokensWithAccruedFees;
uint256 private _counter = 0;
address private immutable _trustedRouter;

// Events for governance and fees
event ProposalCreated(uint256 proposalId, string description);
event VoteCast(uint256 proposalId, address voter, bool support);
event ProposalImplemented(uint256 proposalId, uint64 newSwapFeePercentage, uint8 newLuckyNumber);
event LotteryWinningsPaid(
address indexed hooksContract,
address indexed winner,
IERC20 indexed token,
uint256 amountWon
);

constructor(IVault vault, address router) VaultGuard(vault) Ownable(msg.sender) {
_trustedRouter = router;
}

// Create a new governance proposal
function createProposal(

Choose a reason for hiding this comment

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

So in this design, the owner can propose multiple changes, then choose which one(s) to implement. With 5 proposals, if 3 were approved, the owner could alternate between all the approved ones.

If only the owner can do this, consider one proposal at a time (perhaps with a variable deadline, constrained by a hard-coded minimum; e.g., min 3 days, but they could pick 3, 5, 7, 14 days, etc.), and a permissionless implementProposal. Only the owner could propose, but anyone could enforce the result after the deadline. Also consider an "implemented" flag, so that implementProposal can only be called once. Of course, at that point, since the owner has total control anyway, you might as well just let the owner set the fee directly... which I see you're also doing, so the voting mechanism doesn't really do anything.

Another possible design (though more complex), would be some notion of epochs: time periods during which anyone can create a proposal (probably capped to a certain number, like 5, especially if you're using an iterable array), and a permissionless "implement" that would implement the highest scoring proposal (or do nothing if there were no votes), then clear the data and start a new epoch.

Pending proposals could only be queried off-chain, which is maybe ok.

string memory description,
uint64 newSwapFeePercentage,
uint8 newLuckyNumber
) external onlyOwner {
proposals.push(
Proposal({
proposalId: proposals.length,
description: description,
newSwapFeePercentage: newSwapFeePercentage,
newLuckyNumber: newLuckyNumber,
votesFor: 0,
votesAgainst: 0,
votingDeadline: block.timestamp + 7 days
})
);

emit ProposalCreated(proposals.length - 1, description);
}

// Vote on an active proposal
function voteOnProposal(uint256 proposalId, bool support) external {
require(block.timestamp <= proposals[proposalId].votingDeadline, "Voting period is over");
require(!hasVoted[proposalId][msg.sender], "You have already voted");

if (support) {
proposals[proposalId].votesFor += 1;
} else {
proposals[proposalId].votesAgainst += 1;
}

hasVoted[proposalId][msg.sender] = true;
emit VoteCast(proposalId, msg.sender, support);
}

// Implement the proposal if it has more votes for than against
function implementProposal(uint256 proposalId) external onlyOwner {
Proposal storage proposal = proposals[proposalId];
require(block.timestamp > proposal.votingDeadline, "Voting period not ended");

if (proposal.votesFor > proposal.votesAgainst) {
hookSwapFeePercentage = proposal.newSwapFeePercentage;
LUCKY_NUMBER = proposal.newLuckyNumber;

emit ProposalImplemented(proposalId, proposal.newSwapFeePercentage, proposal.newLuckyNumber);
}
}

// Lottery logic (onAfterSwap remains unchanged for the most part)
function onAfterSwap(
AfterSwapParams calldata params
) public override onlyVault returns (bool success, uint256 hookAdjustedAmountCalculatedRaw) {
uint8 drawnNumber;
if (params.router == _trustedRouter) {
drawnNumber = _getRandomNumber();
}

_counter++;

hookAdjustedAmountCalculatedRaw = params.amountCalculatedRaw;
if (hookSwapFeePercentage > 0) {
uint256 hookFee = params.amountCalculatedRaw.mulDown(hookSwapFeePercentage);
if (params.kind == SwapKind.EXACT_IN) {
uint256 feeToPay = _chargeFeeOrPayWinner(params.router, drawnNumber, params.tokenOut, hookFee);
if (feeToPay > 0) {
hookAdjustedAmountCalculatedRaw -= feeToPay;
}
} else {
uint256 feeToPay = _chargeFeeOrPayWinner(params.router, drawnNumber, params.tokenIn, hookFee);
if (feeToPay > 0) {
hookAdjustedAmountCalculatedRaw += feeToPay;
}
}
}
return (true, hookAdjustedAmountCalculatedRaw);
}

// Function to set swap fee (can also be changed by governance)
function setHookSwapFeePercentage(uint64 swapFeePercentage) external onlyOwner {

Choose a reason for hiding this comment

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

As written, only the owner can set the fee (which would need to be validated in production). And if they can just set the fee directly, the voting doesn't really mean anything. (Though it already doesn't, if only the owner can propose any changes.)

If you want governance to be able to set it, you would have to implement that (e.g., derive from SingletonAuthentication and use the authenticate modifier). Then it could be set by Balancer governance or through voting. (Balancer governance would be unlikely to get involved with a third party hook like this, though.)

hookSwapFeePercentage = swapFeePercentage;
}

// Pseudo-random number generation
function _getRandomNumber() private view returns (uint8) {

Choose a reason for hiding this comment

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

Would have to be an external Oracle or similar thing in production; there's no way to do this safely on-chain.

return uint8((uint(keccak256(abi.encodePacked(block.prevrandao, _counter))) % MAX_NUMBER) + 1);
}

// Lottery fee and reward logic
function _chargeFeeOrPayWinner(
address router,
uint8 drawnNumber,
IERC20 token,
uint256 hookFee
) private returns (uint256) {
if (drawnNumber == LUCKY_NUMBER) {
address user = IRouterCommon(router).getSender();
for (uint256 i = _tokensWithAccruedFees.length(); i > 0; i--) {
(IERC20 feeToken, ) = _tokensWithAccruedFees.at(i - 1);
_tokensWithAccruedFees.remove(feeToken);
uint256 amountWon = feeToken.balanceOf(address(this));
if (amountWon > 0) {
feeToken.safeTransfer(user, amountWon);
emit LotteryWinningsPaid(address(this), user, feeToken, amountWon);
}
}
return 0;
} else {
_tokensWithAccruedFees.set(token, 1);
if (hookFee > 0) {
_vault.sendTo(token, address(this), hookFee);
}
return hookFee;
}
}

function getHookFlags() public view virtual override returns (HookFlags memory) {}
}

//this is contract
Loading