diff --git a/src/interfaces/IBurnableERC20.sol b/src/interfaces/IBurnableERC20.sol index e3e4241..39d8fda 100644 --- a/src/interfaces/IBurnableERC20.sol +++ b/src/interfaces/IBurnableERC20.sol @@ -4,4 +4,5 @@ pragma solidity 0.8.15; interface IBurnableERC20 { function burn(uint256 amount) external; + function burnFrom(address user, uint256 amount) external; } diff --git a/src/minters/BalancedMinter.sol b/src/minters/BalancedMinter.sol new file mode 100644 index 0000000..2e10ce5 --- /dev/null +++ b/src/minters/BalancedMinter.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "./BaseMinter.sol"; + +/** + * @title BalancedMinter + * BOB minting/burning middleware with simple usage quotas. + */ +contract BalancedMinter is BaseMinter { + int128 public mintQuota; // remaining minting quota + int128 public burnQuota; // remaining burning quota + + event UpdateQuotas(int128 mintQuota, int128 burnQuota); + + constructor(address _token, uint128 _mintQuota, uint128 _burnQuota) BaseMinter(_token) { + mintQuota = int128(_mintQuota); + burnQuota = int128(_burnQuota); + } + + /** + * @dev Adjusts mint/burn quotas for the given address. + * Callable only by the contract owner. + * @param _dMint delta for minting quota. + * @param _dBurn delta for burning quota. + */ + function adjustQuotas(int128 _dMint, int128 _dBurn) external onlyOwner { + (int128 newMintQuota, int128 newBurnQuota) = (mintQuota + _dMint, burnQuota + _dBurn); + (mintQuota, burnQuota) = (newMintQuota, newBurnQuota); + + emit UpdateQuotas(newBurnQuota, newBurnQuota); + } + + /** + * @dev Internal function for adjusting quotas on tokens mint. + * @param _amount amount of minted tokens. + */ + function _beforeMint(uint256 _amount) internal override { + int128 amount = int128(uint128(_amount)); + unchecked { + require(mintQuota >= amount, "BalancedMinter: exceeds minting quota"); + (mintQuota, burnQuota) = (mintQuota - amount, burnQuota + amount); + } + } + + /** + * @dev Internal function for adjusting quotas on tokens burn. + * @param _amount amount of burnt tokens. + */ + function _beforeBurn(uint256 _amount) internal override { + int128 amount = int128(uint128(_amount)); + unchecked { + require(burnQuota >= amount, "BalancedMinter: exceeds burning quota"); + (mintQuota, burnQuota) = (mintQuota + amount, burnQuota - amount); + } + } +} diff --git a/src/minters/BaseMinter.sol b/src/minters/BaseMinter.sol new file mode 100644 index 0000000..f92fe70 --- /dev/null +++ b/src/minters/BaseMinter.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "../utils/Ownable.sol"; +import "../interfaces/IMintableERC20.sol"; +import "../interfaces/IBurnableERC20.sol"; +import "../interfaces/IERC677Receiver.sol"; + +/** + * @title BaseMinter + * Base contract for BOB minting/burning middleware + */ +abstract contract BaseMinter is IMintableERC20, IBurnableERC20, IERC677Receiver, Ownable { + address public immutable token; + + mapping(address => bool) public isMinter; + + event Mint(address minter, address to, uint256 amount); + event Burn(address burner, address from, uint256 amount); + + constructor(address _token) { + token = _token; + } + + /** + * @dev Updates mint/burn permissions for the given address. + * Callable only by the contract owner. + * @param _account managed minter account address. + * @param _enabled true, if enabling minting/burning, false otherwise. + */ + function setMinter(address _account, bool _enabled) external onlyOwner { + isMinter[_account] = _enabled; + } + + /** + * @dev Mints the specified amount of tokens. + * This contract should have minting permissions assigned to it in the token contract. + * Callable only by one of the minter addresses. + * @param _to address of the tokens receiver. + * @param _amount amount of tokens to mint. + */ + function mint(address _to, uint256 _amount) external override { + require(isMinter[msg.sender], "BaseMinter: not a minter"); + + _beforeMint(_amount); + IMintableERC20(token).mint(_to, _amount); + + emit Mint(msg.sender, _to, _amount); + } + + /** + * @dev Burns tokens sent to the address. + * Callable only by one of the minter addresses. + * Caller should send specified amount of tokens to this contract, prior to calling burn. + * @param _amount amount of tokens to burn. + */ + function burn(uint256 _amount) external override { + require(isMinter[msg.sender], "BaseMinter: not a burner"); + + _beforeBurn(_amount); + IBurnableERC20(token).burn(_amount); + + emit Burn(msg.sender, msg.sender, _amount); + } + + /** + * @dev Burns pre-approved tokens from the other address. + * Callable only by one of the burner addresses. + * Minters should handle with extra care cases when first argument is not msg.sender. + * @param _from account to burn tokens from. + * @param _amount amount of tokens to burn. Should be less than or equal to account balance. + */ + function burnFrom(address _from, uint256 _amount) external override { + require(isMinter[msg.sender], "BaseMinter: not a burner"); + + _beforeBurn(_amount); + IBurnableERC20(token).burnFrom(_from, _amount); + + emit Burn(msg.sender, _from, _amount); + } + + /** + * @dev ERC677 callback for burning tokens atomically. + * @param _from tokens sender, should correspond to one of the minting addresses. + * @param _amount amount of sent/burnt tokens. + * @param _data extra data, not used. + */ + function onTokenTransfer(address _from, uint256 _amount, bytes calldata _data) external override returns (bool) { + require(msg.sender == address(token), "BaseMinter: not a token"); + require(isMinter[_from], "BaseMinter: not a burner"); + + _beforeBurn(_amount); + IBurnableERC20(token).burn(_amount); + + emit Burn(_from, _from, _amount); + + return true; + } + + function _beforeMint(uint256 _amount) internal virtual; + + function _beforeBurn(uint256 _amount) internal virtual; +} diff --git a/src/minters/DebtMinter.sol b/src/minters/DebtMinter.sol new file mode 100644 index 0000000..2999644 --- /dev/null +++ b/src/minters/DebtMinter.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "./BaseMinter.sol"; + +/** + * @title DebtMinter + * BOB minting/burning middleware for generic debt-minting use-cases. + */ +contract DebtMinter is BaseMinter { + struct Parameters { + uint104 maxDebtLimit; // max possible debt limit + uint104 minDebtLimit; // min possible debt limit + uint48 raiseDelay; // min delay between debt limit raises + uint96 raise; // debt limit raising step + address treasury; // receiver of repaid debt surplus + } + + struct State { + uint104 debtLimit; // current debt limit, minDebtLimit <= debtLimit <= maxDebtLimit + uint104 debt; // current debt value + uint48 lastRaise; // timestamp of last debt limit raise + } + + Parameters internal parameters; + State internal state; + + event UpdateDebt(uint104 debt, uint104 debtLimit); + + constructor( + address _token, + uint104 _maxDebtLimit, + uint104 _minDebtLimit, + uint48 _raiseDelay, + uint96 _raise, + address _treasury + ) + BaseMinter(_token) + { + require(_minDebtLimit + uint104(_raise) <= _maxDebtLimit, "DebtMinter: invalid raise"); + parameters = Parameters(_maxDebtLimit, _minDebtLimit, _raiseDelay, _raise, _treasury); + state = State(_minDebtLimit, 0, uint48(block.timestamp)); + } + + function getState() external view returns (State memory) { + return state; + } + + function getParameters() external view returns (Parameters memory) { + return parameters; + } + + /** + * @dev Tells remaining mint amount subject to immediate debt limit. + * @return available mint amount. + */ + function maxDebtIncrease() external view returns (uint256) { + Parameters memory p = parameters; + State memory s = state; + _updateDebtLimit(p, s); + return s.debtLimit - s.debt; + } + + /** + * @dev Updates limit configuration. + * Callable only by the contract owner. + * @param _params new parameters to apply. + */ + function updateParameters(Parameters calldata _params) external onlyOwner { + require(_params.minDebtLimit + uint104(_params.raise) <= _params.maxDebtLimit, "DebtMinter: invalid raise"); + parameters = _params; + + State memory s = state; + _updateDebtLimit(_params, s); + state = s; + + emit UpdateDebt(s.debt, s.debtLimit); + } + + /** + * @dev Internal function for adjusting debt limits on tokens mint. + * @param _amount amount of minted tokens. + */ + function _beforeMint(uint256 _amount) internal override { + Parameters memory p = parameters; + State memory s = state; + + _updateDebtLimit(p, s); + uint256 newDebt = uint256(s.debt) + _amount; + require(newDebt <= s.debtLimit, "DebtMinter: exceeds debt limit"); + s.debt = uint104(newDebt); + + state = s; + + emit UpdateDebt(s.debt, s.debtLimit); + } + + /** + * @dev Internal function for adjusting debt limits on tokens burn. + * @param _amount amount of burnt tokens. + */ + function _beforeBurn(uint256 _amount) internal override { + Parameters memory p = parameters; + State memory s = state; + + unchecked { + if (_amount <= s.debt) { + s.debt -= uint104(_amount); + } else { + IMintableERC20(token).mint(p.treasury, _amount - s.debt); + s.debt = 0; + } + } + _updateDebtLimit(p, s); + state = s; + + emit UpdateDebt(s.debt, s.debtLimit); + } + + /** + * @dev Internal function for recalculating immediate debt limit. + */ + function _updateDebtLimit(Parameters memory p, State memory s) internal view { + if (s.debt >= p.maxDebtLimit) { + s.debtLimit = s.debt; + } else { + uint104 newDebtLimit = s.debt + p.raise; + if (newDebtLimit < p.minDebtLimit) { + s.debtLimit = p.minDebtLimit; + return; + } + + if (newDebtLimit > p.maxDebtLimit) { + newDebtLimit = p.maxDebtLimit; + } + if (newDebtLimit <= s.debtLimit) { + s.debtLimit = newDebtLimit; + } else if (s.lastRaise + p.raiseDelay < block.timestamp) { + s.debtLimit = newDebtLimit; + s.lastRaise = uint48(block.timestamp); + } + } + } +} diff --git a/src/minters/FaucetMinter.sol b/src/minters/FaucetMinter.sol new file mode 100644 index 0000000..bdcb749 --- /dev/null +++ b/src/minters/FaucetMinter.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "../interfaces/IMintableERC20.sol"; + +/** + * @title FaucetMinter + * Simplest contract for faucet minting. + */ +contract FaucetMinter is IMintableERC20 { + address public immutable token; + uint256 public immutable limit; + + event Mint(address minter, address to, uint256 amount); + + constructor(address _token, uint256 _limit) { + token = _token; + limit = _limit; + } + + /** + * @dev Mints the specified amount of tokens. + * This contract should have minting permissions assigned to it in the token contract. + * @param _to address of the tokens receiver. + * @param _amount amount of tokens to mint. + */ + function mint(address _to, uint256 _amount) external override { + require(_amount <= limit, "FaucetMinter: too much"); + + IMintableERC20(token).mint(_to, _amount); + + emit Mint(msg.sender, _to, _amount); + } +} diff --git a/src/minters/FlashMinter.sol b/src/minters/FlashMinter.sol new file mode 100644 index 0000000..d601abf --- /dev/null +++ b/src/minters/FlashMinter.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "@openzeppelin/contracts/interfaces/IERC3156FlashLender.sol"; +import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "../interfaces/IMintableERC20.sol"; +import "../interfaces/IBurnableERC20.sol"; +import "../utils/Ownable.sol"; + +/** + * @title FlashMinter + * BOB flash minter middleware. + */ +contract FlashMinter is IERC3156FlashLender, ReentrancyGuard, Ownable { + bytes32 private constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan"); + + address public immutable token; + + uint96 public limit; // max limit for flash mint amount + address public treasury; // receiver of flash mint fees + + uint64 public fee; // fee percentage * 1 ether + uint96 public maxFee; // max fee in absolute values + + event FlashMint(address indexed _receiver, uint256 _amount, uint256 _fee); + + constructor(address _token, uint96 _limit, address _treasury, uint64 _fee, uint96 _maxFee) { + require(_treasury != address(0) || _fee == 0, "FlashMinter: invalid fee config"); + token = _token; + limit = _limit; + treasury = _treasury; + _setFees(_fee, _maxFee); + } + + function updateConfig(uint96 _limit, address _treasury, uint64 _fee, uint96 _maxFee) external onlyOwner { + require(_treasury != address(0) || _fee == 0, "FlashMinter: invalid fee config"); + limit = _limit; + treasury = _treasury; + _setFees(_fee, _maxFee); + } + + function _setFees(uint64 _fee, uint96 _maxFee) internal { + require(_fee <= 0.01 ether, "FlashMinter: fee too large"); + (fee, maxFee) = (_fee, _maxFee); + } + + /** + * @dev Returns the maximum amount of tokens available for loan. + * @param _token The address of the token that is requested. + * @return The amount of token that can be loaned. + */ + function maxFlashLoan(address _token) public view virtual override returns (uint256) { + return token == _token ? limit : 0; + } + + /** + * @dev Returns the fee applied when doing flash loans. + * @param _token The token to be flash loaned. + * @param _amount The amount of tokens to be loaned. + * @return The fees applied to the corresponding flash loan. + */ + function flashFee(address _token, uint256 _amount) public view virtual override returns (uint256) { + require(token == _token, "FlashMinter: wrong token"); + return _flashFee(_amount); + } + + /** + * @dev Returns the fee applied when doing flash loans. + * @param _amount The amount of tokens to be loaned. + * @return The fees applied to the corresponding flash loan. + */ + function _flashFee(uint256 _amount) internal view virtual returns (uint256) { + (uint64 _fee, uint96 _maxFee) = (fee, maxFee); + uint256 flashFee = _amount * _fee / 1 ether; + return flashFee > _maxFee ? _maxFee : flashFee; + } + + /** + * @dev Performs a flash loan. New tokens are minted and sent to the + * `receiver`, who is required to implement the IERC3156FlashBorrower + * interface. By the end of the flash loan, the receiver is expected to own + * amount + fee tokens and have them approved back to the token contract itself so + * they can be burned. + * @param _receiver The receiver of the flash loan. Should implement the + * IERC3156FlashBorrower.onFlashLoan interface. + * @param _token The token to be flash loaned. Only configured token is + * supported. + * @param _amount The amount of tokens to be loaned. + * @param _data An arbitrary data that is passed to the receiver. + * @return `true` if the flash loan was successful. + */ + function flashLoan( + IERC3156FlashBorrower _receiver, + address _token, + uint256 _amount, + bytes calldata _data + ) + public + override + nonReentrant + returns (bool) + { + require(token == _token, "FlashMinter: wrong token"); + require(_amount <= limit, "FlashMinter: amount exceeds maxFlashLoan"); + uint256 fee = _flashFee(_amount); + IMintableERC20(_token).mint(address(_receiver), _amount); + require( + _receiver.onFlashLoan(msg.sender, _token, _amount, fee, _data) == _RETURN_VALUE, + "FlashMinter: invalid return value" + ); + IBurnableERC20(_token).burnFrom(address(_receiver), _amount); + if (fee > 0) { + IERC20(_token).transferFrom(address(_receiver), treasury, fee); + } + + emit FlashMint(address(_receiver), _amount, fee); + + return true; + } +} diff --git a/src/minters/SurplusMinter.sol b/src/minters/SurplusMinter.sol new file mode 100644 index 0000000..8f3cd3b --- /dev/null +++ b/src/minters/SurplusMinter.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "../utils/Ownable.sol"; +import "../interfaces/IMintableERC20.sol"; +import "../interfaces/IBurnableERC20.sol"; +import "../interfaces/IERC677Receiver.sol"; + +/** + * @title SurplusMinter + * Managing realized and unrealized BOB surplus from debt-minting use-cases. + */ +contract SurplusMinter is IERC677Receiver, Ownable { + address public immutable token; + + mapping(address => bool) public isMinter; + + uint256 public surplus; // unrealized surplus + + event WithdrawSurplus(address indexed to, uint256 realized, uint256 unrealized); + event AddSurplus(address indexed from, uint256 unrealized); + + constructor(address _token) { + token = _token; + } + + /** + * @dev Updates surplus mint permissions for the given address. + * Callable only by the contract owner. + * @param _account managed minter account address. + * @param _enabled true, if enabling surplus minting, false otherwise. + */ + function setMinter(address _account, bool _enabled) external onlyOwner { + isMinter[_account] = _enabled; + } + + /** + * @dev Records potential unrealized surplus. + * Callable only by the pre-approved surplus minter. + * Once unrealized surplus is realized, it should be transferred to this contract via transferAndCall. + * @param _surplus unrealized surplus to add. + */ + function add(uint256 _surplus) external { + require(isMinter[msg.sender], "SurplusMinter: not a minter"); + + surplus += _surplus; + + emit AddSurplus(msg.sender, _surplus); + } + + /** + * @dev ERC677 callback. Converts previously recorded unrealized surplus into the realized one. + * If converted amount exceeds unrealized surplus, remainder is burnt to account for unrealized interest withdrawn in advance. + * Callable by anyone. + * @param _from tokens sender. + * @param _amount amount of tokens corresponding to realized interest. + * @param _data optional extra data, encoded uint256 amount of unrealized surplus to convert. Defaults to _amount. + */ + function onTokenTransfer(address _from, uint256 _amount, bytes calldata _data) external override returns (bool) { + require(msg.sender == token, "SurplusMinter: invalid caller"); + + uint256 unrealized = _amount; + if (_data.length == 32) { + unrealized = abi.decode(_data, (uint256)); + require(unrealized <= _amount, "SurplusMinter: invalid value"); + } + + uint256 currentSurplus = surplus; + if (currentSurplus >= unrealized) { + unchecked { + surplus = currentSurplus - unrealized; + } + } else { + IBurnableERC20(token).burn(unrealized - currentSurplus); + unrealized = currentSurplus; + surplus = 0; + } + emit WithdrawSurplus(address(this), 0, unrealized); + + return true; + } + + /** + * @dev Burns potential unrealized surplus. + * Callable only by the contract owner. + * Intended to be used for cancelling mistakenly accounted surplus. + * @param _surplus unrealized surplus to cancel. + */ + function burn(uint256 _surplus) external onlyOwner { + require(_surplus <= surplus, "SurplusMinter: exceeds surplus"); + unchecked { + surplus -= _surplus; + } + emit WithdrawSurplus(address(0), 0, _surplus); + } + + /** + * @dev Withdraws surplus. + * Callable only by the contract owner. + * Withdrawing realized surplus is prioritised, unrealized surplus is minted only + * if realized surplus is not enough to cover the requested amount. + * @param _surplus surplus amount to withdraw/mint. + */ + function withdraw(address _to, uint256 _surplus) external onlyOwner { + uint256 realized = IERC20(token).balanceOf(address(this)); + + if (_surplus > realized) { + uint256 unrealized = _surplus - realized; + require(unrealized <= surplus, "SurplusMinter: exceeds surplus"); + unchecked { + surplus -= unrealized; + } + + IERC20(token).transfer(_to, realized); + IMintableERC20(token).mint(_to, unrealized); + + emit WithdrawSurplus(_to, realized, unrealized); + } else { + IERC20(token).transfer(_to, _surplus); + + emit WithdrawSurplus(_to, _surplus, 0); + } + } +} diff --git a/src/token/ERC20MintBurn.sol b/src/token/ERC20MintBurn.sol index c7e0f43..9731cc7 100644 --- a/src/token/ERC20MintBurn.sol +++ b/src/token/ERC20MintBurn.sol @@ -3,7 +3,6 @@ pragma solidity 0.8.15; import "../utils/Ownable.sol"; -import "../interfaces/IMintableERC20.sol"; import "./BaseERC20.sol"; import "../interfaces/IMintableERC20.sol"; import "../interfaces/IBurnableERC20.sol"; @@ -58,4 +57,18 @@ abstract contract ERC20MintBurn is IMintableERC20, IBurnableERC20, Ownable, Base _burn(msg.sender, _value); } + + /** + * @dev Burns pre-approved tokens from the other address. + * Callable only by one of the burner addresses. + * @param _from account to burn tokens from. + * @param _value amount of tokens to burn. Should be less than or equal to account balance. + */ + function burnFrom(address _from, uint256 _value) external virtual { + require(isBurner(msg.sender), "ERC20MintBurn: not a burner"); + + _spendAllowance(_from, msg.sender, _value); + + _burn(_from, _value); + } } diff --git a/test/BobToken.t.sol b/test/BobToken.t.sol index 77e272a..ebd3a9f 100644 --- a/test/BobToken.t.sol +++ b/test/BobToken.t.sol @@ -94,6 +94,30 @@ contract BobTokenTest is Test, EIP2470Test { assertEq(bob.balanceOf(user2), 0 ether); } + function testBurnFrom() public { + vm.prank(user1); + bob.mint(user1, 1 ether); + + vm.expectRevert("ERC20MintBurn: not a burner"); + bob.burnFrom(user1, 1 ether); + + vm.prank(user2); + vm.expectRevert("ERC20: insufficient allowance"); + bob.burnFrom(user1, 1 ether); + + vm.prank(user1); + bob.approve(user2, 10 ether); + + vm.prank(user2); + vm.expectEmit(true, true, false, true); + emit Transfer(user1, address(0), 1 ether); + bob.burnFrom(user1, 1 ether); + + assertEq(bob.totalSupply(), 0); + assertEq(bob.balanceOf(user1), 0); + assertEq(bob.allowance(user1, user2), 9 ether); + } + function testMinterChange() public { vm.expectRevert("Ownable: caller is not the owner"); bob.updateMinter(user3, true, true); diff --git a/test/minters/BalancedMinter.t.sol b/test/minters/BalancedMinter.t.sol new file mode 100644 index 0000000..731d924 --- /dev/null +++ b/test/minters/BalancedMinter.t.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "forge-std/Test.sol"; +import "../shared/Env.t.sol"; +import "../../src/proxy/EIP1967Proxy.sol"; +import "../../src/BobToken.sol"; +import "../../src/minters/BalancedMinter.sol"; + +contract BalancedMinterTest is Test { + BobToken bob; + BalancedMinter minter; + + function setUp() public { + EIP1967Proxy bobProxy = new EIP1967Proxy(address(this), mockImpl, ""); + BobToken bobImpl = new BobToken(address(bobProxy)); + bobProxy.upgradeTo(address(bobImpl)); + bob = BobToken(address(bobProxy)); + + minter = new BalancedMinter(address(bob), 200 ether, 100 ether); + + bob.updateMinter(address(minter), true, true); + bob.updateMinter(address(this), true, true); + } + + function testMintPermissions() public { + vm.expectRevert("BaseMinter: not a minter"); + minter.mint(user3, 1 ether); + vm.expectRevert("BaseMinter: not a burner"); + minter.burn(1 ether); + + vm.startPrank(deployer); + vm.expectRevert("Ownable: caller is not the owner"); + minter.setMinter(deployer, true); + vm.expectRevert("Ownable: caller is not the owner"); + minter.adjustQuotas(200 ether, 100 ether); + vm.stopPrank(); + + minter.setMinter(deployer, true); + minter.adjustQuotas(200 ether, 100 ether); + + vm.expectRevert("BaseMinter: not a minter"); + minter.mint(user1, 1 ether); + + vm.prank(deployer); + minter.mint(user1, 1 ether); + } + + function testQuotas() public { + minter.setMinter(address(this), true); + + assertEq(minter.mintQuota(), 200 ether); + assertEq(minter.burnQuota(), 100 ether); + + minter.mint(user1, 10 ether); + + assertEq(minter.mintQuota(), 190 ether); + assertEq(minter.burnQuota(), 110 ether); + + vm.prank(user1); + bob.transfer(address(minter), 5 ether); + minter.burn(5 ether); + + assertEq(minter.mintQuota(), 195 ether); + assertEq(minter.burnQuota(), 105 ether); + } + + function testExceedingQuotas() public { + bob.mint(address(this), 200 ether); + minter.setMinter(address(this), true); + + vm.expectRevert("BalancedMinter: exceeds minting quota"); + minter.mint(address(this), 300 ether); + minter.mint(address(this), 200 ether); + + bob.transfer(address(minter), 200 ether); + minter.burn(200 ether); + + assertEq(minter.mintQuota(), 200 ether); + assertEq(minter.burnQuota(), 100 ether); + + bob.transfer(address(minter), 200 ether); + vm.expectRevert("BalancedMinter: exceeds burning quota"); + minter.burn(200 ether); + minter.burn(100 ether); + } + + function testBurnWithTransferAndCall() public { + bob.mint(address(this), 200 ether); + bob.mint(user1, 200 ether); + minter.setMinter(address(this), true); + + vm.prank(user1); + vm.expectRevert("BaseMinter: not a burner"); + bob.transferAndCall(address(minter), 10 ether, ""); + vm.expectRevert("BalancedMinter: exceeds burning quota"); + bob.transferAndCall(address(minter), 110 ether, ""); + bob.transferAndCall(address(minter), 10 ether, ""); + + assertEq(minter.mintQuota(), 210 ether); + assertEq(minter.burnQuota(), 90 ether); + } + + function testBurnFrom() public { + bob.mint(address(this), 200 ether); + bob.mint(user1, 200 ether); + minter.setMinter(address(this), true); + + vm.expectRevert("ERC20: insufficient allowance"); + minter.burnFrom(user1, 10 ether); + + vm.prank(user1); + bob.approve(address(minter), 110 ether); + + vm.expectRevert("BalancedMinter: exceeds burning quota"); + minter.burnFrom(user1, 110 ether); + minter.burnFrom(user1, 10 ether); + + assertEq(minter.mintQuota(), 210 ether); + assertEq(minter.burnQuota(), 90 ether); + } + + function testAdjustQuotas() public { + assertEq(minter.mintQuota(), 200 ether); + assertEq(minter.burnQuota(), 100 ether); + + minter.adjustQuotas(10 ether, -20 ether); + + assertEq(minter.mintQuota(), 210 ether); + assertEq(minter.burnQuota(), 80 ether); + + minter.adjustQuotas(-20 ether, 10 ether); + + assertEq(minter.mintQuota(), 190 ether); + assertEq(minter.burnQuota(), 90 ether); + + minter.adjustQuotas(-200 ether, -200 ether); + + assertEq(minter.mintQuota(), -10 ether); + assertEq(minter.burnQuota(), -110 ether); + + minter.adjustQuotas(200 ether, 100 ether); + + assertEq(minter.mintQuota(), 190 ether); + assertEq(minter.burnQuota(), -10 ether); + } + + function _setupDualMinter() internal returns (BalancedMinter) { + BalancedMinter minter2 = new BalancedMinter(address(bob), 100 ether, 200 ether); + minter.setMinter(address(this), true); + minter2.setMinter(address(this), true); + bob.updateMinter(address(minter2), true, true); + bob.mint(address(minter), 1000 ether); + bob.mint(address(minter2), 1000 ether); + return minter2; + } + + function testMultiChain() public { + BalancedMinter minter2 = _setupDualMinter(); + + minter.burn(60 ether); // 200/100 -> 260/40 + minter2.mint(user1, 60 ether); // 100/200 -> 40/260 + + vm.expectRevert("BalancedMinter: exceeds burning quota"); + minter.burn(60 ether); + + minter2.adjustQuotas(50 ether, -50 ether); // 40/260 -> 90/210 + minter.adjustQuotas(-50 ether, 50 ether); // 260/40 -> 210/90 + + minter.burn(60 ether); // 210/90 -> 270/30 + minter2.mint(user1, 60 ether); // 90/210 -> 30/270 + + minter2.burn(250 ether); // 30/270 -> 280/20 + minter2.adjustQuotas(50 ether, -50 ether); // 280/20 -> 330/-30 + minter.mint(user1, 250 ether); // 270/30 -> 20/280 + minter.adjustQuotas(-50 ether, 50 ether); // -30/330 + + minter.burn(100 ether); // -30/330 -> 70/230 + minter2.mint(user1, 100 ether); // 330/-30 -> 230/70 + + assertEq(minter.mintQuota(), 70 ether); + assertEq(minter.burnQuota(), 230 ether); + assertEq(minter2.mintQuota(), 230 ether); + assertEq(minter2.burnQuota(), 70 ether); + } + + function testStuckFailedMint() public { + BalancedMinter minter2 = _setupDualMinter(); + + // burn is executed before first gov limits adjustment is made + minter2.burn(160 ether); // 100/200 -> 260/40 + // this leads to a negative burn quota + minter2.adjustQuotas(50 ether, -50 ether); // 260/40 -> 310/-10 + // second gov limits adjustment is executed before mint + minter.adjustQuotas(-50 ether, 50 ether); // 200/100 -> 150/150 + // mint fails, as the quota was already adjusted + vm.expectRevert("BalancedMinter: exceeds minting quota"); + minter.mint(user1, 160 ether); + + // resolve failed mint manually + minter.adjustQuotas(-160 ether, 160 ether); // 150/150 -> -10/310 + bob.mint(user1, 160 ether); + + assertEq(minter.mintQuota(), -10 ether); + assertEq(minter.burnQuota(), 310 ether); + assertEq(minter2.mintQuota(), 310 ether); + assertEq(minter2.burnQuota(), -10 ether); + } +} diff --git a/test/minters/DebtMinter.t.sol b/test/minters/DebtMinter.t.sol new file mode 100644 index 0000000..6e8d85a --- /dev/null +++ b/test/minters/DebtMinter.t.sol @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "forge-std/Test.sol"; +import "../shared/Env.t.sol"; +import "../../src/proxy/EIP1967Proxy.sol"; +import "../../src/BobToken.sol"; +import "../../src/minters/DebtMinter.sol"; + +contract DebtMinterTest is Test { + BobToken bob; + DebtMinter minter; + + event UpdateDebt(uint104 debt, uint104 debtLimit); + + function setUp() public { + EIP1967Proxy bobProxy = new EIP1967Proxy(address(this), mockImpl, ""); + BobToken bobImpl = new BobToken(address(bobProxy)); + bobProxy.upgradeTo(address(bobImpl)); + bob = BobToken(address(bobProxy)); + + minter = new DebtMinter(address(bob), 800 ether, 400 ether, 12 hours, 200 ether, user1); + + bob.updateMinter(address(minter), true, true); + bob.updateMinter(address(this), true, true); + + minter.setMinter(address(this), true); + + vm.warp(block.timestamp + 1 days); + + vm.prank(user1); + bob.approve(address(minter), 10000 ether); + vm.prank(user2); + bob.approve(address(minter), 10000 ether); + } + + function testGetters() public { + minter.mint(user1, 150 ether); + + assertEq(minter.getState().debt, 150 ether); + assertEq(minter.getState().debtLimit, 400 ether); + assertEq(minter.getState().lastRaise, 1); + + assertEq(minter.getParameters().maxDebtLimit, 800 ether); + assertEq(minter.getParameters().minDebtLimit, 400 ether); + assertEq(minter.getParameters().raiseDelay, 12 hours); + assertEq(minter.getParameters().raise, 200 ether); + assertEq(minter.getParameters().treasury, user1); + } + + function testMintBurnBalanceChange() public { + minter.mint(user1, 150 ether); + assertEq(bob.balanceOf(user1), 150 ether); + + minter.burnFrom(user1, 50 ether); + assertEq(bob.balanceOf(user1), 100 ether); + + vm.prank(user1); + bob.transfer(address(minter), 50 ether); + minter.burn(50 ether); + assertEq(bob.balanceOf(user1), 50 ether); + assertEq(bob.balanceOf(address(minter)), 0 ether); + + vm.prank(user1); + bob.transfer(address(this), 50 ether); + bob.transferAndCall(address(minter), 50 ether, ""); + assertEq(bob.balanceOf(user1), 0 ether); + assertEq(bob.balanceOf(address(minter)), 0 ether); + } + + function testSimpleDebtLimitIncrease() public { + assertEq(minter.getState().debt, 0); + assertEq(minter.getState().debtLimit, 400 ether); + assertEq(minter.maxDebtIncrease(), 400 ether); + + minter.mint(user1, 150 ether); + + assertEq(minter.getState().debt, 150 ether); + assertEq(minter.getState().debtLimit, 400 ether); + assertEq(minter.maxDebtIncrease(), 250 ether); + + minter.mint(user1, 150 ether); + + assertEq(minter.getState().debt, 300 ether); + assertEq(minter.getState().debtLimit, 400 ether); + assertEq(minter.maxDebtIncrease(), 200 ether); + + minter.mint(user1, 150 ether); + + assertEq(minter.getState().debt, 450 ether); + assertEq(minter.getState().debtLimit, 500 ether); + assertEq(minter.maxDebtIncrease(), 50 ether); + + vm.expectRevert("DebtMinter: exceeds debt limit"); + minter.mint(user1, 150 ether); + + vm.warp(block.timestamp + 1 days); + + minter.mint(user1, 150 ether); + assertEq(minter.getState().debt, 600 ether); + assertEq(minter.getState().debtLimit, 650 ether); + assertEq(minter.maxDebtIncrease(), 50 ether); + + vm.expectRevert("DebtMinter: exceeds debt limit"); + minter.mint(user1, 150 ether); + + vm.warp(block.timestamp + 1 days); + + minter.mint(user1, 150 ether); + assertEq(minter.getState().debt, 750 ether); + assertEq(minter.getState().debtLimit, 800 ether); + assertEq(minter.maxDebtIncrease(), 50 ether); + + vm.warp(block.timestamp + 1 days); + + assertEq(minter.getState().debt, 750 ether); + assertEq(minter.getState().debtLimit, 800 ether); + assertEq(minter.maxDebtIncrease(), 50 ether); + + vm.expectRevert("DebtMinter: exceeds debt limit"); + minter.mint(user1, 51 ether); + + minter.mint(user1, 30 ether); + + assertEq(minter.getState().debt, 780 ether); + assertEq(minter.getState().debtLimit, 800 ether); + assertEq(minter.maxDebtIncrease(), 20 ether); + + vm.expectRevert("DebtMinter: exceeds debt limit"); + minter.mint(user1, 21 ether); + + minter.mint(user1, 20 ether); + + assertEq(minter.getState().debt, 800 ether); + assertEq(minter.getState().debtLimit, 800 ether); + assertEq(minter.maxDebtIncrease(), 0); + } + + function testSimpleDebtLimitDecrease() public { + minter.mint(user1, 400 ether); + minter.mint(user1, 200 ether); + vm.expectRevert("DebtMinter: exceeds debt limit"); + minter.mint(user1, 200 ether); + vm.warp(block.timestamp + 1 days); + minter.mint(user1, 200 ether); + + assertEq(minter.getState().debt, 800 ether); + assertEq(minter.getState().debtLimit, 800 ether); + assertEq(minter.maxDebtIncrease(), 0); + + minter.burnFrom(user1, 150 ether); + + assertEq(minter.getState().debt, 650 ether); + assertEq(minter.getState().debtLimit, 800 ether); + assertEq(minter.maxDebtIncrease(), 150 ether); + + minter.burnFrom(user1, 150 ether); + + assertEq(minter.getState().debt, 500 ether); + assertEq(minter.getState().debtLimit, 700 ether); + assertEq(minter.maxDebtIncrease(), 200 ether); + } + + function testParamsIncrease() public { + minter.mint(user1, 350 ether); + minter.mint(user1, 100 ether); + vm.warp(block.timestamp + 1 days); + + assertEq(minter.getState().debt, 450 ether); + assertEq(minter.getState().debtLimit, 550 ether); + assertEq(minter.maxDebtIncrease(), 200 ether); + + minter.updateParameters(DebtMinter.Parameters(1000 ether, 400 ether, 12 hours, 250 ether, user1)); + + assertEq(minter.getState().debt, 450 ether); + assertEq(minter.getState().debtLimit, 700 ether); + assertEq(minter.maxDebtIncrease(), 250 ether); + + minter.updateParameters(DebtMinter.Parameters(2000 ether, 800 ether, 12 hours, 300 ether, user1)); + + assertEq(minter.getState().debt, 450 ether); + assertEq(minter.getState().debtLimit, 800 ether); + assertEq(minter.maxDebtIncrease(), 350 ether); + + minter.mint(user1, 290 ether); + + assertEq(minter.getState().debt, 740 ether); + assertEq(minter.getState().debtLimit, 800 ether); + assertEq(minter.maxDebtIncrease(), 60 ether); + + vm.warp(block.timestamp + 1 days); + + assertEq(minter.getState().debt, 740 ether); + assertEq(minter.getState().debtLimit, 800 ether); + assertEq(minter.maxDebtIncrease(), 300 ether); + + minter.mint(user1, 10 ether); + + assertEq(minter.getState().debt, 750 ether); + assertEq(minter.getState().debtLimit, 1040 ether); + assertEq(minter.maxDebtIncrease(), 290 ether); + + minter.mint(user1, 250 ether); + + assertEq(minter.getState().debt, 1000 ether); + assertEq(minter.getState().debtLimit, 1040 ether); + assertEq(minter.maxDebtIncrease(), 40 ether); + } + + function testParamsDecrease() public { + minter.mint(user1, 350 ether); + minter.mint(user1, 100 ether); + vm.warp(block.timestamp + 1 days); + + assertEq(minter.getState().debt, 450 ether); + assertEq(minter.getState().debtLimit, 550 ether); + assertEq(minter.maxDebtIncrease(), 200 ether); + + minter.updateParameters(DebtMinter.Parameters(600 ether, 400 ether, 12 hours, 200 ether, user1)); + + assertEq(minter.getState().debt, 450 ether); + assertEq(minter.getState().debtLimit, 600 ether); + assertEq(minter.maxDebtIncrease(), 150 ether); + + minter.updateParameters(DebtMinter.Parameters(500 ether, 400 ether, 12 hours, 100 ether, user1)); + + assertEq(minter.getState().debt, 450 ether); + assertEq(minter.getState().debtLimit, 500 ether); + assertEq(minter.maxDebtIncrease(), 50 ether); + + vm.expectRevert("DebtMinter: exceeds debt limit"); + minter.mint(user1, 60 ether); + minter.mint(user1, 50 ether); + vm.warp(block.timestamp + 1 days); + + assertEq(minter.getState().debt, 500 ether); + assertEq(minter.getState().debtLimit, 500 ether); + assertEq(minter.maxDebtIncrease(), 0 ether); + + minter.burnFrom(user1, 50 ether); + + assertEq(minter.getState().debt, 450 ether); + assertEq(minter.getState().debtLimit, 500 ether); + assertEq(minter.maxDebtIncrease(), 50 ether); + + minter.updateParameters(DebtMinter.Parameters(300 ether, 200 ether, 12 hours, 100 ether, user1)); + + assertEq(minter.getState().debt, 450 ether); + assertEq(minter.getState().debtLimit, 450 ether); + assertEq(minter.maxDebtIncrease(), 0 ether); + + minter.burnFrom(user1, 100 ether); + + assertEq(minter.getState().debt, 350 ether); + assertEq(minter.getState().debtLimit, 350 ether); + assertEq(minter.maxDebtIncrease(), 0 ether); + + minter.burnFrom(user1, 60 ether); + + assertEq(minter.getState().debt, 290 ether); + assertEq(minter.getState().debtLimit, 300 ether); + assertEq(minter.maxDebtIncrease(), 10 ether); + + minter.burnFrom(user1, 100 ether); + + assertEq(minter.getState().debt, 190 ether); + assertEq(minter.getState().debtLimit, 290 ether); + assertEq(minter.maxDebtIncrease(), 100 ether); + + minter.burnFrom(user1, 100 ether); + + assertEq(minter.getState().debt, 90 ether); + assertEq(minter.getState().debtLimit, 200 ether); + assertEq(minter.maxDebtIncrease(), 110 ether); + + minter.updateParameters(DebtMinter.Parameters(300 ether, 100 ether, 12 hours, 100 ether, user1)); + + assertEq(minter.getState().debt, 90 ether); + assertEq(minter.getState().debtLimit, 190 ether); + assertEq(minter.maxDebtIncrease(), 100 ether); + + minter.updateParameters(DebtMinter.Parameters(0 ether, 0 ether, 12 hours, 0 ether, user1)); + + assertEq(minter.getState().debt, 90 ether); + assertEq(minter.getState().debtLimit, 90 ether); + assertEq(minter.maxDebtIncrease(), 0 ether); + + minter.burnFrom(user1, 70 ether); + + assertEq(minter.getState().debt, 20 ether); + assertEq(minter.getState().debtLimit, 20 ether); + assertEq(minter.maxDebtIncrease(), 0 ether); + + minter.burnFrom(user1, 20 ether); + + assertEq(minter.getState().debt, 0 ether); + assertEq(minter.getState().debtLimit, 0 ether); + assertEq(minter.maxDebtIncrease(), 0 ether); + } + + function testBurnExcess() public { + minter.mint(user2, 350 ether); + bob.mint(user2, 100 ether); + + assertEq(bob.totalSupply(), 450 ether); + assertEq(bob.balanceOf(user2), 450 ether); + assertEq(minter.getState().debt, 350 ether); + assertEq(minter.getState().debtLimit, 400 ether); + assertEq(minter.maxDebtIncrease(), 200 ether); + + minter.burnFrom(user2, 410 ether); + + assertEq(bob.totalSupply(), 100 ether); + assertEq(bob.balanceOf(user2), 40 ether); + assertEq(bob.balanceOf(user1), 60 ether); + assertEq(minter.getState().debt, 0 ether); + assertEq(minter.getState().debtLimit, 400 ether); + assertEq(minter.maxDebtIncrease(), 400 ether); + } + + function testDebtEmit() public { + vm.expectEmit(true, false, false, true); + emit UpdateDebt(350 ether, 400 ether); + minter.mint(user1, 350 ether); + + vm.expectEmit(true, false, false, true); + emit UpdateDebt(450 ether, 550 ether); + minter.mint(user1, 100 ether); + + vm.warp(block.timestamp + 1 days); + + vm.expectEmit(true, false, false, true); + emit UpdateDebt(420 ether, 620 ether); + minter.burnFrom(user1, 30 ether); + } +} diff --git a/test/minters/FlashMinter.t.sol b/test/minters/FlashMinter.t.sol new file mode 100644 index 0000000..db14705 --- /dev/null +++ b/test/minters/FlashMinter.t.sol @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "forge-std/Test.sol"; +import "../shared/Env.t.sol"; +import "../../src/proxy/EIP1967Proxy.sol"; +import "../../src/BobToken.sol"; +import "../../src/minters/FlashMinter.sol"; +import "../mocks/ERC3156FlashBorrowerMock.sol"; + +contract FlashMinterTest is Test { + BobToken bob; + FlashMinter minter; + + function setUp() public { + EIP1967Proxy bobProxy = new EIP1967Proxy(address(this), mockImpl, ""); + BobToken bobImpl = new BobToken(address(bobProxy)); + bobProxy.upgradeTo(address(bobImpl)); + bob = BobToken(address(bobProxy)); + + minter = new FlashMinter(address(bob), 1000 ether, user1, 0.001 ether, 0.1 ether); + + bob.updateMinter(address(minter), true, true); + bob.updateMinter(address(this), true, true); + + vm.warp(block.timestamp + 1 days); + } + + function testGetters() public { + assertEq(minter.flashFee(address(bob), 50 ether), 0.05 ether); + assertEq(minter.flashFee(address(bob), 100 ether), 0.1 ether); + assertEq(minter.flashFee(address(bob), 500 ether), 0.1 ether); + assertEq(minter.maxFlashLoan(address(bob)), 1000 ether); + } + + function testFlashLoan() public { + ERC3156FlashBorrowerMock mock = new ERC3156FlashBorrowerMock(address(bob), false, false); + vm.expectRevert(bytes("E1")); + minter.flashLoan(IERC3156FlashBorrower(mock), address(bob), 100 ether, ""); + + mock = new ERC3156FlashBorrowerMock(address(minter), false, false); + vm.expectRevert("FlashMinter: invalid return value"); + minter.flashLoan(IERC3156FlashBorrower(mock), address(bob), 100 ether, ""); + + mock = new ERC3156FlashBorrowerMock(address(minter), true, false); + vm.expectRevert("ERC20: insufficient allowance"); + minter.flashLoan(IERC3156FlashBorrower(mock), address(bob), 100 ether, ""); + + mock = new ERC3156FlashBorrowerMock(address(minter), true, true); + vm.expectRevert("ERC20: amount exceeds balance"); + minter.flashLoan(IERC3156FlashBorrower(mock), address(bob), 100 ether, ""); + + mock = new ERC3156FlashBorrowerMock(address(minter), true, true); + vm.expectRevert("FlashMinter: amount exceeds maxFlashLoan"); + minter.flashLoan(IERC3156FlashBorrower(mock), address(bob), 2000 ether, ""); + + mock = new ERC3156FlashBorrowerMock(address(minter), true, true); + bob.mint(address(mock), minter.flashFee(address(bob), 100 ether)); + minter.flashLoan(IERC3156FlashBorrower(mock), address(bob), 100 ether, ""); + + assertEq(bob.totalSupply(), 0.1 ether); + assertEq(bob.balanceOf(address(minter)), 0); + assertEq(bob.balanceOf(address(mock)), 0); + assertEq(bob.balanceOf(address(user1)), 0.1 ether); + } + + function testUpdateConfig() external { + ERC3156FlashBorrowerMock mock = new ERC3156FlashBorrowerMock(address(minter), true, true); + bob.mint(address(mock), minter.flashFee(address(bob), 100 ether)); + minter.flashLoan(IERC3156FlashBorrower(mock), address(bob), 100 ether, ""); + + assertEq(bob.totalSupply(), 0.1 ether); + assertEq(bob.balanceOf(address(minter)), 0); + assertEq(bob.balanceOf(address(mock)), 0); + assertEq(bob.balanceOf(address(user1)), 0.1 ether); + + vm.prank(user1); + vm.expectRevert("Ownable: caller is not the owner"); + minter.updateConfig(10000 ether, user2, 0.01 ether, 1 ether); + minter.updateConfig(10000 ether, user2, 0.01 ether, 1 ether); + + assertEq(minter.maxFlashLoan(address(bob)), 10000 ether); + assertEq(minter.flashFee(address(bob), 10 ether), 0.1 ether); + assertEq(minter.flashFee(address(bob), 100 ether), 1 ether); + assertEq(minter.flashFee(address(bob), 1000 ether), 1 ether); + + mock = new ERC3156FlashBorrowerMock(address(minter), true, true); + bob.mint(address(mock), minter.flashFee(address(bob), 2000 ether)); + minter.flashLoan(IERC3156FlashBorrower(mock), address(bob), 2000 ether, ""); + + assertEq(bob.totalSupply(), 1.1 ether); + assertEq(bob.balanceOf(address(minter)), 0); + assertEq(bob.balanceOf(address(mock)), 0); + assertEq(bob.balanceOf(address(user1)), 0.1 ether); + assertEq(bob.balanceOf(address(user2)), 1 ether); + } +} diff --git a/test/minters/SurplusMinter.t.sol b/test/minters/SurplusMinter.t.sol new file mode 100644 index 0000000..5157681 --- /dev/null +++ b/test/minters/SurplusMinter.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "forge-std/Test.sol"; +import "../shared/Env.t.sol"; +import "../../src/proxy/EIP1967Proxy.sol"; +import "../../src/BobToken.sol"; +import "../../src/minters/SurplusMinter.sol"; + +contract SurplusMinterTest is Test { + BobToken bob; + SurplusMinter minter; + + event WithdrawSurplus(address indexed to, uint256 realized, uint256 unrealized); + event AddSurplus(address indexed from, uint256 surplus); + + function setUp() public { + EIP1967Proxy bobProxy = new EIP1967Proxy(address(this), mockImpl, ""); + BobToken bobImpl = new BobToken(address(bobProxy)); + bobProxy.upgradeTo(address(bobImpl)); + bob = BobToken(address(bobProxy)); + + minter = new SurplusMinter(address(bob)); + + bob.updateMinter(address(minter), true, true); + bob.updateMinter(address(this), true, true); + + minter.setMinter(address(this), true); + } + + function testSurplusAdd() public { + vm.prank(user1); + vm.expectRevert("SurplusMinter: not a minter"); + minter.add(100 ether); + + vm.expectEmit(true, false, false, true); + emit AddSurplus(address(this), 100 ether); + minter.add(100 ether); + assertEq(minter.surplus(), 100 ether); + } + + function testRealizedSurplusAdd() public { + bob.mint(address(this), 1000 ether); + + minter.add(100 ether); + + assertEq(minter.surplus(), 100 ether); + assertEq(bob.balanceOf(address(minter)), 0 ether); + + bob.transferAndCall(address(minter), 10 ether, ""); + + assertEq(minter.surplus(), 90 ether); + assertEq(bob.balanceOf(address(minter)), 10 ether); + + vm.expectRevert("SurplusMinter: invalid value"); + bob.transferAndCall(address(minter), 20 ether, abi.encode(30 ether)); + + bob.transferAndCall(address(minter), 20 ether, abi.encode(20 ether)); + assertEq(minter.surplus(), 70 ether); + assertEq(bob.balanceOf(address(minter)), 30 ether); + + bob.transferAndCall(address(minter), 20 ether, abi.encode(10 ether)); + assertEq(minter.surplus(), 60 ether); + assertEq(bob.balanceOf(address(minter)), 50 ether); + + // convert 60 BOB unrealized surplus, burn 10 BOB + bob.transferAndCall(address(minter), 70 ether, ""); + assertEq(minter.surplus(), 0 ether); + assertEq(bob.balanceOf(address(minter)), 110 ether); + + minter.add(10 ether); + assertEq(minter.surplus(), 10 ether); + assertEq(bob.balanceOf(address(minter)), 110 ether); + + // convert 10 BOB of unrealized surplus, burn 15 BOB, record remaining 5 BOB of realized surplus + bob.transferAndCall(address(minter), 30 ether, abi.encode(25 ether)); + assertEq(minter.surplus(), 0 ether); + assertEq(bob.balanceOf(address(minter)), 125 ether); + } + + function testSurplusBurn() public { + minter.add(100 ether); + + vm.prank(user1); + vm.expectRevert("Ownable: caller is not the owner"); + minter.burn(100 ether); + + vm.expectRevert("SurplusMinter: exceeds surplus"); + minter.burn(2000 ether); + + vm.expectEmit(true, false, false, true); + emit WithdrawSurplus(address(0), 0, 60 ether); + minter.burn(60 ether); + assertEq(minter.surplus(), 40 ether); + minter.burn(40 ether); + assertEq(minter.surplus(), 0 ether); + } + + function testSurplusWithdraw() public { + minter.add(100 ether); + bob.mint(address(minter), 50 ether); + + vm.prank(user1); + vm.expectRevert("Ownable: caller is not the owner"); + minter.withdraw(user1, 100 ether); + + vm.expectRevert("SurplusMinter: exceeds surplus"); + minter.withdraw(user1, 200 ether); + + vm.expectEmit(true, false, false, true); + emit WithdrawSurplus(user1, 30 ether, 0 ether); + minter.withdraw(user1, 30 ether); + assertEq(minter.surplus(), 100 ether); + assertEq(bob.balanceOf(user1), 30 ether); + assertEq(bob.balanceOf(address(minter)), 20 ether); + + vm.expectEmit(true, false, false, true); + emit WithdrawSurplus(user1, 20 ether, 10 ether); + minter.withdraw(user1, 30 ether); + assertEq(minter.surplus(), 90 ether); + assertEq(bob.balanceOf(user1), 60 ether); + assertEq(bob.balanceOf(address(minter)), 0 ether); + + vm.expectRevert("SurplusMinter: exceeds surplus"); + minter.withdraw(user1, 100 ether); + + vm.expectEmit(true, false, false, true); + emit WithdrawSurplus(user1, 0 ether, 90 ether); + minter.withdraw(user1, 90 ether); + assertEq(minter.surplus(), 0 ether); + assertEq(bob.balanceOf(user1), 150 ether); + assertEq(bob.balanceOf(address(minter)), 0 ether); + + vm.expectRevert("SurplusMinter: exceeds surplus"); + minter.withdraw(user1, 1 ether); + } + + function testWithdrawConvertUnrealized() public { + // 0 withdrawn, 50 realized, 50 unrealized + minter.add(50 ether); + bob.mint(address(minter), 50 ether); + + // 80 withdrawn, 0 realized, 20 unrealized + minter.withdraw(user1, 80 ether); + assertEq(minter.surplus(), 20 ether); + assertEq(bob.balanceOf(user1), 80 ether); + assertEq(bob.balanceOf(address(minter)), 0 ether); + + bob.mint(address(this), 1000 ether); + + // convert 20 unrealized into realized surplus, burn 30 previously withdrawn unrealized surplus + bob.transferAndCall(address(minter), 50 ether, ""); + // 80 withdrawn, 20 realized, 0 unrealized + assertEq(minter.surplus(), 0 ether); + assertEq(bob.balanceOf(user1), 80 ether); + assertEq(bob.balanceOf(address(minter)), 20 ether); + + // add 50 realized surplus + bob.transferAndCall(address(minter), 50 ether, abi.encode(0)); + // 80 withdrawn, 70 realized, 0 unrealized + assertEq(minter.surplus(), 0 ether); + assertEq(bob.balanceOf(user1), 80 ether); + assertEq(bob.balanceOf(address(minter)), 70 ether); + } +} diff --git a/test/mocks/ERC3156FlashBorrowerMock.sol b/test/mocks/ERC3156FlashBorrowerMock.sol new file mode 100644 index 0000000..8758928 --- /dev/null +++ b/test/mocks/ERC3156FlashBorrowerMock.sol @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.15; + +import "@openzeppelin/contracts/interfaces/IERC3156.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; + +contract ERC3156FlashBorrowerMock is IERC3156FlashBorrower { + bytes32 internal constant _RETURN_VALUE = keccak256("ERC3156FlashBorrower.onFlashLoan"); + + address immutable _expectedCaller; + bool immutable _enableApprove; + bool immutable _enableReturn; + + event BalanceOf(address token, address account, uint256 value); + event TotalSupply(address token, uint256 value); + + constructor(address caller, bool enableReturn, bool enableApprove) { + _expectedCaller = caller; + _enableApprove = enableApprove; + _enableReturn = enableReturn; + } + + function onFlashLoan( + address, /*initiator*/ + address token, + uint256 amount, + uint256 fee, + bytes calldata data + ) + public + override + returns (bytes32) + { + require(msg.sender == _expectedCaller, "E1"); + + emit BalanceOf(token, address(this), IERC20(token).balanceOf(address(this))); + emit TotalSupply(token, IERC20(token).totalSupply()); + + if (data.length > 0) { + // WARNING: This code is for testing purposes only! Do not use. + Address.functionCall(token, data); + } + + if (_enableApprove) { + IERC20(token).approve(msg.sender, amount + fee); + } + + return _enableReturn ? _RETURN_VALUE : bytes32(0); + } +}