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

BAL Hookathon - Balancer.fun Memecoin Factory #93

Open
wants to merge 1 commit 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
117 changes: 117 additions & 0 deletions packages/foundry/contracts/hooks/BalancerFun.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol";
import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import {
AfterSwapParams,
LiquidityManagement,
TokenConfig,
PoolSwapParams,
RemoveLiquidityKind,
HookFlags,
SwapKind
} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol";
import { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol";
import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol";

/**
* @title BalancerFun Hook Contract
* @notice Implements custom hooks for Balancer V3 liquidity pools to enforce swap limits and prevent liquidity removal.
*/
contract BalancerFun is BaseHooks, VaultGuard {
/// @notice The token that is managed by this hook.
IERC20 public immutable token;

/// @notice The maximum amount that can be swapped in a single block.
uint public immutable maxSwapAmount;

/// @notice A mapping that tracks the total amount of tokens sold per block.
mapping(uint => uint) public blockToTotalSold;

/// @notice Event emitted when the BalancerFun hook is registered.
/// @param hooksContract The address of the hooks contract.
/// @param pool The address of the pool where the hook is registered.
event BalancerFunHookRegistered(address indexed hooksContract, address indexed pool);

/// @notice Error thrown when a swap exceeds the maximum allowed swap amount.
error MaximumSwapExceeded();

/// @notice Error thrown when an attempt is made to remove liquidity, which is not allowed.
error LiquidityIsLocked();

/**
* @notice Constructor for the BalancerFun contract.
* @param vault The Balancer Vault contract.
* @param _token The ERC20 token that is managed by this hook.
*/
constructor(IVault vault, IERC20 _token) VaultGuard(vault) {
token = _token;
maxSwapAmount = 1_000_000 ether * 3 / 100; // 3% of total supply, could use token.totalSupply()
Copy link

Choose a reason for hiding this comment

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

Does this assume that the supply of the token is fixed when the pool is initialized?

I'd suggest making this configurable (perhaps one maximum value per pool and a default one) by making the hook ownable and providing a permissioned setter function

Choose a reason for hiding this comment

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

Also need to define whether this is a raw amount (presumably it is), in which case the initialization would depend on token decimals.

}

/**
* @notice Returns the hook flags indicating which hook functions should be called.
* @return hookFlags The HookFlags struct indicating which hook functions are enabled.
*/
function getHookFlags() public pure override returns (HookFlags memory) {
HookFlags memory hookFlags;
hookFlags.shouldCallAfterSwap = true;
hookFlags.shouldCallAfterRemoveLiquidity = true;
return hookFlags;
}

/**
* @notice Called when the hook is registered to a pool.
* @param pool The address of the pool to which the hook is being registered.
* @return success Boolean indicating whether the registration was successful.
*/
function onRegister(
address,
address pool,
TokenConfig[] memory,
LiquidityManagement calldata
) public override onlyVault returns (bool) {
emit BalancerFunHookRegistered(address(this), pool);
return true;

Choose a reason for hiding this comment

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

Does this work with every pool type? The original fair launch pool was an LBP (in v2, and under construction in v3). Does it need to be 2-token, etc.?

}

/**
* @notice Called after a swap is performed in the pool.
* @param params The parameters for the swap, including token addresses and amounts.
* @return success Boolean indicating if the swap was successful.
* @return amountCalculatedRaw The calculated amount after the swap.
*/
function onAfterSwap(
AfterSwapParams calldata params
) public override onlyVault returns (bool, uint) {
if (address(params.tokenIn) == address(token)) {
uint currentBlockSold = blockToTotalSold[block.number];
if (currentBlockSold + params.amountInScaled18 > maxSwapAmount * 1e18) {

Choose a reason for hiding this comment

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

This assumes the token's 18-decimals; you could integrate the ScalingHelpers library to help here (presumably you don't want to limit it to 18-decimal tokens).

Choose a reason for hiding this comment

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

As noted elsewhere, there could be a "decaying limit" here, or some kind of defined "sale period" during which it would be limited, or perhaps a % distribution. This would have to be some kind of custom pool type anyway (e.g., a regular Weighted Pool doesn't work well for "one-sided" sales, as the price would just go up and up until the sale stopped).

Maybe it returns a constant price or something. It could also have some sort of pricing curve that let you buy as much as you wanted, but at higher and higher prices as the amount went up. There could be a threshold amount, under which you get the "sale price." Or you could sell it in fixed-sized blocks for a defined price, and only let people buy once per block (or 10 blocks, etc.) Arbitrary pool code = limitless design space.

revert MaximumSwapExceeded();
}
blockToTotalSold[block.number] = currentBlockSold + params.amountInScaled18;
}
return (true, params.amountCalculatedRaw);
}

/**
* @notice Called after an attempt to remove liquidity from the pool.
* @dev This function always reverts with `LiquidityIsLocked()` error to prevent liquidity removal.
* @return success Boolean indicating if the function succeeded.
* @return emptyArray An empty array to satisfy return requirements.
*/
function onAfterRemoveLiquidity(
address,
address,
RemoveLiquidityKind,
uint,
uint[] memory,
uint[] memory,
uint[] memory,
bytes memory
) public view override onlyVault returns (bool, uint[] memory) {
revert LiquidityIsLocked();
Copy link

Choose a reason for hiding this comment

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

😅 blunt, but interesting concept!

If I'm not mistaken, what most memecoin launches do is just create a pool with a ton of memecoin liquidity and then burn the LP supply manually; is that correct?
If this hook is trying to achieve the same, the problem with this approach is that whoever adds the liquidity still owns the BPT shares, and the liquidity can still be exited via recovery mode. Balancer V3 is designed to be non-custodial, and LPs can always exit their positions proportionally when recovery mode is activated. Therefore, blocking the liquidity is not as simple, unfortunately (or thankfully for LPs?).

There are ways which can get you around this, although you might need a custom router for that. The one that I have in mind is the following:

  • modify the current router so that the recipient of the BPT can be configured as an argument when you add liquidity / initialize
  • in onAfterAdd (and possibly onAfterInitialize), check that the router is trusted, and check that the recipient the liquidity is e.g. 0x...Dead.

That way, you don't need to revert: BPT shares are always sent to a dead address, which ensures that the liquidity can never be retrieved back again. Can also be done with just onAfterInitialize, and just revert onAfterAdd always.

Choose a reason for hiding this comment

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

Generally the pool creators need to extract the proceeds at some point, though. Balancer pools have to be two-tokens, so there will be a "value token" (like DAI or WETH) that would also be locked. There are lots of options here.

The BPT could be held by the hook contract, and the owner would be able to withdraw it with a permissioned function after a timelock. Then you wouldn't need to do anything in the remove hooks. You'd need an owner then, and also ensure that only the owner can add liquidity (otherwise other people could inadvertently "donate" funds that would go to the owner). LBPs do this as well (except for the timelock withdrawal).

}
}
152 changes: 152 additions & 0 deletions packages/foundry/test/BalancerFun.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol";
import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import {
LiquidityManagement,
PoolRoleAccounts,
SwapKind
} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol";

import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol";
import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol";

import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol";
import { PoolMock } from "@balancer-labs/v3-vault/contracts/test/PoolMock.sol";

import { BalancerFun } from "../contracts/hooks/BalancerFun.sol";

/**
* @title BalancerFunTest
* @notice Unit tests for the BalancerFun contract.
* @dev Inherits from BaseVaultTest to perform setup and test BalancerFun interactions.
*/
contract BalancerFunTest is BaseVaultTest {
using CastingHelpers for address[];
using FixedPoint for uint;

uint internal daiIdx;
uint internal usdcIdx;

/**
* @notice Sets up the test environment.
* @dev Overrides BaseVaultTest's setUp function to initialize token indexes.
*/
function setUp() public virtual override {
BaseVaultTest.setUp();
(daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc));
}

/**
* @notice Creates a new BalancerFun hook for testing.
* @dev Deploys a new instance of BalancerFun and sets it as the hook for the pool.
* @return address The address of the newly created hook.
*/
function createHook() internal override returns (address) {
// lp will be the owner of the hook. Only the owner can set hook fee percentages.
vm.prank(lp);
BalancerFun hook = new BalancerFun(IVault(address(vault)), IERC20(address(router)));
return address(hook);
}

/**
* @notice Creates a new pool with custom liquidity management settings.
* @dev Overrides the pool creation to disable unbalanced liquidity by setting liquidityManagement.
* @param tokens The tokens to be used in the pool.
* @param label A label for the pool.
* @return address The address of the newly created pool.
*/
function _createPool(address[] memory tokens, string memory label) internal override returns (address) {
PoolMock newPool = new PoolMock(IVault(address(vault)), "Balancer.Fun Pool", "BALFUN");
vm.label(address(newPool), label);
PoolRoleAccounts memory roleAccounts;
roleAccounts.poolCreator = lp;
LiquidityManagement memory liquidityManagement;
factoryMock.registerPool(
address(newPool),
vault.buildTokenConfig(tokens.asIERC20()),
roleAccounts,
poolHooksContract,
liquidityManagement
);

return address(newPool);
}

/**
* @notice Tests the setup of the contract.
* @dev Verifies that the setup has been completed successfully.
*/
function testSetUp() public {
assertEq(daiIdx, 0, "SetUp has failed");
}

/**
* @notice Tests a swap operation.
* @dev Executes a swap and verifies the balance changes for Alice.
*/
function testSwap() public {
Copy link

Choose a reason for hiding this comment

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

It would be nice to see a test that actually triggers one of the new conditions errors e.g. MaximumSwapExceeded

(
BaseVaultTest.Balances memory balancesBefore,
BaseVaultTest.Balances memory balancesAfter,
uint swapAmount,
uint[] memory accruedFees,
uint iterations
) = _executeSwap(1 ether);

assertEq(
balancesBefore.aliceTokens[daiIdx] - balancesAfter.aliceTokens[daiIdx],
swapAmount,
"Alice DAI balance is wrong"
);
}

/**
* @notice Executes a swap operation.
* @dev Performs a swap of the specified amount and returns relevant data.
* @param _swapAmount The amount to be swapped.
* @return balancesBefore The balances before the swap.
* @return balancesAfter The balances after the swap.
* @return swapAmount The amount that was swapped.
* @return accruedFees The accrued fees during the swap.
* @return iterations The number of iterations performed.
*/
function _executeSwap(uint _swapAmount) private returns (
BaseVaultTest.Balances memory balancesBefore,
BaseVaultTest.Balances memory balancesAfter,
uint swapAmount,
uint[] memory accruedFees,
uint iterations
)
{
vm.prank(lp);
balancesBefore = getBalances(alice);
bytes4 routerMethod;
routerMethod = IRouter.swapSingleTokenExactIn.selector;
uint amountGiven = _swapAmount;

vm.prank(alice);
(bool success, ) = address(router).call(
abi.encodeWithSelector(
routerMethod,
address(pool),
dai,
usdc,
amountGiven,
amountGiven,
MAX_UINT256,
false,
bytes("")
)
);

assertTrue(success, "Swap has failed");
balancesAfter = getBalances(alice);
swapAmount = _swapAmount;
}
}