diff --git a/config/deploy/BaseConfig.sol b/config/deploy/BaseConfig.sol index f307ae1..183f939 100644 --- a/config/deploy/BaseConfig.sol +++ b/config/deploy/BaseConfig.sol @@ -121,6 +121,7 @@ contract BaseConfig { AggregatorV3Interface aUsdt; AggregatorV3Interface aUsdc; AggregatorV3Interface mNav; + AggregatorV3Interface cappedMNav; } Config public config; diff --git a/config/deploy/Mainnet.sol b/config/deploy/Mainnet.sol index a9b06f5..7b9f966 100644 --- a/config/deploy/Mainnet.sol +++ b/config/deploy/Mainnet.sol @@ -52,7 +52,8 @@ contract Mainnet is BaseConfig { ustb: AggregatorV3Interface(0x289B5036cd942e619E1Ee48670F98d214E745AAC), aUsdt: AggregatorV3Interface(0x380adC857Cd3d0531C0821B5D52F34737C4eCDC4), aUsdc: AggregatorV3Interface(0x95CCDE4C1bb3d56639d22185aa2f95EcfebD7F22), - mNav: AggregatorV3Interface(0xC28198Df9aee1c4990994B35ff51eFA4C769e534) + mNav: AggregatorV3Interface(0xC28198Df9aee1c4990994B35ff51eFA4C769e534), + cappedMNav: AggregatorV3Interface(0x0000000000000000000000000000000000000000) }), users: Users({ admin: 0x343ACce723339D5A417411D8Ff57fde8886E91dc, diff --git a/config/deploy/Sepolia.sol b/config/deploy/Sepolia.sol index 49ef3fd..adea41f 100644 --- a/config/deploy/Sepolia.sol +++ b/config/deploy/Sepolia.sol @@ -52,7 +52,8 @@ contract Sepolia is BaseConfig { ustb: AggregatorV3Interface(0x732d3C7515356eAB22E3F3DcA183c5c65102d518), aUsdc: AggregatorV3Interface(address(0)), aUsdt: AggregatorV3Interface(address(0)), - mNav: AggregatorV3Interface(address(0)) + mNav: AggregatorV3Interface(address(0)), + cappedMNav: AggregatorV3Interface(address(0)) }), users: Users({ admin: 0xb2522DC238DEA8a821dEcE38a1d46eC5C4708256, diff --git a/script/v2/usd/DeploySwapManager.s.sol b/script/v2/usd/DeploySwapManager.s.sol index 865921c..de0d079 100644 --- a/script/v2/usd/DeploySwapManager.s.sol +++ b/script/v2/usd/DeploySwapManager.s.sol @@ -121,6 +121,11 @@ contract DeploySwapManager is Configurable, DeploymentUtils, Script { }) ); + config.levelContracts.swapManager.addOracle(address(config.tokens.usdc), address(config.oracles.usdc)); + config.levelContracts.swapManager.addOracle(address(config.tokens.wrappedM), address(config.oracles.cappedMNav)); + config.levelContracts.swapManager.setHeartBeat(address(config.tokens.usdc), 1 days); + config.levelContracts.swapManager.setHeartBeat(address(config.tokens.wrappedM), 26 hours); + // Transfer ownership to admin timelock config.levelContracts.swapManager.transferOwnership(address(config.levelContracts.adminTimelock)); diff --git a/src/v2/common/libraries/VaultLib.sol b/src/v2/common/libraries/VaultLib.sol index 2722e62..ecf9472 100644 --- a/src/v2/common/libraries/VaultLib.sol +++ b/src/v2/common/libraries/VaultLib.sol @@ -509,7 +509,7 @@ library VaultLib { emit DepositToM0(address(vault), baseCollateral, amount, deposited); - return deposited; + return amount; } /// @notice Withdraws assets from M @@ -584,7 +584,7 @@ library VaultLib { emit StakeToAaveUmbrella(address(vault), address(_config.baseCollateral), amount, staked_); - return staked_; + return amount; } /// @notice Unstakes waTokens from Aave Umbrella @@ -629,14 +629,14 @@ library VaultLib { bytes memory tokensRaw = vault.manage( address(stataToken), abi.encodeWithSignature( - "redeem(uint256,address,address)", wrappedATokens, address(vault), address(vault) + "redeem(uint256,address,address)", waTokenAmount, address(vault), address(vault) ), 0 ); uint256 tokens = abi.decode(tokensRaw, (uint256)); - emit UnstakeFromAaveUmbrella(address(vault), address(_config.baseCollateral), amount, tokens); + emit UnstakeFromAaveUmbrella(address(vault), address(_config.baseCollateral), amount, wrappedATokens); return tokens; } else { diff --git a/src/v2/usd/SwapManager.sol b/src/v2/usd/SwapManager.sol index d2dd1b1..fc71273 100644 --- a/src/v2/usd/SwapManager.sol +++ b/src/v2/usd/SwapManager.sol @@ -3,13 +3,16 @@ 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 {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.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"; import {PauserGuardedUpgradable} from "@level/src/v2/common/guard/PauserGuardedUpgradable.sol"; +import {OracleLib} from "@level/src/v2/common/libraries/OracleLib.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; /** * .-==+=======+: @@ -61,6 +64,22 @@ contract SwapManager is SwapManagerStorage, Initializable, UUPSUpgradeable, Auth swapConfigs[tokenIn][tokenOut] = config; } + /// @notice Adds an oracle to the swap manager + /// @param token The address of the token to add an oracle for + /// @param oracle The address of the oracle to add + /// @dev Only callable by authorized addresses + function addOracle(address token, address oracle) public requiresAuth { + oracles[token] = oracle; + } + + /// @notice Sets the heart beat for a token + /// @param token The address of the token to set the heart beat for + /// @param heartBeat The heart beat to set + /// @dev Only callable by authorized addresses + function setHeartBeat(address token, uint256 heartBeat) public requiresAuth { + heartbeats[token] = heartBeat; + } + /// @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 @@ -86,12 +105,39 @@ contract SwapManager is SwapManagerStorage, Initializable, UUPSUpgradeable, Auth uint128 liquidity = IUniswapV3Pool(config.pool).liquidity(); require(liquidity > 0, "SwapManager: No liquidity"); + IERC20Metadata tokenInMetadata = IERC20Metadata(tokenIn); + IERC20Metadata tokenOutMetadata = IERC20Metadata(tokenOut); + // Transfer & approve - IERC20(tokenIn).transferFrom(msg.sender, address(this), amountIn); - IERC20(tokenIn).approve(address(swapRouter), amountIn); + tokenInMetadata.transferFrom(msg.sender, address(this), amountIn); + tokenInMetadata.approve(address(swapRouter), amountIn); + + (int256 inputTokenPrice, uint256 inputPriceDecimals) = + OracleLib.getPriceAndDecimals(oracles[tokenIn], heartbeats[tokenIn]); + + (int256 outputTokenPrice, uint256 outputPriceDecimals) = + OracleLib.getPriceAndDecimals(oracles[tokenOut], heartbeats[tokenOut]); + + require(inputTokenPrice > 0 && outputTokenPrice > 0, "SwapManager: Invalid oracle price"); + + uint8 tokenInDecimals = tokenInMetadata.decimals(); + uint8 tokenOutDecimals = tokenOutMetadata.decimals(); + + // Normalize amountIn to 1e18 + uint256 amountInNormalized = Math.mulDiv(amountIn, 1e18, 10 ** tokenInDecimals); + + // Normalize oracle prices to 1e18 + uint256 inputPriceNormalized = Math.mulDiv(uint256(inputTokenPrice), 1e18, 10 ** inputPriceDecimals); + uint256 outputPriceNormalized = Math.mulDiv(uint256(outputTokenPrice), 1e18, 10 ** outputPriceDecimals); + + // Calculate expected output amount (in 1e18 units) + uint256 expectedOutNormalized = (amountInNormalized * inputPriceNormalized) / outputPriceNormalized; + + // Scale expectedOut back to tokenOut decimals + uint256 adjustedOutAmount = Math.mulDiv(expectedOutNormalized, 10 ** tokenOutDecimals, 1e18); // Calculate slippage min out - uint256 minOut = (amountIn * (10_000 - config.slippageBps) + 10_000 - 1) / 10_000; // Round up + uint256 minOut = (adjustedOutAmount * (10_000 - config.slippageBps) + 10_000 - 1) / 10_000; // Round up // Build params ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ diff --git a/src/v2/usd/SwapManagerStorage.sol b/src/v2/usd/SwapManagerStorage.sol index b506434..baf6c8f 100644 --- a/src/v2/usd/SwapManagerStorage.sol +++ b/src/v2/usd/SwapManagerStorage.sol @@ -33,6 +33,10 @@ abstract contract SwapManagerStorage { /// @notice The Uniswap V3 SwapRouter contract instance ISwapRouter public swapRouter; + // collateral token address to chainlink oracle address map + mapping(address => address) public oracles; + mapping(address => uint256) public heartbeats; + /// @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; diff --git a/test/v2/integration/VaultManager.t.sol b/test/v2/integration/VaultManager.t.sol index 07f126e..78fda47 100644 --- a/test/v2/integration/VaultManager.t.sol +++ b/test/v2/integration/VaultManager.t.sol @@ -20,11 +20,13 @@ 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 {SwapManager} from "@level/src/v2/usd/SwapManager.sol"; import {IERC4626StataToken} from "@level/src/v2/interfaces/aave/IERC4626StataToken.sol"; import {IERC4626StakeToken} from "@level/src/v2/interfaces/aave/IERC4626StakeToken.sol"; import {CappedOneDollarOracle} from "@level/src/v2/oracles/CappedOneDollarOracle.sol"; import {AaveUmbrellaOracle} from "@level/src/v2/oracles/AaveUmbrellaOracle.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {SwapConfig} from "@level/src/v2/usd/SwapManager.sol"; contract VaultManagerMainnetTests is Utils, Configurable { using SafeTransferLib for ERC20; @@ -53,15 +55,14 @@ contract VaultManagerMainnetTests is Utils, Configurable { function setUp() public { forkMainnet(22664895); + initConfig(1); deployer = vm.createWallet("deployer"); strategist = vm.createWallet("strategist"); - DeploySwapManager deploySwapManager = new DeploySwapManager(); - - vm.prank(deployer.addr); - deploySwapManager.setUp_(1, deployer.privateKey); - config = deploySwapManager.run(); + vm.startPrank(deployer.addr); + _deploySwapManager(); + vm.stopPrank(); _upgradeVaultManager(); @@ -148,6 +149,59 @@ contract VaultManagerMainnetTests is Utils, Configurable { } } + function _deploySwapManager() internal { + // Replicate SwapManager deploy script + CappedOneDollarOracle mNavOracle = new CappedOneDollarOracle(address(config.oracles.mNav)); + config.oracles.cappedMNav = AggregatorV3Interface(address(mNavOracle)); + + bytes memory constructorArgs = abi.encodeWithSignature( + "initialize(address,address,address)", + deployer.addr, + address(config.periphery.uniswapV3Router), + address(config.levelContracts.pauserGuard) + ); + + SwapManager _swapManager = new SwapManager(); + ERC1967Proxy _swapManagerProxy = new ERC1967Proxy(address(_swapManager), constructorArgs); + + config.levelContracts.swapManager = SwapManager(address(_swapManagerProxy)); + config.levelContracts.swapManager.setAuthority(config.levelContracts.rolesAuthority); + + 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 + }) + ); + + config.levelContracts.swapManager.addOracle(address(config.tokens.usdc), address(config.oracles.usdc)); + config.levelContracts.swapManager.addOracle(address(config.tokens.wrappedM), address(config.oracles.cappedMNav)); + config.levelContracts.swapManager.setHeartBeat(address(config.tokens.usdc), 1 days); + config.levelContracts.swapManager.setHeartBeat(address(config.tokens.wrappedM), 26 hours); + + // Transfer ownership to admin timelock + config.levelContracts.swapManager.transferOwnership(address(config.levelContracts.adminTimelock)); + } + function _upgradeVaultManager() internal { VaultManager impl = new VaultManager(); vm.prank(address(config.levelContracts.adminTimelock)); @@ -155,7 +209,7 @@ contract VaultManagerMainnetTests is Utils, Configurable { } function _setupTreasuriesForTests() internal { - CappedOneDollarOracle mNavOracle = new CappedOneDollarOracle(address(config.oracles.mNav)); + CappedOneDollarOracle mNavOracle = CappedOneDollarOracle(address(config.oracles.cappedMNav)); StrategyConfig memory ustbConfig = StrategyConfig({ category: StrategyCategory.SUPERSTATE, @@ -852,6 +906,9 @@ contract VaultManagerMainnetTests is Utils, Configurable { ) ); + _mockChainlinkCall(address(config.oracles.mNav), 105e6); // 1.05 USD per M + _mockChainlinkCall(address(config.oracles.usdc), 1e8); // 1 USD per USDC + vm.startPrank(strategist.addr); vaultManager.deposit(address(config.tokens.usdc), address(config.tokens.wrappedM), deposit); @@ -863,8 +920,6 @@ contract VaultManagerMainnetTests is Utils, Configurable { "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)), @@ -899,6 +954,7 @@ contract VaultManagerMainnetTests is Utils, Configurable { ); _mockChainlinkCall(address(config.oracles.mNav), 105e6); // 1.05 USD per M + _mockChainlinkCall(address(config.oracles.usdc), 1e8); // 1 USD per USDC vm.startPrank(strategist.addr); vaultManager.deposit(address(config.tokens.usdc), address(config.tokens.wrappedM), deposit);