Skip to content

Commit

Permalink
feat(contract): add earning logic
Browse files Browse the repository at this point in the history
  • Loading branch information
PierrickGT committed May 23, 2024
1 parent 10ac7c4 commit c3714e7
Show file tree
Hide file tree
Showing 4 changed files with 422 additions and 57 deletions.
282 changes: 227 additions & 55 deletions src/WM.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,76 +3,74 @@
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 { IWM } from "./interfaces/IWM.sol";

import { TTGRegistrarReader } from "./libs/TTGRegistrarReader.sol";

/**
* @title WM token wrapper with static balances.
* @author M^0 Labs
* @notice ERC5115 WM token.
*/
contract WM is IStandardizedYield, ERC20Extended {
contract WM is IWM, ERC20Extended {
/* ============ Structs ============ */

/**
* @notice WM token balance struct.
* @param isEarning True if the account is earning, false otherwise.
* @param latestIndex Latest recorded index of account. 0 for a non earning account.
* The latest M token index at the time the balance of an earning account was last updated.
* @param rawBalance Balance (for a non earning account) or balance principal (for an earning account).
*/
struct WMBalance {
bool isEarning;
uint128 latestIndex;
uint256 rawBalance;
}

/* ============ Variables ============ */

/// @inheritdoc IERC20
mapping(address account => uint256 shares) public balanceOf;
/// @inheritdoc IWM
uint128 public latestIndex;

/// @inheritdoc IERC20
uint256 public totalSupply;
/// @inheritdoc IWM
uint240 public totalNonEarningSupply;

/// @inheritdoc IStandardizedYield
address public immutable yieldToken;

/// @notice Underlying yield token unit.
uint256 private immutable _yieldTokenUnit;

/* ============ Custom Errors ============ */

/**
* @notice Emitted if the amount of shares minted is lower than the minimum required.
* @param amountSharesOut Amount of shares minted.
* @param minSharesOut Minimum amount of shares required.
*/
error InsufficientSharesOut(uint256 amountSharesOut, uint256 minSharesOut);

/**
* @notice Emitted if the amount of token redeemed is lower than the minimum required.
* @param amountTokenOut Amount of token redeemed.
* @param minTokenOut Minimum amount of token required.
*/
error InsufficientTokenOut(uint256 amountTokenOut, uint256 minTokenOut);

/**
* @notice Emitted if `tokenIn` is unsupported by the WM token wrapper.
* @param tokenIn Address of the unsupported token.
*/
error InvalidTokenIn(address tokenIn);

/**
* @notice Emitted if `tokenOut` is unsupported by the WM token wrapper.
* @param tokenOut Address of the unsupported token.
*/
error InvalidTokenOut(address tokenOut);
/// @inheritdoc IWM
address public immutable ttgRegistrar;

/// @notice Emitted if `amountTokenToDeposit` is 0.
error ZeroDeposit();
// @notice The total principal balance of earning supply.
uint112 internal _principalOfTotalEarningSupply;

/// @notice Emitted in constructor if M token is 0x0.
error ZeroMToken();
/// @notice Underlying yield token unit.
uint256 private immutable _yieldTokenUnit;

/// @notice Emitted if `amountSharesToRedeem` is 0.
error ZeroRedeem();
/// @notice WM token balances.
mapping(address account => WMBalance balance) internal _balances;

/* ============ Constructor ============ */

/**
* @notice Constructs the WM token contract.
* @param mToken_ Address of the underlying yield token.
*/
constructor(address mToken_) ERC20Extended("WM by M^0", "M", IERC20(mToken_).decimals()) {
constructor(address mToken_, address ttgRegistrar_) ERC20Extended("WM by M^0", "M", IERC20(mToken_).decimals()) {
if ((yieldToken = mToken_) == address(0)) revert ZeroMToken();
if ((ttgRegistrar = ttgRegistrar_) == address(0)) revert ZeroTTGRegistrar();

latestIndex = IContinuousIndexing(mToken_).currentIndex();
_yieldTokenUnit = 10 ** IERC20(mToken_).decimals();
}

Expand Down Expand Up @@ -106,7 +104,6 @@ contract WM is IStandardizedYield, ERC20Extended {
uint256 minTokenOut_,
bool burnFromInternalBalance_
) external returns (uint256 amountTokenOut_) {
// TODO: handle redeem from non earner
_isValidTokenOut(tokenOut_);
if (amountSharesToRedeem_ == 0) revert ZeroRedeem();

Expand All @@ -122,8 +119,65 @@ contract WM is IStandardizedYield, ERC20Extended {
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);

IMToken mToken_ = IMToken(yieldToken);

uint256 excessEarnedM = mToken_.isEarning(address(this))
? mToken_.principalBalanceOf(address(this)) - totalNonEarningSupply
: mToken_.balanceOf(address(this)) - totalNonEarningSupply;

minAmount_ = minAmount_ > excessEarnedM ? excessEarnedM : minAmount_;

_mint(ITTGRegistrar(ttgRegistrar).vault(), minAmount_);

emit ExcessEarnedMDistributed(msg.sender, minAmount_);
}

/// @inheritdoc IWM
function startEarning() external {
if (!_isApprovedEarner(msg.sender)) revert NotApprovedEarner(msg.sender);

_startEarning(msg.sender);
}

/// @inheritdoc IWM
function stopEarning() external {
_stopEarning(msg.sender);
}

/// @inheritdoc IWM
function stopEarning(address account_) external {
if (_isApprovedEarner(account_)) revert IsApprovedEarner(account_);

_stopEarning(account_);
}

/* ============ View/Pure Functions ============ */

/// @inheritdoc IERC20
function balanceOf(address account) external view returns (uint256) {
WMBalance storage accountBalance_ = _balances[account];

// If account is earning, return the principal balance + the earned M amount
return
accountBalance_.isEarning
? accountBalance_.rawBalance +
_getPresentAmountRoundedDown(
uint112(accountBalance_.rawBalance),
IContinuousIndexing(yieldToken).currentIndex() - accountBalance_.latestIndex
)
: accountBalance_.rawBalance;
}

/// @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_);
Expand All @@ -138,11 +192,13 @@ contract WM is IStandardizedYield, ERC20Extended {

/// @inheritdoc IStandardizedYield
function exchangeRate() public view returns (uint256) {
uint256 totalSupply_ = totalSupply();

// exchangeRate = (yieldTokenUnit * wrapperBalanceOfYieldToken) / totalSupply
return
totalSupply == 0
totalSupply_ == 0
? _yieldTokenUnit
: (_yieldTokenUnit * IERC20(yieldToken).balanceOf(address(this))) / totalSupply;
: (_yieldTokenUnit * IERC20(yieldToken).balanceOf(address(this))) / totalSupply_;
}

/// @inheritdoc IStandardizedYield
Expand All @@ -169,6 +225,25 @@ contract WM is IStandardizedYield, ERC20Extended {
return token_ == yieldToken;
}

/// @inheritdoc IWM
function totalEarningSupply() public view returns (uint240) {
// Can't underflow since `currentIndex` is after or at `latestIndex`.
unchecked {
return
_getPresentAmountRoundedDown(
_principalOfTotalEarningSupply,
IContinuousIndexing(yieldToken).currentIndex() - latestIndex
);
}
}

/// @inheritdoc IERC20
function totalSupply() public view returns (uint256) {
unchecked {
return totalNonEarningSupply + totalEarningSupply();
}
}

/* ============ Internal Interactive Functions ============ */

/**
Expand Down Expand Up @@ -196,28 +271,114 @@ contract WM is IStandardizedYield, ERC20Extended {
* @param amount_ The amount to be transferred.
*/
function _transfer(address sender_, address receiver_, uint256 amount_) internal override {
// TODO: improve this logic
uint128 currentYieldTokenIndex_ = IContinuousIndexing(yieldToken).currentIndex();

// TODO: implement unchecked maths and rounding
// TODO: safe cast amount_ to uint240 or uint112
if (sender_ != address(0)) {
balanceOf[sender_] -= amount_;
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_.rawBalance),
currentYieldTokenIndex_ - senderBalance_.latestIndex
);

senderBalance_.rawBalance += senderEarnedM;
senderBalance_.latestIndex = currentYieldTokenIndex_;

_principalOfTotalEarningSupply += uint112(senderEarnedM);
}

// Check if sender has enough balance
if (senderBalance_.rawBalance < amount_) {
revert InsufficientBalance(sender_, senderBalance_.rawBalance, amount_);
}

senderBalance_.rawBalance -= amount_;

if (senderBalance_.isEarning) {
_principalOfTotalEarningSupply -= uint112(amount_);
} else {
totalNonEarningSupply -= uint240(amount_);
}
}

if (receiver_ != address(0)) {
balanceOf[receiver_] += amount_;
}
WMBalance storage receiverBalance_ = _balances[receiver_];

if (sender_ == address(0)) {
totalSupply += amount_;
}
// If receiver is earning, capture the earned M tokens and update the index
if (receiverBalance_.isEarning) {
uint240 receiverEarnedM = _getPresentAmountRoundedDown(
uint112(receiverBalance_.rawBalance),
currentYieldTokenIndex_ - receiverBalance_.latestIndex
);

receiverBalance_.rawBalance += receiverEarnedM;
receiverBalance_.latestIndex = currentYieldTokenIndex_;

if (receiver_ == address(0)) {
totalSupply -= amount_;
_principalOfTotalEarningSupply += uint112(receiverEarnedM);
}

receiverBalance_.rawBalance += amount_;

if (receiverBalance_.isEarning) {
_principalOfTotalEarningSupply += uint112(amount_);
} else {
totalNonEarningSupply += uint240(amount_);
}
}

emit Transfer(sender_, receiver_, amount_);

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.
Expand All @@ -244,6 +405,17 @@ contract WM is IStandardizedYield, ERC20Extended {
: (amountSharesToRedeem_ * exchangeRate()) / _yieldTokenUnit;
}

/**
* @dev Checks if earner was approved by TTG.
* @param account_ The account to check.
* @return True if approved, false otherwise.
*/
function _isApprovedEarner(address account_) internal view returns (bool) {
return
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.
Expand Down
Loading

0 comments on commit c3714e7

Please sign in to comment.