diff --git a/config/ContractNames.sol b/config/ContractNames.sol index 440a6c9..f4337c6 100644 --- a/config/ContractNames.sol +++ b/config/ContractNames.sol @@ -9,6 +9,7 @@ contract ContractNames { string public constant LevelUsdReserveName = "Level USD Reserve V0.0"; string public constant LevelUsdReserveManagerName = "Level USD Reserve Manager V0.0"; string public constant LevelUsdRewardsManagerName = "Level USD Rewards Manager V0.0"; + string public constant LevelUsdSwapManagerName = "Level USD Swap Manager V0.0"; string public constant LevelUsdReserveDeployer = "Level USD Reserve Deployer V0.0"; string public constant LevelERC4626OracleFactoryName = "Level ERC4626 Oracle Factory V0.0"; diff --git a/config/deploy/BaseConfig.sol b/config/deploy/BaseConfig.sol index 1eb2d25..f307ae1 100644 --- a/config/deploy/BaseConfig.sol +++ b/config/deploy/BaseConfig.sol @@ -12,18 +12,22 @@ import {stdJson} from "forge-std/StdJson.sol"; import {console2} from "forge-std/console2.sol"; import {IPool} from "@level/src/v2/interfaces/aave/IPool.sol"; +import {IRedemption} from "@level/src/v2/interfaces/superstate/IRedemption.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {ERC4626OracleFactory} from "@level/src/v2/oracles/ERC4626OracleFactory.sol"; import {IMetaMorpho} from "@level/src/v2/interfaces/morpho/IMetaMorpho.sol"; import {IMetaMorphoV1_1} from "@level/src/v2/interfaces/morpho/IMetaMorphoV1_1.sol"; import {IERC4626Oracle} from "@level/src/v2/interfaces/level/IERC4626Oracle.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {AggregatorV3Interface} from "@level/src/v2/interfaces/AggregatorV3Interface.sol"; import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol"; import {PauserGuard} from "@level/src/v2/common/guard/PauserGuard.sol"; import {StrictRolesAuthority} from "@level/src/v2/auth/StrictRolesAuthority.sol"; import {LevelReserveLens} from "@level/src/v2/lens/LevelReserveLens.sol"; import {LevelReserveLensMorphoOracle} from "@level/src/v1/lens/LevelReserveLensMorphoOracle.sol"; +import {ISwapRouter} from "@level/src/v2/interfaces/uniswap/ISwapRouter.sol"; +import {SwapManager} from "@level/src/v2/usd/SwapManager.sol"; contract BaseConfig { using stdJson for string; @@ -35,6 +39,8 @@ contract BaseConfig { LevelContracts levelContracts; PeripheryContracts periphery; MorphoVaults morphoVaults; + SparkVaults sparkVaults; + UmbrellaVaults umbrellaVaults; Oracles oracles; } @@ -45,6 +51,21 @@ contract BaseConfig { ERC20 slvlUsd; ERC20 aUsdc; ERC20 aUsdt; + ERC20 ustb; + ERC20 wrappedM; + } + + struct UmbrellaVaults { + ERC4626Vault waUsdcStakeToken; + } + + struct SparkVaults { + ERC4626Vault sUsdc; + } + + struct ERC4626Vault { + IERC4626 vault; + IERC4626Oracle oracle; } struct Users { @@ -65,6 +86,7 @@ contract BaseConfig { ERC4626OracleFactory erc4626OracleFactory; PauserGuard pauserGuard; LevelReserveLens levelReserveLens; + SwapManager swapManager; } struct MorphoVaults { @@ -88,12 +110,17 @@ contract BaseConfig { IPool aaveV3; IMulticall3 multicall3; LevelReserveLensMorphoOracle levelReserveLensMorphoOracle; + IRedemption ustbRedemptionIdle; + ISwapRouter uniswapV3Router; } struct Oracles { AggregatorV3Interface usdc; AggregatorV3Interface usdt; AggregatorV3Interface ustb; + AggregatorV3Interface aUsdt; + AggregatorV3Interface aUsdc; + AggregatorV3Interface mNav; } Config public config; diff --git a/config/deploy/Mainnet.sol b/config/deploy/Mainnet.sol index 43c6f22..a9b06f5 100644 --- a/config/deploy/Mainnet.sol +++ b/config/deploy/Mainnet.sol @@ -11,6 +11,7 @@ import {ERC20} from "@solmate/src/tokens/ERC20.sol"; import {IPool} from "@level/src/v2/interfaces/aave/IPool.sol"; import {ERC4626OracleFactory} from "@level/src/v2/oracles/ERC4626OracleFactory.sol"; import {IERC4626Oracle} from "@level/src/v2/interfaces/level/IERC4626Oracle.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {IMetaMorpho} from "@level/src/v2/interfaces/morpho/IMetaMorpho.sol"; import {IMetaMorphoV1_1} from "@level/src/v2/interfaces/morpho/IMetaMorphoV1_1.sol"; @@ -21,6 +22,9 @@ import {PauserGuard} from "@level/src/v2/common/guard/PauserGuard.sol"; import {StrictRolesAuthority} from "@level/src/v2/auth/StrictRolesAuthority.sol"; import {LevelReserveLens} from "@level/src/v2/lens/LevelReserveLens.sol"; import {LevelReserveLensMorphoOracle} from "@level/src/v1/lens/LevelReserveLensMorphoOracle.sol"; +import {IRedemption} from "@level/src/v2/interfaces/superstate/IRedemption.sol"; +import {ISwapRouter} from "@level/src/v2/interfaces/uniswap/ISwapRouter.sol"; +import {SwapManager} from "@level/src/v2/usd/SwapManager.sol"; contract Mainnet is BaseConfig { uint256 public constant chainId = 1; @@ -38,12 +42,17 @@ contract Mainnet is BaseConfig { lvlUsd: ERC20(0x7C1156E515aA1A2E851674120074968C905aAF37), slvlUsd: ERC20(0x4737D9b4592B40d51e110b94c9C043c6654067Ae), aUsdc: ERC20(0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c), - aUsdt: ERC20(0x23878914EFE38d27C4D67Ab83ed1b93A74D4086a) + aUsdt: ERC20(0x23878914EFE38d27C4D67Ab83ed1b93A74D4086a), + ustb: ERC20(0x43415eB6ff9DB7E26A15b704e7A3eDCe97d31C4e), + wrappedM: ERC20(0x437cc33344a0B27A429f795ff6B469C72698B291) }), oracles: Oracles({ usdc: AggregatorV3Interface(0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6), usdt: AggregatorV3Interface(0x3E7d1eAB13ad0104d2750B8863b489D65364e32D), - ustb: AggregatorV3Interface(0x289B5036cd942e619E1Ee48670F98d214E745AAC) + ustb: AggregatorV3Interface(0x289B5036cd942e619E1Ee48670F98d214E745AAC), + aUsdt: AggregatorV3Interface(0x380adC857Cd3d0531C0821B5D52F34737C4eCDC4), + aUsdc: AggregatorV3Interface(0x95CCDE4C1bb3d56639d22185aa2f95EcfebD7F22), + mNav: AggregatorV3Interface(0xC28198Df9aee1c4990994B35ff51eFA4C769e534) }), users: Users({ admin: 0x343ACce723339D5A417411D8Ff57fde8886E91dc, @@ -53,20 +62,21 @@ contract Mainnet is BaseConfig { hexagateGatekeepers: hexagateGatekeepers }), levelContracts: LevelContracts({ - rolesAuthority: StrictRolesAuthority(address(0)), - levelMintingV2: LevelMintingV2(address(0)), - boringVault: BoringVault(payable(address(0))), - vaultManager: VaultManager(address(0)), - rewardsManager: RewardsManager(address(0)), + rolesAuthority: StrictRolesAuthority(0xc8425ACE617acA1dDcB09Cb7784b67403440098A), + levelMintingV2: LevelMintingV2(0x9136aB0294986267b71BeED86A75eeb3336d09E1), + boringVault: BoringVault(payable(0x834D9c7688ca1C10479931dE906bCC44879A0446)), + vaultManager: VaultManager(0x5f432430C515964C299bb4F277CdAb0fCC074E25), + rewardsManager: RewardsManager(0xBD05B8B22fE4ccf093a6206C63Cc39f02345E0DA), adminTimelock: TimelockController(payable(0x0798880E772009DDf6eF062F2Ef32c738119d086)), - erc4626OracleFactory: ERC4626OracleFactory(address(0)), - pauserGuard: PauserGuard(address(0)), - levelReserveLens: LevelReserveLens(0x29759944834e08acE755dcEA71491413f7e2CBAD) + erc4626OracleFactory: ERC4626OracleFactory(0xe0eEe186FD22485c2aDA2Eb3fc77d34D2Ae3Abd2), + pauserGuard: PauserGuard(0x9f3328E60Cb9418dBde038B54d588dFEA2C0B6f9), + levelReserveLens: LevelReserveLens(0x29759944834e08acE755dcEA71491413f7e2CBAD), // update impl to: 0xF56c770c4E021848ac7D1DB67D48AA1B4b56e02f + swapManager: SwapManager(address(0)) }), morphoVaults: MorphoVaults({ steakhouseUsdc: MetaMorphoVault({ vault: IMetaMorpho(0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB), - oracle: IERC4626Oracle(address(0)) + oracle: IERC4626Oracle(0x9E42af55431E15fb25615a9E57B028117f0Bee5a) }), steakhouseUsdt: MetaMorphoVault({ vault: IMetaMorpho(0xbEef047a543E45807105E51A8BBEFCc5950fcfBa), @@ -81,10 +91,24 @@ contract Mainnet is BaseConfig { oracle: IERC4626Oracle(address(0)) }) }), + sparkVaults: SparkVaults({ + sUsdc: ERC4626Vault({ + vault: IERC4626(0xBc65ad17c5C0a2A4D159fa5a503f4992c7B545FE), + oracle: IERC4626Oracle(address(0)) + }) + }), + umbrellaVaults: UmbrellaVaults({ + waUsdcStakeToken: ERC4626Vault({ + vault: IERC4626(0x6bf183243FdD1e306ad2C4450BC7dcf6f0bf8Aa6), + oracle: IERC4626Oracle(address(0)) + }) + }), periphery: PeripheryContracts({ aaveV3: IPool(0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2), multicall3: IMulticall3(0xcA11bde05977b3631167028862bE2a173976CA11), - levelReserveLensMorphoOracle: LevelReserveLensMorphoOracle(0x625bB4f5133Ff9F6d43e21F15add35BE46387903) + levelReserveLensMorphoOracle: LevelReserveLensMorphoOracle(0x625bB4f5133Ff9F6d43e21F15add35BE46387903), + ustbRedemptionIdle: IRedemption(0x4c21B7577C8FE8b0B0669165ee7C8f67fa1454Cf), + uniswapV3Router: ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564) }) }); diff --git a/config/deploy/Sepolia.sol b/config/deploy/Sepolia.sol index a9b9dd7..49ef3fd 100644 --- a/config/deploy/Sepolia.sol +++ b/config/deploy/Sepolia.sol @@ -11,6 +11,7 @@ import {ERC20} from "@solmate/src/tokens/ERC20.sol"; import {IPool} from "@level/src/v2/interfaces/aave/IPool.sol"; import {ERC4626OracleFactory} from "@level/src/v2/oracles/ERC4626OracleFactory.sol"; import {IERC4626Oracle} from "@level/src/v2/interfaces/level/IERC4626Oracle.sol"; +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {IMetaMorpho} from "@level/src/v2/interfaces/morpho/IMetaMorpho.sol"; import {IMetaMorphoV1_1} from "@level/src/v2/interfaces/morpho/IMetaMorphoV1_1.sol"; @@ -22,6 +23,9 @@ import {StrictRolesAuthority} from "@level/src/v2/auth/StrictRolesAuthority.sol" import {LevelReserveLens} from "@level/src/v2/lens/LevelReserveLens.sol"; import {LevelReserveLensMorphoOracle} from "@level/src/v1/lens/LevelReserveLensMorphoOracle.sol"; +import {IRedemption} from "@level/src/v2/interfaces/superstate/IRedemption.sol"; +import {ISwapRouter} from "@level/src/v2/interfaces/uniswap/ISwapRouter.sol"; +import {SwapManager} from "@level/src/v2/usd/SwapManager.sol"; contract Sepolia is BaseConfig { uint256 public constant chainId = 11155111; @@ -38,12 +42,17 @@ contract Sepolia is BaseConfig { lvlUsd: ERC20(0xd770C092e4AcA4Cdb187829C350062C43F6f79EB), slvlUsd: ERC20(0xeFE4aB4013beca790A957e12330C7283AB97a047), aUsdc: ERC20(address(0)), - aUsdt: ERC20(address(0)) + aUsdt: ERC20(address(0)), + ustb: ERC20(0x39727692cF58137Bd8c401eFE87Cc8A190D62ead), + wrappedM: ERC20(address(0)) }), oracles: Oracles({ usdc: AggregatorV3Interface(0xA2F78ab2355fe2f984D808B5CeE7FD0A93D5270E), usdt: AggregatorV3Interface(address(0)), - ustb: AggregatorV3Interface(address(0)) + ustb: AggregatorV3Interface(0x732d3C7515356eAB22E3F3DcA183c5c65102d518), + aUsdc: AggregatorV3Interface(address(0)), + aUsdt: AggregatorV3Interface(address(0)), + mNav: AggregatorV3Interface(address(0)) }), users: Users({ admin: 0xb2522DC238DEA8a821dEcE38a1d46eC5C4708256, @@ -61,7 +70,8 @@ contract Sepolia is BaseConfig { adminTimelock: TimelockController(payable(0x980bF41Dc21fA48BE87a421002c18a6c803d480C)), erc4626OracleFactory: ERC4626OracleFactory(0xe9D32Aade0228A8de8E54b48b8020DA2907449fb), pauserGuard: PauserGuard(0xABf29A4a281f6ea06883DedeA962127f9b0621f9), - levelReserveLens: LevelReserveLens(address(0)) + levelReserveLens: LevelReserveLens(address(0)), + swapManager: SwapManager(address(0)) }), morphoVaults: MorphoVaults({ steakhouseUsdc: MetaMorphoVault({ @@ -72,10 +82,18 @@ contract Sepolia is BaseConfig { re7Usdc: MetaMorphoV1_1Vault({vault: IMetaMorphoV1_1(address(0)), oracle: IERC4626Oracle(address(0))}), steakhouseUsdtLite: MetaMorphoV1_1Vault({vault: IMetaMorphoV1_1(address(0)), oracle: IERC4626Oracle(address(0))}) }), + sparkVaults: SparkVaults({ + sUsdc: ERC4626Vault({vault: IERC4626(address(0)), oracle: IERC4626Oracle(address(0))}) + }), + umbrellaVaults: UmbrellaVaults({ + waUsdcStakeToken: ERC4626Vault({vault: IERC4626(address(0)), oracle: IERC4626Oracle(address(0))}) + }), periphery: PeripheryContracts({ aaveV3: IPool(0x6Ae43d3271ff6888e7Fc43Fd7321a503ff738951), multicall3: IMulticall3(0xcA11bde05977b3631167028862bE2a173976CA11), - levelReserveLensMorphoOracle: LevelReserveLensMorphoOracle(address(0)) + levelReserveLensMorphoOracle: LevelReserveLensMorphoOracle(address(0)), + ustbRedemptionIdle: IRedemption(0xd33d340CdbEf8E879C827199BD7D9705b21e18c9), + uniswapV3Router: ISwapRouter(address(0)) }) }); diff --git a/node_modules/.bin/openzeppelin-upgrades-core b/node_modules/.bin/openzeppelin-upgrades-core deleted file mode 100755 index ad7a018..0000000 --- a/node_modules/.bin/openzeppelin-upgrades-core +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") - -case `uname` in - *CYGWIN*) basedir=`cygpath -w "$basedir"`;; -esac - -if [ -z "$NODE_PATH" ]; then - export NODE_PATH="/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules/@openzeppelin/upgrades-core/dist/cli/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules/@openzeppelin/upgrades-core/dist/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules/@openzeppelin/upgrades-core/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules/@openzeppelin/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/node_modules" -else - export NODE_PATH="/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules/@openzeppelin/upgrades-core/dist/cli/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules/@openzeppelin/upgrades-core/dist/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules/@openzeppelin/upgrades-core/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules/@openzeppelin/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules:/Users/davidlee/dev/peregrine/level/monorepo/node_modules/.pnpm/node_modules:$NODE_PATH" -fi -if [ -x "$basedir/node" ]; then - exec "$basedir/node" "$basedir/../@openzeppelin/upgrades-core/dist/cli/cli.js" "$@" -else - exec node "$basedir/../@openzeppelin/upgrades-core/dist/cli/cli.js" "$@" -fi diff --git a/node_modules/@openzeppelin/upgrades-core b/node_modules/@openzeppelin/upgrades-core deleted file mode 120000 index 5b9ed83..0000000 --- a/node_modules/@openzeppelin/upgrades-core +++ /dev/null @@ -1 +0,0 @@ -../../../../node_modules/.pnpm/@openzeppelin+upgrades-core@1.42.1/node_modules/@openzeppelin/upgrades-core \ No newline at end of file diff --git a/remappings.txt b/remappings.txt index 1e2a541..7142ee8 100644 --- a/remappings.txt +++ b/remappings.txt @@ -8,4 +8,5 @@ forge-std/=lib/forge-std/src/ @openzeppelin-4.9.0/contracts/=lib/openzeppelin-contracts-4.9.0/contracts/ @openzeppelin-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ @solmate=lib/solmate -@openzeppelin-upgrades/=lib/openzeppelin-foundry-upgrades/ \ No newline at end of file +@openzeppelin-upgrades/=lib/openzeppelin-foundry-upgrades/ +@uniswap-v3-core/=lib/v3-core/contracts/ \ No newline at end of file diff --git a/script/v2/DeployLevel.s.sol b/script/v2/DeployLevel.s.sol index 9858ee0..5348cb8 100644 --- a/script/v2/DeployLevel.s.sol +++ b/script/v2/DeployLevel.s.sol @@ -16,6 +16,7 @@ import {Script} from "forge-std/Script.sol"; import {Vm} from "forge-std/Vm.sol"; import {console2} from "forge-std/console2.sol"; import {AaveTokenOracle} from "@level/src/v2/oracles/AaveTokenOracle.sol"; +import {CappedOneDollarOracle} from "@level/src/v2/oracles/CappedOneDollarOracle.sol"; import {VaultManager} from "@level/src/v2/usd/VaultManager.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; @@ -32,6 +33,8 @@ import {AggregatorV3Interface} from "@level/src/v2/interfaces/AggregatorV3Interf import {ERC20} from "@solmate/src/tokens/ERC20.sol"; import {PauserGuard} from "@level/src/v2/common/guard/PauserGuard.sol"; import {StrictRolesAuthority} from "@level/src/v2/auth/StrictRolesAuthority.sol"; +import {SwapManager} from "@level/src/v2/usd/SwapManager.sol"; +import {SwapConfig} from "@level/src/v2/usd/SwapManagerStorage.sol"; /** * Kitchen sink deployment script; deploy the entire protocol in one go. @@ -46,11 +49,15 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { Vm.Wallet public deployerWallet; StrategyConfig public aUsdcConfig; + StrategyConfig public sUsdcConfig; StrategyConfig public aUsdtConfig; StrategyConfig public steakhouseUsdcConfig; StrategyConfig public steakhouseUsdtConfig; StrategyConfig public re7UsdcConfig; StrategyConfig public steakhouseUsdtLiteConfig; + StrategyConfig public ustbConfig; + StrategyConfig public mConfig; + // StrategyConfig public umbrellaConfig; function setUp() external { uint256 _chainId = vm.envUint("CHAIN_ID"); @@ -109,6 +116,7 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { deployRewardsManager(); deployLevelMintingV2(); deployERC4626OracleFactory(); + deploySwapManager(); configurePauseGroups(); AaveTokenOracle aUsdcOracle = new AaveTokenOracle(address(config.tokens.usdc)); @@ -117,12 +125,32 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { AaveTokenOracle aUsdtOracle = new AaveTokenOracle(address(config.tokens.usdt)); vm.label(address(aUsdtOracle), "AaveUsdtTokenOracle"); + CappedOneDollarOracle mNavOracle = new CappedOneDollarOracle(address(config.oracles.mNav)); + vm.label(address(mNavOracle), "CappedMNavOracle"); + // Deploy oracles - if (address(config.morphoVaults.steakhouseUsdc.oracle) == address(0)) { - config.morphoVaults.steakhouseUsdc.oracle = - deployERC4626Oracle(config.morphoVaults.steakhouseUsdc.vault, 4 hours); + if ( + address(config.morphoVaults.steakhouseUsdc.oracle) == address(0) + || address(config.morphoVaults.steakhouseUsdc.oracle).code.length == 0 + ) { + config.morphoVaults.steakhouseUsdc.oracle = deployERC4626Oracle(config.morphoVaults.steakhouseUsdc.vault); + } + + if ( + address(config.sparkVaults.sUsdc.oracle) == address(0) + || address(config.sparkVaults.sUsdc.oracle).code.length == 0 + ) { + config.sparkVaults.sUsdc.oracle = deployERC4626Oracle(config.sparkVaults.sUsdc.vault); } + // if ( + // address(config.umbrellaVaults.waUsdcStakeToken.oracle) == address(0) + // || address(config.umbrellaVaults.waUsdcStakeToken.oracle).code.length == 0 + // ) { + // config.umbrellaVaults.waUsdcStakeToken.oracle = + // deployERC4626Oracle(config.umbrellaVaults.waUsdcStakeToken.vault, 4 hours); + // } + aUsdcConfig = StrategyConfig({ category: StrategyCategory.AAVEV3, baseCollateral: config.tokens.usdc, @@ -133,6 +161,16 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { heartbeat: 1 days }); + sUsdcConfig = StrategyConfig({ + category: StrategyCategory.SPARK, + baseCollateral: config.tokens.usdc, + receiptToken: ERC20(address(config.sparkVaults.sUsdc.vault)), + oracle: config.sparkVaults.sUsdc.oracle, + depositContract: address(config.sparkVaults.sUsdc.vault), + withdrawContract: address(config.sparkVaults.sUsdc.vault), + heartbeat: 1 days + }); + aUsdtConfig = StrategyConfig({ category: StrategyCategory.AAVEV3, baseCollateral: config.tokens.usdt, @@ -143,6 +181,16 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { heartbeat: 1 days }); + ustbConfig = StrategyConfig({ + category: StrategyCategory.SUPERSTATE, + baseCollateral: config.tokens.usdc, + receiptToken: config.tokens.ustb, + oracle: config.oracles.ustb, + depositContract: address(config.tokens.ustb), + withdrawContract: address(config.periphery.ustbRedemptionIdle), + heartbeat: 1 days + }); + steakhouseUsdcConfig = StrategyConfig({ category: StrategyCategory.MORPHO, baseCollateral: config.tokens.usdc, @@ -153,9 +201,33 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { heartbeat: 1 days }); - StrategyConfig[] memory usdcConfigs = new StrategyConfig[](2); + mConfig = StrategyConfig({ + category: StrategyCategory.M0, + baseCollateral: config.tokens.usdc, + receiptToken: config.tokens.wrappedM, + oracle: AggregatorV3Interface(address(mNavOracle)), + depositContract: address(config.levelContracts.swapManager), + withdrawContract: address(config.levelContracts.swapManager), + heartbeat: 26 hours + }); + + // umbrellaConfig = StrategyConfig({ + // category: StrategyCategory.AAVEV3_UMBRELLA, + // baseCollateral: config.tokens.aUsdc, + // receiptToken: ERC20(address(config.umbrellaVaults.waUsdcStakeToken.vault)), + // oracle: config.umbrellaVaults.waUsdcStakeToken.oracle, + // depositContract: address(config.umbrellaVaults.waUsdcStakeToken.vault), + // withdrawContract: address(config.umbrellaVaults.waUsdcStakeToken.vault), + // heartbeat: 1 days + // }); + + StrategyConfig[] memory usdcConfigs = new StrategyConfig[](5); usdcConfigs[0] = aUsdcConfig; usdcConfigs[1] = steakhouseUsdcConfig; + usdcConfigs[2] = sUsdcConfig; + usdcConfigs[3] = ustbConfig; + usdcConfigs[4] = mConfig; + // usdcConfigs[5] = umbrellaConfig; StrategyConfig[] memory usdtConfigs = new StrategyConfig[](1); usdtConfigs[0] = aUsdtConfig; @@ -230,6 +302,16 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { address(config.levelContracts.vaultManager), bytes4(abi.encodeWithSignature("withdrawDefault(address,uint256)")) ); + _setRoleCapabilityIfNotExists( + STRATEGIST_ROLE, + address(config.levelContracts.vaultManager), + bytes4(abi.encodeWithSignature("modifyAaveUmbrellaCooldownOperator(address,address,bool)")) + ); + _setRoleCapabilityIfNotExists( + STRATEGIST_ROLE, + address(config.levelContracts.vaultManager), + bytes4(abi.encodeWithSignature("modifyAaveUmbrellaRewardsClaimer(address,address,bool)")) + ); _setRoleIfNotExists(address(config.levelContracts.levelMintingV2), STRATEGIST_ROLE); _setRoleIfNotExists(address(config.users.operator), STRATEGIST_ROLE); @@ -291,6 +373,40 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { _setRoleIfNotExists(config.users.hexagateGatekeepers[0], PAUSER_ROLE); _setRoleIfNotExists(config.users.hexagateGatekeepers[1], PAUSER_ROLE); + // --------------- Setup SwapManager + + // A tick range of [-10, 10] means price must stay between $0.999 and $1.001 + // Allows only a ±0.1% movement from the $1 peg + // This is a conservative range that allows for some flexibility while maintaining stability + + // Apart from the price range, we also enforce a max slippage tolerace of 0.05% + + config.levelContracts.swapManager.setSwapConfig( + address(config.tokens.usdc), + address(config.tokens.wrappedM), + SwapConfig({ + pool: 0x970A7749EcAA4394C8B2Bf5F2471F41FD6b79288, // wM/USDC pool + fee: 100, //0.01% + tickLower: -10, + tickUpper: 10, + slippageBps: 5, //0.05% + active: true + }) + ); + + config.levelContracts.swapManager.setSwapConfig( + address(config.tokens.wrappedM), + address(config.tokens.usdc), + SwapConfig({ + pool: 0x970A7749EcAA4394C8B2Bf5F2471F41FD6b79288, // wM/USDC pool + fee: 100, //0.01% + tickLower: -10, + tickUpper: 10, + slippageBps: 5, //0.05% + active: true + }) + ); + //------------- Add Aave as a strategy config.levelContracts.vaultManager.addAssetStrategy( address(config.tokens.usdc), address(config.periphery.aaveV3), aUsdcConfig @@ -309,10 +425,43 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { ); } + //--------------- Add Spark as a strategy + if (address(config.sparkVaults.sUsdc.vault) == address(0)) { + revert("Spark USDC vaults not deployed"); + } else { + config.levelContracts.vaultManager.addAssetStrategy( + address(config.tokens.usdc), address(config.sparkVaults.sUsdc.vault), sUsdcConfig + ); + } + + //--------------- Add Superstate as a strategy + config.levelContracts.vaultManager.addAssetStrategy( + address(config.tokens.usdc), address(config.tokens.ustb), ustbConfig + ); + + //--------------- Add M as a strategy + config.levelContracts.vaultManager.addAssetStrategy( + address(config.tokens.usdc), + address(config.tokens.wrappedM), // We add the receipt token here and not the deposit contract + mConfig + ); + + //--------------- Add Umbrella as a strategy + // if (address(config.umbrellaVaults.waUsdcStakeToken.vault) == address(0)) { + // revert("Umbrella USDC vaults not deployed"); + // } else { + // config.levelContracts.vaultManager.addAssetStrategy( + // address(config.tokens.usdc), address(config.umbrellaVaults.waUsdcStakeToken.vault), umbrellaConfig + // ); + // } + // Add Aave as a default strategy - address[] memory usdcDefaultStrategies = new address[](2); + address[] memory usdcDefaultStrategies = new address[](5); usdcDefaultStrategies[0] = address(config.periphery.aaveV3); usdcDefaultStrategies[1] = address(config.morphoVaults.steakhouseUsdc.vault); + usdcDefaultStrategies[2] = address(config.sparkVaults.sUsdc.vault); + usdcDefaultStrategies[3] = address(config.tokens.ustb); + usdcDefaultStrategies[4] = address(config.tokens.wrappedM); address[] memory usdtDefaultStrategies = new address[](1); usdtDefaultStrategies[0] = address(config.periphery.aaveV3); @@ -383,6 +532,8 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { config.levelContracts.levelMintingV2.addMintableAsset(address(config.tokens.aUsdc)); config.levelContracts.levelMintingV2.addMintableAsset(address(config.tokens.aUsdt)); config.levelContracts.levelMintingV2.addMintableAsset(address(config.morphoVaults.steakhouseUsdc.vault)); + // config.levelContracts.levelMintingV2.addMintableAsset(address(config.sparkVaults.sUsdc.vault)); + config.levelContracts.levelMintingV2.addMintableAsset(address(config.tokens.ustb)); config.levelContracts.levelMintingV2.addRedeemableAsset(address(config.tokens.usdc)); config.levelContracts.levelMintingV2.addRedeemableAsset(address(config.tokens.usdt)); @@ -394,13 +545,18 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { config.levelContracts.levelMintingV2.addOracle( address(config.morphoVaults.steakhouseUsdc.vault), address(config.morphoVaults.steakhouseUsdc.oracle), true ); + // config.levelContracts.levelMintingV2.addOracle( + // address(config.sparkVaults.sUsdc.vault), address(config.sparkVaults.sUsdc.oracle), true + // ); + config.levelContracts.levelMintingV2.addOracle(address(config.tokens.ustb), address(config.oracles.ustb), true); config.levelContracts.levelMintingV2.setHeartBeat(address(config.tokens.usdc), 1 days); config.levelContracts.levelMintingV2.setHeartBeat(address(config.tokens.usdt), 1 days); config.levelContracts.levelMintingV2.setHeartBeat(address(config.tokens.aUsdc), 1 days); config.levelContracts.levelMintingV2.setHeartBeat(address(config.tokens.aUsdt), 1 days); config.levelContracts.levelMintingV2.setHeartBeat(address(config.morphoVaults.steakhouseUsdc.vault), 4 hours); - + // config.levelContracts.levelMintingV2.setHeartBeat(address(config.sparkVaults.sUsdc.vault), 4 hours); + config.levelContracts.levelMintingV2.setHeartBeat(address(config.tokens.ustb), 1 days); config.levelContracts.levelMintingV2.setCooldownDuration(5 minutes); // ------------ Setup StrictRolesAuthority @@ -432,8 +588,41 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { return config; } + function deploySwapManager() public returns (SwapManager) { + if ( + address(config.levelContracts.swapManager) != address(0) + && address(config.levelContracts.swapManager).code.length > 0 + ) { + return config.levelContracts.swapManager; + } + + if (address(config.levelContracts.rolesAuthority) == address(0)) { + revert("RolesAuthority must be deployed first"); + } + + bytes memory constructorArgs = abi.encodeWithSignature( + "initialize(address,address)", deployerWallet.addr, address(config.periphery.uniswapV3Router) + ); + + SwapManager _swapManager = new SwapManager{salt: convertNameToBytes32(LevelUsdSwapManagerName)}(); + ERC1967Proxy _swapManagerProxy = new ERC1967Proxy{salt: convertNameToBytes32(LevelUsdSwapManagerName)}( + address(_swapManager), constructorArgs + ); + + vm.label(address(_swapManagerProxy), LevelUsdSwapManagerName); + + config.levelContracts.swapManager = SwapManager(address(_swapManagerProxy)); + + config.levelContracts.swapManager.setAuthority(config.levelContracts.rolesAuthority); + + return config.levelContracts.swapManager; + } + function deployRolesAuthority() public returns (StrictRolesAuthority) { - if (address(config.levelContracts.rolesAuthority) != address(0)) { + if ( + address(config.levelContracts.rolesAuthority) != address(0) + && address(config.levelContracts.rolesAuthority).code.length > 0 + ) { return config.levelContracts.rolesAuthority; } @@ -450,7 +639,10 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { } function deployAdminTimelock() public returns (TimelockController) { - if (address(config.levelContracts.adminTimelock) != address(0)) { + if ( + address(config.levelContracts.adminTimelock) != address(0) + && address(config.levelContracts.adminTimelock).code.length > 0 + ) { return config.levelContracts.adminTimelock; } @@ -468,7 +660,10 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { } function deployBoringVault() public returns (BoringVault) { - if (address(config.levelContracts.boringVault) != address(0)) { + if ( + address(config.levelContracts.boringVault) != address(0) + && address(config.levelContracts.boringVault).code.length > 0 + ) { return config.levelContracts.boringVault; } @@ -493,7 +688,10 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { } function deployVaultManager() public returns (VaultManager) { - if (address(config.levelContracts.vaultManager) != address(0)) { + if ( + address(config.levelContracts.vaultManager) != address(0) + && address(config.levelContracts.vaultManager).code.length > 0 + ) { return config.levelContracts.vaultManager; } @@ -530,7 +728,10 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { } function deployRewardsManager() public returns (RewardsManager) { - if (address(config.levelContracts.rewardsManager) != address(0)) { + if ( + address(config.levelContracts.rewardsManager) != address(0) + && address(config.levelContracts.rewardsManager).code.length > 0 + ) { return config.levelContracts.rewardsManager; } @@ -570,7 +771,10 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { } function deployERC4626OracleFactory() public returns (ERC4626OracleFactory) { - if (address(config.levelContracts.erc4626OracleFactory) != address(0)) { + if ( + address(config.levelContracts.erc4626OracleFactory) != address(0) + && address(config.levelContracts.erc4626OracleFactory).code.length > 0 + ) { return config.levelContracts.erc4626OracleFactory; } @@ -583,7 +787,19 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { return config.levelContracts.erc4626OracleFactory; } - function deployERC4626Oracle(IERC4626 vault, uint256 delay) public returns (IERC4626Oracle) { + function deployERC4626DelayedOracle(IERC4626 vault, uint256 delay) public returns (IERC4626Oracle) { + if (address(config.levelContracts.erc4626OracleFactory) == address(0)) { + revert("ERC4626OracleFactory must be deployed first"); + } + + IERC4626Oracle _erc4626Oracle = + IERC4626Oracle(config.levelContracts.erc4626OracleFactory.createDelayed(vault, delay)); + vm.label(address(_erc4626Oracle), string.concat(vault.name(), " Oracle")); + + return _erc4626Oracle; + } + + function deployERC4626Oracle(IERC4626 vault) public returns (IERC4626Oracle) { if (address(config.levelContracts.erc4626OracleFactory) == address(0)) { revert("ERC4626OracleFactory must be deployed first"); } @@ -595,7 +811,10 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { } function deployLevelMintingV2() public returns (LevelMintingV2) { - if (address(config.levelContracts.levelMintingV2) != address(0)) { + if ( + address(config.levelContracts.levelMintingV2) != address(0) + && address(config.levelContracts.levelMintingV2).code.length > 0 + ) { return config.levelContracts.levelMintingV2; } @@ -642,7 +861,10 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { } function deployPauserGuard() public returns (PauserGuard) { - if (address(config.levelContracts.pauserGuard) != address(0)) { + if ( + address(config.levelContracts.pauserGuard) != address(0) + && address(config.levelContracts.pauserGuard).code.length > 0 + ) { return config.levelContracts.pauserGuard; } @@ -660,6 +882,12 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { } function configurePauseGroups() public { + if (config.levelContracts.pauserGuard.owner() == address(config.levelContracts.adminTimelock)) { + // PauserGuard is already configured + console2.log("PauserGuard is already configured"); + return; + } + if (address(config.levelContracts.pauserGuard) == address(0)) { revert("PauserGuard must be deployed first"); } @@ -864,6 +1092,10 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { config.levelContracts.pauserGuard.transferOwnership(address(config.levelContracts.adminTimelock)); } + if (config.levelContracts.swapManager.owner() == deployerWallet.addr) { + config.levelContracts.swapManager.transferOwnership(address(config.levelContracts.adminTimelock)); + } + if ( config.levelContracts.adminTimelock.hasRole( config.levelContracts.adminTimelock.DEFAULT_ADMIN_ROLE(), deployerWallet.addr @@ -888,6 +1120,7 @@ contract DeployLevel is Configurable, DeploymentUtils, Script { _printDeployedContracts( chainId, LevelERC4626OracleFactoryName, address(config.levelContracts.erc4626OracleFactory) ); + _printDeployedContracts(chainId, LevelUsdSwapManagerName, address(config.levelContracts.swapManager)); } // Exclude from coverage diff --git a/script/v2/DeployTestnet.s.sol b/script/v2/DeployTestnet.s.sol index 0bec736..7af00dd 100644 --- a/script/v2/DeployTestnet.s.sol +++ b/script/v2/DeployTestnet.s.sol @@ -100,8 +100,7 @@ contract DeployTestnet is Configurable, DeploymentUtils, Script { // Deploy oracles if (address(config.morphoVaults.steakhouseUsdc.oracle) == address(0)) { - config.morphoVaults.steakhouseUsdc.oracle = - deployERC4626Oracle(config.morphoVaults.steakhouseUsdc.vault, 4 hours); + config.morphoVaults.steakhouseUsdc.oracle = deployERC4626Oracle(config.morphoVaults.steakhouseUsdc.vault); } steakhouseUsdcConfig = StrategyConfig({ @@ -439,8 +438,6 @@ contract DeployTestnet is Configurable, DeploymentUtils, Script { return config.levelContracts.erc4626OracleFactory; } - bytes memory creationCode; - ERC4626OracleFactory _erc4626OracleFactory = new ERC4626OracleFactory{salt: convertNameToBytes32(LevelERC4626OracleFactoryName)}(); @@ -450,7 +447,19 @@ contract DeployTestnet is Configurable, DeploymentUtils, Script { return config.levelContracts.erc4626OracleFactory; } - function deployERC4626Oracle(IERC4626 vault, uint256 delay) public returns (IERC4626Oracle) { + function deployERC4626DelayedOracle(IERC4626 vault, uint256 delay) public returns (IERC4626Oracle) { + if (address(config.levelContracts.erc4626OracleFactory) == address(0)) { + revert("ERC4626OracleFactory must be deployed first"); + } + + IERC4626Oracle _erc4626Oracle = + IERC4626Oracle(config.levelContracts.erc4626OracleFactory.createDelayed(vault, delay)); + vm.label(address(_erc4626Oracle), string.concat(vault.name(), " Oracle")); + + return _erc4626Oracle; + } + + function deployERC4626Oracle(IERC4626 vault) public returns (IERC4626Oracle) { if (address(config.levelContracts.erc4626OracleFactory) == address(0)) { revert("ERC4626OracleFactory must be deployed first"); } diff --git a/script/v2/lens/UpgradeLevelReserveLens.s.sol b/script/v2/lens/UpgradeLevelReserveLens.s.sol index 8d53ff2..6c8ccdd 100644 --- a/script/v2/lens/UpgradeLevelReserveLens.s.sol +++ b/script/v2/lens/UpgradeLevelReserveLens.s.sol @@ -11,6 +11,7 @@ import {Configurable} from "@level/config/Configurable.sol"; import {Upgrades} from "@openzeppelin-upgrades/src/Upgrades.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; import {console2} from "forge-std/console2.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; contract UpgradeLevelReserveLens is Configurable, DeploymentUtils, Script { uint256 public chainId; @@ -72,13 +73,24 @@ contract UpgradeLevelReserveLens is Configurable, DeploymentUtils, Script { "LevelReserveLens Implementation : https://etherscan.io/address/%s", address(impl) ); + // Call timelock to upgrade the proxy vm.startBroadcast(config.users.admin); + console2.log("Scheduling upgrade of LevelReserveLens from proxy %s", address(proxy)); + TimelockController timelock = TimelockController(payable(config.levelContracts.adminTimelock)); + timelock.schedule( + address(proxy), + 0, + abi.encodeWithSelector(proxy.upgradeToAndCall.selector, address(impl), ""), + bytes32(0), + 0, + 5 days + ); - console2.log("Upgrading LevelReserveLens from proxy %s", address(proxy)); - // console2.log("Old implementation: %s", address(proxy.implementation())); - console2.log("New implementation: %s", address(impl)); + vm.warp(block.timestamp + 5 days); - proxy.upgradeToAndCall(address(impl), ""); + timelock.execute( + address(proxy), 0, abi.encodeWithSelector(proxy.upgradeToAndCall.selector, address(impl), ""), bytes32(0), 0 + ); vm.stopBroadcast(); diff --git a/script/v2/periphery/DeployRewardsDistributor.s.sol b/script/v2/periphery/DeployRewardsDistributor.s.sol new file mode 100644 index 0000000..7f984df --- /dev/null +++ b/script/v2/periphery/DeployRewardsDistributor.s.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.19; + +import {Script} from "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {RewardsDistributor} from "@level/src/v2/periphery/RewardsDistributor.sol"; + +import {DeploymentUtils} from "@level/script/v2/DeploymentUtils.s.sol"; +import {Configurable} from "@level/config/Configurable.sol"; + +import {Upgrades} from "@openzeppelin-upgrades/src/Upgrades.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/interfaces/IERC20Metadata.sol"; +import {console2} from "forge-std/console2.sol"; + +contract DeployRewardsDistributor is Configurable, DeploymentUtils, Script { + uint256 public chainId; + + Vm.Wallet public deployerWallet; + + function setUp() external { + uint256 _chainId = vm.envUint("CHAIN_ID"); + + setUp_(_chainId); + } + + function setUp_(uint256 _chainId) public { + chainId = _chainId; + initConfig(_chainId); + + deployerWallet.addr = msg.sender; + + vm.label(msg.sender, "Deployer EOA"); + } + + function setUp_(uint256 _chainId, uint256 _privateKey) public { + chainId = _chainId; + initConfig(_chainId); + + if (msg.sender != vm.addr(_privateKey)) { + revert("Private key does not match sender"); + } + + deployerWallet.privateKey = _privateKey; + deployerWallet.addr = vm.addr(_privateKey); + + vm.label(msg.sender, "Deployer EOA"); + } + + function run() external { + return deploy(); + } + + function deploy() public { + if (deployerWallet.privateKey == 0) { + vm.startBroadcast(); + } else { + vm.startBroadcast(deployerWallet.privateKey); + } + + console2.log("Deploying RewardsDistributor from address %s", deployerWallet.addr); + + RewardsDistributor distributor = new RewardsDistributor( + address(config.levelContracts.levelMintingV2), address(config.levelContracts.rewardsManager) + ); + + vm.stopBroadcast(); + + // Logs + console2.log("=====> RewardsDistributor contracts deployed ...."); + console2.log( + "RewardsDistributor Implementation : https://etherscan.io/address/%s", + address(distributor) + ); + } +} diff --git a/script/v2/usd/DeploySwapManager.s.sol b/script/v2/usd/DeploySwapManager.s.sol new file mode 100644 index 0000000..0d6df9b --- /dev/null +++ b/script/v2/usd/DeploySwapManager.s.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.19; + +import {Script} from "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {SwapManager} from "@level/src/v2/usd/SwapManager.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {SwapConfig} from "@level/src/v2/usd/SwapManager.sol"; + +import {DeploymentUtils} from "@level/script/v2/DeploymentUtils.s.sol"; +import {Configurable} from "@level/config/Configurable.sol"; +import {BaseConfig} from "@level/config/deploy/BaseConfig.sol"; + +import {console2} from "forge-std/console2.sol"; + +/// @title DeploySwapManager +/// @notice Deploys and sets up the SwapManager contract +/// @dev As of May 28, 2025, the SwapManager has not been deployed before. Hence it's not owned by the admin timelock. +/// @dev This script can be used directly to deploy the SwapManager, set it up and transfer ownership to the admin timelock. +contract DeploySwapManager is Configurable, DeploymentUtils, Script { + uint256 public chainId; + + Vm.Wallet public deployerWallet; + + error InvalidProxyAddress(); + error UpgradeFailed(); + + function setUp() external { + uint256 _chainId = vm.envUint("CHAIN_ID"); + setUp_(_chainId); + } + + function setUp_(uint256 _chainId) public { + chainId = _chainId; + initConfig(_chainId); + + vm.label(msg.sender, "Deployer EOA"); + } + + function setUp_(uint256 _chainId, uint256 _privateKey) public { + chainId = _chainId; + initConfig(_chainId); + + if (msg.sender != vm.addr(_privateKey)) { + revert("Private key does not match sender"); + } + + deployerWallet.privateKey = _privateKey; + deployerWallet.addr = vm.addr(_privateKey); + + vm.label(msg.sender, "Deployer EOA"); + } + + function run() external returns (BaseConfig.Config memory) { + return deploy(); + } + + function deploy() public returns (BaseConfig.Config memory) { + vm.startBroadcast(deployerWallet.privateKey); + + console2.log("Deploying SwapManager from address %s", deployerWallet.addr); + + bytes memory constructorArgs = abi.encodeWithSignature( + "initialize(address,address)", deployerWallet.addr, address(config.periphery.uniswapV3Router) + ); + + SwapManager _swapManager = new SwapManager{salt: convertNameToBytes32(LevelUsdSwapManagerName)}(); + ERC1967Proxy _swapManagerProxy = new ERC1967Proxy{salt: convertNameToBytes32(LevelUsdSwapManagerName)}( + address(_swapManager), constructorArgs + ); + + config.levelContracts.swapManager = SwapManager(address(_swapManagerProxy)); + + config.levelContracts.swapManager.setAuthority(config.levelContracts.rolesAuthority); + + // Logs + console2.log("=====> SwapManager deployed ...."); + console2.log( + "SwapManager proxy address : https://etherscan.io/address/%s", address(_swapManagerProxy) + ); + console2.log("SwapManager implementation address : https://etherscan.io/address/%s", address(_swapManager)); + + // A tick range of [-10, 10] means price must stay between $0.999 and $1.001 + // Allows only a ±0.1% movement from the $1 peg + // This is a conservative range that allows for some flexibility while maintaining stability + + config.levelContracts.swapManager.setSwapConfig( + address(config.tokens.usdc), + address(config.tokens.wrappedM), + SwapConfig({ + pool: 0x970A7749EcAA4394C8B2Bf5F2471F41FD6b79288, // wM/USDC pool + fee: 100, //0.01% + tickLower: -10, + tickUpper: 10, + slippageBps: 5, //0.05% + active: true + }) + ); + + config.levelContracts.swapManager.setSwapConfig( + address(config.tokens.wrappedM), + address(config.tokens.usdc), + SwapConfig({ + pool: 0x970A7749EcAA4394C8B2Bf5F2471F41FD6b79288, // wM/USDC pool + fee: 100, //0.01% + tickLower: -10, + tickUpper: 10, + slippageBps: 5, //0.05% + active: true + }) + ); + + // Transfer ownership to admin timelock + config.levelContracts.swapManager.transferOwnership(address(config.levelContracts.adminTimelock)); + + vm.stopBroadcast(); + + return config; + } + + function verify(SwapManager manager) public view { + // TODO: Add verification logic here + } +} diff --git a/script/v2/usd/UpgradeRewardsManager.s.sol b/script/v2/usd/UpgradeRewardsManager.s.sol new file mode 100644 index 0000000..2476f56 --- /dev/null +++ b/script/v2/usd/UpgradeRewardsManager.s.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.19; + +import {Script} from "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {RewardsManager} from "@level/src/v2/usd/RewardsManager.sol"; + +import {DeploymentUtils} from "@level/script/v2/DeploymentUtils.s.sol"; +import {Configurable} from "@level/config/Configurable.sol"; + +import {console2} from "forge-std/console2.sol"; + +contract UpgradeRewardsManager is Configurable, DeploymentUtils, Script { + uint256 public chainId; + + Vm.Wallet public deployerWallet; + + error InvalidProxyAddress(); + error UpgradeFailed(); + error VerificationFailed(); + + function setUp() external { + uint256 _chainId = vm.envUint("CHAIN_ID"); + setUp_(_chainId); + } + + function setUp_(uint256 _chainId) public { + chainId = _chainId; + initConfig(_chainId); + + vm.label(msg.sender, "Deployer EOA"); + } + + function setUp_(uint256 _chainId, uint256 _privateKey) public { + chainId = _chainId; + initConfig(_chainId); + + if (msg.sender != vm.addr(_privateKey)) { + revert("Private key does not match sender"); + } + + deployerWallet.privateKey = _privateKey; + deployerWallet.addr = vm.addr(_privateKey); + + vm.label(msg.sender, "Deployer EOA"); + } + + function run() external { + return upgrade(); + } + + function upgrade() public { + vm.startBroadcast(deployerWallet.privateKey); + + console2.log("Deploying RewardsManager from address %s", deployerWallet.addr); + + RewardsManager proxy = RewardsManager(config.levelContracts.rewardsManager); + + if (address(proxy) == address(0)) { + revert InvalidProxyAddress(); + } + + RewardsManager impl = new RewardsManager(); + + vm.stopBroadcast(); + + // Logs + console2.log("=====> RewardsManager deployed ...."); + console2.log("RewardsManager Implementation : https://etherscan.io/address/%s", address(impl)); + + vm.startBroadcast(config.users.admin); + + console2.log("Upgrading RewardsManager from proxy %s", address(proxy)); + console2.log("New implementation: %s", address(impl)); + + try proxy.upgradeToAndCall(address(impl), "") { + console2.log("Upgrade successful!"); + } catch { + revert UpgradeFailed(); + } + + vm.stopBroadcast(); + + // verify(impl); + + /* STEPS AFTER UPGRADE + + - Add all strategies + + StrategyConfig[] memory usdcConfigs = new StrategyConfig[](3); + usdcConfigs[0] = aUsdcConfig; + usdcConfigs[1] = steakhouseUsdcConfig; + usdcConfigs[2] = sUsdcConfig; + + config.levelContracts.rewardsManager.setAllStrategies(address(config.tokens.usdc), usdcConfigs); + */ + } + + function verify(RewardsManager manager) public view { + // TODO: Add verification logic here + } +} diff --git a/script/v2/usd/UpgradeVaultManager.s.sol b/script/v2/usd/UpgradeVaultManager.s.sol new file mode 100644 index 0000000..95b4ba4 --- /dev/null +++ b/script/v2/usd/UpgradeVaultManager.s.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.19; + +import {Script} from "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {VaultManager} from "@level/src/v2/usd/VaultManager.sol"; +import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol"; +import {DeploymentUtils} from "@level/script/v2/DeploymentUtils.s.sol"; +import {Configurable} from "@level/config/Configurable.sol"; +import {BaseConfig} from "@level/config/deploy/BaseConfig.sol"; +import {StrategyCategory, StrategyConfig} from "@level/src/v2/common/libraries/StrategyLib.sol"; +import {AggregatorV3Interface} from "@level/src/v2/interfaces/AggregatorV3Interface.sol"; +import {CappedOneDollarOracle} from "@level/src/v2/oracles/CappedOneDollarOracle.sol"; + +import {console2} from "forge-std/console2.sol"; + +contract UpgradeVaultManager is Configurable, DeploymentUtils, Script { + uint256 public chainId; + + Vm.Wallet public deployerWallet; + + error InvalidProxyAddress(); + error UpgradeFailed(); + error VerificationFailed(); + + function setUp() external { + uint256 _chainId = vm.envUint("CHAIN_ID"); + setUp_(_chainId); + } + + function setUp_(uint256 _chainId) public { + chainId = _chainId; + initConfig(_chainId); + + vm.label(msg.sender, "Deployer EOA"); + } + + function setUp_(uint256 _chainId, uint256 _privateKey) public { + chainId = _chainId; + initConfig(_chainId); + + if (msg.sender != vm.addr(_privateKey)) { + revert("Private key does not match sender"); + } + + deployerWallet.privateKey = _privateKey; + deployerWallet.addr = vm.addr(_privateKey); + + vm.label(msg.sender, "Deployer EOA"); + } + + function setUp_(uint256 _chainId, uint256 _privateKey, BaseConfig.Config memory _config) public { + chainId = _chainId; + config = _config; + + if (msg.sender != vm.addr(_privateKey)) { + revert("Private key does not match sender"); + } + + deployerWallet.privateKey = _privateKey; + deployerWallet.addr = vm.addr(_privateKey); + + vm.label(msg.sender, "Deployer EOA"); + } + + function run() external returns (BaseConfig.Config memory) { + return upgrade(); + } + + function upgrade() public returns (BaseConfig.Config memory) { + vm.startBroadcast(deployerWallet.privateKey); + + console2.log("Deploying VaultManager from address %s", deployerWallet.addr); + + VaultManager proxy = VaultManager(config.levelContracts.vaultManager); + + if (address(proxy) == address(0)) { + revert InvalidProxyAddress(); + } + + VaultManager impl = new VaultManager(); + + vm.stopBroadcast(); + + // Logs + console2.log("=====> VaultManager deployed ...."); + console2.log("VaultManager Implementation : https://etherscan.io/address/%s", address(impl)); + + // Setup update + + CappedOneDollarOracle mNavOracle = new CappedOneDollarOracle(address(config.oracles.mNav)); + vm.label(address(mNavOracle), "CappedMNavOracle"); + + StrategyConfig memory ustbConfig = StrategyConfig({ + category: StrategyCategory.SUPERSTATE, + baseCollateral: config.tokens.usdc, + receiptToken: config.tokens.ustb, + oracle: config.oracles.ustb, + depositContract: address(config.tokens.ustb), + withdrawContract: address(config.periphery.ustbRedemptionIdle), + heartbeat: 1 days + }); + + StrategyConfig memory mConfig = StrategyConfig({ + category: StrategyCategory.M0, + baseCollateral: config.tokens.usdc, + receiptToken: config.tokens.wrappedM, + oracle: AggregatorV3Interface(address(mNavOracle)), + depositContract: address(config.levelContracts.swapManager), + withdrawContract: address(config.levelContracts.swapManager), + heartbeat: 26 hours + }); + + address[] memory targets = new address[](5); + targets[0] = address(config.levelContracts.vaultManager); + targets[1] = address(config.levelContracts.vaultManager); + targets[2] = address(config.levelContracts.vaultManager); + targets[3] = address(config.levelContracts.rolesAuthority); + targets[4] = address(config.levelContracts.rolesAuthority); + + bytes[] memory payloads = new bytes[](5); + // Upgrade to new implementation + payloads[0] = abi.encodeWithSelector(proxy.upgradeToAndCall.selector, address(impl), ""); + // Add ustb as a strategy + payloads[1] = abi.encodeWithSelector( + config.levelContracts.vaultManager.addAssetStrategy.selector, + address(config.tokens.usdc), + address(config.tokens.ustb), + ustbConfig + ); + // Add m as a strategy + payloads[2] = abi.encodeWithSelector( + config.levelContracts.vaultManager.addAssetStrategy.selector, + address(config.tokens.usdc), + address(config.tokens.wrappedM), + mConfig + ); + // Add cooldown operator capability to strategist role + payloads[3] = abi.encodeWithSelector( + config.levelContracts.rolesAuthority.setRoleCapability.selector, + STRATEGIST_ROLE, + address(config.levelContracts.vaultManager), + bytes4(abi.encodeWithSignature("modifyAaveUmbrellaCooldownOperator(address,address,bool)")), + true + ); + // Add rewards claimer capability to strategist role + payloads[4] = abi.encodeWithSelector( + config.levelContracts.rolesAuthority.setRoleCapability.selector, + STRATEGIST_ROLE, + address(config.levelContracts.vaultManager), + bytes4(abi.encodeWithSignature("modifyAaveUmbrellaRewardsClaimer(address,address,bool)")), + true + ); + + vm.startBroadcast(config.users.admin); + + TimelockController timelock = TimelockController(payable(config.levelContracts.adminTimelock)); + timelock.scheduleBatch(targets, new uint256[](5), payloads, bytes32(0), bytes32(0), 5 days); + + vm.warp(block.timestamp + 5 days); + + timelock.executeBatch(targets, new uint256[](5), payloads, bytes32(0), bytes32(0)); + + vm.stopBroadcast(); + + return config; + + // verify(impl); + + /* STEPS AFTER UPGRADE + + - Add asset strategy for sUsdc + if (address(config.sparkVaults.sUsdc.vault) == address(0)) { + revert("Spark USDC vaults not deployed"); + } else { + config.levelContracts.vaultManager.addAssetStrategy( + address(config.tokens.usdc), address(config.sparkVaults.sUsdc.vault), sUsdcConfig + ); + } + + - Add default strategy for USDC + address[] memory usdcDefaultStrategies = new address[](3); + usdcDefaultStrategies[0] = address(config.periphery.aaveV3); + usdcDefaultStrategies[1] = address(config.morphoVaults.steakhouseUsdc.vault); + usdcDefaultStrategies[2] = address(config.sparkVaults.sUsdc.vault); + + config.levelContracts.vaultManager.setDefaultStrategies(address(config.tokens.usdc), usdcDefaultStrategies); + */ + } + + function verify(VaultManager manager) public view { + // TODO: Add verification logic here + } +} diff --git a/src/v2/common/libraries/StrategyLib.sol b/src/v2/common/libraries/StrategyLib.sol index 0ccc267..62df38d 100644 --- a/src/v2/common/libraries/StrategyLib.sol +++ b/src/v2/common/libraries/StrategyLib.sol @@ -12,7 +12,11 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; enum StrategyCategory { UNDEFINED, AAVEV3, - MORPHO + MORPHO, + SPARK, + SUPERSTATE, + M0, + AAVEV3_UMBRELLA } struct StrategyConfig { diff --git a/src/v2/common/libraries/VaultLib.sol b/src/v2/common/libraries/VaultLib.sol index 653031b..97a767d 100644 --- a/src/v2/common/libraries/VaultLib.sol +++ b/src/v2/common/libraries/VaultLib.sol @@ -5,9 +5,13 @@ import {ERC20} from "@solmate/src/tokens/ERC20.sol"; import {BoringVault} from "@level/src/v2/usd/BoringVault.sol"; import {StrategyLib, StrategyConfig, StrategyCategory} from "@level/src/v2/common/libraries/StrategyLib.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; - import {IPool} from "@level/src/v2/interfaces/aave/IPool.sol"; import {IPoolAddressesProvider} from "@level/src/v2/interfaces/aave/IPoolAddressesProvider.sol"; +import {ISuperstateToken} from "@level/src/v2/interfaces/superstate/ISuperstateToken.sol"; +import {IRedemption} from "@level/src/v2/interfaces/superstate/IRedemption.sol"; +import {ISwapRouter} from "@level/src/v2/interfaces/uniswap/ISwapRouter.sol"; +import {IERC4626StataToken} from "@level/src/v2/interfaces/aave/IERC4626StataToken.sol"; +import {IERC4626StakeToken} from "@level/src/v2/interfaces/aave/IERC4626StakeToken.sol"; /// @title VaultLib /// @author Level (https://level.money) @@ -39,6 +43,20 @@ library VaultLib { address indexed vault, address indexed asset, uint256 amountDeposited, uint256 sharesReceived ); + /// @notice Emitted when assets are deposited into Spark + /// @param vault The vault address + /// @param asset The asset address + /// @param amountDeposited The amount of assets deposited + /// @param sharesReceived The amount of shares received + event DepositToSpark(address indexed vault, address indexed asset, uint256 amountDeposited, uint256 sharesReceived); + + /// @notice Emitted when assets are withdrawn from Spark + /// @param vault The vault address + /// @param asset The asset address + /// @param amountWithdrawn The amount of assets withdrawn + /// @param sharesSent The amount of shares sent + event WithdrawFromSpark(address indexed vault, address indexed asset, uint256 amountWithdrawn, uint256 sharesSent); + /// @notice Emitted when assets are withdrawn from Morpho /// @param vault The vault address /// @param asset The asset address @@ -46,6 +64,56 @@ library VaultLib { /// @param sharesSent The amount of shares sent event WithdrawFromMorpho(address indexed vault, address indexed asset, uint256 amountWithdrawn, uint256 sharesSent); + /// @notice Emitted when assets are deposited into Superstate + /// @param vault The vault address + /// @param asset The asset address + /// @param amountDeposited The amount of assets deposited + /// @param sharesReceived The amount of shares received + event DepositToSuperstate( + address indexed vault, address indexed asset, uint256 amountDeposited, uint256 sharesReceived + ); + + /// @notice Emitted when assets are withdrawn from Superstate + /// @param vault The vault address + /// @param asset The asset address + /// @param amountWithdrawn The amount of assets withdrawn + /// @param sharesSent The amount of superstate token sent + event WithdrawFromSuperstate( + address indexed vault, address indexed asset, uint256 amountWithdrawn, uint256 sharesSent + ); + + /// @notice Emitted when assets are deposited into M + /// @param vault The vault address + /// @param asset The asset address + /// @param amountDeposited The amount of assets deposited + /// @param sharesReceived The amount of shares received + event DepositToM0(address indexed vault, address indexed asset, uint256 amountDeposited, uint256 sharesReceived); + + /// @notice Emitted when assets are withdrawn from M + /// @param vault The vault address + /// @param asset The asset address + /// @param amountWithdrawn The amount of assets withdrawn + /// @param sharesSent The amount of shares sent + event WithdrawFromM0(address indexed vault, address indexed asset, uint256 amountWithdrawn, uint256 sharesSent); + + /// @notice Emitted when assets are staked to Aave Umbrella + /// @param vault The vault address + /// @param asset The asset address + /// @param amountStaked The amount of assets staked + /// @param sharesReceived The amount of shares received + event StakeToAaveUmbrella( + address indexed vault, address indexed asset, uint256 amountStaked, uint256 sharesReceived + ); + + /// @notice Emitted when assets are unstaked from Aave Umbrella + /// @param vault The vault address + /// @param asset The asset address + /// @param amountUnstaked The amount of assets unstaked + /// @param sharesSent The amount of shares sent + event UnstakeFromAaveUmbrella( + address indexed vault, address indexed asset, uint256 amountUnstaked, uint256 sharesSent + ); + /// @notice Returns the total assets of the given strategies /// @param vault The vault address /// @param strategies The strategy configs @@ -112,6 +180,14 @@ library VaultLib { return _depositToAave(vault, config, amount); } else if (config.category == StrategyCategory.MORPHO) { return _depositToMorpho(vault, config, amount); + } else if (config.category == StrategyCategory.SPARK) { + return _depositToSpark(vault, config, amount); + } else if (config.category == StrategyCategory.SUPERSTATE) { + return _depositToSuperstate(vault, config, amount); + } else if (config.category == StrategyCategory.M0) { + return _depositToM0(vault, config, amount); + } else if (config.category == StrategyCategory.AAVEV3_UMBRELLA) { + return _stakeToAaveUmbrella(vault, config, amount); } else { revert("VaultManager: unsupported strategy"); } @@ -130,6 +206,14 @@ library VaultLib { return _withdrawFromAave(vault, config, amount); } else if (config.category == StrategyCategory.MORPHO) { return _withdrawFromMorpho(vault, config, amount); + } else if (config.category == StrategyCategory.SPARK) { + return _withdrawFromSpark(vault, config, amount); + } else if (config.category == StrategyCategory.SUPERSTATE) { + return _withdrawFromSuperstate(vault, config, amount); + } else if (config.category == StrategyCategory.M0) { + return _withdrawFromM0(vault, config, amount); + } else if (config.category == StrategyCategory.AAVEV3_UMBRELLA) { + return _unstakeFromAaveUmbrella(vault, config, amount); } else { revert("VaultManager: unsupported strategy"); } @@ -255,4 +339,282 @@ library VaultLib { return amount; } + + /// @notice Deposits assets into Spark + /// @param vault The vault address + /// @param _config The strategy config + /// @param amount The amount of assets to deposit + /// @return deposited The amount of assets deposited + function _depositToSpark(BoringVault vault, StrategyConfig memory _config, uint256 amount) + internal + returns (uint256 deposited) + { + vault.setTokenAllowance(address(_config.baseCollateral), _config.depositContract, amount); + + bytes memory sharesRaw = vault.manage( + address(_config.depositContract), + abi.encodeWithSignature("deposit(uint256,address,uint256,uint16)", amount, address(vault), 0, 181), + 0 + ); + + uint256 shares_ = abi.decode(sharesRaw, (uint256)); + + emit DepositToSpark(address(vault), address(_config.baseCollateral), amount, shares_); + + return amount; + } + + /// @notice Withdraws assets from Spark + /// @param vault The vault address + /// @param _config The strategy config + /// @param amount The amount of assets to withdraw + /// @return withdrawn The amount of assets withdrawn + function _withdrawFromSpark(BoringVault vault, StrategyConfig memory _config, uint256 amount) + internal + returns (uint256 withdrawn) + { + IERC4626 sparkVault = IERC4626(_config.withdrawContract); + + uint256 sharesToRedeem = sparkVault.previewWithdraw(amount); + + if (sharesToRedeem == 0) { + revert("VaultManager: amount must be greater than 0"); + } + + bytes memory sharesRaw = vault.manage( + address(_config.withdrawContract), + abi.encodeWithSignature("withdraw(uint256,address,address)", amount, address(vault), address(vault)), + 0 + ); + + uint256 shares_ = abi.decode(sharesRaw, (uint256)); + + emit WithdrawFromSpark(address(vault), address(_config.baseCollateral), amount, shares_); + + return amount; + } + + /// @notice Deposits assets into Superstate + /// + /// @dev In the future, Superstate may charge fees on deposits. + /// This will reduce the effective amount of base collateral received in USTB. + /// This could temporarily reduce backing and lead to slight undercollateralization. + /// However, such losses are expected to be recovered over time + /// through the yield generated by the Superstate assets + /// + /// @param vault The vault address + /// @param _config The strategy config + /// @param amount The amount of assets to deposit + /// @return deposited The amount of assets deposited + function _depositToSuperstate(BoringVault vault, StrategyConfig memory _config, uint256 amount) + internal + returns (uint256 deposited) + { + vault.setTokenAllowance(address(_config.baseCollateral), _config.depositContract, amount); + ISuperstateToken superstateToken = ISuperstateToken(_config.depositContract); + (uint256 superstateTokenOutAmount, uint256 stablecoinInAmountAfterFee,) = + superstateToken.calculateSuperstateTokenOut({inAmount: amount, stablecoin: address(_config.baseCollateral)}); + + vault.manage( + address(_config.depositContract), + abi.encodeWithSignature("subscribe(uint256,address)", amount, address(_config.baseCollateral)), + 0 + ); + + emit DepositToSuperstate( + address(vault), address(_config.baseCollateral), stablecoinInAmountAfterFee, superstateTokenOutAmount + ); + + return stablecoinInAmountAfterFee; + } + + /// @notice Withdraws assets from Superstate + /// + /// @dev In the future, Superstate may apply fees on redemptions, reducing the amount of collateral received. + /// Any such loss should be offset by the yield earned while the assets were held in Superstate. + /// + /// @param vault The vault address + /// @param _config The strategy config + /// @param amount The amount of assets to withdraw (USDC/USDT) + /// @return withdrawn The amount of assets withdrawn + function _withdrawFromSuperstate(BoringVault vault, StrategyConfig memory _config, uint256 amount) + internal + returns (uint256 withdrawn) + { + IRedemption redemption = IRedemption(_config.withdrawContract); + + // Calculate the amount of superstate token to redeem + (uint256 superstateTokenInAmount,) = redemption.calculateUstbIn(amount); + + // Approve the redemption contract to spend the superstate token + vault.setTokenAllowance(address(_config.receiptToken), address(redemption), superstateTokenInAmount); + + vault.manage(address(redemption), abi.encodeWithSignature("redeem(uint256)", superstateTokenInAmount), 0); + + emit WithdrawFromSuperstate(address(vault), address(_config.baseCollateral), amount, superstateTokenInAmount); + + return amount; + } + + /// @notice Deposits assets into M + /// + /// @dev The swap is subject to slippage, which may result in receiving fewer receipt tokens (e.g., wM) + /// than the deposit amount. This slippage may lead to temporary undercollateralization. + /// However, the loss is expected to be recovered over time through the yield generated by the wM position. + /// + /// @param vault The vault address + /// @param _config The strategy config + /// @param amount The amount of assets to deposit + /// @return deposited The amount of assets deposited + function _depositToM0(BoringVault vault, StrategyConfig memory _config, uint256 amount) + internal + returns (uint256 deposited) + { + address baseCollateral = address(_config.baseCollateral); + address wrappedM = address(_config.receiptToken); + address swapManager = address(_config.depositContract); + + // Approve vault to move USDC → SwapManager + vault.setTokenAllowance(baseCollateral, swapManager, amount); + + uint256 before = ERC20(wrappedM).balanceOf(address(vault)); + + vault.manage( + swapManager, + abi.encodeWithSignature( + "swap(address,address,uint256,address)", baseCollateral, wrappedM, amount, address(vault) + ), + 0 + ); + + uint256 afterBal = ERC20(wrappedM).balanceOf(address(vault)); + deposited = afterBal - before; + + emit DepositToM0(address(vault), baseCollateral, amount, deposited); + + return deposited; + } + + /// @notice Withdraws assets from M + /// + /// @dev The withdrawal is subject to slippage, which may result in receiving slightly less base collateral + /// than the wM amount. This may temporarily reduce collateral backing. However, the shortfall should be + /// offset by the yield earned while holding the wM receipt tokens. + /// + /// @param vault The vault address + /// @param _config The strategy config + /// @param amount The amount of assets to withdraw + /// @return withdrawn The amount of assets withdrawn + function _withdrawFromM0(BoringVault vault, StrategyConfig memory _config, uint256 amount) + internal + returns (uint256 withdrawn) + { + address baseCollateral = address(_config.baseCollateral); + address wrappedM = address(_config.receiptToken); + address swapManager = address(_config.depositContract); + + // Approve vault to move wM → SwapManager + vault.setTokenAllowance(wrappedM, swapManager, amount); + + uint256 before = ERC20(baseCollateral).balanceOf(address(vault)); + + vault.manage( + swapManager, + abi.encodeWithSignature( + "swap(address,address,uint256,address)", wrappedM, baseCollateral, amount, address(vault) + ), + 0 + ); + + uint256 afterBal = ERC20(baseCollateral).balanceOf(address(vault)); + withdrawn = afterBal - before; + + emit WithdrawFromM0(address(vault), baseCollateral, amount, withdrawn); + + return withdrawn; + } + + /// @notice Stakes wrapped aTokens into Aave Umbrella + /// @param vault The vault address + /// @param _config The strategy config + /// @param amount The amount of assets to stake + /// @return staked The amount of assets staked + function _stakeToAaveUmbrella(BoringVault vault, StrategyConfig memory _config, uint256 amount) + internal + returns (uint256 staked) + { + IERC4626StakeToken stakeToken = IERC4626StakeToken(_config.depositContract); + + // Wrap the aTokens + IERC4626StataToken stataToken = IERC4626StataToken(stakeToken.asset()); + vault.setTokenAllowance(address(_config.baseCollateral), address(stataToken), amount); + bytes memory sharesRaw = vault.manage( + address(stataToken), abi.encodeWithSignature("depositATokens(uint256,address)", amount, address(vault)), 0 + ); + uint256 shares = abi.decode(sharesRaw, (uint256)); + + // Stake waTokens with Aave Umbrella + vault.setTokenAllowance(address(stataToken), address(_config.depositContract), shares); + bytes memory stakedRaw = vault.manage( + address(_config.depositContract), + abi.encodeWithSignature("deposit(uint256,address)", shares, address(vault)), + 0 + ); + + uint256 staked_ = abi.decode(stakedRaw, (uint256)); + + emit StakeToAaveUmbrella(address(vault), address(_config.baseCollateral), amount, staked_); + + return staked_; + } + + /// @notice Unstakes waTokens from Aave Umbrella + /// @param vault The vault address + /// @param _config The strategy config + /// @param amount The amount of assets to unstake + /// @return unstaked The amount of assets unstaked + function _unstakeFromAaveUmbrella(BoringVault vault, StrategyConfig memory _config, uint256 amount) + internal + returns (uint256 unstaked) + { + // Get cooldown snapshot + IERC4626StakeToken stakeToken = IERC4626StakeToken(_config.depositContract); + IERC4626StataToken stataToken = IERC4626StataToken(stakeToken.asset()); + IERC4626StakeToken.CooldownSnapshot memory cooldownSnapshot = stakeToken.getStakerCooldown(address(vault)); + + if ( + block.timestamp > cooldownSnapshot.endOfCooldown + && block.timestamp - cooldownSnapshot.endOfCooldown <= cooldownSnapshot.withdrawalWindow + ) { + if (amount > cooldownSnapshot.amount) { + amount = cooldownSnapshot.amount; + } + + // We're in the withdrawal window + bytes memory unstakedRaw = vault.manage( + address(stakeToken), + abi.encodeWithSignature("redeem(uint256,address,address)", amount, address(vault), address(vault)), + 0 + ); + + uint256 wrappedATokens = abi.decode(unstakedRaw, (uint256)); + + bytes memory aTokensRaw = vault.manage( + address(stataToken), + abi.encodeWithSignature( + "redeemATokens(uint256,address,address)", wrappedATokens, address(vault), address(vault) + ), + 0 + ); + + uint256 aTokens = abi.decode(aTokensRaw, (uint256)); + + emit UnstakeFromAaveUmbrella(address(vault), address(_config.baseCollateral), amount, aTokens); + + return aTokens; + } else { + // We're not in the withdrawal window, need to call cooldown + revert("VaultManager: not in withdrawal window, call cooldown first"); + } + } } diff --git a/src/v2/interfaces/aave/IERC4626StakeToken.sol b/src/v2/interfaces/aave/IERC4626StakeToken.sol new file mode 100644 index 0000000..1af8f7e --- /dev/null +++ b/src/v2/interfaces/aave/IERC4626StakeToken.sol @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.0; + +import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +interface IERC4626StakeToken is IERC4626 { + struct CooldownSnapshot { + /// @notice Amount of shares available to redeem + uint192 amount; + /// @notice Timestamp after which funds will be unlocked for withdrawal + uint32 endOfCooldown; + /// @notice Period of time to withdraw funds after end of cooldown + uint32 withdrawalWindow; + } + + struct SignatureParams { + uint8 v; + bytes32 r; + bytes32 s; + } + + /** + * @notice Event is emitted when a cooldown of staker is changed. + * @param user Staker address + * @param amount Amount of shares on the time cooldown is changed + * @param endOfCooldown Future timestamp, from which funds can be withdrawn + * @param unstakeWindow Duration of time to withdraw funds + */ + event StakerCooldownUpdated(address indexed user, uint256 amount, uint256 endOfCooldown, uint256 unstakeWindow); + + /** + * @notice Event is emitted when a user installs/disables the operator for cooldown. + * @param user User address + * @param operator Address of operator to install/disable + * @param flag Flag responsible for setting/disabling operator + */ + event CooldownOperatorSet(address indexed user, address indexed operator, bool flag); + + /** + * @notice Event is emitted when a successful slash occurs + * @param destination Address, where funds transferred to + * @param amount Amount of funds transferred + */ + event Slashed(address indexed destination, uint256 amount); + + /** + * @notice Event is emitted when `cooldown` is changed to the new one + * @param oldCooldown Old `cooldown` duration + * @param newCooldown New `cooldown` duration + */ + event CooldownChanged(uint256 oldCooldown, uint256 newCooldown); + + /** + * @notice Event is emitted when `unstakeWindow` is changed to the new one + * @param oldUnstakeWindow Old `unstakeWindow` duration + * @param newUnstakeWindow new `unstakeWindow` duration + */ + event UnstakeWindowChanged(uint256 oldUnstakeWindow, uint256 newUnstakeWindow); + + /** + * @dev Attempted to set zero address as a variable. + */ + error ZeroAddress(); + + /** + * @dev Attempted to call cooldown without locked liquidity. + */ + error ZeroBalanceInStaking(); + + /** + * @dev Attempted to slash for zero amount of assets. + */ + error ZeroAmountSlashing(); + + /** + * @dev Attempted to slash with insufficient funds in staking. + */ + error ZeroFundsAvailable(); + + /** + * @dev Attempted to call cooldown without approval for `cooldownOnBehalf`. + * @param owner Address of user, which cooldown wasn't triggered + * @param spender Address of `msg.sender` + */ + error NotApprovedForCooldown(address owner, address spender); + + /** + * @notice Deposits by issuing approval for the required number of tokens (if `asset` supports the `permit` function). + * Emits a {Deposit} event. + * @param assets Amount of assets to be deposited + * @param receiver Receiver of shares + * @param deadline Signature deadline for issuing approve + * @param sig Signature parameters + * @return Amount of shares received + */ + function depositWithPermit(uint256 assets, address receiver, uint256 deadline, SignatureParams calldata sig) + external + returns (uint256); + + /** + * @notice Triggers user's `cooldown` using signature. + * Emits a {StakerCooldownUpdated} event. + * @param user The address, which `cooldown` will be triggered + * @param deadline Signature deadline for issuing approve + * @param sig Signature parameters + */ + function cooldownWithPermit(address user, uint256 deadline, SignatureParams calldata sig) external; + + /** + * @notice Activates the cooldown period to unstake for `msg.sender`. + * It can't be called if the user is not staking. + * Emits a {StakerCooldownUpdated} event. + */ + function cooldown() external; + + /** + * @notice Activates the cooldown period to unstake for a certain user. + * It can't be called if the user is not staking. + * `from` must set as `cooldownOperator` for `msg.sender` so that he can activate the cooldown on his behalf. + * Emits a {StakerCooldownUpdated} event. + * @param from Address at which the `cooldown` will be activated + */ + function cooldownOnBehalfOf(address from) external; + + /** + * @notice Sets the ability to call `cooldownOnBehalf` for `msg.sender` by specified `operator` to `true` or `false`. + * Doesn't revert if the new `flag` value is the same as the old one. + * Emits a {CooldownOnBehalfChanged} event. + * @param operator The address that the ability to call `cooldownOnBehalf` for `msg.sender` can be changed + * @param flag True - to activate this ability, false - to deactivate + */ + function setCooldownOperator(address operator, bool flag) external; + + /** + * @notice Executes a slashing of the asset of a certain amount, transferring the seized funds + * to destination. Decreasing the amount of underlying will automatically adjust the exchange rate. + * If the amount exceeds maxSlashableAmount then the second one is taken. + * Can only be called by the `owner`. + * Emits a {Slashed} event. + * @param destination Address where seized funds will be transferred + * @param amount Amount to be slashed + * @return amount Amount slashed + */ + function slash(address destination, uint256 amount) external returns (uint256); + + /** + * @notice Pauses the contract, can be called by `owner`. + * Emits a {Paused} event. + */ + function pause() external; + + /** + * @notice Unpauses the contract, can be called by `owner`. + * Emits a {Unpaused} event. + */ + function unpause() external; + + /** + * @notice Sets a new `cooldown` duration. + * Can only be called by the `owner`. + * Emits a {CooldownChanged} event. + * @param cooldown Amount of seconds users have to wait between starting the `cooldown` and being able to withdraw funds + */ + function setCooldown(uint256 cooldown) external; + + /** + * @notice Sets a new `unstakeWindow` duration. + * Can only be called by the `owner`. + * Emits a {UnstakeWindowChanged} event. + * @param newUnstakeWindow Amount of seconds users have to withdraw after `cooldown` + */ + function setUnstakeWindow(uint256 newUnstakeWindow) external; + + /** + * @notice Returns current `cooldown` duration. + * @return _cooldown duration + */ + function getCooldown() external view returns (uint256); + + /** + * @notice Returns current `unstakeWindow` duration. + * @return _unstakeWindow duration + */ + function getUnstakeWindow() external view returns (uint256); + + /** + * @notice Returns the last activated user `cooldown`. Contains the amount of tokens and timestamp. + * May return zero values ​​if all funds have been withdrawn or transferred. + * @param user Address of user + * @return User's cooldown snapshot + */ + function getStakerCooldown(address user) external view returns (CooldownSnapshot memory); + + /** + * @notice Returns true if the user's cooldown can be triggered by an operator, false - otherwise. + * @param user Address of the user. + * @param operator Address of an operator. + * @return Is operator set for `cooldownOnBehalf` + */ + function isCooldownOperator(address user, address operator) external view returns (bool); + + /** + * @notice Returns the next unused nonce for an address, which could be used inside signature for `cooldownWithPermit()` function. + * @param owner Address for which unused `cooldown` nonce will be returned + * @return The next unused `cooldown` nonce + */ + function cooldownNonces(address owner) external view returns (uint256); + + /** + * @notice Returns the maximum slashable assets available for now. + * @return Maximum assets available for slash + */ + function getMaxSlashableAssets() external view returns (uint256); + + /** + * @notice Returns the minimum amount of assets, which can't be slashed. + * @return Minimum assets value that cannot be slashed + */ + function MIN_ASSETS_REMAINING() external view returns (uint256); +} diff --git a/src/v2/interfaces/aave/IERC4626StataToken.sol b/src/v2/interfaces/aave/IERC4626StataToken.sol new file mode 100644 index 0000000..b8969a9 --- /dev/null +++ b/src/v2/interfaces/aave/IERC4626StataToken.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +import {IPool} from "@level/src/v2/interfaces/aave/IPool.sol"; +import {IPoolAddressesProvider} from "@level/src/v2/interfaces/aave/IPoolAddressesProvider.sol"; + +interface IERC4626StataToken { + struct SignatureParams { + uint8 v; + bytes32 r; + bytes32 s; + } + + error PoolAddressMismatch(address pool); + + error StaticATokenInvalidZeroShares(); + + error OnlyPauseGuardian(address caller); + + /** + * @notice The pool associated with the aToken. + * @return The pool address. + */ + function POOL() external view returns (IPool); + + /** + * @notice The poolAddressesProvider associated with the pool. + * @return The poolAddressesProvider address. + */ + function POOL_ADDRESSES_PROVIDER() external view returns (IPoolAddressesProvider); + + /** + * @notice Burns `shares` of static aToken, with receiver receiving the corresponding amount of aToken + * @param shares The shares to withdraw, in static balance of StaticAToken + * @param receiver The address that will receive the amount of `ASSET` withdrawn from the Aave protocol + * @return amountToWithdraw: aToken send to `receiver`, dynamic balance + * + */ + function redeemATokens(uint256 shares, address receiver, address owner) external returns (uint256); + + /** + * @notice Deposits aTokens and mints static aTokens to msg.sender + * @param assets The amount of aTokens to deposit (e.g. deposit of 100 aUSDC) + * @param receiver The address that will receive the static aTokens + * @return uint256 The amount of StaticAToken minted, static balance + * + */ + function depositATokens(uint256 assets, address receiver) external returns (uint256); + + /** + * @notice Universal deposit method for proving aToken or underlying liquidity with permit + * @param assets The amount of aTokens or underlying to deposit + * @param receiver The address that will receive the static aTokens + * @param deadline Must be a timestamp in the future + * @param sig A `secp256k1` signature params from `msgSender()` + * @return uint256 The amount of StaticAToken minted, static balance + * + */ + function depositWithPermit( + uint256 assets, + address receiver, + uint256 deadline, + SignatureParams memory sig, + bool depositToAave + ) external returns (uint256); + + /** + * @notice The aToken used inside the 4626 vault. + * @return address The aToken address. + */ + function aToken() external view returns (address); + + /** + * @notice Returns the current asset price of the stataToken. + * The price is calculated as `underlying_price * exchangeRate`. + * It is important to note that: + * - `underlying_price` is the price obtained by the aave-oracle and is subject to it's internal pricing mechanisms. + * - as the price is scaled over the exchangeRate, but maintains the same precision as the underlying the price might be underestimated by 1 unit. + * - when pricing multiple `shares` as `shares * price` keep in mind that the error compounds. + * @return price the current asset price. + */ + function latestAnswer() external view returns (int256); +} diff --git a/src/v2/interfaces/level/IVaultManager.sol b/src/v2/interfaces/level/IVaultManager.sol index d1022d3..205bf7c 100644 --- a/src/v2/interfaces/level/IVaultManager.sol +++ b/src/v2/interfaces/level/IVaultManager.sol @@ -57,6 +57,7 @@ interface IVaultManagerErrors { error StrategyAlreadyExists(); error StrategyDoesNotExist(); error NoStrategiesProvided(); + error InvalidOperatorOrUmbrellaVault(); } /// @title IVaultManager diff --git a/src/v2/interfaces/superstate/IAllowListV2.sol b/src/v2/interfaces/superstate/IAllowListV2.sol new file mode 100644 index 0000000..e5d65c2 --- /dev/null +++ b/src/v2/interfaces/superstate/IAllowListV2.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +interface IAllowListV2 { + type EntityId is uint256; + + /// @notice An event emitted when an address's permission is changed for a fund. + event FundPermissionSet(EntityId indexed entityId, string fundSymbol, bool permission); + + /// @notice An event emitted when a protocol's permission is changed for a fund. + event ProtocolAddressPermissionSet(address indexed addr, string fundSymbol, bool isAllowed); + + /// @notice An event emitted when an address is associated with an entityId + event EntityIdSet(address indexed addr, uint256 indexed entityId); + + /// @dev Thrown when the input for a function is invalid + error BadData(); + + /// @dev Thrown when the input is already equivalent to the storage being set + error AlreadySet(); + + /// @dev An address's entityId can not be changed once set, it can only be unset and then set to a new value + error NonZeroEntityIdMustBeChangedToZero(); + + /// @dev Thrown when trying to set entityId for an address that has protocol permissions + error AddressHasProtocolPermissions(); + /// @dev Thrown when trying to set protocol permissions for an address that has an entityId + error AddressHasEntityId(); + /// @dev Thrown when trying to set protocol permissions but the code size is 0 + error CodeSizeZero(); + /// @dev Thrown when a method is no longer supported + error Deprecated(); + /// @dev Thrown if an attempt to call `renounceOwnership` is made + error RenounceOwnershipDisabled(); + + /// @notice Gets the owner of the allowlist + function owner() external view returns (address); + + /** + * @notice Gets the entityId for the provided address + * @param addr The address to get the entityId for + */ + function addressEntityIds(address addr) external view returns (EntityId); + + /** + * @notice Checks whether an address is allowed to use a fund + * @param addr The address to check permissions for + * @param fundSymbol The fund symbol to check permissions for + */ + function isAddressAllowedForFund(address addr, string calldata fundSymbol) external view returns (bool); + + /** + * @notice Checks whether an Entity is allowed to use a fund + * @param fundSymbol The fund symbol to check permissions for + */ + function isEntityAllowedForFund(EntityId entityId, string calldata fundSymbol) external view returns (bool); + + /** + * @notice Sets whether an Entity is allowed to use a fund + * @param fundSymbol The fund symbol to set permissions for + * @param isAllowed The permission value to set + */ + function setEntityAllowedForFund(EntityId entityId, string calldata fundSymbol, bool isAllowed) external; + + /** + * @notice Sets the entityId for a given address. Setting to 0 removes the address from the allowList + * @param entityId The entityId to associate with an address + * @param addr The address to associate with an entityId + */ + function setEntityIdForAddress(EntityId entityId, address addr) external; + + /** + * @notice Sets the entity Id for a list of addresses. Setting to 0 removes the address from the allowList + * @param entityId The entityId to associate with an address + * @param addresses The addresses to associate with an entityId + */ + function setEntityIdForMultipleAddresses(EntityId entityId, address[] calldata addresses) external; + + /** + * @notice Sets protocol permissions for an address + * @param addr The address to set permissions for + * @param fundSymbol The fund symbol to set permissions for + * @param isAllowed The permission value to set + */ + function setProtocolAddressPermission(address addr, string calldata fundSymbol, bool isAllowed) external; + + /** + * @notice Sets protocol permissions for multiple addresses + * @param addresses The addresses to set permissions for + * @param fundSymbol The fund symbol to set permissions for + * @param isAllowed The permission value to set + */ + function setProtocolAddressPermissions(address[] calldata addresses, string calldata fundSymbol, bool isAllowed) + external; + + /** + * @notice Sets entity for an array of addresses and sets permissions for an entity + * @param entityId The entityId to be updated + * @param addresses The addresses to associate with an entityId + * @param fundPermissionsToUpdate The funds to update permissions for + * @param fundPermissions The permissions for each fund + */ + function setEntityPermissionsAndAddresses( + EntityId entityId, + address[] calldata addresses, + string[] calldata fundPermissionsToUpdate, + bool[] calldata fundPermissions + ) external; + + function hasAnyProtocolPermissions(address addr) external view returns (bool hasPermissions); + + function protocolPermissionsForFunds(address protocol) external view returns (uint256); + + function protocolPermissions(address, string calldata) external view returns (bool); + + function initialize() external; +} diff --git a/src/v2/interfaces/superstate/IRedemption.sol b/src/v2/interfaces/superstate/IRedemption.sol new file mode 100644 index 0000000..3701fa8 --- /dev/null +++ b/src/v2/interfaces/superstate/IRedemption.sol @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +interface IRedemption { + /// @notice The ```SetMaximumOracleDelay``` event is emitted when the max oracle delay is set + /// @param oldMaxOracleDelay The old max oracle delay + /// @param newMaxOracleDelay The new max oracle delay + event SetMaximumOracleDelay(uint256 oldMaxOracleDelay, uint256 newMaxOracleDelay); + + /// @notice The ```SetSweepDestination``` event is emitted when the sweep destination is set + /// @param oldSweepDestination The old sweep destination + /// @param newSweepDestination The new sweep destination + event SetSweepDestination(address oldSweepDestination, address newSweepDestination); + + /// @notice The ```SetRedemptionFee``` event is emitted when the redemption fee is set + /// @param oldFee The old fee + /// @param newFee The new fee + event SetRedemptionFee(uint256 oldFee, uint256 newFee); + + /// @dev Event emitted when SUPERSTATE_TOKEN is redeemed for USDC + /// @param redeemer The address of the entity redeeming + /// @param to The receiver address for the redeemed USDC + /// @param superstateTokenInAmount The amount of SUPERSTATE_TOKEN to redeem + /// @param usdcOutAmountAfterFee The amount of USDC the redeemer gets back, after the fee is deducted + /// @param usdcOutAmountBeforeFee The amount of USDC the redeemer gets back, before the fee is deducted + event RedeemV2( + address indexed redeemer, + address indexed to, + uint256 superstateTokenInAmount, + uint256 usdcOutAmountAfterFee, + uint256 usdcOutAmountBeforeFee + ); + + /// @dev Event emitted when tokens are withdrawn + /// @param token The address of the token being withdrawn + /// @param withdrawer The address of the caller + /// @param to The address receiving the tokens + /// @param amount The amount of token the redeemer gets back + event Withdraw(address indexed token, address indexed withdrawer, address indexed to, uint256 amount); + + /// @dev Thrown when an argument is invalid + error BadArgs(); + + /// @dev Thrown when Chainlink Oracle data is bad + error BadChainlinkData(); + + /// @dev Thrown when owner tries to set the fee for a stablecoin too high + error FeeTooHigh(); + + /// @dev Thrown when there isn't enough token balance in the contract + error InsufficientBalance(); + + /// @dev Thrown if an attempt to call `renounceOwnership` is made + error RenounceOwnershipDisabled(); + + function getChainlinkPrice() external view returns (bool _isBadData, uint256 _updatedAt, uint256 _price); + function calculateUsdcOut(uint256 superstateTokenInAmount) + external + view + returns (uint256 usdcOutAmount, uint256 usdPerUstbChainlinkRaw); + function calculateUstbIn(uint256 usdcOutAmount) + external + view + returns (uint256 ustbInAmount, uint256 usdPerUstbChainlinkRaw); + function calculateFee(uint256 amount) external view returns (uint256); + function maxUstbRedemptionAmount() + external + view + returns (uint256 superstateTokenAmount, uint256 usdPerUstbChainlinkRaw); + function maximumOracleDelay() external view returns (uint256); + function sweepDestination() external view returns (address); + function redemptionFee() external view returns (uint256); + function pause() external; + function redeem(address to, uint256 superstateTokenInAmount) external; + function redeem(uint256 superstateTokenInAmount) external; + function setMaximumOracleDelay(uint256 _newMaxOracleDelay) external; + function setSweepDestination(address _newSweepDestination) external; + function setRedemptionFee(uint256 _newFee) external; + function unpause() external; + function withdraw(address _token, address to, uint256 amount) external; + function withdrawToSweepDestination(uint256 amount) external; + function initialize( + address initialOwner, + uint256 _maximumOracleDelay, + address _sweepDestination, + uint256 _redemptionFee + ) external; +} diff --git a/src/v2/interfaces/superstate/ISuperstateToken.sol b/src/v2/interfaces/superstate/ISuperstateToken.sol new file mode 100644 index 0000000..82cecbd --- /dev/null +++ b/src/v2/interfaces/superstate/ISuperstateToken.sol @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.28; + +import {IAllowListV2} from "./IAllowListV2.sol"; + +interface ISuperstateToken { + // V1 remaining + + /// @dev Event emitted when tokens are minted + event Mint(address indexed minter, address indexed to, uint256 amount); + + /// @dev Emitted when the accounting pause is triggered by `admin`. + event AccountingPaused(address admin); + + /// @dev Emitted when the accounting pause is lifted by `admin`. + event AccountingUnpaused(address admin); + + /// @dev Thrown when a request is not sent by the authorized admin + error Unauthorized(); + + /// @dev Thrown when an address does not have sufficient permissions, as dictated by the AllowList + error InsufficientPermissions(); + + /// @dev Thrown when the current timestamp has surpassed the expiration time for a signature + error SignatureExpired(); + + /// @dev Thrown if the signature has an S value that is in the upper half order. + error InvalidSignatureS(); + + /// @dev Thrown if the signature is invalid or its signer does not match the expected singer + error BadSignatory(); + + /// @dev Thrown if accounting pause is already on + error AccountingIsPaused(); + + /// @dev Thrown if accounting pause is already off + error AccountingIsNotPaused(); + + /// @dev Thrown if array length arguments aren't equal + error InvalidArgumentLengths(); + + /// @dev Thrown if `to` does not share the same `entityId` as `msg.sender` during subscribe + error MismatchEntityIds(); + + /** + * @notice Returns the domain separator used in the encoding of the + * signature for permit + * @return bytes32 The domain separator + */ + function DOMAIN_SEPARATOR() external view returns (bytes32); + + /// @notice The next expected nonce for an address, for validating authorizations via signature + function nonces(address toFind) external view returns (uint256); + + /** + * @notice Invokes the {Pausable-_pause} internal function + * @dev Can only be called by the admin + */ + function pause() external; + + /** + * @notice Invokes the {Pausable-_unpause} internal function + * @dev Can only be called by the admin + */ + function unpause() external; + + /** + * @return bool True if the accounting is currently paused, false otherwise + */ + function accountingPaused() external view returns (bool); + + /** + * @notice Pauses mint and burn + * @dev Can only be called by the admin + */ + function accountingPause() external; + + /** + * @notice Unpauses mint and burn + * @dev Can only be called by the admin + */ + function accountingUnpause() external; + + /** + * @notice Sets approval amount for a spender via signature from signatory + * @param owner The address that signed the signature + * @param spender The address to authorize (or rescind authorization from) + * @param value Amount that `owner` is approving for `spender` + * @param deadline Expiration time for the signature + * @param v The recovery byte of the signature + * @param r Half of the ECDSA signature pair + * @param s Half of the ECDSA signature pair + */ + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external; + + /** + * @notice Mint new tokens to a recipient + * @dev Only callable by the admin + * @param dst Recipient of the minted tokens + * @param amount Amount of tokens to mint + */ + function mint(address dst, uint256 amount) external; + + /** + * @notice Mint new tokens to many recipients + * @dev Only callable by the admin + * @param dsts Recipients of the minted tokens + * @param amounts Amounts of tokens to mint + */ + function bulkMint(address[] calldata dsts, uint256[] calldata amounts) external; + + /** + * @notice Initialize the contract + * @param _name The token name + * @param _symbol The token symbol + */ + function initialize(string calldata _name, string calldata _symbol) external; + + // V2 remaining + + /// @dev Thrown if an attempt to call `renounceOwnership` is made + error RenounceOwnershipDisabled(); + + /** + * @notice Initialize version 2 of the contract. + * @notice If creating an entirely new contract, the original `initialize` method still needs to be called. + */ + function initializeV2() external; + + // V3 remaining + + /// @dev Struct for storing supported stablecoin configuration + struct StablecoinConfig { + address sweepDestination; + uint96 fee; + } + + /// @dev Emitted when the max oracle delay is set + event SetMaximumOracleDelay(uint256 oldMaxOracleDelay, uint256 newMaxOracleDelay); + + /// @dev Event emitted when the address for the pricing oracle changes + event SetOracle(address oldOracle, address newOracle); + + /// @dev Event emitted when the configuration for a supported stablecoin changes + event SetStablecoinConfig( + address indexed stablecoin, + address oldSweepDestination, + address newSweepDestination, + uint96 oldFee, + uint96 newFee + ); + + /// @dev Event emitted when stablecoins are used to Subscribe to a Superstate fund + /// @param subscriber The address of the subscriber + /// @param to The address of the recipient + /// @param stablecoin The address of the stablecoin used to subscribe + /// @param stablecoinInAmountAfterFee The amount of stablecoin used to subscribe, after fees are deducted + /// @param stablecoinInAmountBeforeFee The amount of stablecoin used to subscribe, before fees are deducted + /// @param superstateTokenOutAmount The amount of Superstate tokens received + event SubscribeV2( + address indexed subscriber, + address indexed to, + address stablecoin, + uint256 stablecoinInAmountAfterFee, + uint256 stablecoinInAmountBeforeFee, + uint256 superstateTokenOutAmount + ); + + /// @dev Thrown when an argument is invalid + error BadArgs(); + + /// @dev Thrown when Chainlink Oracle data is bad + error BadChainlinkData(); + + /// @dev Thrown when owner tries to set the fee for a stablecoin too high + error FeeTooHigh(); + + /// @dev Thrown when the superstateUstbOracle is the 0 address + error OnchainSubscriptionsDisabled(); + + /// @dev Thrown when trying to calculate amount of Superstate Tokens you'd get for an unsupported stablecoin + error StablecoinNotSupported(); + + /// @dev Thrown when the msg.sender would receive 0 Superstate tokens out for their call to subscribe, or trying to bridge 0 tokens + error ZeroSuperstateTokensOut(); + + function allowListV2() external view returns (IAllowListV2); + + /** + * @notice Initialize version 3 of the contract + * @notice If creating an entirely new contract, the original `initialize` method still needs to be called. + */ + function initializeV3(IAllowListV2 _allowList) external; + + // V4 + + /// @dev Event emitted when the admin burns tokens + event AdminBurn(address indexed burner, address indexed src, uint256 amount); + + /// @dev Event emitted when the user wants to bridge their tokens to another chain or book entry + event Bridge( + address caller, + address indexed src, + uint256 amount, + address indexed ethDestinationAddress, + string indexed otherDestinationAddress, + uint256 chainId + ); + + /// @dev Event emitted when the users wants to redeem their shares with an offchain payout + event OffchainRedeem(address indexed burner, address indexed src, uint256 amount); + + /// @dev Event emitted when the owner changes the redemption contract address + event SetRedemptionContract(address oldRedemptionContract, address newRedemptionContract); + + /// @notice Emitted when a chain ID's support status is updated + event SetChainIdSupport(uint256 indexed chainId, bool oldSupported, bool newSupported); + + /// @dev Thrown when bridge function arguments have two destinations + error TwoDestinationsInvalid(); + + /// @dev Thrown when bridge function chainId is set to 0 but onchain destination arguments are provided + error OnchainDestinationSetForBridgeToBookEntry(); + + /// @dev Thrown when bridge function chainId is not supported + error BridgeChainIdDestinationNotSupported(); + + /** + * @notice Check permissions of an address for transferring + * @param addr Address to check permissions for + * @return bool True if the address has sufficient permission, false otherwise + */ + function isAllowed(address addr) external view returns (bool); + + /** + * @notice Burn tokens from a given source address + * @dev Only callable by the admin + * @param src Source address from which tokens will be burned + * @param amount Amount of tokens to burn + */ + function adminBurn(address src, uint256 amount) external; + + /** + * @notice Burn tokens from the caller's address for offchain redemption + * @param amount Amount of tokens to burn + */ + function offchainRedeem(uint256 amount) external; + + /** + * @notice Burn tokens from the caller's address to bridge to another chain + * @dev If destination address on chainId isn't on allowlist, or chainID isn't supported, tokens wind up in book entry + * @param amount Amount of tokens to burn + * @param ethDestinationAddress ETH address to send to on another chain + * @param otherDestinationAddress Non-EVM addresses to send to on another chain + * @param chainId Numerical identifier of destination chain to send tokens to + */ + function bridge( + uint256 amount, + address ethDestinationAddress, + string calldata otherDestinationAddress, + uint256 chainId + ) external; + + /** + * @notice Burn tokens from the caller's address to bridge to Superstate book entry + * @param amount Amount of tokens to burn + */ + function bridgeToBookEntry(uint256 amount) external; + + /** + * @notice Sets redemption contract address + * @dev Used for convenience for devs + * @dev Set to address(0) if no such contract exists for the token + * @param _newRedemptionContract New contract address + */ + function setRedemptionContract(address _newRedemptionContract) external; + + /** + * @notice Sets support status for a specific chain ID + * @param _chainId The chain ID to update + * @param _supported Whether the chain ID should be supported + */ + function setChainIdSupport(uint256 _chainId, bool _supported) external; + + /** + * @notice The ```subscribe`` function takes in stablecoins and mints SuperstateToken in the proper amount for the to address depending on the current Net Asset Value per Share. + * @param to The address where USTB will be deposited at + * @param inAmount The amount of the stablecoin in + * @param stablecoin The address of the stablecoin to calculate with + */ + function subscribe(address to, uint256 inAmount, address stablecoin) external; + + /** + * @notice The ```subscribe``` function takes in stablecoins and mints SuperstateToken in the proper amount for the msg.sender depending on the current Net Asset Value per Share. + * @param inAmount The amount of the stablecoin in + * @param stablecoin The address of the stablecoin to calculate with + */ + function subscribe(uint256 inAmount, address stablecoin) external; + + /** + * @notice The ```setOracle``` function sets the address of the AggregatorV3Interface to be used to price the SuperstateToken + * @dev Requires msg.sender to be the owner address + * @param _newOracle The address of the oracle contract to update to + */ + function setOracle(address _newOracle) external; + + /** + * @notice The ```setMaximumOracleDelay``` function sets the max oracle delay to determine if Chainlink data is stale + * @dev Requires msg.sender to be the owner address + * @param _newMaxOracleDelay The new max oracle delay + */ + function setMaximumOracleDelay(uint256 _newMaxOracleDelay) external; + + /** + * @notice The ```setStablecoinConfig``` function sets the configuration fields for accepted stablecoins for onchain subscriptions + * @dev Requires msg.sender to be the owner address + * @param stablecoin The address of the stablecoin + * @param newSweepDestination The new address to sweep stablecoin subscriptions to + * @param newFee The new fee in basis points to charge for subscriptions in ```stablecoin``` + */ + function setStablecoinConfig(address stablecoin, address newSweepDestination, uint96 newFee) external; + + /** + * @notice The ```getChainlinkPrice``` function returns the chainlink price and the timestamp of the last update + * @return _isBadData True if the data is stale or negative + * @return _updatedAt The timestamp of the last update + * @return _price The price + */ + function getChainlinkPrice() external view returns (bool _isBadData, uint256 _updatedAt, uint256 _price); + + /** + * @notice Calculates the fee amount based on the input amount and subscription fee + * @param amount The input amount to calculate fee for + * @param subscriptionFee The fee rate to apply + * @return The calculated fee amount + */ + function calculateFee(uint256 amount, uint256 subscriptionFee) external pure returns (uint256); + + /** + * @notice The ```calculateSuperstateTokenOut``` function calculates the total amount of Superstate tokens you'll receive for the inAmount of stablecoin. Treats all stablecoins as if they are always worth a dollar. + * @param inAmount The amount of the stablecoin in + * @param stablecoin The address of the stablecoin to calculate with + * @return superstateTokenOutAmount The amount of Superstate tokens received for inAmount of stablecoin + * @return stablecoinInAmountAfterFee The amount of stablecoin in after any fees + * @return feeOnStablecoinInAmount The amount of the stablecoin taken in fees + */ + function calculateSuperstateTokenOut(uint256 inAmount, address stablecoin) + external + view + returns (uint256 superstateTokenOutAmount, uint256 stablecoinInAmountAfterFee, uint256 feeOnStablecoinInAmount); +} diff --git a/src/v2/interfaces/uniswap/ISwapRouter.sol b/src/v2/interfaces/uniswap/ISwapRouter.sol new file mode 100644 index 0000000..3a2d366 --- /dev/null +++ b/src/v2/interfaces/uniswap/ISwapRouter.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; +pragma abicoder v2; + +/// @title Callback for IUniswapV3PoolActions#swap +/// @notice Any contract that calls IUniswapV3PoolActions#swap must implement this interface +interface IUniswapV3SwapCallback { + /// @notice Called to `msg.sender` after executing a swap via IUniswapV3Pool#swap. + /// @dev In the implementation you must pay the pool tokens owed for the swap. + /// The caller of this method must be checked to be a UniswapV3Pool deployed by the canonical UniswapV3Factory. + /// amount0Delta and amount1Delta can both be 0 if no tokens were swapped. + /// @param amount0Delta The amount of token0 that was sent (negative) or must be received (positive) by the pool by + /// the end of the swap. If positive, the callback must send that amount of token0 to the pool. + /// @param amount1Delta The amount of token1 that was sent (negative) or must be received (positive) by the pool by + /// the end of the swap. If positive, the callback must send that amount of token1 to the pool. + /// @param data Any data passed through by the caller via the IUniswapV3PoolActions#swap call + function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata data) external; +} + +/// @title Router token swapping functionality +/// @notice Functions for swapping tokens via Uniswap V3 +interface ISwapRouter is IUniswapV3SwapCallback { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + /// @notice Swaps `amountIn` of one token for as much as possible of another token + /// @param params The parameters necessary for the swap, encoded as `ExactInputSingleParams` in calldata + /// @return amountOut The amount of the received token + function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); + + struct ExactInputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + } + + /// @notice Swaps `amountIn` of one token for as much as possible of another along the specified path + /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactInputParams` in calldata + /// @return amountOut The amount of the received token + function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut); + + struct ExactOutputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + uint160 sqrtPriceLimitX96; + } + + /// @notice Swaps as little as possible of one token for `amountOut` of another token + /// @param params The parameters necessary for the swap, encoded as `ExactOutputSingleParams` in calldata + /// @return amountIn The amount of the input token + function exactOutputSingle(ExactOutputSingleParams calldata params) external payable returns (uint256 amountIn); + + struct ExactOutputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + } + + /// @notice Swaps as little as possible of one token for `amountOut` of another along the specified path (reversed) + /// @param params The parameters necessary for the multi-hop swap, encoded as `ExactOutputParams` in calldata + /// @return amountIn The amount of the input token + function exactOutput(ExactOutputParams calldata params) external payable returns (uint256 amountIn); +} diff --git a/src/v2/lens/LevelReserveLens.sol b/src/v2/lens/LevelReserveLens.sol index 9c20387..413e50a 100644 --- a/src/v2/lens/LevelReserveLens.sol +++ b/src/v2/lens/LevelReserveLens.sol @@ -37,7 +37,7 @@ contract LevelReserveLens is Initializable, OwnableUpgradeable, UUPSUpgradeable, // TODO: update when rewards manager is deployed // Immutable values are incompatible with upgradeable contracts (see https://forum.openzeppelin.com/t/upgradable-contracts-instantiating-an-immutable-value/28763/2) // Since this contract would be updating an existing proxy's implementation, we're choosing to set this as a constant. - address public constant rewardsManager = 0x81dC431C5D213Ef87266502993822f1340e26B11; + address public constant rewardsManager = 0xBD05B8B22fE4ccf093a6206C63Cc39f02345E0DA; /** * @notice Helper function to get the reserves of the given collateral token. diff --git a/src/v2/oracles/CappedOneDollarOracle.sol b/src/v2/oracles/CappedOneDollarOracle.sol new file mode 100644 index 0000000..0ff0b39 --- /dev/null +++ b/src/v2/oracles/CappedOneDollarOracle.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity ^0.8.28; + +import {AggregatorV3Interface} from "@level/src/v2/interfaces/AggregatorV3Interface.sol"; + +/** + * .-==+=======+: + * :---=-::-==: + * .-:-==-:-==: + * .:::--::::::. .--:-=--:--. .:--:::--.. + * .=++=++:::::.. .:::---::--. ....::...:::. + * :::-::..::.. .::::-:::::. ...::...:::. + * ...::..::::.. .::::--::-:. ....::...:::.. + * ............ ....:::..::. ------:...... + * ........... ........:.... .....::..:.. ======-...... ........... + * :------:.:... ...:+***++*#+ .------:---. ...::::.:::... .....:-----::. + * .::::::::-:.. .::--..:-::.. .-=+===++=-==: ...:::..:--:.. .:==+=++++++*: + * + * @title CappedOneDollarOracle + * @author Level (https://level.money) + * @notice Oracle that returns the lower of 1 dollar or the price from another oracle + */ +contract CappedOneDollarOracle is AggregatorV3Interface { + uint8 public constant override decimals = 8; + AggregatorV3Interface public immutable externalOracle; + + constructor(address _externalOracle) { + externalOracle = AggregatorV3Interface(_externalOracle); + } + + function description() external pure override returns (string memory) { + return "Capped $1 Oracle with fallback"; + } + + function version() external pure override returns (uint256) { + return 1; + } + + function getRoundData(uint80 _roundId) + external + view + override + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + (roundId, answer, startedAt, updatedAt, answeredInRound) = externalOracle.getRoundData(_roundId); + + // Cap the price at $1.00 (1e8) + int256 capped = answer < 1e8 ? answer : int256(1e8); + + return (roundId, capped, startedAt, updatedAt, answeredInRound); + } + + function latestRoundData() + external + view + override + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + (uint80 extRoundId, int256 extAnswer, uint256 extStartedAt, uint256 extUpdatedAt, uint80 extAnsweredInRound) = + externalOracle.latestRoundData(); + + // Cap the price at $1.00 (1e8) + int256 capped = extAnswer < 1e8 ? extAnswer : int256(1e8); + + return (extRoundId, capped, extStartedAt, extUpdatedAt, extAnsweredInRound); + } +} diff --git a/src/v2/periphery/RewardsDistributor.sol b/src/v2/periphery/RewardsDistributor.sol new file mode 100644 index 0000000..6c49aa6 --- /dev/null +++ b/src/v2/periphery/RewardsDistributor.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {LevelMintingV2} from "@level/src/v2/LevelMintingV2.sol"; +import {ILevelMintingV2, ILevelMintingV2Structs} from "@level/src/v2/interfaces/level/ILevelMintingV2.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {MathLib} from "@level/src/v2/common/libraries/MathLib.sol"; +import {VaultLib} from "@level/src/v2/common/libraries/VaultLib.sol"; +import {StrategyLib, StrategyConfig} from "@level/src/v2/common/libraries/StrategyLib.sol"; +import {RewardsManagerStorage} from "@level/src/v2/usd/RewardsManagerStorage.sol"; +import {IRewardsManager} from "@level/src/v2/interfaces/level/IRewardsManager.sol"; +import {PauserGuardedUpgradable} from "@level/src/v2/common/guard/PauserGuardedUpgradable.sol"; +import {OracleLib} from "@level/src/v2/common/libraries/OracleLib.sol"; +import {BoringVault} from "@level/src/v2/usd/BoringVault.sol"; + +contract RewardsDistributor { + using SafeERC20 for ERC20; + using MathLib for uint256; + using StrategyLib for StrategyConfig; + using VaultLib for BoringVault; + + LevelMintingV2 public immutable mintingContract; + BoringVault public immutable vault; + IRewardsManager public immutable rewardsManager; + + constructor(address _mintingContract, address _rewardsManager) { + mintingContract = LevelMintingV2(_mintingContract); + vault = mintingContract.vaultManager().vault(); + rewardsManager = IRewardsManager(_rewardsManager); + } + + function mint(ERC20 _asset) external returns (uint256) { + uint256 balance = _asset.balanceOf(msg.sender); + + _asset.safeTransferFrom(msg.sender, address(this), balance); + + if (balance == 0) revert("InvalidAmount"); + + _asset.forceApprove(address(vault), balance); + ILevelMintingV2Structs.Order memory order = ILevelMintingV2Structs.Order({ + beneficiary: msg.sender, + collateral_asset: address(_asset), + collateral_amount: balance, + min_lvlusd_amount: 0 + }); + return mintingContract.mint(order); + } + + function getAccruedYield(address[] memory assets) public view returns (uint256 accrued) { + uint256 total; + + for (uint256 i = 0; i < assets.length; i++) { + address asset = assets[i]; + + StrategyConfig[] memory strategies = rewardsManager.getAllStrategies(asset); + + uint256 totalForAsset = vault._getTotalAssets(strategies, asset); + + (int256 price, uint256 decimals) = OracleLib.getPriceAndDecimals( + RewardsManagerStorage(address(rewardsManager)).oracles(asset), + RewardsManagerStorage(address(rewardsManager)).HEARTBEAT() + ); + uint256 adjustedAmount; + + // Check if price is under peg + if (uint256(price) < 10 ** decimals) { + adjustedAmount = totalForAsset.mulDivDown(uint256(price), 10 ** decimals); + total += adjustedAmount.convertDecimalsDown(ERC20(asset).decimals(), vault.decimals()); + } else { + total += totalForAsset.convertDecimalsDown(ERC20(asset).decimals(), vault.decimals()); + } + } + + uint256 vaultShares = vault.balanceOf(address(vault)); + + if (total <= vaultShares) { + // If the total is less than the vault shares, return 0 + // This can happen if the price is under peg + return 0; + } + + accrued = total - vaultShares; + + return accrued; + } +} diff --git a/src/v2/usd/SwapManager.sol b/src/v2/usd/SwapManager.sol new file mode 100644 index 0000000..1bdf88e --- /dev/null +++ b/src/v2/usd/SwapManager.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IUniswapV3Pool} from "@uniswap-v3-core/interfaces/IUniswapV3Pool.sol"; +import {ISwapRouter} from "@level/src/v2/interfaces/uniswap/ISwapRouter.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SwapManagerStorage} from "./SwapManagerStorage.sol"; +import {Initializable} from "@openzeppelin-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {AuthUpgradeable} from "@level/src/v2/auth/AuthUpgradeable.sol"; +import {SwapConfig} from "./SwapManagerStorage.sol"; + +/** + * .-==+=======+: + * :---=-::-==: + * .-:-==-:-==: + * .:::--::::::. .--:-=--:--. .:--:::--.. + * .=++=++:::::.. .:::---::--. ....::...:::. + * :::-::..::.. .::::-:::::. ...::...:::. + * ...::..::::.. .::::--::-:. ....::...:::.. + * ............ ....:::..::. ------:...... + * ........... ........:.... .....::..:.. ======-...... ........... + * :------:.:... ...:+***++*#+ .------:---. ...::::.:::... .....:-----::. + * .::::::::-:.. .::--..:-::.. .-=+===++=-==: ...:::..:--:.. .:==+=++++++*: + * + * @title SwapManager + * @author Level (https://level.money) + * @notice Manages token swaps using Uniswap V3 pools with configurable parameters + * @dev This contract is upgradeable and uses UUPS pattern + */ +contract SwapManager is SwapManagerStorage, Initializable, UUPSUpgradeable, AuthUpgradeable { + /// @notice Constructor that disables initializers + constructor() { + _disableInitializers(); + } + + /// @notice Initializes the contract with admin and swap router addresses + /// @param admin_ The address of the admin who can manage the contract + /// @param swapRouter_ The address of the Uniswap V3 Swap Router + function initialize(address admin_, address swapRouter_) external initializer { + __UUPSUpgradeable_init(); + __Auth_init(admin_, address(0)); + swapRouter = ISwapRouter(swapRouter_); + } + + /// @notice Sets the swap configuration for a token pair + /// @param tokenIn The address of the input token + /// @param tokenOut The address of the output token + /// @param config The swap configuration parameters + /// @dev Only callable by authorized addresses + function setSwapConfig(address tokenIn, address tokenOut, SwapConfig memory config) external requiresAuth { + // add access control here + require(config.pool != address(0), "Invalid pool"); + swapConfigs[tokenIn][tokenOut] = config; + } + + /// @notice Executes a token swap using the configured parameters + /// @param tokenIn The address of the input token + /// @param tokenOut The address of the output token + /// @param amountIn The amount of input tokens to swap + /// @param recipient The address that will receive the output tokens + /// @return amountOut The amount of output tokens received + /// @dev Checks tick range and liquidity before executing swap + /// + /// @dev In order to enforce checks on the amount of token coming out, + /// use the slippage in the pair's swap config. + function swap(address tokenIn, address tokenOut, uint256 amountIn, address recipient) + external + returns (uint256 amountOut) + { + SwapConfig memory config = swapConfigs[tokenIn][tokenOut]; + require(config.active, "Swap not enabled"); + + // Check tick range + (, int24 currentTick,,,,,) = IUniswapV3Pool(config.pool).slot0(); + require(currentTick >= config.tickLower && currentTick <= config.tickUpper, "SwapManager: Out of tick range"); + + uint128 liquidity = IUniswapV3Pool(config.pool).liquidity(); + require(liquidity > 0, "SwapManager: No liquidity"); + + // Transfer & approve + IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); + IERC20(tokenIn).approve(address(swapRouter), amountIn); + + // Calculate slippage min out + uint256 minOut = (amountIn * (10_000 - config.slippageBps)) / 10_000; + + // Build params + ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + fee: config.fee, + recipient: recipient, + deadline: block.timestamp + 60, + amountIn: amountIn, + amountOutMinimum: minOut, + sqrtPriceLimitX96: 0 + }); + + amountOut = swapRouter.exactInputSingle(params); + } + + // ------- Upgradeable --------- + + /// @notice Authorizes an upgrade to a new implementation + /// @param newImplementation The address of the new implementation + /// @dev Only callable by authorized addresses + function _authorizeUpgrade(address newImplementation) internal override requiresAuth {} +} diff --git a/src/v2/usd/SwapManagerStorage.sol b/src/v2/usd/SwapManagerStorage.sol new file mode 100644 index 0000000..b506434 --- /dev/null +++ b/src/v2/usd/SwapManagerStorage.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ISwapRouter} from "@level/src/v2/interfaces/uniswap/ISwapRouter.sol"; + +/** + * @title SwapConfig + * @notice Configuration structure for managing swap parameters + * @dev Contains all necessary parameters for configuring a swap pool and its behavior + */ +struct SwapConfig { + /// @notice The address of the Uniswap V3 pool + address pool; + /// @notice The fee tier of the pool in hundredths of a basis point (e.g., 3000 = 0.3%) + uint24 fee; + /// @notice The lower tick boundary for the position + int24 tickLower; + /// @notice The upper tick boundary for the position + int24 tickUpper; + /// @notice The maximum allowed slippage in basis points (e.g., 10 = 0.1%) + uint256 slippageBps; + /// @notice Whether this swap configuration is currently active + bool active; +} + +/** + * @title SwapManagerStorage + * @notice Storage contract for managing swap configurations and router interactions + * @dev This contract stores the swap router and configurations for different token pairs + * @dev Inherits from OpenZeppelin's storage gap pattern for upgradeability + */ +abstract contract SwapManagerStorage { + /// @notice The Uniswap V3 SwapRouter contract instance + ISwapRouter public swapRouter; + + /// @notice Mapping of token pairs to their swap configurations + /// @dev First address is token0, second address is token1 + mapping(address => mapping(address => SwapConfig)) public swapConfigs; + + /** + * @dev This empty reserved space is put in place to allow future versions to add new + * variables without shifting down storage in the inheritance chain. + * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + */ + uint256[50] private __gap; +} diff --git a/src/v2/usd/VaultManager.sol b/src/v2/usd/VaultManager.sol index acc1ce4..b42a8c6 100644 --- a/src/v2/usd/VaultManager.sol +++ b/src/v2/usd/VaultManager.sol @@ -183,6 +183,52 @@ contract VaultManager is emit DefaultStrategiesSet(_asset, strategies); } + // ------- Aave Umbrella --------- + + /// @notice Approve or revoke an address to be able to start the cooldown of boring vault on Umbrella + /// @param operator The address to approve or revoke + /// @param umbrellaVault The umbrella vault to modify the cooldown operator for + /// @param isApproved Whether to approve or revoke the operator + /// @dev callable only by the STRATEGIST_ROLE (e.g. operator) + /// @dev will emit a CooldownOnBehalfChanged event from the umbrella vault + /// @dev if an approved operator calls cooldown during an ongoing cooldown, the timer will start over + function modifyAaveUmbrellaCooldownOperator(address operator, address umbrellaVault, bool isApproved) + external + requiresAuth + { + if (operator == address(0) || umbrellaVault == address(0)) { + revert InvalidOperatorOrUmbrellaVault(); + } + + vault.manage( + address(umbrellaVault), + abi.encodeWithSignature("setCooldownOperator(address,bool)", operator, isApproved), + 0 + ); + } + + /// @notice Approve or revoke an address to be able to claim rewards from Aave Umbrella on behalf of the vault + /// @param rewardsClaimer The address to approve or revoke + /// @param umbrellaRewardsController The umbrella rewards controller of Aave Umbrella + /// @param isApproved Whether to approve or revoke the rewards claimer + /// @dev callable only by the STRATEGIST_ROLE (e.g. operator) + /// @dev will emit a ClaimerSet event from the umbrella rewards controller + function modifyAaveUmbrellaRewardsClaimer( + address rewardsClaimer, + address umbrellaRewardsController, + bool isApproved + ) external requiresAuth { + if (rewardsClaimer == address(0) || umbrellaRewardsController == address(0)) { + revert InvalidOperatorOrUmbrellaVault(); + } + + vault.manage( + address(umbrellaRewardsController), + abi.encodeWithSignature("setClaimer(address,bool)", rewardsClaimer, isApproved), + 0 + ); + } + // ------- Internal ------------ /// @notice Internal function to deposit an asset into the vault /// @param asset The address of the asset to deposit diff --git a/test/v2/integration/LevelReserveLens.t.sol b/test/v2/integration/LevelReserveLens.t.sol index 6ced436..afdafb3 100644 --- a/test/v2/integration/LevelReserveLens.t.sol +++ b/test/v2/integration/LevelReserveLens.t.sol @@ -30,24 +30,15 @@ contract LevelReserveLensTests is Utils, Configurable { uint256 public constant INITIAL_BALANCE = 1000e6; function setUp() public { - forkMainnet(22305203); + forkMainnet(22444195); - deployer = vm.createWallet("deployer"); - - DeployLevel deployScript = new DeployLevel(); - - // Deploy + // Use Mainnet config + initConfig(1); + deployer = vm.createWallet("deployer"); vm.prank(deployer.addr); - deployScript.setUp_(1, deployer.privateKey); - - config = deployScript.run(); - - rewardsManager = config.levelContracts.rewardsManager; lens = config.levelContracts.levelReserveLens; - console2.log(address(rewardsManager)); - // Upgrade level reserve lens UpgradeLevelReserveLens upgradeScript = new UpgradeLevelReserveLens(); @@ -104,31 +95,37 @@ contract LevelReserveLensTests is Utils, Configurable { uint256 usdcReservesAfterDeal = lens.getReserves(address(config.tokens.usdc)); uint256 usdtReservesAfterDeal = lens.getReserves(address(config.tokens.usdt)); - assertEq( + assertApproxEqRel( usdcReservesAfterDeal, usdcReservesBeforeDeal + INITIAL_BALANCE.convertDecimalsDown(config.tokens.aUsdc.decimals(), config.tokens.lvlUsd.decimals()), + 0.00000001e18, "USDC reserves do not match" ); - assertEq( + assertApproxEqRel( usdtReservesAfterDeal, usdtReservesBeforeDeal + INITIAL_BALANCE.convertDecimalsDown(config.tokens.aUsdt.decimals(), config.tokens.lvlUsd.decimals()), + 0.00000001e18, "USDT reserves do not match" ); } function test__getReserves_succedsWithMorphoTokens() public { - uint256 steakhouseUsdcShares = 1000000e18; uint256 usdcReservesBeforeDeal = lens.getReserves(address(config.tokens.usdc)); - deal( - address(config.morphoVaults.steakhouseUsdc.vault), - address(config.levelContracts.boringVault), - steakhouseUsdcShares + deal(address(config.tokens.usdc), address(config.levelContracts.boringVault), 1_000_000e6); + + vm.prank(address(config.levelContracts.boringVault)); + config.tokens.usdc.approve(address(config.morphoVaults.steakhouseUsdc.vault), 1_000_000e6); + + vm.prank(address(config.levelContracts.boringVault)); + uint256 sharesReceived = config.morphoVaults.steakhouseUsdc.vault.deposit( + 1_000_000e6, // assets + address(config.levelContracts.boringVault) ); - uint256 steakhouseUsdcAssets = config.morphoVaults.steakhouseUsdc.vault.convertToAssets(steakhouseUsdcShares); + uint256 steakhouseUsdcAssets = config.morphoVaults.steakhouseUsdc.vault.convertToAssets(sharesReceived); uint256 usdcReservesAfterDeal = lens.getReserves(address(config.tokens.usdc)); diff --git a/test/v2/integration/RewardsManager.t.sol b/test/v2/integration/RewardsManager.t.sol index 2929cb6..5d3de96 100644 --- a/test/v2/integration/RewardsManager.t.sol +++ b/test/v2/integration/RewardsManager.t.sol @@ -23,6 +23,7 @@ import {MockERC4626} from "@level/test/v2/mocks/MockERC4626.sol"; import {IERC4626Oracle} from "@level/src/v2/interfaces/level/IERC4626Oracle.sol"; import {ILevelMintingV2Structs} from "@level/src/v2/interfaces/level/ILevelMintingV2.sol"; import {lvlUSD} from "@level/src/v1/lvlUSD.sol"; +import {CappedOneDollarOracle} from "@level/src/v2/oracles/CappedOneDollarOracle.sol"; contract RewardsManagerMainnetTests is Utils, Configurable { using SafeTransferLib for ERC20; @@ -76,6 +77,8 @@ contract RewardsManagerMainnetTests is Utils, Configurable { _scheduleAndExecuteAdminActionBatch( address(config.users.admin), address(config.levelContracts.adminTimelock), targets, payloads ); + _mockChainlinkCall(address(config.oracles.ustb), 105e5); // 10.5 USD per USTB + _mockChainlinkCall(address(config.oracles.mNav), 1e8); // 1 USD per wrappedM deal(address(config.tokens.usdc), address(strategist.addr), INITIAL_BALANCE); deal(address(config.tokens.usdt), address(strategist.addr), INITIAL_BALANCE); @@ -354,6 +357,7 @@ contract RewardsManagerMainnetTests is Utils, Configurable { _scheduleAndExecuteAdminActionBatch( address(config.users.admin), address(config.levelContracts.adminTimelock), targets, payloads ); + _mockChainlinkCall(address(config.oracles.ustb), 105e5); // 10.5 USD per USTB vm.startPrank(strategist.addr); @@ -413,6 +417,27 @@ contract RewardsManagerMainnetTests is Utils, Configurable { assertApproxEqRel(totalAssets, 2 * INITIAL_BALANCE + deposit, 0.0001e18, "Total assets do not match"); } + function test_customMNavOracle_succeeds() public { + // wrappedM oracle should return 1 USD + CappedOneDollarOracle mNavOracle = new CappedOneDollarOracle(address(config.oracles.mNav)); + + // Get latest price from the oracle + (, int256 price,,,) = mNavOracle.latestRoundData(); + assertEq(price, 1e8, "Price should be 1 USD"); + + _mockChainlinkCall(address(config.oracles.mNav), 105e6); // 1.05 USD per M + + // Get latest price from the oracle + (, price,,,) = mNavOracle.latestRoundData(); + assertEq(price, 1e8, "Price should be 1 USD"); + + _mockChainlinkCall(address(config.oracles.mNav), 99e6); // 0.99 USD per M + + // Get latest price from the oracle + (, price,,,) = mNavOracle.latestRoundData(); + assertEq(price, 99e6, "Price should be 0.99 USD"); + } + // ------------- Internal Helpers ------------- function _getAssetsInStrategy(address asset, address strategy) public view returns (uint256) { @@ -469,10 +494,24 @@ contract RewardsManagerMainnetTests is Utils, Configurable { } } + // Need to mock chainlink call for ustb + // because vm.warp() makes it return stale prices + function _mockChainlinkCall(address chainLinkFeed, int256 price) internal { + AggregatorV3Interface chainlink = AggregatorV3Interface(chainLinkFeed); + + uint80 roundId = 1; + uint256 startedAt = block.timestamp; + uint256 updatedAt = block.timestamp; + uint80 answeredInRound = 1; + + vm.mockCall( + address(chainlink), + abi.encodeWithSelector(chainlink.latestRoundData.selector), + abi.encode(roundId, price, startedAt, updatedAt, answeredInRound) + ); + } + function _printBalance(address asset, address vault) internal { console2.log(vm.getLabel(asset), ERC20(asset).balanceOf(vault)); } - - // Test cases to add: - // - Test when morpho yield accrues } diff --git a/test/v2/integration/VaultManager.t.sol b/test/v2/integration/VaultManager.t.sol index ee34cd1..03b0020 100644 --- a/test/v2/integration/VaultManager.t.sol +++ b/test/v2/integration/VaultManager.t.sol @@ -17,6 +17,12 @@ import {AggregatorV3Interface} from "@level/src/v2/interfaces/AggregatorV3Interf import {VaultLib} from "@level/src/v2/common/libraries/VaultLib.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {IERC4626Oracle} from "@level/src/v2/interfaces/level/IERC4626Oracle.sol"; +import {AggregatorV3Interface} from "@level/src/v2/interfaces/AggregatorV3Interface.sol"; +import {IAllowListV2} from "@level/src/v2/interfaces/superstate/IAllowListV2.sol"; +import {UpgradeVaultManager} from "@level/script/v2/usd/UpgradeVaultManager.s.sol"; +import {DeploySwapManager} from "@level/script/v2/usd/DeploySwapManager.s.sol"; +import {IERC4626StataToken} from "@level/src/v2/interfaces/aave/IERC4626StataToken.sol"; +import {IERC4626StakeToken} from "@level/src/v2/interfaces/aave/IERC4626StakeToken.sol"; contract VaultManagerMainnetTests is Utils, Configurable { using SafeTransferLib for ERC20; @@ -28,6 +34,8 @@ contract VaultManagerMainnetTests is Utils, Configurable { uint256 public constant INITIAL_BALANCE = 100_000_000e6; uint256 public constant INITIAL_SHARES = 200_000_000e18; + address public constant USTB_CHAINLINK_FEED = 0xE4fA682f94610cCd170680cc3B045d77D9E528a8; + address public constant USTB_ALLOWLIST_ADDRESS = 0x873b548Ee1e5813dBE35898AC4d63e8b41809109; StrategyConfig[] public usdcStrategies; StrategyConfig[] public usdtStrategies; @@ -36,25 +44,35 @@ contract VaultManagerMainnetTests is Utils, Configurable { StrategyConfig public steakhouseUsdtConfig; StrategyConfig public re7UsdcConfig; StrategyConfig public steakhouseUsdtLiteConfig; + StrategyConfig public sparkUsdcConfig; + StrategyConfig public umbrellaConfig; + + event Referral(uint16 indexed referral, address indexed owner, uint256 assets, uint256 shares); function setUp() public { - forkMainnet(22305203); + forkMainnet(22664895); deployer = vm.createWallet("deployer"); strategist = vm.createWallet("strategist"); - DeployLevel deployScript = new DeployLevel(); + DeploySwapManager deploySwapManager = new DeploySwapManager(); + + vm.prank(deployer.addr); + deploySwapManager.setUp_(1, deployer.privateKey); + config = deploySwapManager.run(); + + UpgradeVaultManager upgradeScript = new UpgradeVaultManager(); // Deploy vm.prank(deployer.addr); - deployScript.setUp_(1, deployer.privateKey); + upgradeScript.setUp_(1, deployer.privateKey, config); - config = deployScript.run(); + config = upgradeScript.run(); // Setup strategist vm.prank(config.users.admin); - _setupMorphoVaultsForTests(); + _setupVaultsForTests(); address[] memory targets = new address[](2); targets[0] = address(config.levelContracts.rolesAuthority); @@ -71,6 +89,14 @@ contract VaultManagerMainnetTests is Utils, Configurable { deal(address(config.tokens.usdc), address(config.levelContracts.boringVault), INITIAL_BALANCE); deal(address(config.tokens.usdt), address(config.levelContracts.boringVault), INITIAL_BALANCE); + // Since we are using a fork, the vault has exisiting balances of various strategies + // The tests are designed according to zero initial balances + _resetTokenBalance(config.tokens.aUsdc, address(config.levelContracts.boringVault)); + _resetTokenBalance(config.tokens.aUsdt, address(config.levelContracts.boringVault)); + _resetTokenBalance( + ERC20(address(config.morphoVaults.steakhouseUsdc.vault)), address(config.levelContracts.boringVault) + ); + deal(address(config.levelContracts.boringVault), address(config.levelContracts.boringVault), INITIAL_SHARES); vaultManager = config.levelContracts.vaultManager; @@ -126,7 +152,7 @@ contract VaultManagerMainnetTests is Utils, Configurable { } } - function _setupMorphoVaultsForTests() internal { + function _setupVaultsForTests() internal { //--------------- Add test Morpho vaults as strategies if (address(config.morphoVaults.steakhouseUsdt.vault) == address(0)) { revert("Steakhouse USDT vaults not deployed"); @@ -140,6 +166,14 @@ contract VaultManagerMainnetTests is Utils, Configurable { revert("Steakhouse USDT Lite vaults not deployed"); } + if (address(config.sparkVaults.sUsdc.vault) == address(0)) { + revert("Spark USDC vault not deployed"); + } + + if (address(config.umbrellaVaults.waUsdcStakeToken.vault) == address(0)) { + revert("Umbrella vault not deployed"); + } + if (address(config.morphoVaults.re7Usdc.oracle) == address(0)) { config.morphoVaults.re7Usdc.oracle = deployERC4626Oracle(config.morphoVaults.re7Usdc.vault, 4 hours); } @@ -154,6 +188,15 @@ contract VaultManagerMainnetTests is Utils, Configurable { deployERC4626Oracle(config.morphoVaults.steakhouseUsdtLite.vault, 4 hours); } + if (address(config.sparkVaults.sUsdc.oracle) == address(0)) { + config.sparkVaults.sUsdc.oracle = deployERC4626Oracle(config.sparkVaults.sUsdc.vault, 4 hours); + } + + if (address(config.umbrellaVaults.waUsdcStakeToken.oracle) == address(0)) { + config.umbrellaVaults.waUsdcStakeToken.oracle = + deployERC4626Oracle(config.umbrellaVaults.waUsdcStakeToken.vault, 4 hours); + } + //--------------- Add test Morpho vaults as strategies steakhouseUsdcConfig = StrategyConfig({ category: StrategyCategory.MORPHO, @@ -195,24 +238,47 @@ contract VaultManagerMainnetTests is Utils, Configurable { heartbeat: 1 days }); - address[] memory usdcDefaultStrategies = new address[](3); + sparkUsdcConfig = StrategyConfig({ + category: StrategyCategory.SPARK, + baseCollateral: config.tokens.usdc, + receiptToken: ERC20(address(config.sparkVaults.sUsdc.vault)), + oracle: config.sparkVaults.sUsdc.oracle, + depositContract: address(config.sparkVaults.sUsdc.vault), + withdrawContract: address(config.sparkVaults.sUsdc.vault), + heartbeat: 1 days + }); + + umbrellaConfig = StrategyConfig({ + category: StrategyCategory.AAVEV3_UMBRELLA, + baseCollateral: config.tokens.aUsdc, + receiptToken: ERC20(address(config.umbrellaVaults.waUsdcStakeToken.vault)), + oracle: config.umbrellaVaults.waUsdcStakeToken.oracle, + depositContract: address(config.umbrellaVaults.waUsdcStakeToken.vault), + withdrawContract: address(config.umbrellaVaults.waUsdcStakeToken.vault), + heartbeat: 1 days + }); + + address[] memory usdcDefaultStrategies = new address[](4); usdcDefaultStrategies[0] = address(config.periphery.aaveV3); usdcDefaultStrategies[1] = address(config.morphoVaults.steakhouseUsdc.vault); usdcDefaultStrategies[2] = address(config.morphoVaults.re7Usdc.vault); + usdcDefaultStrategies[3] = address(config.sparkVaults.sUsdc.vault); address[] memory usdtDefaultStrategies = new address[](3); usdtDefaultStrategies[0] = address(config.periphery.aaveV3); usdtDefaultStrategies[1] = address(config.morphoVaults.steakhouseUsdt.vault); usdtDefaultStrategies[2] = address(config.morphoVaults.steakhouseUsdtLite.vault); - address[] memory targets = new address[](5); + address[] memory targets = new address[](7); targets[0] = address(config.levelContracts.vaultManager); targets[1] = address(config.levelContracts.vaultManager); targets[2] = address(config.levelContracts.vaultManager); targets[3] = address(config.levelContracts.vaultManager); targets[4] = address(config.levelContracts.vaultManager); + targets[5] = address(config.levelContracts.vaultManager); + targets[6] = address(config.levelContracts.vaultManager); - bytes[] memory payloads = new bytes[](5); + bytes[] memory payloads = new bytes[](7); payloads[0] = abi.encodeWithSelector( VaultManager.addAssetStrategy.selector, address(config.tokens.usdc), @@ -232,9 +298,21 @@ contract VaultManagerMainnetTests is Utils, Configurable { steakhouseUsdtLiteConfig ); payloads[3] = abi.encodeWithSelector( - VaultManager.setDefaultStrategies.selector, address(config.tokens.usdc), usdcDefaultStrategies + VaultManager.addAssetStrategy.selector, + address(config.tokens.usdc), + address(config.sparkVaults.sUsdc.vault), + sparkUsdcConfig ); payloads[4] = abi.encodeWithSelector( + VaultManager.addAssetStrategy.selector, + address(config.tokens.usdc), + address(config.umbrellaVaults.waUsdcStakeToken.vault), + umbrellaConfig + ); + payloads[5] = abi.encodeWithSelector( + VaultManager.setDefaultStrategies.selector, address(config.tokens.usdc), usdcDefaultStrategies + ); + payloads[6] = abi.encodeWithSelector( VaultManager.setDefaultStrategies.selector, address(config.tokens.usdt), usdtDefaultStrategies ); @@ -410,21 +488,421 @@ contract VaultManagerMainnetTests is Utils, Configurable { ); } + // ------------- Umbrella Tests ------------- + + function test_modifyCooldownOperator_succeeds() public { + // Create a new operator + address operator = makeAddr("operator"); + address umbrellaVault = address(config.umbrellaVaults.waUsdcStakeToken.vault); + + vm.startPrank(strategist.addr); + vaultManager.modifyAaveUmbrellaCooldownOperator(operator, umbrellaVault, true); + vm.stopPrank(); + + // Check we're not in cooldown + IERC4626StakeToken.CooldownSnapshot memory cooldownSnapshot = + IERC4626StakeToken(umbrellaVault).getStakerCooldown(address(config.levelContracts.boringVault)); + + assertEq(cooldownSnapshot.endOfCooldown, 0); + + // NOTE: cooldown() or cooldownOnBehalfOf() cannot be called unless we are staked + deal(umbrellaVault, address(config.levelContracts.boringVault), 1e18); + + // Call cooldown on behalf of the boring vault + vm.prank(operator); + IERC4626StakeToken(umbrellaVault).cooldownOnBehalfOf(address(config.levelContracts.boringVault)); + + // Check we're in cooldown + cooldownSnapshot = + IERC4626StakeToken(umbrellaVault).getStakerCooldown(address(config.levelContracts.boringVault)); + assertGt(cooldownSnapshot.endOfCooldown, block.timestamp, "Cooldown should have started"); + } + + function test_callingCooldownAgain_resetsCooldown() public { + IERC4626StakeToken umbrellaVault = IERC4626StakeToken(address(config.umbrellaVaults.waUsdcStakeToken.vault)); + + // Make the strategist staked in the vault + deal(address(config.umbrellaVaults.waUsdcStakeToken.vault), strategist.addr, 1e18); + + // Check strategist is not in cooldown + IERC4626StakeToken.CooldownSnapshot memory cooldownSnapshot = umbrellaVault.getStakerCooldown(strategist.addr); + assertEq(cooldownSnapshot.endOfCooldown, 0); + + // Call cooldown on itself + vm.prank(strategist.addr); + IERC4626StakeToken(umbrellaVault).cooldown(); + + cooldownSnapshot = umbrellaVault.getStakerCooldown(strategist.addr); + assertNotEq(cooldownSnapshot.endOfCooldown, 0); + assertEq(cooldownSnapshot.amount, 1e18); + + vm.warp(block.timestamp + 1 days); + + // Call cooldown again + vm.prank(strategist.addr); + IERC4626StakeToken(umbrellaVault).cooldown(); + + IERC4626StakeToken.CooldownSnapshot memory newCooldownSnapshot = + umbrellaVault.getStakerCooldown(strategist.addr); + assertGt(newCooldownSnapshot.endOfCooldown, cooldownSnapshot.endOfCooldown, "Cooldown should have been reset"); + } + + function test_settingClaimer_succeeds() public { + // Create a new operator + address operator = makeAddr("operator"); + address umbrellaRewardsController = 0x4655Ce3D625a63d30bA704087E52B4C31E38188B; + address umbrellaVault = address(config.umbrellaVaults.waUsdcStakeToken.vault); + + vm.startPrank(strategist.addr); + vaultManager.modifyAaveUmbrellaRewardsClaimer(operator, umbrellaRewardsController, true); + vm.stopPrank(); + + (bool isClaimerAuthorized,) = umbrellaRewardsController.staticcall( + abi.encodeWithSignature("isClaimerAuthorized(address,address)", umbrellaVault, operator) + ); + assertEq(isClaimerAuthorized, true); + } + + function test_depositingToUmbrella_succeeds(uint256 deposit) public { + deposit = bound(deposit, 1e3, INITIAL_BALANCE); + vm.startPrank(strategist.addr); + + // Need to get some aUsdc into the vault + config.tokens.aUsdc.transfer(address(config.levelContracts.boringVault), deposit); + + // Deposit aUsdc into the vault + vaultManager.deposit( + address(config.tokens.usdc), address(config.umbrellaVaults.waUsdcStakeToken.vault), deposit + ); + + uint256 expectedWrappedBalance = + IERC4626(config.umbrellaVaults.waUsdcStakeToken.vault.asset()).previewDeposit(deposit); + + uint256 expectedStakedBalance = + config.umbrellaVaults.waUsdcStakeToken.vault.previewDeposit(expectedWrappedBalance); + + // Balance of stk-waToken in the vault + uint256 balance = + config.umbrellaVaults.waUsdcStakeToken.vault.balanceOf(address(config.levelContracts.boringVault)); + + // Allow rounding error of 1 + assertApproxEqAbs(balance, expectedStakedBalance, 1, "Wrong amount of stk-waToken"); + } + + function test_wrappingOfaUsdcToWaUsdc_succeeds(uint256 deposit) public { + deposit = bound(deposit, 1e3, INITIAL_BALANCE); + vm.startPrank(strategist.addr); + + uint256 aUsdcBalance = config.tokens.aUsdc.balanceOf(strategist.addr); + console2.log("aUsdcBalance", aUsdcBalance); + + IERC4626StataToken stataToken = IERC4626StataToken(config.umbrellaVaults.waUsdcStakeToken.vault.asset()); + + uint256 expectedWrappedBalance = IERC4626(address(stataToken)).previewDeposit(deposit); + + config.tokens.aUsdc.approve(address(stataToken), deposit); + stataToken.depositATokens(deposit, strategist.addr); + + // Check we received the correct amount of waUsdc + assertEq(IERC4626(address(stataToken)).balanceOf(strategist.addr), expectedWrappedBalance); + + vm.stopPrank(); + } + + function test_withdrawingFromUmbrella_succeeds(uint256 deposit) public { + deposit = bound(deposit, 1e3, INITIAL_BALANCE); + vm.startPrank(strategist.addr); + + // Need to get some aUsdc into the vault + config.tokens.aUsdc.transfer(address(config.levelContracts.boringVault), deposit); + + // Deposit aUsdc into the vault + vaultManager.deposit( + address(config.tokens.usdc), address(config.umbrellaVaults.waUsdcStakeToken.vault), deposit + ); + vm.stopPrank(); + + // Start cooldown + vm.prank(address(config.levelContracts.boringVault)); + IERC4626StakeToken(address(config.umbrellaVaults.waUsdcStakeToken.vault)).cooldown(); + + // Find out cooldown end time + IERC4626StakeToken.CooldownSnapshot memory cooldownSnapshot = IERC4626StakeToken( + address(config.umbrellaVaults.waUsdcStakeToken.vault) + ).getStakerCooldown(address(config.levelContracts.boringVault)); + + // Warp to end of cooldown + vm.warp(cooldownSnapshot.endOfCooldown + 1); + + vm.startPrank(strategist.addr); + // Withdraw from the vault + vaultManager.withdraw( + address(config.tokens.usdc), address(config.umbrellaVaults.waUsdcStakeToken.vault), deposit + ); + + // Check we received the correct amount of aUsdc + assertGe( + config.tokens.aUsdc.balanceOf(address(config.levelContracts.boringVault)), deposit, "Wrong amount of aUsdc" + ); + } + + function test_withdrawingFromUmbrella_failsIfNotInCooldown(uint256 deposit) public { + deposit = bound(deposit, 1e3, INITIAL_BALANCE); + vm.startPrank(strategist.addr); + + // Need to get some aUsdc into the vault + config.tokens.aUsdc.transfer(address(config.levelContracts.boringVault), deposit); + + // Deposit aUsdc into the vault + vaultManager.deposit( + address(config.tokens.usdc), address(config.umbrellaVaults.waUsdcStakeToken.vault), deposit + ); + + vm.expectRevert("VaultManager: not in withdrawal window, call cooldown first"); + vaultManager.withdraw( + address(config.tokens.usdc), address(config.umbrellaVaults.waUsdcStakeToken.vault), deposit + ); + + vm.stopPrank(); + + // Start cooldown + vm.prank(address(config.levelContracts.boringVault)); + IERC4626StakeToken(address(config.umbrellaVaults.waUsdcStakeToken.vault)).cooldown(); + + vm.startPrank(strategist.addr); + vm.expectRevert("VaultManager: not in withdrawal window, call cooldown first"); + vaultManager.withdraw( + address(config.tokens.usdc), address(config.umbrellaVaults.waUsdcStakeToken.vault), deposit + ); + + IERC4626StakeToken.CooldownSnapshot memory cooldownSnapshot = IERC4626StakeToken( + address(config.umbrellaVaults.waUsdcStakeToken.vault) + ).getStakerCooldown(address(config.levelContracts.boringVault)); + vm.warp(cooldownSnapshot.endOfCooldown + 1); + + // Should work now + vaultManager.withdraw( + address(config.tokens.usdc), address(config.umbrellaVaults.waUsdcStakeToken.vault), deposit + ); + + vm.stopPrank(); + } + + // ------------- Spark Tests ------------- + + function test_depositSparkUsdc_succeeds(uint256 deposit) public { + deposit = bound(deposit, 1e3, INITIAL_BALANCE); + + vm.startPrank(strategist.addr); + + vaultManager.deposit(address(config.tokens.usdc), address(config.sparkVaults.sUsdc.vault), deposit); + + // Check we received the correct amount of vault shares + assertEq( + config.sparkVaults.sUsdc.vault.balanceOf(address(config.levelContracts.boringVault)), + config.sparkVaults.sUsdc.vault.convertToShares(deposit), + "Wrong amount of vault shares" + ); + + // Check USDC balance + assertApproxEqAbs( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_BALANCE - deposit, + 1, + "Wrong amount of usdc" + ); + + // Print sUsdc vault address + console2.log("sUsdc vault address", address(config.sparkVaults.sUsdc.vault)); + + // Check total assets + assertApproxEqRel( + _getTotalAssets(address(config.tokens.usdc)), INITIAL_BALANCE, 0.000001e18, "Wrong amount of total assets" + ); + } + + function test_depositSparkUsdc_ReferralCode_succeeds(uint256 deposit) public { + deposit = bound(deposit, 1e3, INITIAL_BALANCE); + vm.startPrank(strategist.addr); + + uint256 expectedShares = config.sparkVaults.sUsdc.vault.previewDeposit(deposit); + + vm.expectEmit(true, true, true, true); + emit Referral(uint16(181), address(config.levelContracts.boringVault), deposit, expectedShares); + vaultManager.deposit(address(config.tokens.usdc), address(config.sparkVaults.sUsdc.vault), deposit); + } + + function test_convertToAssets_notAffectedByDonation() public { + deal(address(config.tokens.usdc), strategist.addr, 150_000e6); + + uint256 deposit = 100_000e6; // 100k USDC + vm.startPrank(strategist.addr); + + // Deposit into sUsdc via vaultManager + vaultManager.deposit(address(config.tokens.usdc), address(config.sparkVaults.sUsdc.vault), deposit); + + // Get convertToAssets value before donation + uint256 before = config.sparkVaults.sUsdc.vault.convertToAssets(1e18); + + // Simulate external donation directly to sUsdc vault + config.tokens.usdc.transfer(address(config.sparkVaults.sUsdc.vault), 50_000e6); // Donate 50k USDC directly + + // Check that convertToAssets(1e18) is the same + uint256 after1 = config.sparkVaults.sUsdc.vault.convertToAssets(1e18); + + assertApproxEqAbs(after1, before, 1, "convertToAssets() was affected by donation"); + + // Check shares are unchanged + assertEq( + config.sparkVaults.sUsdc.vault.totalSupply(), + config.sparkVaults.sUsdc.vault.totalSupply(), // no new shares minted + "totalSupply should remain unchanged" + ); + } + + function test_depositDefault_usdc_sparkOnly_succeeds(uint256 deposit) public { + _depositDefault_vaultOnly(deposit, config.tokens.usdc, address(config.sparkVaults.sUsdc.vault)); + } + + // ------------- M0 Tests ------------- + + function test_deposit_usdc_m0_succeeds() public { + uint256 deposit = 5_000_000e6; + + address[] memory defaultStrategies = new address[](3); + defaultStrategies[0] = address(config.periphery.aaveV3); + defaultStrategies[1] = address(config.morphoVaults.steakhouseUsdc.vault); + defaultStrategies[2] = address(config.tokens.wrappedM); + + _scheduleAndExecuteAdminAction( + address(config.levelContracts.vaultManager), + abi.encodeWithSignature( + "setDefaultStrategies(address,address[])", address(config.tokens.usdc), defaultStrategies + ) + ); + + vm.startPrank(strategist.addr); + vaultManager.deposit(address(config.tokens.usdc), address(config.tokens.wrappedM), deposit); + + // Check we received the correct amount of wrapped M + assertApproxEqRel( + config.tokens.wrappedM.balanceOf(address(config.levelContracts.boringVault)), + deposit, + 0.0005e18, // Slippage 0.05% + "Wrong amount of wrapped M" + ); + + _mockChainlinkCall(address(config.oracles.mNav), 105e6); // 1.05 USD per M + + // Check assets in strategy + assertApproxEqRel( + _getAssetsInStrategy(address(config.tokens.usdc), address(config.tokens.wrappedM)), + deposit, + 0.0005e18, // Slippage 0.05% + "Wrong amount of assets in strategy" + ); + + // Check total assets + assertApproxEqRel( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)) + + _getAssetsInStrategy(address(config.tokens.usdc), address(config.tokens.wrappedM)), + INITIAL_BALANCE, + 0.0005e18, + "Wrong amount of total assets" + ); + } + + function test_withdraw_usdc_m0_succeeds(uint256 deposit) public { + deposit = 5_000_000e6; + + address[] memory defaultStrategies = new address[](3); + defaultStrategies[0] = address(config.periphery.aaveV3); + defaultStrategies[1] = address(config.morphoVaults.steakhouseUsdc.vault); + defaultStrategies[2] = address(config.tokens.wrappedM); + + _scheduleAndExecuteAdminAction( + address(config.levelContracts.vaultManager), + abi.encodeWithSignature( + "setDefaultStrategies(address,address[])", address(config.tokens.usdc), defaultStrategies + ) + ); + + _mockChainlinkCall(address(config.oracles.mNav), 105e6); // 1.05 USD per M + + vm.startPrank(strategist.addr); + vaultManager.deposit(address(config.tokens.usdc), address(config.tokens.wrappedM), deposit); + + uint256 wMDeposited = config.tokens.wrappedM.balanceOf(address(config.levelContracts.boringVault)); + + // Withdraw all wrapped M + vaultManager.withdraw(address(config.tokens.usdc), address(config.tokens.wrappedM), wMDeposited); + + // Check we received the correct amount of USDC + assertApproxEqRel( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_BALANCE, + 0.001e18, // Slippage 0.1% + "Wrong amount of USDC" + ); + + // Check total assets + assertApproxEqRel( + _getTotalAssets(address(config.tokens.usdc)), INITIAL_BALANCE, 0.0005e18, "Wrong amount of total assets" + ); + } + + // ------------- Superstate Tests ------------- + + function test_depositDefault_ustb_succeeds(uint256 deposit) public { + _setupForSuperStateTest(); + deposit = bound(deposit, 1e6, INITIAL_BALANCE); + + (uint256 expectedUstb,) = config.periphery.ustbRedemptionIdle.calculateUstbIn(deposit); + + vm.prank(strategist.addr); + vaultManager.depositDefault(address(config.tokens.usdc), deposit); + + // Check we received the correct amount of USTB + assertApproxEqAbs( + config.tokens.ustb.balanceOf(address(config.levelContracts.boringVault)), + expectedUstb, + 1, + "Wrong amount of ustb" + ); + + // Check USDC balance + assertApproxEqAbs( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_BALANCE - deposit, + 1, + "Wrong amount of usdc" + ); + + // Check total assets + assertApproxEqRel( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)) + + _getAssetsInStrategy(address(config.tokens.usdc), address(config.tokens.ustb)), + INITIAL_BALANCE, + 0.000001e18, + "Wrong amount of total assets" + ); + } + // Test on both MetaMorpho and MetaMorphoV1_1 function test_depositDefault_usdc_morphoOnly_succeeds(uint256 deposit) public { - _depositDefault_morphoOnly(deposit, config.tokens.usdc, address(config.morphoVaults.steakhouseUsdc.vault)); + _depositDefault_vaultOnly(deposit, config.tokens.usdc, address(config.morphoVaults.steakhouseUsdc.vault)); } function test_depositDefault_usdc_morphoV1_1_succeeds(uint256 deposit) public { - _depositDefault_morphoOnly(deposit, config.tokens.usdc, address(config.morphoVaults.re7Usdc.vault)); + _depositDefault_vaultOnly(deposit, config.tokens.usdc, address(config.morphoVaults.re7Usdc.vault)); } function test_depositDefault_usdt_morphoOnly_succeeds(uint256 deposit) public { - _depositDefault_morphoOnly(deposit, config.tokens.usdt, address(config.morphoVaults.steakhouseUsdt.vault)); + _depositDefault_vaultOnly(deposit, config.tokens.usdt, address(config.morphoVaults.steakhouseUsdt.vault)); } function test_depositDefault_usdt_morphoV1_1_succeeds(uint256 deposit) public { - _depositDefault_morphoOnly(deposit, config.tokens.usdt, address(config.morphoVaults.steakhouseUsdtLite.vault)); + _depositDefault_vaultOnly(deposit, config.tokens.usdt, address(config.morphoVaults.steakhouseUsdtLite.vault)); } function test_depositDefault_usdc_multipleStrategiesWithdrawSome(uint256 deposit) public { @@ -436,6 +914,68 @@ contract VaultManagerMainnetTests is Utils, Configurable { _depositDefault_multipleStrategies(config.tokens.usdc, defaultStrategies, deposit); } + function test_depositDefault_usdc_multipleStrategies_spark(uint256 deposit) public { + address[] memory defaultStrategies = new address[](3); + defaultStrategies[0] = address(config.periphery.aaveV3); + defaultStrategies[1] = address(config.morphoVaults.steakhouseUsdc.vault); + defaultStrategies[2] = address(config.sparkVaults.sUsdc.vault); + + _depositDefault_multipleStrategies(config.tokens.usdc, defaultStrategies, deposit); + } + + function test_depositDefault_usdc_multipleStrategies_ustb(uint256 deposit) public { + deposit = bound(deposit, 2e2, INITIAL_BALANCE); + + address[] memory defaultStrategies = new address[](3); + defaultStrategies[0] = address(config.periphery.aaveV3); + defaultStrategies[1] = address(config.morphoVaults.steakhouseUsdc.vault); + defaultStrategies[2] = address(config.tokens.ustb); + + console2.log("Default strategies"); + + _scheduleAndExecuteAdminAction( + address(config.levelContracts.vaultManager), + abi.encodeWithSignature( + "setDefaultStrategies(address,address[])", address(config.tokens.usdc), defaultStrategies + ) + ); + + // Superstate setup + _mockChainlinkCall(USTB_CHAINLINK_FEED, 105e5); // 10.5 USD per USTB + _mockChainlinkCall(address(config.oracles.ustb), 105e5); // 10.5 USD per USTB + + // Superstate Allowlist V2 on Mainnet + IAllowListV2 allowList = IAllowListV2(0x02f1fA8B196d21c7b733EB2700B825611d8A38E5); + address[] memory addresses = new address[](1); + addresses[0] = address(config.levelContracts.boringVault); + + vm.prank(allowList.owner()); + allowList.setProtocolAddressPermissions(addresses, "USTB", true); + + vm.startPrank(strategist.addr); + + for (uint256 i = 0; i < defaultStrategies.length; i++) { + vaultManager.deposit( + address(config.tokens.usdc), defaultStrategies[i], _applyPercentage(deposit, 0.333333333333333e18) + ); + } + + assertApproxEqAbs( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_BALANCE - deposit, + 1e6, + "Wrong amount of underlying after deposit" + ); + + assertApproxEqRel( + _getTotalAssets(address(config.tokens.usdc)) + + _getAssetsInStrategy(address(config.tokens.usdc), address(config.tokens.ustb)), + INITIAL_BALANCE, + 0.000001e18, + "Wrong amount of total assets after deposit" + ); + } + function test_depositDefault_usdt_multipleStrategiesWithdrawSome(uint256 deposit) public { address[] memory defaultStrategies = new address[](3); defaultStrategies[0] = address(config.periphery.aaveV3); @@ -445,24 +985,61 @@ contract VaultManagerMainnetTests is Utils, Configurable { _depositDefault_multipleStrategies(config.tokens.usdt, defaultStrategies, deposit); } + function test_withdrawDefault_usdc_superstateOnly_succeeds(uint256 deposit, uint256 withdrawal) public { + deposit = bound(deposit, 1e3, INITIAL_BALANCE); + withdrawal = deposit - 1; + + _setupForSuperStateTest(); + + vm.startPrank(strategist.addr); + + vaultManager.depositDefault(address(config.tokens.usdc), deposit); + deal(address(config.tokens.usdc), address(config.periphery.ustbRedemptionIdle), deposit); + vaultManager.withdrawDefault(address(config.tokens.usdc), withdrawal); + + assertEq( + config.levelContracts.boringVault.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_SHARES, + "Number of shares must not change" + ); + assertApproxEqRel( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_BALANCE - deposit + withdrawal, + 0.000001e18, + "Wrong amount of underlying" + ); + + assertApproxEqRel( + _getTotalAssets(address(config.tokens.usdc)) + + _getAssetsInStrategy(address(config.tokens.usdc), address(config.tokens.ustb)), + INITIAL_BALANCE, + 0.000001e18, + "Wrong amount of total assets after deposit" + ); + } + + function test_withdrawDefault_usdc_sparkOnly_succeeds(uint256 deposit, uint256 withdrawal) public { + _withdrawDefault_vaultOnly(deposit, withdrawal, config.tokens.usdc, address(config.sparkVaults.sUsdc.vault)); + } + function test_withdrawDefault_usdc_morphoOnly_succeeds(uint256 deposit, uint256 withdrawal) public { - _withdrawDefault_morphoOnly( + _withdrawDefault_vaultOnly( deposit, withdrawal, config.tokens.usdc, address(config.morphoVaults.steakhouseUsdc.vault) ); } function test_withdrawDefault_usdc_morphoV1_1_succeeds(uint256 deposit, uint256 withdrawal) public { - _withdrawDefault_morphoOnly(deposit, withdrawal, config.tokens.usdc, address(config.morphoVaults.re7Usdc.vault)); + _withdrawDefault_vaultOnly(deposit, withdrawal, config.tokens.usdc, address(config.morphoVaults.re7Usdc.vault)); } function test_withdrawDefault_usdt_morphoOnly_succeeds(uint256 deposit, uint256 withdrawal) public { - _withdrawDefault_morphoOnly( + _withdrawDefault_vaultOnly( deposit, withdrawal, config.tokens.usdt, address(config.morphoVaults.steakhouseUsdt.vault) ); } function test_withdrawDefault_usdt_morphoV1_1_succeeds(uint256 deposit, uint256 withdrawal) public { - _withdrawDefault_morphoOnly( + _withdrawDefault_vaultOnly( deposit, withdrawal, config.tokens.usdt, address(config.morphoVaults.steakhouseUsdtLite.vault) ); } @@ -515,6 +1092,21 @@ contract VaultManagerMainnetTests is Utils, Configurable { ); } + function test_rebalance_usdc_fromSpark_toAave_succeeds(uint256 deposit) public { + address[] memory defaultStrategies = new address[](3); + defaultStrategies[0] = address(config.periphery.aaveV3); + defaultStrategies[1] = address(config.sparkVaults.sUsdc.vault); + defaultStrategies[2] = address(config.morphoVaults.steakhouseUsdc.vault); + + rebalance( + config.tokens.usdc, + defaultStrategies, + deposit, + address(config.sparkVaults.sUsdc.vault), + address(config.periphery.aaveV3) + ); + } + function test_rebalance_usdt_betweenMorpho_succeeds(uint256 deposit) public { address[] memory defaultStrategies = new address[](3); defaultStrategies[0] = address(config.periphery.aaveV3); @@ -545,6 +1137,81 @@ contract VaultManagerMainnetTests is Utils, Configurable { ); } + function test_withdraw_usdc_fromAave_failsIfNoLiquidity(uint256 deposit) public { + deposit = bound(deposit, 1000e6, 100_000e6); // Deposit anywhere between 1000 and 100k USDC + + vm.startPrank(strategist.addr); + + // Deposit USDC into Aave + vaultManager.deposit(address(config.tokens.usdc), address(config.periphery.aaveV3), deposit); + + // Check we receive the correct amount of aUSDC (allowing for off-by-one) + assertApproxEqAbs( + config.tokens.aUsdc.balanceOf(address(config.levelContracts.boringVault)), + deposit, + 1, + "Wrong amount of aUSDC" + ); + + // Check USDC was transferred to the vault + assertApproxEqAbs( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_BALANCE - deposit, + 1, + "Wrong amount of USDC" + ); + + // First withdrawal should succeed - use the actual aUSDC balance for withdrawal + uint256 aUsdcBalance = config.tokens.aUsdc.balanceOf(address(config.levelContracts.boringVault)); + vaultManager.withdraw(address(config.tokens.usdc), address(config.periphery.aaveV3), aUsdcBalance); + + // Check we received the correct amount of USDC + assertApproxEqAbs( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_BALANCE, + 1, + "Wrong amount of USDC" + ); + + // More withdrawals should fail + vm.expectRevert(); + vaultManager.withdraw(address(config.tokens.usdc), address(config.periphery.aaveV3), deposit); + } + + function test_withdraw_usdc_fromMorpho_failsIfNoLiquidity(uint256 deposit) public { + deposit = bound(deposit, 1000e6, 100_000e6); // Deposit anywhere between 1000 and 100k USDC + + vm.startPrank(strategist.addr); + + // Deposit USDC into Morpho + vaultManager.deposit(address(config.tokens.usdc), address(config.morphoVaults.steakhouseUsdc.vault), deposit); + + // Check USDC was transferred to the vault + assertApproxEqAbs( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_BALANCE - deposit, + 1, + "Wrong amount of USDC" + ); + + // First withdrawal should succeed - use the actual USDC balance for withdrawal + vaultManager.withdraw( + address(config.tokens.usdc), address(config.morphoVaults.steakhouseUsdc.vault), deposit - 1 + ); + + // Check we received the correct amount of USDC + assertApproxEqAbs( + config.tokens.usdc.balanceOf(address(config.levelContracts.boringVault)), + INITIAL_BALANCE, + 1, + "Wrong amount of USDC" + ); + + // More withdrawals should fail + vm.expectRevert(); + vaultManager.withdraw(address(config.tokens.usdc), address(config.morphoVaults.steakhouseUsdc.vault), deposit); + } + // function test_rewardYield_succeeds(uint256 accrued) public { // accrued = bound(accrued, 1, 100_000_000e6); @@ -573,6 +1240,61 @@ contract VaultManagerMainnetTests is Utils, Configurable { // ------------- Internal Helpers ------------- + function _resetTokenBalance(ERC20 token, address account) internal { + uint256 balance = token.balanceOf(account); + if (balance == 0) { + return; + } + + // In case of tokens like aUsdc, we cannot use deal() to reset the balance + + vm.prank(account); + token.transfer(strategist.addr, balance); + return; + } + + // Need to mock chainlink call for ustb + // because vm.warp() makes it return stale prices + function _mockChainlinkCall(address chainLinkFeed, int256 price) internal { + AggregatorV3Interface chainlink = AggregatorV3Interface(chainLinkFeed); + + uint80 roundId = 1; + uint256 startedAt = block.timestamp; + uint256 updatedAt = block.timestamp; + uint80 answeredInRound = 1; + + vm.mockCall( + address(chainlink), + abi.encodeWithSelector(chainlink.latestRoundData.selector), + abi.encode(roundId, price, startedAt, updatedAt, answeredInRound) + ); + } + + function _setupForSuperStateTest() internal { + address[] memory defaultStrategies = new address[](1); + defaultStrategies[0] = address(config.tokens.ustb); + + assertEq(config.tokens.ustb.balanceOf(address(config.levelContracts.boringVault)), 0, "USTB balance must be 0"); + + _scheduleAndExecuteAdminAction( + address(config.levelContracts.vaultManager), + abi.encodeWithSignature( + "setDefaultStrategies(address,address[])", address(config.tokens.usdc), defaultStrategies + ) + ); + + _mockChainlinkCall(USTB_CHAINLINK_FEED, 105e5); // 10.5 USD per USTB + _mockChainlinkCall(address(config.oracles.ustb), 105e5); // 10.5 USD per USTB + + // Superstate Allowlist V2 on Mainnet + IAllowListV2 allowList = IAllowListV2(0x02f1fA8B196d21c7b733EB2700B825611d8A38E5); + address[] memory addresses = new address[](1); + addresses[0] = address(config.levelContracts.boringVault); + + vm.prank(allowList.owner()); + allowList.setProtocolAddressPermissions(addresses, "USTB", true); + } + function _getAssetsInStrategy(address asset, address strategy) public view returns (uint256) { ( StrategyCategory category, @@ -617,12 +1339,12 @@ contract VaultManagerMainnetTests is Utils, Configurable { vm.stopPrank(); } - function _depositDefault_morphoOnly(uint256 deposit, ERC20 asset, address morphoVault) internal { + function _depositDefault_vaultOnly(uint256 deposit, ERC20 asset, address vault) internal { deposit = bound(deposit, 1, INITIAL_BALANCE); // Set Morpho only as a default strategy address[] memory defaultStrategies = new address[](1); - defaultStrategies[0] = morphoVault; + defaultStrategies[0] = vault; _scheduleAndExecuteAdminAction( address(config.levelContracts.vaultManager), @@ -633,7 +1355,7 @@ contract VaultManagerMainnetTests is Utils, Configurable { vm.startPrank(strategist.addr); - uint256 expectedShares = ERC4626(morphoVault).previewDeposit(deposit); + uint256 expectedShares = ERC4626(vault).previewDeposit(deposit); vaultManager.depositDefault(address(asset), deposit); @@ -648,9 +1370,9 @@ contract VaultManagerMainnetTests is Utils, Configurable { "Wrong amount of underlying" ); assertEq( - ERC4626(morphoVault).balanceOf(address(config.levelContracts.boringVault)), + ERC4626(vault).balanceOf(address(config.levelContracts.boringVault)), expectedShares, - "Wrong amount of Morpho vault shares" + "Wrong amount of vault shares" ); assertApproxEqRel( @@ -658,15 +1380,13 @@ contract VaultManagerMainnetTests is Utils, Configurable { ); } - function _withdrawDefault_morphoOnly(uint256 deposit, uint256 withdrawal, ERC20 asset, address morphoVault) - internal - { + function _withdrawDefault_vaultOnly(uint256 deposit, uint256 withdrawal, ERC20 asset, address vault) internal { deposit = bound(deposit, 2, INITIAL_BALANCE); withdrawal = bound(withdrawal, 1, deposit - 1); // Set Morpho only as a default strategy address[] memory defaultStrategies = new address[](1); - defaultStrategies[0] = morphoVault; + defaultStrategies[0] = vault; _scheduleAndExecuteAdminAction( address(config.levelContracts.vaultManager), @@ -894,7 +1614,7 @@ contract VaultManagerMainnetTests is Utils, Configurable { function test_removeAssetStrategy_succeeds() public { // Get initial state address[] memory initialUsdcStrategies = vaultManager.getDefaultStrategies(address(config.tokens.usdc)); - assertEq(initialUsdcStrategies.length, 3, "Initial USDC strategies count should be 3"); + assertEq(initialUsdcStrategies.length, 4, "Initial USDC strategies count should be 4"); // Remove Aave V3 strategy (which is in defaultStrategies) vm.startPrank(config.users.admin); @@ -903,7 +1623,7 @@ contract VaultManagerMainnetTests is Utils, Configurable { // Verify Aave V3 was removed from defaultStrategies address[] memory afterAaveRemoval = vaultManager.getDefaultStrategies(address(config.tokens.usdc)); - assertEq(afterAaveRemoval.length, 2, "USDC strategies count should be 2 after removing Aave"); + assertEq(afterAaveRemoval.length, 3, "USDC strategies count should be 3 after removing Aave"); assertEq( afterAaveRemoval[0], address(config.morphoVaults.steakhouseUsdc.vault), @@ -932,11 +1652,11 @@ contract VaultManagerMainnetTests is Utils, Configurable { // Verify Re7 was removed from defaultStrategies address[] memory afterRe7Removal = vaultManager.getDefaultStrategies(address(config.tokens.usdc)); - assertEq(afterRe7Removal.length, 1, "USDC strategies count should be 1 after removing Re7"); + assertEq(afterRe7Removal.length, 2, "USDC strategies count should be 2 after removing Re7"); assertEq( - afterRe7Removal[0], address(config.morphoVaults.steakhouseUsdc.vault), "Only strategy should be Steakhouse" + afterRe7Removal[0], address(config.morphoVaults.steakhouseUsdc.vault), "First strategy should be Steakhouse" ); - + assertEq(afterRe7Removal[1], address(config.sparkVaults.sUsdc.vault), "Second strategy should be Spark"); // Verify Re7 was removed from assetToStrategy (category, baseCollateral, receiptToken, oracle, depositContract, withdrawContract, heartbeat) = vaultManager.assetToStrategy(address(config.tokens.usdc), address(config.morphoVaults.re7Usdc.vault)); @@ -945,9 +1665,3 @@ contract VaultManagerMainnetTests is Utils, Configurable { ); } } - -/** - * Test cases to add - * - Test what happens when there is not enough liquidity to withdraw from Morpho - * - Test what happens when there is not enough liquidity to withdraw from Aave - */ diff --git a/test/v2/integration/mint/LevelMintingV2.receipts.t.sol b/test/v2/integration/mint/LevelMintingV2.receipts.t.sol index fdace6a..cb0d45d 100644 --- a/test/v2/integration/mint/LevelMintingV2.receipts.t.sol +++ b/test/v2/integration/mint/LevelMintingV2.receipts.t.sol @@ -24,6 +24,8 @@ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {AaveTokenOracle} from "@level/src/v2/oracles/AaveTokenOracle.sol"; import {IVaultManager} from "@level/src/v2/interfaces/level/IVaultManager.sol"; import {StrategyConfig, StrategyCategory} from "@level/src/v2/common/libraries/StrategyLib.sol"; +import {AggregatorV3Interface} from "@level/src/v2/interfaces/AggregatorV3Interface.sol"; +import {IAllowListV2} from "@level/src/v2/interfaces/superstate/IAllowListV2.sol"; // Test minting using receipt tokens. contract LevelMintingV2ReceiptTests is Utils, Configurable { @@ -34,6 +36,8 @@ contract LevelMintingV2ReceiptTests is Utils, Configurable { Vm.Wallet private normalUser; uint256 public constant INITIAL_BALANCE = 500000e6; + address public constant USTB_CHAINLINK_FEED = 0xE4fA682f94610cCd170680cc3B045d77D9E528a8; + address public constant USTB_ALLOWLIST_ADDRESS = 0x873b548Ee1e5813dBE35898AC4d63e8b41809109; LevelMintingV2 public levelMinting; MockOracle public mockOracle; @@ -163,6 +167,12 @@ contract LevelMintingV2ReceiptTests is Utils, Configurable { deal(morphoVaults[i], normalUser.addr, IERC4626(morphoVaults[i]).convertToShares(INITIAL_BALANCE)); } + deal( + address(config.sparkVaults.sUsdc.vault), + normalUser.addr, + IERC4626(config.sparkVaults.sUsdc.vault).convertToShares(INITIAL_BALANCE) + ); + deal(address(config.tokens.ustb), normalUser.addr, INITIAL_BALANCE * 10 ** ERC20(config.tokens.ustb).decimals()); deal(address(config.tokens.usdc), normalUser.addr, INITIAL_BALANCE * 10 ** ERC20(config.tokens.usdc).decimals()); deal(address(config.tokens.usdt), normalUser.addr, INITIAL_BALANCE * 10 ** ERC20(config.tokens.usdt).decimals()); @@ -179,6 +189,126 @@ contract LevelMintingV2ReceiptTests is Utils, Configurable { levelMinting = LevelMintingV2(address(config.levelContracts.levelMintingV2)); } + // function test_mint_sparkUsdc_succeeds(uint256 _underlyingAmount) public { + // IERC4626 collateral = IERC4626(config.sparkVaults.sUsdc.vault); + + // uint256 underlyingAmount = bound(_underlyingAmount, 1e3, INITIAL_BALANCE); + // uint256 collateralAmount = collateral.convertToShares(underlyingAmount); + + // console2.log("_underlyingAmount", underlyingAmount); + + // uint256 minLvlUsdAmount = + // _adjustAmount(underlyingAmount, collateral.asset(), address(config.tokens.lvlUsd)).mulDivUp(0.999e18, 1e18); + + // ILevelMintingV2Structs.Order memory order = ILevelMintingV2Structs.Order({ + // beneficiary: normalUser.addr, + // collateral_asset: address(config.sparkVaults.sUsdc.vault), + // collateral_amount: collateralAmount, + // min_lvlusd_amount: minLvlUsdAmount + // }); + + // _mint_withVaultShares_succeeds(normalUser.addr, order, underlyingAmount); + // } + + function test_mint_superstateUstb_failsWhenNotOnAllowlist(uint256 _underlyingAmount) public { + // This test should fail as neither boringVault nor normalUser is on the allowlist for USTB + + // For redemptionIdle contract + _mockChainlinkCall(USTB_CHAINLINK_FEED, 105e5); // 10.5 USD per USTB + // For levelMintingV2 contract's computeRedeem() + _mockChainlinkCall(address(config.oracles.ustb), 105e5); // 10.5 USD per USTB + + ERC20 collateral = config.tokens.ustb; + ERC20 underlying = config.tokens.usdc; + + uint256 underlyingAmount = bound(_underlyingAmount, 1e3, 490000e6); + + // Calculate the amount of USTB for the underlying USDC amount + (uint256 ustbAmount,) = config.periphery.ustbRedemptionIdle.calculateUstbIn(underlyingAmount); + + console2.log("ustbAmount", ustbAmount); + + uint256 minLvlUsdAmount = + _adjustAmount(underlyingAmount, address(underlying), address(config.tokens.lvlUsd)).mulDivUp(0.999e18, 1e18); + + ILevelMintingV2Structs.Order memory order = ILevelMintingV2Structs.Order({ + beneficiary: normalUser.addr, + collateral_asset: address(collateral), + collateral_amount: ustbAmount, + min_lvlusd_amount: minLvlUsdAmount + }); + + ERC20(address(collateral)).safeApprove(address(config.levelContracts.boringVault), type(uint256).max); + vm.expectRevert("TRANSFER_FROM_FAILED"); + uint256 minted = levelMinting.mint(order); + } + + function test_mint_superstateUstb_succeedsWithAllowlist(uint256 _collateralAmount) public { + // This test should succeed as both boringVault and normalUser are on the allowlist for USTB + + // Superstate Allowlist V2 on Mainnet + IAllowListV2 allowList = IAllowListV2(0x02f1fA8B196d21c7b733EB2700B825611d8A38E5); + address[] memory addresses = new address[](1); + addresses[0] = address(config.levelContracts.boringVault); + + // Following is an allowlisted address for USTB + // Will act as a sender in this test + address allowlistedAddress = USTB_ALLOWLIST_ADDRESS; + + vm.prank(allowList.owner()); + allowList.setProtocolAddressPermissions(addresses, "USTB", true); + + // For redemptionIdle contract + _mockChainlinkCall(USTB_CHAINLINK_FEED, 105e5); // 10.5 USD per USTB + // For levelMintingV2 contract's computeRedeem() + _mockChainlinkCall(address(config.oracles.ustb), 105e5); // 10.5 USD per USTB + + ERC20 collateral = config.tokens.ustb; + ERC20 underlying = config.tokens.usdc; + + // TODO: Fix this test failing with low precision + uint256 collateralAmount = bound(_collateralAmount, 1e5, 47_000e6); // 47,000 USTB + + // Calculate the amount of USDC for the collateral amount + (uint256 underlyingAmount,) = config.periphery.ustbRedemptionIdle.calculateUsdcOut(collateralAmount); + + deal(address(config.tokens.ustb), allowlistedAddress, collateralAmount); + + uint256 minLvlUsdAmount = + _adjustAmount(underlyingAmount, address(underlying), address(config.tokens.lvlUsd)).mulDivUp(0.999e18, 1e18); + + ILevelMintingV2Structs.Order memory order = ILevelMintingV2Structs.Order({ + beneficiary: allowlistedAddress, + collateral_asset: address(collateral), + collateral_amount: collateralAmount, + min_lvlusd_amount: minLvlUsdAmount + }); + + vm.startPrank(allowlistedAddress); + ERC20(address(collateral)).safeApprove(address(config.levelContracts.boringVault), type(uint256).max); + uint256 minted = levelMinting.mint(order); + vm.stopPrank(); + + assertApproxEqRel( + minted, + underlyingAmount.mulDivDown(10 ** config.tokens.lvlUsd.decimals(), 10 ** underlying.decimals()), + 0.000001e18, + "Minted amount does not match expected amount" + ); + assertEq(ERC20(address(collateral)).balanceOf(allowlistedAddress), 0, "Allowlisted address should have 0 USTB"); + assertEq( + ERC20(address(collateral)).balanceOf(address(config.levelContracts.boringVault)), + collateralAmount, + "Boring vault should have received USTB" + ); + assertApproxEqRel( + config.tokens.lvlUsd.balanceOf(allowlistedAddress), + underlyingAmount.mulDivDown(10 ** config.tokens.lvlUsd.decimals(), 10 ** underlying.decimals()), + 0.000001e18, + "Allowlisted address should have received lvlUSD" + ); + } + function test_mint_steakhouseUsdc_succeeds(uint256 _underlyingAmount) public { IERC4626 collateral = IERC4626(config.morphoVaults.steakhouseUsdc.vault); @@ -197,7 +327,7 @@ contract LevelMintingV2ReceiptTests is Utils, Configurable { min_lvlusd_amount: minLvlUsdAmount }); - _mint_withMorphoVaultShares_succeeds(normalUser.addr, order, underlyingAmount); + _mint_withVaultShares_succeeds(normalUser.addr, order, underlyingAmount); } function test_mint_steakhouseUsdtLite_succeeds(uint256 _underlyingAmount) public { @@ -216,10 +346,10 @@ contract LevelMintingV2ReceiptTests is Utils, Configurable { min_lvlusd_amount: minLvlUsdAmount }); - _mint_withMorphoVaultShares_succeeds(normalUser.addr, order, underlyingAmount); + _mint_withVaultShares_succeeds(normalUser.addr, order, underlyingAmount); } - function _mint_withMorphoVaultShares_succeeds( + function _mint_withVaultShares_succeeds( address caller, ILevelMintingV2Structs.Order memory order, uint256 underlyingAmount @@ -278,4 +408,21 @@ contract LevelMintingV2ReceiptTests is Utils, Configurable { vm.stopPrank(); } + + // Need to mock chainlink call for ustb + // because vm.warp() makes it return stale prices + function _mockChainlinkCall(address chainLinkFeed, int256 price) internal { + AggregatorV3Interface chainlink = AggregatorV3Interface(chainLinkFeed); + + uint80 roundId = 1; + uint256 startedAt = block.timestamp; + uint256 updatedAt = block.timestamp; + uint80 answeredInRound = 1; + + vm.mockCall( + address(chainlink), + abi.encodeWithSelector(chainlink.latestRoundData.selector), + abi.encode(roundId, price, startedAt, updatedAt, answeredInRound) + ); + } } diff --git a/test/v2/integration/periphery/RewardsDistributor.t.sol b/test/v2/integration/periphery/RewardsDistributor.t.sol new file mode 100644 index 0000000..0cc6abf --- /dev/null +++ b/test/v2/integration/periphery/RewardsDistributor.t.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.28; + +import {Test, Vm} from "forge-std/Test.sol"; +import {Utils} from "@level/test/utils/Utils.sol"; +import {DeployLevel} from "@level/script/v2/DeployLevel.s.sol"; +import {Configurable} from "@level/config/Configurable.sol"; +import {console2} from "forge-std/console2.sol"; +import {MathLib} from "@level/src/v2/common/libraries/MathLib.sol"; +import {ERC20 as OpenZeppelinERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20 as SolmateERC20} from "@solmate/src/tokens/ERC20.sol"; +import {RewardsDistributor} from "@level/src/v2/periphery/RewardsDistributor.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract RewardsDistributorTests is Utils, Configurable { + using SafeERC20 for OpenZeppelinERC20; + using MathLib for uint256; + + Vm.Wallet private deployer; + + RewardsDistributor public distributor; + + uint256 public constant INITIAL_BALANCE = 1000e6; + + function setUp() public { + forkMainnet(22464604); + + deployer = vm.createWallet("deployer"); + + // Use Mainnet config + initConfig(1); + + vm.startPrank(config.users.admin); + distributor = new RewardsDistributor( + address(config.levelContracts.levelMintingV2), address(config.levelContracts.rewardsManager) + ); + + vm.stopPrank(); + } + + function test__mint__usdc() public { + vm.startPrank(deployer.addr); + deal(address(config.tokens.usdc), address(deployer.addr), INITIAL_BALANCE); + OpenZeppelinERC20(address(config.tokens.usdc)).forceApprove(address(distributor), INITIAL_BALANCE); + uint256 lvlUsdMinted = distributor.mint(OpenZeppelinERC20(address(config.tokens.usdc))); + vm.stopPrank(); + + assertApproxEqRel( + config.tokens.lvlUsd.balanceOf(deployer.addr), + INITIAL_BALANCE.convertDecimalsDown(config.tokens.usdc.decimals(), config.tokens.lvlUsd.decimals()), + 0.00002e18 + ); + } + + function test__mint__usdt() public { + vm.startPrank(deployer.addr); + deal(address(config.tokens.usdt), address(deployer.addr), INITIAL_BALANCE); + OpenZeppelinERC20(address(config.tokens.usdt)).forceApprove(address(distributor), INITIAL_BALANCE); + uint256 lvlUsdMinted = distributor.mint(OpenZeppelinERC20(address(config.tokens.usdt))); + vm.stopPrank(); + + assertApproxEqRel( + config.tokens.lvlUsd.balanceOf(deployer.addr), + INITIAL_BALANCE.convertDecimalsDown(config.tokens.usdt.decimals(), config.tokens.lvlUsd.decimals()), + 0.00001e18 + ); + } + + function test__getAccruedYield() public { + address[] memory assets = new address[](2); + assets[0] = address(config.tokens.usdc); + assets[1] = address(config.tokens.usdt); + uint256 accruedYield = distributor.getAccruedYield(assets); + + assertEq(accruedYield, 833008702674735395344); + + vm.startPrank(config.users.protocolTreasury); + uint256 yieldFromRewardsManager = config.levelContracts.rewardsManager.getAccruedYield(assets); + + assertEq(accruedYield, yieldFromRewardsManager); + + vm.stopPrank(); + } +}