diff --git a/.gitmodules b/.gitmodules index bfd357b9..dff455ab 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/README.md b/README.md index 1397b2da..57ac0d6c 100644 --- a/README.md +++ b/README.md @@ -1,301 +1,158 @@ -# ๐Ÿ—๏ธŽ Scaffold Balancer v3 -A starter kit for building on top of Balancer v3. Accelerate the process of creating custom pools and hooks contracts. Concentrate on mastering the core concepts within a swift and responsive environment augmented by a local fork and a frontend pool operations playground. +# SafeSwap -[![intro-to-scaffold-balancer](https://github.com/user-attachments/assets/f862091d-2fe9-4b4b-8d70-cb2fdc667384)](https://www.youtube.com/watch?v=m6q5M34ZdXw) +SafeSwap is a decentralized automated market maker (AMM) built on the Balancer V3 protocol. It enables liquidity providers to add and remove liquidity in a pool while allowing users to swap between tokens. Additionally, SafeSwap introduces a unique discount mechanism for users holding a specific token, providing them with a 10% discount on swap fees. -### ๐Ÿ” Development Life Cycle +## Key Features -1. Learn the core concepts for building on top of Balancer v3 -2. Configure and deploy factories, pools, and hooks contracts to a local anvil fork of Sepolia -3. Interact with pools via a frontend that runs at [localhost:3000](http://localhost:3000/) +- **Balancer V3 Integration**: Built on top of the Balancer V3 protocol with BalancerPoolToken, allowing flexible pool operations. +- **Custom Liquidity Operations**: Custom hooks for adding and removing liquidity. +- **Discount on Swap Fees**: Users holding a specific ERC-20 token are eligible for a 10% discount on the swap fee. +- **Optimized Fee Structure**: The contract enforces a swap fee between 0% and 10%, with a default fee of 10%, reduced if the user qualifies for a discount. -### ๐Ÿชง Table Of Contents +## Contract Structure -- [๐Ÿง‘โ€๐Ÿ’ป Environment Setup](#-environment-setup) -- [๐Ÿ‘ฉโ€๐Ÿซ Learn Core Concepts](#-learn-core-concepts) -- [๐Ÿ•ต๏ธ Explore the Examples](#-explore-the-examples) -- [๐ŸŒŠ Create a Custom Pool](#-create-a-custom-pool) -- [๐Ÿญ Create a Pool Factory](#-create-a-pool-factory) -- [๐Ÿช Create a Pool Hook](#-create-a-pool-hook) -- [๐Ÿšข Deploy the Contracts](#-deploy-the-contracts) -- [๐Ÿงช Test the Contracts](#-test-the-contracts) +### SafeSwap -## ๐Ÿง‘โ€๐Ÿ’ป Environment Setup +The `SafeSwap` contract extends the Balancer V3 pool functionality with added customization for liquidity and swaps: -### 1. Requirements ๐Ÿ“œ +- **Discount Token**: The contract allows setting a discount token, an ERC-20 token. Users holding this token in their wallets receive a reduced swap fee. +- **Swap Fee Logic**: The default swap fee is 10%, which can be reduced by 10% (i.e., down to 9%) if the user holds the discount token. +- **Custom Liquidity Hooks**: Implementations for custom liquidity operations (`onAddLiquidityCustom` and `onRemoveLiquidityCustom`). -- [Node (>= v18.17)](https://nodejs.org/en/download/) -- Yarn ([v1](https://classic.yarnpkg.com/en/docs/install/) or [v2+](https://yarnpkg.com/getting-started/install)) -- [Git](https://git-scm.com/downloads) -- [Foundry](https://book.getfoundry.sh/getting-started/installation) (>= v0.2.0) +### Key Functions -### 2. Quickstart ๐Ÿƒ +1. **`onSwap`**: Executes a token swap within the pool. If the user holds the discount token, they receive a 10% discount on the swap fee. + - Parameters: + - `params`: Pool swap parameters, including token balances and amounts. + - Returns: + - `amountCalculatedScaled18`: The calculated amount for the swap, adjusted for the discounted swap fee. -1. Ensure you have the latest version of foundry installed +2. **`computeInvariant`**: Calculates the pool's invariant based on the current token balances. -``` -foundryup -``` +3. **`onAddLiquidityCustom`**: Custom implementation for adding liquidity to the pool. + - Returns: + - `amountsInScaled18`: Amount of tokens being added to the pool. + - `bptAmountOut`: Calculated pool token (BPT) amount the user receives. + - `swapFeeAmountsScaled18`: The swap fee charged for each token. -2. Clone this repo & install dependencies +4. **`onRemoveLiquidityCustom`**: Custom implementation for removing liquidity from the pool. + - Returns: + - `bptAmountIn`: Amount of pool tokens (BPT) burned. + - `amountsOutScaled18`: Amount of tokens the user receives. + - `swapFeeAmountsScaled18`: The swap fee charged for each token. -```bash -git clone https://github.com/balancer/scaffold-balancer-v3.git -cd scaffold-balancer-v3 -yarn install -``` +### Discount Logic -3. Set a `SEPOLIA_RPC_URL` in the `packages/foundry/.env` file +The discount logic checks whether the user holds a specific ERC-20 token (the "discount token"). If the user holds this token, they are eligible for a 10% discount on the swap fee. -``` -SEPOLIA_RPC_URL=... -``` +### Constructor Parameters -4. Start a local anvil fork of the Sepolia testnet +The `SafeSwap` contract constructor takes the following parameters: -```bash -yarn fork -``` - -5. Deploy the mock tokens, pool factories, pool hooks, and custom pools contracts - > By default, the anvil account #0 will be the deployer and recieve the mock tokens and BPT from pool initialization +1. **`IVault vault`**: The Balancer Vault that manages the pool. +2. **`string name`**: Name of the pool token (BPT). +3. **`string symbol`**: Symbol of the pool token (BPT). +4. **`address discountToken`**: The address of the ERC-20 token that provides users with the discount on swap fees. -```bash -yarn deploy -``` +### Deployment -6. Start the nextjs frontend +To deploy the contract, provide the following arguments to the constructor: -```bash -yarn start +```solidity +constructor( + IVault vault, + string memory name, + string memory symbol, + address _discountToken +) ``` -7. Explore the frontend +- **`vault`**: Address of the Balancer Vault. +- **`name`**: The name of the Balancer Pool Token (BPT). +- **`symbol`**: The symbol of the Balancer Pool Token (BPT). +- **`_discountToken`**: Address of the ERC-20 token that gives users a swap fee discount. -- Navigate to http://localhost:3000 to see the home page -- Visit the [Pools Page](http://localhost:3000/pools) to search by address or select using the pool buttons -- Vist the [Debug Page](http://localhost:3000/debug) to see the mock tokens, factory, and hooks contracts +### Example -8. Run the Foundry tests +Deploying the `SafeSwap` contract: +```solidity +SafeSwap safeSwap = new SafeSwap( + vaultAddress, + "SafeSwap Pool Token", + "SSPT", + discountTokenAddress +); ``` -yarn test -``` - -### 3. Scaffold ETH 2 Tips ๐Ÿ—๏ธ - -SE-2 offers a variety of configuration options for connecting an account, choosing networks, and deploying contracts - -
๐Ÿ”ฅ Burner Wallet - -If you do not have an active wallet extension connected to your web browser, then scaffold eth will automatically connect to a "burner wallet" that is randomly generated on the frontend and saved to the browser's local storage. When using the burner wallet, transactions will be instantly signed, which is convenient for quick iterative development. - -To force the use of burner wallet, disable your browsers wallet extensions and refresh the page. Note that the burner wallet comes with 0 ETH to pay for gas so you will need to click the faucet button in top right corner. Also the mock tokens for the pool are minted to your deployer account set in `.env` so you will want to navigate to the "Debug Contracts" page to mint your burner wallet some mock tokens to use with the pool. - -![Burner Wallet](https://github.com/Dev-Rel-as-a-Service/scaffold-balancer-v3/assets/73561520/0a1f3456-f22a-46b5-9e05-0ef5cd17cce7) - -![Debug Tab Mint](https://github.com/Dev-Rel-as-a-Service/scaffold-balancer-v3/assets/73561520/fbb53772-8f6d-454d-a153-0e7a2925ef9f) - -
- -
๐Ÿ‘› Browser Extension Wallet - -- To use your preferred browser extension wallet, ensure that the account you are using matches the PK you previously provided in the `foundry/.env` file -- You may need to add a local development network with rpc url `http://127.0.0.1:8545/` and chain id `31337`. Also, you may need to reset the nonce data for your wallet exension if it gets out of sync. -
+### Swap Fee Discount Example -
๐Ÿ› Debug Contracts Page +If a user holds the specified `discountToken`, they will receive a 10% discount on the swap fee: -The [Debug Contracts Page](http://localhost:3000/debug) can be useful for viewing and interacting with all of the externally avaiable read and write functions of a contract. The page will automatically hot reload with contracts that are deployed via the `01_DeployConstantSumFactory.s.sol` script. We use this handy setup to mint `mockERC20` tokens to any connected wallet +- Default swap fee: 10% +- Discounted swap fee: 9% (if the user holds the discount token) -
+## Installation -
๐ŸŒ Changing The Frontend Network Connection +To install and run the SafeSwap contract, follow these steps: -- The network the frontend points at is set via `targetNetworks` in the `scaffold.config.ts` file using `chains` from viem. -- By default, the frontend runs on a local node at `http://127.0.0.1:8545` +1. Clone the repository: -```typescript -const scaffoldConfig = { - targetNetworks: [chains.foundry], -``` - -
- -
๐Ÿด Changing The Forked Network - -- By default, the `yarn fork` command points at sepolia, but any of the network aliases from the `[rpc_endpoints]` of `foundry.toml` can be used to modify the `"fork"` alias in the `packages/foundry/package.json` file - -```json - "fork": "anvil --fork-url ${0:-sepolia} --chain-id 31337 --config-out localhost.json", +```bash +git clone https://github.com/your-repo/safeswap.git ``` -- To point the frontend at a different forked network, change the `targetFork` in `scaffold.config.ts` +2. Install dependencies: -```typescript -const scaffoldConfig = { - // The networks the frontend can connect to - targetNetworks: [chains.foundry], - - // If using chains.foundry as your targetNetwork, you must specify a network to fork - targetFork: chains.sepolia, +```bash +npm install ``` -
- -## ๐Ÿ‘ฉโ€๐Ÿซ Learn Core Concepts - -- [Contract Architecture](https://docs-v3.balancer.fi/concepts/core-concepts/architecture.html) -- [Balancer Pool Tokens](https://docs-v3.balancer.fi/concepts/core-concepts/balancer-pool-tokens.html) -- [Balancer Pool Types](https://docs-v3.balancer.fi/concepts/explore-available-balancer-pools/) -- [Building Custom AMMs](https://docs-v3.balancer.fi/build-a-custom-amm/) -- [Exploring Hooks and Custom Routers](https://pitchandrolls.com/2024/08/30/unlocking-the-power-of-balancer-v3-exploring-hooks-and-custom-routers/) -- [Hook Development Tips](https://medium.com/@johngrant/unlocking-the-power-of-balancer-v3-hook-development-made-simple-831391a68296) - -![v3-components](https://github.com/user-attachments/assets/ccda9323-790f-4276-b092-c867fd80bf9e) - -## ๐Ÿ•ต๏ธ Explore the Examples - -Each of the following examples have turn key deploy scripts that can be found in the [foundry/script/](https://github.com/balancer/scaffold-balancer-v3/tree/main/packages/foundry/script) directory - -### 1. Constant Sum Pool with Dynamic Swap Fee Hook - -The swap fee percentage is altered by the hook contract before the pool calculates the amount for the swap - -![dynamic-fee-hook](https://github.com/user-attachments/assets/5ba69ea3-6894-4eeb-befa-ed87cfeb6b13) - -### 2. Constant Product Pool with Lottery Hook - -An after swap hook makes a request to an oracle contract for a random number - -![after-swap-hook](https://github.com/user-attachments/assets/594ce1ac-2edc-4d16-9631-14feb2d085f8) - -### 3. Weighted Pool with Exit Fee Hook - -An after remove liquidity hook adjusts the amounts before the vault transfers tokens to the user - -![after-remove-liquidity-hook](https://github.com/user-attachments/assets/2e8f4a5c-f168-4021-b316-28a79472c8d1) - -## ๐ŸŒŠ Create a Custom Pool - -[![custom-amm-video](https://github.com/user-attachments/assets/e6069a51-f1b5-4f98-a2a9-3a2098696f96)](https://www.youtube.com/watch?v=kXynS3jAu0M) - -### 1. Review the Docs ๐Ÿ“– - -- [Create a custom AMM with a novel invariant](https://docs-v3.balancer.fi/build-a-custom-amm/build-an-amm/create-custom-amm-with-novel-invariant.html) - -### 2. Recall the Key Requirements ๐Ÿ”‘ - -- Must inherit from `IBasePool` and `BalancerPoolToken` -- Must implement `onSwap`, `computeInvariant`, and `computeBalance` -- Must implement `getMaximumSwapFeePercentage` and `getMinimumSwapFeePercentage` - -### 3. Write a Custom Pool Contract ๐Ÿ“ - -- To get started, edit the`ConstantSumPool.sol` contract directly or make a copy - -## ๐Ÿญ Create a Pool Factory - -After designing a pool contract, the next step is to prepare a factory contract because Balancer's off-chain infrastructure uses the factory address as a means to identify the type of pool, which is important for integration into the UI, SDK, and external aggregators - -### 1. Review the Docs ๐Ÿ“– - -- [Deploy a Custom AMM Using a Factory](https://docs-v3.balancer.fi/build-a-custom-amm/build-an-amm/deploy-custom-amm-using-factory.html) - -### 2. Recall the Key Requirements ๐Ÿ”‘ - -- A pool factory contract must inherit from [BasePoolFactory](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/contracts/factories/BasePoolFactory.sol) -- Use the internal `_create` function to deploy a new pool -- Use the internal `_registerPoolWithVault` fuction to register a pool immediately after creation - -### 3. Write a Factory Contract ๐Ÿ“ - -- To get started, edit the`ConstantSumFactory.sol` contract directly or make a copy - -## ๐Ÿช Create a Pool Hook - -[![hook-video](https://github.com/user-attachments/assets/96e12c29-53c2-4a52-9437-e477f6d992d1)](https://www.youtube.com/watch?v=kaz6duliRPA) - -### 1. Review the Docs ๐Ÿ“– - -- [Extend an Existing Pool Type Using Hooks](https://docs-v3.balancer.fi/build-a-custom-amm/build-an-amm/extend-existing-pool-type-using-hooks.html) - -### 2. Recall the Key Requirements ๐Ÿ”‘ - -- A hooks contract must inherit from [BasePoolHooks.sol](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/contracts/BaseHooks.sol) -- A hooks contract should also inherit from [VaultGuard.sol](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/contracts/VaultGuard.sol) -- Must implement `onRegister` to determine if a pool is allowed to use the hook contract -- Must implement `getHookFlags` to define which hooks are supported -- The `onlyVault` modifier should be applied to all hooks functions (i.e. `onRegister`, `onBeforeSwap`, `onAfterSwap` ect.) - -### 3. Write a Hook Contract ๐Ÿ“ - -- To get started, edit the `VeBALFeeDiscountHook.sol` contract directly or make a copy - -## ๐Ÿšข Deploy the Contracts - -The deploy scripts are located in the [foundry/script/](https://github.com/balancer/scaffold-balancer-v3/tree/main/packages/foundry/script) directory. To better understand the lifecycle of deploying a pool that uses a hooks contract, see the diagram below - -![pool-deploy-scripts](https://github.com/user-attachments/assets/bb906080-8f42-46c0-af90-ba01ba1754fc) - -### 1. Modifying the Deploy Scripts ๐Ÿ› ๏ธ - -For all the scaffold integrations to work properly, each deploy script must be imported into `Deploy.s.sol` and inherited by the `DeployScript` contract in `Deploy.s.sol` - -### 2. Broadcast the Transactions ๐Ÿ“ก - -#### Deploy to local fork - -1. Run the following command +3. Compile the contracts: ```bash -yarn deploy +npx hardhat compile ``` -#### Deploy to a live network +4. Run tests: -1. Add a `DEPLOYER_PRIVATE_KEY` to the `packages/foundry/.env` file - -``` -DEPLOYER_PRIVATE_KEY=0x... -SEPOLIA_RPC_URL=... +```bash +npx hardhat test ``` -> The `DEPLOYER_PRIVATE_KEY` must start with `0x` and must hold enough Sepolia ETH to deploy the contracts. This account will receive the BPT from pool initialization +## Usage -2. Run the following command - -``` -yarn deploy --network sepolia -``` +Once deployed, users can interact with the `SafeSwap` contract to: -## ๐Ÿงช Test the Contracts +1. **Swap tokens** using the `onSwap` function. +2. **Add liquidity** with the `onAddLiquidityCustom` function. +3. **Remove liquidity** with the `onRemoveLiquidityCustom` function. -The [balancer-v3-monorepo](https://github.com/balancer/balancer-v3-monorepo) provides testing utility contracts like [BasePoolTest](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/test/foundry/utils/BasePoolTest.sol) and [BaseVaultTest](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/vault/test/foundry/utils/BaseVaultTest.sol). Therefore, the best way to begin writing tests for custom factories, pools, and hooks contracts is to leverage the examples established by the source code. +### Interacting with the Contract -### 1. Testing Factories ๐Ÿ‘จโ€๐Ÿ”ฌ +#### Swap Tokens -The `ConstantSumFactoryTest` roughly mirrors the [WeightedPool8020FactoryTest -](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/pool-weighted/test/foundry/WeightedPool8020Factory.t.sol) +To execute a swap, call the `onSwap` function: -``` -yarn test --match-contract ConstantSumFactoryTest +```solidity +safeSwap.onSwap(swapParams); ``` -### 2. Testing Pools ๐ŸŠ +If the user holds the discount token, the swap fee will automatically be reduced. -The `ConstantSumPoolTest` roughly mirrors the [WeightedPoolTest](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/pool-weighted/test/foundry/WeightedPool.t.sol) +#### Add Liquidity -``` -yarn test --match-contract ConstantSumPoolTest +To add liquidity, call the `onAddLiquidityCustom` function: + +```solidity +safeSwap.onAddLiquidityCustom(router, maxAmountsInScaled18, minBptAmountOut, balancesScaled18, userData); ``` -### 3. Testing Hooks ๐ŸŽฃ +#### Remove Liquidity -The `VeBALFeeDiscountHookExampleTest` mirrors the [VeBALFeeDiscountHookExampleTest](https://github.com/balancer/balancer-v3-monorepo/blob/main/pkg/pool-hooks/test/foundry/VeBALFeeDiscountHookExample.t.sol) +To remove liquidity, call the `onRemoveLiquidityCustom` function: -``` -yarn test --match-contract VeBALFeeDiscountHookExampleTest +```solidity +safeSwap.onRemoveLiquidityCustom(router, maxBptAmountIn, minAmountsOutScaled18, balancesScaled18, userData); ``` diff --git a/packages/foundry/.env.example b/packages/foundry/.env.example index 05cf662f..4cd43cd0 100644 --- a/packages/foundry/.env.example +++ b/packages/foundry/.env.example @@ -1,2 +1,3 @@ DEPLOYER_PRIVATE_KEY= -SEPOLIA_RPC_URL= \ No newline at end of file +SEPOLIA_RPC_URL=https://sepolia.infura.io/v3/ +COTRACT_ADDRESS=0x90193C961A926261B756D1E5bb255e67ff9498A1 \ No newline at end of file diff --git a/packages/foundry/contracts/hooks/SwapDiscountHook.sol b/packages/foundry/contracts/hooks/SwapDiscountHook.sol new file mode 100644 index 00000000..1ea6f4c7 --- /dev/null +++ b/packages/foundry/contracts/hooks/SwapDiscountHook.sol @@ -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); + } +} diff --git a/packages/foundry/lib/openzeppelin-contracts-upgradeable b/packages/foundry/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 00000000..df85a5ac --- /dev/null +++ b/packages/foundry/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit df85a5acb5daae4dbc576b7e4ce2ca8600c646ff diff --git a/packages/foundry/script/SafeSwapDiscount.s.sol b/packages/foundry/script/SafeSwapDiscount.s.sol new file mode 100644 index 00000000..078e5286 --- /dev/null +++ b/packages/foundry/script/SafeSwapDiscount.s.sol @@ -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)); + } +} diff --git a/packages/foundry/test/SafeSwapDiscount.t.sol b/packages/foundry/test/SafeSwapDiscount.t.sol new file mode 100644 index 00000000..4e1eea0c --- /dev/null +++ b/packages/foundry/test/SafeSwapDiscount.t.sol @@ -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 + ); + } +}