├── .prettierrc ├── .gitignore ├── scripts ├── flatten.py ├── increase_cardinality.py ├── print_price.py ├── rebalance.py ├── deploy_mainnet.py ├── upgrade_strategy.py └── deploy_rinkeby.py ├── interfaces ├── IStrategy.sol └── IVault.sol ├── package.json ├── brownie-config.yaml ├── contracts ├── test │ ├── MockToken.sol │ └── TestRouter.sol ├── AlphaStrategy.sol ├── PassiveStrategy.sol └── AlphaVault.sol ├── tests ├── test_deploy_vault.py ├── test_deploy_strategy.py ├── test_total_amounts.py ├── test_simulations.py ├── test_passive_strategy.py ├── test_governance_methods.py ├── conftest.py ├── test_deposit_withdraw.py ├── test_rebalance.py └── test_invariants.py ├── README.md └── LICENSE /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 96, 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.swp 3 | 4 | __pycache__ 5 | .pytest_cache 6 | 7 | node_modules/ 8 | reports/ 9 | build/ 10 | 11 | OpenZeppelin/ 12 | Uniswap/ 13 | 14 | flat.sol 15 | -------------------------------------------------------------------------------- /scripts/flatten.py: -------------------------------------------------------------------------------- 1 | from brownie import AlphaStrategy 2 | 3 | 4 | def main(): 5 | source = AlphaStrategy.get_verification_info()["flattened_source"] 6 | 7 | with open("flat.sol", "w") as f: 8 | f.write(source) 9 | -------------------------------------------------------------------------------- /interfaces/IStrategy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense 2 | 3 | pragma solidity 0.7.6; 4 | 5 | interface IStrategy { 6 | function rebalance() external; 7 | 8 | function shouldRebalance() external view returns (bool); 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alpha-vaults-contracts", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "lint": "prettier --list-different **/*.sol", 7 | "lint:check": "prettier --check **/*.sol", 8 | "lint:fix": "prettier --write **/*.sol" 9 | }, 10 | "dependencies": { 11 | "prettier": "^2.2.1", 12 | "prettier-plugin-solidity": "^1.0.0-beta.10", 13 | "solhint-plugin-prettier": "0.0.5" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /brownie-config.yaml: -------------------------------------------------------------------------------- 1 | 2 | dependencies: 3 | - OpenZeppelin/openzeppelin-contracts@3.4.0 4 | - Uniswap/uniswap-v3-core@1.0.0 5 | - Uniswap/uniswap-v3-periphery@1.0.0 6 | 7 | autofetch_sources: true 8 | 9 | compiler: 10 | solc: 11 | version: 0.7.6 12 | remappings: 13 | - "@openzeppelin=OpenZeppelin/openzeppelin-contracts@3.4.0" 14 | - "@uniswap/v3-core=Uniswap/uniswap-v3-core@1.0.0" 15 | - "@uniswap/v3-periphery=Uniswap/uniswap-v3-periphery@1.0.0" 16 | 17 | -------------------------------------------------------------------------------- /contracts/test/MockToken.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense 2 | 3 | pragma solidity 0.7.6; 4 | 5 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 6 | 7 | contract MockToken is ERC20 { 8 | constructor( 9 | string memory name, 10 | string memory symbol, 11 | uint8 decimals 12 | ) ERC20(name, symbol) { 13 | _setupDecimals(decimals); 14 | } 15 | 16 | function mint(address account, uint256 amount) external { 17 | _mint(account, amount); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /interfaces/IVault.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense 2 | 3 | pragma solidity 0.7.6; 4 | 5 | interface IVault { 6 | function deposit( 7 | uint256, 8 | uint256, 9 | uint256, 10 | uint256, 11 | address 12 | ) 13 | external 14 | returns ( 15 | uint256, 16 | uint256, 17 | uint256 18 | ); 19 | 20 | function withdraw( 21 | uint256, 22 | uint256, 23 | uint256, 24 | address 25 | ) external returns (uint256, uint256); 26 | 27 | function getTotalAmounts() external view returns (uint256, uint256); 28 | } 29 | -------------------------------------------------------------------------------- /tests/test_deploy_vault.py: -------------------------------------------------------------------------------- 1 | from brownie import reverts, ZERO_ADDRESS 2 | 3 | 4 | def test_constructor(AlphaVault, pool, gov): 5 | vault = gov.deploy(AlphaVault, pool, 10000, 100e18) 6 | assert vault.pool() == pool 7 | assert vault.token0() == pool.token0() 8 | assert vault.token1() == pool.token1() 9 | 10 | assert vault.protocolFee() == 10000 11 | assert vault.maxTotalSupply() == 100e18 12 | assert vault.governance() == gov 13 | assert vault.strategy() == ZERO_ADDRESS 14 | 15 | assert vault.name() == "Alpha Vault" 16 | assert vault.symbol() == "AV" 17 | assert vault.decimals() == 18 18 | 19 | assert vault.getTotalAmounts() == (0, 0) 20 | 21 | 22 | def test_constructor_checks(AlphaVault, pool, gov): 23 | with reverts("protocolFee"): 24 | gov.deploy(AlphaVault, pool, 1e6, 100e18) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Alpha Vaults 2 | 3 | This repository contains the smart contracts for the [Alpha Vaults](https://alpha.charm.fi/) protocol. 4 | 5 | Feel free to [join our discord](https://discord.gg/6BY3Fq2) if you have any questions. 6 | 7 | 8 | ### Usage 9 | 10 | Before compiling, run below. The uniswap-v3-periphery package has to be cloned 11 | otherwise imports don't work. 12 | 13 | `brownie pm clone Uniswap/uniswap-v3-periphery@1.0.0` 14 | 15 | Run tests 16 | 17 | `brownie test` 18 | 19 | To deploy, modify the parameters in `scripts/deploy_mainnet.py` and run: 20 | 21 | `brownie run deploy_mainnet` 22 | 23 | To trigger a rebalance, run: 24 | 25 | `brownie run rebalance` 26 | 27 | 28 | ### Bug Bounty 29 | 30 | We have a bug bounty program hosted on Immunefi. Please visit [our bounty page](https://immunefi.com/bounty/charm/) for more details 31 | -------------------------------------------------------------------------------- /scripts/increase_cardinality.py: -------------------------------------------------------------------------------- 1 | from brownie import accounts, project 2 | from brownie.network.gas.strategies import GasNowScalingStrategy 3 | 4 | 5 | # POOL = "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8" # USDC / ETH 6 | POOL = "0x4e68ccd3e89f51c3074ca5072bbac773960dfa36" # ETH / USDT 7 | 8 | CARDINALITY = 10 9 | 10 | 11 | def main(): 12 | deployer = accounts.load("deployer") 13 | UniswapV3Core = project.load("Uniswap/uniswap-v3-core@1.0.0") 14 | 15 | gas_strategy = GasNowScalingStrategy() 16 | balance = keeper.balance() 17 | 18 | pool = UniswapV3Core.interface.IUniswapV3Pool(POOL) 19 | pool.increaseObservationCardinalityNext( 20 | CARDINALITY, {"from": deployer, "gas_price": gas_strategy} 21 | ) 22 | 23 | print(f"Gas used: {(balance - keeper.balance()) / 1e18:.4f} ETH") 24 | print(f"New balance: {keeper.balance() / 1e18:.4f} ETH") 25 | -------------------------------------------------------------------------------- /scripts/print_price.py: -------------------------------------------------------------------------------- 1 | from brownie import accounts, project 2 | import time 3 | 4 | 5 | POOL = "0x7858e59e0c01ea06df3af3d20ac7b0003275d4bf" # USDC / USDT / 0.05% 6 | POOL = "0x6c6bc977e13df9b0de53b251522280bb72383700" # DAI / USDC / 0.05% 7 | # POOL = "0x6f48eca74b38d2936b02ab603ff4e36a6c0e3a77" # DAI / USDT / 0.05% 8 | 9 | SECONDS_AGO = 60 10 | 11 | 12 | def main(): 13 | UniswapV3Core = project.load("Uniswap/uniswap-v3-core@1.0.0") 14 | pool = UniswapV3Core.interface.IUniswapV3Pool(POOL) 15 | 16 | while True: 17 | (before, after), _ = pool.observe([SECONDS_AGO, 0]) 18 | twap = (after - before) / SECONDS_AGO 19 | last = pool.slot0()[1] 20 | 21 | print(f"twap\t{twap}\t{1.0001**twap}") 22 | print(f"last\t{last}\t{1.0001**last}") 23 | print(f"trend\t{last-twap}") 24 | print() 25 | 26 | time.sleep(max(SECONDS_AGO, 60)) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /tests/test_deploy_strategy.py: -------------------------------------------------------------------------------- 1 | from brownie import reverts 2 | 3 | 4 | def test_constructor(AlphaStrategy, vault, gov, keeper): 5 | strategy = gov.deploy(AlphaStrategy, vault, 2400, 1200, 500, 600, keeper) 6 | assert strategy.vault() == vault 7 | assert strategy.pool() == vault.pool() 8 | assert strategy.baseThreshold() == 2400 9 | assert strategy.limitThreshold() == 1200 10 | assert strategy.maxTwapDeviation() == 500 11 | assert strategy.twapDuration() == 600 12 | assert strategy.keeper() == keeper 13 | 14 | 15 | def test_constructor_checks(AlphaStrategy, vault, gov, keeper): 16 | with reverts("threshold % tickSpacing"): 17 | gov.deploy(AlphaStrategy, vault, 2401, 1200, 500, 600, keeper) 18 | 19 | with reverts("threshold % tickSpacing"): 20 | gov.deploy(AlphaStrategy, vault, 2400, 1201, 500, 600, keeper) 21 | 22 | with reverts("threshold > 0"): 23 | gov.deploy(AlphaStrategy, vault, 0, 1200, 500, 600, keeper) 24 | 25 | with reverts("threshold > 0"): 26 | gov.deploy(AlphaStrategy, vault, 2400, 0, 500, 600, keeper) 27 | 28 | with reverts("threshold too high"): 29 | gov.deploy(AlphaStrategy, vault, 887280, 1200, 500, 600, keeper) 30 | 31 | with reverts("threshold too high"): 32 | gov.deploy(AlphaStrategy, vault, 2400, 887280, 500, 600, keeper) 33 | 34 | with reverts("maxTwapDeviation"): 35 | gov.deploy(AlphaStrategy, vault, 2400, 1200, -1, 600, keeper) 36 | 37 | with reverts("twapDuration"): 38 | gov.deploy(AlphaStrategy, vault, 2400, 1200, 500, 0, keeper) 39 | -------------------------------------------------------------------------------- /scripts/rebalance.py: -------------------------------------------------------------------------------- 1 | from brownie import accounts, PassiveStrategy 2 | from brownie.network.gas.strategies import ExponentialScalingStrategy 3 | import os 4 | 5 | 6 | STRATEGIES = [ 7 | # "0x40C36799490042b31Efc4D3A7F8BDe5D3cB03526", # V0 ETH/USDT 8 | # "0xA6803E6164EE978d8C511AfB23BA49AE0ae0C1C3", # old V1 ETH/USDC 9 | # "0x5503bB32a0E37A1F0B8F8FE2006abC33C779a6FD", # old V1 ETH/USDT 10 | 11 | "0x1cEA471aab8c57118d187315f3d6Ae1834cCD836", # V1 ETH/USDC 12 | "0x4e03028626aa5e5d5e4CFeF2970231b0D6c5d5Ed", # V1 ETH/USDT 13 | "0x8209df5A847C321d26eCb155CA76f95224c5DCd9", # V1 WBTC/USDC 14 | ] 15 | 16 | 17 | def getAccount(account, pw): 18 | from web3.auto import w3 19 | 20 | with open(account, "r") as f: 21 | return accounts.add(w3.eth.account.decrypt(f.read(), pw)) 22 | 23 | 24 | def main(): 25 | keeper = getAccount(os.environ["KEEPER_ACCOUNT"], os.environ["KEEPER_PW"]) 26 | # keeper = accounts.load(input("Brownie account: ")) 27 | balance = keeper.balance() 28 | 29 | gas_strategy = ExponentialScalingStrategy("50 gwei", "1000 gwei") 30 | 31 | for address in STRATEGIES: 32 | print(f"Running for strategy: {address}") 33 | strategy = PassiveStrategy.at(address) 34 | try: 35 | strategy.rebalance({"from": keeper, "gas_price": gas_strategy}) 36 | print("Rebalanced!") 37 | except ValueError as e: 38 | print(e) 39 | print() 40 | 41 | print(f"Gas used: {(balance - keeper.balance()) / 1e18:.4f} ETH") 42 | print(f"New balance: {keeper.balance() / 1e18:.4f} ETH") 43 | -------------------------------------------------------------------------------- /scripts/deploy_mainnet.py: -------------------------------------------------------------------------------- 1 | from brownie import accounts, AlphaVault, PassiveStrategy 2 | from brownie.network.gas.strategies import GasNowScalingStrategy 3 | 4 | 5 | # POOL = "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8" # USDC / ETH / 0.3% 6 | # POOL = "0x4e68ccd3e89f51c3074ca5072bbac773960dfa36" # ETH / USDT / 0.3% 7 | POOL = "0x99ac8ca7087fa4a2a1fb6357269965a2014abc35" # WBTC / USDC / 0.3% 8 | 9 | PROTOCOL_FEE = 5000 # 5% 10 | MAX_TOTAL_SUPPLY = 2e17 11 | 12 | BASE_THRESHOLD = 3600 13 | LIMIT_THRESHOLD = 1200 14 | PERIOD = 41400 # ~12 hours 15 | MIN_TICK_MOVE = 0 16 | MAX_TWAP_DEVIATION = 100 # 1% 17 | TWAP_DURATION = 60 # 60 seconds 18 | KEEPER = "0x04c82c5791bbbdfbdda3e836ccbef567fdb2ea07" 19 | 20 | 21 | def main(): 22 | deployer = accounts.load("deployer") 23 | balance = deployer.balance() 24 | 25 | gas_strategy = GasNowScalingStrategy() 26 | 27 | vault = deployer.deploy( 28 | AlphaVault, 29 | POOL, 30 | PROTOCOL_FEE, 31 | MAX_TOTAL_SUPPLY, 32 | publish_source=True, 33 | gas_price=gas_strategy, 34 | ) 35 | strategy = deployer.deploy( 36 | PassiveStrategy, 37 | vault, 38 | BASE_THRESHOLD, 39 | LIMIT_THRESHOLD, 40 | PERIOD, 41 | MIN_TICK_MOVE, 42 | MAX_TWAP_DEVIATION, 43 | TWAP_DURATION, 44 | KEEPER, 45 | publish_source=True, 46 | gas_price=gas_strategy, 47 | ) 48 | vault.setStrategy(strategy, {"from": deployer, "gas_price": gas_strategy}) 49 | 50 | print(f"Gas used: {(balance - deployer.balance()) / 1e18:.4f} ETH") 51 | print(f"Vault address: {vault.address}") 52 | -------------------------------------------------------------------------------- /tests/test_total_amounts.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest import approx 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "amount0Desired,amount1Desired", 7 | [[1e18, 1e4], [1e4, 1e18], [1e18, 1e18]], 8 | ) 9 | def test_total_amounts_includes_fees( 10 | vaultAfterPriceMove, 11 | pool, 12 | router, 13 | tokens, 14 | getPositions, 15 | gov, 16 | user, 17 | recipient, 18 | amount0Desired, 19 | amount1Desired, 20 | ): 21 | vault = vaultAfterPriceMove 22 | 23 | # First deposit 24 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, user, {"from": user}) 25 | shares, _, _ = tx.return_value 26 | 27 | total0, total1 = vault.getTotalAmounts() 28 | 29 | # Generate fees 30 | router.swap(pool, True, 1e16, {"from": gov}) 31 | router.swap(pool, False, -1e16 * 0.997, {"from": gov}) 32 | 33 | total0After, total1After = vault.getTotalAmounts() 34 | assert approx(total0After) == total0 35 | assert approx(total1After) == total1 36 | assert total0After < total0 or total1After < total1 37 | assert total0After > total0 or total1After > total1 38 | 39 | # Poke pool 40 | vault.deposit(10, 10, 0, 0, user, {"from": user}) 41 | total0After, total1After = vault.getTotalAmounts() 42 | assert approx(total0After, rel=1e-2) == total0 43 | assert approx(total1After, rel=1e-2) == total1 44 | 45 | # Check total amounts grew due to fees 46 | assert total0After > total0 47 | assert total1After > total1 48 | 49 | 50 | def test_total_amounts_before_rebalance(vault, user): 51 | total0, total1 = vault.getTotalAmounts() 52 | assert total0 == total1 == 0 53 | 54 | vault.deposit(1e8, 1e10, 0, 0, user, {"from": user}) 55 | total0, total1 = vault.getTotalAmounts() 56 | assert total0 == 1e8 57 | assert total1 == 1e10 58 | -------------------------------------------------------------------------------- /scripts/upgrade_strategy.py: -------------------------------------------------------------------------------- 1 | from brownie import ( 2 | accounts, 3 | project, 4 | AlphaVault, 5 | PassiveStrategy, 6 | ) 7 | from brownie.network.gas.strategies import GasNowScalingStrategy 8 | 9 | 10 | VAULT_ADDRESS = "0x9bf7b46c7ad5ab62034e9349ab912c0345164322" 11 | 12 | BASE_THRESHOLD = 3600 13 | LIMIT_THRESHOLD = 1200 14 | PERIOD = 41400 # ~12 hours 15 | MIN_TICK_MOVE = 0 16 | MAX_TWAP_DEVIATION = 100 # 1% 17 | TWAP_DURATION = 60 # 60 seconds 18 | KEEPER = "0x04c82c5791bbbdfbdda3e836ccbef567fdb2ea07" 19 | 20 | 21 | def main(): 22 | deployer = accounts.load("deployer") 23 | UniswapV3Core = project.load("Uniswap/uniswap-v3-core@1.0.0") 24 | 25 | gas_strategy = GasNowScalingStrategy() 26 | 27 | vault = AlphaVault.at(VAULT_ADDRESS) 28 | old = PassiveStrategy.at(vault.strategy()) 29 | print(f"Old strategy address: {old.address}") 30 | 31 | strategy = deployer.deploy( 32 | PassiveStrategy, 33 | vault, 34 | BASE_THRESHOLD, 35 | LIMIT_THRESHOLD, 36 | PERIOD, 37 | MIN_TICK_MOVE, 38 | MAX_TWAP_DEVIATION, 39 | TWAP_DURATION, 40 | KEEPER, 41 | publish_source=True, 42 | gas_price=gas_strategy, 43 | ) 44 | print(f"Strategy address: {strategy.address}") 45 | 46 | assert old.vault() == strategy.vault() == VAULT_ADDRESS 47 | assert old.baseThreshold() == strategy.baseThreshold() == BASE_THRESHOLD 48 | assert old.limitThreshold() == strategy.limitThreshold() == LIMIT_THRESHOLD 49 | assert old.period() == strategy.period() == PERIOD 50 | assert old.minTickMove() == strategy.minTickMove() == MIN_TICK_MOVE 51 | assert old.maxTwapDeviation() == strategy.maxTwapDeviation() == MAX_TWAP_DEVIATION 52 | assert old.twapDuration() == strategy.twapDuration() == TWAP_DURATION 53 | assert old.keeper() == strategy.keeper() == KEEPER 54 | 55 | vault.setStrategy(strategy, {"from": deployer, "gas_price": gas_strategy}) 56 | -------------------------------------------------------------------------------- /contracts/test/TestRouter.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense 2 | 3 | pragma solidity 0.7.6; 4 | 5 | import "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; 6 | 7 | import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; 8 | import "@uniswap/v3-core/contracts/interfaces/callback/IUniswapV3MintCallback.sol"; 9 | import "@uniswap/v3-core/contracts/interfaces/callback/IUniswapV3SwapCallback.sol"; 10 | import "@uniswap/v3-core/contracts/libraries/TickMath.sol"; 11 | 12 | /** 13 | * @title TestRouter 14 | * @dev DO NOT USE IN PRODUCTION. This is only intended to be used for 15 | * tests and lacks slippage and callback caller checks. 16 | */ 17 | contract TestRouter is IUniswapV3MintCallback, IUniswapV3SwapCallback { 18 | using SafeERC20 for IERC20; 19 | 20 | function mint( 21 | IUniswapV3Pool pool, 22 | int24 tickLower, 23 | int24 tickUpper, 24 | uint128 amount 25 | ) external returns (uint256, uint256) { 26 | int24 tickSpacing = pool.tickSpacing(); 27 | require(tickLower % tickSpacing == 0, "tickLower must be a multiple of tickSpacing"); 28 | require(tickUpper % tickSpacing == 0, "tickUpper must be a multiple of tickSpacing"); 29 | return pool.mint(msg.sender, tickLower, tickUpper, amount, abi.encode(msg.sender)); 30 | } 31 | 32 | function swap( 33 | IUniswapV3Pool pool, 34 | bool zeroForOne, 35 | int256 amountSpecified 36 | ) external returns (int256, int256) { 37 | return 38 | pool.swap( 39 | msg.sender, 40 | zeroForOne, 41 | amountSpecified, 42 | zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1, 43 | abi.encode(msg.sender) 44 | ); 45 | } 46 | 47 | function uniswapV3MintCallback( 48 | uint256 amount0Owed, 49 | uint256 amount1Owed, 50 | bytes calldata data 51 | ) external override { 52 | _callback(amount0Owed, amount1Owed, data); 53 | } 54 | 55 | function uniswapV3SwapCallback( 56 | int256 amount0Delta, 57 | int256 amount1Delta, 58 | bytes calldata data 59 | ) external override { 60 | uint256 amount0 = amount0Delta > 0 ? uint256(amount0Delta) : 0; 61 | uint256 amount1 = amount1Delta > 0 ? uint256(amount1Delta) : 0; 62 | _callback(amount0, amount1, data); 63 | } 64 | 65 | function _callback( 66 | uint256 amount0, 67 | uint256 amount1, 68 | bytes calldata data 69 | ) internal { 70 | IUniswapV3Pool pool = IUniswapV3Pool(msg.sender); 71 | address payer = abi.decode(data, (address)); 72 | 73 | IERC20(pool.token0()).safeTransferFrom(payer, msg.sender, amount0); 74 | IERC20(pool.token1()).safeTransferFrom(payer, msg.sender, amount1); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /scripts/deploy_rinkeby.py: -------------------------------------------------------------------------------- 1 | from brownie import ( 2 | accounts, 3 | project, 4 | MockToken, 5 | AlphaVault, 6 | PassiveStrategy, 7 | TestRouter, 8 | ZERO_ADDRESS, 9 | ) 10 | from brownie.network.gas.strategies import GasNowScalingStrategy 11 | from math import floor, sqrt 12 | import time 13 | 14 | 15 | # Uniswap v3 factory on Rinkeby 16 | FACTORY = "0xAE28628c0fdFb5e54d60FEDC6C9085199aec14dF" 17 | 18 | PROTOCOL_FEE = 10000 19 | MAX_TOTAL_SUPPLY = 1e32 20 | 21 | BASE_THRESHOLD = 3600 22 | LIMIT_THRESHOLD = 1200 23 | PERIOD = 43200 # 12 hours 24 | MIN_TICK_MOVE = 0 25 | MAX_TWAP_DEVIATION = 100 # 1% 26 | TWAP_DURATION = 60 # 60 seconds 27 | 28 | 29 | def main(): 30 | deployer = accounts.load("deployer") 31 | UniswapV3Core = project.load("Uniswap/uniswap-v3-core@1.0.0") 32 | 33 | gas_strategy = GasNowScalingStrategy() 34 | 35 | eth = deployer.deploy(MockToken, "ETH", "ETH", 18) 36 | usdc = deployer.deploy(MockToken, "USDC", "USDC", 6) 37 | 38 | eth.mint(deployer, 100 * 1e18, {"from": deployer, "gas_price": gas_strategy}) 39 | usdc.mint(deployer, 100000 * 1e6, {"from": deployer, "gas_price": gas_strategy}) 40 | 41 | factory = UniswapV3Core.interface.IUniswapV3Factory(FACTORY) 42 | factory.createPool(eth, usdc, 3000, {"from": deployer, "gas_price": gas_strategy}) 43 | time.sleep(15) 44 | 45 | pool = UniswapV3Core.interface.IUniswapV3Pool(factory.getPool(eth, usdc, 3000)) 46 | 47 | inverse = pool.token0() == usdc 48 | price = 1e18 / 2000e6 if inverse else 2000e6 / 1e18 49 | 50 | # Set ETH/USDC price to 2000 51 | pool.initialize( 52 | floor(sqrt(price) * (1 << 96)), {"from": deployer, "gas_price": gas_strategy} 53 | ) 54 | 55 | # Increase cardinality so TWAP works 56 | pool.increaseObservationCardinalityNext( 57 | 100, {"from": deployer, "gas_price": gas_strategy} 58 | ) 59 | 60 | router = deployer.deploy(TestRouter) 61 | MockToken.at(eth).approve( 62 | router, 1 << 255, {"from": deployer, "gas_price": gas_strategy} 63 | ) 64 | MockToken.at(usdc).approve( 65 | router, 1 << 255, {"from": deployer, "gas_price": gas_strategy} 66 | ) 67 | time.sleep(15) 68 | 69 | max_tick = 887272 // 60 * 60 70 | router.mint( 71 | pool, -max_tick, max_tick, 1e14, {"from": deployer, "gas_price": gas_strategy} 72 | ) 73 | 74 | vault = deployer.deploy( 75 | AlphaVault, 76 | pool, 77 | PROTOCOL_FEE, 78 | MAX_TOTAL_SUPPLY, 79 | publish_source=True, 80 | gas_price=gas_strategy, 81 | ) 82 | 83 | strategy = deployer.deploy( 84 | PassiveStrategy, 85 | vault, 86 | BASE_THRESHOLD, 87 | LIMIT_THRESHOLD, 88 | PERIOD, 89 | MIN_TICK_MOVE, 90 | MAX_TWAP_DEVIATION, 91 | TWAP_DURATION, 92 | deployer, 93 | publish_source=True, 94 | gas_price=gas_strategy, 95 | ) 96 | vault.setStrategy(strategy, {"from": deployer, "gas_price": gas_strategy}) 97 | 98 | print(f"Vault address: {vault.address}") 99 | print(f"Strategy address: {strategy.address}") 100 | print(f"Router address: {router.address}") 101 | -------------------------------------------------------------------------------- /tests/test_simulations.py: -------------------------------------------------------------------------------- 1 | from brownie.test import given, strategy 2 | from math import sqrt 3 | 4 | 5 | EPS = 1e-9 6 | 7 | 8 | @given( 9 | lower=strategy("uint256", min_value=1, max_value=999), 10 | upper=strategy("uint256", min_value=1001, max_value=10000), 11 | shares=strategy("uint256", min_value=1, max_value=1000), 12 | px1=strategy("uint256", min_value=1, max_value=10000), 13 | px2=strategy("uint256", min_value=1, max_value=10000), 14 | px3=strategy("uint256", min_value=1, max_value=10000), 15 | ) 16 | def test_manipulation(lower, upper, shares, px1, px2, px3): 17 | sim = Simulation(1000, lower, upper, 100) 18 | sim.manipulate(px1) 19 | sim.deposit(shares) 20 | sim.manipulate(px2) 21 | sim.withdraw(shares) 22 | sim.manipulate(px3) 23 | assert sim.balance() < EPS 24 | 25 | 26 | @given( 27 | lower=strategy("uint256", min_value=1, max_value=999), 28 | upper=strategy("uint256", min_value=1001, max_value=10000), 29 | shares=strategy("uint256", min_value=1, max_value=1000), 30 | rebalance_lower=strategy("uint256", min_value=1, max_value=2000), 31 | rebalance_width=strategy("uint256", min_value=1, max_value=2000), 32 | ) 33 | def test_arb_rebalance(lower, upper, shares, rebalance_lower, rebalance_width): 34 | sim = Simulation(1000, lower, upper, 100) 35 | sim.deposit(shares) 36 | sim.rebalance(rebalance_lower, rebalance_lower + rebalance_width) 37 | sim.withdraw(shares) 38 | assert sim.balance() < EPS 39 | 40 | 41 | def calc_amount0(px, lower, upper): 42 | return max(0, sqrt(min(upper, px)) - sqrt(lower)) 43 | 44 | 45 | def calc_amount1(px, lower, upper): 46 | return max(0, 1.0 / sqrt(max(lower, px)) - 1.0 / sqrt(upper)) 47 | 48 | 49 | class Simulation(object): 50 | def __init__(self, px, lower, upper, total_supply): 51 | self.initial_px = self.px = px 52 | self.lower = lower 53 | self.upper = upper 54 | self.total_supply = total_supply 55 | 56 | self.liquidity = total_supply 57 | self.pool0 = self.liquidity * calc_amount0(px, lower, upper) 58 | self.pool1 = self.liquidity * calc_amount1(px, lower, upper) 59 | self.unused0 = self.unused1 = 0 60 | 61 | self.balance0 = self.balance1 = self.shares = 0 62 | 63 | def deposit(self, shares): 64 | # calc amounts 65 | amount0 = self.total0() * shares / self.total_supply 66 | amount1 = self.total1() * shares / self.total_supply 67 | 68 | # transfer 69 | self.balance0 -= amount0 70 | self.balance1 -= amount1 71 | self.unused0 += amount0 72 | self.unused1 += amount1 73 | 74 | # mint 75 | self.total_supply += shares 76 | self.shares += shares 77 | 78 | def withdraw(self, shares): 79 | frac = shares / self.total_supply 80 | assert frac > -EPS 81 | 82 | # transfer 83 | self.balance0 += self.total0() * frac 84 | self.balance1 += self.total1() * frac 85 | self.unused0 *= 1.0 - frac 86 | self.unused1 *= 1.0 - frac 87 | self.liquidity *= 1.0 - frac 88 | 89 | # burn 90 | self.total_supply -= shares 91 | self.shares -= shares 92 | assert self.shares > -EPS 93 | 94 | def rebalance(self, lower, upper): 95 | # burn 96 | self.unused0 += self.liquidity * calc_amount0(self.px, self.lower, self.upper) 97 | self.unused1 += self.liquidity * calc_amount1(self.px, self.lower, self.upper) 98 | 99 | # update range 100 | self.lower = lower 101 | self.upper = upper 102 | 103 | # calculate new liquidity 104 | per_share0 = calc_amount0(self.px, self.lower, self.upper) 105 | per_share1 = calc_amount1(self.px, self.lower, self.upper) 106 | 107 | # mint 108 | self.liquidity = min( 109 | self.unused0 / max(EPS, per_share0), self.unused1 / max(EPS, per_share1) 110 | ) 111 | self.unused0 -= self.liquidity * calc_amount0(self.px, self.lower, self.upper) 112 | self.unused1 -= self.liquidity * calc_amount1(self.px, self.lower, self.upper) 113 | 114 | def manipulate(self, px): 115 | self.balance0 += self.liquidity * calc_amount0(self.px, self.lower, self.upper) 116 | self.balance1 += self.liquidity * calc_amount1(self.px, self.lower, self.upper) 117 | self.px = px 118 | 119 | self.balance0 -= self.liquidity * calc_amount0(self.px, self.lower, self.upper) 120 | self.balance1 -= self.liquidity * calc_amount1(self.px, self.lower, self.upper) 121 | 122 | def balance(self): 123 | return self.balance0 + self.initial_px * self.balance1 124 | 125 | def total0(self): 126 | return self.unused0 + self.liquidity * calc_amount0( 127 | self.px, self.lower, self.upper 128 | ) 129 | 130 | def total1(self): 131 | return self.unused1 + self.liquidity * calc_amount1( 132 | self.px, self.lower, self.upper 133 | ) 134 | -------------------------------------------------------------------------------- /tests/test_passive_strategy.py: -------------------------------------------------------------------------------- 1 | from brownie import chain, reverts 2 | import pytest 3 | from pytest import approx 4 | 5 | from conftest import computePositionKey 6 | 7 | 8 | @pytest.mark.parametrize("buy", [False, True]) 9 | @pytest.mark.parametrize("big", [False, True]) 10 | def test_strategy_rebalance( 11 | vault, 12 | strategy, 13 | pool, 14 | tokens, 15 | router, 16 | getPositions, 17 | gov, 18 | user, 19 | keeper, 20 | buy, 21 | big, 22 | PassiveStrategy, 23 | ): 24 | strategy = gov.deploy(PassiveStrategy, vault, 2400, 1200, 0, 0, 200000, 600, keeper) 25 | vault.setStrategy(strategy, {"from": gov}) 26 | 27 | # Mint some liquidity 28 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 29 | strategy.rebalance({"from": keeper}) 30 | 31 | # Do a swap to move the price 32 | qty = 1e16 * [100, 1][buy] * [1, 100][big] 33 | router.swap(pool, buy, qty, {"from": gov}) 34 | 35 | # fast forward 1 day 36 | chain.sleep(86400) 37 | 38 | # Store totals 39 | total0, total1 = vault.getTotalAmounts() 40 | totalSupply = vault.totalSupply() 41 | 42 | # Rebalance 43 | tx = strategy.rebalance({"from": keeper}) 44 | 45 | # Check ranges are set correctly 46 | tick = pool.slot0()[1] 47 | tickFloor = tick // 60 * 60 48 | assert vault.baseLower() == tickFloor - 2400 49 | assert vault.baseUpper() == tickFloor + 60 + 2400 50 | if buy: 51 | assert vault.limitLower() == tickFloor + 60 52 | assert vault.limitUpper() == tickFloor + 60 + 1200 53 | else: 54 | assert vault.limitLower() == tickFloor - 1200 55 | assert vault.limitUpper() == tickFloor 56 | 57 | assert strategy.lastTimestamp() == tx.timestamp 58 | assert strategy.lastTick() == tick 59 | 60 | 61 | def test_rebalance_period_check( 62 | vault, strategy, pool, tokens, router, gov, user, keeper, PassiveStrategy 63 | ): 64 | strategy = gov.deploy(PassiveStrategy, vault, 2400, 1200, 0, 0, 200000, 600, keeper) 65 | vault.setStrategy(strategy, {"from": gov}) 66 | 67 | # Set period 68 | strategy.setPeriod(86400, {"from": gov}) 69 | 70 | # Rebalance 71 | strategy.rebalance({"from": keeper}) 72 | 73 | # Wait just under 24 hours 74 | chain.sleep(86400 - 10) 75 | 76 | # Can't rebalance 77 | with reverts("cannot rebalance"): 78 | strategy.rebalance({"from": keeper}) 79 | 80 | chain.sleep(10) 81 | 82 | # Rebalance 83 | strategy.rebalance({"from": keeper}) 84 | 85 | 86 | @pytest.mark.parametrize("buy", [False, True]) 87 | def test_rebalance_min_tick_move_check( 88 | vault, strategy, pool, tokens, router, gov, user, keeper, buy, PassiveStrategy 89 | ): 90 | strategy = gov.deploy(PassiveStrategy, vault, 2400, 1200, 0, 0, 200000, 600, keeper) 91 | vault.setStrategy(strategy, {"from": gov}) 92 | 93 | # Rebalance 94 | strategy.rebalance({"from": keeper}) 95 | 96 | # Set min tick move 97 | strategy.setMinTickMove(100, {"from": gov}) 98 | 99 | # Can't rebalance 100 | with reverts("cannot rebalance"): 101 | strategy.rebalance({"from": keeper}) 102 | 103 | router.swap(pool, buy, 1e18, {"from": gov}) 104 | 105 | # Rebalance 106 | strategy.rebalance({"from": keeper}) 107 | 108 | 109 | @pytest.mark.parametrize("buy", [False, True]) 110 | def test_rebalance_twap_check( 111 | vault, strategy, pool, tokens, router, gov, user, keeper, buy, PassiveStrategy 112 | ): 113 | strategy = gov.deploy(PassiveStrategy, vault, 2400, 1200, 0, 0, 200000, 600, keeper) 114 | vault.setStrategy(strategy, {"from": gov}) 115 | 116 | # Set max deviation 117 | strategy.setMaxTwapDeviation(500, {"from": gov}) 118 | 119 | # Mint some liquidity 120 | vault.deposit(1e8, 1e10, 0, 0, user, {"from": user}) 121 | 122 | # Do a swap to move the price a lot 123 | qty = 1e16 * 100 * [100, 1][buy] 124 | router.swap(pool, buy, qty, {"from": gov}) 125 | 126 | # Can't rebalance 127 | with reverts("cannot rebalance"): 128 | strategy.rebalance({"from": keeper}) 129 | 130 | # Wait for twap period to pass and poke price 131 | chain.sleep(610) 132 | router.swap(pool, buy, 1e8, {"from": gov}) 133 | 134 | # Rebalance 135 | strategy.rebalance({"from": keeper}) 136 | 137 | 138 | def test_can_rebalance_when_vault_empty( 139 | vault, strategy, pool, tokens, gov, user, keeper, PassiveStrategy 140 | ): 141 | strategy = gov.deploy(PassiveStrategy, vault, 2400, 1200, 0, 0, 200000, 600, keeper) 142 | vault.setStrategy(strategy, {"from": gov}) 143 | 144 | assert tokens[0].balanceOf(vault) == 0 145 | assert tokens[1].balanceOf(vault) == 0 146 | tx = strategy.rebalance({"from": keeper}) 147 | 148 | # Check ranges are set correctly 149 | tick = pool.slot0()[1] 150 | tickFloor = tick // 60 * 60 151 | assert vault.baseLower() == tickFloor - 2400 152 | assert vault.baseUpper() == tickFloor + 60 + 2400 153 | assert vault.limitLower() == tickFloor + 60 154 | assert vault.limitUpper() == tickFloor + 60 + 1200 155 | 156 | assert strategy.lastTimestamp() == tx.timestamp 157 | assert strategy.lastTick() == tick 158 | -------------------------------------------------------------------------------- /tests/test_governance_methods.py: -------------------------------------------------------------------------------- 1 | from brownie import reverts 2 | from pytest import approx 3 | 4 | 5 | def test_vault_governance_methods( 6 | MockToken, vault, strategy, tokens, gov, user, recipient, keeper 7 | ): 8 | 9 | # Check sweep 10 | with reverts("token"): 11 | vault.sweep(tokens[0], 1e18, recipient, {"from": gov}) 12 | with reverts("token"): 13 | vault.sweep(tokens[1], 1e18, recipient, {"from": gov}) 14 | randomToken = gov.deploy(MockToken, "a", "a", 18) 15 | randomToken.mint(vault, 3e18, {"from": gov}) 16 | with reverts("governance"): 17 | vault.sweep(randomToken, 1e18, recipient, {"from": user}) 18 | balance = randomToken.balanceOf(recipient) 19 | vault.sweep(randomToken, 1e18, recipient, {"from": gov}) 20 | assert randomToken.balanceOf(recipient) == balance + 1e18 21 | assert randomToken.balanceOf(vault) == 2e18 22 | 23 | # Check setting protocol fee 24 | with reverts("governance"): 25 | vault.setProtocolFee(0, {"from": user}) 26 | with reverts("protocolFee"): 27 | vault.setProtocolFee(1e6, {"from": gov}) 28 | vault.setProtocolFee(0, {"from": gov}) 29 | assert vault.protocolFee() == 0 30 | 31 | # Check setting max total supply 32 | with reverts("governance"): 33 | vault.setMaxTotalSupply(1 << 255, {"from": user}) 34 | vault.setMaxTotalSupply(1 << 255, {"from": gov}) 35 | assert vault.maxTotalSupply() == 1 << 255 36 | 37 | # Check emergency burn 38 | vault.deposit(1e8, 1e10, 0, 0, gov, {"from": gov}) 39 | strategy.rebalance({"from": keeper}) 40 | 41 | with reverts("governance"): 42 | vault.emergencyBurn(vault.baseLower(), vault.baseUpper(), 1e4, {"from": user}) 43 | balance0 = tokens[0].balanceOf(vault) 44 | balance1 = tokens[1].balanceOf(vault) 45 | total0, total1 = vault.getTotalAmounts() 46 | vault.emergencyBurn(vault.baseLower(), vault.baseUpper(), 1e4, {"from": gov}) 47 | assert tokens[0].balanceOf(vault) > balance0 48 | assert tokens[1].balanceOf(vault) > balance1 49 | total0After, total1After = vault.getTotalAmounts() 50 | assert approx(total0After) == total0 51 | assert approx(total1After) == total1 52 | 53 | # Check setting strategy 54 | with reverts("governance"): 55 | vault.setStrategy(recipient, {"from": user}) 56 | assert vault.strategy() != recipient 57 | vault.setStrategy(recipient, {"from": gov}) 58 | assert vault.strategy() == recipient 59 | 60 | # Check setting governance 61 | with reverts("governance"): 62 | vault.setGovernance(recipient, {"from": user}) 63 | assert vault.pendingGovernance() != recipient 64 | vault.setGovernance(recipient, {"from": gov}) 65 | assert vault.pendingGovernance() == recipient 66 | 67 | # Check accepting governance 68 | with reverts("pendingGovernance"): 69 | vault.acceptGovernance({"from": user}) 70 | assert vault.governance() != recipient 71 | vault.acceptGovernance({"from": recipient}) 72 | assert vault.governance() == recipient 73 | 74 | 75 | def test_collect_protocol_fees( 76 | vault, pool, strategy, router, tokens, gov, user, recipient, keeper 77 | ): 78 | strategy.setMaxTwapDeviation(1 << 20, {"from": gov}) 79 | vault.deposit(1e18, 1e20, 0, 0, gov, {"from": gov}) 80 | tx = strategy.rebalance({"from": keeper}) 81 | 82 | router.swap(pool, True, 1e16, {"from": gov}) 83 | router.swap(pool, False, 1e18, {"from": gov}) 84 | tx = strategy.rebalance({"from": keeper}) 85 | protocolFees0, protocolFees1 = ( 86 | vault.accruedProtocolFees0(), 87 | vault.accruedProtocolFees1(), 88 | ) 89 | 90 | balance0 = tokens[0].balanceOf(recipient) 91 | balance1 = tokens[1].balanceOf(recipient) 92 | with reverts("governance"): 93 | vault.collectProtocol(1e3, 1e4, recipient, {"from": user}) 94 | with reverts("SafeMath: subtraction overflow"): 95 | vault.collectProtocol(1e18, 1e4, recipient, {"from": gov}) 96 | with reverts("SafeMath: subtraction overflow"): 97 | vault.collectProtocol(1e3, 1e18, recipient, {"from": gov}) 98 | vault.collectProtocol(1e3, 1e4, recipient, {"from": gov}) 99 | assert vault.accruedProtocolFees0() == protocolFees0 - 1e3 100 | assert vault.accruedProtocolFees1() == protocolFees1 - 1e4 101 | assert tokens[0].balanceOf(recipient) - balance0 == 1e3 102 | assert tokens[1].balanceOf(recipient) - balance1 == 1e4 > 0 103 | 104 | 105 | def test_strategy_governance_methods(vault, strategy, gov, user, recipient): 106 | 107 | # Check setting base threshold 108 | with reverts("governance"): 109 | strategy.setBaseThreshold(0, {"from": user}) 110 | with reverts("threshold % tickSpacing"): 111 | strategy.setBaseThreshold(2401, {"from": gov}) 112 | with reverts("threshold > 0"): 113 | strategy.setBaseThreshold(0, {"from": gov}) 114 | with reverts("threshold too high"): 115 | strategy.setBaseThreshold(887280, {"from": gov}) 116 | strategy.setBaseThreshold(4800, {"from": gov}) 117 | assert strategy.baseThreshold() == 4800 118 | 119 | # Check setting limit threshold 120 | with reverts("governance"): 121 | strategy.setLimitThreshold(0, {"from": user}) 122 | with reverts("threshold % tickSpacing"): 123 | strategy.setLimitThreshold(1201, {"from": gov}) 124 | with reverts("threshold > 0"): 125 | strategy.setLimitThreshold(0, {"from": gov}) 126 | with reverts("threshold too high"): 127 | strategy.setLimitThreshold(887280, {"from": gov}) 128 | strategy.setLimitThreshold(600, {"from": gov}) 129 | assert strategy.limitThreshold() == 600 130 | 131 | # Check setting max twap deviation 132 | with reverts("governance"): 133 | strategy.setMaxTwapDeviation(1000, {"from": user}) 134 | with reverts("maxTwapDeviation"): 135 | strategy.setMaxTwapDeviation(-1, {"from": gov}) 136 | strategy.setMaxTwapDeviation(1000, {"from": gov}) 137 | assert strategy.maxTwapDeviation() == 1000 138 | 139 | # Check setting twap duration 140 | with reverts("governance"): 141 | strategy.setTwapDuration(800, {"from": user}) 142 | strategy.setTwapDuration(800, {"from": gov}) 143 | assert strategy.twapDuration() == 800 144 | 145 | # Check setting keeper 146 | with reverts("governance"): 147 | strategy.setKeeper(recipient, {"from": user}) 148 | assert strategy.keeper() != recipient 149 | strategy.setKeeper(recipient, {"from": gov}) 150 | assert strategy.keeper() == recipient 151 | 152 | # Check gov changed in vault 153 | vault.setGovernance(user, {"from": gov}) 154 | vault.acceptGovernance({"from": user}) 155 | with reverts("governance"): 156 | strategy.setKeeper(recipient, {"from": gov}) 157 | strategy.setKeeper(recipient, {"from": user}) 158 | -------------------------------------------------------------------------------- /contracts/AlphaStrategy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense 2 | 3 | pragma solidity 0.7.6; 4 | 5 | import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; 6 | import "@uniswap/v3-core/contracts/libraries/TickMath.sol"; 7 | 8 | import "./AlphaVault.sol"; 9 | 10 | /** 11 | * @title Alpha Strategy 12 | * @notice Rebalancing strategy for Alpha Vault that maintains the two 13 | * following range orders: 14 | * 15 | * 1. Base order is placed between X - B and X + B + TS. 16 | * 2. Limit order is placed between X - L and X, or between X + TS 17 | * and X + L + TS, depending on which token it holds more of. 18 | * 19 | * where: 20 | * 21 | * X = current tick rounded down to multiple of tick spacing 22 | * TS = tick spacing 23 | * B = base threshold 24 | * L = limit threshold 25 | * 26 | * Note that after these two orders, the vault should have deposited 27 | * all its tokens and should only have a few wei left. 28 | * 29 | * Because the limit order tries to sell whichever token the vault 30 | * holds more of, the vault's holdings will have a tendency to get 31 | * closer to a 1:1 balance. This enables it to continue providing 32 | * liquidity without running out of inventory of either token, and 33 | * achieves this without the need to swap directly on Uniswap and pay 34 | * fees. 35 | */ 36 | contract AlphaStrategy { 37 | AlphaVault public immutable vault; 38 | IUniswapV3Pool public immutable pool; 39 | int24 public immutable tickSpacing; 40 | 41 | int24 public baseThreshold; 42 | int24 public limitThreshold; 43 | int24 public maxTwapDeviation; 44 | uint32 public twapDuration; 45 | address public keeper; 46 | 47 | uint256 public lastRebalance; 48 | int24 public lastTick; 49 | 50 | /** 51 | * @param _vault Underlying Alpha Vault 52 | * @param _baseThreshold Used to determine base order range 53 | * @param _limitThreshold Used to determine limit order range 54 | * @param _maxTwapDeviation Max deviation from TWAP during rebalance 55 | * @param _twapDuration TWAP duration in seconds for rebalance check 56 | * @param _keeper Account that can call `rebalance()` 57 | */ 58 | constructor( 59 | address _vault, 60 | int24 _baseThreshold, 61 | int24 _limitThreshold, 62 | int24 _maxTwapDeviation, 63 | uint32 _twapDuration, 64 | address _keeper 65 | ) { 66 | IUniswapV3Pool _pool = AlphaVault(_vault).pool(); 67 | int24 _tickSpacing = _pool.tickSpacing(); 68 | 69 | vault = AlphaVault(_vault); 70 | pool = _pool; 71 | tickSpacing = _tickSpacing; 72 | 73 | baseThreshold = _baseThreshold; 74 | limitThreshold = _limitThreshold; 75 | maxTwapDeviation = _maxTwapDeviation; 76 | twapDuration = _twapDuration; 77 | keeper = _keeper; 78 | 79 | _checkThreshold(_baseThreshold, _tickSpacing); 80 | _checkThreshold(_limitThreshold, _tickSpacing); 81 | require(_maxTwapDeviation > 0, "maxTwapDeviation"); 82 | require(_twapDuration > 0, "twapDuration"); 83 | 84 | (, lastTick, , , , , ) = _pool.slot0(); 85 | } 86 | 87 | /** 88 | * @notice Calculates new ranges for orders and calls `vault.rebalance()` 89 | * so that vault can update its positions. Can only be called by keeper. 90 | */ 91 | function rebalance() external { 92 | require(msg.sender == keeper, "keeper"); 93 | 94 | int24 _baseThreshold = baseThreshold; 95 | int24 _limitThreshold = limitThreshold; 96 | 97 | // Check price is not too close to min/max allowed by Uniswap. Price 98 | // shouldn't be this extreme unless something was wrong with the pool. 99 | int24 tick = getTick(); 100 | int24 maxThreshold = 101 | _baseThreshold > _limitThreshold ? _baseThreshold : _limitThreshold; 102 | require(tick > TickMath.MIN_TICK + maxThreshold + tickSpacing, "tick too low"); 103 | require(tick < TickMath.MAX_TICK - maxThreshold - tickSpacing, "tick too high"); 104 | 105 | // Check price has not moved a lot recently. This mitigates price 106 | // manipulation during rebalance and also prevents placing orders 107 | // when it's too volatile. 108 | int24 twap = getTwap(); 109 | int24 deviation = tick > twap ? tick - twap : twap - tick; 110 | require(deviation <= maxTwapDeviation, "maxTwapDeviation"); 111 | 112 | int24 tickFloor = _floor(tick); 113 | int24 tickCeil = tickFloor + tickSpacing; 114 | 115 | vault.rebalance( 116 | 0, 117 | 0, 118 | tickFloor - _baseThreshold, 119 | tickCeil + _baseThreshold, 120 | tickFloor - _limitThreshold, 121 | tickFloor, 122 | tickCeil, 123 | tickCeil + _limitThreshold 124 | ); 125 | 126 | lastRebalance = block.timestamp; 127 | lastTick = tick; 128 | } 129 | 130 | /// @dev Fetches current price in ticks from Uniswap pool. 131 | function getTick() public view returns (int24 tick) { 132 | (, tick, , , , , ) = pool.slot0(); 133 | } 134 | 135 | /// @dev Fetches time-weighted average price in ticks from Uniswap pool. 136 | function getTwap() public view returns (int24) { 137 | uint32 _twapDuration = twapDuration; 138 | uint32[] memory secondsAgo = new uint32[](2); 139 | secondsAgo[0] = _twapDuration; 140 | secondsAgo[1] = 0; 141 | 142 | (int56[] memory tickCumulatives, ) = pool.observe(secondsAgo); 143 | return int24((tickCumulatives[1] - tickCumulatives[0]) / _twapDuration); 144 | } 145 | 146 | /// @dev Rounds tick down towards negative infinity so that it's a multiple 147 | /// of `tickSpacing`. 148 | function _floor(int24 tick) internal view returns (int24) { 149 | int24 compressed = tick / tickSpacing; 150 | if (tick < 0 && tick % tickSpacing != 0) compressed--; 151 | return compressed * tickSpacing; 152 | } 153 | 154 | function _checkThreshold(int24 threshold, int24 _tickSpacing) internal pure { 155 | require(threshold > 0, "threshold > 0"); 156 | require(threshold <= TickMath.MAX_TICK, "threshold too high"); 157 | require(threshold % _tickSpacing == 0, "threshold % tickSpacing"); 158 | } 159 | 160 | function setKeeper(address _keeper) external onlyGovernance { 161 | keeper = _keeper; 162 | } 163 | 164 | function setBaseThreshold(int24 _baseThreshold) external onlyGovernance { 165 | _checkThreshold(_baseThreshold, tickSpacing); 166 | baseThreshold = _baseThreshold; 167 | } 168 | 169 | function setLimitThreshold(int24 _limitThreshold) external onlyGovernance { 170 | _checkThreshold(_limitThreshold, tickSpacing); 171 | limitThreshold = _limitThreshold; 172 | } 173 | 174 | function setMaxTwapDeviation(int24 _maxTwapDeviation) external onlyGovernance { 175 | require(_maxTwapDeviation > 0, "maxTwapDeviation"); 176 | maxTwapDeviation = _maxTwapDeviation; 177 | } 178 | 179 | function setTwapDuration(uint32 _twapDuration) external onlyGovernance { 180 | require(_twapDuration > 0, "twapDuration"); 181 | twapDuration = _twapDuration; 182 | } 183 | 184 | /// @dev Uses same governance as underlying vault. 185 | modifier onlyGovernance { 186 | require(msg.sender == vault.governance(), "governance"); 187 | _; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from brownie import chain 2 | from math import sqrt 3 | import pytest 4 | from web3 import Web3 5 | 6 | 7 | UNISWAP_V3_CORE = "Uniswap/uniswap-v3-core@1.0.0" 8 | 9 | 10 | @pytest.fixture(scope="module") 11 | def gov(accounts): 12 | yield accounts[0] 13 | 14 | 15 | @pytest.fixture(scope="module") 16 | def user(accounts): 17 | yield accounts[1] 18 | 19 | 20 | @pytest.fixture(scope="module") 21 | def recipient(accounts): 22 | yield accounts[2] 23 | 24 | 25 | @pytest.fixture(scope="module") 26 | def keeper(accounts): 27 | yield accounts[3] 28 | 29 | 30 | @pytest.fixture(scope="module") 31 | def users(gov, user, recipient, keeper): 32 | yield [gov, user, recipient, keeper] 33 | 34 | 35 | @pytest.fixture(scope="module") 36 | def router(TestRouter, gov): 37 | yield gov.deploy(TestRouter) 38 | 39 | 40 | @pytest.fixture 41 | def pool(MockToken, router, pm, gov, users): 42 | UniswapV3Core = pm(UNISWAP_V3_CORE) 43 | 44 | tokenA = gov.deploy(MockToken, "name A", "symbol A", 18) 45 | tokenB = gov.deploy(MockToken, "name B", "symbol B", 18) 46 | fee = 3000 47 | 48 | factory = gov.deploy(UniswapV3Core.UniswapV3Factory) 49 | tx = factory.createPool(tokenA, tokenB, fee, {"from": gov}) 50 | pool = UniswapV3Core.interface.IUniswapV3Pool(tx.return_value) 51 | token0 = MockToken.at(pool.token0()) 52 | token1 = MockToken.at(pool.token1()) 53 | 54 | # initialize price to 100 55 | price = int(sqrt(100) * (1 << 96)) 56 | pool.initialize(price, {"from": gov}) 57 | 58 | for u in users: 59 | token0.mint(u, 100e18, {"from": gov}) 60 | token1.mint(u, 10000e18, {"from": gov}) 61 | token0.approve(router, 100e18, {"from": u}) 62 | token1.approve(router, 10000e18, {"from": u}) 63 | 64 | # Add some liquidity over whole range 65 | max_tick = 887272 // 60 * 60 66 | router.mint(pool, -max_tick, max_tick, 1e16, {"from": gov}) 67 | 68 | # Increase cardinality and fast forward so TWAP works 69 | pool.increaseObservationCardinalityNext(100, {"from": gov}) 70 | chain.sleep(3600) 71 | yield pool 72 | 73 | 74 | @pytest.fixture 75 | def tokens(MockToken, pool): 76 | return MockToken.at(pool.token0()), MockToken.at(pool.token1()) 77 | 78 | 79 | @pytest.fixture 80 | def vault(AlphaVault, AlphaStrategy, pool, router, tokens, gov, users, keeper): 81 | # protocolFee = 10000 (1%) 82 | # maxTotalSupply = 100e18 (100 tokens) 83 | vault = gov.deploy(AlphaVault, pool, 10000, 100e18) 84 | 85 | for u in users: 86 | tokens[0].approve(vault, 100e18, {"from": u}) 87 | tokens[1].approve(vault, 10000e18, {"from": u}) 88 | 89 | # baseThreshold = 2400 90 | # limitThreshold = 1200 91 | # maxTwapDeviation = 200000 (just a big number) 92 | # twapDuration = 600 (10 minutes) 93 | strategy = gov.deploy(AlphaStrategy, vault, 2400, 1200, 200000, 600, keeper) 94 | vault.setStrategy(strategy, {"from": gov}) 95 | 96 | yield vault 97 | 98 | 99 | @pytest.fixture 100 | def strategy(AlphaStrategy, vault): 101 | return AlphaStrategy.at(vault.strategy()) 102 | 103 | 104 | @pytest.fixture 105 | def vaultAfterPriceMove(vault, strategy, pool, router, gov, keeper): 106 | 107 | # Deposit and move price to simulate existing activity 108 | vault.deposit(1e16, 1e18, 0, 0, gov, {"from": gov}) 109 | prevTick = pool.slot0()[1] // 60 * 60 110 | router.swap(pool, True, 1e16, {"from": gov}) 111 | 112 | # Check price did indeed move 113 | tick = pool.slot0()[1] // 60 * 60 114 | assert tick != prevTick 115 | 116 | # Rebalance vault 117 | strategy.rebalance({"from": keeper}) 118 | 119 | # Check vault holds both tokens 120 | total0, total1 = vault.getTotalAmounts() 121 | assert total0 > 0 and total1 > 0 122 | 123 | yield vault 124 | 125 | 126 | @pytest.fixture 127 | def vaultOnlyWithToken0(vault, strategy, pool, router, gov, keeper): 128 | 129 | # Deposit 130 | vault.deposit(1e14, 1e16, 0, 0, gov, {"from": gov}) 131 | 132 | # Rebalance vault 133 | strategy.rebalance({"from": keeper}) 134 | 135 | # Swap token0 -> token1 136 | router.swap(pool, True, 1e16, {"from": gov}) 137 | 138 | # Check vault holds only token0 139 | total0, total1 = vault.getTotalAmounts() 140 | assert total0 > 0 141 | assert total1 == 0 142 | 143 | yield vault 144 | 145 | 146 | @pytest.fixture 147 | def vaultOnlyWithToken1(vault, strategy, pool, router, gov, keeper): 148 | 149 | # Deposit 150 | vault.deposit(1e14, 1e16, 0, 0, gov, {"from": gov}) 151 | 152 | # Rebalance vault 153 | strategy.rebalance({"from": keeper}) 154 | 155 | # Swap token1 -> token0 156 | router.swap(pool, False, 1e18, {"from": gov}) 157 | 158 | # Check vault holds only token0 159 | total0, total1 = vault.getTotalAmounts() 160 | assert total0 == 0 161 | assert total1 > 0 162 | 163 | yield vault 164 | 165 | 166 | # returns method to set up a pool, vault and strategy. can be used in 167 | # hypothesis tests where function-scoped fixtures are not allowed 168 | @pytest.fixture(scope="module") 169 | def createPoolVaultStrategy( 170 | pm, AlphaVault, AlphaStrategy, MockToken, router, gov, keeper, users 171 | ): 172 | UniswapV3Core = pm(UNISWAP_V3_CORE) 173 | 174 | def f(): 175 | tokenA = gov.deploy(MockToken, "name A", "symbol A", 18) 176 | tokenB = gov.deploy(MockToken, "name B", "symbol B", 18) 177 | fee = 3000 178 | 179 | for u in users: 180 | tokenA.mint(u, 100e18, {"from": gov}) 181 | tokenB.mint(u, 10000e18, {"from": gov}) 182 | tokenA.approve(router, 100e18, {"from": u}) 183 | tokenB.approve(router, 10000e18, {"from": u}) 184 | 185 | factory = gov.deploy(UniswapV3Core.UniswapV3Factory) 186 | tx = factory.createPool(tokenA, tokenB, fee, {"from": gov}) 187 | pool = UniswapV3Core.interface.IUniswapV3Pool(tx.return_value) 188 | 189 | initialPrice = int(sqrt(100) * (1 << 96)) 190 | pool.initialize(initialPrice, {"from": gov}) 191 | 192 | # Increase cardinality and fast forward so TWAP works 193 | pool.increaseObservationCardinalityNext(100, {"from": gov}) 194 | chain.sleep(3600) 195 | 196 | vault = gov.deploy(AlphaVault, pool, 10000, 100e18) 197 | for u in users: 198 | tokenA.approve(vault, 100e18, {"from": u}) 199 | tokenB.approve(vault, 10000e18, {"from": u}) 200 | 201 | strategy = gov.deploy(AlphaStrategy, vault, 2400, 1200, 200000, 600, keeper) 202 | vault.setStrategy(strategy, {"from": gov}) 203 | return pool, vault, strategy 204 | 205 | yield f 206 | 207 | 208 | @pytest.fixture 209 | def getPositions(pool): 210 | def f(vault): 211 | baseKey = computePositionKey(vault, vault.baseLower(), vault.baseUpper()) 212 | limitKey = computePositionKey(vault, vault.limitLower(), vault.limitUpper()) 213 | return pool.positions(baseKey), pool.positions(limitKey) 214 | 215 | yield f 216 | 217 | 218 | @pytest.fixture 219 | def debug(pool, tokens): 220 | def f(vault): 221 | baseKey = computePositionKey(vault, vault.baseLower(), vault.baseUpper()) 222 | limitKey = computePositionKey(vault, vault.limitLower(), vault.limitUpper()) 223 | print(f"Passive position: {pool.positions(baseKey)}") 224 | print(f"Rebalance position: {pool.positions(limitKey)}") 225 | print(f"Spare balance 0: {tokens[0].balanceOf(vault)}") 226 | print(f"Spare balance 1: {tokens[1].balanceOf(vault)}") 227 | 228 | yield f 229 | 230 | 231 | def computePositionKey(owner, tickLower, tickUpper): 232 | return Web3.solidityKeccak( 233 | ["address", "int24", "int24"], [str(owner), tickLower, tickUpper] 234 | ) 235 | -------------------------------------------------------------------------------- /contracts/PassiveStrategy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense 2 | 3 | pragma solidity 0.7.6; 4 | 5 | import "@openzeppelin/contracts/math/SafeMath.sol"; 6 | import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; 7 | import "@uniswap/v3-core/contracts/libraries/TickMath.sol"; 8 | 9 | import "./AlphaVault.sol"; 10 | import "../interfaces/IStrategy.sol"; 11 | 12 | /** 13 | * @title Passive Strategy 14 | * @notice Rebalancing strategy for Alpha Vault that maintains the two 15 | * following range orders: 16 | * 17 | * 1. Base order is placed between X - B and X + B + TS. 18 | * 2. Limit order is placed between X - L and X, or between X + TS 19 | * and X + L + TS, depending on which token it holds more of. 20 | * 21 | * where: 22 | * 23 | * X = current tick rounded down to multiple of tick spacing 24 | * TS = tick spacing 25 | * B = base threshold 26 | * L = limit threshold 27 | * 28 | * Note that after these two orders, the vault should have deposited 29 | * all its tokens and should only have a few wei left. 30 | * 31 | * Because the limit order tries to sell whichever token the vault 32 | * holds more of, the vault's holdings will have a tendency to get 33 | * closer to a 1:1 balance. This enables it to continue providing 34 | * liquidity without running out of inventory of either token, and 35 | * achieves this without the need to swap directly on Uniswap and pay 36 | * fees. 37 | */ 38 | contract PassiveStrategy is IStrategy { 39 | using SafeMath for uint256; 40 | 41 | AlphaVault public immutable vault; 42 | IUniswapV3Pool public immutable pool; 43 | int24 public immutable tickSpacing; 44 | 45 | int24 public baseThreshold; 46 | int24 public limitThreshold; 47 | uint256 public period; 48 | int24 public minTickMove; 49 | int24 public maxTwapDeviation; 50 | uint32 public twapDuration; 51 | address public keeper; 52 | 53 | uint256 public lastTimestamp; 54 | int24 public lastTick; 55 | 56 | /** 57 | * @param _vault Underlying Alpha Vault 58 | * @param _baseThreshold Used to determine base order range 59 | * @param _limitThreshold Used to determine limit order range 60 | * @param _period Can only rebalance if this length of time has passed 61 | * @param _minTickMove Can only rebalance if price has moved at least this much 62 | * @param _maxTwapDeviation Max deviation from TWAP during rebalance 63 | * @param _twapDuration TWAP duration in seconds for deviation check 64 | * @param _keeper Account that can call `rebalance()` 65 | */ 66 | constructor( 67 | address _vault, 68 | int24 _baseThreshold, 69 | int24 _limitThreshold, 70 | uint256 _period, 71 | int24 _minTickMove, 72 | int24 _maxTwapDeviation, 73 | uint32 _twapDuration, 74 | address _keeper 75 | ) { 76 | IUniswapV3Pool _pool = AlphaVault(_vault).pool(); 77 | int24 _tickSpacing = _pool.tickSpacing(); 78 | 79 | vault = AlphaVault(_vault); 80 | pool = _pool; 81 | tickSpacing = _tickSpacing; 82 | 83 | baseThreshold = _baseThreshold; 84 | limitThreshold = _limitThreshold; 85 | period = _period; 86 | minTickMove = _minTickMove; 87 | maxTwapDeviation = _maxTwapDeviation; 88 | twapDuration = _twapDuration; 89 | keeper = _keeper; 90 | 91 | _checkThreshold(_baseThreshold, _tickSpacing); 92 | _checkThreshold(_limitThreshold, _tickSpacing); 93 | require(_minTickMove >= 0, "minTickMove must be >= 0"); 94 | require(_maxTwapDeviation >= 0, "maxTwapDeviation must be >= 0"); 95 | require(_twapDuration > 0, "twapDuration must be > 0"); 96 | 97 | (, lastTick, , , , , ) = _pool.slot0(); 98 | } 99 | 100 | /** 101 | * @notice Calculates new ranges for orders and calls `vault.rebalance()` 102 | * so that vault can update its positions. Can only be called by keeper. 103 | */ 104 | function rebalance() external override { 105 | require(shouldRebalance(), "cannot rebalance"); 106 | 107 | (, int24 tick, , , , , ) = pool.slot0(); 108 | int24 tickFloor = _floor(tick); 109 | int24 tickCeil = tickFloor + tickSpacing; 110 | 111 | vault.rebalance( 112 | 0, 113 | 0, 114 | tickFloor - baseThreshold, 115 | tickCeil + baseThreshold, 116 | tickFloor - limitThreshold, 117 | tickFloor, 118 | tickCeil, 119 | tickCeil + limitThreshold 120 | ); 121 | 122 | lastTimestamp = block.timestamp; 123 | lastTick = tick; 124 | } 125 | 126 | function shouldRebalance() public view override returns (bool) { 127 | // check called by keeper 128 | if (msg.sender != keeper) { 129 | return false; 130 | } 131 | 132 | // check enough time has passed 133 | if (block.timestamp < lastTimestamp.add(period)) { 134 | return false; 135 | } 136 | 137 | // check price has moved enough 138 | (, int24 tick, , , , , ) = pool.slot0(); 139 | int24 tickMove = tick > lastTick ? tick - lastTick : lastTick - tick; 140 | if (tickMove < minTickMove) { 141 | return false; 142 | } 143 | 144 | // check price near twap 145 | int24 twap = getTwap(); 146 | int24 twapDeviation = tick > twap ? tick - twap : twap - tick; 147 | if (twapDeviation > maxTwapDeviation) { 148 | return false; 149 | } 150 | 151 | // check price not too close to boundary 152 | int24 maxThreshold = baseThreshold > limitThreshold ? baseThreshold : limitThreshold; 153 | if ( 154 | tick < TickMath.MIN_TICK + maxThreshold + tickSpacing || 155 | tick > TickMath.MAX_TICK - maxThreshold - tickSpacing 156 | ) { 157 | return false; 158 | } 159 | 160 | return true; 161 | } 162 | 163 | /// @dev Fetches time-weighted average price in ticks from Uniswap pool. 164 | function getTwap() public view returns (int24) { 165 | uint32 _twapDuration = twapDuration; 166 | uint32[] memory secondsAgo = new uint32[](2); 167 | secondsAgo[0] = _twapDuration; 168 | secondsAgo[1] = 0; 169 | 170 | (int56[] memory tickCumulatives, ) = pool.observe(secondsAgo); 171 | return int24((tickCumulatives[1] - tickCumulatives[0]) / _twapDuration); 172 | } 173 | 174 | /// @dev Rounds tick down towards negative infinity so that it's a multiple 175 | /// of `tickSpacing`. 176 | function _floor(int24 tick) internal view returns (int24) { 177 | int24 compressed = tick / tickSpacing; 178 | if (tick < 0 && tick % tickSpacing != 0) compressed--; 179 | return compressed * tickSpacing; 180 | } 181 | 182 | function _checkThreshold(int24 threshold, int24 _tickSpacing) internal pure { 183 | require(threshold > 0, "threshold must be > 0"); 184 | require(threshold <= TickMath.MAX_TICK, "threshold too high"); 185 | require(threshold % _tickSpacing == 0, "threshold must be multiple of tickSpacing"); 186 | } 187 | 188 | function setKeeper(address _keeper) external onlyGovernance { 189 | keeper = _keeper; 190 | } 191 | 192 | function setBaseThreshold(int24 _baseThreshold) external onlyGovernance { 193 | _checkThreshold(_baseThreshold, tickSpacing); 194 | baseThreshold = _baseThreshold; 195 | } 196 | 197 | function setLimitThreshold(int24 _limitThreshold) external onlyGovernance { 198 | _checkThreshold(_limitThreshold, tickSpacing); 199 | limitThreshold = _limitThreshold; 200 | } 201 | 202 | function setPeriod(uint256 _period) external onlyGovernance { 203 | period = _period; 204 | } 205 | 206 | function setMinTickMove(int24 _minTickMove) external onlyGovernance { 207 | require(_minTickMove >= 0, "minTickMove must be >= 0"); 208 | minTickMove = _minTickMove; 209 | } 210 | 211 | function setMaxTwapDeviation(int24 _maxTwapDeviation) external onlyGovernance { 212 | require(_maxTwapDeviation >= 0, "maxTwapDeviation must be >= 0"); 213 | maxTwapDeviation = _maxTwapDeviation; 214 | } 215 | 216 | function setTwapDuration(uint32 _twapDuration) external onlyGovernance { 217 | require(_twapDuration > 0, "twapDuration must be > 0"); 218 | twapDuration = _twapDuration; 219 | } 220 | 221 | /// @dev Uses same governance as underlying vault. 222 | modifier onlyGovernance { 223 | require(msg.sender == vault.governance(), "governance"); 224 | _; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /tests/test_deposit_withdraw.py: -------------------------------------------------------------------------------- 1 | from brownie import chain, reverts, ZERO_ADDRESS 2 | import pytest 3 | from pytest import approx 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "amount0Desired,amount1Desired", 8 | [[0, 1], [1, 0], [1e18, 0], [0, 1e18], [1e4, 1e18], [1e18, 1e18]], 9 | ) 10 | def test_initial_deposit( 11 | vault, 12 | tokens, 13 | gov, 14 | user, 15 | recipient, 16 | amount0Desired, 17 | amount1Desired, 18 | ): 19 | 20 | # Store balances 21 | balance0 = tokens[0].balanceOf(user) 22 | balance1 = tokens[1].balanceOf(user) 23 | 24 | # Deposit 25 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, recipient, {"from": user}) 26 | shares, amount0, amount1 = tx.return_value 27 | 28 | # Check amounts are same as inputs 29 | assert amount0 == amount0Desired 30 | assert amount1 == amount1Desired 31 | 32 | # Check received right number of shares 33 | assert shares == vault.balanceOf(recipient) > 0 34 | 35 | # Check paid right amount of tokens 36 | assert amount0 == balance0 - tokens[0].balanceOf(user) 37 | assert amount1 == balance1 - tokens[1].balanceOf(user) 38 | 39 | # Check event 40 | assert tx.events["Deposit"] == { 41 | "sender": user, 42 | "to": recipient, 43 | "shares": shares, 44 | "amount0": amount0, 45 | "amount1": amount1, 46 | } 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "amount0Desired,amount1Desired", 51 | [[1, 1e18], [1e18, 1], [1e4, 1e18], [1e18, 1e18]], 52 | ) 53 | def test_deposit( 54 | vaultAfterPriceMove, 55 | tokens, 56 | getPositions, 57 | gov, 58 | user, 59 | recipient, 60 | amount0Desired, 61 | amount1Desired, 62 | ): 63 | vault = vaultAfterPriceMove 64 | 65 | # Store balances, supply and positions 66 | balance0 = tokens[0].balanceOf(user) 67 | balance1 = tokens[1].balanceOf(user) 68 | totalSupply = vault.totalSupply() 69 | total0, total1 = vault.getTotalAmounts() 70 | govShares = vault.balanceOf(gov) 71 | 72 | # Deposit 73 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, recipient, {"from": user}) 74 | shares, amount0, amount1 = tx.return_value 75 | 76 | # Check amounts don't exceed desired 77 | assert amount0 <= amount0Desired 78 | assert amount1 <= amount1Desired 79 | 80 | # Check received right number of shares 81 | assert shares == vault.balanceOf(recipient) > 0 82 | 83 | # Check paid right amount of tokens 84 | assert amount0 == balance0 - tokens[0].balanceOf(user) 85 | assert amount1 == balance1 - tokens[1].balanceOf(user) 86 | 87 | # Check one amount is tight 88 | assert approx(amount0) == amount0Desired or approx(amount1) == amount1Desired 89 | 90 | # Check total amounts are in proportion 91 | total0After, total1After = vault.getTotalAmounts() 92 | totalSupplyAfter = vault.totalSupply() 93 | assert approx(total0 * total1After) == total1 * total0After 94 | assert approx(total0 * totalSupplyAfter) == total0After * totalSupply 95 | assert approx(total1 * totalSupplyAfter) == total1After * totalSupply 96 | 97 | # Check event 98 | assert tx.events["Deposit"] == { 99 | "sender": user, 100 | "to": recipient, 101 | "shares": shares, 102 | "amount0": amount0, 103 | "amount1": amount1, 104 | } 105 | 106 | 107 | @pytest.mark.parametrize( 108 | "amount0Desired,amount1Desired", 109 | [[1e4, 1e18], [1e18, 1e18]], 110 | ) 111 | def test_deposit_when_vault_only_has_token0( 112 | vaultOnlyWithToken0, 113 | pool, 114 | tokens, 115 | getPositions, 116 | gov, 117 | user, 118 | recipient, 119 | amount0Desired, 120 | amount1Desired, 121 | ): 122 | vault = vaultOnlyWithToken0 123 | 124 | # Poke fees 125 | vault.withdraw(vault.balanceOf(gov) // 2, 0, 0, gov, {"from": gov}) 126 | 127 | # Store balances, supply and positions 128 | balance0 = tokens[0].balanceOf(user) 129 | balance1 = tokens[1].balanceOf(user) 130 | totalSupply = vault.totalSupply() 131 | total0, total1 = vault.getTotalAmounts() 132 | 133 | # Deposit 134 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, recipient, {"from": user}) 135 | shares, amount0, amount1 = tx.return_value 136 | 137 | # Check amounts don't exceed desired 138 | assert amount0 <= amount0Desired 139 | assert amount1 <= amount1Desired 140 | 141 | # Check received right number of shares 142 | assert shares == vault.balanceOf(recipient) > 0 143 | 144 | # Check paid right amount of tokens 145 | assert amount0 == balance0 - tokens[0].balanceOf(user) 146 | assert amount1 == balance1 - tokens[1].balanceOf(user) 147 | 148 | # Check paid mainly token0 149 | assert amount0 > 0 150 | assert approx(amount1 / amount0, abs=1e-3) == 0 151 | 152 | # Check amount is tight 153 | assert approx(amount0) == amount0Desired 154 | 155 | # Check total amounts are in proportion 156 | total0After, total1After = vault.getTotalAmounts() 157 | totalSupplyAfter = vault.totalSupply() 158 | assert approx(total0 * totalSupplyAfter) == total0After * totalSupply 159 | 160 | 161 | @pytest.mark.parametrize( 162 | "amount0Desired,amount1Desired", 163 | [[1e4, 1e18], [1e18, 1e18]], 164 | ) 165 | def test_deposit_when_vault_only_has_token1( 166 | vaultOnlyWithToken1, 167 | pool, 168 | tokens, 169 | getPositions, 170 | gov, 171 | user, 172 | recipient, 173 | amount0Desired, 174 | amount1Desired, 175 | ): 176 | vault = vaultOnlyWithToken1 177 | 178 | # Poke fees 179 | vault.withdraw(vault.balanceOf(gov) // 2, 0, 0, gov, {"from": gov}) 180 | 181 | # Store balances, supply and positions 182 | balance0 = tokens[0].balanceOf(user) 183 | balance1 = tokens[1].balanceOf(user) 184 | totalSupply = vault.totalSupply() 185 | total0, total1 = vault.getTotalAmounts() 186 | 187 | # Deposit 188 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, recipient, {"from": user}) 189 | shares, amount0, amount1 = tx.return_value 190 | 191 | # Check amounts don't exceed desired 192 | assert amount0 <= amount0Desired 193 | assert amount1 <= amount1Desired 194 | 195 | # Check received right number of shares 196 | assert shares == vault.balanceOf(recipient) > 0 197 | 198 | # Check paid right amount of tokens 199 | assert amount0 == balance0 - tokens[0].balanceOf(user) 200 | assert amount1 == balance1 - tokens[1].balanceOf(user) 201 | 202 | # Check paid mainly token1 203 | assert amount1 > 0 204 | assert approx(amount0 / amount1, abs=1e-3) == 0 205 | 206 | # Check amount is tight 207 | assert approx(amount1) == amount1Desired 208 | 209 | # Check total amounts are in proportion 210 | total0After, total1After = vault.getTotalAmounts() 211 | totalSupplyAfter = vault.totalSupply() 212 | assert approx(total1 * totalSupplyAfter) == total1After * totalSupply 213 | 214 | 215 | def test_deposit_checks(vault, user): 216 | with reverts("amount0Desired or amount1Desired"): 217 | vault.deposit(0, 0, 0, 0, user, {"from": user}) 218 | with reverts("to"): 219 | vault.deposit(1e8, 1e8, 0, 0, ZERO_ADDRESS, {"from": user}) 220 | with reverts("to"): 221 | vault.deposit(1e8, 1e8, 0, 0, vault, {"from": user}) 222 | 223 | with reverts("amount0Min"): 224 | vault.deposit(1e8, 0, 2e8, 0, user, {"from": user}) 225 | with reverts("amount1Min"): 226 | vault.deposit(0, 1e8, 0, 2e8, user, {"from": user}) 227 | 228 | with reverts("maxTotalSupply"): 229 | vault.deposit(1e8, 200e18, 0, 0, user, {"from": user}) 230 | 231 | 232 | def test_withdraw( 233 | vaultAfterPriceMove, 234 | strategy, 235 | pool, 236 | tokens, 237 | getPositions, 238 | gov, 239 | user, 240 | recipient, 241 | keeper, 242 | ): 243 | vault = vaultAfterPriceMove 244 | 245 | # Deposit and rebalance 246 | tx = vault.deposit(1e8, 1e10, 0, 0, user, {"from": user}) 247 | shares, _, _ = tx.return_value 248 | strategy.rebalance({"from": keeper}) 249 | 250 | # Store balances, supply and positions 251 | balance0 = tokens[0].balanceOf(recipient) 252 | balance1 = tokens[1].balanceOf(recipient) 253 | totalSupply = vault.totalSupply() 254 | total0, total1 = vault.getTotalAmounts() 255 | basePos, limitPos = getPositions(vault) 256 | 257 | # Withdraw all shares 258 | tx = vault.withdraw(shares, 0, 0, recipient, {"from": user}) 259 | amount0, amount1 = tx.return_value 260 | 261 | # Check is empty now 262 | assert vault.balanceOf(user) == 0 263 | 264 | # Check received right amount of tokens 265 | assert tokens[0].balanceOf(recipient) - balance0 == amount0 > 0 266 | assert tokens[1].balanceOf(recipient) - balance1 == amount1 > 0 267 | 268 | # Check total amounts are in proportion 269 | ratio = (totalSupply - shares) / totalSupply 270 | total0After, total1After = vault.getTotalAmounts() 271 | assert approx(total0After / total0) == ratio 272 | assert approx(total1After / total1) == ratio 273 | 274 | # Check liquidity in pool decreases proportionally 275 | basePosAfter, limitPosAfter = getPositions(vault) 276 | assert approx(basePosAfter[0] / basePos[0]) == ratio 277 | assert approx(limitPosAfter[0] / limitPos[0]) == ratio 278 | 279 | # Check event 280 | assert tx.events["Withdraw"] == { 281 | "sender": user, 282 | "to": recipient, 283 | "shares": shares, 284 | "amount0": amount0, 285 | "amount1": amount1, 286 | } 287 | 288 | 289 | def test_withdraw_checks(vault, user, recipient): 290 | tx = vault.deposit(1e8, 1e10, 0, 0, user, {"from": user}) 291 | shares, _, _ = tx.return_value 292 | 293 | with reverts("shares"): 294 | vault.withdraw(0, 0, 0, recipient, {"from": user}) 295 | with reverts("to"): 296 | vault.withdraw(shares - 1000, 0, 0, ZERO_ADDRESS, {"from": user}) 297 | with reverts("to"): 298 | vault.withdraw(shares - 1000, 0, 0, vault, {"from": user}) 299 | 300 | with reverts("amount0Min"): 301 | vault.withdraw(shares - 1000, 1e18, 0, recipient, {"from": user}) 302 | with reverts("amount1Min"): 303 | vault.withdraw(shares - 1000, 0, 1e18, recipient, {"from": user}) 304 | -------------------------------------------------------------------------------- /tests/test_rebalance.py: -------------------------------------------------------------------------------- 1 | from brownie import chain, reverts 2 | import pytest 3 | from pytest import approx 4 | 5 | from conftest import computePositionKey 6 | 7 | 8 | @pytest.mark.parametrize("buy", [False, True]) 9 | @pytest.mark.parametrize("big", [False, True]) 10 | def test_strategy_rebalance( 11 | vault, strategy, pool, tokens, router, getPositions, gov, user, keeper, buy, big 12 | ): 13 | # Mint some liquidity 14 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 15 | strategy.rebalance({"from": keeper}) 16 | 17 | # Do a swap to move the price 18 | qty = 1e16 * [100, 1][buy] * [1, 100][big] 19 | router.swap(pool, buy, qty, {"from": gov}) 20 | baseLower, baseUpper = vault.baseLower(), vault.baseUpper() 21 | limitLower, limitUpper = vault.limitLower(), vault.limitUpper() 22 | 23 | # fast forward 1 hour 24 | chain.sleep(3600) 25 | 26 | # Store totals 27 | total0, total1 = vault.getTotalAmounts() 28 | totalSupply = vault.totalSupply() 29 | 30 | # Rebalance 31 | tx = strategy.rebalance({"from": keeper}) 32 | 33 | # Check old positions are empty 34 | liquidity, _, _, owed0, owed1 = pool.positions( 35 | computePositionKey(vault, baseLower, baseUpper) 36 | ) 37 | assert liquidity == owed0 == owed1 == 0 38 | liquidity, _, _, owed0, owed1 = pool.positions( 39 | computePositionKey(vault, limitLower, limitUpper) 40 | ) 41 | assert liquidity == owed0 == owed1 == 0 42 | 43 | # Check ranges are set correctly 44 | tick = pool.slot0()[1] 45 | tickFloor = tick // 60 * 60 46 | assert vault.baseLower() == tickFloor - 2400 47 | assert vault.baseUpper() == tickFloor + 60 + 2400 48 | if buy: 49 | assert vault.limitLower() == tickFloor + 60 50 | assert vault.limitUpper() == tickFloor + 60 + 1200 51 | else: 52 | assert vault.limitLower() == tickFloor - 1200 53 | assert vault.limitUpper() == tickFloor 54 | 55 | assert strategy.lastRebalance() == tx.timestamp 56 | assert strategy.lastTick() == tick 57 | assert strategy.getTick() == tick 58 | 59 | base, limit = getPositions(vault) 60 | 61 | if big: 62 | # If order is too big, all tokens go to limit order 63 | assert base[0] == 0 64 | assert limit[0] > 0 65 | else: 66 | assert base[0] > 0 67 | assert limit[0] > 0 68 | 69 | # Check no tokens left unused. Only small amount left due to rounding 70 | assert tokens[0].balanceOf(vault) - vault.accruedProtocolFees0() < 1000 71 | assert tokens[1].balanceOf(vault) - vault.accruedProtocolFees1() < 1000 72 | 73 | # Check event 74 | total0After, total1After = vault.getTotalAmounts() 75 | (ev,) = tx.events["Snapshot"] 76 | assert ev["tick"] == tick 77 | assert approx(ev["totalAmount0"]) == total0After 78 | assert approx(ev["totalAmount1"]) == total1After 79 | assert ev["totalSupply"] == vault.totalSupply() 80 | 81 | (ev1, ev2) = tx.events["CollectFees"] 82 | dtotal0 = total0After - total0 + ev1["feesToProtocol0"] + ev2["feesToProtocol0"] 83 | dtotal1 = total1After - total1 + ev1["feesToProtocol1"] + ev2["feesToProtocol1"] 84 | assert ( 85 | approx(ev1["feesToVault0"] + ev2["feesToVault0"], rel=1e-6, abs=1) 86 | == dtotal0 * 0.99 87 | ) 88 | assert ( 89 | approx(ev1["feesToProtocol0"] + ev2["feesToProtocol0"], rel=1e-6, abs=1) 90 | == dtotal0 * 0.01 91 | ) 92 | assert ( 93 | approx(ev1["feesToVault1"] + ev2["feesToVault1"], rel=1e-6, abs=1) 94 | == dtotal1 * 0.99 95 | ) 96 | assert ( 97 | approx(ev1["feesToProtocol1"] + ev2["feesToProtocol1"], rel=1e-6, abs=1) 98 | == dtotal1 * 0.01 99 | ) 100 | 101 | 102 | @pytest.mark.parametrize("buy", [False, True]) 103 | def test_rebalance_twap_check( 104 | vault, strategy, pool, tokens, router, gov, user, keeper, buy 105 | ): 106 | 107 | # Reduce max deviation 108 | strategy.setMaxTwapDeviation(500, {"from": gov}) 109 | 110 | # Mint some liquidity 111 | vault.deposit(1e8, 1e10, 0, 0, user, {"from": user}) 112 | 113 | # Do a swap to move the price a lot 114 | qty = 1e16 * 100 * [100, 1][buy] 115 | router.swap(pool, buy, qty, {"from": gov}) 116 | 117 | # Can't rebalance 118 | with reverts("maxTwapDeviation"): 119 | strategy.rebalance({"from": keeper}) 120 | 121 | # Wait for twap period to pass and poke price 122 | chain.sleep(610) 123 | router.swap(pool, buy, 1e8, {"from": gov}) 124 | 125 | # Rebalance 126 | strategy.rebalance({"from": keeper}) 127 | 128 | 129 | def test_can_rebalance_when_vault_empty( 130 | vault, strategy, pool, tokens, gov, user, keeper 131 | ): 132 | assert tokens[0].balanceOf(vault) == 0 133 | assert tokens[1].balanceOf(vault) == 0 134 | strategy.rebalance({"from": keeper}) 135 | tx = strategy.rebalance({"from": keeper}) 136 | 137 | # Check ranges are set correctly 138 | tick = pool.slot0()[1] 139 | tickFloor = tick // 60 * 60 140 | assert vault.baseLower() == tickFloor - 2400 141 | assert vault.baseUpper() == tickFloor + 60 + 2400 142 | assert vault.limitLower() == tickFloor + 60 143 | assert vault.limitUpper() == tickFloor + 60 + 1200 144 | 145 | assert strategy.lastRebalance() == tx.timestamp 146 | assert strategy.lastTick() == tick 147 | 148 | 149 | @pytest.mark.parametrize("bid", [False, True]) 150 | def test_rebalance( 151 | vault, strategy, pool, tokens, router, getPositions, gov, user, keeper, bid 152 | ): 153 | # Mint some liquidity 154 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 155 | 156 | # Store old state 157 | baseLower, baseUpper = vault.baseLower(), vault.baseUpper() 158 | limitLower, limitUpper = vault.limitLower(), vault.limitUpper() 159 | total0, total1 = vault.getTotalAmounts() 160 | totalSupply = vault.totalSupply() 161 | tick = pool.slot0()[1] 162 | assert 42000 < tick < 48000 163 | 164 | # Rebalance 165 | if bid: 166 | tx = vault.rebalance( 167 | 0, 0, 42000, 54000, -120000, -60000, 60000, 120000, {"from": strategy} 168 | ) 169 | else: 170 | tx = vault.rebalance( 171 | 0, 0, 36000, 48000, -120000, -60000, 60000, 120000, {"from": strategy} 172 | ) 173 | 174 | # Check old positions are empty 175 | liquidity, _, _, owed0, owed1 = pool.positions( 176 | computePositionKey(vault, baseLower, baseUpper) 177 | ) 178 | assert liquidity == owed0 == owed1 == 0 179 | liquidity, _, _, owed0, owed1 = pool.positions( 180 | computePositionKey(vault, limitLower, limitUpper) 181 | ) 182 | assert liquidity == owed0 == owed1 == 0 183 | 184 | # Check ranges are set correctly 185 | if bid: 186 | assert vault.baseLower() == 42000 187 | assert vault.baseUpper() == 54000 188 | assert vault.limitLower() == -120000 189 | assert vault.limitUpper() == -60000 190 | else: 191 | assert vault.baseLower() == 36000 192 | assert vault.baseUpper() == 48000 193 | assert vault.limitLower() == 60000 194 | assert vault.limitUpper() == 120000 195 | 196 | base, limit = getPositions(vault) 197 | assert base[0] > 0 198 | assert limit[0] > 0 199 | 200 | # Check no tokens left unused. Only small amount left due to rounding 201 | assert tokens[0].balanceOf(vault) - vault.accruedProtocolFees0() < 1000 202 | assert tokens[1].balanceOf(vault) - vault.accruedProtocolFees1() < 1000 203 | 204 | # Check event 205 | total0After, total1After = vault.getTotalAmounts() 206 | (ev,) = tx.events["Snapshot"] 207 | assert ev["tick"] == tick 208 | assert approx(ev["totalAmount0"]) == total0After 209 | assert approx(ev["totalAmount1"]) == total1After 210 | assert ev["totalSupply"] == vault.totalSupply() 211 | 212 | (ev1, ev2) = tx.events["CollectFees"] 213 | dtotal0 = total0After - total0 + ev1["feesToProtocol0"] + ev2["feesToProtocol0"] 214 | dtotal1 = total1After - total1 + ev1["feesToProtocol1"] + ev2["feesToProtocol1"] 215 | assert ( 216 | approx(ev1["feesToVault0"] + ev2["feesToVault0"], rel=1e-6, abs=1) 217 | == dtotal0 * 0.99 218 | ) 219 | assert ( 220 | approx(ev1["feesToProtocol0"] + ev2["feesToProtocol0"], rel=1e-6, abs=1) 221 | == dtotal0 * 0.01 222 | ) 223 | assert ( 224 | approx(ev1["feesToVault1"] + ev2["feesToVault1"], rel=1e-6, abs=1) 225 | == dtotal1 * 0.99 226 | ) 227 | assert ( 228 | approx(ev1["feesToProtocol1"] + ev2["feesToProtocol1"], rel=1e-6, abs=1) 229 | == dtotal1 * 0.01 230 | ) 231 | 232 | 233 | def test_rebalance_checks(vault, strategy, pool, gov, user, keeper): 234 | with reverts("tickLower < tickUpper"): 235 | vault.rebalance(0, 0, 600, 600, 0, 60, 0, 60, {"from": strategy}) 236 | with reverts("tickLower < tickUpper"): 237 | vault.rebalance(0, 0, 0, 60, 600, 600, 0, 60, {"from": strategy}) 238 | with reverts("tickLower < tickUpper"): 239 | vault.rebalance(0, 0, 0, 60, 0, 60, 600, 600, {"from": strategy}) 240 | 241 | with reverts("tickLower too low"): 242 | vault.rebalance(0, 0, -887280, 60, 0, 60, 0, 60, {"from": strategy}) 243 | with reverts("tickLower too low"): 244 | vault.rebalance(0, 0, 0, 60, -887280, 60, 0, 60, {"from": strategy}) 245 | with reverts("tickLower too low"): 246 | vault.rebalance(0, 0, 0, 60, 0, 60, -887280, 60, {"from": strategy}) 247 | 248 | with reverts("tickUpper too high"): 249 | vault.rebalance(0, 0, 0, 887280, 0, 60, 0, 60, {"from": strategy}) 250 | with reverts("tickUpper too high"): 251 | vault.rebalance(0, 0, 0, 60, 0, 887280, 0, 60, {"from": strategy}) 252 | with reverts("tickUpper too high"): 253 | vault.rebalance(0, 0, 0, 60, 0, 60, 0, 887280, {"from": strategy}) 254 | 255 | with reverts("tickLower % tickSpacing"): 256 | vault.rebalance(0, 0, 1, 60, 0, 60, 0, 60, {"from": strategy}) 257 | with reverts("tickLower % tickSpacing"): 258 | vault.rebalance(0, 0, 0, 60, 1, 60, 0, 60, {"from": strategy}) 259 | with reverts("tickLower % tickSpacing"): 260 | vault.rebalance(0, 0, 0, 60, 0, 60, 1, 60, {"from": strategy}) 261 | 262 | with reverts("tickUpper % tickSpacing"): 263 | vault.rebalance(0, 0, 0, 61, 0, 60, 0, 60, {"from": strategy}) 264 | with reverts("tickUpper % tickSpacing"): 265 | vault.rebalance(0, 0, 0, 60, 0, 61, 0, 60, {"from": strategy}) 266 | with reverts("tickUpper % tickSpacing"): 267 | vault.rebalance(0, 0, 0, 60, 0, 60, 0, 61, {"from": strategy}) 268 | 269 | with reverts("bidUpper"): 270 | vault.rebalance( 271 | 0, 0, -60000, 60000, -120000, 60000, 60000, 120000, {"from": strategy} 272 | ) 273 | with reverts("askLower"): 274 | vault.rebalance( 275 | 0, 0, -60000, 60000, -120000, -60000, -60000, 120000, {"from": strategy} 276 | ) 277 | 278 | for u in [gov, user, keeper]: 279 | with reverts("strategy"): 280 | vault.rebalance(0, 0, 0, 60, 0, 60, 0, 60, {"from": u}) 281 | 282 | vault.rebalance( 283 | 0, 0, -60000, 60000, -120000, -60000, 60000, 120000, {"from": strategy} 284 | ) 285 | 286 | 287 | def test_rebalance_swap(vault, strategy, pool, user, keeper): 288 | min_sqrt = 4295128739 289 | max_sqrt = 1461446703485210103287273052203988822378723970342 290 | 291 | # Mint some liquidity 292 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 293 | 294 | total0, total1 = vault.getTotalAmounts() 295 | vault.rebalance( 296 | 1e8, 297 | min_sqrt + 1, 298 | -60000, 299 | 60000, 300 | -120000, 301 | -60000, 302 | 60000, 303 | 120000, 304 | {"from": strategy}, 305 | ) 306 | 307 | total0After, total1After = vault.getTotalAmounts() 308 | assert approx(total0 - total0After) == 1e8 309 | assert total1 < total1After 310 | 311 | price = 1.0001 ** pool.slot0()[1] 312 | assert approx(total0 * price + total1) == total0After * price + total1 313 | 314 | total0, total1 = vault.getTotalAmounts() 315 | vault.rebalance( 316 | -1e8, 317 | max_sqrt - 1, 318 | -60000, 319 | 60000, 320 | -120000, 321 | -60000, 322 | 60000, 323 | 120000, 324 | {"from": strategy}, 325 | ) 326 | 327 | total0After, total1After = vault.getTotalAmounts() 328 | assert approx(total1 - total1After) == 1e8 329 | assert total0 < total0After 330 | 331 | price = 1.0001 ** pool.slot0()[1] 332 | assert approx(total0 * price + total1) == total0After * price + total1 333 | -------------------------------------------------------------------------------- /tests/test_invariants.py: -------------------------------------------------------------------------------- 1 | from brownie.test import given, strategy 2 | from hypothesis import settings 3 | from pytest import approx 4 | 5 | 6 | MAX_EXAMPLES = 5 # faster 7 | # MAX_EXAMPLES = 50 8 | 9 | 10 | def getPrice(pool): 11 | sqrtPrice = pool.slot0()[0] / (1 << 96) 12 | return sqrtPrice ** 2 13 | 14 | 15 | @given( 16 | amount0Desired=strategy("uint256", min_value=0, max_value=1e18), 17 | amount1Desired=strategy("uint256", min_value=0, max_value=1e18), 18 | buy=strategy("bool"), 19 | qty=strategy("uint256", min_value=1e3, max_value=1e16), 20 | ) 21 | @settings(max_examples=MAX_EXAMPLES) 22 | def test_deposit_invariants( 23 | createPoolVaultStrategy, 24 | router, 25 | gov, 26 | user, 27 | keeper, 28 | amount0Desired, 29 | amount1Desired, 30 | buy, 31 | qty, 32 | ): 33 | pool, vault, strategy = createPoolVaultStrategy() 34 | 35 | # Set fee to 0 since this when an arb is most likely to work 36 | vault.setProtocolFee(0, {"from": gov}) 37 | 38 | # Simulate deposit and random price move 39 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 40 | strategy.rebalance({"from": keeper}) 41 | router.swap(pool, buy, qty, {"from": user}) 42 | 43 | # Poke Uniswap amounts owed to include fees 44 | shares = vault.balanceOf(user) 45 | vault.withdraw(shares // 2, 0, 0, user, {"from": user}) 46 | 47 | # Store totals 48 | total0, total1 = vault.getTotalAmounts() 49 | totalSupply = vault.totalSupply() 50 | 51 | # Ignore when output shares is 0: 52 | if amount1Desired < 2 and total1 > 0: 53 | return 54 | if amount0Desired < 2 and total0 > 0: 55 | return 56 | 57 | # Deposit 58 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, user, {"from": user}) 59 | shares, amount0, amount1 = tx.return_value 60 | 61 | # Check amounts don't exceed desired 62 | assert amount0 <= amount0Desired 63 | assert amount1 <= amount1Desired 64 | 65 | # Check one is tight 66 | assert amount0 == amount0Desired or amount1 == amount1Desired 67 | 68 | # Check ratios stay the same 69 | if amount0 > 1e6 and amount1 > 1e6: 70 | assert approx(amount1 * total0) == amount0 * total1 71 | assert approx(amount0 * totalSupply) == shares * total0 72 | assert approx(amount1 * totalSupply) == shares * total1 73 | 74 | # Check doesn't under-pay 75 | assert amount0 * totalSupply >= shares * total0 76 | assert amount1 * totalSupply >= shares * total1 77 | 78 | 79 | @given( 80 | share_frac=strategy("uint256", min_value=1, max_value=1e8), 81 | buy=strategy("bool"), 82 | qty=strategy("uint256", min_value=1e3, max_value=1e16), 83 | ) 84 | @settings(max_examples=MAX_EXAMPLES) 85 | def test_withdraw_invariants( 86 | createPoolVaultStrategy, 87 | router, 88 | gov, 89 | user, 90 | keeper, 91 | share_frac, 92 | buy, 93 | qty, 94 | ): 95 | pool, vault, strategy = createPoolVaultStrategy() 96 | 97 | # Simulate deposit and random price move 98 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 99 | strategy.rebalance({"from": keeper}) 100 | router.swap(pool, buy, qty, {"from": user}) 101 | 102 | # Poke Uniswap amounts owed to include fees 103 | vault.deposit(100, 100, 0, 0, user, {"from": user}) 104 | 105 | # Store totals 106 | total0, total1 = vault.getTotalAmounts() 107 | totalSupply = vault.totalSupply() 108 | 109 | shares = vault.balanceOf(user) * share_frac / 1e8 110 | if shares == 0: 111 | return 112 | 113 | tx = vault.withdraw(shares, 0, 0, user, {"from": user}) 114 | withdraw0, withdraw1 = tx.return_value 115 | assert approx(withdraw0 * totalSupply) == total0 * shares 116 | assert approx(withdraw1 * totalSupply) == total1 * shares 117 | 118 | total0After, total1After = vault.getTotalAmounts() 119 | assert approx(total0After) == total0 - withdraw0 120 | assert approx(total1After) == total1 - withdraw1 121 | 122 | 123 | @given( 124 | amount0Desired=strategy("uint256", min_value=1e12, max_value=1e18), 125 | amount1Desired=strategy("uint256", min_value=1e12, max_value=1e18), 126 | buy=strategy("bool"), 127 | qty=strategy("uint256", min_value=1e3, max_value=1e8), 128 | ) 129 | @settings(max_examples=MAX_EXAMPLES) 130 | def test_rebalance_invariants( 131 | MockToken, 132 | createPoolVaultStrategy, 133 | router, 134 | gov, 135 | user, 136 | keeper, 137 | amount0Desired, 138 | amount1Desired, 139 | buy, 140 | qty, 141 | ): 142 | pool, vault, strategy = createPoolVaultStrategy() 143 | 144 | # Set fee to 0 since this when an arb is most likely to work 145 | vault.setProtocolFee(0, {"from": gov}) 146 | 147 | # Simulate random deposit and random price move 148 | vault.deposit(amount0Desired, amount1Desired, 0, 0, user, {"from": user}) 149 | strategy.rebalance({"from": keeper}) 150 | router.swap(pool, buy, qty, {"from": user}) 151 | 152 | # Ignore TWAP deviation 153 | strategy.setMaxTwapDeviation(1 << 22, {"from": gov}) 154 | 155 | # Poke Uniswap amounts owed to include fees 156 | shares = vault.balanceOf(user) 157 | vault.withdraw(shares // 2, 0, 0, user, {"from": user}) 158 | 159 | # Store totals 160 | total0, total1 = vault.getTotalAmounts() 161 | 162 | strategy.rebalance({"from": keeper}) 163 | 164 | # Check leftover balances is low 165 | tokens = MockToken.at(pool.token0()), MockToken.at(pool.token1()) 166 | assert tokens[0].balanceOf(vault) - vault.accruedProtocolFees0() < 10000 167 | assert tokens[1].balanceOf(vault) - vault.accruedProtocolFees1() < 10000 168 | 169 | # Check total amounts haven't changed 170 | newTotal0, newTotal1 = vault.getTotalAmounts() 171 | assert approx(total0, abs=1000) == newTotal0 172 | assert approx(total1, abs=1000) == newTotal1 173 | assert total0 - 2 <= newTotal0 <= total0 174 | assert total1 - 2 <= newTotal1 <= total1 175 | 176 | 177 | @given( 178 | amount0Desired=strategy("uint256", min_value=1e8, max_value=1e18), 179 | amount1Desired=strategy("uint256", min_value=1e8, max_value=1e18), 180 | buy=strategy("bool"), 181 | qty=strategy("uint256", min_value=1e3, max_value=1e16), 182 | ) 183 | @settings(max_examples=MAX_EXAMPLES) 184 | def test_cannot_make_instant_profit_from_deposit_then_withdraw( 185 | createPoolVaultStrategy, 186 | router, 187 | gov, 188 | user, 189 | keeper, 190 | amount0Desired, 191 | amount1Desired, 192 | buy, 193 | qty, 194 | ): 195 | pool, vault, strategy = createPoolVaultStrategy() 196 | 197 | # Set fee to 0 since this when an arb is most likely to work 198 | vault.setProtocolFee(0, {"from": gov}) 199 | 200 | # Simulate deposit and random price move 201 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 202 | strategy.rebalance({"from": keeper}) 203 | router.swap(pool, buy, qty, {"from": user}) 204 | 205 | # Deposit 206 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, user, {"from": user}) 207 | shares, amount0Deposit, amount1Deposit = tx.return_value 208 | 209 | # Withdraw all 210 | tx = vault.withdraw(shares, 0, 0, user, {"from": user}) 211 | amount0Withdraw, amount1Withdraw = tx.return_value 212 | 213 | # Check did not make a profit 214 | assert amount0Deposit >= amount0Withdraw 215 | assert amount1Deposit >= amount1Withdraw 216 | 217 | # Check amounts are roughly equal 218 | assert approx(amount0Deposit, abs=1000) == amount0Withdraw 219 | assert approx(amount1Deposit, abs=1000) == amount1Withdraw 220 | 221 | 222 | @given( 223 | amount0Desired=strategy("uint256", min_value=1e8, max_value=1e18), 224 | amount1Desired=strategy("uint256", min_value=1e8, max_value=1e18), 225 | buy=strategy("bool"), 226 | buy2=strategy("bool"), 227 | qty=strategy("uint256", min_value=1e3, max_value=1e16), 228 | qty2=strategy("uint256", min_value=1e3, max_value=1e16), 229 | manipulateBack=strategy("bool"), 230 | ) 231 | @settings(max_examples=MAX_EXAMPLES) 232 | def test_cannot_make_instant_profit_from_manipulated_deposit( 233 | MockToken, 234 | createPoolVaultStrategy, 235 | router, 236 | gov, 237 | user, 238 | keeper, 239 | amount0Desired, 240 | amount1Desired, 241 | buy, 242 | qty, 243 | buy2, 244 | qty2, 245 | manipulateBack, 246 | ): 247 | pool, vault, strategy = createPoolVaultStrategy() 248 | 249 | # Set fee to 0 since this when an arb is most likely to work 250 | vault.setProtocolFee(0, {"from": gov}) 251 | 252 | # Simulate deposit and random price move 253 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 254 | strategy.rebalance({"from": keeper}) 255 | router.swap(pool, buy, qty, {"from": user}) 256 | 257 | # Store balances and totals before 258 | tokens = MockToken.at(pool.token0()), MockToken.at(pool.token1()) 259 | balance0 = tokens[0].balanceOf(user) 260 | balance1 = tokens[1].balanceOf(user) 261 | total0, total1 = vault.getTotalAmounts() 262 | price = getPrice(pool) 263 | 264 | # Manipulate 265 | router.swap(pool, buy2, qty2, {"from": user}) 266 | 267 | # Deposit 268 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, user, {"from": user}) 269 | shares, _, _ = tx.return_value 270 | 271 | # Manipulate price back 272 | if manipulateBack: 273 | router.swap(pool, not buy2, -qty2 * 0.997, {"from": user}) 274 | 275 | # Withdraw all 276 | vault.withdraw(shares, 0, 0, user, {"from": user}) 277 | 278 | # Store balances and totals after 279 | balance0After = tokens[0].balanceOf(user) 280 | balance1After = tokens[1].balanceOf(user) 281 | total0After, total1After = vault.getTotalAmounts() 282 | 283 | # Check attacker did not make a profit 284 | dbalance0 = balance0After - balance0 285 | dbalance1 = balance1After - balance1 286 | assert dbalance0 * price + dbalance1 <= 0 287 | 288 | # Check vault can't be griefed 289 | dtotal0 = total0After - total0 290 | dtotal1 = total1After - total1 291 | assert dtotal0 * price + dtotal1 >= 0 292 | 293 | 294 | @given( 295 | amount0Desired=strategy("uint256", min_value=1e8, max_value=1e18), 296 | amount1Desired=strategy("uint256", min_value=1e8, max_value=1e18), 297 | buy=strategy("bool"), 298 | buy2=strategy("bool"), 299 | qty=strategy("uint256", min_value=1e3, max_value=1e16), 300 | qty2=strategy("uint256", min_value=1e3, max_value=1e16), 301 | manipulateBack=strategy("bool"), 302 | ) 303 | @settings(max_examples=MAX_EXAMPLES) 304 | def test_cannot_make_instant_profit_from_manipulated_withdraw( 305 | MockToken, 306 | createPoolVaultStrategy, 307 | router, 308 | gov, 309 | user, 310 | keeper, 311 | amount0Desired, 312 | amount1Desired, 313 | buy, 314 | qty, 315 | buy2, 316 | qty2, 317 | manipulateBack, 318 | ): 319 | pool, vault, strategy = createPoolVaultStrategy() 320 | 321 | # Set fee to 0 since this when an arb is most likely to work 322 | vault.setProtocolFee(0, {"from": gov}) 323 | 324 | # Simulate deposit and random price move 325 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 326 | strategy.rebalance({"from": keeper}) 327 | router.swap(pool, buy, qty, {"from": user}) 328 | 329 | # Store initial balances 330 | tokens = MockToken.at(pool.token0()), MockToken.at(pool.token1()) 331 | balance0 = tokens[0].balanceOf(user) 332 | balance1 = tokens[1].balanceOf(user) 333 | total0, total1 = vault.getTotalAmounts() 334 | price = getPrice(pool) 335 | 336 | # Deposit 337 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, user, {"from": user}) 338 | shares, _, _ = tx.return_value 339 | 340 | # Manipulate 341 | router.swap(pool, buy2, qty2, {"from": user}) 342 | 343 | # Withdraw all 344 | vault.withdraw(shares, 0, 0, user, {"from": user}) 345 | 346 | # Manipulate back 347 | if manipulateBack: 348 | router.swap(pool, not buy2, -qty2 * 0.997, {"from": user}) 349 | 350 | balance0After = tokens[0].balanceOf(user) 351 | balance1After = tokens[1].balanceOf(user) 352 | total0After, total1After = vault.getTotalAmounts() 353 | 354 | # Check attacker did not make a profit 355 | dbalance0 = balance0After - balance0 356 | dbalance1 = balance1After - balance1 357 | assert dbalance0 * price + dbalance1 <= 0 358 | 359 | # Check vault can't be griefed 360 | dtotal0 = total0After - total0 361 | dtotal1 = total1After - total1 362 | assert dtotal0 * price + dtotal1 >= 0 363 | 364 | 365 | @given( 366 | amount0Desired=strategy("uint256", min_value=1e12, max_value=1e18), 367 | amount1Desired=strategy("uint256", min_value=1e12, max_value=1e18), 368 | buy=strategy("bool"), 369 | buy2=strategy("bool"), 370 | qty=strategy("uint256", min_value=1e3, max_value=1e8), 371 | qty2=strategy("uint256", min_value=1e3, max_value=1e8), 372 | ) 373 | @settings(max_examples=MAX_EXAMPLES) 374 | def test_cannot_make_instant_profit_around_rebalance( 375 | createPoolVaultStrategy, 376 | router, 377 | gov, 378 | user, 379 | keeper, 380 | amount0Desired, 381 | amount1Desired, 382 | buy, 383 | qty, 384 | buy2, 385 | qty2, 386 | ): 387 | pool, vault, strategy = createPoolVaultStrategy() 388 | 389 | # Set fee to 0 since this when an arb is most likely to work 390 | vault.setProtocolFee(0, {"from": gov}) 391 | 392 | # Simulate deposit and random price move 393 | vault.deposit(1e16, 1e18, 0, 0, user, {"from": user}) 394 | strategy.rebalance({"from": keeper}) 395 | router.swap(pool, buy, qty, {"from": user}) 396 | 397 | # Poke Uniswap amounts owed to include fees 398 | shares = vault.balanceOf(user) 399 | vault.withdraw(shares // 2, 0, 0, user, {"from": user}) 400 | 401 | # Store totals before 402 | total0, total1 = vault.getTotalAmounts() 403 | 404 | # Deposit 405 | tx = vault.deposit(amount0Desired, amount1Desired, 0, 0, user, {"from": user}) 406 | shares, amount0Deposit, amount1Deposit = tx.return_value 407 | 408 | # Rebalance 409 | strategy.rebalance({"from": keeper}) 410 | 411 | # Withdraw all 412 | tx = vault.withdraw(shares, 0, 0, user, {"from": user}) 413 | amount0Withdraw, amount1Withdraw = tx.return_value 414 | total0After, total1After = vault.getTotalAmounts() 415 | 416 | assert not (amount0Deposit < amount0Withdraw and amount1Deposit <= amount1Withdraw) 417 | assert not (amount0Deposit <= amount0Withdraw and amount1Deposit < amount1Withdraw) 418 | 419 | assert total0 <= total0After + 2 420 | assert total1 <= total1After + 2 421 | assert approx(total0) == total0After 422 | assert approx(total1) == total1After 423 | -------------------------------------------------------------------------------- /contracts/AlphaVault.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense 2 | 3 | pragma solidity 0.7.6; 4 | 5 | import "@openzeppelin/contracts/math/Math.sol"; 6 | import "@openzeppelin/contracts/math/SafeMath.sol"; 7 | import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; 8 | import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; 9 | import "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; 10 | import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; 11 | import "@uniswap/v3-core/contracts/interfaces/callback/IUniswapV3MintCallback.sol"; 12 | import "@uniswap/v3-core/contracts/interfaces/callback/IUniswapV3SwapCallback.sol"; 13 | import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; 14 | import "@uniswap/v3-core/contracts/libraries/TickMath.sol"; 15 | import "@uniswap/v3-periphery/contracts/libraries/LiquidityAmounts.sol"; 16 | import "@uniswap/v3-periphery/contracts/libraries/PositionKey.sol"; 17 | 18 | import "../interfaces/IVault.sol"; 19 | 20 | /** 21 | * @title Alpha Vault 22 | * @notice A vault that provides liquidity on Uniswap V3. 23 | */ 24 | contract AlphaVault is 25 | IVault, 26 | IUniswapV3MintCallback, 27 | IUniswapV3SwapCallback, 28 | ERC20, 29 | ReentrancyGuard 30 | { 31 | using SafeERC20 for IERC20; 32 | using SafeMath for uint256; 33 | 34 | event Deposit( 35 | address indexed sender, 36 | address indexed to, 37 | uint256 shares, 38 | uint256 amount0, 39 | uint256 amount1 40 | ); 41 | 42 | event Withdraw( 43 | address indexed sender, 44 | address indexed to, 45 | uint256 shares, 46 | uint256 amount0, 47 | uint256 amount1 48 | ); 49 | 50 | event CollectFees( 51 | uint256 feesToVault0, 52 | uint256 feesToVault1, 53 | uint256 feesToProtocol0, 54 | uint256 feesToProtocol1 55 | ); 56 | 57 | event Snapshot(int24 tick, uint256 totalAmount0, uint256 totalAmount1, uint256 totalSupply); 58 | 59 | IUniswapV3Pool public immutable pool; 60 | IERC20 public immutable token0; 61 | IERC20 public immutable token1; 62 | int24 public immutable tickSpacing; 63 | 64 | uint256 public protocolFee; 65 | uint256 public maxTotalSupply; 66 | address public strategy; 67 | address public governance; 68 | address public pendingGovernance; 69 | 70 | int24 public baseLower; 71 | int24 public baseUpper; 72 | int24 public limitLower; 73 | int24 public limitUpper; 74 | uint256 public accruedProtocolFees0; 75 | uint256 public accruedProtocolFees1; 76 | 77 | /** 78 | * @dev After deploying, strategy needs to be set via `setStrategy()` 79 | * @param _pool Underlying Uniswap V3 pool 80 | * @param _protocolFee Protocol fee expressed as multiple of 1e-6 81 | * @param _maxTotalSupply Cap on total supply 82 | */ 83 | constructor( 84 | address _pool, 85 | uint256 _protocolFee, 86 | uint256 _maxTotalSupply 87 | ) ERC20("Alpha Vault", "AV") { 88 | pool = IUniswapV3Pool(_pool); 89 | token0 = IERC20(IUniswapV3Pool(_pool).token0()); 90 | token1 = IERC20(IUniswapV3Pool(_pool).token1()); 91 | tickSpacing = IUniswapV3Pool(_pool).tickSpacing(); 92 | 93 | protocolFee = _protocolFee; 94 | maxTotalSupply = _maxTotalSupply; 95 | governance = msg.sender; 96 | 97 | require(_protocolFee < 1e6, "protocolFee"); 98 | } 99 | 100 | /** 101 | * @notice Deposits tokens in proportion to the vault's current holdings. 102 | * @dev These tokens sit in the vault and are not used for liquidity on 103 | * Uniswap until the next rebalance. Also note it's not necessary to check 104 | * if user manipulated price to deposit cheaper, as the value of range 105 | * orders can only by manipulated higher. 106 | * @param amount0Desired Max amount of token0 to deposit 107 | * @param amount1Desired Max amount of token1 to deposit 108 | * @param amount0Min Revert if resulting `amount0` is less than this 109 | * @param amount1Min Revert if resulting `amount1` is less than this 110 | * @param to Recipient of shares 111 | * @return shares Number of shares minted 112 | * @return amount0 Amount of token0 deposited 113 | * @return amount1 Amount of token1 deposited 114 | */ 115 | function deposit( 116 | uint256 amount0Desired, 117 | uint256 amount1Desired, 118 | uint256 amount0Min, 119 | uint256 amount1Min, 120 | address to 121 | ) 122 | external 123 | override 124 | nonReentrant 125 | returns ( 126 | uint256 shares, 127 | uint256 amount0, 128 | uint256 amount1 129 | ) 130 | { 131 | require(amount0Desired > 0 || amount1Desired > 0, "amount0Desired or amount1Desired"); 132 | require(to != address(0) && to != address(this), "to"); 133 | 134 | // Poke positions so vault's current holdings are up-to-date 135 | _poke(baseLower, baseUpper); 136 | _poke(limitLower, limitUpper); 137 | 138 | // Calculate amounts proportional to vault's holdings 139 | (shares, amount0, amount1) = _calcSharesAndAmounts(amount0Desired, amount1Desired); 140 | require(shares > 0, "shares"); 141 | require(amount0 >= amount0Min, "amount0Min"); 142 | require(amount1 >= amount1Min, "amount1Min"); 143 | 144 | // Pull in tokens from sender 145 | if (amount0 > 0) token0.safeTransferFrom(msg.sender, address(this), amount0); 146 | if (amount1 > 0) token1.safeTransferFrom(msg.sender, address(this), amount1); 147 | 148 | // Mint shares to recipient 149 | _mint(to, shares); 150 | emit Deposit(msg.sender, to, shares, amount0, amount1); 151 | require(totalSupply() <= maxTotalSupply, "maxTotalSupply"); 152 | } 153 | 154 | /// @dev Do zero-burns to poke a position on Uniswap so earned fees are 155 | /// updated. Should be called if total amounts needs to include up-to-date 156 | /// fees. 157 | function _poke(int24 tickLower, int24 tickUpper) internal { 158 | (uint128 liquidity, , , , ) = _position(tickLower, tickUpper); 159 | if (liquidity > 0) { 160 | pool.burn(tickLower, tickUpper, 0); 161 | } 162 | } 163 | 164 | /// @dev Calculates the largest possible `amount0` and `amount1` such that 165 | /// they're in the same proportion as total amounts, but not greater than 166 | /// `amount0Desired` and `amount1Desired` respectively. 167 | function _calcSharesAndAmounts(uint256 amount0Desired, uint256 amount1Desired) 168 | internal 169 | view 170 | returns ( 171 | uint256 shares, 172 | uint256 amount0, 173 | uint256 amount1 174 | ) 175 | { 176 | uint256 totalSupply = totalSupply(); 177 | (uint256 total0, uint256 total1) = getTotalAmounts(); 178 | 179 | // If total supply > 0, vault can't be empty 180 | assert(totalSupply == 0 || total0 > 0 || total1 > 0); 181 | 182 | if (totalSupply == 0) { 183 | // For first deposit, just use the amounts desired 184 | amount0 = amount0Desired; 185 | amount1 = amount1Desired; 186 | shares = Math.max(amount0, amount1); 187 | } else if (total0 == 0) { 188 | amount1 = amount1Desired; 189 | shares = amount1.mul(totalSupply).div(total1); 190 | } else if (total1 == 0) { 191 | amount0 = amount0Desired; 192 | shares = amount0.mul(totalSupply).div(total0); 193 | } else { 194 | uint256 cross = Math.min(amount0Desired.mul(total1), amount1Desired.mul(total0)); 195 | require(cross > 0, "cross"); 196 | 197 | // Round up amounts 198 | amount0 = cross.sub(1).div(total1).add(1); 199 | amount1 = cross.sub(1).div(total0).add(1); 200 | shares = cross.mul(totalSupply).div(total0).div(total1); 201 | } 202 | } 203 | 204 | /** 205 | * @notice Withdraws tokens in proportion to the vault's holdings. 206 | * @param shares Shares burned by sender 207 | * @param amount0Min Revert if resulting `amount0` is smaller than this 208 | * @param amount1Min Revert if resulting `amount1` is smaller than this 209 | * @param to Recipient of tokens 210 | * @return amount0 Amount of token0 sent to recipient 211 | * @return amount1 Amount of token1 sent to recipient 212 | */ 213 | function withdraw( 214 | uint256 shares, 215 | uint256 amount0Min, 216 | uint256 amount1Min, 217 | address to 218 | ) external override nonReentrant returns (uint256 amount0, uint256 amount1) { 219 | require(shares > 0, "shares"); 220 | require(to != address(0) && to != address(this), "to"); 221 | uint256 totalSupply = totalSupply(); 222 | 223 | // Burn shares 224 | _burn(msg.sender, shares); 225 | 226 | // Calculate token amounts proportional to unused balances 227 | uint256 unusedAmount0 = getBalance0().mul(shares).div(totalSupply); 228 | uint256 unusedAmount1 = getBalance1().mul(shares).div(totalSupply); 229 | 230 | // Withdraw proportion of liquidity from Uniswap pool 231 | (uint256 baseAmount0, uint256 baseAmount1) = 232 | _burnLiquidityShare(baseLower, baseUpper, shares, totalSupply); 233 | (uint256 limitAmount0, uint256 limitAmount1) = 234 | _burnLiquidityShare(limitLower, limitUpper, shares, totalSupply); 235 | 236 | // Sum up total amounts owed to recipient 237 | amount0 = unusedAmount0.add(baseAmount0).add(limitAmount0); 238 | amount1 = unusedAmount1.add(baseAmount1).add(limitAmount1); 239 | require(amount0 >= amount0Min, "amount0Min"); 240 | require(amount1 >= amount1Min, "amount1Min"); 241 | 242 | // Push tokens to recipient 243 | if (amount0 > 0) token0.safeTransfer(to, amount0); 244 | if (amount1 > 0) token1.safeTransfer(to, amount1); 245 | 246 | emit Withdraw(msg.sender, to, shares, amount0, amount1); 247 | } 248 | 249 | /// @dev Withdraws share of liquidity in a range from Uniswap pool. 250 | function _burnLiquidityShare( 251 | int24 tickLower, 252 | int24 tickUpper, 253 | uint256 shares, 254 | uint256 totalSupply 255 | ) internal returns (uint256 amount0, uint256 amount1) { 256 | (uint128 totalLiquidity, , , , ) = _position(tickLower, tickUpper); 257 | uint256 liquidity = uint256(totalLiquidity).mul(shares).div(totalSupply); 258 | 259 | if (liquidity > 0) { 260 | (uint256 burned0, uint256 burned1, uint256 fees0, uint256 fees1) = 261 | _burnAndCollect(tickLower, tickUpper, _toUint128(liquidity)); 262 | 263 | // Add share of fees 264 | amount0 = burned0.add(fees0.mul(shares).div(totalSupply)); 265 | amount1 = burned1.add(fees1.mul(shares).div(totalSupply)); 266 | } 267 | } 268 | 269 | /** 270 | * @notice Updates vault's positions. Can only be called by the strategy. 271 | * @dev Two orders are placed - a base order and a limit order. The base 272 | * order is placed first with as much liquidity as possible. This order 273 | * should use up all of one token, leaving only the other one. This excess 274 | * amount is then placed as a single-sided bid or ask order. 275 | */ 276 | function rebalance( 277 | int256 swapAmount, 278 | uint160 sqrtPriceLimitX96, 279 | int24 _baseLower, 280 | int24 _baseUpper, 281 | int24 _bidLower, 282 | int24 _bidUpper, 283 | int24 _askLower, 284 | int24 _askUpper 285 | ) external nonReentrant { 286 | require(msg.sender == strategy, "strategy"); 287 | _checkRange(_baseLower, _baseUpper); 288 | _checkRange(_bidLower, _bidUpper); 289 | _checkRange(_askLower, _askUpper); 290 | 291 | (, int24 tick, , , , , ) = pool.slot0(); 292 | require(_bidUpper <= tick, "bidUpper"); 293 | require(_askLower > tick, "askLower"); // inequality is strict as tick is rounded down 294 | 295 | // Withdraw all current liquidity from Uniswap pool 296 | { 297 | (uint128 baseLiquidity, , , , ) = _position(baseLower, baseUpper); 298 | (uint128 limitLiquidity, , , , ) = _position(limitLower, limitUpper); 299 | _burnAndCollect(baseLower, baseUpper, baseLiquidity); 300 | _burnAndCollect(limitLower, limitUpper, limitLiquidity); 301 | } 302 | 303 | // Emit snapshot to record balances and supply 304 | uint256 balance0 = getBalance0(); 305 | uint256 balance1 = getBalance1(); 306 | emit Snapshot(tick, balance0, balance1, totalSupply()); 307 | 308 | if (swapAmount != 0) { 309 | pool.swap( 310 | address(this), 311 | swapAmount > 0, 312 | swapAmount > 0 ? swapAmount : -swapAmount, 313 | sqrtPriceLimitX96, 314 | "" 315 | ); 316 | balance0 = getBalance0(); 317 | balance1 = getBalance1(); 318 | } 319 | 320 | // Place base order on Uniswap 321 | uint128 liquidity = _liquidityForAmounts(_baseLower, _baseUpper, balance0, balance1); 322 | _mintLiquidity(_baseLower, _baseUpper, liquidity); 323 | (baseLower, baseUpper) = (_baseLower, _baseUpper); 324 | 325 | balance0 = getBalance0(); 326 | balance1 = getBalance1(); 327 | 328 | // Place bid or ask order on Uniswap depending on which token is left 329 | uint128 bidLiquidity = _liquidityForAmounts(_bidLower, _bidUpper, balance0, balance1); 330 | uint128 askLiquidity = _liquidityForAmounts(_askLower, _askUpper, balance0, balance1); 331 | if (bidLiquidity > askLiquidity) { 332 | _mintLiquidity(_bidLower, _bidUpper, bidLiquidity); 333 | (limitLower, limitUpper) = (_bidLower, _bidUpper); 334 | } else { 335 | _mintLiquidity(_askLower, _askUpper, askLiquidity); 336 | (limitLower, limitUpper) = (_askLower, _askUpper); 337 | } 338 | } 339 | 340 | function _checkRange(int24 tickLower, int24 tickUpper) internal view { 341 | int24 _tickSpacing = tickSpacing; 342 | require(tickLower < tickUpper, "tickLower < tickUpper"); 343 | require(tickLower >= TickMath.MIN_TICK, "tickLower too low"); 344 | require(tickUpper <= TickMath.MAX_TICK, "tickUpper too high"); 345 | require(tickLower % _tickSpacing == 0, "tickLower % tickSpacing"); 346 | require(tickUpper % _tickSpacing == 0, "tickUpper % tickSpacing"); 347 | } 348 | 349 | /// @dev Withdraws liquidity from a range and collects all fees in the 350 | /// process. 351 | function _burnAndCollect( 352 | int24 tickLower, 353 | int24 tickUpper, 354 | uint128 liquidity 355 | ) 356 | internal 357 | returns ( 358 | uint256 burned0, 359 | uint256 burned1, 360 | uint256 feesToVault0, 361 | uint256 feesToVault1 362 | ) 363 | { 364 | if (liquidity > 0) { 365 | (burned0, burned1) = pool.burn(tickLower, tickUpper, liquidity); 366 | } 367 | 368 | // Collect all owed tokens including earned fees 369 | (uint256 collect0, uint256 collect1) = 370 | pool.collect( 371 | address(this), 372 | tickLower, 373 | tickUpper, 374 | type(uint128).max, 375 | type(uint128).max 376 | ); 377 | 378 | feesToVault0 = collect0.sub(burned0); 379 | feesToVault1 = collect1.sub(burned1); 380 | uint256 feesToProtocol0; 381 | uint256 feesToProtocol1; 382 | 383 | // Update accrued protocol fees 384 | uint256 _protocolFee = protocolFee; 385 | if (_protocolFee > 0) { 386 | feesToProtocol0 = feesToVault0.mul(_protocolFee).div(1e6); 387 | feesToProtocol1 = feesToVault1.mul(_protocolFee).div(1e6); 388 | feesToVault0 = feesToVault0.sub(feesToProtocol0); 389 | feesToVault1 = feesToVault1.sub(feesToProtocol1); 390 | accruedProtocolFees0 = accruedProtocolFees0.add(feesToProtocol0); 391 | accruedProtocolFees1 = accruedProtocolFees1.add(feesToProtocol1); 392 | } 393 | emit CollectFees(feesToVault0, feesToVault1, feesToProtocol0, feesToProtocol1); 394 | } 395 | 396 | /// @dev Deposits liquidity in a range on the Uniswap pool. 397 | function _mintLiquidity( 398 | int24 tickLower, 399 | int24 tickUpper, 400 | uint128 liquidity 401 | ) internal { 402 | if (liquidity > 0) { 403 | pool.mint(address(this), tickLower, tickUpper, liquidity, ""); 404 | } 405 | } 406 | 407 | /** 408 | * @notice Calculates the vault's total holdings of token0 and token1 - in 409 | * other words, how much of each token the vault would hold if it withdrew 410 | * all its liquidity from Uniswap. 411 | */ 412 | function getTotalAmounts() public view override returns (uint256 total0, uint256 total1) { 413 | (uint256 baseAmount0, uint256 baseAmount1) = getPositionAmounts(baseLower, baseUpper); 414 | (uint256 limitAmount0, uint256 limitAmount1) = 415 | getPositionAmounts(limitLower, limitUpper); 416 | total0 = getBalance0().add(baseAmount0).add(limitAmount0); 417 | total1 = getBalance1().add(baseAmount1).add(limitAmount1); 418 | } 419 | 420 | /** 421 | * @notice Amounts of token0 and token1 held in vault's position. Includes 422 | * owed fees but excludes the proportion of fees that will be paid to the 423 | * protocol. Doesn't include fees accrued since last poke. 424 | */ 425 | function getPositionAmounts(int24 tickLower, int24 tickUpper) 426 | public 427 | view 428 | returns (uint256 amount0, uint256 amount1) 429 | { 430 | (uint128 liquidity, , , uint128 tokensOwed0, uint128 tokensOwed1) = 431 | _position(tickLower, tickUpper); 432 | (amount0, amount1) = _amountsForLiquidity(tickLower, tickUpper, liquidity); 433 | 434 | // Subtract protocol fees 435 | uint256 oneMinusFee = uint256(1e6).sub(protocolFee); 436 | amount0 = amount0.add(uint256(tokensOwed0).mul(oneMinusFee).div(1e6)); 437 | amount1 = amount1.add(uint256(tokensOwed1).mul(oneMinusFee).div(1e6)); 438 | } 439 | 440 | /** 441 | * @notice Balance of token0 in vault not used in any position. 442 | */ 443 | function getBalance0() public view returns (uint256) { 444 | return token0.balanceOf(address(this)).sub(accruedProtocolFees0); 445 | } 446 | 447 | /** 448 | * @notice Balance of token1 in vault not used in any position. 449 | */ 450 | function getBalance1() public view returns (uint256) { 451 | return token1.balanceOf(address(this)).sub(accruedProtocolFees1); 452 | } 453 | 454 | /// @dev Wrapper around `IUniswapV3Pool.positions()`. 455 | function _position(int24 tickLower, int24 tickUpper) 456 | internal 457 | view 458 | returns ( 459 | uint128, 460 | uint256, 461 | uint256, 462 | uint128, 463 | uint128 464 | ) 465 | { 466 | bytes32 positionKey = PositionKey.compute(address(this), tickLower, tickUpper); 467 | return pool.positions(positionKey); 468 | } 469 | 470 | /// @dev Wrapper around `LiquidityAmounts.getAmountsForLiquidity()`. 471 | function _amountsForLiquidity( 472 | int24 tickLower, 473 | int24 tickUpper, 474 | uint128 liquidity 475 | ) internal view returns (uint256, uint256) { 476 | (uint160 sqrtRatioX96, , , , , , ) = pool.slot0(); 477 | return 478 | LiquidityAmounts.getAmountsForLiquidity( 479 | sqrtRatioX96, 480 | TickMath.getSqrtRatioAtTick(tickLower), 481 | TickMath.getSqrtRatioAtTick(tickUpper), 482 | liquidity 483 | ); 484 | } 485 | 486 | /// @dev Wrapper around `LiquidityAmounts.getLiquidityForAmounts()`. 487 | function _liquidityForAmounts( 488 | int24 tickLower, 489 | int24 tickUpper, 490 | uint256 amount0, 491 | uint256 amount1 492 | ) internal view returns (uint128) { 493 | (uint160 sqrtRatioX96, , , , , , ) = pool.slot0(); 494 | return 495 | LiquidityAmounts.getLiquidityForAmounts( 496 | sqrtRatioX96, 497 | TickMath.getSqrtRatioAtTick(tickLower), 498 | TickMath.getSqrtRatioAtTick(tickUpper), 499 | amount0, 500 | amount1 501 | ); 502 | } 503 | 504 | /// @dev Casts uint256 to uint128 with overflow check. 505 | function _toUint128(uint256 x) internal pure returns (uint128) { 506 | assert(x <= type(uint128).max); 507 | return uint128(x); 508 | } 509 | 510 | /// @dev Callback for Uniswap V3 pool. 511 | function uniswapV3MintCallback( 512 | uint256 amount0, 513 | uint256 amount1, 514 | bytes calldata data 515 | ) external override { 516 | require(msg.sender == address(pool)); 517 | if (amount0 > 0) token0.safeTransfer(msg.sender, amount0); 518 | if (amount1 > 0) token1.safeTransfer(msg.sender, amount1); 519 | } 520 | 521 | /// @dev Callback for Uniswap V3 pool. 522 | function uniswapV3SwapCallback( 523 | int256 amount0Delta, 524 | int256 amount1Delta, 525 | bytes calldata data 526 | ) external override { 527 | require(msg.sender == address(pool)); 528 | if (amount0Delta > 0) token0.safeTransfer(msg.sender, uint256(amount0Delta)); 529 | if (amount1Delta > 0) token1.safeTransfer(msg.sender, uint256(amount1Delta)); 530 | } 531 | 532 | /** 533 | * @notice Used to collect accumulated protocol fees. 534 | */ 535 | function collectProtocol( 536 | uint256 amount0, 537 | uint256 amount1, 538 | address to 539 | ) external onlyGovernance { 540 | accruedProtocolFees0 = accruedProtocolFees0.sub(amount0); 541 | accruedProtocolFees1 = accruedProtocolFees1.sub(amount1); 542 | if (amount0 > 0) token0.safeTransfer(to, amount0); 543 | if (amount1 > 0) token1.safeTransfer(to, amount1); 544 | } 545 | 546 | /** 547 | * @notice Removes tokens accidentally sent to this vault. 548 | */ 549 | function sweep( 550 | IERC20 token, 551 | uint256 amount, 552 | address to 553 | ) external onlyGovernance { 554 | require(token != token0 && token != token1, "token"); 555 | token.safeTransfer(to, amount); 556 | } 557 | 558 | /** 559 | * @notice Used to set the strategy contract that determines the position 560 | * ranges and calls rebalance(). Must be called after this vault is 561 | * deployed. 562 | */ 563 | function setStrategy(address _strategy) external onlyGovernance { 564 | strategy = _strategy; 565 | } 566 | 567 | /** 568 | * @notice Used to change the protocol fee charged on pool fees earned from 569 | * Uniswap, expressed as multiple of 1e-6. 570 | */ 571 | function setProtocolFee(uint256 _protocolFee) external onlyGovernance { 572 | require(_protocolFee < 1e6, "protocolFee"); 573 | protocolFee = _protocolFee; 574 | } 575 | 576 | /** 577 | * @notice Used to change deposit cap for a guarded launch or to ensure 578 | * vault doesn't grow too large relative to the pool. Cap is on total 579 | * supply rather than amounts of token0 and token1 as those amounts 580 | * fluctuate naturally over time. 581 | */ 582 | function setMaxTotalSupply(uint256 _maxTotalSupply) external onlyGovernance { 583 | maxTotalSupply = _maxTotalSupply; 584 | } 585 | 586 | /** 587 | * @notice Removes liquidity in case of emergency. 588 | */ 589 | function emergencyBurn( 590 | int24 tickLower, 591 | int24 tickUpper, 592 | uint128 liquidity 593 | ) external onlyGovernance { 594 | pool.burn(tickLower, tickUpper, liquidity); 595 | pool.collect(address(this), tickLower, tickUpper, type(uint128).max, type(uint128).max); 596 | } 597 | 598 | /** 599 | * @notice Governance address is not updated until the new governance 600 | * address has called `acceptGovernance()` to accept this responsibility. 601 | */ 602 | function setGovernance(address _governance) external onlyGovernance { 603 | pendingGovernance = _governance; 604 | } 605 | 606 | /** 607 | * @notice `setGovernance()` should be called by the existing governance 608 | * address prior to calling this function. 609 | */ 610 | function acceptGovernance() external { 611 | require(msg.sender == pendingGovernance, "pendingGovernance"); 612 | governance = msg.sender; 613 | } 614 | 615 | modifier onlyGovernance { 616 | require(msg.sender == governance, "governance"); 617 | _; 618 | } 619 | } 620 | --------------------------------------------------------------------------------