// SPDX-License-Identifier: GPL-3.0-or-later pragma solidity >=0.8.22; import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { Comptrollerable } from "@sablier/evm-utils/src/Comptrollerable.sol"; import { ISablierComptroller } from "@sablier/evm-utils/src/interfaces/ISablierComptroller.sol"; import { SafeOracle } from "@sablier/evm-utils/src/libraries/SafeOracle.sol"; import { SafeTokenSymbol } from "@sablier/evm-utils/src/libraries/SafeTokenSymbol.sol"; import { SablierBobState } from "./abstracts/SablierBobState.sol"; import { BobVaultShare } from "./BobVaultShare.sol"; import { IWETH9 } from "./interfaces/external/IWETH9.sol"; import { IBobVaultShare } from "./interfaces/IBobVaultShare.sol"; import { ISablierBob } from "./interfaces/ISablierBob.sol"; import { ISablierBobAdapter } from "./interfaces/ISablierBobAdapter.sol"; import { Errors } from "./libraries/Errors.sol"; import { Bob } from "./types/Bob.sol"; /* ███████╗ █████╗ ██████╗ ██╗ ██╗███████╗██████╗ ██████╗ ██████╗ ██████╗ ██╔════╝██╔══██╗██╔══██╗██║ ██║██╔════╝██╔══██╗ ██╔══██╗██╔═══██╗██╔══██╗ ███████╗███████║██████╔╝██║ ██║█████╗ ██████╔╝ ██████╔╝██║ ██║██████╔╝ ╚════██║██╔══██║██╔══██╗██║ ██║██╔══╝ ██╔══██╗ ██╔══██╗██║ ██║██╔══██╗ ███████║██║ ██║██████╔╝███████╗██║███████╗██║ ██║ ██████╔╝╚██████╔╝██████╔╝ ╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ */ /// @title SablierBob /// @notice See the documentation in {ISablierBob}. contract SablierBob is Comptrollerable, // 1 inherited component ISablierBob, // 1 inherited component ReentrancyGuard, // 1 inherited component SablierBobState // 1 inherited component { using SafeCast for uint256; using SafeERC20 for IERC20; using SafeTokenSymbol for address; using Strings for uint256; /*////////////////////////////////////////////////////////////////////////// MODIFIERS //////////////////////////////////////////////////////////////////////////*/ /// @dev Modifier to check that the vault is active. modifier onlyActive(uint256 vaultId) { _revertIfSettledOrExpired(vaultId); _; } /*////////////////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////////////////*/ /// @param initialComptroller The address of the initial comptroller contract. constructor(address initialComptroller) Comptrollerable(initialComptroller) SablierBobState() { } /*////////////////////////////////////////////////////////////////////////// USER-FACING READ-ONLY FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc ISablierBob function calculateMinFeeWei(uint256 vaultId) external view override notNull(vaultId) returns (uint256 minFeeWei) { // Return 0 if the vault has an adapter, since the fee is taken from yield generated by the adapter. if (address(_vaults[vaultId].adapter) != address(0)) { return 0; } // Calculate the minimum fee in wei for the Bob protocol. minFeeWei = comptroller.calculateMinFeeWei({ protocol: ISablierComptroller.Protocol.Bob }); } /*////////////////////////////////////////////////////////////////////////// USER-FACING STATE-CHANGING FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ /// @inheritdoc ISablierBob function createVault( IERC20 token, AggregatorV3Interface oracle, uint40 expiry, uint128 targetPrice ) external override returns (uint256 vaultId) { // Check: token is not the zero address. if (address(token) == address(0)) { revert Errors.SablierBob_TokenAddressZero(); } // Check: token is not the native token. if (address(token) == nativeToken) { revert Errors.SablierBob_ForbidNativeToken(address(token)); } uint40 currentTimestamp = uint40(block.timestamp); // Check: expiry is in the future. if (expiry <= currentTimestamp) { revert Errors.SablierBob_ExpiryNotInFuture(expiry, currentTimestamp); } // Check: target price is not zero. if (targetPrice == 0) { revert Errors.SablierBob_TargetPriceZero(); } // Check: oracle implements the Chainlink {AggregatorV3Interface} interface. uint128 latestPrice = SafeOracle.validateOracle(oracle); // Check: target price is greater than latest oracle price. if (targetPrice <= latestPrice) { revert Errors.SablierBob_TargetPriceTooLow(targetPrice, latestPrice); } // Load the vault ID from storage. vaultId = nextVaultId; // Effect: bump the next vault ID. unchecked { nextVaultId = vaultId + 1; } // Retrieve token symbol and token decimal. string memory tokenSymbol = address(token).safeTokenSymbol(); uint8 tokenDecimals = IERC20Metadata(address(token)).decimals(); // Effect: deploy the share token for this vault. IBobVaultShare shareToken = new BobVaultShare({ name_: string.concat("Sablier Bob ", tokenSymbol, " Vault #", vaultId.toString()), symbol_: string.concat( tokenSymbol, "-", uint256(targetPrice).toString(), "-", uint256(expiry).toString(), "-", vaultId.toString() ), decimals_: tokenDecimals, sablierBob: address(this), vaultId: vaultId }); // Copy the adapter from storage to memory. ISablierBobAdapter adapter = _defaultAdapters[token]; // Effect: create the vault. _vaults[vaultId] = Bob.Vault({ token: token, expiry: expiry, lastSyncedAt: currentTimestamp, shareToken: shareToken, oracle: oracle, adapter: adapter, isStakedInAdapter: false, targetPrice: targetPrice, lastSyncedPrice: latestPrice }); // Interaction: register the vault with the adapter. if (address(adapter) != address(0)) { adapter.registerVault(vaultId); // Effect: mark the vault as staked in the adapter. _vaults[vaultId].isStakedInAdapter = true; } // Log the event. emit CreateVault(vaultId, token, oracle, adapter, shareToken, targetPrice, expiry); } /// @inheritdoc ISablierBob function enter(uint256 vaultId, uint128 amount) external override nonReentrant notNull(vaultId) onlyActive(vaultId) { // Enter the vault. _enter({ vaultId: vaultId, from: msg.sender, amount: amount, token: _vaults[vaultId].token }); } /// @inheritdoc ISablierBob function enterWithNativeToken(uint256 vaultId) external payable override nonReentrant notNull(vaultId) onlyActive(vaultId) { // Cache the vault's token. address token = address(_vaults[vaultId].token); // Interaction: call the deposit function in the vault tokens assuming it follows the IWETH9 interface. // Otherwise, it will revert. IWETH9(token).deposit{ value: msg.value }(); // Cast `msg.value` to `uint128`. uint128 amount = msg.value.toUint128(); // Enter the vault. _enter({ vaultId: vaultId, from: address(this), amount: amount, token: IERC20(token) }); } /// @inheritdoc ISablierBob function redeem(uint256 vaultId) external payable override nonReentrant notNull(vaultId) returns (uint128 transferAmount, uint128 feeAmountDeductedFromYield) { // If the vault is active, sync the price from the oracle to update the status. if (_statusOf(vaultId) == Bob.Status.ACTIVE) { // Effect: sync the price from oracle. _syncPriceFromOracle(vaultId); // If it's still active after the sync, revert. if (_statusOf(vaultId) == Bob.Status.ACTIVE) { revert Errors.SablierBob_VaultStillActive(vaultId); } // Otherwise, the vault has been settled. } // Cache storage variables. ISablierBobAdapter adapter = _vaults[vaultId].adapter; IBobVaultShare shareToken = _vaults[vaultId].shareToken; IERC20 token = _vaults[vaultId].token; // Get the caller's share balance. uint128 shareBalance = shareToken.balanceOf(msg.sender).toUint128(); // Check: the share balance is not zero. if (shareBalance == 0) { revert Errors.SablierBob_NoSharesToRedeem(vaultId, msg.sender); } // Check if the vault has an adapter. if (address(adapter) != address(0)) { // Check: the `msg.value` is zero. if (msg.value > 0) { revert Errors.SablierBob_MsgValueNotZero(vaultId); } // Check: the deposit token is staked with the adapter. if (_vaults[vaultId].isStakedInAdapter) { // Effect: set isStakedInAdapter to false. _vaults[vaultId].isStakedInAdapter = false; // Interaction: unstake all tokens via the adapter. _unstakeFullAmountViaAdapter(vaultId, adapter); } // Interaction: Get the transfer amount and the fee deducted from yield. (transferAmount, feeAmountDeductedFromYield) = adapter.processRedemption(vaultId, msg.sender, shareBalance); // Interaction: transfer the fee to the comptroller address. if (feeAmountDeductedFromYield > 0) { token.safeTransfer(address(comptroller), feeAmountDeductedFromYield); } } // Otherwise, check that `msg.value` is greater than or equal to the minimum fee required. else { // Get the minimum fee from the comptroller. ISablierComptroller _comptroller = comptroller; uint256 minFeeWei = _comptroller.calculateMinFeeWei({ protocol: ISablierComptroller.Protocol.Bob }); // Check: `msg.value` is greater than or equal to the minimum fee. if (msg.value < minFeeWei) { revert Errors.SablierBob_InsufficientFeePayment(msg.value, minFeeWei); } // Interaction: forward native token fee to comptroller. if (msg.value > 0) { (bool success,) = address(_comptroller).call{ value: msg.value }(""); if (!success) { revert Errors.SablierBob_NativeFeeTransferFailed(); } } // Return the transfer amount. transferAmount = shareBalance; } // Interaction: burn share tokens from the caller. shareToken.burn(vaultId, msg.sender, shareBalance); // Interaction: transfer tokens to the caller. token.safeTransfer(msg.sender, transferAmount); // Log the event. emit Redeem({ vaultId: vaultId, user: msg.sender, amountReceived: transferAmount, sharesBurned: shareBalance, fee: feeAmountDeductedFromYield }); } /// @inheritdoc ISablierBob function setNativeToken(address newNativeToken) external override onlyComptroller { // Check: provided token is not zero address. if (newNativeToken == address(0)) { revert Errors.SablierBob_NativeTokenZeroAddress(); } // Check: native token is not set. if (nativeToken != address(0)) { revert Errors.SablierBob_NativeTokenAlreadySet(nativeToken); } // Effect: set the native token. nativeToken = newNativeToken; // Log the update. emit SetNativeToken({ comptroller: msg.sender, nativeToken: newNativeToken }); } /// @inheritdoc ISablierBob function setDefaultAdapter(IERC20 token, ISablierBobAdapter newAdapter) external override onlyComptroller { // Check: the new adapter implements the {ISablierBobAdapter} interface. if (address(newAdapter) != address(0)) { bytes4 interfaceId = type(ISablierBobAdapter).interfaceId; if (!IERC165(address(newAdapter)).supportsInterface(interfaceId)) { revert Errors.SablierBob_NewAdapterMissesInterface(address(newAdapter)); } } // Effect: set the default adapter for the token. _defaultAdapters[token] = newAdapter; // Log the adapter change. emit SetDefaultAdapter(token, newAdapter); } /// @inheritdoc ISablierBob function syncPriceFromOracle(uint256 vaultId) external override nonReentrant notNull(vaultId) onlyActive(vaultId) returns (uint128 latestPrice) { // Effect: sync the price from oracle. latestPrice = _syncPriceFromOracle(vaultId); } /// @inheritdoc ISablierBob function unstakeTokensViaAdapter(uint256 vaultId) external override nonReentrant notNull(vaultId) returns (uint128 amountReceivedFromAdapter) { // Cache storage variable. ISablierBobAdapter adapter = _vaults[vaultId].adapter; // Check: the vault has an adapter. if (address(adapter) == address(0)) { revert Errors.SablierBob_VaultHasNoAdapter(vaultId); } // Check: the vault has not already been unstaked. if (!_vaults[vaultId].isStakedInAdapter) { revert Errors.SablierBob_VaultAlreadyUnstaked(vaultId); } // Check: there is something to unstake. if (adapter.getTotalYieldBearingTokenBalance(vaultId) == 0) { revert Errors.SablierBob_UnstakeAmountZero(vaultId); } // If the vault is active, sync the price from the oracle to update the status. if (_statusOf(vaultId) == Bob.Status.ACTIVE) { // Effect: sync the price from oracle. _syncPriceFromOracle(vaultId); // If it's still active after the sync, revert. if (_statusOf(vaultId) == Bob.Status.ACTIVE) { revert Errors.SablierBob_VaultStillActive(vaultId); } // Otherwise, the vault has been settled. } // Effect: mark the vault as not staked with the adapter. _vaults[vaultId].isStakedInAdapter = false; // Interaction: unstake all tokens via the adapter. amountReceivedFromAdapter = _unstakeFullAmountViaAdapter(vaultId, adapter); } /// @inheritdoc ISablierBob function onShareTransfer( uint256 vaultId, address from, address to, uint256 amount, uint256 fromBalanceBefore ) external override { // Check: caller is the share token for this vault. if (msg.sender != address(_vaults[vaultId].shareToken)) { revert Errors.SablierBob_CallerNotShareToken(vaultId, msg.sender); } // Cache storage variable. ISablierBobAdapter adapter = _vaults[vaultId].adapter; if (address(adapter) != address(0)) { // Interaction: update staked token holding of the user in the adapter. adapter.updateStakedTokenBalance(vaultId, from, to, amount, fromBalanceBefore); } } /*////////////////////////////////////////////////////////////////////////// PRIVATE STATE-CHANGING FUNCTIONS //////////////////////////////////////////////////////////////////////////*/ /// @dev Common function to enter into a vault by depositing tokens into it and minting share tokens to caller. /// @param vaultId The ID of the vault to deposit into. /// @param from The address holding the vault token when calling this function. In case of native token deposits, /// the vault tokens are held by this contract. /// @param amount The amount of tokens to deposit. /// @param token The ERC-20 token accepted by the vault. function _enter(uint256 vaultId, address from, uint128 amount, IERC20 token) private { // Check: the deposit amount is not zero. if (amount == 0) { revert Errors.SablierBob_DepositAmountZero(vaultId, msg.sender); } // Effect: sync the price from oracle. _syncPriceFromOracle(vaultId); // Check: the vault is still active after the price sync. _revertIfSettledOrExpired(vaultId); // Cache storage variables. ISablierBobAdapter adapter = _vaults[vaultId].adapter; // If adapter is set, transfer tokens to the adapter. if (address(adapter) != address(0)) { // Interaction: transfer tokens to the adapter. Use `safeTransfer` for the native token path since // the contract already holds the wrapped tokens. if (from == address(this)) { token.safeTransfer(address(adapter), amount); } else { token.safeTransferFrom(from, address(adapter), amount); } // Interaction: stake tokens via the adapter on behalf of the caller. adapter.stake(vaultId, msg.sender, amount); } // Otherwise, if `from` is `msg.sender`, transfer tokens to this contract. When this function is called by // `enterWithNativeToken`, the vault tokens are held by this contract already. else if (from == msg.sender) { // Interaction: transfer tokens from caller to this contract. token.safeTransferFrom(from, address(this), amount); } // Interaction: mint share tokens to the caller. _vaults[vaultId].shareToken.mint(vaultId, msg.sender, amount); // Log the deposit. emit Enter({ vaultId: vaultId, user: msg.sender, amountReceived: amount, sharesMinted: amount }); } /// @notice Private function that reverts if the vault is settled or expired. /// @param vaultId The ID of the vault. function _revertIfSettledOrExpired(uint256 vaultId) private view { if (_statusOf(vaultId) != Bob.Status.ACTIVE) { revert Errors.SablierBob_VaultNotActive(vaultId); } } /// @dev Private function to fetch the latest oracle price and update it in the vault storage. /// @param vaultId The ID of the vault. /// @return latestPrice The latest price from the oracle. function _syncPriceFromOracle(uint256 vaultId) private returns (uint128 latestPrice) { AggregatorV3Interface oracleAddress = _vaults[vaultId].oracle; // Get the latest price, normalized to 8 decimals, from the oracle with safety checks. (latestPrice,,) = SafeOracle.safeOraclePrice({ oracle: oracleAddress, normalize: true }); // Return if the latest price is zero. if (latestPrice == 0) { return 0; } uint40 currentTimestamp = uint40(block.timestamp); // Effect: update the last synced price and timestamp. _vaults[vaultId].lastSyncedPrice = latestPrice; _vaults[vaultId].lastSyncedAt = currentTimestamp; // Log the event. emit SyncPriceFromOracle({ vaultId: vaultId, oracle: oracleAddress, latestPrice: latestPrice, syncedAt: currentTimestamp }); } /// @dev Private function to unstake all tokens using the adapter. /// @param vaultId The ID of the vault. /// @param adapter The adapter to use for unstaking. /// @return amountReceivedFromAdapter The amount of tokens received from the adapter after unstaking. function _unstakeFullAmountViaAdapter( uint256 vaultId, ISablierBobAdapter adapter ) private returns (uint128 amountReceivedFromAdapter) { uint128 wrappedTokenUnstakedAmount; // Interaction: unstake all tokens via the adapter. (wrappedTokenUnstakedAmount, amountReceivedFromAdapter) = adapter.unstakeFullAmount(vaultId); // Log the event. emit UnstakeFromAdapter(vaultId, adapter, wrappedTokenUnstakedAmount, amountReceivedFromAdapter); } }