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 -SafeSwap Hook #91

Open
wants to merge 7 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
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@
[submodule "packages/foundry/lib/balancer-v3-monorepo"]
path = packages/foundry/lib/balancer-v3-monorepo
url = https://github.com/balancer/balancer-v3-monorepo
[submodule "packages/foundry/lib/openzeppelin-contracts-upgradeable"]
path = packages/foundry/lib/openzeppelin-contracts-upgradeable
url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable
337 changes: 97 additions & 240 deletions README.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/foundry/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
DEPLOYER_PRIVATE_KEY=
SEPOLIA_RPC_URL=
SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/
COTRACT_ADDRESS=0x90193C961A926261B756D1E5bb255e67ff9498A1
69 changes: 69 additions & 0 deletions packages/foundry/contracts/hooks/SwapDiscountHook.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later

pragma solidity ^0.8.24;

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

import { IBasePoolFactory } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePoolFactory.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 {
LiquidityManagement,
TokenConfig,
PoolSwapParams,
HookFlags
} 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";

contract SafeSwapDiscount is BaseHooks, VaultGuard {
address private immutable _allowedFactory;
address private immutable _trustedRouter;
IERC20 private immutable _discountToken;

event SwapDiscountHookRegistered(address indexed hooksContract, address indexed factory, address indexed pool);

constructor(IVault vault, address allowedFactory, address discountToken, address trustedRouter) VaultGuard(vault) {
_allowedFactory = allowedFactory;
_trustedRouter = trustedRouter;
_discountToken = IERC20(discountToken);
}

/// @inheritdoc IHooks
function getHookFlags() public pure override returns (HookFlags memory hookFlags) {
hookFlags.shouldCallComputeDynamicSwapFee = true;
}

/// @inheritdoc IHooks
function onRegister(
address factory,
address pool,
TokenConfig[] memory,
LiquidityManagement calldata
) public override onlyVault returns (bool) {
emit SwapDiscountHookRegistered(address(this), factory, pool);

return factory == _allowedFactory && IBasePoolFactory(factory).isPoolFromFactory(pool);
}

/// @inheritdoc IHooks
function onComputeDynamicSwapFeePercentage(
PoolSwapParams calldata params,
address,
uint256 staticSwapFeePercentage
) public view override onlyVault returns (bool, uint256) {
if (params.router != _trustedRouter) {
return (true, staticSwapFeePercentage);
}

address user = IRouterCommon(params.router).getSender();

if (_discountToken.balanceOf(user) > 0) {
return (true, staticSwapFeePercentage / 2);
}

return (true, staticSwapFeePercentage);
}
}
50 changes: 50 additions & 0 deletions packages/foundry/script/SafeSwapDiscount.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later

pragma solidity ^0.8.24;

import "forge-std/Script.sol";

import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import { PoolFactoryMock } from "@balancer-labs/v3-vault/contracts/test/PoolFactoryMock.sol";
import { SwapDiscountHook } from "../contracts/hooks/SwapDiscountHook.sol";

contract DeploySwapDiscountHook is Script {
IVault internal vault;
address internal allowedFactory;
address internal trustedRouter;
address internal discountToken;
uint64 internal hookSwapDiscountPercentage; // Discount percentage
uint256 internal requiredBalance; // Required balance for discount eligibility

function run() external {
// Set the addresses and parameters
vault = IVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8); // Replace with actual vault address
allowedFactory = 0xed52D8E202401645eDAD1c0AA21e872498ce47D0; // Replace with actual factory address
trustedRouter = 0x886A3Ec7bcC508B8795990B60Fa21f85F9dB7948; // Replace with actual router address
discountToken = 0xba100000625a3754423978a60c9317c58a424e3D; // Replace with actual discount token address
hookSwapDiscountPercentage = 50e16; // 50% discount
requiredBalance = 100e18; // 100 of the discount token required for eligibility

// Start the deployment process
vm.startBroadcast();

// Deploy the SwapDiscountHook contract
SwapDiscountHook swapDiscountHook = new SwapDiscountHook(
vault,
allowedFactory,
trustedRouter,
discountToken,
hookSwapDiscountPercentage,
requiredBalance
);

// Label the deployed contract for easier identification
vm.label(address(swapDiscountHook), "Swap Discount Hook");

// End the deployment process
vm.stopBroadcast();

// Output the deployed contract address
console.log("SwapDiscountHook deployed at:", address(swapDiscountHook));
}
}
243 changes: 243 additions & 0 deletions packages/foundry/test/SafeSwapDiscount.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// SPDX-License-Identifier: GPL-3.0-or-later

