diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d31bda2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +cache/ +out/ + +lcov.info \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..888d42dc --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/README.md b/README.md index 9bfb6177..1e308403 100644 --- a/README.md +++ b/README.md @@ -1 +1,33 @@ -# FrontierV2 \ No newline at end of file + /$$$$$$$$ /$$ /$$$$$$$ /$$ /$$ + | $$_____/|__/| $$__ $$| $$$ /$$$ + | $$ /$$| $$ \ $$| $$$$ /$$$$ + | $$$$$ | $$| $$$$$$$/| $$ $$/$$ $$ + | $$__/ | $$| $$__ $$| $$ $$$| $$ + | $$ | $$| $$ \ $$| $$\ $ | $$ + | $$ | $$| $$ | $$| $$ \/ | $$ + |__/ |__/|__/ |__/|__/ |__/ +# Fixed Rates Market +FiRM is an over collateralized money market protocol for borrowing DOLA at a fixed price, over an arbitrary period of time. This is accomplished with the *Dola Borrowing Rights* (**DBR**) token. +One DBR token gives the right to borrow one DOLA for one year. As time progresses, DBR will be burnt from the borrower's wallet at a rate that depends on their debt. A borrower may repay their loan at any time, and sell their DBR at a potential profit, if interest rates have gone up. + +## Architecture +Simplified overview of the FiRM architecture: + +### Market +The market contract is the central contract of the FiRM protocol and contains most logic pertaining to borrowing and liquidations. A DOLA Fed mints DOLA to a market, which is then available to borrow for users holding DBR, using the Borrow function. + +A borrow controller contract is connected to the market, which may add additional logic to who and how much is allowed to be borrowed. + +If a borrower's credit limit falls below the value of their outstanding debt, a percentage of their collateral may be liquidated on behalf of the protocol. The liquidation carries an additional fee, which will be paid out to the liquidator, and may benefit protocol governance as well. + +### DBR +The DBR contract is a non-standard ERC-20 contract. As a user borrows DOLA, DBR are slowly burned from the user’s wallet to pay for the borrowing. Since the burn rate is deterministic depending on the user's debt, it's only necessary to update the accrued debt whenever the borrower increase or decrease their debt position. + +If a user's DBR balance falls below 0, the burn will continue as the user accrues a deficit. The user can be forced to replenish their DBR balance through a *forced replenishment*. Force replenishments will mint fresh DBR tokens to a user, a sufficiently high price, that it's unnecessary to query an oracle about the market value of DBR tokens. To pay for the forced replenishment, additional DOLA debt is accrued to the borrower. Forced replenishments can be initiated by anyone and will immediately pay out a percentage of the debt accrued by the action to the caller. + +### Escrows +Each wallet has a unique escrow contract, one for every deployed market. Their primary purpose is holding a user’s collateral, but may have additional functionality that allow borrowers to use their collateral for voting, yield farming etc. + +### Fed +Feds are a class of contracts in the Inverse Finance ecosystem responsible for minting DOLA in a way that preserves the peg and can't be easily abused. In the FiRM protocol, the role of the Fed is to supply and remove DOLA to and from markets. + diff --git a/SimplifiedArchitecture.png b/SimplifiedArchitecture.png new file mode 100644 index 00000000..b580708e Binary files /dev/null and b/SimplifiedArchitecture.png differ diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 00000000..e6810b2b --- /dev/null +++ b/foundry.toml @@ -0,0 +1,6 @@ +[profile.default] +src = 'src' +out = 'out' +libs = ['lib'] + +# See more config options https://github.com/foundry-rs/foundry/tree/master/config \ No newline at end of file diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 00000000..27e14b7f --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 27e14b7f2448e5f5ac32719f51fe652aa0b0733e diff --git a/src/BorrowController.sol b/src/BorrowController.sol new file mode 100644 index 00000000..49c97f8d --- /dev/null +++ b/src/BorrowController.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +/** +@title Borrow Controller +@notice Contract for limiting the contracts that are allowed to interact with markets +*/ +contract BorrowController { + + address public operator; + mapping(address => bool) public contractAllowlist; + mapping(address => uint) public dailyLimits; + mapping(address => mapping(uint => uint)) public dailyBorrows; + + constructor(address _operator) { + operator = _operator; + } + + modifier onlyOperator { + require(msg.sender == operator, "Only operator"); + _; + } + + /** + @notice Sets the operator of the borrow controller. Only callable by the operator. + @param _operator The address of the new operator. + */ + function setOperator(address _operator) public onlyOperator { operator = _operator; } + + /** + @notice Allows a contract to use the associated market. + @param allowedContract The address of the allowed contract + */ + function allow(address allowedContract) public onlyOperator { contractAllowlist[allowedContract] = true; } + + /** + @notice Denies a contract to use the associated market + @param deniedContract The addres of the denied contract + */ + function deny(address deniedContract) public onlyOperator { contractAllowlist[deniedContract] = false; } + + /** + @notice Sets the daily borrow limit for a specific market + @param market The addres of the market contract + @param limit The daily borrow limit amount + */ + function setDailyLimit(address market, uint limit) public onlyOperator { dailyLimits[market] = limit; } + + /** + @notice Checks if a borrow is allowed + @dev Currently the borrowController checks if contracts are part of an allow list and enforces a daily limit + @param msgSender The message sender trying to borrow + @param amount The amount to be borrowed + @return A boolean that is true if borrowing is allowed and false if not. + */ + function borrowAllowed(address msgSender, address, uint amount) public returns (bool) { + uint day = block.timestamp / 1 days; + uint dailyLimit = dailyLimits[msg.sender]; + if(dailyLimit > 0) { + if(dailyBorrows[msg.sender][day] + amount > dailyLimit) { + return false; + } else { + //Safe to use unchecked, as function will revert in if statement if overflow + unchecked{ + dailyBorrows[msg.sender][day] += amount; + } + } + } + if(msgSender == tx.origin) return true; + return contractAllowlist[msgSender]; + } + + /** + @notice Reduces the daily limit used, when a user repays debt + @dev This is necessary to prevent a DOS attack, where a user borrows the daily limit and immediately repays it again. + @param amount Amount repaid in the market + */ + function onRepay(uint amount) public { + uint day = block.timestamp / 1 days; + if(dailyBorrows[msg.sender][day] < amount) { + dailyBorrows[msg.sender][day] = 0; + } else { + //Safe to use unchecked, as dailyBorow is checked to be higher than amount + unchecked{ + dailyBorrows[msg.sender][day] -= amount; + } + } + } +} diff --git a/src/DBR.sol b/src/DBR.sol new file mode 100644 index 00000000..cc0562a4 --- /dev/null +++ b/src/DBR.sol @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +/** +@title Dola Borrow Rights +@notice The DolaBorrowRights contract is a non-standard ERC20 token, that gives the right of holders to borrow DOLA at 0% interest. + As a borrower takes on DOLA debt, their DBR balance will be exhausted at 1 DBR per 1 DOLA borrowed per year. +*/ +contract DolaBorrowingRights { + + string public name; + string public symbol; + uint8 public constant decimals = 18; + uint256 public _totalSupply; + address public operator; + address public pendingOperator; + uint public totalDueTokensAccrued; + uint public replenishmentPriceBps; + mapping(address => uint256) public balances; + mapping(address => mapping(address => uint256)) public allowance; + uint256 internal immutable INITIAL_CHAIN_ID; + bytes32 internal immutable INITIAL_DOMAIN_SEPARATOR; + mapping(address => uint256) public nonces; + mapping (address => bool) public minters; + mapping (address => bool) public markets; + mapping (address => uint) public debts; // user => debt across all tracked markets + mapping (address => uint) public dueTokensAccrued; // user => amount of due tokens accrued + mapping (address => uint) public lastUpdated; // user => last update timestamp + + constructor( + uint _replenishmentPriceBps, + string memory _name, + string memory _symbol, + address _operator + ) { + replenishmentPriceBps = _replenishmentPriceBps; + name = _name; + symbol = _symbol; + operator = _operator; + INITIAL_CHAIN_ID = block.chainid; + INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator(); + } + + modifier onlyOperator { + require(msg.sender == operator, "ONLY OPERATOR"); + _; + } + + /** + @notice Sets pending operator of the contract. Operator role must be claimed by the new oprator. Only callable by Operator. + @param newOperator_ The address of the newOperator + */ + function setPendingOperator(address newOperator_) public onlyOperator { + pendingOperator = newOperator_; + } + + /** + @notice Sets the replenishment price in basis points. Replenishment price denotes the increase in DOLA debt upon forced replenishments. + At 10000, the cost of replenishing 1 DBR is 1 DOLA in debt. Only callable by Operator. + @param newReplenishmentPriceBps_ The new replen + */ + function setReplenishmentPriceBps(uint newReplenishmentPriceBps_) public onlyOperator { + require(newReplenishmentPriceBps_ > 0, "replenishment price must be over 0"); + require(newReplenishmentPriceBps_ <= 1_000_000, "Replenishment price cannot exceed 100 DOLA per DBR"); + replenishmentPriceBps = newReplenishmentPriceBps_; + } + + /** + @notice claims the Operator role if set as pending operator. + */ + function claimOperator() public { + require(msg.sender == pendingOperator, "ONLY PENDING OPERATOR"); + operator = pendingOperator; + pendingOperator = address(0); + emit ChangeOperator(operator); + } + + /** + @notice Add a minter to the set of addresses allowed to mint DBR tokens. Only callable by Operator. + @param minter_ The address of the new minter. + */ + function addMinter(address minter_) public onlyOperator { + minters[minter_] = true; + emit AddMinter(minter_); + } + + /** + @notice Removes a minter from the set of addresses allowe to mint DBR tokens. Only callable by Operator. + @param minter_ The address to be removed from the minter set. + */ + function removeMinter(address minter_) public onlyOperator { + minters[minter_] = false; + emit RemoveMinter(minter_); + } + /** + @notice Adds a market to the set of active markets. Only callable by Operator. + @dev markets can be added but cannot be removed. A removed market would result in unrepayable debt for some users. + @param market_ The address of the new market contract to be added. + */ + function addMarket(address market_) public onlyOperator { + markets[market_] = true; + emit AddMarket(market_); + } + + /** + @notice Get the total supply of DBR tokens. + @dev The total supply is calculated as the difference between total DBR minted and total DBR accrued. + @return uint representing the total supply of DBR. + */ + function totalSupply() public view returns (uint) { + if(totalDueTokensAccrued > _totalSupply) return 0; + return _totalSupply - totalDueTokensAccrued; + } + + /** + @notice Get the DBR balance of an address. Will return 0 if the user has zero DBR or a deficit. + @dev The balance of a user is calculated as the difference between the user's balance and the user's accrued DBR debt + due DBR debt. + @param user Address of the user. + @return uint representing the balance of the user. + */ + function balanceOf(address user) public view returns (uint) { + uint debt = debts[user]; + uint accrued = (block.timestamp - lastUpdated[user]) * debt / 365 days; + if(dueTokensAccrued[user] + accrued > balances[user]) return 0; + return balances[user] - dueTokensAccrued[user] - accrued; + } + + /** + @notice Get the DBR deficit of an address. Will return 0 if th user has zero DBR or more. + @dev The deficit of a user is calculated as the difference between the user's accrued DBR deb + due DBR debt and their balance. + @param user Address of the user. + @return uint representing the deficit of the user. + */ + function deficitOf(address user) public view returns (uint) { + uint debt = debts[user]; + uint accrued = (block.timestamp - lastUpdated[user]) * debt / 365 days; + if(dueTokensAccrued[user] + accrued < balances[user]) return 0; + return dueTokensAccrued[user] + accrued - balances[user]; + } + + /** + @notice Get the signed DBR balance of an address. + @dev This function will revert if a user has a balance of more than 2^255-1 DBR + @param user Address of the user. + @return Returns a signed int of the user's balance + */ + function signedBalanceOf(address user) public view returns (int) { + uint debt = debts[user]; + uint accrued = (block.timestamp - lastUpdated[user]) * debt / 365 days; + return int(balances[user]) - int(dueTokensAccrued[user]) - int(accrued); + } + + /** + @notice Approves spender to spend amount of DBR on behalf of the message sender. + @param spender Address of the spender to be approved + @param amount Amount to be approved to spend + @return Always returns true, will revert if not successful. + */ + function approve(address spender, uint256 amount) public virtual returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + /** + @notice Transfers amount to address to from message sender. + @param to The address to transfer to + @param amount The amount of DBR to transfer + @return Always returns true, will revert if not successful. + */ + function transfer(address to, uint256 amount) public virtual returns (bool) { + require(balanceOf(msg.sender) >= amount, "Insufficient balance"); + balances[msg.sender] -= amount; + unchecked { + balances[to] += amount; + } + emit Transfer(msg.sender, to, amount); + return true; + } + + /** + @notice Transfer amount of DBR on behalf of address from to address to. Message sender must have a sufficient allowance from the from address. + @dev Allowance is reduced by the amount transferred. + @param from Address to transfer from. + @param to Address to transfer to. + @param amount Amount of DBR to transfer. + @return Always returns true, will revert if not successful. + */ + function transferFrom( + address from, + address to, + uint256 amount + ) public virtual returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount; + require(balanceOf(from) >= amount, "Insufficient balance"); + balances[from] -= amount; + unchecked { + balances[to] += amount; + } + emit Transfer(from, to, amount); + return true; + } + + /** + @notice Permits an address to spend on behalf of another address via a signed message. + @dev Can be bundled with a transferFrom call, to reduce transaction load on users. + @param owner Address of the owner permitting the spending + @param spender Address allowed to spend on behalf of owner. + @param value Amount to be allowed to spend. + @param deadline Timestamp after which the signed message is no longer valid. + @param v The v param of the ECDSA signature + @param r The r param of the ECDSA signature + @param s The s param of the ECDSA signature + */ + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) public virtual { + require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED"); + unchecked { + address recoveredAddress = ecrecover( + keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ), + owner, + spender, + value, + nonces[owner]++, + deadline + ) + ) + ) + ), + v, + r, + s + ); + require(recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER"); + allowance[recoveredAddress][spender] = value; + } + emit Approval(owner, spender, value); + } + + /** + @notice Function for invalidating the nonce of a signed message. + */ + function invalidateNonce() public { + nonces[msg.sender]++; + } + + function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { + return block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator(); + } + + function computeDomainSeparator() internal view virtual returns (bytes32) { + return + keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(name)), + keccak256("1"), + block.chainid, + address(this) + ) + ); + } + + /** + @notice Accrue due DBR debt of user + @dev DBR debt is accrued at a rate of 1 DBR per 1 DOLA of debt per year. + @param user The address of the user to accrue DBR debt to. + */ + function accrueDueTokens(address user) public { + uint debt = debts[user]; + if(lastUpdated[user] == block.timestamp) return; + uint accrued = (block.timestamp - lastUpdated[user]) * debt / 365 days; + if(accrued > 0 || lastUpdated[user] == 0){ + dueTokensAccrued[user] += accrued; + totalDueTokensAccrued += accrued; + lastUpdated[user] = block.timestamp; + emit Transfer(user, address(0), accrued); + } + } + + /** + @notice Function to be called by markets when a borrow occurs. + @dev Accrues due tokens on behalf of the user, before increasing their debt. + @param user The address of the borrower + @param additionalDebt The additional amount of DOLA the user is borrowing + */ + function onBorrow(address user, uint additionalDebt) public { + require(markets[msg.sender], "Only markets can call onBorrow"); + accrueDueTokens(user); + require(deficitOf(user) == 0, "DBR Deficit"); + debts[user] += additionalDebt; + } + + /** + @notice Function to be called by markets when a repayment occurs. + @dev Accrues due tokens on behalf of the user, before reducing their debt. + @param user The address of the borrower having their debt repaid + @param repaidDebt The amount of DOLA repaid + */ + function onRepay(address user, uint repaidDebt) public { + require(markets[msg.sender], "Only markets can call onRepay"); + accrueDueTokens(user); + debts[user] -= repaidDebt; + } + + /** + @notice Function to be called by markets when a force replenish occurs. This function can only be called if the user has a DBR deficit. + @dev Accrues due tokens on behalf of the user, before increasing their debt by the replenishment price and minting them new DBR. + @param user The user to be force replenished. + @param amount The amount of DBR the user will be force replenished. + */ + function onForceReplenish(address user, address replenisher, uint amount, uint replenisherReward) public { + require(markets[msg.sender], "Only markets can call onForceReplenish"); + uint deficit = deficitOf(user); + require(deficit > 0, "No deficit"); + require(deficit >= amount, "Amount > deficit"); + uint replenishmentCost = amount * replenishmentPriceBps / 10000; + accrueDueTokens(user); + debts[user] += replenishmentCost; + _mint(user, amount); + emit ForceReplenish(user, replenisher, msg.sender, amount, replenishmentCost, replenisherReward); + } + + /** + @notice Function for burning DBR from message sender, reducing supply. + @param amount Amount to be burned + */ + function burn(uint amount) public { + _burn(msg.sender, amount); + } + + /** + @notice Function for minting new DBR, increasing supply. Only callable by minters and the operator. + @param to Address to mint DBR to. + @param amount Amount of DBR to mint. + */ + function mint(address to, uint amount) public { + require(minters[msg.sender] == true || msg.sender == operator, "ONLY MINTERS OR OPERATOR"); + _mint(to, amount); + } + + /** + @notice Internal function for minting DBR. + @param to Address to mint DBR to. + @param amount Amount of DBR to mint. + */ + function _mint(address to, uint256 amount) internal virtual { + _totalSupply += amount; + unchecked { + balances[to] += amount; + } + emit Transfer(address(0), to, amount); + } + + /** + @notice Internal function for burning DBR. + @param from Address to burn DBR from. + @param amount Amount of DBR to be burned. + */ + function _burn(address from, uint256 amount) internal virtual { + require(balanceOf(from) >= amount, "Insufficient balance"); + balances[from] -= amount; + unchecked { + _totalSupply -= amount; + } + emit Transfer(from, address(0), amount); + } + + event Transfer(address indexed from, address indexed to, uint256 amount); + event Approval(address indexed owner, address indexed spender, uint256 amount); + event AddMinter(address indexed minter); + event RemoveMinter(address indexed minter); + event AddMarket(address indexed market); + event ChangeOperator(address indexed newOperator); + event ForceReplenish(address indexed account, address indexed replenisher, address indexed market, uint deficit, uint replenishmentCost, uint replenisherReward); + +} diff --git a/src/Fed.sol b/src/Fed.sol new file mode 100644 index 00000000..8c0c2bfb --- /dev/null +++ b/src/Fed.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +interface IMarket { + function recall(uint amount) external; + function totalDebt() external view returns (uint); + function borrowPaused() external view returns (bool); +} + +interface IDola { + function mint(address to, uint amount) external; + function burn(uint amount) external; + function balanceOf(address user) external view returns (uint); + function transfer(address to, uint amount) external returns (bool); +} + +interface IDBR { + function markets(address) external view returns (bool); +} + +/** +@title The Market Fed +@notice Feds are a class of contracts in the Inverse Finance ecosystem responsible for minting and burning DOLA. + This specific Fed can expand DOLA supply into markets and contract DOLA supply from markets. +*/ +contract Fed { + + IDBR public immutable dbr; + IDola public immutable dola; + address public gov; + address public chair; + uint public supplyCeiling; + uint public globalSupply; + mapping (IMarket => uint) public supplies; + mapping (IMarket => uint) public ceilings; + + constructor (IDBR _dbr, IDola _dola, address _gov, address _chair, uint _supplyCeiling) { + dbr = _dbr; + dola = _dola; + gov = _gov; + chair = _chair; + supplyCeiling = _supplyCeiling; + } + + /** + @notice Change the governance of the Fed contact. Only callable by governance. + @param _gov The address of the new governance contract + */ + function changeGov(address _gov) public { + require(msg.sender == gov, "ONLY GOV"); + gov = _gov; + } + + /** + @notice Set the supply ceiling of the Fed. Only callable by governance. + @param _supplyCeiling Amount to set the supply ceiling to + */ + function changeSupplyCeiling(uint _supplyCeiling) public { + require(msg.sender == gov, "ONLY GOV"); + supplyCeiling = _supplyCeiling; + } + + /** + @notice Set a market's isolated ceiling of the Fed. Only callable by governance. + @param _market Market to set the ceiling for + @param _ceiling Amount to set the ceiling to + */ + function changeMarketCeiling(IMarket _market, uint _ceiling) public { + require(msg.sender == gov, "ONLY GOV"); + ceilings[_market] = _ceiling; + } + + /** + @notice Set the chair of the fed. Only callable by governance. + @param _chair Address of the new chair. + */ + function changeChair(address _chair) public { + require(msg.sender == gov, "ONLY GOV"); + chair = _chair; + } + + /** + @notice Set the address of the chair to the 0 address. Only callable by the chair. + @dev Useful for immediately removing chair powers in case of a wallet compromise. + */ + function resign() public { + require(msg.sender == chair, "ONLY CHAIR"); + chair = address(0); + } + + /** + @notice Expand the amount of DOLA by depositing the amount into a specific market. + @dev While not immediately dangerous to the DOLA peg, make sure the market can absorb the new potential supply. Market must have a positive ceiling before expansion. + @param market The market to add additional DOLA supply to. + @param amount The amount of DOLA to mint and supply to the market. + */ + function expansion(IMarket market, uint amount) public { + require(msg.sender == chair, "ONLY CHAIR"); + require(dbr.markets(address(market)), "UNSUPPORTED MARKET"); + require(market.borrowPaused() != true, "CANNOT EXPAND PAUSED MARKETS"); + dola.mint(address(market), amount); + supplies[market] += amount; + globalSupply += amount; + require(globalSupply <= supplyCeiling); + require(supplies[market] <= ceilings[market]); + emit Expansion(market, amount); + } + + /** + @notice Contract the amount of DOLA by withdrawing some amount of DOLA from a market, before burning it. + @dev Markets can have more DOLA in them than they've been supplied, due to force replenishes. This call will revert if trying to contract more than have been supplied. + @param market The market to withdraw DOLA from + @param amount The amount of DOLA to withdraw and burn. + */ + function contraction(IMarket market, uint amount) public { + require(msg.sender == chair, "ONLY CHAIR"); + require(dbr.markets(address(market)), "UNSUPPORTED MARKET"); + uint supply = supplies[market]; + require(amount <= supply, "AMOUNT TOO BIG"); // can't burn profits + market.recall(amount); + dola.burn(amount); + supplies[market] -= amount; + globalSupply -= amount; + emit Contraction(market, amount); + } + + /** + @notice Gets the profit of a market. + @param market The market to withdraw profit from. + @return A uint representing the profit of the market. + */ + function getProfit(IMarket market) public view returns (uint) { + uint marketValue = dola.balanceOf(address(market)) + market.totalDebt(); + uint supply = supplies[market]; + if(supply >= marketValue) return 0; + return marketValue - supply; + } + + /** + @notice Takes profit from a market + @param market The market to take profit from. + */ + function takeProfit(IMarket market) public { + uint profit = getProfit(market); + if(profit > 0) { + market.recall(profit); + dola.transfer(gov, profit); + } + } + + + event Expansion(IMarket indexed market, uint amount); + event Contraction(IMarket indexed market, uint amount); + +} diff --git a/src/Market.sol b/src/Market.sol new file mode 100644 index 00000000..04bcf09c --- /dev/null +++ b/src/Market.sol @@ -0,0 +1,640 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +// Caution. We assume all failed transfers cause reverts and ignore the returned bool. +interface IERC20 { + function transfer(address,uint) external returns (bool); + function transferFrom(address,address,uint) external returns (bool); + function balanceOf(address) external view returns (uint); +} + +interface IOracle { + function getPrice(address,uint) external returns (uint); + function viewPrice(address,uint) external view returns (uint); +} + +interface IEscrow { + function initialize(IERC20 _token, address beneficiary) external; + function onDeposit() external; + function pay(address recipient, uint amount) external; + function balance() external view returns (uint); +} + +interface IDolaBorrowingRights { + function onBorrow(address user, uint additionalDebt) external; + function onRepay(address user, uint repaidDebt) external; + function onForceReplenish(address user, address replenisher, uint amount, uint replenisherReward) external; + function balanceOf(address user) external view returns (uint); + function deficitOf(address user) external view returns (uint); + function replenishmentPriceBps() external view returns (uint); +} + +interface IBorrowController { + function borrowAllowed(address msgSender, address borrower, uint amount) external returns (bool); + function onRepay(uint amount) external; +} + +contract Market { + + address public gov; + address public lender; + address public pauseGuardian; + address public immutable escrowImplementation; + IDolaBorrowingRights public immutable dbr; + IBorrowController public borrowController; + IERC20 public immutable dola = IERC20(0x865377367054516e17014CcdED1e7d814EDC9ce4); + IERC20 public immutable collateral; + IOracle public oracle; + uint public collateralFactorBps; + uint public replenishmentIncentiveBps; + uint public liquidationIncentiveBps; + uint public liquidationFeeBps; + uint public liquidationFactorBps = 5000; // 50% by default + bool immutable callOnDepositCallback; + bool public borrowPaused; + uint public totalDebt; + uint256 internal immutable INITIAL_CHAIN_ID; + bytes32 internal immutable INITIAL_DOMAIN_SEPARATOR; + mapping (address => IEscrow) public escrows; // user => escrow + mapping (address => uint) public debts; // user => debt + mapping(address => uint256) public nonces; // user => nonce + + constructor ( + address _gov, + address _lender, + address _pauseGuardian, + address _escrowImplementation, + IDolaBorrowingRights _dbr, + IERC20 _collateral, + IOracle _oracle, + uint _collateralFactorBps, + uint _replenishmentIncentiveBps, + uint _liquidationIncentiveBps, + bool _callOnDepositCallback + ) { + require(_collateralFactorBps < 10000, "Invalid collateral factor"); + require(_liquidationIncentiveBps > 0 && _liquidationIncentiveBps < 10000, "Invalid liquidation incentive"); + require(_replenishmentIncentiveBps < 10000, "Replenishment incentive must be less than 100%"); + gov = _gov; + lender = _lender; + pauseGuardian = _pauseGuardian; + escrowImplementation = _escrowImplementation; + dbr = _dbr; + collateral = _collateral; + oracle = _oracle; + collateralFactorBps = _collateralFactorBps; + replenishmentIncentiveBps = _replenishmentIncentiveBps; + liquidationIncentiveBps = _liquidationIncentiveBps; + callOnDepositCallback = _callOnDepositCallback; + INITIAL_CHAIN_ID = block.chainid; + INITIAL_DOMAIN_SEPARATOR = computeDomainSeparator(); + if(collateralFactorBps > 0){ + uint unsafeLiquidationIncentive = (10000 - collateralFactorBps) * (liquidationFeeBps + 10000) / collateralFactorBps; + require(liquidationIncentiveBps < unsafeLiquidationIncentive, "Liquidation param allow profitable self liquidation"); + } + } + + modifier onlyGov { + require(msg.sender == gov, "Only gov can call this function"); + _; + } + + modifier liquidationParamChecker { + _; + if(collateralFactorBps > 0){ + uint unsafeLiquidationIncentive = (10000 - collateralFactorBps) * (liquidationFeeBps + 10000) / collateralFactorBps; + require(liquidationIncentiveBps < unsafeLiquidationIncentive, "New liquidation param allow profitable self liquidation"); + } + } + + function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { + return block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : computeDomainSeparator(); + } + + function computeDomainSeparator() internal view virtual returns (bytes32) { + return + keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("DBR MARKET")), + keccak256("1"), + block.chainid, + address(this) + ) + ); + } + + /** + @notice sets the oracle to a new oracle. Only callable by governance. + @param _oracle The new oracle conforming to the IOracle interface. + */ + function setOracle(IOracle _oracle) public onlyGov { oracle = _oracle; } + + /** + @notice sets the borrow controller to a new borrow controller. Only callable by governance. + @param _borrowController The new borrow controller conforming to the IBorrowController interface. + */ + function setBorrowController(IBorrowController _borrowController) public onlyGov { borrowController = _borrowController; } + + /** + @notice sets the address of governance. Only callable by governance. + @param _gov Address of the new governance. + */ + function setGov(address _gov) public onlyGov { gov = _gov; } + + /** + @notice sets the lender to a new lender. The lender is allowed to recall dola from the contract. Only callable by governance. + @param _lender Address of the new lender. + */ + function setLender(address _lender) public onlyGov { lender = _lender; } + + /** + @notice sets the pause guardian. The pause guardian can pause borrowing. Only callable by governance. + @param _pauseGuardian Address of the new pauseGuardian. + */ + function setPauseGuardian(address _pauseGuardian) public onlyGov { pauseGuardian = _pauseGuardian; } + + /** + @notice sets the Collateral Factor requirement of the market as measured in basis points. 1 = 0.01%. Only callable by governance. + @dev Collateral factor mus be set below 100% + @param _collateralFactorBps The new collateral factor as measured in basis points. + */ + function setCollateralFactorBps(uint _collateralFactorBps) public onlyGov liquidationParamChecker { + require(_collateralFactorBps < 10000, "Invalid collateral factor"); + collateralFactorBps = _collateralFactorBps; + } + + /** + @notice sets the Liquidation Factor of the market as denoted in basis points. + The liquidation Factor denotes the maximum amount of debt that can be liquidated in basis points. + At 5000, 50% of of a borrower's underwater debt can be liquidated. Only callable by governance. + @dev Must be set between 1 and 10000. + @param _liquidationFactorBps The new liquidation factor in basis points. 1 = 0.01%/ + */ + function setLiquidationFactorBps(uint _liquidationFactorBps) public onlyGov { + require(_liquidationFactorBps > 0 && _liquidationFactorBps <= 10000, "Invalid liquidation factor"); + liquidationFactorBps = _liquidationFactorBps; + } + + /** + @notice sets the Replenishment Incentive of the market as denoted in basis points. + The Replenishment Incentive is the percentage paid out to replenishers on a successful forceReplenish call, denoted in basis points. + @dev Must be set between 1 and 10000. + @param _replenishmentIncentiveBps The new replenishment incentive set in basis points. 1 = 0.01% + */ + function setReplenismentIncentiveBps(uint _replenishmentIncentiveBps) public onlyGov { + require(_replenishmentIncentiveBps > 0 && _replenishmentIncentiveBps < 10000, "Invalid replenishment incentive"); + replenishmentIncentiveBps = _replenishmentIncentiveBps; + } + + /** + @notice sets the Liquidation Incentive of the market as denoted in basis points. + The Liquidation Incentive is the percentage paid out to liquidators of a borrower's debt when successfully liquidated. + @dev Must be set between 0 and 10000 - liquidation fee. + @param _liquidationIncentiveBps The new liqudation incentive set in basis points. 1 = 0.01% + */ + function setLiquidationIncentiveBps(uint _liquidationIncentiveBps) public onlyGov liquidationParamChecker { + require(_liquidationIncentiveBps > 0 && _liquidationIncentiveBps + liquidationFeeBps < 10000, "Invalid liquidation incentive"); + liquidationIncentiveBps = _liquidationIncentiveBps; + } + + /** + @notice sets the Liquidation Fee of the market as denoted in basis points. + The Liquidation Fee is the percentage paid out to governance of a borrower's debt when successfully liquidated. + @dev Must be set between 0 and 10000 - liquidation factor. + @param _liquidationFeeBps The new liquidation fee set in basis points. 1 = 0.01% + */ + function setLiquidationFeeBps(uint _liquidationFeeBps) public onlyGov liquidationParamChecker { + require(_liquidationFeeBps > 0 && _liquidationFeeBps + liquidationIncentiveBps < 10000, "Invalid liquidation fee"); + liquidationFeeBps = _liquidationFeeBps; + } + + /** + @notice Recalls amount of DOLA to the lender. + @param amount The amount od DOLA to recall to the the lender. + */ + function recall(uint amount) public { + require(msg.sender == lender, "Only lender can recall"); + dola.transfer(msg.sender, amount); + } + + /** + @notice Pauses or unpauses borrowing for the market. Only gov can unpause a market, while gov and pauseGuardian can pause it. + @param _value Boolean representing the state pause state of borrows. true = paused, false = unpaused. + */ + function pauseBorrows(bool _value) public { + if(_value) { + require(msg.sender == pauseGuardian || msg.sender == gov, "Only pause guardian or governance can pause"); + } else { + require(msg.sender == gov, "Only governance can unpause"); + } + borrowPaused = _value; + } + + /** + @notice Internal function for creating an escrow for users to deposit collateral in. + @dev Uses create2 and minimal proxies to create the escrow at a deterministic address + @param user The address of the user to create an escrow for. + */ + function createEscrow(address user) internal returns (IEscrow instance) { + address implementation = escrowImplementation; + /// @solidity memory-safe-assembly + assembly { + let ptr := mload(0x40) + mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000) + mstore(add(ptr, 0x14), shl(0x60, implementation)) + mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000) + instance := create2(0, ptr, 0x37, user) + } + require(instance != IEscrow(address(0)), "ERC1167: create2 failed"); + emit CreateEscrow(user, address(instance)); + } + + /** + @notice Internal function for getting the escrow of a user. + @dev If the escrow doesn't exist, an escrow contract is deployed. + @param user The address of the user owning the escrow. + */ + function getEscrow(address user) internal returns (IEscrow) { + if(escrows[user] != IEscrow(address(0))) return escrows[user]; + IEscrow escrow = createEscrow(user); + escrow.initialize(collateral, user); + escrows[user] = escrow; + return escrow; + } + + /** + @notice Deposit amount of collateral into escrow + @dev Will deposit the amount into the escrow contract. + @param amount Amount of collateral token to deposit. + */ + function deposit(uint amount) public { + deposit(msg.sender, amount); + } + + /** + @notice Deposit and borrow in a single transaction. + @param amountDeposit Amount of collateral token to deposit into escrow. + @param amountBorrow Amount of DOLA to borrow. + */ + function depositAndBorrow(uint amountDeposit, uint amountBorrow) public { + deposit(amountDeposit); + borrow(amountBorrow); + } + + /** + @notice Deposit amount of collateral into escrow on behalf of msg.sender + @dev Will deposit the amount into the escrow contract. + @param user User to deposit on behalf of. + @param amount Amount of collateral token to deposit. + */ + function deposit(address user, uint amount) public { + IEscrow escrow = getEscrow(user); + collateral.transferFrom(msg.sender, address(escrow), amount); + if(callOnDepositCallback) { + escrow.onDeposit(); + } + emit Deposit(user, amount); + } + + /** + @notice View function for predicting the deterministic escrow address of a user. + @dev Only use deposit() function for deposits and NOT the predicted escrow address unless you know what you're doing + @param user Address of the user owning the escrow. + */ + function predictEscrow(address user) public view returns (IEscrow predicted) { + address implementation = escrowImplementation; + address deployer = address(this); + /// @solidity memory-safe-assembly + assembly { + let ptr := mload(0x40) + mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000) + mstore(add(ptr, 0x14), shl(0x60, implementation)) + mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf3ff00000000000000000000000000000000) + mstore(add(ptr, 0x38), shl(0x60, deployer)) + mstore(add(ptr, 0x4c), user) + mstore(add(ptr, 0x6c), keccak256(ptr, 0x37)) + predicted := keccak256(add(ptr, 0x37), 0x55) + } + } + + /** + @notice View function for getting the dollar value of the user's collateral in escrow for the market. + @param user Address of the user. + */ + function getCollateralValue(address user) public view returns (uint) { + IEscrow escrow = predictEscrow(user); + uint collateralBalance = escrow.balance(); + return collateralBalance * oracle.viewPrice(address(collateral), collateralFactorBps) / 1 ether; + } + + /** + @notice Internal function for getting the dollar value of the user's collateral in escrow for the market. + @dev Updates the lowest price comparisons of the pessimistic oracle + @param user Address of the user. + */ + function getCollateralValueInternal(address user) internal returns (uint) { + IEscrow escrow = predictEscrow(user); + uint collateralBalance = escrow.balance(); + return collateralBalance * oracle.getPrice(address(collateral), collateralFactorBps) / 1 ether; + } + + /** + @notice View function for getting the credit limit of a user. + @dev To calculate the available credit, subtract user debt from credit limit. + @param user Address of the user. + */ + function getCreditLimit(address user) public view returns (uint) { + uint collateralValue = getCollateralValue(user); + return collateralValue * collateralFactorBps / 10000; + } + + /** + @notice Internal function for getting the credit limit of a user. + @dev To calculate the available credit, subtract user debt from credit limit. Updates the pessimistic oracle. + @param user Address of the user. + */ + function getCreditLimitInternal(address user) internal returns (uint) { + uint collateralValue = getCollateralValueInternal(user); + return collateralValue * collateralFactorBps / 10000; + } + /** + @notice Internal function for getting the withdrawal limit of a user. + The withdrawal limit is how much collateral a user can withdraw before their loan would be underwater. Updates the pessimistic oracle. + @param user Address of the user. + */ + function getWithdrawalLimitInternal(address user) internal returns (uint) { + IEscrow escrow = predictEscrow(user); + uint collateralBalance = escrow.balance(); + if(collateralBalance == 0) return 0; + uint debt = debts[user]; + if(debt == 0) return collateralBalance; + if(collateralFactorBps == 0) return 0; + uint minimumCollateral = debt * 1 ether / oracle.getPrice(address(collateral), collateralFactorBps) * 10000 / collateralFactorBps; + if(collateralBalance <= minimumCollateral) return 0; + return collateralBalance - minimumCollateral; + } + + /** + @notice View function for getting the withdrawal limit of a user. + The withdrawal limit is how much collateral a user can withdraw before their loan would be underwater. + @param user Address of the user. + */ + function getWithdrawalLimit(address user) public view returns (uint) { + IEscrow escrow = predictEscrow(user); + uint collateralBalance = escrow.balance(); + if(collateralBalance == 0) return 0; + uint debt = debts[user]; + if(debt == 0) return collateralBalance; + if(collateralFactorBps == 0) return 0; + uint minimumCollateral = debt * 1 ether / oracle.viewPrice(address(collateral), collateralFactorBps) * 10000 / collateralFactorBps; + if(collateralBalance <= minimumCollateral) return 0; + return collateralBalance - minimumCollateral; + } + + /** + @notice Internal function for borrowing DOLA against collateral. + @dev This internal function is shared between the borrow and borrowOnBehalf function + @param borrower The address of the borrower that debt will be accrued to. + @param to The address that will receive the borrowed DOLA + @param amount The amount of DOLA to be borrowed + */ + function borrowInternal(address borrower, address to, uint amount) internal { + require(!borrowPaused, "Borrowing is paused"); + if(borrowController != IBorrowController(address(0))) { + require(borrowController.borrowAllowed(msg.sender, borrower, amount), "Denied by borrow controller"); + } + uint credit = getCreditLimitInternal(borrower); + debts[borrower] += amount; + require(credit >= debts[borrower], "Exceeded credit limit"); + totalDebt += amount; + dbr.onBorrow(borrower, amount); + dola.transfer(to, amount); + emit Borrow(borrower, amount); + } + + /** + @notice Function for borrowing DOLA. + @dev Will borrow to msg.sender + @param amount The amount of DOLA to be borrowed. + */ + function borrow(uint amount) public { + borrowInternal(msg.sender, msg.sender, amount); + } + + /** + @notice Function for using a signed message to borrow on behalf of an address owning an escrow with collateral. + @dev Signed messaged can be invalidated by incrementing the nonce. Will always borrow to the msg.sender. + @param from The address of the user being borrowed from + @param amount The amount to be borrowed + @param deadline Timestamp after which the signed message will be invalid + @param v The v param of the ECDSA signature + @param r The r param of the ECDSA signature + @param s The s param of the ECDSA signature + */ + function borrowOnBehalf(address from, uint amount, uint deadline, uint8 v, bytes32 r, bytes32 s) public { + require(deadline >= block.timestamp, "DEADLINE_EXPIRED"); + unchecked { + address recoveredAddress = ecrecover( + keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + msg.sender, + from, + amount, + nonces[from]++, + deadline + ) + ) + ) + ), + v, + r, + s + ); + require(recoveredAddress != address(0) && recoveredAddress == from, "INVALID_SIGNER"); + borrowInternal(from, msg.sender, amount); + } + } + + /** + @notice Internal function for withdrawing from the escrow + @dev The internal function is shared by the withdraw function and withdrawOnBehalf function + @param from The address owning the escrow to withdraw from. + @param to The address receiving the tokens + @param amount The amount being withdrawn. + */ + function withdrawInternal(address from, address to, uint amount) internal { + uint limit = getWithdrawalLimitInternal(from); + require(limit >= amount, "Insufficient withdrawal limit"); + require(dbr.deficitOf(from) == 0, "Can't withdraw with DBR deficit"); + IEscrow escrow = getEscrow(from); + escrow.pay(to, amount); + emit Withdraw(from, to, amount); + } + + /** + @notice Function for withdrawing to msg.sender. + @param amount Amount to withdraw. + */ + function withdraw(uint amount) public { + withdrawInternal(msg.sender, msg.sender, amount); + } + + /** + @notice Function for using a signed message to withdraw on behalf of an address owning an escrow with collateral. + @dev Signed messaged can be invalidated by incrementing the nonce. Will always withdraw to the msg.sender. + @param from The address of the user owning the escrow being withdrawn from + @param amount The amount to be withdrawn + @param deadline Timestamp after which the signed message will be invalid + @param v The v param of the ECDSA signature + @param r The r param of the ECDSA signature + @param s The s param of the ECDSA signature + */ + function withdrawOnBehalf(address from, uint amount, uint deadline, uint8 v, bytes32 r, bytes32 s) public { + require(deadline >= block.timestamp, "DEADLINE_EXPIRED"); + unchecked { + address recoveredAddress = ecrecover( + keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + msg.sender, + from, + amount, + nonces[from]++, + deadline + ) + ) + ) + ), + v, + r, + s + ); + require(recoveredAddress != address(0) && recoveredAddress == from, "INVALID_SIGNER"); + withdrawInternal(from, msg.sender, amount); + } + } + + /** + @notice Function for incrementing the nonce of the msg.sender, making their latest signed message unusable. + */ + function invalidateNonce() public { + nonces[msg.sender]++; + } + + /** + @notice Function for repaying debt on behalf of user. Debt must be repaid in DOLA. + @dev If the user has a DBR deficit, they risk initial debt being accrued by forced replenishments. + @param user Address of the user whose debt is being repaid + @param amount DOLA amount to be repaid. If set to max uint debt will be repaid in full. + */ + function repay(address user, uint amount) public { + uint debt = debts[user]; + if(amount == type(uint).max){ + amount = debt; + } + require(debt >= amount, "Repayment greater than debt"); + debts[user] -= amount; + totalDebt -= amount; + dbr.onRepay(user, amount); + if(address(borrowController) != address(0)){ + borrowController.onRepay(amount); + } + dola.transferFrom(msg.sender, address(this), amount); + emit Repay(user, msg.sender, amount); + } + + /** + @notice Bundles repayment and withdrawal into a single function call. + @param repayAmount Amount of DOLA to be repaid + @param withdrawAmount Amount of underlying to be withdrawn from the escrow + */ + function repayAndWithdraw(uint repayAmount, uint withdrawAmount) public { + repay(msg.sender, repayAmount); + withdraw(withdrawAmount); + } + + /** + @notice Function for forcing a user to replenish their DBR deficit at a pre-determined price. + The replenishment will accrue additional DOLA debt. + On a successful call, the caller will be paid a replenishment incentive. + @dev The function will only top the user back up to 0, meaning that the user will have a DBR deficit again in the next block. + @param user The address of the user being forced to replenish DBR + @param amount The amount of DBR the user will be replenished. + */ + function forceReplenish(address user, uint amount) public { + uint deficit = dbr.deficitOf(user); + require(deficit > 0, "No DBR deficit"); + require(deficit >= amount, "Amount > deficit"); + uint replenishmentCost = amount * dbr.replenishmentPriceBps() / 10000; + uint replenisherReward = replenishmentCost * replenishmentIncentiveBps / 10000; + debts[user] += replenishmentCost; + uint collateralValue = getCollateralValueInternal(user) * (10000 - liquidationIncentiveBps - liquidationFeeBps) / 10000; + require(collateralValue >= debts[user], "Exceeded collateral value"); + totalDebt += replenishmentCost; + dbr.onForceReplenish(user, msg.sender, amount, replenisherReward); + dola.transfer(msg.sender, replenisherReward); + } + /** + @notice Function for forcing a user to replenish all of their DBR deficit at a pre-determined price. + The replenishment will accrue additional DOLA debt. + On a successful call, the caller will be paid a replenishment incentive. + @dev The function will only top the user back up to 0, meaning that the user will have a DBR deficit again in the next block. + @param user The address of the user being forced to replenish DBR + */ + function forceReplenishAll(address user) public { + uint deficit = dbr.deficitOf(user); + forceReplenish(user, deficit); + } + + /** + @notice Function for liquidating a user's under water debt. Debt is under water when the value of a user's debt is above their collateral factor. + @param user The user to be liquidated + @param repaidDebt Th amount of user user debt to liquidate. + */ + function liquidate(address user, uint repaidDebt) public { + require(repaidDebt > 0, "Must repay positive debt"); + uint debt = debts[user]; + require(getCreditLimitInternal(user) < debt, "User debt is healthy"); + require(repaidDebt <= debt * liquidationFactorBps / 10000, "Exceeded liquidation factor"); + uint price = oracle.getPrice(address(collateral), collateralFactorBps); + uint liquidatorReward = repaidDebt * 1 ether / price; + liquidatorReward += liquidatorReward * liquidationIncentiveBps / 10000; + debts[user] -= repaidDebt; + totalDebt -= repaidDebt; + dbr.onRepay(user, repaidDebt); + dola.transferFrom(msg.sender, address(this), repaidDebt); + IEscrow escrow = predictEscrow(user); + escrow.pay(msg.sender, liquidatorReward); + if(liquidationFeeBps > 0) { + uint liquidationFee = repaidDebt * 1 ether / price * liquidationFeeBps / 10000; + uint balance = escrow.balance(); + if(balance >= liquidationFee) { + escrow.pay(gov, liquidationFee); + } else if(balance > 0) { + escrow.pay(gov, balance); + } + } + emit Liquidate(user, msg.sender, repaidDebt, liquidatorReward); + } + + event Deposit(address indexed account, uint amount); + event Borrow(address indexed account, uint amount); + event Withdraw(address indexed account, address indexed to, uint amount); + event Repay(address indexed account, address indexed repayer, uint amount); + event Liquidate(address indexed account, address indexed liquidator, uint repaidDebt, uint liquidatorReward); + event CreateEscrow(address indexed user, address escrow); +} diff --git a/src/Oracle.sol b/src/Oracle.sol new file mode 100644 index 00000000..9631886e --- /dev/null +++ b/src/Oracle.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +interface IChainlinkFeed { + function decimals() external view returns (uint8); + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80); +} + +/** +@title Oracle +@notice Oracle used by markets. Uses Chainlink-style feeds for prices. +The Pessimistic Oracle introduces collateral factor into the pricing formula. It ensures that any given oracle price is dampened to prevent borrowers from borrowing more than the lowest recorded value of their collateral over the past 2 days. +This has the advantage of making price manipulation attacks more difficult, as an attacker needs to log artificially high lows. +It has the disadvantage of reducing borrow power of borrowers to a 2-day minimum value of their collateral, where the value must have been seen by the oracle. +*/ +contract Oracle { + + struct FeedData { + IChainlinkFeed feed; + uint8 tokenDecimals; + } + + address public operator; + address public pendingOperator; + mapping (address => FeedData) public feeds; + mapping (address => mapping(uint => uint)) public dailyLows; // token => day => price + + constructor( + address _operator + ) { + operator = _operator; + } + + modifier onlyOperator { + require(msg.sender == operator, "ONLY OPERATOR"); + _; + } + + /** + @notice Sets the pending operator of the oracle. Only callable by operator. + @param newOperator_ The address of the pending operator. + */ + function setPendingOperator(address newOperator_) public onlyOperator { pendingOperator = newOperator_; } + + /** + @notice Sets the price feed of a specific token address. + @dev Even though the price feeds implement the chainlink interface, it's possible to use other price oracle. + @param token Address of the ERC20 token to set a feed for + @param feed The chainlink feed of the ERC20 token. + @param tokenDecimals uint8 representing the decimal precision of the token + */ + function setFeed(address token, IChainlinkFeed feed, uint8 tokenDecimals) public onlyOperator { feeds[token] = FeedData(feed, tokenDecimals); } + + /** + @notice Claims the operator role. Only successfully callable by the pending operator. + */ + function claimOperator() public { + require(msg.sender == pendingOperator, "ONLY PENDING OPERATOR"); + operator = pendingOperator; + pendingOperator = address(0); + emit ChangeOperator(operator); + } + + /** + @notice Gets the price of a specific token in DOLA + @param token The address of the token to get price of + @return The price of the token in DOLA, adjusted for token and feed decimals + */ + function viewPrice(address token, uint collateralFactorBps) external view returns (uint) { + if(feeds[token].feed != IChainlinkFeed(address(0))) { + + //get normalized price + uint normalizedPrice = getNormalizedPrice(token); + uint day = block.timestamp / 1 days; + // get today's low + uint todaysLow = dailyLows[token][day]; + if(todaysLow == 0 || normalizedPrice < todaysLow) { + todaysLow = normalizedPrice; + } + // if collateralFactorBps is 0, return normalizedPrice; + if(collateralFactorBps == 0) return normalizedPrice; + // get yesterday's low + uint yesterdaysLow = dailyLows[token][day - 1]; + // calculate new borrowing power based on collateral factor + uint newBorrowingPower = normalizedPrice * collateralFactorBps / 10000; + uint twoDayLow = todaysLow > yesterdaysLow && yesterdaysLow > 0 ? yesterdaysLow : todaysLow; + if(twoDayLow > 0 && newBorrowingPower > twoDayLow) { + uint dampenedPrice = twoDayLow * 10000 / collateralFactorBps; + return dampenedPrice < normalizedPrice ? dampenedPrice: normalizedPrice; + } + return normalizedPrice; + + } + revert("Price not found"); + } + + /** + @notice Gets the price of a specific token in DOLA while also saving the price if it is the day's lowest. + @param token The address of the token to get price of + @return The price of the token in DOLA, adjusted for token and feed decimals + */ + function getPrice(address token, uint collateralFactorBps) external returns (uint) { + if(feeds[token].feed != IChainlinkFeed(address(0))) { + // get normalized price + uint normalizedPrice = getNormalizedPrice(token); + // potentially store price as today's low + uint day = block.timestamp / 1 days; + uint todaysLow = dailyLows[token][day]; + if(todaysLow == 0 || normalizedPrice < todaysLow) { + dailyLows[token][day] = normalizedPrice; + todaysLow = normalizedPrice; + emit RecordDailyLow(token, normalizedPrice); + } + // if collateralFactorBps is 0, return normalizedPrice; + if(collateralFactorBps == 0) return normalizedPrice; + // get yesterday's low + uint yesterdaysLow = dailyLows[token][day - 1]; + // calculate new borrowing power based on collateral factor + uint newBorrowingPower = normalizedPrice * collateralFactorBps / 10000; + uint twoDayLow = todaysLow > yesterdaysLow && yesterdaysLow > 0 ? yesterdaysLow : todaysLow; + if(twoDayLow > 0 && newBorrowingPower > twoDayLow) { + uint dampenedPrice = twoDayLow * 10000 / collateralFactorBps; + return dampenedPrice < normalizedPrice ? dampenedPrice: normalizedPrice; + } + return normalizedPrice; + + } + revert("Price not found"); + } + + /** + @notice Gets the price from the price feed and normalizes it. + @param token The token to get the normalized price for. + @return normalizedPrice Returns the normalized price. + */ + function getNormalizedPrice(address token) internal view returns (uint normalizedPrice) { + //get price from feed + uint price = getFeedPrice(token); + // normalize price + uint8 feedDecimals = feeds[token].feed.decimals(); + uint8 tokenDecimals = feeds[token].tokenDecimals; + if(feedDecimals + tokenDecimals <= 36) { + uint8 decimals = 36 - feedDecimals - tokenDecimals; + normalizedPrice = price * (10 ** decimals); + } else { + uint8 decimals = feedDecimals + tokenDecimals - 36; + normalizedPrice = price / 10 ** decimals; + } + + } + + /** + @notice returns the underlying feed price of the given token address + @dev Will revert if price is negative or token is not in the oracle + @param token The address of the token to get the price of + @return Return the unaltered price of the underlying token + */ + function getFeedPrice(address token) public view returns(uint) { + (,int256 price,,,) = feeds[token].feed.latestRoundData(); + require(price > 0, "Invalid feed price"); + return uint(price); + } + + event ChangeOperator(address indexed newOperator); + event RecordDailyLow(address indexed token, uint price); + +} diff --git a/src/escrows/GovTokenEscrow.sol b/src/escrows/GovTokenEscrow.sol new file mode 100644 index 00000000..4c4ee168 --- /dev/null +++ b/src/escrows/GovTokenEscrow.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +// Caution. We assume all failed transfers cause reverts and ignore the returned bool. +interface IERC20 { + function transfer(address,uint) external returns (bool); + function transferFrom(address,address,uint) external returns (bool); + function balanceOf(address) external view returns (uint); + function delegate(address delegatee) external; + function delegates(address delegator) external view returns (address delegatee); +} + +/** +@title Gov Token Escrow +@notice Collateral is stored in unique escrow contracts for every user and every market. + This specific escrow is meant as an example of how an escrow can be implemented that allows depositors to delegate votes with their collateral, unlike pooled deposit protocols. +@dev Caution: This is a proxy implementation. Follow proxy pattern best practices +*/ +contract GovTokenEscrow { + address public market; + IERC20 public token; + address public beneficiary; + + /** + @notice Initialize escrow with a token + @dev Must be called right after proxy is created. + @param _token The IERC20 token representing the governance token + @param _beneficiary The beneficiary who may delegate token voting power + */ + function initialize(IERC20 _token, address _beneficiary) public { + require(market == address(0), "ALREADY INITIALIZED"); + market = msg.sender; + token = _token; + beneficiary = _beneficiary; + _token.delegate(_token.delegates(_beneficiary)); + } + + /** + @notice Transfers the associated ERC20 token to a recipient. + @param recipient The address to receive payment from the escrow + @param amount The amount of ERC20 token to be transferred. + */ + function pay(address recipient, uint amount) public { + require(msg.sender == market, "ONLY MARKET"); + token.transfer(recipient, amount); + } + + /** + @notice Get the token balance of the escrow + @return Uint representing the INV token balance of the escrow including the additional INV accrued from xINV + */ + function balance() public view returns (uint) { + return token.balanceOf(address(this)); + } + + /** + @notice Function called by market on deposit. Function is empty for this escrow. + @dev This function should remain callable by anyone to handle direct inbound transfers. + */ + function onDeposit() public { + + } + + /** + @notice Delegates voting power of the underlying xINV. + @param delegatee The address to be delegated voting power + */ + function delegate(address delegatee) public { + require(msg.sender == beneficiary); + token.delegate(delegatee); + } +} diff --git a/src/escrows/INVEscrow.sol b/src/escrows/INVEscrow.sol new file mode 100644 index 00000000..376bb479 --- /dev/null +++ b/src/escrows/INVEscrow.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +// @dev Caution: We assume all failed transfers cause reverts and ignore the returned bool. +interface IERC20 { + function transfer(address,uint) external returns (bool); + function transferFrom(address,address,uint) external returns (bool); + function balanceOf(address) external view returns (uint); + function approve(address, uint) external view returns (uint); + function delegate(address delegatee) external; + function delegates(address delegator) external view returns (address delegatee); +} + +interface IXINV { + function balanceOf(address) external view returns (uint); + function exchangeRateStored() external view returns (uint); + function mint(uint mintAmount) external returns (uint); + function redeemUnderlying(uint redeemAmount) external returns (uint); + function syncDelegate(address user) external; +} + +/** +@title INV Escrow +@notice Collateral is stored in unique escrow contracts for every user and every market. + This escrow allows user to deposit INV collateral directly into the xINV contract, earning APY and allowing them to delegate votes on behalf of the xINV collateral +@dev Caution: This is a proxy implementation. Follow proxy pattern best practices +*/ +contract INVEscrow { + address public market; + IERC20 public token; + address public beneficiary; + IXINV public immutable xINV; + + constructor(IXINV _xINV) { + xINV = _xINV; // TODO: Test whether an immutable variable will persist across proxies + } + + /** + @notice Initialize escrow with a token + @dev Must be called right after proxy is created. + @param _token The IERC20 token representing the INV governance token + @param _beneficiary The beneficiary who may delegate token voting power + */ + function initialize(IERC20 _token, address _beneficiary) public { + require(market == address(0), "ALREADY INITIALIZED"); + market = msg.sender; + token = _token; + beneficiary = _beneficiary; + _token.delegate(_token.delegates(_beneficiary)); + _token.approve(address(xINV), type(uint).max); + xINV.syncDelegate(address(this)); + } + + /** + @notice Transfers the associated ERC20 token to a recipient. + @param recipient The address to receive payment from the escrow + @param amount The amount of ERC20 token to be transferred. + */ + function pay(address recipient, uint amount) public { + require(msg.sender == market, "ONLY MARKET"); + uint invBalance = token.balanceOf(address(this)); + if(invBalance < amount) xINV.redeemUnderlying(amount - invBalance); // we do not check return value because next call will fail if this fails anyway + token.transfer(recipient, amount); + } + + /** + @notice Get the token balance of the escrow + @return Uint representing the INV token balance of the escrow including the additional INV accrued from xINV + */ + function balance() public view returns (uint) { + uint invBalance = token.balanceOf(address(this)); + uint invBalanceInXInv = xINV.balanceOf(address(this)) * xINV.exchangeRateStored() / 1 ether; + return invBalance + invBalanceInXInv; + } + /** + @notice Function called by market on deposit. Will deposit INV into xINV + @dev This function should remain callable by anyone to handle direct inbound transfers. + */ + function onDeposit() public { + uint invBalance = token.balanceOf(address(this)); + if(invBalance > 0) { + xINV.mint(invBalance); // we do not check return value because we don't want errors to block this call + } + } + + /** + @notice Delegates voting power of the underlying xINV. + @param delegatee The address to be delegated voting power + */ + function delegate(address delegatee) public { + require(msg.sender == beneficiary); + token.delegate(delegatee); + xINV.syncDelegate(address(this)); + } +} diff --git a/src/escrows/SimpleERC20Escrow.sol b/src/escrows/SimpleERC20Escrow.sol new file mode 100644 index 00000000..25d02221 --- /dev/null +++ b/src/escrows/SimpleERC20Escrow.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +/// @dev Caution: We assume all failed transfers cause reverts and ignore the returned bool. +interface IERC20 { + function transfer(address,uint) external returns (bool); + function transferFrom(address,address,uint) external returns (bool); + function balanceOf(address) external view returns (uint); +} + +/** +@title Simple ERC20 Escrow +@notice Collateral is stored in unique escrow contracts for every user and every market. +@dev Caution: This is a proxy implementation. Follow proxy pattern best practices +*/ +contract SimpleERC20Escrow { + address public market; + IERC20 public token; + + /** + @notice Initialize escrow with a token + @dev Must be called right after proxy is created + @param _token The IERC20 token to be stored in this specific escrow + */ + function initialize(IERC20 _token, address) public { + require(market == address(0), "ALREADY INITIALIZED"); + market = msg.sender; + token = _token; + } + + /** + @notice Transfers the associated ERC20 token to a recipient. + @param recipient The address to receive payment from the escrow + @param amount The amount of ERC20 token to be transferred. + */ + function pay(address recipient, uint amount) public { + require(msg.sender == market, "ONLY MARKET"); + token.transfer(recipient, amount); + } + + /** + @notice Get the token balance of the escrow + @return Uint representing the token balance of the escrow + */ + function balance() public view returns (uint) { + return token.balanceOf(address(this)); + } + + /** + @notice Function called by market on deposit. Function is empty for this escrow. + @dev This function should remain callable by anyone to handle direct inbound transfers. + */ + function onDeposit() public { + + } +} diff --git a/src/test/BorrowController.t.sol b/src/test/BorrowController.t.sol new file mode 100644 index 00000000..6df9897d --- /dev/null +++ b/src/test/BorrowController.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../BorrowController.sol"; +import "./mocks/BorrowContract.sol"; +import "./FrontierV2Test.sol"; +import "../Market.sol"; +import "./mocks/WETH9.sol"; + +contract BorrowContractTxOrigin { + + uint256 constant AMOUNT = 1 ether; + uint256 constant PRICE = 1000; + uint256 constant COLLATERAL_FACTOR_BPS = 8500; + uint256 constant BPS_BASIS = 10_000; + + constructor(Market market, WETH9 weth) payable { + weth.approve(address(market), type(uint).max); + weth.deposit{value: msg.value}(); + market.deposit(address(this), AMOUNT); + market.borrow(AMOUNT * COLLATERAL_FACTOR_BPS * PRICE / BPS_BASIS); + } +} + +contract BorrowControllerTest is FrontierV2Test { + BorrowContract borrowContract; + bytes onlyOperatorLowercase = "Only operator"; + + function setUp() public { + initialize(replenishmentPriceBps, collateralFactorBps, replenishmentIncentiveBps, liquidationBonusBps, callOnDepositCallback); + + borrowContract = new BorrowContract(address(market), payable(address(WETH))); + require(address(market.borrowController()) != address(0), "Borrow controller not set"); + } + + function test_BorrowAllowed_True_Where_UserIsEOA() public { + vm.startPrank(user, user); + assertEq(borrowController.borrowAllowed(user, address(0), 0), true, "EOA not allowed to borrow"); + } + + function test_BorrowAllowed_False_Where_UserIsUnallowedContract() public { + assertEq(borrowController.borrowAllowed(address(borrowContract),address(0), 0), false, "Unallowed contract allowed to borrow"); + } + + function test_BorrowAllowed_True_Where_UserIsAllowedContract() public { + vm.startPrank(gov); + borrowController.allow(address(borrowContract)); + vm.stopPrank(); + + assertEq(borrowController.borrowAllowed(address(borrowContract), address(0), 0), true, "Allowed contract not allowed to borrow"); + } + + function test_Allow_Successfully_AddsAddressToAllowlist() public { + bool allowed = borrowController.contractAllowlist(address(borrowContract)); + assertEq(allowed, false, "Contract was allowed before call to allow"); + + vm.startPrank(gov); + borrowController.allow(address(borrowContract)); + vm.stopPrank(); + + assertEq(borrowController.contractAllowlist(address(borrowContract)), true, "Contract was not added to allowlist successfully"); + } + + function test_Deny_Successfully_RemovesAddressFromAllowlist() public { + test_Allow_Successfully_AddsAddressToAllowlist(); + + vm.startPrank(gov); + borrowController.deny(address(borrowContract)); + + assertEq(borrowController.contractAllowlist(address(borrowContract)), false, "Contract was not removed from allowlist successfully"); + } + + function test_BorrowAllowed_False_Where_UserIsUnallowedContractCallingFromConstructor() public { + vm.startPrank(chair); + fed.expansion(IMarket(address(market)), convertWethToDola(1 ether)); + vm.stopPrank(); + vm.deal(address(0xA), 1 ether); + vm.startPrank(address(0xA), address(0xA)); + bytes memory denied = "Denied by borrow controller"; + vm.expectRevert(denied); + new BorrowContractTxOrigin{value:1 ether}(market, WETH); + } + + //Access Control + + function test_accessControl_setOperator() public { + vm.startPrank(gov); + borrowController.setOperator(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyOperatorLowercase); + borrowController.setOperator(address(0)); + } + + function test_accessControl_allow() public { + vm.startPrank(gov); + borrowController.allow(address(0)); + + vm.stopPrank(); + + vm.expectRevert(onlyOperatorLowercase); + borrowController.allow(address(0)); + } + + function test_accessControl_deny() public { + vm.startPrank(gov); + borrowController.deny(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyOperatorLowercase); + borrowController.deny(address(0)); + } +} diff --git a/src/test/DBR.t.sol b/src/test/DBR.t.sol new file mode 100644 index 00000000..2072a719 --- /dev/null +++ b/src/test/DBR.t.sol @@ -0,0 +1,444 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../DBR.sol"; +import "./FrontierV2Test.sol"; + +contract DBRTest is FrontierV2Test { + address operator; + + bytes onlyPendingOperator = "ONLY PENDING OPERATOR"; + bytes onlyMinterOperator = "ONLY MINTERS OR OPERATOR"; + bytes onBorrowError = "Only markets can call onBorrow"; + bytes onRepayError = "Only markets can call onRepay"; + bytes onForceReplenishError = "Only markets can call onForceReplenish"; + + function setUp() public { + vm.label(gov, "operator"); + operator = gov; + + initialize(replenishmentPriceBps, collateralFactorBps, replenishmentIncentiveBps, liquidationBonusBps, callOnDepositCallback); + + vm.startPrank(chair); + fed.expansion(IMarket(address(market)), 1_000_000e18); + vm.stopPrank(); + } + + function testOnBorrow_Reverts_When_AccrueDueTokensBringsUserDbrBelow0() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + uint borrowAmount = wethTestAmount * ethFeed.latestAnswer() * collateralFactorBps / 1e18 / 10_000; + market.borrow(borrowAmount / 2); + + vm.warp(block.timestamp + 7 days); + + vm.expectRevert("DBR Deficit"); + market.borrow(borrowAmount / 2); + } + + function test_BalanceFunctions_ReturnCorrectBalance_WhenAddressHasDeficit() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount / 20); + + vm.startPrank(user, user); + deposit(wethTestAmount); + uint borrowAmount = 1 ether; + market.borrow(borrowAmount); + + vm.warp(block.timestamp + 365 days); + + assertEq(dbr.balanceOf(user), 0, "balanceOf should be 0 when user has deficit"); + //We give user 0.05 DBR. Borrow 1 DOLA for 1 year, expect to pay 1 DBR. -0.95 DBR should be the deficit. + assertEq(dbr.deficitOf(user), borrowAmount * 19 / 20, "incorrect deficitOf"); + assertEq(dbr.signedBalanceOf(user), int(0) - int(dbr.deficitOf(user)), "signedBalanceOf should equal negative deficitOf when there is a deficit"); + + //ensure balances are the same after accrueDueTokens is called + dbr.accrueDueTokens(user); + assertEq(dbr.balanceOf(user), 0, "balanceOf should be 0 when user has deficit"); + assertEq(dbr.deficitOf(user), borrowAmount * 19 / 20, "incorrect deficitOf"); + assertEq(dbr.signedBalanceOf(user), int(0) - int(dbr.deficitOf(user)), "signedBalanceOf should equal negative deficitOf when there is a deficit"); + } + + function test_BalanceFunctions_ReturnCorrectBalance_WhenAddressHasPositiveBalance() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount * 2); + + vm.startPrank(user, user); + deposit(wethTestAmount); + + uint borrowAmount = wethTestAmount; + market.borrow(borrowAmount); + + vm.warp(block.timestamp + 365 days); + + assertEq(dbr.deficitOf(user), 0, "deficitOf should be 0 when user has deficit"); + //We give user 2 DBR. Borrow 1 DOLA for 1 year, expect to pay 1 DBR. 1 DBR should be left as the balance. + assertEq(dbr.balanceOf(user), borrowAmount, "incorrect dbr balance"); + assertEq(dbr.signedBalanceOf(user), int(dbr.balanceOf(user)), "signedBalanceOf should equal balanceOf when there is a positive balance"); + + //ensure balances are the same after accrueDueTokens is called + dbr.accrueDueTokens(user); + assertEq(dbr.deficitOf(user), 0, "deficitOf should be 0 when user has deficit"); + assertEq(dbr.balanceOf(user), borrowAmount, "incorrect dbr balance"); + assertEq(dbr.signedBalanceOf(user), int(dbr.balanceOf(user)), "signedBalanceOf should equal balanceOf when there is a positive balance"); + } + + function test_BalanceFunctions_ReturnCorrectBalance_WhenAddressHasZeroBalance() public { + assertEq(dbr.deficitOf(user), 0, "deficitOf should be 0 when user has no balance"); + assertEq(dbr.balanceOf(user), 0, "balanceOf should be 0 when user has no balance"); + assertEq(dbr.signedBalanceOf(user), 0, "signedBalanceOf should be 0 when user has no balance"); + + //ensure balances are the same after accrueDueTokens is called + dbr.accrueDueTokens(user); + assertEq(dbr.deficitOf(user), 0, "deficitOf should be 0 when user has no balance"); + assertEq(dbr.balanceOf(user), 0, "balanceOf should be 0 when user has no balance"); + assertEq(dbr.signedBalanceOf(user), 0, "signedBalanceOf should be 0 when user has no balance"); + } + + function test_burn() public { + vm.startPrank(operator); + dbr.mint(user, 1e18); + vm.stopPrank(); + + assertEq(dbr.totalSupply(), 1e18, "dbr mint failed"); + assertEq(dbr.balanceOf(user), 1e18, "dbr mint failed"); + + vm.startPrank(user, user); + dbr.burn(1e18); + + assertEq(dbr.totalSupply(), 0, "dbr burn failed"); + assertEq(dbr.balanceOf(user), 0, "dbr burn failed"); + } + + function test_burn_reverts_whenAmountGtCallerBalance() public { + gibDBR(user, 1e18); + + vm.startPrank(user, user); + vm.expectRevert("Insufficient balance"); + dbr.burn(2e18); + } + + function test_totalSupply() public { + vm.startPrank(operator); + dbr.mint(user, 100e18); + + assertEq(dbr.totalSupply(), 100e18, "Incorrect total supply"); + } + + function test_totalSupply_returns0_whenTotalDueTokensAccruedGtSupply() public { + gibWeth(user, wethTestAmount); + gibDBR(user, 1); + + vm.startPrank(user, user); + deposit(wethTestAmount); + + market.borrow(getMaxBorrowAmount(wethTestAmount)); + vm.warp(block.timestamp + 365 days); + + dbr.accrueDueTokens(user); + assertEq(dbr.totalSupply(), 0, "Incorrect total supply"); + } + + function test_invalidateNonce() public { + assertEq(dbr.nonces(user), 0, "User nonce should be uninitialized"); + + vm.startPrank(user, user); + dbr.invalidateNonce(); + + assertEq(dbr.nonces(user), 1, "User nonce was not invalidated"); + } + + function test_approve_increasesAllowanceByAmount() public { + uint amount = 100e18; + + assertEq(dbr.allowance(user, gov), 0, "Allowance should not be set yet"); + + vm.startPrank(user, user); + dbr.approve(gov, amount); + + assertEq(dbr.allowance(user, gov), amount, "Allowance was not set properly"); + } + + function test_permit_increasesAllowanceByAmount() public { + uint amount = 100e18; + address userPk = vm.addr(1); + + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + dbr.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ), + userPk, + gov, + amount, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + assertEq(dbr.allowance(userPk, gov), 0, "Allowance should not be set yet"); + + vm.startPrank(gov); + dbr.permit(userPk, gov, amount, block.timestamp, v, r, s); + + assertEq(dbr.allowance(userPk, gov), amount, "Allowance was not set properly"); + } + + function test_permit_reverts_whenDeadlinesHasPassed() public { + uint amount = 100e18; + address userPk = vm.addr(1); + + uint timestamp = block.timestamp; + + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + dbr.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ), + userPk, + gov, + amount, + 0, + timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + assertEq(dbr.allowance(userPk, gov), 0, "Allowance should not be set yet"); + + vm.startPrank(gov); + vm.warp(block.timestamp + 1); + vm.expectRevert("PERMIT_DEADLINE_EXPIRED"); + dbr.permit(userPk, gov, amount, timestamp, v, r, s); + } + + function test_permit_reverts_whenNonceInvaidated() public { + uint amount = 100e18; + address userPk = vm.addr(1); + + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + dbr.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" + ), + userPk, + gov, + amount, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + vm.startPrank(userPk); + dbr.invalidateNonce(); + vm.stopPrank(); + + assertEq(dbr.allowance(userPk, gov), 0, "Allowance should not be set yet"); + + vm.startPrank(gov); + vm.expectRevert("INVALID_SIGNER"); + dbr.permit(userPk, gov, amount, block.timestamp, v, r, s); + } + + function test_transfer() public { + uint amount = 100e18; + + vm.startPrank(operator); + dbr.mint(user, amount * 2); + vm.stopPrank(); + + assertEq(dbr.balanceOf(user), amount * 2); + assertEq(dbr.balanceOf(gov), 0); + vm.startPrank(user, user); + dbr.transfer(gov, amount); + assertEq(dbr.balanceOf(user), amount); + assertEq(dbr.balanceOf(gov), amount); + } + + function test_transfer_reverts_whenAmountGtCallerBalance() public { + uint amount = 100e18; + + vm.startPrank(operator); + dbr.mint(user, amount / 2); + vm.stopPrank(); + + vm.startPrank(user, user); + vm.expectRevert("Insufficient balance"); + dbr.transfer(gov, amount); + } + + function test_transferFrom() public { + uint amount = 100e18; + + vm.startPrank(operator); + dbr.mint(user, amount * 2); + vm.stopPrank(); + + assertEq(dbr.balanceOf(user), amount * 2); + assertEq(dbr.balanceOf(gov), 0); + + vm.startPrank(user, user); + dbr.approve(gov, amount); + vm.stopPrank(); + + vm.startPrank(gov); + dbr.transferFrom(user, gov, amount); + + assertEq(dbr.balanceOf(user), amount); + assertEq(dbr.balanceOf(gov), amount); + } + + function test_transferFrom_reverts_whenAmountGtFromBalance() public { + uint amount = 100e18; + + vm.startPrank(user, user); + dbr.approve(gov, amount); + vm.stopPrank(); + + vm.startPrank(gov); + vm.expectRevert("Insufficient balance"); + dbr.transferFrom(user, gov, amount); + } + + function test_transferFrom_reverts_whenAmountGtAllowance() public { + uint amount = 100e18; + + vm.startPrank(operator); + dbr.mint(user, amount * 2); + vm.stopPrank(); + + vm.startPrank(user, user); + dbr.approve(gov, amount); + vm.stopPrank(); + + vm.startPrank(gov); + vm.expectRevert(); + dbr.transferFrom(user, gov, amount * 2); + } + + //Access Control + function test_accessControl_setPendingOperator() public { + vm.startPrank(operator); + dbr.setPendingOperator(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyOperator); + dbr.setPendingOperator(address(0)); + } + + function test_accessControl_claimOperator() public { + vm.startPrank(operator); + dbr.setPendingOperator(user); + vm.stopPrank(); + + vm.startPrank(user2); + vm.expectRevert(onlyPendingOperator); + dbr.claimOperator(); + vm.stopPrank(); + + vm.startPrank(user, user); + dbr.claimOperator(); + assertEq(dbr.operator(), user, "Call to claimOperator failed"); + } + + function test_accessControl_setReplenishmentPriceBps() public { + vm.startPrank(operator); + dbr.setReplenishmentPriceBps(100); + + vm.expectRevert("replenishment price must be over 0"); + dbr.setReplenishmentPriceBps(0); + vm.stopPrank(); + + vm.expectRevert(onlyOperator); + dbr.setReplenishmentPriceBps(100); + } + + function test_accessControl_addMinter() public { + vm.startPrank(operator); + dbr.addMinter(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyOperator); + dbr.addMinter(address(0)); + } + + function test_accessControl_removeMinter() public { + vm.startPrank(operator); + dbr.removeMinter(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyOperator); + dbr.removeMinter(address(0)); + } + + function test_accessControl_addMarket() public { + vm.startPrank(operator); + dbr.addMarket(address(market)); + vm.stopPrank(); + + vm.expectRevert(onlyOperator); + dbr.addMarket(address(market)); + } + + function test_accessControl_mint() public { + vm.startPrank(operator); + dbr.mint(user, 100); + assertEq(dbr.balanceOf(user), 100, "mint failed"); + vm.stopPrank(); + + vm.startPrank(operator); + dbr.addMinter(user); + vm.stopPrank(); + vm.startPrank(user, user); + dbr.mint(user, 100); + assertEq(dbr.balanceOf(user), 200, "mint failed"); + vm.stopPrank(); + + vm.expectRevert(onlyMinterOperator); + dbr.mint(user, 100); + } + + function test_accessControl_onBorrow() public { + vm.startPrank(operator); + vm.expectRevert(onBorrowError); + dbr.onBorrow(user, 100e18); + } + + function test_accessControl_onRepay() public { + vm.startPrank(operator); + vm.expectRevert(onRepayError); + dbr.onRepay(user, 100e18); + } + + function test_accessControl_onForceReplenish() public { + vm.startPrank(user, user); + uint deficit = dbr.deficitOf(user); + vm.expectRevert(onForceReplenishError); + dbr.onForceReplenish(user, msg.sender, deficit, 1); + } +} diff --git a/src/test/Fed.t.sol b/src/test/Fed.t.sol new file mode 100644 index 00000000..a08b7ec3 --- /dev/null +++ b/src/test/Fed.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../Fed.sol"; +import "./FrontierV2Test.sol"; + +contract FedTest is FrontierV2Test { + bytes onlyGovUpper = "ONLY GOV"; + bytes unsupportedMarket = "UNSUPPORTED MARKET"; + bytes tooBig = "AMOUNT TOO BIG"; + bytes pausedMarkets = "CANNOT EXPAND PAUSED MARKETS"; + + IMarket marketParameter; + + uint testAmount = 1_000_000e18; + + function setUp() public { + initialize(replenishmentPriceBps, collateralFactorBps, replenishmentIncentiveBps, liquidationBonusBps, callOnDepositCallback); + + marketParameter = IMarket(address(market)); + } + + function testExpansion(uint256 amount) public { + uint startingDolaBal = DOLA.balanceOf(address(marketParameter)); + + vm.startPrank(chair); + fed.expansion(marketParameter, amount); + + assertEq(startingDolaBal + amount, DOLA.balanceOf(address(marketParameter)), "Expansion failed - dola balance"); + assertEq(fed.supplies(marketParameter), amount, "Expansion failed - fed accounting"); + } + + function testExpansion_Fails_If_UnsupportedMarket() public { + vm.startPrank(chair); + vm.expectRevert(unsupportedMarket); + fed.expansion(IMarket(address(0)), testAmount); + } + + function testExpansion_Fails_While_MarketPaused() public { + vm.startPrank(gov); + market.pauseBorrows(true); + vm.stopPrank(); + + vm.startPrank(chair); + vm.expectRevert(pausedMarkets); + fed.expansion(marketParameter, testAmount); + } + + function testContraction(uint256 amount) public { + vm.startPrank(chair); + fed.expansion(marketParameter, amount); + assertEq(fed.supplies(marketParameter), amount, "expansion failed - fed accounting"); + assertEq(DOLA.balanceOf(address(marketParameter)), amount, "expansion failed - dola balance"); + + fed.contraction(marketParameter, amount); + assertEq(fed.supplies(marketParameter), 0, "contraction failed - fed accounting"); + assertEq(DOLA.balanceOf(address(marketParameter)), 0, "contraction failed - dola balance"); + } + + function testContraction_Fails_If_UnsupportedMarket() public { + vm.startPrank(chair); + vm.expectRevert(unsupportedMarket); + fed.contraction(IMarket(address(0)), testAmount); + } + + function testContraction_Fails_If_Amount_GT_SuppliedDOLA() public { + vm.startPrank(chair); + fed.expansion(marketParameter, testAmount); + + vm.expectRevert(tooBig); + fed.contraction(marketParameter, testAmount + 1); + } + + function testGetProfit_Returns0_If_SuppliedDola_GT_MarketDolaValue() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(chair); + fed.expansion(marketParameter, testAmount); + + vm.stopPrank(); + vm.startPrank(user, user); + deposit(wethTestAmount); + market.borrow(getMaxBorrowAmount(wethTestAmount)); + + assertLt(DOLA.balanceOf(address(market)), testAmount, "Market DOLA value > Supplied Dola"); + assertEq(fed.getProfit(marketParameter), 0, "getProfit should return 0 since market DOLA value > fed's supplied DOLA"); + } + + function test_takeProfit() public { + vm.startPrank(chair); + fed.expansion(marketParameter, testAmount); + + gibDOLA(address(marketParameter), testAmount * 2); + + vm.stopPrank(); + + uint startingDolaBal = DOLA.balanceOf(gov); + fed.takeProfit(marketParameter); + + assertEq(startingDolaBal + testAmount, DOLA.balanceOf(gov), "takeProfit failed"); + } + + function test_takeProfit_doesNothing_WhenProfitIs0() public { + vm.startPrank(chair); + fed.expansion(marketParameter, testAmount); + + uint startingDolaBal = DOLA.balanceOf(gov); + fed.takeProfit(marketParameter); + + assertEq(startingDolaBal, DOLA.balanceOf(gov), "DOLA balance should be unchanged, there is no profit"); + } + + //Access Control + function test_accessControl_changeGov() public { + vm.startPrank(gov); + fed.changeGov(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyGovUpper); + fed.changeGov(address(0)); + } + + function test_accessControl_changeChair() public { + vm.startPrank(gov); + fed.changeChair(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyGovUpper); + fed.changeChair(address(0)); + } + + function test_accessControl_resign() public { + vm.startPrank(chair); + fed.resign(); + vm.stopPrank(); + + vm.expectRevert(onlyChair); + fed.resign(); + } + + function test_accessControl_expansion() public { + vm.startPrank(chair); + fed.expansion(IMarket(address(market)), 100e18); + vm.stopPrank(); + + vm.expectRevert(onlyChair); + fed.expansion(IMarket(address(market)), 100e18); + } + + function test_accessControl_contraction() public { + vm.startPrank(chair); + fed.expansion(IMarket(address(market)), 100e18); + fed.contraction(IMarket(address(market)), 100e18); + fed.expansion(IMarket(address(market)), 100e18); + vm.stopPrank(); + + vm.expectRevert(onlyChair); + fed.contraction(IMarket(address(market)), 100e18); + } +} diff --git a/src/test/FrontierV2Test.sol b/src/test/FrontierV2Test.sol new file mode 100644 index 00000000..5fa70070 --- /dev/null +++ b/src/test/FrontierV2Test.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../BorrowController.sol"; +import "../DBR.sol"; +import "../Fed.sol"; +import {SimpleERC20Escrow} from "../escrows/SimpleERC20Escrow.sol"; +import "../Market.sol"; +import "../Oracle.sol"; + +import "./mocks/ERC20.sol"; +import "./mocks/WETH9.sol"; +import "./mocks/WBTC.sol"; +import {WbtcFeed} from "./mocks/WbtcFeed.sol"; +import {EthFeed} from "./mocks/EthFeed.sol"; + +contract FrontierV2Test is Test { + //EOAs & Multisigs + address user = address(0x69); + address user2 = address(0x70); + address replenisher = address(0x71); + address gov = address(0xA); + address chair = address(0xB); + address pauseGuardian = address(0xB); + + //ERC-20s + ERC20 DOLA; + WETH9 WETH; + WBTC wBTC; + + //Frontier V2 + Oracle oracle; + EthFeed ethFeed; + WbtcFeed wbtcFeed; + BorrowController borrowController; + SimpleERC20Escrow escrowImplementation; + DolaBorrowingRights dbr; + Market market; + Fed fed; + + //Constants + uint collateralFactorBps = 8500; + uint replenishmentIncentiveBps = 500; + uint liquidationBonusBps = 100; + bool callOnDepositCallback = false; + + uint replenishmentPriceBps = 10000; + + uint wethTestAmount = 1 ether; + + bytes onlyChair = "ONLY CHAIR"; + bytes onlyGov = "Only gov can call this function"; + bytes onlyLender = "Only lender can recall"; + bytes onlyOperator = "ONLY OPERATOR"; + + function initialize(uint replenishmentPriceBps_, uint collateralFactorBps_, uint replenishmentIncentiveBps_, uint liquidationBonusBps_, bool callOnDepositCallback_) public { + vm.label(user, "user"); + vm.label(user2, "user2"); + + //Warp forward 7 days since local chain timestamp is 0, will cause revert when calculating `days` in oracle. + vm.warp(block.timestamp + 7 days); + vm.startPrank(gov); + + //This is done to make DOLA live at a predetermined address so it does not need to be included in constructor + DOLA = new ERC20("DOLA", "DOLA", 18); + bytes memory code = codeAt(address(DOLA)); + vm.etch(0x865377367054516e17014CcdED1e7d814EDC9ce4, code); + DOLA = ERC20(0x865377367054516e17014CcdED1e7d814EDC9ce4); + + WETH = new WETH9(); + wBTC = new WBTC(); + + ethFeed = new EthFeed(); + wbtcFeed = new WbtcFeed(); + + oracle = new Oracle(gov); + borrowController = new BorrowController(gov); + escrowImplementation = new SimpleERC20Escrow(); + dbr = new DolaBorrowingRights(replenishmentPriceBps_, "DOLA Borrowing Rights", "DBR", gov); + fed = new Fed(IDBR(address(dbr)), IDola(address(DOLA)), gov, chair, type(uint).max); + market = new Market(gov, address(fed), pauseGuardian, address(escrowImplementation), IDolaBorrowingRights(address(dbr)), IERC20(address(WETH)), IOracle(address(oracle)), collateralFactorBps_, replenishmentIncentiveBps_, liquidationBonusBps_, callOnDepositCallback_); + market.setBorrowController(IBorrowController(address(borrowController))); + + dbr.addMarket(address(market)); + oracle.setFeed(address(WETH), IChainlinkFeed(address(ethFeed)), 18); + oracle.setFeed(address(wBTC), IChainlinkFeed(address(wbtcFeed)), 8); + vm.stopPrank(); + + vm.startPrank(address(0)); + DOLA.addMinter(address(fed)); + vm.stopPrank(); + } + + //Helper functions + function deposit(uint amount) internal { + WETH.approve(address(market), amount); + market.deposit(amount); + } + + function convertWethToDola(uint amount) public view returns (uint) { + return amount * ethFeed.latestAnswer() / 1e18; + } + + function convertDolaToWeth(uint amount) public view returns (uint) { + return amount * 1e18 / ethFeed.latestAnswer(); + } + + function getMaxBorrowAmount(uint amountWeth) public view returns (uint) { + return convertWethToDola(amountWeth) * market.collateralFactorBps() / 10_000; + } + + function gibWeth(address _address, uint _amount) internal { + vm.deal(_address, _amount); + vm.startPrank(_address); + WETH.deposit{value: _amount}(); + vm.stopPrank(); + } + + function gibDBR(address _address, uint _amount) internal { + vm.startPrank(gov); + dbr.mint(_address, _amount); + vm.stopPrank(); + } + + function gibDOLA(address _address, uint _amount) internal { + bytes32 slot; + assembly { + mstore(0, _address) + mstore(0x20, 0x6) + slot := keccak256(0, 0x40) + } + + vm.store(address(DOLA), slot, bytes32(_amount)); + } + + function codeAt(address _addr) public view returns (bytes memory o_code) { + assembly { + // retrieve the size of the code, this needs assembly + let size := extcodesize(_addr) + // allocate output byte array - this could also be done without assembly + // by using o_code = new bytes(size) + o_code := mload(0x40) + // new "memory end" including padding + mstore(0x40, add(o_code, and(add(add(size, 0x20), 0x1f), not(0x1f)))) + // store length in memory + mstore(o_code, size) + // actually retrieve the code, this needs assembly + extcodecopy(_addr, add(o_code, 0x20), 0, size) + } + } +} diff --git a/src/test/Market.t.sol b/src/test/Market.t.sol new file mode 100644 index 00000000..74dfc85b --- /dev/null +++ b/src/test/Market.t.sol @@ -0,0 +1,987 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "./FrontierV2Test.sol"; +import "../BorrowController.sol"; +import "../DBR.sol"; +import "../Fed.sol"; +import {SimpleERC20Escrow} from "../escrows/SimpleERC20Escrow.sol"; +import "../Market.sol"; +import "../Oracle.sol"; + +import "./mocks/ERC20.sol"; +import "./mocks/WETH9.sol"; +import "./mocks/BorrowContract.sol"; +import {EthFeed} from "./mocks/EthFeed.sol"; + +contract MarketTest is FrontierV2Test { + bytes onlyGovUnpause = "Only governance can unpause"; + bytes onlyPauseGuardianOrGov = "Only pause guardian or governance can pause"; + + BorrowContract borrowContract; + + function setUp() public { + initialize(replenishmentPriceBps, collateralFactorBps, replenishmentIncentiveBps, liquidationBonusBps, callOnDepositCallback); + + vm.startPrank(chair); + fed.expansion(IMarket(address(market)), 1_000_000e18); + vm.stopPrank(); + + borrowContract = new BorrowContract(address(market), payable(address(WETH))); + } + + function testDeposit() public { + gibWeth(user, wethTestAmount); + uint balanceUserBefore = WETH.balanceOf(user); + + vm.startPrank(user, user); + deposit(wethTestAmount); + assertEq(WETH.balanceOf(address(market.predictEscrow(user))), wethTestAmount, "Escrow balance did not increase"); + assertEq(WETH.balanceOf(user), balanceUserBefore - wethTestAmount, "User balance did not decrease"); + } + + function testDeposit2() public { + gibWeth(user, wethTestAmount); + uint balanceUserBefore = WETH.balanceOf(user); + + vm.startPrank(user, user); + WETH.approve(address(market), wethTestAmount); + market.deposit(user2, wethTestAmount); + assertEq(WETH.balanceOf(address(market.predictEscrow(user))), 0, "User balance not 0"); + assertEq(WETH.balanceOf(address(market.predictEscrow(user2))), wethTestAmount, "User2 escrow balance did not increase "); + assertEq(WETH.balanceOf(user), balanceUserBefore - wethTestAmount, "User balance did not decrease"); + assertEq(WETH.balanceOf(user2), 0, "User2 not 0"); + } + + function testBorrow() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + vm.startPrank(user, user); + uint initialDolaBalance = DOLA.balanceOf(user); + deposit(wethTestAmount); + + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + + assertEq(DOLA.balanceOf(user), initialDolaBalance + borrowAmount, "User balance did not increase by borrowAmount"); + } + + function testBorrow_BurnsCorrectAmountOfDBR_WhenTimePasses() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + vm.startPrank(user, user); + uint initialDolaBalance = DOLA.balanceOf(user); + deposit(wethTestAmount); + + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + vm.warp(1_000_000); + uint dbrBal = dbr.balanceOf(user); + market.borrow(borrowAmount); + assertEq(dbrBal, wethTestAmount, "DBR balance burned immediately after borrow"); + vm.warp(1_000_001); + dbr.accrueDueTokens(user); + assertEq(dbr.balanceOf(user), dbrBal - borrowAmount / 365 days, "DBR balance didn't drop by 1 second worth"); + + assertEq(DOLA.balanceOf(user), initialDolaBalance + borrowAmount, "User balance did not increase by borrowAmount"); + } + + + + function testDepositAndBorrow() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + vm.startPrank(user, user); + + uint initialDolaBalance = DOLA.balanceOf(user); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + uint balanceUserBefore = WETH.balanceOf(user); + WETH.approve(address(market), wethTestAmount); + market.depositAndBorrow(wethTestAmount, borrowAmount); + + assertEq(DOLA.balanceOf(user), initialDolaBalance + borrowAmount, "User balance did not increase by borrowAmount"); + assertEq(WETH.balanceOf(address(market.predictEscrow(user))), wethTestAmount, "Escrow balance did not increase"); + assertEq(WETH.balanceOf(user), balanceUserBefore - wethTestAmount, "User balance did not decrease"); + } + + function testBorrowOnBehalf() public { + address userPk = vm.addr(1); + gibWeth(userPk, wethTestAmount); + gibDBR(userPk, wethTestAmount); + + vm.startPrank(userPk, userPk); + uint maxBorrowAmount = getMaxBorrowAmount(wethTestAmount); + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + user2, + userPk, + maxBorrowAmount, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + deposit(wethTestAmount); + vm.stopPrank(); + + assertEq(WETH.balanceOf(address(market.escrows(userPk))), wethTestAmount, "failed to deposit WETH"); + assertEq(WETH.balanceOf(userPk), 0, "failed to deposit WETH"); + + vm.startPrank(user2, user2); + market.borrowOnBehalf(userPk, maxBorrowAmount, block.timestamp, v, r, s); + + assertEq(DOLA.balanceOf(userPk), 0, "borrowed DOLA went to the wrong user"); + assertEq(DOLA.balanceOf(user2), maxBorrowAmount, "failed to borrow DOLA"); + } + + function testBorrowOnBehalf_Fails_When_InvalidateNonceCalledPrior() public { + address userPk = vm.addr(1); + gibWeth(userPk, wethTestAmount); + gibDBR(userPk, wethTestAmount); + + vm.startPrank(userPk); + uint maxBorrowAmount = getMaxBorrowAmount(wethTestAmount); + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + user2, + userPk, + maxBorrowAmount, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + deposit(wethTestAmount); + market.invalidateNonce(); + vm.stopPrank(); + + vm.startPrank(user2); + vm.expectRevert("INVALID_SIGNER"); + market.borrowOnBehalf(userPk, maxBorrowAmount, block.timestamp, v, r, s); + } + + function testBorrowOnBehalf_Fails_When_DeadlineHasPassed() public { + address userPk = vm.addr(1); + gibWeth(userPk, wethTestAmount); + gibDBR(userPk, wethTestAmount); + + uint timestamp = block.timestamp; + + vm.startPrank(userPk); + uint maxBorrowAmount = getMaxBorrowAmount(wethTestAmount); + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + user2, + userPk, + maxBorrowAmount, + 0, + timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + deposit(wethTestAmount); + market.invalidateNonce(); + vm.stopPrank(); + + vm.startPrank(user2); + vm.warp(block.timestamp + 1); + vm.expectRevert("DEADLINE_EXPIRED"); + market.borrowOnBehalf(userPk, maxBorrowAmount, timestamp, v, r, s); + } + + function testBorrow_Fails_When_BorrowingPaused() public { + vm.startPrank(gov); + market.pauseBorrows(true); + vm.stopPrank(); + + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + vm.startPrank(user, user); + + deposit(wethTestAmount); + + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + vm.expectRevert("Borrowing is paused"); + market.borrow(borrowAmount); + } + + function testBorrow_Fails_When_DeniedByBorrowController() public { + vm.startPrank(gov); + market.setBorrowController(IBorrowController(address(borrowController))); + vm.stopPrank(); + + gibWeth(address(borrowContract), wethTestAmount); + gibDBR(address(borrowContract), wethTestAmount); + vm.startPrank(user, user); + + borrowContract.deposit(wethTestAmount); + + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + vm.expectRevert("Denied by borrow controller"); + borrowContract.borrow(borrowAmount); + } + + function testBorrow_Fails_When_AmountGTCreditLimit() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + vm.startPrank(user, user); + + deposit(wethTestAmount); + + uint borrowAmount = convertWethToDola(wethTestAmount); + vm.expectRevert("Exceeded credit limit"); + market.borrow(borrowAmount); + } + + function testBorrow_Fails_When_NotEnoughDolaInMarket() public { + vm.startPrank(market.lender()); + market.recall(DOLA.balanceOf(address(market))); + vm.stopPrank(); + + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + + vm.expectRevert("SafeMath: subtraction underflow"); + market.borrow(1 ether); + } + + function testLiquidate_NoLiquidationFee(uint depositAmount, uint liqAmount, uint16 borrowMulti_) public { + depositAmount = bound(depositAmount, 1e18, 100_000e18); + liqAmount = bound(liqAmount, 500e18, 200_000_000e18); + uint borrowMulti = bound(borrowMulti_, 0, 100); + + uint maxBorrowAmount = convertWethToDola(depositAmount) * market.collateralFactorBps() / 10_000; + uint borrowAmount = maxBorrowAmount * borrowMulti / 100; + + gibWeth(user, depositAmount); + gibDBR(user, depositAmount); + + vm.startPrank(chair); + fed.expansion(IMarket(address(market)), convertWethToDola(depositAmount)); + vm.stopPrank(); + + vm.startPrank(user, user); + deposit(depositAmount); + market.borrow(borrowAmount); + vm.stopPrank(); + + ethFeed.changeAnswer(oracle.getFeedPrice(address(WETH)) * 9 / 10); + + vm.startPrank(user2); + gibDOLA(user2, liqAmount); + DOLA.approve(address(market), type(uint).max); + + uint marketDolaBal = DOLA.balanceOf(address(market)); + uint govDolaBal = DOLA.balanceOf(gov); + uint repayAmount = market.debts(user) * market.liquidationFactorBps() / 10_000; + + if (market.debts(user) <= market.getCreditLimit(user)) { + vm.expectRevert("User debt is healthy"); + market.liquidate(user, liqAmount); + } else if (repayAmount < liqAmount) { + vm.expectRevert("Exceeded liquidation factor"); + market.liquidate(user, liqAmount); + } else { + //Successful liquidation + market.liquidate(user, liqAmount); + + uint expectedReward = convertDolaToWeth(liqAmount); + expectedReward += expectedReward * market.liquidationIncentiveBps() / 10_000; + assertEq(expectedReward, WETH.balanceOf(user2), "user2 didn't receive proper liquidation reward"); + assertEq(DOLA.balanceOf(address(market)), marketDolaBal + liqAmount, "market didn't receive repaid DOLA"); + assertEq(DOLA.balanceOf(gov), govDolaBal, "gov should not receive liquidation fee when it's set to 0"); + } + } + + function testLiquidate_WithLiquidationFee(uint depositAmount, uint liqAmount, uint256 liquidationFeeBps, uint16 borrowMulti_) public { + depositAmount = bound(depositAmount, 1e18, 100_000e18); + liqAmount = bound(liqAmount, 500e18, 200_000_000e18); + uint borrowMulti = bound(borrowMulti_, 0, 100); + + gibWeth(user, depositAmount); + gibDBR(user, depositAmount); + + vm.startPrank(chair); + fed.expansion(IMarket(address(market)), convertWethToDola(depositAmount)); + vm.stopPrank(); + + vm.startPrank(gov); + liquidationFeeBps = bound(liquidationFeeBps, 1, 10_000); + vm.assume(liquidationFeeBps > 0 && liquidationFeeBps + market.liquidationIncentiveBps() < 10000); + market.setLiquidationFeeBps(liquidationFeeBps); + vm.stopPrank(); + + vm.startPrank(user, user); + deposit(depositAmount); + uint maxBorrowAmount = convertWethToDola(depositAmount) * market.collateralFactorBps() / 10_000; + uint borrowAmount = maxBorrowAmount * borrowMulti / 100; + market.borrow(borrowAmount); + vm.stopPrank(); + + ethFeed.changeAnswer(oracle.getFeedPrice(address(WETH)) * 9 / 10); + + vm.startPrank(user2); + gibDOLA(user2, liqAmount); + DOLA.approve(address(market), type(uint).max); + + uint marketDolaBal = DOLA.balanceOf(address(market)); + uint govWethBal = WETH.balanceOf(gov); + uint repayAmount = market.debts(user) * market.liquidationFactorBps() / 10_000; + + if (market.debts(user) <= market.getCreditLimit(user)) { + vm.expectRevert("User debt is healthy"); + market.liquidate(user, liqAmount); + } else if (repayAmount < liqAmount) { + vm.expectRevert("Exceeded liquidation factor"); + market.liquidate(user, liqAmount); + } else { + //Successful liquidation + market.liquidate(user, liqAmount); + + uint expectedReward = convertDolaToWeth(liqAmount); + expectedReward += expectedReward * market.liquidationIncentiveBps() / 10_000; + uint expectedLiquidationFee = convertDolaToWeth(liqAmount) * market.liquidationFeeBps() / 10_000; + assertEq(expectedReward, WETH.balanceOf(user2), "user2 didn't receive proper liquidation reward"); + assertEq(DOLA.balanceOf(address(market)), marketDolaBal + liqAmount, "market didn't receive repaid DOLA"); + assertEq(WETH.balanceOf(gov), govWethBal + expectedLiquidationFee, "gov didn't receive proper liquidation fee"); + } + } + + function testLiquidate_Fails_When_repaidDebtIs0() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + + vm.stopPrank(); + + ethFeed.changeAnswer(oracle.getFeedPrice(address(WETH)) * 9 / 10); + + vm.startPrank(user2); + gibDOLA(user2, 5_000 ether); + DOLA.approve(address(market), type(uint).max); + vm.expectRevert("Must repay positive debt"); + market.liquidate(user, 0); + } + + function testLiquidate_Fails_When_repaidDebtGtLiquidatableDebt() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + + vm.stopPrank(); + + ethFeed.changeAnswer(oracle.getFeedPrice(address(WETH)) * 9 / 10); + + vm.startPrank(user2); + gibDOLA(user2, 5_000 ether); + DOLA.approve(address(market), type(uint).max); + + uint liquidationAmount = (market.debts(user) * market.liquidationFactorBps() / 10_000) + 1; + vm.expectRevert("Exceeded liquidation factor"); + market.liquidate(user, liquidationAmount); + } + + function testLiquidate_Fails_When_UserDebtIsHealthy() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + + vm.stopPrank(); + + vm.startPrank(user2); + gibDOLA(user2, 5_000 ether); + DOLA.approve(address(market), type(uint).max); + + uint liquidationAmount = market.debts(user); + vm.expectRevert("User debt is healthy"); + market.liquidate(user, liquidationAmount); + } + + function testRepay_Successful_OwnBorrow_FullAmount() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + + uint initialMarketBal = DOLA.balanceOf(address(market)); + uint initialUserDebt = market.debts(user); + uint initialDolaBal = DOLA.balanceOf(user); + + market.repay(user, market.debts(user)); + + assertEq(market.debts(user), 0, "user's debt was not paid"); + assertEq(initialDolaBal - initialUserDebt, DOLA.balanceOf(user), "DOLA was not subtracted from user"); + assertEq(initialMarketBal + initialUserDebt, DOLA.balanceOf(address(market)), "Market DOLA balance did not increase"); + } + + function testRepay_Successful_OtherUserBorrow_FullAmount() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + + vm.stopPrank(); + vm.startPrank(user2); + + uint initialUserDebt = market.debts(user); + uint initialDolaBal = initialUserDebt * 2; + gibDOLA(user2, initialDolaBal); + + market.repay(user, market.debts(user)); + + assertEq(market.debts(user), 0, "user's debt was not paid"); + assertEq(initialDolaBal - initialUserDebt, DOLA.balanceOf(user2), "DOLA was not subtracted from user2"); + } + + function testRepay_RepaysDebt_WhenAmountSetToMaxUint() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + gibDOLA(user, 500e18); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + uint dolaBalAfterBorrow = DOLA.balanceOf(user); + uint debtAfterBorrow = market.debts(user); + + market.repay(user, type(uint).max); + assertEq(dolaBalAfterBorrow-borrowAmount, DOLA.balanceOf(user)); + assertEq(market.debts(user), 0); + } + + + function testRepay_Fails_WhenAmountGtThanDebt() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + gibDOLA(user, 500e18); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + uint dolaBalAfterBorrow = DOLA.balanceOf(user); + uint debtAfterBorrow = market.debts(user); + + vm.expectRevert("Repayment greater than debt"); + market.repay(user, borrowAmount + 1); + } + + function testForceReplenish() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount / 14); + uint initialReplenisherDola = DOLA.balanceOf(replenisher); + + vm.startPrank(user, user); + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + uint initialUserDebt = market.debts(user); + uint initialMarketDola = DOLA.balanceOf(address(market)); + vm.stopPrank(); + + vm.warp(block.timestamp + 5 days); + uint deficitBefore = dbr.deficitOf(user); + vm.startPrank(replenisher); + + market.forceReplenish(user, deficitBefore); + assertGt(DOLA.balanceOf(replenisher), initialReplenisherDola, "DOLA balance of replenisher did not increase"); + assertLt(DOLA.balanceOf(address(market)), initialMarketDola, "DOLA balance of market did not decrease"); + assertEq(DOLA.balanceOf(replenisher) - initialReplenisherDola, initialMarketDola - DOLA.balanceOf(address(market)), "DOLA balance of market did not decrease by amount paid to replenisher"); + assertEq(dbr.deficitOf(user), 0, "Deficit of borrower was not fully replenished"); + assertEq(market.debts(user) - initialUserDebt, deficitBefore * replenishmentPriceBps / 10000, "Debt of borrower did not increase by replenishment price"); + } + + function testForceReplenish_Fails_When_UserHasNoDbrDeficit() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount * 100); + + vm.startPrank(user, user); + + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + uint deficit = dbr.deficitOf(user); + + vm.stopPrank(); + vm.startPrank(user2); + + vm.expectRevert("No DBR deficit"); + market.forceReplenish(user, deficit); + } + + function testForceReplenish_Fails_When_NotEnoughDolaInMarket() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount / 14); + + vm.startPrank(user, user); + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + + vm.warp(block.timestamp + 5 days); + vm.stopPrank(); + vm.startPrank(market.lender()); + market.recall(DOLA.balanceOf(address(market))); + uint deficit = dbr.deficitOf(user); + vm.stopPrank(); + vm.startPrank(replenisher); + vm.expectRevert("SafeMath: subtraction underflow"); + market.forceReplenish(user, deficit); + } + + function testForceReplenish_Fails_When_DebtWouldExceedCollateralValue() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount / 14); + + vm.startPrank(user, user); + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + + vm.warp(block.timestamp + 10000 days); + uint deficit = dbr.deficitOf(user); + vm.stopPrank(); + + vm.startPrank(replenisher); + vm.expectRevert("Exceeded collateral value"); + market.forceReplenish(user, deficit); + } + + function testForceReplenish_Succeed_When_PartiallyReplenishedDebtExceedCollateralValue() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount / 14); + + vm.startPrank(user, user); + deposit(wethTestAmount); + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + market.borrow(borrowAmount); + + vm.warp(block.timestamp + 10000 days); + uint deficit = dbr.deficitOf(user); + vm.stopPrank(); + + vm.startPrank(replenisher, replenisher); + uint maxDebt = market.getCollateralValue(user) * (10000 - market.liquidationIncentiveBps() - market.liquidationFeeBps()) / 10000; + market.forceReplenish(user, maxDebt - market.debts(user)); + assertEq(market.debts(user), maxDebt); + assertLt(dbr.deficitOf(user), deficit, "Deficit didn't shrink"); + } + + function testGetWithdrawalLimit_Returns_CollateralBalance() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + deposit(wethTestAmount); + + uint collateralBalance = market.escrows(user).balance(); + assertEq(collateralBalance, wethTestAmount); + assertEq(market.getWithdrawalLimit(user), collateralBalance, "Should return collateralBalance when user's escrow balance > 0 & debts = 0"); + } + + function testGetWithdrawalLimit_Returns_CollateralBalanceAdjustedForDebts() public { + uint borrowAmount = getMaxBorrowAmount(wethTestAmount); + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + deposit(wethTestAmount); + market.borrow(borrowAmount); + uint collateralBalance = market.escrows(user).balance(); + uint collateralFactor = market.collateralFactorBps(); + uint minimumCollateral = borrowAmount * 1 ether / oracle.viewPrice(address(WETH), collateralFactor) * 10000 / collateralFactor; + assertEq(market.getWithdrawalLimit(user), collateralBalance - minimumCollateral, "Should return collateral balance adjusted for debt"); + } + + function testGetWithdrawalLimit_Returns_0_WhenEscrowBalanceIs0() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + deposit(wethTestAmount); + + uint collateralBalance = market.escrows(user).balance(); + assertEq(collateralBalance, wethTestAmount); + + market.withdraw(wethTestAmount); + assertEq(market.getWithdrawalLimit(user), 0, "Should return 0 when user's escrow balance is 0"); + } + + function testGetWithdrawalLimit_Returns_0_WhenCollateralValueLtDebts() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + deposit(wethTestAmount); + + uint collateralBalance = market.escrows(user).balance(); + assertEq(collateralBalance, wethTestAmount); + market.withdraw(wethTestAmount); + + uint ethPrice = oracle.getFeedPrice(address(WETH)); + ethFeed.changeAnswer(ethPrice * 6 / 10); + assertEq(market.getWithdrawalLimit(user), 0, "Should return 0 when user's collateral value is less than debts"); + ethFeed.changeAnswer(ethPrice); + } + + function testGetWithdrawalLimit_Returns_0_WhenMarketCollateralFactoris0() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + + vm.startPrank(user, user); + deposit(wethTestAmount); + market.borrow(1); + vm.stopPrank(); + + vm.startPrank(gov); + market.setCollateralFactorBps(0); + assertEq(market.getWithdrawalLimit(user), 0, "Should return 0 when user has non-zero debt & collateralFactorBps = 0"); + } + + function testPauseBorrows() public { + vm.startPrank(gov); + + market.pauseBorrows(true); + assertEq(market.borrowPaused(), true, "Market wasn't paused"); + market.pauseBorrows(false); + assertEq(market.borrowPaused(), false, "Market wasn't unpaused"); + + vm.stopPrank(); + vm.startPrank(pauseGuardian); + market.pauseBorrows(true); + assertEq(market.borrowPaused(), true, "Market wasn't paused"); + vm.expectRevert(onlyGovUnpause); + market.pauseBorrows(false); + vm.stopPrank(); + + vm.startPrank(user, user); + vm.expectRevert(onlyPauseGuardianOrGov); + market.pauseBorrows(true); + + vm.expectRevert(onlyGovUnpause); + market.pauseBorrows(false); + } + + function testWithdraw() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + vm.startPrank(user, user); + + deposit(wethTestAmount); + + assertEq(WETH.balanceOf(address(market.escrows(user))), wethTestAmount, "failed to deposit WETH"); + assertEq(WETH.balanceOf(user), 0, "failed to deposit WETH"); + + market.withdraw(wethTestAmount); + + assertEq(WETH.balanceOf(address(market.escrows(user))), 0, "failed to withdraw WETH"); + assertEq(WETH.balanceOf(user), wethTestAmount, "failed to withdraw WETH"); + } + + function testWithdraw_Fail_When_WithdrawingCollateralBelowCF() public { + gibWeth(user, wethTestAmount); + gibDBR(user, wethTestAmount); + vm.startPrank(user, user); + + deposit(wethTestAmount); + + assertEq(WETH.balanceOf(address(market.escrows(user))), wethTestAmount, "failed to deposit WETH"); + assertEq(WETH.balanceOf(user), 0, "failed to deposit WETH"); + + market.borrow(1 ether); + + vm.expectRevert("Insufficient withdrawal limit"); + market.withdraw(wethTestAmount); + + assertEq(WETH.balanceOf(address(market.escrows(user))), wethTestAmount, "successfully withdrew WETH"); + assertEq(WETH.balanceOf(user), 0, "successfully withdrew WETH"); + } + + function testWithdrawOnBehalf() public { + address userPk = vm.addr(1); + gibWeth(userPk, wethTestAmount); + gibDBR(userPk, wethTestAmount); + + vm.startPrank(userPk); + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + user2, + userPk, + wethTestAmount, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + deposit(wethTestAmount); + vm.stopPrank(); + + assertEq(WETH.balanceOf(address(market.escrows(userPk))), wethTestAmount, "failed to deposit WETH"); + assertEq(WETH.balanceOf(userPk), 0, "failed to deposit WETH"); + + vm.startPrank(user2); + market.withdrawOnBehalf(userPk, wethTestAmount, block.timestamp, v, r, s); + + assertEq(WETH.balanceOf(address(market.escrows(userPk))), 0, "failed to withdraw WETH"); + assertEq(WETH.balanceOf(user2), wethTestAmount, "failed to withdraw WETH"); + } + + function testWithdrawOnBehalf_When_InvalidateNonceCalledPrior() public { + address userPk = vm.addr(1); + gibWeth(userPk, wethTestAmount); + gibDBR(userPk, wethTestAmount); + + vm.startPrank(userPk); + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + user2, + userPk, + wethTestAmount, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + deposit(wethTestAmount); + market.invalidateNonce(); + vm.stopPrank(); + + vm.startPrank(user2); + vm.expectRevert("INVALID_SIGNER"); + market.withdrawOnBehalf(userPk, wethTestAmount, block.timestamp, v, r, s); + } + + function testWithdrawOnBehalf_When_DeadlineHasPassed() public { + address userPk = vm.addr(1); + gibWeth(userPk, wethTestAmount); + gibDBR(userPk, wethTestAmount); + + uint timestamp = block.timestamp; + + vm.startPrank(userPk); + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + user2, + userPk, + wethTestAmount, + 0, + timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + deposit(wethTestAmount); + market.invalidateNonce(); + vm.stopPrank(); + + vm.startPrank(user2); + vm.warp(block.timestamp + 1); + vm.expectRevert("DEADLINE_EXPIRED"); + market.withdrawOnBehalf(userPk, wethTestAmount, timestamp, v, r, s); + } + + //Access Control Tests + + function test_accessControl_setOracle() public { + vm.startPrank(gov); + market.setOracle(IOracle(address(0))); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setOracle(IOracle(address(0))); + } + + function test_accessControl_setBorrowController() public { + vm.startPrank(gov); + market.setBorrowController(IBorrowController(address(0))); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setBorrowController(IBorrowController(address(0))); + } + + function test_accessControl_setGov() public { + vm.startPrank(gov); + market.setGov(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setGov(address(0)); + } + + function test_accessControl_setLender() public { + vm.startPrank(gov); + market.setLender(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setLender(address(0)); + } + + function test_accessControl_setPauseGuardian() public { + vm.startPrank(gov); + market.setPauseGuardian(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setPauseGuardian(address(0)); + } + + function test_accessControl_setCollateralFactorBps() public { + vm.startPrank(gov); + market.setCollateralFactorBps(100); + + vm.expectRevert("Invalid collateral factor"); + market.setCollateralFactorBps(10001); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setCollateralFactorBps(100); + } + + function test_accessControl_setReplenismentIncentiveBps() public { + vm.startPrank(gov); + market.setReplenismentIncentiveBps(100); + + vm.expectRevert("Invalid replenishment incentive"); + market.setReplenismentIncentiveBps(10001); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setReplenismentIncentiveBps(100); + } + + function test_accessControl_setLiquidationIncentiveBps() public { + vm.startPrank(gov); + market.setLiquidationIncentiveBps(100); + + vm.expectRevert("Invalid liquidation incentive"); + market.setLiquidationIncentiveBps(0); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setLiquidationIncentiveBps(100); + } + + function test_accessControl_setLiquidationFactorBps() public { + vm.startPrank(gov); + market.setLiquidationFactorBps(100); + + vm.expectRevert("Invalid liquidation factor"); + market.setLiquidationFactorBps(0); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setLiquidationFactorBps(100); + } + + function test_accessControl_setLiquidationFeeBps() public { + vm.startPrank(gov); + market.setLiquidationFeeBps(100); + + vm.expectRevert("Invalid liquidation fee"); + market.setLiquidationFeeBps(0); + vm.stopPrank(); + + vm.expectRevert(onlyGov); + market.setLiquidationFeeBps(100); + } + + function test_accessControl_recall() public { + vm.startPrank(address(fed)); + market.recall(100e18); + vm.stopPrank(); + + vm.expectRevert(onlyLender); + market.recall(100e18); + } +} diff --git a/src/test/Oracle.t.sol b/src/test/Oracle.t.sol new file mode 100644 index 00000000..00d9f68b --- /dev/null +++ b/src/test/Oracle.t.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "./FrontierV2Test.sol"; +import "../Oracle.sol"; + +import {EthFeed} from "./mocks/EthFeed.sol"; +import "./mocks/WETH9.sol"; + +contract OracleTest is FrontierV2Test { + address operator; + + bytes onlyPendingOperator = "ONLY PENDING OPERATOR"; + + function setUp() public { + vm.label(gov, "operator"); + operator = gov; + + initialize(replenishmentPriceBps, collateralFactorBps, replenishmentIncentiveBps, liquidationBonusBps, callOnDepositCallback); + } + + function test_getPrice_recordsDailyLowWeth() public { + uint day = block.timestamp / 1 days; + uint collateralFactor = market.collateralFactorBps(); + uint feedPrice = ethFeed.latestAnswer(); + uint oraclePrice = oracle.getPrice(address(WETH), collateralFactor); + + assertEq(oraclePrice, feedPrice); + assertEq(oracle.dailyLows(address(WETH), day), feedPrice, "Oracle didn't record daily low on call to getPrice"); + + uint newPrice = 1200e18; + ethFeed.changeAnswer(newPrice); + oraclePrice = oracle.getPrice(address(WETH), collateralFactor); + + assertEq(oraclePrice, newPrice, "Oracle didn't update when feed did"); + assertEq(oracle.dailyLows(address(WETH), day), newPrice, "Oracle didn't record daily low on call to getPrice"); + } + + function test_getPrice_recordsDailyLowWbtc() public { + uint day = block.timestamp / 1 days; + uint collateralFactor = market.collateralFactorBps(); + uint feedPrice = wbtcFeed.latestAnswer(); + uint expectedOraclePrice = feedPrice * 1e20; + uint oraclePrice = oracle.getPrice(address(wBTC), collateralFactor); + emit log_uint(oraclePrice); + assertEq(oraclePrice, expectedOraclePrice); + assertEq(oracle.dailyLows(address(wBTC), day), expectedOraclePrice, "Oracle didn't record daily low on call to getPrice"); + + uint newPrice = 12000e8; + uint expectedPrice = newPrice * 1e20; + wbtcFeed.changeAnswer(newPrice); + oraclePrice = oracle.getPrice(address(wBTC), collateralFactor); + + assertEq(oraclePrice, expectedPrice, "Oracle didn't update when feed did"); + assertEq(oracle.dailyLows(address(wBTC), day), expectedPrice, "Oracle didn't record daily low on call to getPrice"); + } + + function test_viewPrice_returnsDampenedPrice() public { + uint collateralFactor = market.collateralFactorBps(); + uint day = block.timestamp / 1 days; + uint feedPrice = ethFeed.latestAnswer(); + + //1600e18 price saved as daily low + oracle.getPrice(address(WETH), collateralFactor); + assertEq(oracle.dailyLows(address(WETH), day), feedPrice, "Oracle didn't record daily low on call to getPrice"); + + vm.warp(block.timestamp + 1 days); + uint newPrice = 1200e18; + ethFeed.changeAnswer(newPrice); + //1200e18 price saved as daily low + oracle.getPrice(address(WETH), collateralFactor); + assertEq(oracle.dailyLows(address(WETH), ++day), newPrice, "Oracle didn't record daily low on call to getPrice"); + + vm.warp(block.timestamp + 1 days); + newPrice = 3000e18; + ethFeed.changeAnswer(newPrice); + + //1200e18 should be twoDayLow, 3000e18 is current price. We should receive dampened price here. + uint price = oracle.getPrice(address(WETH), collateralFactor); + uint viewPrice = oracle.viewPrice(address(WETH), collateralFactor); + assertEq(oracle.dailyLows(address(WETH), ++day), newPrice, "Oracle didn't record daily low on call to getPrice"); + + assertEq(price, 1200e18 * 10_000 / collateralFactor, "Oracle did not dampen price correctly"); + assertEq(viewPrice, 1200e18 * 10_000 / collateralFactor, "Oracle did not dampen view price correctly"); + } + + function test_viewPrice_reverts_whenNoPriceSet() public { + uint collateralFactor = market.collateralFactorBps(); + + vm.expectRevert("Price not found"); + oracle.viewPrice(address(0), collateralFactor); + } + + function test_getPrice_reverts_whenNoPriceSet() public { + uint collateralFactor = market.collateralFactorBps(); + + vm.expectRevert("Price not found"); + oracle.getPrice(address(0), collateralFactor); + } + + function test_viewPrice_reverts_whenFeedPriceReturns0() public { + ethFeed.changeAnswer(0); + uint collateralFactor = market.collateralFactorBps(); + + vm.expectRevert("Invalid feed price"); + + oracle.viewPrice(address(WETH), collateralFactor); + } + + function test_getPrice_reverts_whenFeedPriceReturns0() public { + ethFeed.changeAnswer(0); + uint collateralFactor = market.collateralFactorBps(); + + vm.expectRevert("Invalid feed price"); + + oracle.getPrice(address(WETH), collateralFactor); + } + + function test_viewPriceNoDampenedPrice_AUDIT() public { + uint collateralFactor = market.collateralFactorBps(); + uint day = block.timestamp / 1 days; + uint feedPrice = ethFeed.latestAnswer(); + + //1600e18 price saved as daily low + oracle.getPrice(address(WETH), collateralFactor); + assertEq(oracle.dailyLows(address(WETH), day), feedPrice); + + vm.warp(block.timestamp + 1 days); + uint newPrice = 1200e18; + ethFeed.changeAnswer(newPrice); + //1200e18 price saved as daily low + oracle.getPrice(address(WETH), collateralFactor); + assertEq(oracle.dailyLows(address(WETH), ++day), newPrice); + + vm.warp(block.timestamp + 1 days); + newPrice = 3000e18; + ethFeed.changeAnswer(newPrice); + + //1200e18 should be twoDayLow, 3000e18 is current price. We should receive dampened price here. + // Notice that viewPrice is called before getPrice. + uint viewPrice = oracle.viewPrice(address(WETH), collateralFactor); + uint price = oracle.getPrice(address(WETH), collateralFactor); + assertEq(viewPrice, price); + } + + //Access Control + + function test_accessControl_setPendingOperator() public { + vm.startPrank(operator); + oracle.setPendingOperator(address(0)); + vm.stopPrank(); + + vm.expectRevert(onlyOperator); + oracle.setPendingOperator(address(0)); + } + + function test_accessControl_claimOperator() public { + vm.startPrank(operator); + oracle.setPendingOperator(user); + vm.stopPrank(); + + vm.startPrank(user2); + vm.expectRevert(onlyPendingOperator); + oracle.claimOperator(); + vm.stopPrank(); + + vm.startPrank(user); + oracle.claimOperator(); + assertEq(oracle.operator(), user, "Call to claimOperator failed"); + } + + function test_accessControl_setFeed() public { + vm.startPrank(operator); + oracle.setFeed(address(WETH), IChainlinkFeed(address(ethFeed)), 18); + uint collateralFactor = market.collateralFactorBps(); + assertEq(oracle.viewPrice(address(WETH), collateralFactor), ethFeed.latestAnswer(), "Feed failed to set"); + vm.stopPrank(); + + vm.expectRevert(onlyOperator); + oracle.setFeed(address(WETH), IChainlinkFeed(address(ethFeed)), 18); + } + +} diff --git a/src/test/interfaces/IERC20.sol b/src/test/interfaces/IERC20.sol new file mode 100644 index 00000000..aa4096ee --- /dev/null +++ b/src/test/interfaces/IERC20.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v4.5.0) (token/ERC20/IERC20.sol) + +pragma solidity ^0.8.0; + +/** + * @dev Interface of the ERC20 standard as defined in the EIP. + */ + +interface IERC20 { + /** + * @dev Emitted when `value` tokens are moved from one account (`from`) to + * another (`to`). + * + * Note that `value` may be zero. + */ + event Transfer(address indexed from, address indexed to, uint256 value); + + /** + * @dev Emitted when the allowance of a `spender` for an `owner` is set by + * a call to {approve}. `value` is the new allowance. + */ + event Approval(address indexed owner, address indexed spender, uint256 value); + + /** + * @dev Returns the amount of tokens in existence. + */ + function totalSupply() external view returns (uint256); + + /** + * @dev Returns the decimal points used by the token. + */ + function decimals() external view returns (uint8); + + /** + * @dev Returns the amount of tokens owned by `account`. + */ + function balanceOf(address account) external view returns (uint256); + + /** + * @dev Moves `amount` tokens from the caller's account to `to`. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transfer(address to, uint256 amount) external returns (bool); + + /** + * @dev Returns the remaining number of tokens that `spender` will be + * allowed to spend on behalf of `owner` through {transferFrom}. This is + * zero by default. + * + * This value changes when {approve} or {transferFrom} are called. + */ + function allowance(address owner, address spender) external view returns (uint256); + + /** + * @dev Sets `amount` as the allowance of `spender` over the caller's tokens. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * IMPORTANT: Beware that changing an allowance with this method brings the risk + * that someone may use both the old and the new allowance by unfortunate + * transaction ordering. One possible solution to mitigate this race + * condition is to first reduce the spender's allowance to 0 and set the + * desired value afterwards: + * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 + * + * Emits an {Approval} event. + */ + function approve(address spender, uint256 amount) external returns (bool); + + /** + * @dev Moves `amount` tokens from `from` to `to` using the + * allowance mechanism. `amount` is then deducted from the caller's + * allowance. + * + * Returns a boolean value indicating whether the operation succeeded. + * + * Emits a {Transfer} event. + */ + function transferFrom( + address from, + address to, + uint256 amount + ) external returns (bool); + + /** + * @dev Burns `amount` of token, shringking total supply + */ + function burn(uint amount) external; + + /** + * @dev Mints `amount` of token to address `to` increasing total supply + */ + function mint(address to, uint amount) external; + + //For testing + function addMinter(address minter_) external; +} diff --git a/src/test/mocks/BorrowContract.sol b/src/test/mocks/BorrowContract.sol new file mode 100644 index 00000000..1f37d75f --- /dev/null +++ b/src/test/mocks/BorrowContract.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "../../Market.sol"; +import "../mocks/WETH9.sol"; + +contract BorrowContract { + Market market; + + constructor(address market_, address payable weth_) { + market = Market(market_); + WETH9(weth_).approve(address(market), type(uint).max); + } + + function borrow(uint amount) external { + market.borrow(amount); + } + + function deposit(uint amount) external { + market.deposit(amount); + } +} \ No newline at end of file diff --git a/src/test/mocks/ERC20.sol b/src/test/mocks/ERC20.sol new file mode 100644 index 00000000..56a850e9 --- /dev/null +++ b/src/test/mocks/ERC20.sol @@ -0,0 +1,184 @@ +pragma solidity ^0.8.13; + +import "./SafeMath.sol"; + +contract ERC20 { + using SafeMath for uint256; + + string public name; + string public symbol; + uint8 public decimals; + uint256 public totalSupply; + address public operator; + address public pendingOperator; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + mapping(address => bool) public minters; + + bytes32 public DOMAIN_SEPARATOR; + // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + bytes32 public constant PERMIT_TYPEHASH = + 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; + mapping(address => uint256) public nonces; + + event Approval( + address indexed owner, + address indexed spender, + uint256 value + ); + event Transfer(address indexed from, address indexed to, uint256 value); + event AddMinter(address indexed minter); + event RemoveMinter(address indexed minter); + event ChangeOperator(address indexed newOperator); + + modifier onlyOperator() { + require(msg.sender == operator, "ONLY OPERATOR"); + _; + } + + constructor( + string memory name_, + string memory symbol_, + uint8 decimals_ + ) { + name = name_; + symbol = symbol_; + decimals = decimals_; + operator = msg.sender; + uint256 chainId; + assembly { + chainId := chainid() + } + DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + keccak256(bytes(name)), + keccak256(bytes("1")), + chainId, + address(this) + ) + ); + } + + function setPendingOperator(address newOperator_) public onlyOperator { + pendingOperator = newOperator_; + } + + function claimOperator() public { + require(msg.sender == pendingOperator, "ONLY PENDING OPERATOR"); + operator = pendingOperator; + pendingOperator = address(0); + emit ChangeOperator(operator); + } + + function addMinter(address minter_) public onlyOperator { + minters[minter_] = true; + emit AddMinter(minter_); + } + + function removeMinter(address minter_) public onlyOperator { + minters[minter_] = false; + emit RemoveMinter(minter_); + } + + function mint(address to, uint256 amount) public { + require( + minters[msg.sender] == true || msg.sender == operator, + "ONLY MINTERS OR OPERATOR" + ); + _mint(to, amount); + } + + function burn(uint256 amount) public { + _burn(msg.sender, amount); + } + + function _mint(address to, uint256 value) internal { + totalSupply = totalSupply.add(value); + balanceOf[to] = balanceOf[to].add(value); + emit Transfer(address(0), to, value); + } + + function _burn(address from, uint256 value) internal { + balanceOf[from] = balanceOf[from].sub(value); + totalSupply = totalSupply.sub(value); + emit Transfer(from, address(0), value); + } + + function _approve( + address owner, + address spender, + uint256 value + ) private { + allowance[owner][spender] = value; + emit Approval(owner, spender, value); + } + + function _transfer( + address from, + address to, + uint256 value + ) private { + balanceOf[from] = balanceOf[from].sub(value); + balanceOf[to] = balanceOf[to].add(value); + emit Transfer(from, to, value); + } + + function approve(address spender, uint256 value) external returns (bool) { + _approve(msg.sender, spender, value); + return true; + } + + function transfer(address to, uint256 value) external returns (bool) { + _transfer(msg.sender, to, value); + return true; + } + + function transferFrom( + address from, + address to, + uint256 value + ) external returns (bool) { + // if (allowance[from][msg.sender] != uint(-1)) { + // allowance[from][msg.sender] = allowance[from][msg.sender].sub(value); + // } + _transfer(from, to, value); + return true; + } + + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + require(deadline >= block.timestamp, "EXPIRED"); + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR, + keccak256( + abi.encode( + PERMIT_TYPEHASH, + owner, + spender, + value, + nonces[owner]++, + deadline + ) + ) + ) + ); + address recoveredAddress = ecrecover(digest, v, r, s); + require( + recoveredAddress != address(0) && recoveredAddress == owner, + "INVALID_SIGNATURE" + ); + _approve(owner, spender, value); + } +} diff --git a/src/test/mocks/EthFeed.sol b/src/test/mocks/EthFeed.sol new file mode 100644 index 00000000..9238c514 --- /dev/null +++ b/src/test/mocks/EthFeed.sol @@ -0,0 +1,27 @@ +pragma solidity ^0.8.13; + +interface IChainlinkFeed { + function decimals() external view returns (uint8); + function latestAnswer() external view returns (uint); +} + +contract EthFeed is IChainlinkFeed { + uint8 decimals_ = 18; + uint price_ = 1600e18; + + function decimals() external view returns (uint8) { + return decimals_; + } + + function latestAnswer() external view returns (uint) { + return price_; + } + + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { + return (0,int(price_),0,0,0); + } + + function changeAnswer(uint price) external { + price_ = price; + } +} diff --git a/src/test/mocks/SafeMath.sol b/src/test/mocks/SafeMath.sol new file mode 100644 index 00000000..5904380e --- /dev/null +++ b/src/test/mocks/SafeMath.sol @@ -0,0 +1,186 @@ +pragma solidity ^0.8.13; + +// From https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/math/Math.sol +// Subject to the MIT license. + +/** + * @dev Wrappers over Solidity's arithmetic operations with added overflow + * checks. + * + * Arithmetic operations in Solidity wrap on overflow. This can easily result + * in bugs, because programmers usually assume that an overflow raises an + * error, which is the standard behavior in high level programming languages. + * `SafeMath` restores this intuition by reverting the transaction when an + * operation overflows. + * + * Using this library instead of the unchecked operations eliminates an entire + * class of bugs, so it's recommended to use it always. + */ +library SafeMath { + /** + * @dev Returns the addition of two unsigned integers, reverting on overflow. + * + * Counterpart to Solidity's `+` operator. + * + * Requirements: + * - Addition cannot overflow. + */ + function add(uint256 a, uint256 b) internal pure returns (uint256) { + uint256 c = a + b; + require(c >= a, "SafeMath: addition overflow"); + + return c; + } + + /** + * @dev Returns the addition of two unsigned integers, reverting with custom message on overflow. + * + * Counterpart to Solidity's `+` operator. + * + * Requirements: + * - Addition cannot overflow. + */ + function add(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + uint256 c = a + b; + require(c >= a, errorMessage); + + return c; + } + + /** + * @dev Returns the subtraction of two unsigned integers, reverting on underflow (when the result is negative). + * + * Counterpart to Solidity's `-` operator. + * + * Requirements: + * - Subtraction cannot underflow. + */ + function sub(uint256 a, uint256 b) internal pure returns (uint256) { + return sub(a, b, "SafeMath: subtraction underflow"); + } + + /** + * @dev Returns the subtraction of two unsigned integers, reverting with custom message on underflow (when the result is negative). + * + * Counterpart to Solidity's `-` operator. + * + * Requirements: + * - Subtraction cannot underflow. + */ + function sub(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + require(b <= a, errorMessage); + uint256 c = a - b; + + return c; + } + + /** + * @dev Returns the multiplication of two unsigned integers, reverting on overflow. + * + * Counterpart to Solidity's `*` operator. + * + * Requirements: + * - Multiplication cannot overflow. + */ + function mul(uint256 a, uint256 b) internal pure returns (uint256) { + // Gas optimization: this is cheaper than requiring 'a' not being zero, but the + // benefit is lost if 'b' is also tested. + // See: https://github.com/OpenZeppelin/openzeppelin-contracts/pull/522 + if (a == 0) { + return 0; + } + + uint256 c = a * b; + require(c / a == b, "SafeMath: multiplication overflow"); + + return c; + } + + /** + * @dev Returns the multiplication of two unsigned integers, reverting on overflow. + * + * Counterpart to Solidity's `*` operator. + * + * Requirements: + * - Multiplication cannot overflow. + */ + function mul(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + // Gas optimization: this is cheaper than requiring 'a' not being zero, but the + // benefit is lost if 'b' is also tested. + // See: https://github.com/OpenZeppelin/openzeppelin-contracts/pull/522 + if (a == 0) { + return 0; + } + + uint256 c = a * b; + require(c / a == b, errorMessage); + + return c; + } + + /** + * @dev Returns the integer division of two unsigned integers. + * Reverts on division by zero. The result is rounded towards zero. + * + * Counterpart to Solidity's `/` operator. Note: this function uses a + * `revert` opcode (which leaves remaining gas untouched) while Solidity + * uses an invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * - The divisor cannot be zero. + */ + function div(uint256 a, uint256 b) internal pure returns (uint256) { + return div(a, b, "SafeMath: division by zero"); + } + + /** + * @dev Returns the integer division of two unsigned integers. + * Reverts with custom message on division by zero. The result is rounded towards zero. + * + * Counterpart to Solidity's `/` operator. Note: this function uses a + * `revert` opcode (which leaves remaining gas untouched) while Solidity + * uses an invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * - The divisor cannot be zero. + */ + function div(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + // Solidity only automatically asserts when dividing by 0 + require(b > 0, errorMessage); + uint256 c = a / b; + // assert(a == b * c + a % b); // There is no case in which this doesn't hold + + return c; + } + + /** + * @dev Returns the remainder of dividing two unsigned integers. (unsigned integer modulo), + * Reverts when dividing by zero. + * + * Counterpart to Solidity's `%` operator. This function uses a `revert` + * opcode (which leaves remaining gas untouched) while Solidity uses an + * invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * - The divisor cannot be zero. + */ + function mod(uint256 a, uint256 b) internal pure returns (uint256) { + return mod(a, b, "SafeMath: modulo by zero"); + } + + /** + * @dev Returns the remainder of dividing two unsigned integers. (unsigned integer modulo), + * Reverts with custom message when dividing by zero. + * + * Counterpart to Solidity's `%` operator. This function uses a `revert` + * opcode (which leaves remaining gas untouched) while Solidity uses an + * invalid opcode to revert (consuming all remaining gas). + * + * Requirements: + * - The divisor cannot be zero. + */ + function mod(uint256 a, uint256 b, string memory errorMessage) internal pure returns (uint256) { + require(b != 0, errorMessage); + return a % b; + } +} diff --git a/src/test/mocks/WBTC.sol b/src/test/mocks/WBTC.sol new file mode 100644 index 00000000..dce9402d --- /dev/null +++ b/src/test/mocks/WBTC.sol @@ -0,0 +1,65 @@ +// Copyright (C) 2015, 2016, 2017 Dapphub + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.13; + +contract WBTC { + string public name = "Wrapped Bitcon"; + string public symbol = "WBTC"; + uint8 public decimals = 8; + + event Approval(address indexed src, address indexed guy, uint wad); + event Transfer(address indexed src, address indexed dst, uint wad); + + mapping (address => uint) public balanceOf; + mapping (address => mapping (address => uint)) public allowance; + + function mint(uint amount, address user) public { + balanceOf[user] += amount; + } + + function totalSupply() public view returns (uint) { + return address(this).balance; + } + + function approve(address guy, uint wad) public returns (bool) { + allowance[msg.sender][guy] = wad; + emit Approval(msg.sender, guy, wad); + return true; + } + + function transfer(address dst, uint wad) public returns (bool) { + return transferFrom(msg.sender, dst, wad); + } + + function transferFrom(address src, address dst, uint wad) + public + returns (bool) + { + require(balanceOf[src] >= wad); + + if (src != msg.sender && allowance[src][msg.sender] != type(uint).max) { + require(allowance[src][msg.sender] >= wad); + allowance[src][msg.sender] -= wad; + } + + balanceOf[src] -= wad; + balanceOf[dst] += wad; + + emit Transfer(src, dst, wad); + + return true; + } +} diff --git a/src/test/mocks/WETH9.sol b/src/test/mocks/WETH9.sol new file mode 100644 index 00000000..bdb614b1 --- /dev/null +++ b/src/test/mocks/WETH9.sol @@ -0,0 +1,77 @@ +// Copyright (C) 2015, 2016, 2017 Dapphub + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +pragma solidity ^0.8.13; + +contract WETH9 { + string public name = "Wrapped Ether"; + string public symbol = "WETH"; + uint8 public decimals = 18; + + event Approval(address indexed src, address indexed guy, uint wad); + event Transfer(address indexed src, address indexed dst, uint wad); + event Deposit(address indexed dst, uint wad); + event Withdrawal(address indexed src, uint wad); + + mapping (address => uint) public balanceOf; + mapping (address => mapping (address => uint)) public allowance; + + receive() external payable { + deposit(); + } + function deposit() public payable { + balanceOf[msg.sender] += msg.value; + emit Deposit(msg.sender, msg.value); + } + function withdraw(uint wad) public { + require(balanceOf[msg.sender] >= wad); + balanceOf[msg.sender] -= wad; + payable(msg.sender).transfer(wad); + emit Withdrawal(msg.sender, wad); + } + + function totalSupply() public view returns (uint) { + return address(this).balance; + } + + function approve(address guy, uint wad) public returns (bool) { + allowance[msg.sender][guy] = wad; + emit Approval(msg.sender, guy, wad); + return true; + } + + function transfer(address dst, uint wad) public returns (bool) { + return transferFrom(msg.sender, dst, wad); + } + + function transferFrom(address src, address dst, uint wad) + public + returns (bool) + { + require(balanceOf[src] >= wad); + + if (src != msg.sender && allowance[src][msg.sender] != type(uint).max) { + require(allowance[src][msg.sender] >= wad); + allowance[src][msg.sender] -= wad; + } + + balanceOf[src] -= wad; + balanceOf[dst] += wad; + + emit Transfer(src, dst, wad); + + return true; + } +} \ No newline at end of file diff --git a/src/test/mocks/WbtcFeed.sol b/src/test/mocks/WbtcFeed.sol new file mode 100644 index 00000000..31aebca6 --- /dev/null +++ b/src/test/mocks/WbtcFeed.sol @@ -0,0 +1,27 @@ +pragma solidity ^0.8.13; + +interface IChainlinkFeed { + function decimals() external view returns (uint8); + function latestAnswer() external view returns (uint); +} + +contract WbtcFeed is IChainlinkFeed { + uint8 decimals_ = 8; + uint price_ = 16000e8; + + function decimals() external view returns (uint8) { + return decimals_; + } + + function latestAnswer() external view returns (uint) { + return price_; + } + + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { + return(0, int(price_), 0, 0, 0); + } + + function changeAnswer(uint price) external { + price_ = price; + } +}