Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/test.yml
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
14 changes: 14 additions & 0 deletions .gitignore
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
9 changes: 9 additions & 0 deletions .gitmodules
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
20 changes: 20 additions & 0 deletions foundry.toml
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
1 change: 1 addition & 0 deletions lib/forge-std
Submodule forge-std added at 77041d
1 change: 1 addition & 0 deletions lib/openzeppelin
Submodule openzeppelin added at be547e
1 change: 1 addition & 0 deletions lib/solmate
Submodule solmate added at c93f77
32 changes: 32 additions & 0 deletions src/Controller.sol
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 {
/**
* @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
}
}
254 changes: 254 additions & 0 deletions src/PSM.sol
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration will fail if redeem > maxWithdraw

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If maxRedeem exceeds the balance in the PSM, should we allow to set a specific amount lower than that and allow for multiple smaller migrations?
In this edge case I guess we would then handle both vaults in terms of selling, total reserves and profit or we could just sweep the old vault tokens and redeem them via governance in multiple steps depending on availability (sending shares to the PSM)? Let me think a bit on what could be the side effects if any


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);
}
}
Loading