pragma solidity ^0.8.24;

import "forge-std/Test.sol";

import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol";
import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultAdmin.sol";
import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol";
import {
HooksConfig,
LiquidityManagement,
PoolRoleAccounts,
TokenConfig
} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol";

import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol";
import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.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 { PoolFactoryMock } from "@balancer-labs/v3-vault/contracts/test/PoolFactoryMock.sol";
import { RouterMock } from "@balancer-labs/v3-vault/contracts/test/RouterMock.sol";

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

contract SwapDiscountHookTest is BaseVaultTest {
using CastingHelpers for address[];
using FixedPoint for uint256;
using ArrayHelpers for *;

uint256 internal daiIdx;
uint256 internal usdcIdx;

// Maximum swap fee of 10%
uint64 public constant MAX_SWAP_FEE_PERCENTAGE = 10e16;

address payable internal trustedRouter;

function setUp() public override {
super.setUp();

(daiIdx, usdcIdx) = getSortedIndexes(address(dai), address(usdc));

// Grants LP the ability to change the static swap fee percentage.
authorizer.grantRole(vault.getActionId(IVaultAdmin.setStaticSwapFeePercentage.selector), lp);
}

function createHook() internal override returns (address) {
trustedRouter = payable(router);

// lp will be the owner of the hook. Only LP is able to set hook fee percentages.
vm.prank(lp);
address veBALFeeHook = address(
new SafeSwapDiscount(IVault(address(vault)), address(factoryMock), address(veBAL), trustedRouter)
);
vm.label(veBALFeeHook, "VeBAL Fee Hook");
return veBALFeeHook;
}

function testRegistryWithWrongFactory() public {
address veBALFeePool = _createPoolToRegister();
TokenConfig[] memory tokenConfig = vault.buildTokenConfig(
[address(dai), address(usdc)].toMemoryArray().asIERC20()
);

uint32 pauseWindowEndTime = IVaultAdmin(address(vault)).getPauseWindowEndTime();
uint32 bufferPeriodDuration = IVaultAdmin(address(vault)).getBufferPeriodDuration();
uint32 pauseWindowDuration = pauseWindowEndTime - bufferPeriodDuration;
address unauthorizedFactory = address(new PoolFactoryMock(IVault(address(vault)), pauseWindowDuration));

vm.expectRevert(
abi.encodeWithSelector(
IVaultErrors.HookRegistrationFailed.selector,
poolHooksContract,
veBALFeePool,
unauthorizedFactory
)
);
_registerPoolWithHook(veBALFeePool, tokenConfig, unauthorizedFactory);
}

function testCreationWithWrongFactory() public {
address veBALFeePool = _createPoolToRegister();
TokenConfig[] memory tokenConfig = vault.buildTokenConfig(
[address(dai), address(usdc)].toMemoryArray().asIERC20()
);

vm.expectRevert(
abi.encodeWithSelector(
IVaultErrors.HookRegistrationFailed.selector,
poolHooksContract,
veBALFeePool,
address(factoryMock)
)
);
_registerPoolWithHook(veBALFeePool, tokenConfig, address(factoryMock));
}

function testSuccessfulRegistry() public {
// Register with the allowed factory.
address veBALFeePool = factoryMock.createPool("Test Pool", "TEST");
TokenConfig[] memory tokenConfig = vault.buildTokenConfig(
[address(dai), address(usdc)].toMemoryArray().asIERC20()
);

vm.expectEmit();
emit SafeSwapDiscount.SwapDiscountHookRegistered(
poolHooksContract,
address(factoryMock),
veBALFeePool
);

_registerPoolWithHook(veBALFeePool, tokenConfig, address(factoryMock));

HooksConfig memory hooksConfig = vault.getHooksConfig(veBALFeePool);

assertEq(hooksConfig.hooksContract, poolHooksContract, "Wrong poolHooksContract");
assertEq(hooksConfig.shouldCallComputeDynamicSwapFee, true, "shouldCallComputeDynamicSwapFee is false");
}

function testSwapWithoutVeBal() public {
assertEq(veBAL.balanceOf(bob), 0, "Bob still has veBAL");

_doSwapAndCheckBalances(trustedRouter);
}

function testSwapWithVeBal() public {
// Mint 1 veBAL to Bob, so he's able to receive the fee discount.
veBAL.mint(bob, 1);
assertGt(veBAL.balanceOf(bob), 0, "Bob does not have veBAL");

_doSwapAndCheckBalances(trustedRouter);
}

function testSwapWithVeBalAndUntrustedRouter() public {
// Mint 1 veBAL to Bob, so he's able to receive the fee discount.
veBAL.mint(bob, 1);
assertGt(veBAL.balanceOf(bob), 0, "Bob does not have veBAL");

// Create an untrusted router
address payable untrustedRouter = payable(new RouterMock(IVault(address(vault)), weth, permit2));
vm.label(untrustedRouter, "untrusted router");

// Allows permit2 to move DAI tokens from Bob to untrustedRouter.
vm.prank(bob);
permit2.approve(address(dai), untrustedRouter, type(uint160).max, type(uint48).max);

// Even if Bob has veBAL, since he is using an untrusted router, he will get no discount.
_doSwapAndCheckBalances(untrustedRouter);
}

function _doSwapAndCheckBalances(address payable routerToUse) private {
// Since the Vault has no swap fee, the fee will stay in the pool.
uint256 swapFeePercentage = MAX_SWAP_FEE_PERCENTAGE;

vm.prank(lp);
vault.setStaticSwapFeePercentage(pool, swapFeePercentage);

uint256 exactAmountIn = poolInitAmount / 100;
// PoolMock uses linear math with a rate of 1, so amountIn == amountOut when no fees are applied.
uint256 expectedAmountOut = exactAmountIn;
// If Bob has veBAL and the router is trusted, Bob gets a 50% discount.
bool shouldGetDiscount = routerToUse == trustedRouter && veBAL.balanceOf(bob) > 0;
uint256 expectedHookFee = exactAmountIn.mulDown(swapFeePercentage) / (shouldGetDiscount ? 2 : 1);
// The hook fee will remain in the pool, so the expected amountOut discounts the fees.
expectedAmountOut -= expectedHookFee;

BaseVaultTest.Balances memory balancesBefore = getBalances(bob);

vm.prank(bob);
RouterMock(routerToUse).swapSingleTokenExactIn(
pool,
dai,
usdc,
exactAmountIn,
expectedAmountOut,
MAX_UINT256,
false,
bytes("")
);

BaseVaultTest.Balances memory balancesAfter = getBalances(bob);

// Bob's balance of DAI is supposed to decrease, since DAI is the token in
assertEq(
balancesBefore.userTokens[daiIdx] - balancesAfter.userTokens[daiIdx],
exactAmountIn,
"Bob's DAI balance is wrong"
);
// Bob's balance of USDC is supposed to increase, since USDC is the token out
assertEq(
balancesAfter.userTokens[usdcIdx] - balancesBefore.userTokens[usdcIdx],
expectedAmountOut,
"Bob's USDC balance is wrong"
);

// Vault's balance of DAI is supposed to increase, since DAI was added by Bob
assertEq(
balancesAfter.vaultTokens[daiIdx] - balancesBefore.vaultTokens[daiIdx],
exactAmountIn,
"Vault's DAI balance is wrong"
);
// Vault's balance of USDC is supposed to decrease, since USDC was given to Bob
assertEq(
balancesBefore.vaultTokens[usdcIdx] - balancesAfter.vaultTokens[usdcIdx],
expectedAmountOut,
"Vault's USDC balance is wrong"
);

// Pool deltas should equal vault's deltas
assertEq(
balancesAfter.poolTokens[daiIdx] - balancesBefore.poolTokens[daiIdx],
exactAmountIn,
"Pool's DAI balance is wrong"
);
assertEq(
balancesBefore.poolTokens[usdcIdx] - balancesAfter.poolTokens[usdcIdx],
expectedAmountOut,
"Pool's USDC balance is wrong"
);
}

// Registry tests require a new pool, because an existing pool may be already registered
function _createPoolToRegister() private returns (address newPool) {
newPool = address(new PoolMock(IVault(address(vault)), "VeBAL Fee Pool", "veBALFeePool"));
vm.label(newPool, "VeBAL Fee Pool");
}

function _registerPoolWithHook(address exitFeePool, TokenConfig[] memory tokenConfig, address factory) private {
PoolRoleAccounts memory roleAccounts;
LiquidityManagement memory liquidityManagement;

PoolFactoryMock(factory).registerPool(
exitFeePool,
tokenConfig,
roleAccounts,
poolHooksContract,
liquidityManagement
);
}
}