-
Notifications
You must be signed in to change notification settings - Fork 0
PSM #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
PSM #1
Changes from all commits
bbd52b2
70b03ba
f6de449
95b355a
0ddf1dc
fe9a61f
4989fdd
d0be880
fe88039
8c99fe4
ff9edfa
103fd28
af9fd95
d200808
6dd04c9
08ddd69
e1b7f83
7e8779e
2396c43
dbc9305
485612f
4a419d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| push: | ||
| pull_request: | ||
| workflow_dispatch: | ||
|
|
||
| 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 | ||
|
|
||
| - 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 | ||
| env: | ||
| RPC_MAINNET: ${{ secrets.RPC_MAINNET }} | ||
| run: | | ||
| forge test -vvv | ||
| id: test |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # Compiler files | ||
| cache/ | ||
| out/ | ||
|
|
||
| # Ignores development broadcast logs | ||
| !/broadcast | ||
| /broadcast/*/31337/ | ||
| /broadcast/**/dry-run/ | ||
|
|
||
| # Docs | ||
| docs/ | ||
|
|
||
| # Dotenv file | ||
| .env |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| [submodule "lib/forge-std"] | ||
| path = lib/forge-std | ||
| url = https://github.com/foundry-rs/forge-std | ||
| [submodule "lib/openzeppelin"] | ||
| path = lib/openzeppelin | ||
| url = https://github.com/OpenZeppelin/openzeppelin-contracts.git | ||
| [submodule "lib/solmate"] | ||
| path = lib/solmate | ||
| url = https://github.com/transmissions11/solmate.git |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| [profile.default] | ||
| evm_version = "prague" | ||
| src = "src" | ||
| out = "out" | ||
| libs = ["lib"] | ||
| optimizer = true | ||
| optimizer_runs = 10000 | ||
| solc = "0.8.20" | ||
| remappings = [ | ||
| "@openzeppelin/contracts/=lib/openzeppelin/contracts/", | ||
| "forge-std/=lib/forge-std/src/", | ||
| "openzeppelin/=lib/openzeppelin/", | ||
| "solmate/=lib/solmate/src/", | ||
| ] | ||
| [rpc_endpoints] | ||
| mainnet = "${RPC_MAINNET}" | ||
| [etherscan] | ||
| mainnet = {key = "${ETHERSCAN_API_KEY}"} | ||
|
|
||
| # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| pragma solidity ^0.8.20; | ||
|
|
||
| // Warning: Contracts using this template must include require(msg.sender == PSM) for any state-mutating functions. | ||
| // Omitting this check can introduce vulnerabilities if future controllers forget to enforce it. | ||
| contract Controller { | ||
nourharidy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /** | ||
| * @notice Checks if a buy operation is allowed. | ||
| * @dev This function can be modified to include specific conditions for allowing buys. | ||
| * @param amount The amount of collateral to be sold. | ||
| * @return bool Returns true if the buy operation is allowed, false otherwise. | ||
| * For now, it returns true to allow all buy operations. | ||
| */ | ||
| function onBuy(address user, uint256 amount) external returns (bool) { | ||
| user; | ||
| amount; // To avoid unused variable warning | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Checks if a sell operation is allowed. | ||
| * @dev This function can be modified to include specific conditions for allowing sells. | ||
| * @param amount The amount of DOLA to be sold. | ||
| * @return bool Returns true if the sell operation is allowed, false otherwise. | ||
| * For now, it returns true to allow all sell operations. | ||
| */ | ||
| function onSell(address user, uint256 amount) external returns (bool) { | ||
| user; | ||
| amount; // To avoid unused variable warning | ||
| return true; // For now, we allow all calls | ||
| } | ||
| } | ||
08xmt marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,254 @@ | ||
| // SPDX-License-Identifier: MIT | ||
| pragma solidity ^0.8.20; | ||
|
|
||
| import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | ||
| import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; | ||
| import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; | ||
| import "@openzeppelin/contracts/interfaces/IERC4626.sol"; | ||
| import {PSMFed} from "src/PSMFed.sol"; | ||
|
|
||
| interface IController { | ||
| function onBuy(address user, uint256 amount) external returns (bool); | ||
| function onSell(address user, uint256 amount) external returns (bool); | ||
| } | ||
|
|
||
| contract PSM { | ||
| using SafeERC20 for IERC20; | ||
|
|
||
| IERC20 public immutable collateral; | ||
| IERC20 public immutable DOLA; | ||
| address public immutable fed; // PSMFed contract address | ||
|
|
||
| address public gov; | ||
| address public pendingGov; | ||
| IController public controller; // Controller contract address | ||
| uint256 public buyFeeBps; // e.g., 50 = 0.5% | ||
| uint256 public sellFeeBps; // e.g., 50 = 0.5% | ||
| uint256 public constant BPS_DENOMINATOR = 10_000; | ||
| uint256 public supply; // Collateral supplied in the PSM (excluding fees and profit) | ||
| IERC4626 public vault; | ||
|
|
||
| event GovChanged(address indexed oldGov, address indexed newGov); | ||
| event PendingGovUpdated(address indexed pendingGov); | ||
| event ControllerChanged(address indexed oldController, address indexed newController); | ||
| event VaultMigrated(address indexed oldVault, address indexed newVault); | ||
| event BuyFeeUpdated(uint256 oldFee, uint256 newFee); | ||
| event SellFeeUpdated(uint256 oldFee, uint256 newFee); | ||
| event SupplyCapUpdated(uint256 newSupplyCap); | ||
| event Buy(address indexed user, uint256 purchased, uint256 spent); | ||
| event Sell(address indexed user, uint256 sold, uint256 received); | ||
|
|
||
| constructor(address _collateral, address _vault, address _DOLA, address _gov, address _controller, address _chair) { | ||
| collateral = IERC20(_collateral); | ||
| vault = IERC4626(_vault); | ||
| DOLA = IERC20(_DOLA); | ||
| gov = _gov; | ||
| controller = IController(_controller); | ||
| fed = address(new PSMFed(address(this), _gov, _chair, _DOLA)); | ||
| DOLA.approve(fed, type(uint256).max); | ||
| } | ||
|
|
||
| modifier onlyGov() { | ||
| require(msg.sender == gov, "Not gov"); | ||
| _; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows to buy DOLA minus fee using collateral. | ||
| * @param collateralAmountIn Amount of collateral to sell for DOLA. | ||
| */ | ||
| function buy(uint256 collateralAmountIn) external { | ||
| buy(msg.sender, collateralAmountIn); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows to buy DOLA minus fee using collateral. | ||
| * @param to DOLA receiver. | ||
| * @param collateralAmountIn Amount of collateral to sell for DOLA. | ||
| */ | ||
| function buy(address to, uint256 collateralAmountIn) public { | ||
| require(collateralAmountIn > 0, "Amount must be > 0"); | ||
| require(controller.onBuy(msg.sender, collateralAmountIn), "Denied by controller"); | ||
| uint256 amountOut = collateralAmountIn; | ||
|
|
||
| if (buyFeeBps > 0) { | ||
| uint256 fee = (collateralAmountIn * buyFeeBps) / BPS_DENOMINATOR; | ||
| amountOut -= fee; | ||
| } | ||
| supply += amountOut; | ||
|
|
||
| collateral.safeTransferFrom(msg.sender, address(this), collateralAmountIn); | ||
| collateral.approve(address(vault), collateralAmountIn); | ||
| vault.deposit(collateralAmountIn, address(this)); | ||
| DOLA.safeTransfer(to, amountOut); | ||
| emit Buy(msg.sender, collateralAmountIn, amountOut); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows to sell DOLA for collateral minus fee. | ||
| * @param dolaAmountIn Amount of DOLA to sell. | ||
| */ | ||
| function sell(uint256 dolaAmountIn) external { | ||
| sell(msg.sender, dolaAmountIn); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows to sell DOLA for collateral minus fee. | ||
| * @param to Address to receive the collateral. | ||
| * @param dolaAmountIn Amount of DOLA to sell. | ||
| */ | ||
| function sell(address to, uint256 dolaAmountIn) public { | ||
| require(dolaAmountIn > 0, "Amount must be > 0"); | ||
| require(controller.onSell(msg.sender, dolaAmountIn), "Denied by controller"); | ||
| supply -= dolaAmountIn; | ||
| DOLA.safeTransferFrom(msg.sender, address(this), dolaAmountIn); | ||
|
|
||
| uint256 amountOut = dolaAmountIn; | ||
|
|
||
| if (sellFeeBps > 0) { | ||
| uint256 fee = (dolaAmountIn * sellFeeBps) / BPS_DENOMINATOR; | ||
| amountOut -= fee; | ||
| } | ||
|
|
||
| vault.withdraw(amountOut, to, address(this)); | ||
| emit Sell(msg.sender, dolaAmountIn, amountOut); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Takes profit from the vault and transfers it to the governance address. | ||
| * @dev Can be called by anyone to transfer profits and fees to governance. | ||
| */ | ||
| function takeProfit() public { | ||
| uint256 vaultBal = vault.balanceOf(address(this)); | ||
| uint256 amountOut = vault.previewRedeem(vaultBal); | ||
| uint256 profit = amountOut > supply ? amountOut - supply : 0; | ||
| if (profit > 0) { | ||
| vault.withdraw(profit, gov, address(this)); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @notice Returns the total collateral reserves in the vault, including profit and fees. | ||
| * @return Total reserves in the vault. | ||
| */ | ||
| function getTotalReserves() public view returns (uint256) { | ||
| return vault.previewRedeem(vault.balanceOf(address(this))); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Returns the total profit made by the PSM. | ||
| * @return Total profit in the PSM. | ||
| */ | ||
| function getProfit() external view returns (uint256) { | ||
| uint256 totalReserves = getTotalReserves(); | ||
| return totalReserves > supply ? totalReserves - supply : 0; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Returns the amount of DOLA that can be obtained for a given amount of collateral. | ||
| * @param collateralIn Amount of collateral to convert to DOLA. | ||
| * @return Amount of DOLA that will be received after fees. | ||
| */ | ||
| function getDolaOut(uint256 collateralIn) external view returns (uint256) { | ||
| uint256 fee = (collateralIn * buyFeeBps) / BPS_DENOMINATOR; | ||
| return collateralIn - fee; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Returns the amount of collateral that can be obtained for a given amount of DOLA. | ||
| * @param dolaIn Amount of DOLA to convert to collateral. | ||
| * @return Amount of collateral that will be received after fees. | ||
| */ | ||
| function getCollateralOut(uint256 dolaIn) external view returns (uint256) { | ||
| uint256 fee = (dolaIn * sellFeeBps) / BPS_DENOMINATOR; | ||
| return dolaIn - fee; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Migrate the vault to a new IERC4626 vault. | ||
| * @dev Can only be called by governance and will take profit before migration. | ||
| * @param newVault Address of the new vault to migrate to. | ||
| * @param minCollateralAmount Minimum amount of collateral to be deposited in the new vault. | ||
| */ | ||
| function migrate(address newVault, uint256 minCollateralAmount) external onlyGov { | ||
| require(newVault != address(0), "Zero address"); | ||
| require(IERC4626(newVault).asset() == address(collateral), "New vault must accept collateral"); | ||
|
|
||
| takeProfit(); | ||
|
|
||
| if (vault.balanceOf(address(this)) != 0) { | ||
| vault.redeem(vault.balanceOf(address(this)), address(this), address(this)); | ||
| } | ||
|
Comment on lines
+179
to
+181
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Migration will fail if redeem > maxWithdraw
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If |
||
|
|
||
| address oldVault = address(vault); | ||
| vault = IERC4626(newVault); | ||
| uint256 collateralBalance = collateral.balanceOf(address(this)); | ||
| require(collateralBalance >= minCollateralAmount, "Insufficient collateral balance for migration"); | ||
| if (collateralBalance > 0) { | ||
| collateral.approve(address(vault), collateralBalance); | ||
| vault.deposit(collateralBalance, address(this)); | ||
| } | ||
| emit VaultMigrated(oldVault, newVault); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows governance to sweep any ERC20 tokens from the contract. | ||
| * @dev Can only be called by governance. | ||
| * @param token The ERC20 token to sweep. | ||
| */ | ||
| function sweep(IERC20 token) external onlyGov { | ||
| token.safeTransfer(gov, token.balanceOf(address(this))); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows governance to set the buy fee. | ||
| * @dev The fee is specified in basis points (bps), where 100 bps = 1%. | ||
| */ | ||
| function setBuyFeeBps(uint256 newFee) external onlyGov { | ||
| require(newFee <= BPS_DENOMINATOR, "Fee too high"); | ||
| emit BuyFeeUpdated(buyFeeBps, newFee); | ||
| buyFeeBps = newFee; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows governance to set the sell fee. | ||
| * @dev The fee is specified in basis points (bps), where 100 bps = 1%. | ||
| */ | ||
| function setSellFeeBps(uint256 newFee) external onlyGov { | ||
| require(newFee <= BPS_DENOMINATOR, "Fee too high"); | ||
| emit SellFeeUpdated(sellFeeBps, newFee); | ||
| sellFeeBps = newFee; | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows governance to set a new pending governance. | ||
| * @dev The pending governance must accept the role. | ||
| * @param _pendingGov Address of the new pending governance. | ||
| */ | ||
| function setPendingGov(address _pendingGov) external onlyGov { | ||
| pendingGov = _pendingGov; | ||
| emit PendingGovUpdated(_pendingGov); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows the pending governance to claim the governance role. | ||
| * @dev Can only be called by the pending governance. | ||
| */ | ||
| function claimPendingGov() external { | ||
| require(msg.sender == pendingGov, "Not pending gov"); | ||
| emit GovChanged(gov, pendingGov); | ||
| gov = pendingGov; | ||
| pendingGov = address(0); | ||
| } | ||
|
|
||
| /** | ||
| * @notice Allows governance to set a new controller. | ||
| * @dev The new controller must not be the zero address. | ||
| * @param newController Address of the new controller. | ||
| */ | ||
| function setController(address newController) external onlyGov { | ||
| require(newController != address(0), "Zero address"); | ||
| emit ControllerChanged(address(controller), newController); | ||
| controller = IController(newController); | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.