diff --git a/src/WM.sol b/src/WM.sol index 2303a1d..6ec5ed2 100644 --- a/src/WM.sol +++ b/src/WM.sol @@ -42,187 +42,91 @@ contract WM is IWM, ERC20Extended { /* ============ Variables ============ */ - /// @inheritdoc IWM - uint128 public latestIndex; - /// @inheritdoc IERC20 uint256 public totalSupply; /// @inheritdoc IWM - uint256 public totalEarnedM; - - /// @inheritdoc IStandardizedYield - address public immutable yieldToken; + address public immutable mToken; /// @inheritdoc IWM - address public immutable ttgRegistrar; - - // @notice The total principal balance of earning supply. - uint112 internal _principalOfTotalEarningSupply; + address public immutable yMToken; /// @notice M token decimals. uint8 private constant _DECIMALS = 6; - /// @notice Underlying yield token unit. - uint256 private constant _YIELD_TOKEN_UNIT = 10 ** _DECIMALS; + /// @notice Dead address to send YM tokens to if caller is not on the earners list. + address private constant _DEAD_ADDRESS = address(0x000000000000000000000000000000000000dEaD); /// @notice WM token balances. - mapping(address account => WMBalance balance) internal _balances; + mapping(address account => uint256 balance) internal _balances; /* ============ Constructor ============ */ /** * @notice Constructs the WM token contract. - * @param mToken_ Address of the underlying yield token. + * @param mToken_ Address of the underlying M token. + * @param yMToken_ Address of the Yield M token. * @param ttgRegistrar_ Address of the TTG Registrar contract. */ - constructor(address mToken_, address ttgRegistrar_) ERC20Extended("WM by M^0", "M", _DECIMALS) { - if ((yieldToken = mToken_) == address(0)) revert ZeroMToken(); + constructor(address mToken_, address yMToken_, address ttgRegistrar_) ERC20Extended("WM by M^0", "WM", _DECIMALS) { + if ((mToken = mToken_) == address(0)) revert ZeroMToken(); + if ((yMToken = yMToken_) == address(0)) revert ZeroYMToken(); if ((ttgRegistrar = ttgRegistrar_) == address(0)) revert ZeroTTGRegistrar(); - - latestIndex = IContinuousIndexing(mToken_).currentIndex(); } /* ============ Interactive Functions ============ */ - /// @inheritdoc IStandardizedYield - function deposit( - address receiver_, - address tokenIn_, - uint256 amountTokenToDeposit_, - uint256 minSharesOut_ - ) external payable returns (uint256 amountSharesOut_) { - // TODO: handle deposit from non earner - _isValidTokenIn(tokenIn_); - if (amountTokenToDeposit_ == 0) revert ZeroDeposit(); - - IERC20(tokenIn_).transferFrom(msg.sender, address(this), amountTokenToDeposit_); - - amountSharesOut_ = _previewDeposit(amountTokenToDeposit_); - if (amountSharesOut_ < minSharesOut_) revert InsufficientSharesOut(amountSharesOut_, minSharesOut_); - - _mint(receiver_, amountSharesOut_); - emit Deposit(msg.sender, receiver_, tokenIn_, amountTokenToDeposit_, amountSharesOut_); - } - - /// @inheritdoc IStandardizedYield - function redeem( - address receiver_, - uint256 amountSharesToRedeem_, - address tokenOut_, - uint256 minTokenOut_, - bool burnFromInternalBalance_ - ) external returns (uint256 amountTokenOut_) { - _isValidTokenOut(tokenOut_); - if (amountSharesToRedeem_ == 0) revert ZeroRedeem(); - - if (burnFromInternalBalance_) { - _burn(address(this), amountSharesToRedeem_); - } else { - _burn(msg.sender, amountSharesToRedeem_); - } - - amountTokenOut_ = _previewRedeem(amountSharesToRedeem_); - if (amountTokenOut_ < minTokenOut_) revert InsufficientTokenOut(amountTokenOut_, minTokenOut_); - - emit Redeem(msg.sender, receiver_, tokenOut_, amountSharesToRedeem_, amountTokenOut_); - } - /// @inheritdoc IWM - function distributeExcessEarnedM(uint256 minAmount_) external { - if (!TTGRegistrarReader.isApprovedLiquidator(ttgRegistrar, msg.sender)) - revert NotApprovedLiquidator(msg.sender); + function deposit(address receiver_, uint256 amount_) external payable returns (uint256) { + if (amount_ == 0) revert ZeroDeposit(); - IMToken mToken_ = IMToken(yieldToken); + IERC20(mToken).transferFrom(msg.sender, address(this), amount_); - uint256 excessEarnedM = mToken_.balanceOf(address(this)) - totalEarnedM; - minAmount_ = minAmount_ > excessEarnedM ? excessEarnedM : minAmount_; + // WM shares are minted 1:1 to the amount of M tokens deposited. + _mint(receiver_, amount_); - _mint(ITTGRegistrar(ttgRegistrar).vault(), minAmount_); + IYM(yMToken).mint(_isApprovedEarner(msg.sender) ? receiver_ : _DEAD_ADDRESS, amount_); - emit ExcessEarnedMDistributed(msg.sender, minAmount_); + emit Deposit(msg.sender, receiver_, amount_, amount_); + + return amount_; } /// @inheritdoc IWM - function startEarning() external { - if (!_isApprovedEarner(msg.sender)) revert NotApprovedEarner(msg.sender); + function redeem(address receiver_, uint256 shares_) external returns (uint256) { + if (shares_ == 0) revert ZeroRedeem(); - _startEarning(msg.sender); - } + IERC20(mToken).transfer(receiver_, shares_); - /// @inheritdoc IWM - function stopEarning() external { - _stopEarning(msg.sender); - } + // WM shares are burned 1:1 to the amount of M tokens withdrawn. + _burn(msg.sender, shares_); - /// @inheritdoc IWM - function stopEarning(address account_) external { - if (_isApprovedEarner(account_)) revert IsApprovedEarner(account_); + emit Redeem(msg.sender, receiver_, tokenOut_, amountSharesToRedeem_, amountTokenOut_); - _stopEarning(account_); + return shares_; } /* ============ View/Pure Functions ============ */ /// @inheritdoc IERC20 - function balanceOf(address account) external view returns (uint256) { - return _balances[account].balance; + function balanceOf(address account_) external view returns (uint256) { + return _balances[account_].balance; } /// @inheritdoc IWM - function isEarning(address account_) external view returns (bool) { - return _balances[account_].isEarning; - } - - /// @inheritdoc IStandardizedYield - function previewDeposit(address tokenIn_, uint256 amountTokenToDeposit_) external view returns (uint256) { - _isValidTokenIn(tokenIn_); - return _previewDeposit(amountTokenToDeposit_); - } - - /// @inheritdoc IStandardizedYield - function previewRedeem(address tokenOut, uint256 amountSharesToRedeem) external view returns (uint256) { - _isValidTokenOut(tokenOut); - return _previewRedeem(amountSharesToRedeem); - } - - /// @inheritdoc IStandardizedYield - function exchangeRate() public view returns (uint256) { - // exchangeRate = (yieldTokenUnit * wrapperBalanceOfYieldToken) / totalSupply - return - totalSupply == 0 - ? _YIELD_TOKEN_UNIT - : (_YIELD_TOKEN_UNIT * IERC20(yieldToken).balanceOf(address(this))) / totalSupply; - } - - /// @inheritdoc IStandardizedYield - function getTokensIn() public view override returns (address[] memory) { - address[] memory tokensIn_ = new address[](1); - tokensIn_[0] = yieldToken; - return tokensIn_; - } - - /// @inheritdoc IStandardizedYield - function getTokensOut() public view override returns (address[] memory) { - address[] memory tokensOut_ = new address[](1); - tokensOut_[0] = yieldToken; - return tokensOut_; - } - - /// @inheritdoc IStandardizedYield - function isValidTokenIn(address token_) public view override returns (bool) { - return token_ == yieldToken; - } - - /// @inheritdoc IStandardizedYield - function isValidTokenOut(address token_) public view override returns (bool) { - return token_ == yieldToken; + function totalEarnedM() external view returns (uint256) { + // Safe to use unchecked here since the balance of M tokens + // will always be greater than or equal to the amount of WM tokens minted. + unchecked { + return IERC20(mToken).balanceOf(address(this)) - totalSupply; + } } /* ============ Internal Interactive Functions ============ */ /** * @dev Burns `shares_` amount from `account_`. + * @dev WM shares are burned 1:1 to the amount of M tokens withdrawn. * @param account_ Address to burn from. * @param shares_ Amount of shares to burn. */ @@ -232,6 +136,7 @@ contract WM is IWM, ERC20Extended { /** * @dev Mints `shares_` amount to `receiver_`. + * @dev WM shares are minted 1:1 to the amount of M tokens deposited. * @param receiver_ Address to mint to. * @param shares_ Amount of shares to mint. */ @@ -246,147 +151,32 @@ contract WM is IWM, ERC20Extended { * @param shares_ The amount of shares to be transferred. */ function _transfer(address sender_, address receiver_, uint256 shares_) internal override { - uint128 currentYieldTokenIndex_ = IContinuousIndexing(yieldToken).currentIndex(); - uint256 totalEarnedMToBurn_; - - // TODO: implement unchecked maths and rounding - // TODO: safe cast shares_ to uint240 or uint112 if (sender_ != address(0)) { - WMBalance storage senderBalance_ = _balances[sender_]; - - // If sender is earning, capture the earned M tokens and update the index - if (senderBalance_.isEarning) { - uint240 senderEarnedM = _getPresentAmountRoundedDown( - uint112(senderBalance_.balance), - currentYieldTokenIndex_ - senderBalance_.latestIndex - ); - - senderBalance_.earned += _previewDeposit(senderEarnedM); - senderBalance_.latestIndex = currentYieldTokenIndex_; - - totalEarnedM += senderEarnedM; - - uint256 totalBalance_ = senderBalance_.balance + senderBalance_.earned; - _hasEnoughBalance(sender_, totalBalance_, shares_); - - if (shares_ > senderBalance_.earned) { - uint256 balanceDiff_ = shares_ - senderBalance_.earned; - senderBalance_.balance -= balanceDiff_; - - if (receiver_ == address(0)) { - totalEarnedMToBurn_ = senderBalance_.earned; - totalSupply -= balanceDiff_; - } - - delete senderBalance_.earned; - } else { - senderBalance_.earned -= shares_; - - if (receiver_ == address(0)) { - totalSupply -= shares_; - } - } - } else { - _hasEnoughBalance(sender_, senderBalance_.balance, shares_); - senderBalance_.balance -= shares_; + _hasEnoughBalance(sender_, balanceOf(sender_), shares_); + + // Safe to use unchecked here since we check above that the sender has enough balance. + unchecked { + _balances[sender_].balance -= shares_; } + } else { + totalSupply += shares_; } if (receiver_ != address(0)) { - WMBalance storage receiverBalance_ = _balances[receiver_]; - - // If receiver is earning, capture the earned M tokens and update the index - if (receiverBalance_.isEarning) { - uint240 receiverEarnedM = _getPresentAmountRoundedDown( - uint112(receiverBalance_.balance), - currentYieldTokenIndex_ - receiverBalance_.latestIndex - ); - - receiverBalance_.earned += _previewDeposit(receiverEarnedM); - receiverBalance_.latestIndex = currentYieldTokenIndex_; - - totalEarnedM += receiverEarnedM; + _balances[receiver_].balance += shares_; + } else { + // Safe to use unchecked here since we can burn at most + // the balance of the sender which can't exceed the total supply. + unchecked { + totalSupply -= shares_; } - - receiverBalance_.balance += shares_; - totalSupply += shares_; } emit Transfer(sender_, receiver_, shares_); - - latestIndex = currentYieldTokenIndex_; - } - - /** - * @dev Starts earning for account. - * @param account_ The account to start earning for. - */ - function _startEarning(address account_) internal { - WMBalance storage accountBalance_ = _balances[account_]; - - // Account is already earning. - if (accountBalance_.isEarning) return; - - emit StartedEarning(account_); - - accountBalance_.isEarning = true; - accountBalance_.latestIndex = IContinuousIndexing(yieldToken).currentIndex(); - } - - /** - * @dev Stops earning for account. - * @param account_ The account to stop earning for. - */ - function _stopEarning(address account_) internal { - WMBalance storage accountBalance_ = _balances[account_]; - - // Account is currently not earning. - if (!accountBalance_.isEarning) return; - - emit StoppedEarning(account_); - - delete accountBalance_.isEarning; - delete accountBalance_.latestIndex; } /* ============ Internal View/Pure Functions ============ */ - /** - * @dev Returns the present amount (rounded down) given the principal amount and an index. - * @param principalAmount_ The principal amount. - * @param index_ An index. - * @return The present amount rounded down. - */ - function _getPresentAmountRoundedDown(uint112 principalAmount_, uint128 index_) internal pure returns (uint240) { - return ContinuousIndexingMath.multiplyDown(principalAmount_, index_); - } - - /** - * @notice Returns the amount of shares that would be minted for a given amount of token to deposit. - * @param amountTokenToDeposit_ Amount of token to deposit. - * @return Amount of shares that would be minted. - */ - function _previewDeposit(uint256 amountTokenToDeposit_) internal view returns (uint256) { - // shares = (amountTokenToDeposit_ * totalSupply) / wrapperBalanceOfYieldToken - return - amountTokenToDeposit_ == 0 - ? amountTokenToDeposit_ - : (amountTokenToDeposit_ * _YIELD_TOKEN_UNIT) / exchangeRate(); - } - - /** - * @notice Returns the amount of token that would be redeemed for a given amount of shares to redeem. - * @param amountSharesToRedeem_ Amount of shares to redeem. - * @return Amount of token that would be redeemed. - */ - function _previewRedeem(uint256 amountSharesToRedeem_) internal view returns (uint256) { - // tokenOut = (amountSharesToRedeem_ * wrapperBalanceOfYieldToken) / totalSupply - return - amountSharesToRedeem_ == 0 - ? amountSharesToRedeem_ - : (amountSharesToRedeem_ * exchangeRate()) / _YIELD_TOKEN_UNIT; - } - /** * @notice Checks if account has enough balance to transfer. * @param account_ Address to check. @@ -407,20 +197,4 @@ contract WM is IWM, ERC20Extended { TTGRegistrarReader.isEarnersListIgnored(ttgRegistrar) || TTGRegistrarReader.isApprovedEarner(ttgRegistrar, account_); } - - /** - * @notice Checks if `tokenIn_` is a valid token to deposit. - * @param tokenIn_ Address of the token to check. - */ - function _isValidTokenIn(address tokenIn_) internal view { - if (!isValidTokenIn(tokenIn_)) revert InvalidTokenIn(tokenIn_); - } - - /** - * @notice Checks if `tokenOut_` is a valid token to redeem. - * @param tokenOut_ Address of the token to check. - */ - function _isValidTokenOut(address tokenOut_) internal view { - if (!isValidTokenOut(tokenOut_)) revert InvalidTokenOut(tokenOut_); - } } diff --git a/src/YM.sol b/src/YM.sol new file mode 100644 index 0000000..6bd7c28 --- /dev/null +++ b/src/YM.sol @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +import { IERC20 } from "../lib/common/src/interfaces/IERC20.sol"; +import { ERC20Extended } from "../lib/common/src/ERC20Extended.sol"; + +import { IContinuousIndexing } from "../lib/protocol/src/interfaces/IContinuousIndexing.sol"; +import { ITTGRegistrar } from "../lib/protocol/src/interfaces/ITTGRegistrar.sol"; +import { IMToken } from "../lib/protocol/src/interfaces/IMToken.sol"; + +import { ContinuousIndexingMath } from "../lib/protocol/src/libs/ContinuousIndexingMath.sol"; + +import { IStandardizedYield } from "./interfaces/IStandardizedYield.sol"; +import { IYM } from "./interfaces/IYM.sol"; + +import { TTGRegistrarReader } from "./libs/TTGRegistrarReader.sol"; + +/** + * @title YM token. + * @author M^0 Labs + * @notice Interest bearing YM token. + */ +contract YM is IYM, ERC20Extended { + /* ============ Structs ============ */ + + /** + * @notice YM token balance struct. + * @param balance Balance of YW tokens representing the underlying principal balance of M tokens. + * @param latestIndex Latest recorded M token index of account when the balance was last updated. + */ + struct YMBalance { + uint256 balance; + uint128 latestIndex; + } + + /* ============ Variables ============ */ + + /// @inheritdoc IERC20 + uint256 public totalSupply; + + /// @inheritdoc IWM + uint128 public latestIndex; + + /// @inheritdoc IYM + address public immutable mToken; + + /// @inheritdoc IYM + address public immutable ttgRegistrar; + + /// @inheritdoc IYM + address public immutable wMToken; + + /// @notice M token decimals. + uint8 private constant _DECIMALS = 6; + + /// @notice Underlying M token unit. + uint256 private constant _M_TOKEN_UNIT = 10 ** _DECIMALS; + + /// @notice Dead address to send YM tokens to if caller is not on the earners list. + address private constant _DEAD_ADDRESS = address(0x000000000000000000000000000000000000dEaD); + + /// @notice YM token balances. + mapping(address account => YMBalance balance) internal _balances; + + /* ============ Modifiers ============ */ + + /// @dev Modifier to check if caller is WM token. + modifier onlyWM() { + if (msg.sender != wMToken) revert NotWMToken(); + + _; + } + + /* ============ Constructor ============ */ + + /** + * @notice Constructs the YM token contract. + * @param wMToken_ The wrapped M token address. + */ + constructor(address wMToken_) ERC20Extended("YM by M^0", "YM", _DECIMALS) { + if ((wMToken = wMToken_) == address(0)) revert ZeroWMToken(); + + address mToken_ = IWM(wMToken_).mToken(); + + if ((mToken = mToken_) == address(0)) revert ZeroMToken(); + if ((ttgRegistrar = IMToken(mToken_).ttgRegistrar()) == address(0)) revert ZeroTTGRegistrar(); + + latestIndex = IContinuousIndexing(mToken_).currentIndex(); + } + + /* ============ Interactive Functions ============ */ + + /// @inheritdoc IYM + function mint(address account_, uint256 amount_) external onlyWM { + _mint(account_, amount_); + } + + /// @inheritdoc IYM + function burn(address account, uint256 amount) external onlyWM { + _burn(account, amount); + } + + /// @inheritdoc IYM + function distributeExcessEarnedM(uint256 minAmount_) external { + if (!TTGRegistrarReader.isApprovedLiquidator(ttgRegistrar, msg.sender)) + revert NotApprovedLiquidator(msg.sender); + + IMToken mToken_ = IMToken(yieldToken); + + uint256 excessEarnedM = mToken_.balanceOf(address(this)) - totalEarnedM; + minAmount_ = minAmount_ > excessEarnedM ? excessEarnedM : minAmount_; + + _mint(ITTGRegistrar(ttgRegistrar).vault(), minAmount_); + + emit ExcessEarnedMDistributed(msg.sender, minAmount_); + } + + /* ============ External View/Pure Functions ============ */ + + /// @inheritdoc IERC20 + function balanceOf(address account_) external view returns (uint256) { + return _balances[account_].balance; + } + + /// @inheritdoc IERC20 + function balanceOfM(address account_) external view returns (uint256) { + return _balances[account_].balance; + } + + /// @inheritdoc IStandardizedYield + function exchangeRate() public view returns (uint256) { + // exchangeRate = (yieldTokenUnit * wrapperTotalEarnedM) / wrapperTotalSupply + return + totalSupply == 0 + ? _M_TOKEN_UNIT + : (_M_TOKEN_UNIT * IWM(wMToken).totalEarnedM() / totalSupply(); + } + + /* ============ Internal Interactive Functions ============ */ + + /** + * @dev Burns `shares_` amount from `account_`. + * @param account_ Address to burn from. + * @param shares_ Amount of shares to burn. + */ + function _burn(address account_, uint256 shares_) internal { + _transfer(account_, address(0), shares_); + } + + /** + * @dev Mints `shares_` amount to `receiver_`. + * @param receiver_ Address to mint to. + * @param shares_ Amount of shares to mint. + */ + function _mint(address receiver_, uint256 shares_) internal { + _transfer(address(0), receiver_, shares_); + } + + /** + * @dev Internal ERC20 transfer function. + * @param sender_ The sender's address. + * @param receiver_ The receiver's address. + * @param shares_ The amount of shares to be transferred. + */ + function _transfer(address sender_, address receiver_, uint256 shares_) internal override { + // Update M token index to update the amount of M tokens earned by WM token. + IContinuousIndexing(mToken).updateIndex(); + + uint128 currentYieldTokenIndex_ = IContinuousIndexing(mToken).currentIndex(); + + // TODO: implement unchecked maths and rounding + // TODO: safe cast shares_ to uint240 or uint112 + if (sender_ != address(0)) { + YMBalance storage senderBalance_ = _balances[sender_]; + + uint240 senderEarnedM_ = _getPresentAmountRoundedDown( + uint112(senderBalance_.balance), + currentYieldTokenIndex_ - senderBalance_.latestIndex + ); + + // Update sender balance with earned M tokens since the balance was last updated. + senderBalance_.balance += senderEarnedM_; + senderBalance_.latestIndex = currentYieldTokenIndex_; + + totalSupply += senderEarnedM; + + _hasEnoughBalance(sender_, senderBalance_.balance, shares_); + + senderBalance_.balance -= shares_; + } else { + totalSupply += shares_; + } + + if (receiver_ != address(0)) { + YMBalance storage receiverBalance_ = _balances[receiver_]; + + uint240 receiverEarnedM_ = _getPresentAmountRoundedDown( + uint112(receiverBalance_.balance), + currentYieldTokenIndex_ - receiverBalance_.latestIndex + ); + + // Update receiver balance with earned M tokens since the balance was last updated + shhares received. + receiverBalance_.balance += (receiverEarnedM_ + shares_); + receiverBalance_.latestIndex = currentYieldTokenIndex_; + + totalSupply += receiverEarnedM_; + } else { + totalSupply -= shares_; + } + + emit Transfer(sender_, receiver_, shares_); + + latestIndex = currentYieldTokenIndex_; + } + + /* ============ Internal View/Pure Functions ============ */ + + /** + * @notice Returns the amount of shares that would be minted for a given amount of token to deposit. + * @param amount_ Amount of tokens to deposit. + * @return Amount of shares that would be minted. + */ + function _tokenToShares(uint256 amount_) internal view returns (uint256) { + // shares = (amount_ * totalSupply) / wrapperTotalEarnedM + return + amount_ == 0 + ? amount_ + : (amount_ * _YIELD_TOKEN_UNIT) / exchangeRate(); + } + + /** + * @notice Returns the amount of token that would be redeemed for a given amount of shares to redeem. + * @param shares_ Amount of shares to redeem. + * @return Amount of token that would be redeemed. + */ + function _sharesToToken(uint256 shares_) internal view returns (uint256) { + // tokenOut = (shares_ * wrapperTotalEarnedM) / totalSupply + return + shares_ == 0 + ? shares_ + : (shares_ * exchangeRate()) / _YIELD_TOKEN_UNIT; + } + + /** + * @notice Checks if account has enough balance to transfer. + * @param account_ Address to check. + * @param balance_ Current balance of account. May account for the earned amount if account is earning. + * @param amount_ Amount to transfer. + */ + function _hasEnoughBalance(address account_, uint256 balance_, uint256 amount_) internal view { + if (balance_ < amount_) revert InsufficientBalance(account_, balance_, amount_); + } + + /** + * @dev Returns the present amount (rounded down) given the principal amount and an index. + * @param principalAmount_ The principal amount. + * @param index_ An index. + * @return The present amount rounded down. + */ + function _getPresentAmountRoundedDown(uint112 principalAmount_, uint128 index_) internal pure returns (uint240) { + return ContinuousIndexingMath.multiplyDown(principalAmount_, index_); + } +} diff --git a/src/interfaces/IStandardizedYield.sol b/src/interfaces/IStandardizedYield.sol index 85e9879..091599c 100644 --- a/src/interfaces/IStandardizedYield.sol +++ b/src/interfaces/IStandardizedYield.sol @@ -37,40 +37,6 @@ interface IStandardizedYield { /// @notice Emitted if `amountSharesToRedeem` is 0. error ZeroRedeem(); - /* ============ Events ============ */ - - /** - * @notice Emitted when any base tokens is deposited to mint shares. - * @param caller Address which deposited the base tokens. - * @param receiver Address which received the shares. - * @param tokenIn Address of the base token deposited. - * @param amountDeposited Amount of base tokens deposited. - * @param amountSyOut Amount of shares minted. - */ - event Deposit( - address indexed caller, - address indexed receiver, - address indexed tokenIn, - uint256 amountDeposited, - uint256 amountSyOut - ); - - /** - * @notice Emitted when any shares are redeemed for base tokens. - * @param caller Address which redeemed the shares. - * @param receiver Address which received the base tokens. - * @param tokenOut Address of the base token redeemed. - * @param amountSyToRedeem Amount of shares redeemed. - * @param amountTokenOut Amount of base tokens received. - */ - event Redeem( - address indexed caller, - address indexed receiver, - address indexed tokenOut, - uint256 amountSyToRedeem, - uint256 amountTokenOut - ); - /* ============ Interactive Functions ============ */ /** @@ -126,16 +92,6 @@ interface IStandardizedYield { */ function exchangeRate() external view returns (uint256); - /** - * @notice Returns the address of the underlying yield token. - * @dev MUST return a token address that conforms to the ERC-20 interface, or zero address. - * MUST NOT revert. - * MUST reflect the exact underlying yield-bearing token address if the SY token is a wrapped token. - * MAY return 0x or zero address if the SY token is natively implemented, and not from wrapping. - * @return Address of the underlying yield token. - */ - function yieldToken() external view returns (address); - /** * @notice Returns all tokens that can mint this SY. * @dev MUST return ERC-20 token addresses. diff --git a/src/interfaces/IWM.sol b/src/interfaces/IWM.sol index 820777e..bd4e6b3 100644 --- a/src/interfaces/IWM.sol +++ b/src/interfaces/IWM.sol @@ -4,29 +4,26 @@ pragma solidity 0.8.23; import { IERC20Extended } from "../../lib/common/src/interfaces/IERC20Extended.sol"; -import { IStandardizedYield } from "./IStandardizedYield.sol"; - -interface IWM is IERC20Extended, IStandardizedYield { +interface IWM is IERC20Extended { /* ============ Events ============ */ /** - * @notice Emitted when excess earned M tokens are distributed to ZERO token holders. - * @param account The account that distributed the excess earned M tokens. - * @param amount The amount of WM tokens distributed. - */ - event ExcessEarnedMDistributed(address indexed account, uint256 amount); - - /** - * @notice Emitted when account starts being a WM earner. - * @param account The account that started earning. + * @notice Emitted when M tokens are deposited to mint WM shares. + * @param caller Address which deposited the M tokens. + * @param receiver Address which received the WM shares. + * @param amount Amount of M tokens deposited. + * @param shares Amount of WM shares minted. */ - event StartedEarning(address indexed account); + event Deposit(address indexed caller, address indexed receiver, uint256 amount, uint256 shares); /** - * @notice Emitted when account stops being a WM earner. - * @param account The account that stopped earning. + * @notice Emitted when WM shares are redeemed for M tokens. + * @param caller Address which redeemed the WM shares. + * @param receiver Address which received the M tokens. + * @param shares Amount of WM shares redeemed. + * @param amount Amount of M tokens received. */ - event StoppedEarning(address indexed account); + event Redeem(address indexed caller, address indexed receiver, uint256 shares, uint256 amount); /* ============ Custom Errors ============ */ @@ -50,55 +47,56 @@ interface IWM is IERC20Extended, IStandardizedYield { */ error NotApprovedEarner(address account); - /** - * @notice Emitted when calling `distributeExcessEarnedM` by an account not approved as liquidator by TTG. - * @param account Address of the unapproved account. - */ - error NotApprovedLiquidator(address account); - /// @notice Emitted in constructor if M token is 0x0. error ZeroMToken(); /// @notice Emitted in constructor if TTG Registrar is 0x0. error ZeroTTGRegistrar(); + /// @notice Emitted in constructor if YM token is 0x0. + error ZeroYMToken(); + /* ============ Interactive Functions ============ */ /** - * @notice Distributes excess earned M tokens to ZERO token holders. - * @dev MUST revert if caller is not the approved liquidator in TTG Registrar. - * @param minAmount Minimum amount of M tokens to distribute. + * @notice Mints an amount of WM shares by depositing M tokens. + * @dev MUST emit the `Deposit` event. + * MUST support ERC-20’s `approve` / `transferFrom` flow. + * @param receiver Address which will receive the WM shares. + * @param amount Amount of M tokens to deposit into the wrapper. + * @return Amount of WM shares minted. */ - function distributeExcessEarnedM(uint256 minAmount) external; - - /// @notice Starts earning for caller if allowed by TTG. - function startEarning() external; - - /// @notice Stops earning for caller. - function stopEarning() external; + function deposit(address receiver, uint256 amount) external payable returns (uint256); /** - * @notice Stops earning for `account`. - * @dev MUST revert if `account` is an approved earner in TTG Registrar. - * @param account The account to stop earning for. + * @notice Redeems an amount of M tokens by burning WM shares. + * @dev MUST emit the `Redeem` event. + * MUST support ERC-20’s `approve` / `transferFrom` flow. + * @param receiver Address which will receive the M tokens. + * @param shares Amount of WM shares to be burned. + * @return Amount of M tokens redeemed. */ - function stopEarning(address account) external; + function redeem(address receiver, uint256 shares) external returns (uint256); /* ============ View/Pure Functions ============ */ - /// @notice The M token index at deployment. - function latestIndex() external view returns (uint128); - /** - * @notice Checks if account is an earner. - * @param account The account to check. - * @return True if account is an earner, false otherwise. + * @notice Returns the address of the underlying M token. + * @return Address of the underlying M token. */ - function isEarning(address account) external view returns (bool); + function mToken() external view returns (address); - /// @notice The total amount of M earned by the earning accounts. + /** + * @notice The total amount of M earned by the WM token. + * @dev Is equivalent to the total amount of M held by the WM token + * minus the total supply of WM tokens since WM is minted 1:1 with M. + */ function totalEarnedM() external view returns (uint256); - /// @notice The address of the TTG Registrar contract. - function ttgRegistrar() external view returns (address); + /** + * @notice Returns the address of the Yield M token. + * @dev This token allows the holder to claim their share of the yield generated by M in this wrapper. + * @return Address of the Yield M token. + */ + function yMToken() external view returns (address); } diff --git a/src/interfaces/IYM.sol b/src/interfaces/IYM.sol new file mode 100644 index 0000000..c9178bb --- /dev/null +++ b/src/interfaces/IYM.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity 0.8.23; + +import { IERC20Extended } from "../../lib/common/src/interfaces/IERC20Extended.sol"; + +interface IYM is IERC20Extended { + /* ============ Custom Errors ============ */ + + // TODO: merge in a common IERC20Errors.sol file + /** + * @notice Indicates an error related to the current `balance` of a `sender`. Used in transfers. + * @param sender Address whose tokens are being transferred. + * @param balance Current balance for the interacting account. + * @param needed Minimum amount required to perform a transfer. + */ + error InsufficientBalance(address sender, uint256 balance, uint256 needed); + + /** + * @notice Emitted when calling `stopEarning` for an account approved as earner by TTG. + * @param account Address of the approved account. + */ + error IsApprovedEarner(address account); + + /** + * @notice Emitted when calling `startEarning` for an account not approved as earner by TTG. + * @param account Address of the unapproved account. + */ + error NotApprovedEarner(address account); + + /** + * @notice Emitted when calling `distributeExcessEarnedM` by an account not approved as liquidator by TTG. + * @param account Address of the unapproved account. + */ + error NotApprovedLiquidator(address account); + + /// @notice Emitted if the caller is not the WM token. + error NotWMToken(); + + /// @notice Emitted in constructor if M token is 0x0. + error ZeroMToken(); + + /// @notice Emitted in constructor if WM token is 0x0. + error ZeroWMToken(); + + /* ============ Interactive Functions ============ */ + + /** + * @notice Mint YM tokens to `account`. + * @dev MUST only be callable by the WM token. + * @param account The account to mint YM to. + * @param amount The amount of YM to mint. + */ + function mint(address account, uint256 amount) external; + + /** + * @notice burn YM tokens from `account`. + * @dev MUST only be callable by the WM token. + * @param account The account to burn YM from. + * @param amount The amount of YM to burn. + */ + function burn(address account, uint256 amount) external; + + /** + * @notice Distributes excess earned M tokens to ZERO token holders. + * @dev MUST revert if caller is not the approved liquidator in TTG Registrar. + * @param minAmount Minimum amount of M tokens to distribute. + */ + function distributeExcessEarnedM(uint256 minAmount) external; + + /* ============ View/Pure Functions ============ */ + + /** + * @notice Returns the current exchange rate between the YM shares and the amount M token earned by the WM token. + * @return The current exchange rate. + */ + function exchangeRate() external view returns (uint256); + + /// @notice Returns the address of the underlying M token. + function mToken() external view returns (address); + + /// @notice The address of the TTG Registrar contract. + function ttgRegistrar() external view returns (address); + + /// @notice Returns the address of the WM token. + function wMToken() external view returns (address); +} diff --git a/test/WM.t.sol b/test/WM.t.sol index 44557fb..663d8b4 100644 --- a/test/WM.t.sol +++ b/test/WM.t.sol @@ -89,6 +89,9 @@ contract WMTest is TestUtils { // Balance should stay the same. assertEq(_wM.balanceOf(_alice), balance_); + + // Balance of underlying should be the same. + assertEq(_wM.balanceOfUnderlying(_alice), balance_); } function testFuzz_balanceOf_nonEarner(uint256 balance_) external { @@ -98,8 +101,8 @@ contract WMTest is TestUtils { vm.warp(vm.getBlockTimestamp() + _ONE_YEAR); - // Balance should stay the same. assertEq(_wM.balanceOf(_alice), balance_); + assertEq(_wM.balanceOfUnderlying(_alice), balance_); } function test_balanceOf_earner() external { @@ -114,12 +117,15 @@ contract WMTest is TestUtils { vm.warp(vm.getBlockTimestamp() + _ONE_YEAR); + // Balance should stay the same. + assertEq(_wM.balanceOf(_alice), balance_); + uint128 deltaIndex_ = _getContinuousIndexAt(_earnerRate, _initialIndex, _ONE_YEAR) - _initialIndex; uint240 earnedM_ = _getPresentAmountRoundedDown(balance_, deltaIndex_); - uint256 expectedBalance_ = balance_ + earnedM_; + uint256 expectedUnderlyingBalance_ = balance_ + earnedM_; - // Balance should have compounded continuously by 10% for a year. - assertEq(_wM.balanceOf(_alice), expectedBalance_); + // Balance of the underlying should have compounded continuously by 10% for a year. + assertEq(_wM.balanceOfUnderlying(_alice), expectedUnderlyingBalance_); } function testFuzz_balanceOf_earner(uint256 balance_, uint256 elapsedTime_) external { @@ -135,11 +141,14 @@ contract WMTest is TestUtils { elapsedTime_ = bound(elapsedTime_, 0, type(uint32).max); vm.warp(vm.getBlockTimestamp() + elapsedTime_); + // Balance should stay the same. + assertApproxEqAbs(_wM.balanceOf(_alice), balance_, 100); + + // Balance of the underlying should have compounded continuously by 10% for `elapsedTime_`. uint128 deltaIndex_ = _getContinuousIndexAt(_earnerRate, _initialIndex, uint32(elapsedTime_)) - _initialIndex; uint240 earnedM_ = _getPresentAmountRoundedDown(uint112(balance_), deltaIndex_); - uint256 expectedBalance_ = balance_ + earnedM_; + uint256 expectedUnderlyingBalance_ = balance_ + earnedM_; - // Balance should have compounded continuously by 10% for `elapsedTime_`. - assertApproxEqAbs(_wM.balanceOf(_alice), expectedBalance_, 100); + assertEq(_wM.balanceOfUnderlying(_alice), expectedUnderlyingBalance_); } }