diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..b0d3c19 --- /dev/null +++ b/.example.env @@ -0,0 +1 @@ +MAINNET_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/demo \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 762a296..1074b5f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,45 +1,45 @@ -name: CI - -on: - push: - pull_request: - workflow_dispatch: - -env: - FOUNDRY_PROFILE: ci - -jobs: - check: - strategy: - fail-fast: true - - name: Foundry project - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - with: - version: nightly - - - name: Show Forge version - run: | - forge --version - - - name: Run Forge fmt - run: | - forge fmt --check - id: fmt - - - name: Run Forge build - run: | - forge build --sizes - id: build - - - name: Run Forge tests - run: | - forge test -vvv - id: test +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +env: + FOUNDRY_PROFILE: ci + +jobs: + check: + strategy: + fail-fast: true + + name: Foundry project + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Show Forge version + run: | + forge --version + + - name: Run Forge fmt + run: | + forge fmt --check + id: fmt + + - name: Run Forge build + run: | + forge build --sizes + id: build + + - name: Run Forge tests + run: | + forge test -vvv + id: test diff --git a/.gitignore b/.gitignore index 85198aa..046df68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,14 @@ -# Compiler files -cache/ -out/ - -# Ignores development broadcast logs -!/broadcast -/broadcast/*/31337/ -/broadcast/**/dry-run/ - -# Docs -docs/ - -# Dotenv file -.env +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/.gitmodules b/.gitmodules index c59f396..7244277 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,6 @@ -[submodule "lib/forge-std"] - path = lib/forge-std - url = https://github.com/foundry-rs/forge-std -[submodule "lib/solmate"] - path = lib/solmate - url = https://github.com/transmissions11/solmate +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/solmate"] + path = lib/solmate + url = https://github.com/transmissions11/solmate diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fa29cdf --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +** \ No newline at end of file diff --git a/README.md b/README.md index 244c5dd..68324e3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -### MicroStable - -Most simple design of a stablecoin. +### MicroStable + +Most simple design of a stablecoin. diff --git a/foundry.toml b/foundry.toml index 25b918f..786301d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,6 @@ -[profile.default] -src = "src" -out = "out" -libs = ["lib"] - -# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/src/MicroStable.sol b/src/MicroStable.sol index bda5a2a..d5a13fe 100644 --- a/src/MicroStable.sol +++ b/src/MicroStable.sol @@ -1,75 +1,84 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.13; - -import {ERC20} from "lib/solmate/src/tokens/ERC20.sol"; - -interface Oracle { function latestAnswer() external view returns (uint); } - -contract ShUSD is ERC20("Shafu USD", "shUSD", 18) { - address public manager; - - constructor(address _manager) { manager = _manager; } - - modifier onlyManager() { - require(manager == msg.sender); - _; - } - - function mint(address to, uint amount) public onlyManager { _mint(to, amount); } - function burn(address from, uint amount) public onlyManager { _burn(from, amount); } -} - -contract Manager { - uint public constant MIN_COLLAT_RATIO = 1.5e18; - - ERC20 public weth; - ShUSD public shUSD; - - Oracle public oracle; - - mapping(address => uint) public address2deposit; - mapping(address => uint) public address2minted; - - constructor(address _weth, address _shUSD, address _oracle) { - weth = ERC20(_weth); - shUSD = ShUSD(_shUSD); - oracle = Oracle(_oracle); - } - - function deposit(uint amount) public { - weth.transferFrom(msg.sender, address(this), amount); - address2deposit[msg.sender] += amount; - } - - function burn(uint amount) public { - address2minted[msg.sender] -= amount; - shUSD.burn(msg.sender, amount); - } - - function mint(uint amount) public { - address2minted[msg.sender] += amount; - require(collatRatio(msg.sender) >= MIN_COLLAT_RATIO); - shUSD.mint(msg.sender, amount); - } - - function withdraw(uint amount) public { - address2deposit[msg.sender] -= amount; - require(collatRatio(msg.sender) >= MIN_COLLAT_RATIO); - weth.transfer(msg.sender, amount); - } - - function liquidate(address user) public { - require(collatRatio(user) < MIN_COLLAT_RATIO); - shUSD.burn(msg.sender, address2minted[user]); - weth.transfer(msg.sender, address2deposit[user]); - address2deposit[user] = 0; - address2minted[user] = 0; - } - - function collatRatio(address user) public view returns (uint) { - uint minted = address2minted[user]; - if (minted == 0) return type(uint256).max; - uint totalValue = address2deposit[user] * oracle.latestAnswer() / 1e18; - return totalValue / minted; - } -} +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {ERC20} from "lib/solmate/src/tokens/ERC20.sol"; + +interface Oracle { + function latestAnswer() external view returns (uint256); +} + +contract ShUSD is ERC20("Shafu USD", "shUSD", 18) { + address public manager; + + constructor(address _manager) { + manager = _manager; + } + + modifier onlyManager() { + require(manager == msg.sender); + _; + } + + function mint(address to, uint256 amount) public onlyManager { + _mint(to, amount); + } + + function burn(address from, uint256 amount) public onlyManager { + _burn(from, amount); + } +} + +contract Manager { + uint256 public constant MIN_COLLAT_RATIO = 1.5e18; + + ERC20 public weth; + ShUSD public shUSD; + + Oracle public oracle; + + mapping(address => uint256) public address2deposit; + mapping(address => uint256) public address2minted; + + constructor(address _weth, address _shUSD, address _oracle) { + weth = ERC20(_weth); + shUSD = ShUSD(_shUSD); + oracle = Oracle(_oracle); + } + + function deposit(uint256 amount) public { + weth.transferFrom(msg.sender, address(this), amount); + address2deposit[msg.sender] += amount; + } + + function burn(uint256 amount) public { + address2minted[msg.sender] -= amount; + shUSD.burn(msg.sender, amount); + } + + function mint(uint256 amount) public { + require(collatRatio(msg.sender) >= MIN_COLLAT_RATIO); + address2minted[msg.sender] += amount; + shUSD.mint(msg.sender, amount); + } + + function withdraw(uint256 amount) public { + address2deposit[msg.sender] -= amount; + require(collatRatio(msg.sender) >= MIN_COLLAT_RATIO); + weth.transfer(msg.sender, amount); + } + + function liquidate(address user) public { + require(collatRatio(user) < MIN_COLLAT_RATIO); + shUSD.burn(msg.sender, address2minted[user]); + weth.transfer(msg.sender, address2deposit[user]); + address2deposit[user] = 0; + address2minted[user] = 0; + } + + function collatRatio(address user) public view returns (uint256) { + uint256 minted = address2minted[user]; + if (minted == 0) return type(uint256).max; + uint256 totalValue = address2deposit[user] * (oracle.latestAnswer() * 1e10) / 1e18; + return totalValue / minted; + } +} diff --git a/test/MicroStable.t.sol b/test/MicroStable.t.sol new file mode 100644 index 0000000..79fa051 --- /dev/null +++ b/test/MicroStable.t.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import "forge-std/Test.sol"; +import {console2} from "forge-std/Test.sol"; +import {Manager, ShUSD} from "../src/MicroStable.sol"; +import {MockWETH} from "../test/mocks/MockWETH.sol"; + +interface Oracle { + function latestAnswer() external view returns (uint256); +} + +contract MicroStableTest is Test { + uint256 mainnetFork; + string MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL"); + + address bob = makeAddr("bob"); + address alice = makeAddr("alice"); + + ShUSD public stablecoin; + Manager public manager; + MockWETH public weth; + Oracle public oracle = Oracle(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419); // chainlink eth-usd price feed, eth mainnet + + uint256 ONE_WETH = 1e18; + uint256 TWO_WETH = 2e18; + + uint256 public constant MIN_COLLAT_RATIO = 1.5e18; + + function setUp() external { + // fork mainnet for oracle access + mainnetFork = vm.createFork(MAINNET_RPC_URL); + vm.selectFork(mainnetFork); + + // mint eth to bob & alice + vm.deal(bob, 1000e18); + vm.deal(alice, 1000e18); + + // @todo: the following to be precomputed using vm.computeCreateAddress instead, but will work for now + address managerContractAddress = 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a; + + // instantiate contracts + stablecoin = new ShUSD(managerContractAddress); + weth = new MockWETH(); + manager = new Manager(address(weth), address(stablecoin), address(oracle)); + console2.log(address(manager)); + + // mint mock weth to bob & alice + weth.mint(bob, 1000e18); + weth.mint(alice, 1000e18); + + // approve max to manager + vm.prank(bob); + weth.approve(address(manager), type(uint256).max); + vm.prank(alice); + weth.approve(address(manager), type(uint256).max); + } + + function test_depositCollateral() public { + depositWeth(bob, ONE_WETH); + + uint256 bobCollateral = manager.address2deposit(bob); + assertEq(bobCollateral, ONE_WETH); + } + + function test_withdrawCollateral() public { + depositWeth(bob, TWO_WETH); // deposit 2 x WETH + uint256 bobCollateralBeforeWithdrawal = manager.address2deposit(bob); + + withdrawWeth(bob, ONE_WETH); // withdraw 1 x WETH + uint256 bobCollateralAfterWithdrawal = manager.address2deposit(bob); + + assertEq(bobCollateralBeforeWithdrawal, TWO_WETH); + assertEq(bobCollateralAfterWithdrawal, ONE_WETH); + } + + function test_mintStablecoins(uint256 amount) public { + amount = bound(amount, 100, calculateMaxMintablePerEthDeposited()); + + depositWeth(bob, ONE_WETH); + + mintStablecoins(bob, amount); + + uint256 bobAmountMinted = stablecoin.balanceOf(bob); + assertEq(bobAmountMinted, amount); + } + + function test_burnStablecoins(uint256 amount) public { + amount = bound(amount, 100, calculateMaxMintablePerEthDeposited()); + + depositWeth(bob, ONE_WETH); + + mintStablecoins(bob, amount); + + vm.startPrank(bob); + manager.burn(stablecoin.balanceOf(bob) - 100); // burn all but 100 of bob's tokens + vm.stopPrank(); + + uint256 bobAmountMinted = stablecoin.balanceOf(bob); + + assertEq(bobAmountMinted, 100); + } + + function test_liquidateUser(uint256 amount) public { + amount = bound(amount, 100e18, calculateMaxMintablePerEthDeposited()); + + // bob deposits + mints stables + depositWeth(bob, ONE_WETH); + mintStablecoins(bob, amount); + + // alice is the liquidator, give her stables to pay off bob's debt + vm.prank(address(manager)); + stablecoin.mint(alice, amount); + + // change bob's address2minted balance, thus making him liquidatable + vm.mockCall(address(manager), abi.encodeWithSelector(manager.address2minted.selector, bob), abi.encode(amount)); + assertEq(manager.address2minted(bob), amount); + + // alice liquidates bob + vm.startPrank(alice); + manager.liquidate(bob); + vm.stopPrank(); + + // alice should have no stables left + uint256 aliceRemainingStables = 0; + assertEq(stablecoin.balanceOf(alice), aliceRemainingStables); + + // bob should have no collateral remaining + assertEq(manager.address2deposit(bob), 0); + + // alice should have an extera 1 x WETH on top of her starting balance (1000e18) + assertEq(weth.balanceOf(alice), 1001e18); + } + + function test_cannotLiquidateWhenFullyCollateralised(uint256 amount) public { + amount = bound(amount, 100e18, calculateMaxMintablePerEthDeposited()); + + // bob deposits + mints stables + depositWeth(bob, ONE_WETH); + mintStablecoins(bob, amount); + + // alice is the liquidator, give her stables to pay off bob's debt + vm.prank(address(manager)); + stablecoin.mint(alice, amount); + + // alice liquidates bob - @note: should revert + vm.startPrank(alice); + vm.expectRevert(); + manager.liquidate(bob); + vm.stopPrank(); + } + + // === HELPER FUNCTIONS === + function depositWeth(address depositer, uint256 depositAmount) public { + vm.startPrank(depositer); + manager.deposit(depositAmount); + vm.stopPrank(); + } + + function withdrawWeth(address withdrawer, uint256 withdrawalAmount) public { + vm.startPrank(withdrawer); + manager.withdraw(withdrawalAmount); + vm.stopPrank(); + } + + function mintStablecoins(address minter, uint256 mintAmount) public { + vm.startPrank(minter); + manager.mint(mintAmount); + vm.stopPrank(); + } + + function calculateMaxMintablePerEthDeposited() public view returns (uint256) { + uint256 ethPriceInUsd = oracle.latestAnswer(); + uint256 maxMintPerEthDeposited = (ethPriceInUsd * 1e10) / MIN_COLLAT_RATIO; + uint256 maxMintPerEthWithPrecision = maxMintPerEthDeposited * 1e18; + return maxMintPerEthWithPrecision; + } +} diff --git a/test/mocks/MockWETH.sol b/test/mocks/MockWETH.sol new file mode 100644 index 0000000..c6c0288 --- /dev/null +++ b/test/mocks/MockWETH.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {ERC20} from "lib/solmate/src/tokens/ERC20.sol"; + +contract MockWETH is ERC20("Mock WETH", "WETH", 18) { + constructor() {} + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } +}