diff --git a/lib/forge-std b/lib/forge-std index 5dd1c68..f52f92f 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 5dd1c68131ddd3c89ef169666eb262b92e90507c +Subproject commit f52f92f2fd8f46d85a009ea1b73a11575e3c5232 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index 11dc5e3..4032b42 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit 11dc5e3809ebe07d5405fe524385cbe4f890a08b +Subproject commit 4032b42694ff6599b17ffde65b2b64d7fc8a38f8 diff --git a/lib/v4-core b/lib/v4-core index bd6cce2..18320a9 160000 --- a/lib/v4-core +++ b/lib/v4-core @@ -1 +1 @@ -Subproject commit bd6cce29aec544aeb015cb9ce595266712854782 +Subproject commit 18320a9ff40954a62f733c71edbdfe722744b9c0 diff --git a/src/TtcLogic.sol b/src/TtcLogic.sol new file mode 100644 index 0000000..7d65f0e --- /dev/null +++ b/src/TtcLogic.sol @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.20; + +// TTC token contract +import "./TTC.sol"; + +// Types +import {Route, Token} from "./types/types.sol"; +import {IUniswapV3PoolState} from "@uniswap/v3-core/contracts/interfaces/pool/IUniswapV3PoolState.sol"; +import {console} from "forge-std/Test.sol"; +import "./TtcVault.sol"; + +// Interfaces +import "./interfaces/ITtcLogic.sol"; +import "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; +import "@rocketpool-router/contracts/RocketSwapRouter.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +/** + * @title TtcLogic + * @author Shivaansh Kapoor, Ruslan Akhtariev + * @notice Vault for Continuum's first product: TTC (Top Ten Continuum) Token + * @notice TTC tokens are fungible assets backed by a basket of the top 10 ERC20 tokens by market cap (the allocation of each token depends on its market cap relative to others) + * @notice The TtcVault allows for minting TTC tokens with ETH and redeeming TTC tokens for its constituent tokens + * @notice The vault also undergoes periodic reconstitutions + */ +contract TtcLogic is ITtcLogic, ReentrancyGuard { + // Treasury fee is only taken upon redemption + // Treasury fee is denominated in BPS (basis points). 1 basis point = 0.01% + // Fee is initally set to 0.1% of redemption amount. + uint8 public constant TREASURY_REDEMPTION_FEE = 1e1; + // Uniswap pool fees are denominated in 100ths of a basis point. + uint24 public constant UNISWAP_PRIMARY_POOL_FEE = 3e3; + uint24 public constant UNISWAP_SECONDARY_POOL_FEE = 1e4; + uint24 public constant UNISWAP_TERTIARY_POOL_FEE = 5e2; + uint24 public constant UNISWAP_QUATERNARY_POOL_FEE = 1e2; // added via proposal in 2021 + // Balancer rETH/wETH swap fee is 0.04% + uint24 public constant BALANCER_STABLE_POOL_FEE = 4e2; + // Max price impact allowed for rocket swap + uint24 public constant MAX_ROCKET_SWAP_PRICE_IMPACT = 1e1; + + // Immutable globals + TTC public immutable i_ttcToken; + address public immutable i_ttcVault; + address payable public immutable i_continuumTreasury; + address public immutable i_uniswapFactory; + ISwapRouter public immutable i_swapRouter; + RocketSwapRouter public immutable i_rocketSwapRouter; + IWETH public immutable i_wEthToken; + IrETH public immutable i_rEthToken; + + // Current tokens and their alloGcations in the vault + Token[10] public constituentTokens; + + // Amount of ETH allocated into rEth so far (after swap fees) + uint256 private ethAllocationREth; + uint8 private constant ETH_DECIMALS = 18; + uint8 private constant UNISWAP_ROCKET_PORTION = 0; + uint8 private constant BALANCER_ROCKET_PORTION = 10; + + // Flag to check for reentrancy + bool private locked; + + // Modifiers + modifier onlyTreasury() { + if (msg.sender != i_continuumTreasury) { + revert OnlyTreasury(); + } + _; + } + + /** + * @notice Constructor to initialize the TTC vault with specified parameters. + * @param _treasury The address of the treasury to receive fees. + * @param _swapRouterAddress The address of the Uniswap v3 swap router. + * @param _wEthAddress The address of the Wrapped Ethereum token. + * @param _rocketSwapRouter The address of the Rocket Swap Router + * @param _initialTokens The initial set of tokens and their allocations for the vault. + */ + constructor( + address _treasury, + address _swapRouterAddress, + address _wEthAddress, + address _rocketSwapRouter, + address _uniswapFactoryAddress, + address _ttcVaultAddress, + Token[10] memory _initialTokens + ) { + i_ttcToken = new TTC(); + i_continuumTreasury = payable(_treasury); + i_swapRouter = ISwapRouter(_swapRouterAddress); + i_wEthToken = IWETH(_wEthAddress); + i_rocketSwapRouter = RocketSwapRouter(payable(_rocketSwapRouter)); + i_rEthToken = i_rocketSwapRouter.rETH(); + i_uniswapFactory = _uniswapFactoryAddress; + i_ttcVault = _ttcVaultAddress; + + if (!checkTokenList(_initialTokens)) { + revert InvalidTokenList(); + } + + for (uint8 i; i < 10; i++) { + constituentTokens[i] = _initialTokens[i]; + } + } + + /** + * @notice Gets the address of the TTC token contract. + * @return ttcAddress The address of the TTC token contract. + */ + function getTtcTokenAddress() public view returns (address ttcAddress) { + return address(i_ttcToken); + } + + /** + * @notice Mints TTC tokens in exchange for ETH sent to the contract. + * @notice The amount of TTC minted is based on the amount of ETH sent, the pre-mint valuation of the vault's assets in ETH, and the pre-mint total supply of TTC. + */ + function mint() public payable { + if (msg.value < 0.01 ether) { + revert MinimumAmountToMint(); + } + // Initialize AUM's value in ETH to 0 + uint256 aum = 0; + // Variable to keep track of actual amount of eth contributed to vault after swap fees + uint256 ethMintAmountAfterFees = 0; + + // Get amount of ETH to swap for rETH + uint256 ethAmountForREth = (msg.value * constituentTokens[0].weight) / 100; + uint256 ethValueInREth = i_rEthToken.getRethValue(ethAmountForREth); + uint256 minREthAmountOut = (ethValueInREth * (10000 - MAX_ROCKET_SWAP_PRICE_IMPACT)) / 10000; + // Execute the rocket swap + uint256 initialREthBalance = i_rEthToken.balanceOf(i_ttcVault); + executeRocketSwapTo(ethAmountForREth, minREthAmountOut); + uint256 resultingREthBalance = i_rEthToken.balanceOf(i_ttcVault); + // Get the pre-swap value of rETH (in ETH) in the vault based on the swap price + aum += ((initialREthBalance * ethAmountForREth) / (resultingREthBalance - initialREthBalance)); + ethMintAmountAfterFees += ( + ethAmountForREth - calculateRocketSwapFee(ethAmountForREth, UNISWAP_ROCKET_PORTION, BALANCER_ROCKET_PORTION) + ); + ethAllocationREth += ethMintAmountAfterFees; + + // Rest of the ETH must be wrapped for the other tokenSwaps + address wEthAddress = address(i_wEthToken); + uint256 ethAmountForTokenSwaps = msg.value - ethAmountForREth; + IWETH(wEthAddress).deposit{value: ethAmountForTokenSwaps}(); + IWETH(wEthAddress).approve(address(i_swapRouter), ethAmountForTokenSwaps); + + for (uint256 i = 1; i < 10; i++) { + Token memory token = constituentTokens[i]; + // Calculate amount of ETH to swap based on token weight in basket + uint256 amountToSwap = (msg.value * token.weight) / 100; + // Get pre-swap balance of token (represented with the precision of the token's decimals) + uint256 tokenBalance = IERC20(token.tokenAddress).balanceOf(i_ttcVault); + // Execute swap and return the tokens received and fee pool which swap executed in + // tokensReceived is represented with the precision of the tokenOut's decimals + (uint256 tokensReceived, uint24 swapFee) = executeUniswapSwap(wEthAddress, token.tokenAddress, amountToSwap, 0); + // Calculate the actual amount swapped after pool fee was deducted + uint256 amountSwappedAfterFee = amountToSwap - ((amountToSwap * swapFee) / 1000000); + ethMintAmountAfterFees += amountSwappedAfterFee; + // Adjust the incoming token precision to match that of ETH if not already + uint8 tokenDecimals = ERC20(token.tokenAddress).decimals(); + if (tokenDecimals < ETH_DECIMALS) { + tokenBalance = tokenBalance * (10 ** (ETH_DECIMALS - tokenDecimals)); + tokensReceived = tokensReceived * (10 ** (ETH_DECIMALS - tokenDecimals)); + } + // Add the token's value in ETH to AUM. + // (amountToSwap / tokensReceived) is the current market price (on Uniswap) of the asset relative to ETH. + // (amountToSwap / tokensReceived) multiplied by tokenBalance gives us the value in ETH of the token in the vault prior to the swap + aum += (tokenBalance * amountSwappedAfterFee) / tokensReceived; + } + + // TTC minting logic + uint256 amountToMint; + uint256 totalSupplyTtc = i_ttcToken.totalSupply(); + if (totalSupplyTtc > 0) { + // If total supply of TTC > 0, mint a variable number of tokens. + // Price of TTC (in ETH) prior to this deposit is the AUM (in ETH) prior to deposit divided by the total supply of TTC + // Amount they deposited in ETH divided by price of TTC (in ETH) is the amount to mint to the minter + amountToMint = (ethMintAmountAfterFees * totalSupplyTtc) / aum; + } else { + // If total supply of TTC is 0, mint 1 token. First mint sets initial price of TTC. + amountToMint = 1 * (10 ** i_ttcToken.decimals()); + } + + transferToVault(); + + // Mint TTC to the minter + i_ttcToken.mint(msg.sender, amountToMint); + emit Minted(msg.sender, msg.value, amountToMint); + } + + /** + * @notice Redeems TTC tokens for a proportional share of the vault's assets. + * @param _ttcAmount The amount of TTC tokens to redeem. + */ + function redeem(uint256 _ttcAmount) public nonReentrant { + uint256 totalSupplyTtc = i_ttcToken.totalSupply(); + // Check if vault is empty + if (totalSupplyTtc == 0) { + revert EmptyVault(); + } + // Check if redeemer has enough TTC to redeem amount + if (_ttcAmount > i_ttcToken.balanceOf(msg.sender)) { + revert InvalidRedemptionAmount(); + } + + // Handle rETH redemption and keep profit to fund reconstitution + uint256 rEthRedemptionAmount = (i_rEthToken.balanceOf(address(this)) * _ttcAmount) / totalSupplyTtc; + uint256 rEthValueInEth = i_rEthToken.getEthValue(rEthRedemptionAmount); + uint256 minAmountOut = (rEthValueInEth * (10000 - MAX_ROCKET_SWAP_PRICE_IMPACT)) / 10000; + uint256 ethAllocationAmountPostSwap = ((ethAllocationREth * _ttcAmount) / totalSupplyTtc) + - calculateRocketSwapFee(rEthRedemptionAmount, UNISWAP_ROCKET_PORTION, BALANCER_ROCKET_PORTION); + uint256 initialEthBalance = address(this).balance; + executeRocketSwapFrom(rEthRedemptionAmount, minAmountOut); + uint256 resultingEthBalance = address(this).balance; + + uint256 ethChange = resultingEthBalance - initialEthBalance; + + uint256 rEthProfit = ethChange - ethAllocationAmountPostSwap; + uint256 fee = ((ethAllocationAmountPostSwap * TREASURY_REDEMPTION_FEE) / 10000); + payable(msg.sender).transfer(ethAllocationAmountPostSwap - fee); + i_continuumTreasury.transfer(fee + rEthProfit); + ethAllocationREth -= ethChange; + + for (uint8 i = 1; i < 10; i++) { + Token memory token = constituentTokens[i]; + uint256 balanceOfAsset = IERC20(token.tokenAddress).balanceOf(address(this)); + // amount to transfer is balanceOfAsset times the ratio of redemption amount of TTC to total supply + uint256 amountToTransfer = (balanceOfAsset * _ttcAmount) / totalSupplyTtc; + // Calculate fee for Continuum Treasury using BPS + fee = (amountToTransfer * TREASURY_REDEMPTION_FEE) / 10000; + // Transfer tokens to redeemer + if (!IERC20(token.tokenAddress).transfer(msg.sender, (amountToTransfer - fee))) { + revert RedemptionTransferFailed(); + } + // Transfer fee to treasury + if (!IERC20(token.tokenAddress).transfer(i_continuumTreasury, fee)) { + revert TreasuryTransferFailed(); + } + } + + // Burn the TTC redeemed + i_ttcToken.burn(msg.sender, _ttcAmount); + emit Redeemed(msg.sender, _ttcAmount); + } + + /** + * @notice Rebalances the vault's portfolio with a new set of tokens and their allocations. + * @dev If routes are slightly outdated, the deviations are corrected by buying/selling the tokens using ETH as a proxy. + * @param _newTokens The new weights for the tokens in the vault. + * @param routes The routes for the swaps to be executed. Route[i] corresponds to the best route for rebalancing token[i] + */ + function rebalance( + Token[10] calldata _newTokens, + Route[10][] calldata routes + ) public payable onlyTreasury nonReentrant { + if (!checkTokenList(_newTokens)) { + revert InvalidTokenList(); + } + + // deviations correspond to the difference between the expected new amount of each token and the actual amount + // not percentages, concrete values + int256[10] memory deviations; + + // perform swaps for other tokens + for (uint8 i; i < 10; i++) { + // if the weight is the same, or no routes provided - no need to swap + if (_newTokens[i].weight == constituentTokens[i].weight || routes[i][0].tokenIn == address(0)) { + continue; + } + + // perform swap + for (uint8 j; j < routes[i].length; j++) { + if (routes[i][j].tokenIn == address(0)) { + break; + } + + // custom logic for rETH swaps + // TODO: abstract this into a separate function + if (routes[i][j].tokenIn == address(i_rEthToken) || routes[i][j].tokenOut == address(i_rEthToken)) { + // for rETH, use balancer and uniswap with predetermined weights to swap + // assumption: route[0] is a single route for rETH (since there is no need to use some proxy token between rETH/ETH) + Route calldata rEthRoute = routes[i][j]; + + // use rocket swap for rETH + if (rEthRoute.tokenIn == address(i_rEthToken) && rEthRoute.tokenOut == address(i_wEthToken)) { + // get ETH for rETH + uint256 rEthAmountForEth = rEthRoute.amountIn; + executeRocketSwapFrom(rEthAmountForEth, rEthRoute.amountOutMinimum); + } else if (rEthRoute.tokenOut == address(i_rEthToken) && rEthRoute.tokenIn == address(i_wEthToken)) { + // get rETH for ETH + uint256 ethAmountForREth = rEthRoute.amountIn; + executeRocketSwapTo(ethAmountForREth, rEthRoute.amountOutMinimum); + } else { + revert InvalidRoute(); + } + } + + // get routes for the swap + Route calldata route = routes[i][j]; + IERC20(route.tokenIn).approve(address(i_swapRouter), route.amountIn); + + // Execute swap. + executeUniswapSwap(route.tokenIn, route.tokenOut, route.amountIn, route.amountOutMinimum); + } + } + + // get ethereum amount of each token in the vault (aumPerToken) and total ethereum amount in the vault (totalAUM) + constituentTokens = _newTokens; + (uint256[10] memory aumPerToken, uint256 totalAUM) = aumBreakdown(); + + // find deviations from the expected amount in the vault of each token + // since routes could be calculated in a block with different prices, we need to check if the deviations are not too big + for (uint8 i; i < 10; i++) { + // calculate deviations + uint256 tokenValueInEth = aumPerToken[i]; // get price of token in ETH + uint256 expectedTokenValueInEth = (totalAUM * _newTokens[i].weight) / 100; // get expected value of token in ETH in the contract after rebalancing + + // calculate deviation of actual token value from expected token value in ETH + deviations[i] = int256(expectedTokenValueInEth) - int256(tokenValueInEth); + } + + // TODO: consider checking the fraction of deviations, so we can abort if they are too big + + // msg.value is used to correct positive deviations + // positive deviation corresponds to the fact that we expect more of the token in the vault than we have -> buy it with eth + uint256 amountForDeviationCorrection = msg.value; + + // correct deviations + for (uint8 i; i < 10; i++) { + address tokenAddress = constituentTokens[i].tokenAddress; + int256 deviation = deviations[i]; + + if (deviation > 0) { // -> need to buy more of this token (amount < expected) + // absolute value of deviation + uint256 uDeviation = uint256(deviation); + + // wrap eth for a swap + IWETH(address(i_wEthToken)).deposit{value: uDeviation}(); + amountForDeviationCorrection -= uDeviation; // track how much eth we have left to send back to treasury + + // swap ETH for Token + IWETH(address(i_wEthToken)).approve(address(i_swapRouter), uDeviation); + executeUniswapSwap(address(i_wEthToken), tokenAddress, uDeviation, 0); + } else if (deviation < 0) { // need to sell a token (amount > expected) + // absolute value of deviation + uint256 uDeviation = abs(deviation); + + uint256 tokenValueInEth = getLatestPriceInEthOf(tokenAddress); + uint256 deviationToSellInToken = uDeviation * 10 ** ERC20(tokenAddress).decimals() / tokenValueInEth; + + IERC20(tokenAddress).approve(address(i_swapRouter), deviationToSellInToken); + executeUniswapSwap(tokenAddress, address(i_wEthToken), deviationToSellInToken, 0); + } + } + + // update weights + for (uint8 i; i < 10; i++) { + constituentTokens[i].weight = _newTokens[i].weight; + } + + // send remaining amountToDeviationCorrection back to treasury + if (amountForDeviationCorrection > 0) { + i_continuumTreasury.transfer(amountForDeviationCorrection); + } + + emit Rebalanced(_newTokens); + } + + /** + * @notice Allows the contract to receive ETH directly. + */ + receive() external payable {} + + function transferToVault() internal { + // Transfer all constituent tokens to the vault + for (uint8 i; i < 10; i++) { + IERC20 token = IERC20(constituentTokens[i].tokenAddress); + uint256 balance = token.balanceOf(address(this)); + token.transfer(address(i_ttcVault), balance); + } + } + + /** + * @notice Checks the validity of the initial token list setup for the vault. + * @param _tokens The array of tokens to check. + * @return bool Returns true if the token list is valid, otherwise false. + */ + function checkTokenList(Token[10] memory _tokens) internal view returns (bool) { + // Make sure the first token is always rETH + if (_tokens[0].tokenAddress != address(i_rEthToken) || _tokens[0].weight != 50) { + return false; + } + + uint8 totalWeight; + + for (uint8 i; i < 10; i++) { + // Check weight is > 0 + if (_tokens[i].weight == 0) return false; + totalWeight += _tokens[i].weight; + + // Check if token is a fungible token and is less or as precise as ETH + if (ERC20(_tokens[i].tokenAddress).decimals() > ETH_DECIMALS) { + return false; + } + + // Check for any duplicate tokens + for (uint8 j = i + 1; j < _tokens.length; j++) { + if (_tokens[i].tokenAddress == _tokens[j].tokenAddress) { + return false; + } + } + } + + // Check sum of weights is 100 + return (totalWeight == 100); + } + + /** + * @notice Execute ETH->rETH swap using rocket swap router + * @param _amountEthToSwap The amount of ETH to swap + * @param _minREthAmountOut Minimum amount of RETH to receive + */ + function executeRocketSwapTo( + uint256 _amountEthToSwap, + uint256 _minREthAmountOut + ) internal { + // Swap rETH for ETH using provided route + i_rocketSwapRouter.swapTo{value: _amountEthToSwap}( + UNISWAP_ROCKET_PORTION, BALANCER_ROCKET_PORTION, _minREthAmountOut, _minREthAmountOut + ); + } + + /** + * @notice Execute rETH->ETH swap using rocket swap router + * @param _amountREthToSwap The amount of ETH to swap + * @param _minREthAmountOut Minimum amount of RETH to receive + */ + function executeRocketSwapFrom( + uint256 _amountREthToSwap, + uint256 _minREthAmountOut + ) internal { + // Approve rocket swap router to spend the tokens + i_rEthToken.approve(address(i_rocketSwapRouter), _amountREthToSwap); + // Swap rETH for ETH using provided route + i_rocketSwapRouter.swapFrom( + UNISWAP_ROCKET_PORTION, BALANCER_ROCKET_PORTION, _minREthAmountOut, _minREthAmountOut, _amountREthToSwap + ); + } + + /** + * @notice Calculate the total fee for a rocket swap + * @param _amountToSwap The amount of ETH or rETH to swap + * @param _uniswapPortion Portion to swap using uniswap + * @param _balancerPortion Portion to swap using balancer + * @return fee The total fee for the swap + */ + function calculateRocketSwapFee(uint256 _amountToSwap, uint256 _uniswapPortion, uint256 _balancerPortion) + internal + pure + returns (uint256 fee) + { + uint256 totalPortions = _uniswapPortion + _balancerPortion; + // Rocket swap router uses 0.05% fee tier for uniswap + uint256 uniswapSwapFee = + (((_amountToSwap * _uniswapPortion) / totalPortions) * UNISWAP_TERTIARY_POOL_FEE) / 1000000; + uint256 balancerSwapFee = + (((_amountToSwap * _balancerPortion) / totalPortions) * BALANCER_STABLE_POOL_FEE) / 1000000; + return (uniswapSwapFee + balancerSwapFee); + } + + /** + * @notice Executes a swap using Uniswap V3 for a given token pair and amount. + * @param _tokenIn The address of the token to swap from. + * @param _tokenOut The address of the token to swap to. + * @param _amountIn The amount of `tokenIn` to swap. + * @return amountOut The amount of tokens received from the swap. + * @return feeTier The pool fee used for the swap. + */ + function executeUniswapSwap(address _tokenIn, address _tokenOut, uint256 _amountIn, uint256 amountOutMinimum) + internal + returns (uint256 amountOut, uint24 feeTier) + { + ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ + tokenIn: _tokenIn, // Token to swap + tokenOut: _tokenOut, // Token to receive + fee: UNISWAP_PRIMARY_POOL_FEE, // Initially set primary fee pool + recipient: address(this), // Send tokens to TTC vault + deadline: block.timestamp, // Swap must be performed in the current block. This should be passed in as a parameter to mitigate MEV exploits. + amountIn: _amountIn, // Amount of tokenIn to swap + amountOutMinimum: amountOutMinimum, // Receive whatever we can get for now (should set in production) + sqrtPriceLimitX96: 0 // Ignore for now (should set in production to reduce price impact) + }); + + // Try swap at primary, secondary, and tertiary fee tiers respectively. + // Fee priority is 0.3% -> 1% -> 0.05% since we assume most high cap coins will have the best liquidity in the middle, then the highest, then the lowest fee tier. + // Ideally, optimal routing would be computed off-chain and provided as a parameter to mint. + // This is a placeholder to make minting functional for now. + try i_swapRouter.exactInputSingle(params) returns (uint256 amountOutResult) { + return (amountOutResult, params.fee); + } catch { + params.fee = UNISWAP_SECONDARY_POOL_FEE; + try i_swapRouter.exactInputSingle(params) returns (uint256 amountOutResult) { + return (amountOutResult, params.fee); + } catch { + params.fee = UNISWAP_TERTIARY_POOL_FEE; + return (i_swapRouter.exactInputSingle(params), params.fee); + } + } + } + + /** + * @notice Get the latest price of a token + * @param tokenAddress The address of the token to get the price of + * @return The number of ETH that has to be paid for 1 constituent token + */ + function getLatestPriceInEthOf(address tokenAddress) public view returns (uint256) { + address wEthAddress = address(i_wEthToken); + + // if token is rETH (0 index), use native contract for better price accuracy + if (tokenAddress == address(i_rEthToken)) { + return i_rEthToken.getEthValue(1e18); + } + + // get a token/wETH pool's address + + address pool = getPoolWithFee(tokenAddress, wEthAddress, UNISWAP_PRIMARY_POOL_FEE); + if (pool == address(0)) { + revert PoolDoesNotExist(); + } + + // convert to IUniswapV3PoolState to get access to sqrtPriceX96 + IUniswapV3PoolState _pool = IUniswapV3PoolState(pool); + + uint24[3] memory feeTiers = [UNISWAP_SECONDARY_POOL_FEE, UNISWAP_TERTIARY_POOL_FEE, UNISWAP_QUATERNARY_POOL_FEE]; + + for (uint8 i; i < 3; i++) { + pool = getPoolWithFee(tokenAddress, wEthAddress, feeTiers[i]); + if (pool == address(0)) { + continue; + } else { + _pool = IUniswapV3PoolState(pool); + break; + } + } + + (uint160 sqrtPriceX96, , , , , ,) = _pool.slot0(); // get sqrtPrice of a pool multiplied by 2^96 + uint256 decimals = 10 ** ERC20(tokenAddress).decimals(); + uint256 sqrtPrice = (sqrtPriceX96 * decimals) / 2 ** 96; // get sqrtPrice with decimals of token + + uint256 result = sqrtPrice ** 2 / decimals; + return result; // get price of token in ETH, remove added decimals of token due to squaring + } + + /** + * @notice Get the AUM of the vault in ETH per each token and the total AUM + * @return The AUM of the vault in ETH + */ + function aumBreakdown() public view returns (uint256[10] memory, uint256) { + uint256[10] memory aumPerToken; + uint256 totalAum; + for (uint8 i; i < 10; i++) { + address tokenAddress = constituentTokens[i].tokenAddress; + uint256 tokenPrice = getLatestPriceInEthOf(tokenAddress); + uint256 tokenBalance = IERC20(tokenAddress).balanceOf(address(this)); + + aumPerToken[i] = (tokenBalance * tokenPrice) / (10 ** ERC20(tokenAddress).decimals()); + totalAum += aumPerToken[i]; + } + + return (aumPerToken, totalAum); + } + + /** + * @notice Absolute Value + * @param x The number to get the absolute value of + * @return The absolute value of x + */ + function abs(int x) private pure returns (uint) { + return x >= 0 ? uint(x) : uint(-x); + } + + /** + * @notice Get the address of a pool with a given fee + * @param tokenIn The address of the token to swap from + * @param tokenOut The address of the token to swap to + * @param fee The fee of the pool + * @return The address of the pool + */ + function getPoolWithFee(address tokenIn, address tokenOut, uint24 fee) public view returns (address) { + bytes memory payload = abi.encodeWithSignature("getPool(address,address,uint24)", tokenIn, tokenOut, fee); + (bool success, bytes memory data) = i_uniswapFactory.staticcall(payload); + if (!success) { + revert PoolDoesNotExist(); + } + + return abi.decode(data, (address)); + } +} diff --git a/src/TtcVault.sol b/src/TtcVault.sol index f23e93a..2e3af5b 100644 --- a/src/TtcVault.sol +++ b/src/TtcVault.sol @@ -1,586 +1,66 @@ // SPDX-License-Identifier: GPL-3.0 - pragma solidity 0.8.20; -// TTC token contract -import "./TTC.sol"; - -// Types -import {Route, Token} from "./types/types.sol"; -import {IUniswapV3PoolState} from "@uniswap/v3-core/contracts/interfaces/pool/IUniswapV3PoolState.sol"; -import {console} from "forge-std/Test.sol"; +import {ITtcVault} from "./interfaces/ITtcVault.sol"; +import {Token} from "./types/types.sol"; -// Interfaces -import "./interfaces/ITtcVault.sol"; -import "@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol"; -import "@rocketpool-router/contracts/RocketSwapRouter.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -/** +/** * @title TtcVault - * @author Shivaansh Kapoor - * @notice Vault for Continuum's first product: TTC (Top Ten Continuum) Token - * @notice TTC tokens are fungible assets backed by a basket of the top 10 ERC20 tokens by market cap (the allocation of each token depends on its market cap relative to others) - * @notice The TtcVault allows for minting TTC tokens with ETH and redeeming TTC tokens for its constituent tokens - * @notice The vault also undergoes periodic reconstitutions - */ + * @author Ruslan Akhtariev + * @notice Vault contract for storing TTC constituent tokens +*/ contract TtcVault is ITtcVault, ReentrancyGuard { - // Treasury fee is only taken upon redemption - // Treasury fee is denominated in BPS (basis points). 1 basis point = 0.01% - // Fee is initally set to 0.1% of redemption amount. - uint8 public constant TREASURY_REDEMPTION_FEE = 1e1; - // Uniswap pool fees are denominated in 100ths of a basis point. - uint24 public constant UNISWAP_PRIMARY_POOL_FEE = 3e3; - uint24 public constant UNISWAP_SECONDARY_POOL_FEE = 1e4; - uint24 public constant UNISWAP_TERTIARY_POOL_FEE = 5e2; - uint24 public constant UNISWAP_QUATERNARY_POOL_FEE = 1e2; // added via proposal in 2021 - // Balancer rETH/wETH swap fee is 0.04% - uint24 public constant BALANCER_STABLE_POOL_FEE = 4e2; - // Max price impact allowed for rocket swap - uint24 public constant MAX_ROCKET_SWAP_PRICE_IMPACT = 1e1; - - // Immutable globals - TTC public immutable i_ttcToken; - address payable public immutable i_continuumTreasury; - address public immutable i_uniswapFactory; - ISwapRouter public immutable i_swapRouter; - RocketSwapRouter public immutable i_rocketSwapRouter; - IWETH public immutable i_wEthToken; - IrETH public immutable i_rEthToken; - - // Current tokens and their alloGcations in the vault - Token[10] public constituentTokens; + address public continuumModerator; + address public ttcLogicContract; - // Amount of ETH allocated into rEth so far (after swap fees) - uint256 private ethAllocationREth; - uint8 private constant ETH_DECIMALS = 18; - uint8 private constant UNISWAP_ROCKET_PORTION = 0; - uint8 private constant BALANCER_ROCKET_PORTION = 10; - - // Flag to check for reentrancy - bool private locked; - - // Modifiers - modifier onlyTreasury() { - if (msg.sender != i_continuumTreasury) { - revert OnlyTreasury(); - } + // only moderator + modifier onlyModerator() { + require(msg.sender == continuumModerator, "Only owner can call this function"); _; } /** - * @notice Constructor to initialize the TTC vault with specified parameters. - * @param _treasury The address of the treasury to receive fees. - * @param _swapRouterAddress The address of the Uniswap v3 swap router. - * @param _wEthAddress The address of the Wrapped Ethereum token. - * @param _rocketSwapRouter The address of the Rocket Swap Router - * @param _initialTokens The initial set of tokens and their allocations for the vault. - */ - constructor( - address _treasury, - address _swapRouterAddress, - address _wEthAddress, - address _rocketSwapRouter, - address _uniswapFactoryAddress, - Token[10] memory _initialTokens - ) { - i_ttcToken = new TTC(); - i_continuumTreasury = payable(_treasury); - i_swapRouter = ISwapRouter(_swapRouterAddress); - i_wEthToken = IWETH(_wEthAddress); - i_rocketSwapRouter = RocketSwapRouter(payable(_rocketSwapRouter)); - i_rEthToken = i_rocketSwapRouter.rETH(); - i_uniswapFactory = _uniswapFactoryAddress; - - if (!checkTokenList(_initialTokens)) { - revert InvalidTokenList(); - } - - for (uint8 i; i < 10; i++) { - constituentTokens[i] = _initialTokens[i]; - } - } - - /** - * @notice Gets the address of the TTC token contract. - * @return ttcAddress The address of the TTC token contract. + * @notice Constructor to initialize a TTC vault contract */ - function getTtcTokenAddress() public view returns (address ttcAddress) { - return address(i_ttcToken); + constructor() { + continuumModerator = msg.sender; } - /** - * @notice Mints TTC tokens in exchange for ETH sent to the contract. - * @notice The amount of TTC minted is based on the amount of ETH sent, the pre-mint valuation of the vault's assets in ETH, and the pre-mint total supply of TTC. + /** + * @notice Transfer ownership of the contract + * @param _newOwner Address of the new owner + * @dev Will be used to eventually transfer ownerwhip to DAO contract */ - function mint() public payable { - if (msg.value < 0.01 ether) { - revert MinimumAmountToMint(); - } - // Initialize AUM's value in ETH to 0 - uint256 aum = 0; - // Variable to keep track of actual amount of eth contributed to vault after swap fees - uint256 ethMintAmountAfterFees = 0; - - // Get amount of ETH to swap for rETH - uint256 ethAmountForREth = (msg.value * constituentTokens[0].weight) / 100; - uint256 ethValueInREth = i_rEthToken.getRethValue(ethAmountForREth); - uint256 minREthAmountOut = (ethValueInREth * (10000 - MAX_ROCKET_SWAP_PRICE_IMPACT)) / 10000; - // Execute the rocket swap - uint256 initialREthBalance = i_rEthToken.balanceOf(address(this)); - executeRocketSwapTo(ethAmountForREth, minREthAmountOut); - uint256 resultingREthBalance = i_rEthToken.balanceOf(address(this)); - // Get the pre-swap value of rETH (in ETH) in the vault based on the swap price - aum += ((initialREthBalance * ethAmountForREth) / (resultingREthBalance - initialREthBalance)); - ethMintAmountAfterFees += ( - ethAmountForREth - calculateRocketSwapFee(ethAmountForREth, UNISWAP_ROCKET_PORTION, BALANCER_ROCKET_PORTION) - ); - ethAllocationREth += ethMintAmountAfterFees; - - // Rest of the ETH must be wrapped for the other tokenSwaps - address wEthAddress = address(i_wEthToken); - uint256 ethAmountForTokenSwaps = msg.value - ethAmountForREth; - IWETH(wEthAddress).deposit{value: ethAmountForTokenSwaps}(); - IWETH(wEthAddress).approve(address(i_swapRouter), ethAmountForTokenSwaps); - - for (uint256 i = 1; i < 10; i++) { - Token memory token = constituentTokens[i]; - // Calculate amount of ETH to swap based on token weight in basket - uint256 amountToSwap = (msg.value * token.weight) / 100; - // Get pre-swap balance of token (represented with the precision of the token's decimals) - uint256 tokenBalance = IERC20(token.tokenAddress).balanceOf(address(this)); - // Execute swap and return the tokens received and fee pool which swap executed in - // tokensReceived is represented with the precision of the tokenOut's decimals - (uint256 tokensReceived, uint24 swapFee) = executeUniswapSwap(wEthAddress, token.tokenAddress, amountToSwap, 0); - // Calculate the actual amount swapped after pool fee was deducted - uint256 amountSwappedAfterFee = amountToSwap - ((amountToSwap * swapFee) / 1000000); - ethMintAmountAfterFees += amountSwappedAfterFee; - // Adjust the incoming token precision to match that of ETH if not already - uint8 tokenDecimals = ERC20(token.tokenAddress).decimals(); - if (tokenDecimals < ETH_DECIMALS) { - tokenBalance = tokenBalance * (10 ** (ETH_DECIMALS - tokenDecimals)); - tokensReceived = tokensReceived * (10 ** (ETH_DECIMALS - tokenDecimals)); - } - // Add the token's value in ETH to AUM. - // (amountToSwap / tokensReceived) is the current market price (on Uniswap) of the asset relative to ETH. - // (amountToSwap / tokensReceived) multiplied by tokenBalance gives us the value in ETH of the token in the vault prior to the swap - aum += (tokenBalance * amountSwappedAfterFee) / tokensReceived; - } + function transferOwnership(address _newOwner) public onlyModerator { + continuumModerator = _newOwner; - // TTC minting logic - uint256 amountToMint; - uint256 totalSupplyTtc = i_ttcToken.totalSupply(); - if (totalSupplyTtc > 0) { - // If total supply of TTC > 0, mint a variable number of tokens. - // Price of TTC (in ETH) prior to this deposit is the AUM (in ETH) prior to deposit divided by the total supply of TTC - // Amount they deposited in ETH divided by price of TTC (in ETH) is the amount to mint to the minter - amountToMint = (ethMintAmountAfterFees * totalSupplyTtc) / aum; - } else { - // If total supply of TTC is 0, mint 1 token. First mint sets initial price of TTC. - amountToMint = 1 * (10 ** i_ttcToken.decimals()); - } - - // Mint TTC to the minter - i_ttcToken.mint(msg.sender, amountToMint); - emit Minted(msg.sender, msg.value, amountToMint); + emit OwnershipTransferred(msg.sender, _newOwner); } - /** - * @notice Redeems TTC tokens for a proportional share of the vault's assets. - * @param _ttcAmount The amount of TTC tokens to redeem. + /** + * @notice Set the address of the TTC logic contract + * @param _ttcLogicContract Address of the TTC logic contract + * @dev Subject to change on logic upgrades */ - function redeem(uint256 _ttcAmount) public nonReentrant { - uint256 totalSupplyTtc = i_ttcToken.totalSupply(); - // Check if vault is empty - if (totalSupplyTtc == 0) { - revert EmptyVault(); - } - // Check if redeemer has enough TTC to redeem amount - if (_ttcAmount > i_ttcToken.balanceOf(msg.sender)) { - revert InvalidRedemptionAmount(); - } - - // Handle rETH redemption and keep profit to fund reconstitution - uint256 rEthRedemptionAmount = (i_rEthToken.balanceOf(address(this)) * _ttcAmount) / totalSupplyTtc; - uint256 rEthValueInEth = i_rEthToken.getEthValue(rEthRedemptionAmount); - uint256 minAmountOut = (rEthValueInEth * (10000 - MAX_ROCKET_SWAP_PRICE_IMPACT)) / 10000; - uint256 ethAllocationAmountPostSwap = ((ethAllocationREth * _ttcAmount) / totalSupplyTtc) - - calculateRocketSwapFee(rEthRedemptionAmount, UNISWAP_ROCKET_PORTION, BALANCER_ROCKET_PORTION); - uint256 initialEthBalance = address(this).balance; - executeRocketSwapFrom(rEthRedemptionAmount, minAmountOut); - uint256 resultingEthBalance = address(this).balance; - - uint256 ethChange = resultingEthBalance - initialEthBalance; + function setTtcLogicContract(address _ttcLogicContract) public onlyModerator { + ttcLogicContract = _ttcLogicContract; - uint256 rEthProfit = ethChange - ethAllocationAmountPostSwap; - uint256 fee = ((ethAllocationAmountPostSwap * TREASURY_REDEMPTION_FEE) / 10000); - payable(msg.sender).transfer(ethAllocationAmountPostSwap - fee); - i_continuumTreasury.transfer(fee + rEthProfit); - ethAllocationREth -= ethChange; - - for (uint8 i = 1; i < 10; i++) { - Token memory token = constituentTokens[i]; - uint256 balanceOfAsset = IERC20(token.tokenAddress).balanceOf(address(this)); - // amount to transfer is balanceOfAsset times the ratio of redemption amount of TTC to total supply - uint256 amountToTransfer = (balanceOfAsset * _ttcAmount) / totalSupplyTtc; - // Calculate fee for Continuum Treasury using BPS - fee = (amountToTransfer * TREASURY_REDEMPTION_FEE) / 10000; - // Transfer tokens to redeemer - if (!IERC20(token.tokenAddress).transfer(msg.sender, (amountToTransfer - fee))) { - revert RedemptionTransferFailed(); - } - // Transfer fee to treasury - if (!IERC20(token.tokenAddress).transfer(i_continuumTreasury, fee)) { - revert TreasuryTransferFailed(); - } - } - - // Burn the TTC redeemed - i_ttcToken.burn(msg.sender, _ttcAmount); - emit Redeemed(msg.sender, _ttcAmount); + emit TtcLogicContractSet(_ttcLogicContract); } - /** - * @notice Rebalances the vault's portfolio with a new set of tokens and their allocations. - * @dev If routes are slightly outdated, the deviations are corrected by buying/selling the tokens using ETH as a proxy. - * @param _newTokens The new weights for the tokens in the vault. - * @param routes The routes for the swaps to be executed. Route[i] corresponds to the best route for rebalancing token[i] - */ - function rebalance( - Token[10] calldata _newTokens, - Route[10][] calldata routes - ) public payable onlyTreasury nonReentrant { - if (!checkTokenList(_newTokens)) { - revert InvalidTokenList(); - } - - // deviations correspond to the difference between the expected new amount of each token and the actual amount - // not percentages, concrete values - int256[10] memory deviations; - - // perform swaps for other tokens - for (uint8 i; i < 10; i++) { - // if the weight is the same, or no routes provided - no need to swap - if (_newTokens[i].weight == constituentTokens[i].weight || routes[i][0].tokenIn == address(0)) { - continue; - } - - // perform swap - for (uint8 j; j < routes[i].length; j++) { - if (routes[i][j].tokenIn == address(0)) { - break; - } - - // custom logic for rETH swaps - // TODO: abstract this into a separate function - if (routes[i][j].tokenIn == address(i_rEthToken) || routes[i][j].tokenOut == address(i_rEthToken)) { - // for rETH, use balancer and uniswap with predetermined weights to swap - // assumption: route[0] is a single route for rETH (since there is no need to use some proxy token between rETH/ETH) - Route calldata rEthRoute = routes[i][j]; - - // use rocket swap for rETH - if (rEthRoute.tokenIn == address(i_rEthToken) && rEthRoute.tokenOut == address(i_wEthToken)) { - // get ETH for rETH - uint256 rEthAmountForEth = rEthRoute.amountIn; - executeRocketSwapFrom(rEthAmountForEth, rEthRoute.amountOutMinimum); - } else if (rEthRoute.tokenOut == address(i_rEthToken) && rEthRoute.tokenIn == address(i_wEthToken)) { - // get rETH for ETH - uint256 ethAmountForREth = rEthRoute.amountIn; - executeRocketSwapTo(ethAmountForREth, rEthRoute.amountOutMinimum); - } else { - revert InvalidRoute(); - } - } - - // get routes for the swap - Route calldata route = routes[i][j]; - IERC20(route.tokenIn).approve(address(i_swapRouter), route.amountIn); - - // Execute swap. - executeUniswapSwap(route.tokenIn, route.tokenOut, route.amountIn, route.amountOutMinimum); - } - } - - // get ethereum amount of each token in the vault (aumPerToken) and total ethereum amount in the vault (totalAUM) - constituentTokens = _newTokens; - (uint256[10] memory aumPerToken, uint256 totalAUM) = aumBreakdown(); - - // find deviations from the expected amount in the vault of each token - // since routes could be calculated in a block with different prices, we need to check if the deviations are not too big - for (uint8 i; i < 10; i++) { - // calculate deviations - uint256 tokenValueInEth = aumPerToken[i]; // get price of token in ETH - uint256 expectedTokenValueInEth = (totalAUM * _newTokens[i].weight) / 100; // get expected value of token in ETH in the contract after rebalancing - - // calculate deviation of actual token value from expected token value in ETH - deviations[i] = int256(expectedTokenValueInEth) - int256(tokenValueInEth); - } - - // TODO: consider checking the fraction of deviations, so we can abort if they are too big - - // msg.value is used to correct positive deviations - // positive deviation corresponds to the fact that we expect more of the token in the vault than we have -> buy it with eth - uint256 amountForDeviationCorrection = msg.value; - - // correct deviations - for (uint8 i; i < 10; i++) { - address tokenAddress = constituentTokens[i].tokenAddress; - int256 deviation = deviations[i]; - - if (deviation > 0) { // -> need to buy more of this token (amount < expected) - // absolute value of deviation - uint256 uDeviation = uint256(deviation); - - // wrap eth for a swap - IWETH(address(i_wEthToken)).deposit{value: uDeviation}(); - amountForDeviationCorrection -= uDeviation; // track how much eth we have left to send back to treasury - - // swap ETH for Token - IWETH(address(i_wEthToken)).approve(address(i_swapRouter), uDeviation); - executeUniswapSwap(address(i_wEthToken), tokenAddress, uDeviation, 0); - } else if (deviation < 0) { // need to sell a token (amount > expected) - // absolute value of deviation - uint256 uDeviation = abs(deviation); - - uint256 tokenValueInEth = getLatestPriceInEthOf(tokenAddress); - uint256 deviationToSellInToken = uDeviation * 10 ** ERC20(tokenAddress).decimals() / tokenValueInEth; - - IERC20(tokenAddress).approve(address(i_swapRouter), deviationToSellInToken); - executeUniswapSwap(tokenAddress, address(i_wEthToken), deviationToSellInToken, 0); - } - } - - // update weights - for (uint8 i; i < 10; i++) { - constituentTokens[i].weight = _newTokens[i].weight; - } - - // send remaining amountToDeviationCorrection back to treasury - if (amountForDeviationCorrection > 0) { - i_continuumTreasury.transfer(amountForDeviationCorrection); - } - - emit Rebalanced(_newTokens); - } - - /** - * @notice Allows the contract to receive ETH directly. - */ - receive() external payable {} - - /** - * @notice Checks the validity of the initial token list setup for the vault. - * @param _tokens The array of tokens to check. - * @return bool Returns true if the token list is valid, otherwise false. - */ - function checkTokenList(Token[10] memory _tokens) internal view returns (bool) { - // Make sure the first token is always rETH - if (_tokens[0].tokenAddress != address(i_rEthToken) || _tokens[0].weight != 50) { - return false; - } - - uint8 totalWeight; - - for (uint8 i; i < 10; i++) { - // Check weight is > 0 - if (_tokens[i].weight == 0) return false; - totalWeight += _tokens[i].weight; - - // Check if token is a fungible token and is less or as precise as ETH - if (ERC20(_tokens[i].tokenAddress).decimals() > ETH_DECIMALS) { - return false; - } - - // Check for any duplicate tokens - for (uint8 j = i + 1; j < _tokens.length; j++) { - if (_tokens[i].tokenAddress == _tokens[j].tokenAddress) { - return false; - } - } - } - - // Check sum of weights is 100 - return (totalWeight == 100); - } - - /** - * @notice Execute ETH->rETH swap using rocket swap router - * @param _amountEthToSwap The amount of ETH to swap - * @param _minREthAmountOut Minimum amount of RETH to receive - */ - function executeRocketSwapTo( - uint256 _amountEthToSwap, - uint256 _minREthAmountOut - ) internal { - // Swap rETH for ETH using provided route - i_rocketSwapRouter.swapTo{value: _amountEthToSwap}( - UNISWAP_ROCKET_PORTION, BALANCER_ROCKET_PORTION, _minREthAmountOut, _minREthAmountOut - ); - } - - /** - * @notice Execute rETH->ETH swap using rocket swap router - * @param _amountREthToSwap The amount of ETH to swap - * @param _minREthAmountOut Minimum amount of RETH to receive - */ - function executeRocketSwapFrom( - uint256 _amountREthToSwap, - uint256 _minREthAmountOut - ) internal { - // Approve rocket swap router to spend the tokens - i_rEthToken.approve(address(i_rocketSwapRouter), _amountREthToSwap); - // Swap rETH for ETH using provided route - i_rocketSwapRouter.swapFrom( - UNISWAP_ROCKET_PORTION, BALANCER_ROCKET_PORTION, _minREthAmountOut, _minREthAmountOut, _amountREthToSwap - ); - } - - /** - * @notice Calculate the total fee for a rocket swap - * @param _amountToSwap The amount of ETH or rETH to swap - * @param _uniswapPortion Portion to swap using uniswap - * @param _balancerPortion Portion to swap using balancer - * @return fee The total fee for the swap - */ - function calculateRocketSwapFee(uint256 _amountToSwap, uint256 _uniswapPortion, uint256 _balancerPortion) - internal - pure - returns (uint256 fee) - { - uint256 totalPortions = _uniswapPortion + _balancerPortion; - // Rocket swap router uses 0.05% fee tier for uniswap - uint256 uniswapSwapFee = - (((_amountToSwap * _uniswapPortion) / totalPortions) * UNISWAP_TERTIARY_POOL_FEE) / 1000000; - uint256 balancerSwapFee = - (((_amountToSwap * _balancerPortion) / totalPortions) * BALANCER_STABLE_POOL_FEE) / 1000000; - return (uniswapSwapFee + balancerSwapFee); - } - - /** - * @notice Executes a swap using Uniswap V3 for a given token pair and amount. - * @param _tokenIn The address of the token to swap from. - * @param _tokenOut The address of the token to swap to. - * @param _amountIn The amount of `tokenIn` to swap. - * @return amountOut The amount of tokens received from the swap. - * @return feeTier The pool fee used for the swap. - */ - function executeUniswapSwap(address _tokenIn, address _tokenOut, uint256 _amountIn, uint256 amountOutMinimum) - internal - returns (uint256 amountOut, uint24 feeTier) - { - ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ - tokenIn: _tokenIn, // Token to swap - tokenOut: _tokenOut, // Token to receive - fee: UNISWAP_PRIMARY_POOL_FEE, // Initially set primary fee pool - recipient: address(this), // Send tokens to TTC vault - deadline: block.timestamp, // Swap must be performed in the current block. This should be passed in as a parameter to mitigate MEV exploits. - amountIn: _amountIn, // Amount of tokenIn to swap - amountOutMinimum: amountOutMinimum, // Receive whatever we can get for now (should set in production) - sqrtPriceLimitX96: 0 // Ignore for now (should set in production to reduce price impact) - }); - - // Try swap at primary, secondary, and tertiary fee tiers respectively. - // Fee priority is 0.3% -> 1% -> 0.05% since we assume most high cap coins will have the best liquidity in the middle, then the highest, then the lowest fee tier. - // Ideally, optimal routing would be computed off-chain and provided as a parameter to mint. - // This is a placeholder to make minting functional for now. - try i_swapRouter.exactInputSingle(params) returns (uint256 amountOutResult) { - return (amountOutResult, params.fee); - } catch { - params.fee = UNISWAP_SECONDARY_POOL_FEE; - try i_swapRouter.exactInputSingle(params) returns (uint256 amountOutResult) { - return (amountOutResult, params.fee); - } catch { - params.fee = UNISWAP_TERTIARY_POOL_FEE; - return (i_swapRouter.exactInputSingle(params), params.fee); - } - } - } - - /** - * @notice Get the latest price of a token - * @param tokenAddress The address of the token to get the price of - * @return The number of ETH that has to be paid for 1 constituent token - */ - function getLatestPriceInEthOf(address tokenAddress) public view returns (uint256) { - address wEthAddress = address(i_wEthToken); - - // if token is rETH (0 index), use native contract for better price accuracy - if (tokenAddress == address(i_rEthToken)) { - return i_rEthToken.getEthValue(1e18); - } - - // get a token/wETH pool's address - - address pool = getPoolWithFee(tokenAddress, wEthAddress, UNISWAP_PRIMARY_POOL_FEE); - if (pool == address(0)) { - revert PoolDoesNotExist(); - } - - // convert to IUniswapV3PoolState to get access to sqrtPriceX96 - IUniswapV3PoolState _pool = IUniswapV3PoolState(pool); - - uint24[3] memory feeTiers = [UNISWAP_SECONDARY_POOL_FEE, UNISWAP_TERTIARY_POOL_FEE, UNISWAP_QUATERNARY_POOL_FEE]; - - for (uint8 i; i < 3; i++) { - pool = getPoolWithFee(tokenAddress, wEthAddress, feeTiers[i]); - if (pool == address(0)) { - continue; - } else { - _pool = IUniswapV3PoolState(pool); - break; - } - } - - (uint160 sqrtPriceX96, , , , , ,) = _pool.slot0(); // get sqrtPrice of a pool multiplied by 2^96 - uint256 decimals = 10 ** ERC20(tokenAddress).decimals(); - uint256 sqrtPrice = (sqrtPriceX96 * decimals) / 2 ** 96; // get sqrtPrice with decimals of token - - uint256 result = sqrtPrice ** 2 / decimals; - return result; // get price of token in ETH, remove added decimals of token due to squaring - } - - /** - * @notice Get the AUM of the vault in ETH per each token and the total AUM - * @return The AUM of the vault in ETH - */ - function aumBreakdown() public view returns (uint256[10] memory, uint256) { - uint256[10] memory aumPerToken; - uint256 totalAum; - for (uint8 i; i < 10; i++) { - address tokenAddress = constituentTokens[i].tokenAddress; - uint256 tokenPrice = getLatestPriceInEthOf(tokenAddress); - uint256 tokenBalance = IERC20(tokenAddress).balanceOf(address(this)); - - aumPerToken[i] = (tokenBalance * tokenPrice) / (10 ** ERC20(tokenAddress).decimals()); - totalAum += aumPerToken[i]; - } - - return (aumPerToken, totalAum); - } - - /** - * @notice Absolute Value - * @param x The number to get the absolute value of - * @return The absolute value of x - */ - function abs(int x) private pure returns (uint) { - return x >= 0 ? uint(x) : uint(-x); - } - - /** - * @notice Get the address of a pool with a given fee - * @param tokenIn The address of the token to swap from - * @param tokenOut The address of the token to swap to - * @param fee The fee of the pool - * @return The address of the pool + /** + * @notice Approve spending of TTC constituent tokens to the logic contract + * @param tokens Array of Token structs */ - function getPoolWithFee(address tokenIn, address tokenOut, uint24 fee) public view returns (address) { - bytes memory payload = abi.encodeWithSignature("getPool(address,address,uint24)", tokenIn, tokenOut, fee); - (bool success, bytes memory data) = i_uniswapFactory.staticcall(payload); - if (!success) { - revert PoolDoesNotExist(); + function approveSpendToLogicContract(Token[10] calldata tokens) public onlyModerator { + for (uint256 i = 0; i < tokens.length; i++) { + IERC20(tokens[i].tokenAddress).approve(ttcLogicContract, type(uint256).max); + emit SpendApproved(tokens[i].tokenAddress, ttcLogicContract, type(uint256).max); } - return abi.decode(data, (address)); + emit Approval(address(this), ttcLogicContract, type(uint256).max); } -} +} \ No newline at end of file diff --git a/src/interfaces/ITtcLogic.sol b/src/interfaces/ITtcLogic.sol new file mode 100644 index 0000000..c8b8f3a --- /dev/null +++ b/src/interfaces/ITtcLogic.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.20; + +import {Route, Token} from "../types/types.sol"; + +interface ITtcLogic { + // Errors + error InvalidTokenList(); + error MinimumAmountToMint(); + error EmptyVault(); + error InvalidRedemptionAmount(); + error RedemptionTransferFailed(); + error TreasuryTransferFailed(); + error NoReentrancy(); + error OnlyTreasury(); + error RocketSwapMaxSlippageExceeded(); + error InvalidWeights(); + error PoolDoesNotExist(); + error NegativePrice(); + error NegativeTick(); + error InvalidRoute(); + + // Events + /// @notice event for minting + event Minted(address indexed sender, uint256 ethAmount, uint256 ttcAmount); + + /// @notice event for redeeming + event Redeemed(address indexed sender, uint256 ttcAmount); + + /// @notice event for rebalancing + event Rebalanced(Token[10] _newTokens); + + // Methods + /// @notice mint tokens for msg.value to msg.sender + function mint() external payable; + + /// @notice Return constituents to msg.sender and burn + function redeem(uint256 _ttcAmount) external; + + /// @notice Rebalance the vault + function rebalance(Token[10] calldata _newTokens, Route[10][] calldata routes) external payable; +} diff --git a/src/interfaces/ITtcVault.sol b/src/interfaces/ITtcVault.sol index 07c8f74..ce66c71 100644 --- a/src/interfaces/ITtcVault.sol +++ b/src/interfaces/ITtcVault.sol @@ -1,43 +1,14 @@ -// SPDX-License-Identifier: GPL-3.0 - +// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.20; -import {Route, Token} from "../types/types.sol"; - +import {Token} from "../types/types.sol"; interface ITtcVault { - // Errors - error InvalidTokenList(); - error MinimumAmountToMint(); - error EmptyVault(); - error InvalidRedemptionAmount(); - error RedemptionTransferFailed(); - error TreasuryTransferFailed(); - error NoReentrancy(); - error OnlyTreasury(); - error RocketSwapMaxSlippageExceeded(); - error InvalidWeights(); - error PoolDoesNotExist(); - error NegativePrice(); - error NegativeTick(); - error InvalidRoute(); - - // Events - /// @notice event for minting - event Minted(address indexed sender, uint256 ethAmount, uint256 ttcAmount); - - /// @notice event for redeeming - event Redeemed(address indexed sender, uint256 ttcAmount); - - /// @notice event for rebalancing - event Rebalanced(Token[10] _newTokens); - - // Methods - /// @notice mint tokens for msg.value to msg.sender - function mint() external payable; - - /// @notice Return constituents to msg.sender and burn - function redeem(uint256 _ttcAmount) external; - - /// @notice Rebalance the vault - function rebalance(Token[10] calldata _newTokens, Route[10][] calldata routes) external payable; + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event TtcLogicContractSet(address indexed ttcLogicContract); + event SpendApproved(address indexed token, address indexed spender, uint256 amount); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function transferOwnership(address _newOwner) external; + function setTtcLogicContract(address _ttcLogicContract) external; + function approveSpendToLogicContract(Token[10] calldata tokens) external; } diff --git a/test/TtcTestContext.sol b/test/TtcTestContext.sol index 2e486d7..e476d35 100644 --- a/test/TtcTestContext.sol +++ b/test/TtcTestContext.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; -import "../src/TtcVault.sol"; +import "../src/TtcLogic.sol"; import "../src/types/types.sol"; contract TtcTestContext is Test { @@ -39,7 +39,7 @@ contract TtcTestContext is Test { address constant RENDER_ADDRESS = address(0x6De037ef9aD2725EB40118Bb1702EBb27e4Aeb24); address constant AAVE_ADDRESS = address(0x7Fc66500c84A76Ad7e9c93437bFc5Ac33E2DDaE9); - TtcVault public vault; + TtcLogic public logic; Token[10] public tokens; function calculateOptimalREthRoute(uint256 _amountIn) public returns (uint[2] memory portions, uint amountOut) { @@ -59,23 +59,23 @@ contract TtcTestContext is Test { } - function getVaultBalances() public view returns (TokenBalance[10] memory) { + function getlogicBalances() public view returns (TokenBalance[10] memory) { TokenBalance[10] memory balances; for (uint8 i; i < 10; i++) { - (, address tokenAddress) = vault.constituentTokens(i); + (, address tokenAddress) = logic.constituentTokens(i); uint256 balance = IERC20(tokenAddress).balanceOf( - address(vault) + address(logic) ); balances[i] = TokenBalance(tokenAddress, balance); } return balances; } - function printVaultBalances() public view { - console.log("Vault Balances:"); + function printlogicBalances() public view { + console.log("logic Balances:"); for (uint8 i; i < 10; i++) { uint256 balance = IERC20(tokens[i].tokenAddress).balanceOf( - address(vault) + address(logic) ); console.log(tokens[i].tokenAddress, "-", balance); } diff --git a/test/TtcVaultTest.t.sol b/test/TtcVaultTest.t.sol index dab40e8..70cecac 100644 --- a/test/TtcVaultTest.t.sol +++ b/test/TtcVaultTest.t.sol @@ -3,10 +3,10 @@ pragma solidity 0.8.20; import "forge-std/Test.sol"; -import "../src/TtcVault.sol"; +import "../src/TtcLogic.sol"; import "./TtcTestContext.sol"; -contract VaultTest is TtcTestContext { +contract logicTest is TtcTestContext { function setUp() public { try vm.createFork(vm.envString("ALCHEMY_MAINNET_RPC_URL")) returns ( uint256 forkId @@ -18,7 +18,7 @@ contract VaultTest is TtcTestContext { vm.selectFork(mainnetFork); address treasury = makeAddr("treasury"); setUpTokens(); - vault = new TtcVault( + logic = new TtcLogic( treasury, UNISWAP_SWAP_ROUTER_ADDRESS, WETH_ADDRESS, @@ -38,34 +38,34 @@ contract VaultTest is TtcTestContext { address user = makeAddr("user"); vm.deal(user, weiAmount); - TokenBalance[10] memory balances = getVaultBalances(); + TokenBalance[10] memory balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertEq( balances[i].balance, 0, - "Initial vault balances should be 0" + "Initial logic balances should be 0" ); } vm.startPrank(user); - vault.mint{value: weiAmount}(); + logic.mint{value: weiAmount}(); vm.stopPrank(); assertEq( - IERC20(vault.getTtcTokenAddress()).balanceOf(user), + IERC20(logic.getTtcTokenAddress()).balanceOf(user), 1 * (10 ** 18), "User should have received 1 TTC token" ); - balances = getVaultBalances(); + balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertGt( balances[i].balance, 0, - "Post-mint vault balances should be greater than 0" + "Post-mint logic balances should be greater than 0" ); } } @@ -75,22 +75,22 @@ contract VaultTest is TtcTestContext { testInitialMint(); vm.startPrank(user); - uint256 ttcBalance = (vault.i_ttcToken()).balanceOf(user); - vault.redeem(ttcBalance); + uint256 ttcBalance = (logic.i_ttcToken()).balanceOf(user); + logic.redeem(ttcBalance); vm.stopPrank(); assertEq( - IERC20(vault.getTtcTokenAddress()).totalSupply(), + IERC20(logic.getTtcTokenAddress()).totalSupply(), 0, "Total supply should be 0" ); - TokenBalance[10] memory balances = getVaultBalances(); + TokenBalance[10] memory balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertEq( balances[i].balance, 0, - "Vault should be empty after redeem" + "logic should be empty after redeem" ); } } @@ -100,50 +100,50 @@ contract VaultTest is TtcTestContext { address user = makeAddr("user"); vm.deal(user, weiAmount); - TokenBalance[10] memory balances = getVaultBalances(); + TokenBalance[10] memory balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertEq( balances[i].balance, 0, - "Initial vault balances should be 0" + "Initial logic balances should be 0" ); } vm.startPrank(user); - vault.mint{value: weiAmount}(); + logic.mint{value: weiAmount}(); assertEq( - IERC20(vault.getTtcTokenAddress()).balanceOf(user), + IERC20(logic.getTtcTokenAddress()).balanceOf(user), 1 * (10 ** 18), "User should have received 1 TTC token" ); - balances = getVaultBalances(); + balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertGt( balances[i].balance, 0, - "Post-mint vault balances should be greater than 0" + "Post-mint logic balances should be greater than 0" ); } weiAmount = 5 ether; vm.deal(user, weiAmount); - vault.mint{value: weiAmount}(); + logic.mint{value: weiAmount}(); vm.stopPrank(); assertGt( - IERC20(vault.getTtcTokenAddress()).balanceOf(user), + IERC20(logic.getTtcTokenAddress()).balanceOf(user), 1 * (10 ** 18), "User should have received some TTC token from second mint" ); - TokenBalance[10] memory newBalances = getVaultBalances(); + TokenBalance[10] memory newBalances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertGt( newBalances[i].balance, balances[i].balance, - "Post-second mint vault balances should be greater than post-first mint balances" + "Post-second mint logic balances should be greater than post-first mint balances" ); } @@ -151,53 +151,53 @@ contract VaultTest is TtcTestContext { function testGetLatestPriceInEthOf() view public { // rETH - (, address tokenAddress) = vault.constituentTokens(0); - uint256 price = vault.getLatestPriceInEthOf(tokenAddress); + (, address tokenAddress) = logic.constituentTokens(0); + uint256 price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of rETH should be greater than 0"); // SHIB - (, tokenAddress) = vault.constituentTokens(1); - price = vault.getLatestPriceInEthOf(tokenAddress); + (, tokenAddress) = logic.constituentTokens(1); + price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of SHIB should be greater than 0"); // OKB - (, tokenAddress) = vault.constituentTokens(2); - price = vault.getLatestPriceInEthOf(tokenAddress); + (, tokenAddress) = logic.constituentTokens(2); + price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of OKB should be greater than 0"); // LINK - (, tokenAddress) = vault.constituentTokens(3); - price = vault.getLatestPriceInEthOf(tokenAddress); + (, tokenAddress) = logic.constituentTokens(3); + price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of LINK should be greater than 0"); // wBTC - (, tokenAddress) = vault.constituentTokens(4); - price = vault.getLatestPriceInEthOf(tokenAddress); + (, tokenAddress) = logic.constituentTokens(4); + price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of wBTC should be greater than 0"); // UNI - (, tokenAddress) = vault.constituentTokens(5); - price = vault.getLatestPriceInEthOf(tokenAddress); + (, tokenAddress) = logic.constituentTokens(5); + price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of UNI should be greater than 0"); // MATIC - (, tokenAddress) = vault.constituentTokens(6); - price = vault.getLatestPriceInEthOf(tokenAddress); + (, tokenAddress) = logic.constituentTokens(6); + price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of MATIC should be greater than 0"); // ARB - (, tokenAddress) = vault.constituentTokens(7); - price = vault.getLatestPriceInEthOf(tokenAddress); + (, tokenAddress) = logic.constituentTokens(7); + price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of ARB should be greater than 0"); // MANTLE - (, tokenAddress) = vault.constituentTokens(8); - price = vault.getLatestPriceInEthOf(tokenAddress); + (, tokenAddress) = logic.constituentTokens(8); + price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of MANTLE should be greater than 0"); // MKR - (, tokenAddress) = vault.constituentTokens(9); - price = vault.getLatestPriceInEthOf(tokenAddress); + (, tokenAddress) = logic.constituentTokens(9); + price = logic.getLatestPriceInEthOf(tokenAddress); assertGt(price, 0, "Price of MKR should be greater than 0"); } @@ -231,15 +231,15 @@ contract VaultTest is TtcTestContext { // basic rebalance between two tokens // SUT: rETH, SHIB vm.startPrank(treasury); - vault.rebalance{value: weiAmount}(testTokens, routes); + logic.rebalance{value: weiAmount}(testTokens, routes); vm.stopPrank(); - TokenBalance[10] memory balances = getVaultBalances(); + TokenBalance[10] memory balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertGt( balances[i].balance, 0, - "Post-rebalance vault balances should be greater than 0" + "Post-rebalance logic balances should be greater than 0" ); } } @@ -286,15 +286,15 @@ contract VaultTest is TtcTestContext { vm.deal(treasury, weiAmount); vm.startPrank(treasury); - vault.rebalance{value: weiAmount}(testTokens, routes); + logic.rebalance{value: weiAmount}(testTokens, routes); vm.stopPrank(); - TokenBalance[10] memory balances = getVaultBalances(); + TokenBalance[10] memory balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertGt( balances[i].balance, 0, - "Post-rebalance vault balances should be greater than 0" + "Post-rebalance logic balances should be greater than 0" ); } } @@ -327,29 +327,29 @@ contract VaultTest is TtcTestContext { vm.deal(treasury, weiAmount); vm.startPrank(treasury); - vault.rebalance{value: weiAmount}(testTokens, routes); + logic.rebalance{value: weiAmount}(testTokens, routes); vm.stopPrank(); - TokenBalance[10] memory balances = getVaultBalances(); + TokenBalance[10] memory balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertGt( balances[i].balance, 0, - "Post-rebalance vault balances should be greater than 0" + "Post-rebalance logic balances should be greater than 0" ); } - // assert that RENDER was added to the vault + // assert that RENDER was added to the logic Token[10] memory newTokens; for (uint8 i = 0; i < 10; i++) { - (uint8 tokenIndex, address tokenAddress) = vault.constituentTokens(i); + (uint8 tokenIndex, address tokenAddress) = logic.constituentTokens(i); newTokens[i] = Token(tokenIndex, tokenAddress); } assertEq( newTokens[9].tokenAddress, RENDER_ADDRESS, - "RENDER should be in the vault" + "RENDER should be in the logic" ); } @@ -395,34 +395,34 @@ contract VaultTest is TtcTestContext { vm.deal(treasury, weiAmount); vm.startPrank(treasury); - vault.rebalance{value: weiAmount}(testTokens, routes); + logic.rebalance{value: weiAmount}(testTokens, routes); vm.stopPrank(); - TokenBalance[10] memory balances = getVaultBalances(); + TokenBalance[10] memory balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertGt( balances[i].balance, 0, - "Post-rebalance vault balances should be greater than 0" + "Post-rebalance logic balances should be greater than 0" ); } - // assert that the new tokens are in the vault + // assert that the new tokens are in the logic Token[10] memory newTokens; for (uint8 i = 0; i < 10; i++) { - (uint8 tokenIndex, address tokenAddress) = vault.constituentTokens(i); + (uint8 tokenIndex, address tokenAddress) = logic.constituentTokens(i); newTokens[i] = Token(tokenIndex, tokenAddress); } assertEq( newTokens[8].tokenAddress, AAVE_ADDRESS, - "AAVE should be in the vault" + "AAVE should be in the logic" ); assertEq( newTokens[9].tokenAddress, RENDER_ADDRESS, - "RENDER should be in the vault" + "RENDER should be in the logic" ); } @@ -479,26 +479,26 @@ contract VaultTest is TtcTestContext { vm.deal(treasury, weiAmount); vm.startPrank(treasury); - vault.rebalance{value: weiAmount}(testTokens, routes); + logic.rebalance{value: weiAmount}(testTokens, routes); vm.stopPrank(); - TokenBalance[10] memory balances = getVaultBalances(); + TokenBalance[10] memory balances = getlogicBalances(); for (uint8 i; i < 10; i++) { assertGt( balances[i].balance, 0, - "Post-rebalance vault balances should be greater than 0" + "Post-rebalance logic balances should be greater than 0" ); } } - // Returns the amount of tokens that is x% of the balance of the vault + // Returns the amount of tokens that is x% of the balance of the logic function xPercentFromBalance(uint8 percent, address tokenAddress) private view returns (uint256) { - return (percent * IERC20(tokenAddress).balanceOf(address(vault))) / 100; + return (percent * IERC20(tokenAddress).balanceOf(address(logic))) / 100; } // Returns the amount of eth that is equivalent to the amount of tokens @@ -508,7 +508,7 @@ contract VaultTest is TtcTestContext { returns (uint256) { uint256 tokenDecimals = ERC20(tokenAddress).decimals(); - return (amount * vault.getLatestPriceInEthOf(tokenAddress)) / (10**tokenDecimals); + return (amount * logic.getLatestPriceInEthOf(tokenAddress)) / (10**tokenDecimals); } // Returns the amount with 3% slippage applied