From bbd52b278a7d2ad3e87be00c075702583f2eb92d Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Sun, 8 Jun 2025 13:57:17 -0500 Subject: [PATCH 01/22] add PSM --- .github/workflows/test.yml | 43 +++++++++ .gitignore | 14 +++ .gitmodules | 9 ++ foundry.toml | 12 +++ lib/forge-std | 1 + lib/openzeppelin | 1 + lib/solmate | 1 + src/PSM.sol | 144 ++++++++++++++++++++++++++++ test/PSM.t.sol | 186 +++++++++++++++++++++++++++++++++++++ 9 files changed, 411 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 foundry.toml create mode 160000 lib/forge-std create mode 160000 lib/openzeppelin create mode 160000 lib/solmate create mode 100644 src/PSM.sol create mode 100644 test/PSM.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..34a4a52 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,43 @@ +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 + + - 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 new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# 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 new file mode 100644 index 0000000..cec4625 --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..a326434 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,12 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +remappings = [ + "@openzeppelin/contracts/=lib/openzeppelin/contracts/", + "forge-std/=lib/forge-std/src/", + "openzeppelin/=lib/openzeppelin/", + "solmate/=lib/solmate/src/", +] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/forge-std b/lib/forge-std new file mode 160000 index 0000000..77041d2 --- /dev/null +++ b/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 77041d2ce690e692d6e03cc812b57d1ddaa4d505 diff --git a/lib/openzeppelin b/lib/openzeppelin new file mode 160000 index 0000000..be547e4 --- /dev/null +++ b/lib/openzeppelin @@ -0,0 +1 @@ +Subproject commit be547e4d14524be563e02019d81e0a734b3f15af diff --git a/lib/solmate b/lib/solmate new file mode 160000 index 0000000..c93f771 --- /dev/null +++ b/lib/solmate @@ -0,0 +1 @@ +Subproject commit c93f7716c9909175d45f6ef80a34a650e2d24e56 diff --git a/src/PSM.sol b/src/PSM.sol new file mode 100644 index 0000000..b7dcc4c --- /dev/null +++ b/src/PSM.sol @@ -0,0 +1,144 @@ +// 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"; + +interface IDOLA is IERC20 { + function mint(address to, uint256 amount) external; + function burn(address from, uint256 amount) external; +} + +contract PSM { + using SafeERC20 for IERC20; + + IERC20 public immutable collateral; + IERC4626 public immutable vault; + IDOLA public immutable DOLA; + address public immutable gov; + + address public operator; + uint256 public depositFeeBps; // e.g., 50 = 0.5% + uint256 public withdrawFeeBps; // e.g., 50 = 0.5% + uint256 public constant BPS_DENOMINATOR = 10_000; + uint256 public supply; + uint256 public supplyCap; + event OperatorChanged(address indexed oldOperator, address indexed newOperator); + event DepositFeeUpdated(uint256 oldFee, uint256 newFee); + event WithdrawFeeUpdated(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, + uint256 _depositFeeBps, + uint256 _withdrawFeeBps, + address _operator + ) { + collateral = IERC20(_collateral); + vault = IERC4626(_vault); + DOLA = IDOLA(_DOLA); + gov = _gov; + depositFeeBps = _depositFeeBps; + withdrawFeeBps = _withdrawFeeBps; + operator = _operator; + } + + modifier onlyOperator() { + require(msg.sender == operator, "Not operator"); + _; + } + + function buy(address to, uint256 amount) external { + require(amount > 0, "Amount must be > 0"); + require(supply + amount <= supplyCap, "Supply cap exceeded"); + supply += amount; + uint256 amountIn = amount; + if(depositFeeBps > 0) { + uint256 fee = (amount * depositFeeBps) / BPS_DENOMINATOR; + amountIn += fee; + } + + collateral.safeTransferFrom(msg.sender, address(this), amountIn); + collateral.approve(address(vault), amountIn); + vault.deposit(amountIn, address(this)); + DOLA.mint(to, amount); + emit Buy(msg.sender, amount, amountIn); + } + + + function sell(address to, uint256 amount) external { + require(amount > 0, "Amount must be > 0"); + supply -= amount; + DOLA.transferFrom(msg.sender, address(this), amount); + DOLA.burn(address(this), amount); + + uint256 amountOut = amount; + + if(withdrawFeeBps > 0) { + uint256 fee = (amount * withdrawFeeBps) / BPS_DENOMINATOR; + amountOut -= fee; + } + + vault.withdraw(amountOut, to, address(this)); + emit Sell(msg.sender, amount, amountOut); + } + + function takeProfit() external { + uint256 vaultBal = vault.balanceOf(address(this)); + uint256 amountOut = vault.previewRedeem(vaultBal); + uint256 profit = amountOut - supply; + if (profit > 0) { + vault.withdraw(profit, gov, address(this)); + } + } + + function getTotalReserves() external view returns (uint256) { + return vault.previewRedeem(vault.balanceOf(address(this))); + } + + function getCollateralIn(uint256 dolaBuyAmount) external view returns (uint256) { + uint256 fee = (dolaBuyAmount * depositFeeBps) / BPS_DENOMINATOR; + return dolaBuyAmount + fee; + } + + function getCollateralOut(uint256 dolaSellAmount) external view returns (uint256) { + uint256 fee = (dolaSellAmount * withdrawFeeBps) / BPS_DENOMINATOR; + return dolaSellAmount - fee; + } + + function sweep(IERC20 token) public onlyOperator { + require(token != IERC20(address(vault)), "Vault token cannot be swept"); + token.safeTransfer(gov, token.balanceOf(address(this))); + } + + + function setDepositFeeBps(uint256 newFee) external onlyOperator { + require(newFee <= BPS_DENOMINATOR, "Fee too high"); + emit DepositFeeUpdated(depositFeeBps, newFee); + depositFeeBps = newFee; + } + + function setWithdrawFeeBps(uint256 newFee) external onlyOperator { + require(newFee <= BPS_DENOMINATOR, "Fee too high"); + emit WithdrawFeeUpdated(withdrawFeeBps, newFee); + withdrawFeeBps = newFee; + } + + function setSupplyCap(uint256 newSupplyCap) external onlyOperator { + supplyCap = newSupplyCap; + emit SupplyCapUpdated(supplyCap); + } + + function setOperator(address newOperator) external onlyOperator { + require(newOperator != address(0), "Zero address"); + emit OperatorChanged(operator, newOperator); + operator = newOperator; + } +} \ No newline at end of file diff --git a/test/PSM.t.sol b/test/PSM.t.sol new file mode 100644 index 0000000..1f9ab3e --- /dev/null +++ b/test/PSM.t.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "src/PSM.sol"; +import {MockERC4626, ERC20} from "lib/solmate/src/test/utils/mocks/MockERC4626.sol"; +// Simple mocks for ERC20 +contract MockERC20 is IERC20 { + string public name = "Mock"; + string public symbol = "MOCK"; + uint8 public decimals = 18; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + function transfer(address to, uint256 amount) external returns (bool) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + return true; + } + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + return true; + } + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalSupply += amount; + } + function burn(address from, uint256 amount) external { + balanceOf[from] -= amount; + totalSupply -= amount; + } +} + + +contract PSMTest is Test { + PSM psm; + MockERC20 collateral; + MockERC4626 vault; + MockERC20 DOLA; + address gov = address(0xfee); + address operator = address(this); + address user = address(0x123); + + function setUp() public { + collateral = new MockERC20(); + vault = new MockERC4626(ERC20(address(collateral)), "MOCK", "MOCK"); + DOLA = new MockERC20(); + psm = new PSM( + address(collateral), + address(vault), + address(DOLA), + gov, + 50, // 0.5% deposit fee + 100, // 1% withdraw fee + operator + ); + collateral.mint(user, 1_000_000 ether); + vm.startPrank(user); + collateral.approve(address(psm), type(uint256).max); + vm.stopPrank(); + psm.setSupplyCap(1_000_000 ether); // Set supply cap for DOLA + } + + function testMintDOLAWithFee() public { + uint256 buyAmount = 1000 ether; + vm.startPrank(user); + psm.buy(user, buyAmount); + vm.stopPrank(); + + // Check fee: 0.5% of 1000 = 5 + // Vault should have 1005 collateral + assertEq(collateral.balanceOf(address(vault)), 1005 ether); + // User should have 1000 DOLA (buyAmount) + assertEq(DOLA.balanceOf(user), 1000 ether); + } + + function testBurnDOLAWithFee() public { + uint256 buyAmount = 1000 ether; + uint256 initialCollateralBal = collateral.balanceOf(user); + vm.startPrank(user); + psm.buy(user, buyAmount); + DOLA.approve(address(psm), type(uint256).max); + console2.log(DOLA.balanceOf(user)); + // Now burn 1000 DOLA + psm.sell(user,1000 ether); + vm.stopPrank(); + + assertEq(collateral.balanceOf(gov), 0); + assertEq(collateral.balanceOf(user), initialCollateralBal - 1005 ether + 990 ether); + // Vault should have only fees left + assertEq(collateral.balanceOf(address(vault)), 15 ether); // deposit and withdraw fees + } + + function testBurnDolaExceedSupply() public { + // Mint some DOLA + uint256 buyAmount = 1000 ether; + vm.startPrank(user); + psm.buy(user, buyAmount); + DOLA.approve(address(psm), type(uint256).max); + vm.stopPrank(); + + // Now try to burn more than minted + vm.startPrank(user); + psm.sell(user, 1000 ether); + + assertEq(collateral.balanceOf(address(vault)), 15 ether); // deposit and withdraw fees + DOLA.mint(user, 15 ether); // Mint some DOLA to user + vm.expectRevert(); + psm.sell(user, 15 ether); + vm.stopPrank(); + } + + + function testTakeProfit() public { + // Mint some DOLA + uint256 buyAmount = 1000 ether; + uint256 initialCollateralBal = collateral.balanceOf(user); + vm.startPrank(user); + psm.buy(user, buyAmount); + DOLA.approve(address(psm), type(uint256).max); + vm.stopPrank(); + + // Simulate profit in vault + uint256 profit = 200 ether; // Assume profit of 200 ether + collateral.mint(address(vault), profit); // Add profit to vault + + vm.prank(operator); + psm.takeProfit(); + + // Check that profit was taken + assertEq(collateral.balanceOf(gov), profit + 5 ether); // 5 ether from deposit fee + + vm.prank(user); + psm.sell(user, 1000 ether); // User sells DOLA + assertEq(psm.supply(), 0); // Supply should be zero after selling all DOLA + assertEq(collateral.balanceOf(user), initialCollateralBal - 1005 ether + 990 ether); // User gets back collateral minus fees + + uint256 govBalanceAfter = collateral.balanceOf(gov); + + vm.prank(operator); + psm.takeProfit(); // Operator tries to take profit again (fees from previous sell) + assertEq(collateral.balanceOf(gov), govBalanceAfter + 10 ether); + } + + function test_fail_if_exceed_supply_cap() public { + uint256 supplyCap = psm.supplyCap(); + uint256 buyAmount = supplyCap + 1 ether; // Exceeding supply cap + vm.startPrank(user); + vm.expectRevert("Supply cap exceeded"); + psm.buy(user, buyAmount); + vm.stopPrank(); + } + + function testOperatorCanUpdateFees() public { + psm.setDepositFeeBps(100); + assertEq(psm.depositFeeBps(), 100); + psm.setWithdrawFeeBps(200); + assertEq(psm.withdrawFeeBps(), 200); + } + + function testNonOperatorCannotUpdateFees() public { + vm.startPrank(user); + vm.expectRevert("Not operator"); + psm.setDepositFeeBps(100); + vm.expectRevert("Not operator"); + psm.setWithdrawFeeBps(200); + } + + function testNonOperatorCannotUpdateSupplyCap() public { + vm.startPrank(user); + vm.expectRevert("Not operator"); + psm.setSupplyCap(2_000_000 ether); + } + function testOperatorChange() public { + address newOp = address(0x456); + psm.setOperator(newOp); + assertEq(psm.operator(), newOp); + } +} \ No newline at end of file From 70b03baa9aac8aa3ac30f81e450de2dfd1d4bda3 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Sun, 8 Jun 2025 13:58:55 -0500 Subject: [PATCH 02/22] run formatter --- src/PSM.sol | 19 +++++++++---------- test/PSM.t.sol | 16 ++++++++++------ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/PSM.sol b/src/PSM.sol index b7dcc4c..48100b3 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -20,11 +20,12 @@ contract PSM { address public immutable gov; address public operator; - uint256 public depositFeeBps; // e.g., 50 = 0.5% - uint256 public withdrawFeeBps; // e.g., 50 = 0.5% + uint256 public depositFeeBps; // e.g., 50 = 0.5% + uint256 public withdrawFeeBps; // e.g., 50 = 0.5% uint256 public constant BPS_DENOMINATOR = 10_000; uint256 public supply; - uint256 public supplyCap; + uint256 public supplyCap; + event OperatorChanged(address indexed oldOperator, address indexed newOperator); event DepositFeeUpdated(uint256 oldFee, uint256 newFee); event WithdrawFeeUpdated(uint256 oldFee, uint256 newFee); @@ -60,7 +61,7 @@ contract PSM { require(supply + amount <= supplyCap, "Supply cap exceeded"); supply += amount; uint256 amountIn = amount; - if(depositFeeBps > 0) { + if (depositFeeBps > 0) { uint256 fee = (amount * depositFeeBps) / BPS_DENOMINATOR; amountIn += fee; } @@ -72,7 +73,6 @@ contract PSM { emit Buy(msg.sender, amount, amountIn); } - function sell(address to, uint256 amount) external { require(amount > 0, "Amount must be > 0"); supply -= amount; @@ -81,7 +81,7 @@ contract PSM { uint256 amountOut = amount; - if(withdrawFeeBps > 0) { + if (withdrawFeeBps > 0) { uint256 fee = (amount * withdrawFeeBps) / BPS_DENOMINATOR; amountOut -= fee; } @@ -92,10 +92,10 @@ contract PSM { function takeProfit() external { uint256 vaultBal = vault.balanceOf(address(this)); - uint256 amountOut = vault.previewRedeem(vaultBal); + uint256 amountOut = vault.previewRedeem(vaultBal); uint256 profit = amountOut - supply; if (profit > 0) { - vault.withdraw(profit, gov, address(this)); + vault.withdraw(profit, gov, address(this)); } } @@ -118,7 +118,6 @@ contract PSM { token.safeTransfer(gov, token.balanceOf(address(this))); } - function setDepositFeeBps(uint256 newFee) external onlyOperator { require(newFee <= BPS_DENOMINATOR, "Fee too high"); emit DepositFeeUpdated(depositFeeBps, newFee); @@ -141,4 +140,4 @@ contract PSM { emit OperatorChanged(operator, newOperator); operator = newOperator; } -} \ No newline at end of file +} diff --git a/test/PSM.t.sol b/test/PSM.t.sol index 1f9ab3e..cbfcd7b 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -5,6 +5,7 @@ import "forge-std/Test.sol"; import "src/PSM.sol"; import {MockERC4626, ERC20} from "lib/solmate/src/test/utils/mocks/MockERC4626.sol"; // Simple mocks for ERC20 + contract MockERC20 is IERC20 { string public name = "Mock"; string public symbol = "MOCK"; @@ -18,27 +19,30 @@ contract MockERC20 is IERC20 { balanceOf[to] += amount; return true; } + function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] = amount; return true; } + function transferFrom(address from, address to, uint256 amount) external returns (bool) { allowance[from][msg.sender] -= amount; balanceOf[from] -= amount; balanceOf[to] += amount; return true; } + function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; } + function burn(address from, uint256 amount) external { balanceOf[from] -= amount; totalSupply -= amount; } } - contract PSMTest is Test { PSM psm; MockERC20 collateral; @@ -89,7 +93,7 @@ contract PSMTest is Test { DOLA.approve(address(psm), type(uint256).max); console2.log(DOLA.balanceOf(user)); // Now burn 1000 DOLA - psm.sell(user,1000 ether); + psm.sell(user, 1000 ether); vm.stopPrank(); assertEq(collateral.balanceOf(gov), 0); @@ -99,7 +103,7 @@ contract PSMTest is Test { } function testBurnDolaExceedSupply() public { - // Mint some DOLA + // Mint some DOLA uint256 buyAmount = 1000 ether; vm.startPrank(user); psm.buy(user, buyAmount); @@ -117,9 +121,8 @@ contract PSMTest is Test { vm.stopPrank(); } - function testTakeProfit() public { - // Mint some DOLA + // Mint some DOLA uint256 buyAmount = 1000 ether; uint256 initialCollateralBal = collateral.balanceOf(user); vm.startPrank(user); @@ -178,9 +181,10 @@ contract PSMTest is Test { vm.expectRevert("Not operator"); psm.setSupplyCap(2_000_000 ether); } + function testOperatorChange() public { address newOp = address(0x456); psm.setOperator(newOp); assertEq(psm.operator(), newOp); } -} \ No newline at end of file +} From f6de4491ea6804be312e2b07a581557fea1b4e31 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Fri, 13 Jun 2025 15:22:58 -0500 Subject: [PATCH 03/22] add PSMFed and Controller, update tests --- src/Controller.sol | 13 ++ src/PSM.sol | 107 +++++++++----- src/PSMFed.sol | 71 +++++++++ test/PSM.t.sol | 353 ++++++++++++++++++++++++++++++++++++++------- 4 files changed, 453 insertions(+), 91 deletions(-) create mode 100644 src/Controller.sol create mode 100644 src/PSMFed.sol diff --git a/src/Controller.sol b/src/Controller.sol new file mode 100644 index 0000000..475d34c --- /dev/null +++ b/src/Controller.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract Controller { + + function isBuyAllowed() external pure returns (bool) { + return true; // For now, we allow all calls + } + + function isSellAllowed() external pure returns (bool) { + return true; // For now, we allow all calls + } +} \ No newline at end of file diff --git a/src/PSM.sol b/src/PSM.sol index 48100b3..7f6077f 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -5,28 +5,31 @@ 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 IDOLA is IERC20 { - function mint(address to, uint256 amount) external; - function burn(address from, uint256 amount) external; +interface IController { + function isBuyAllowed() external view returns (bool); + function isSellAllowed() external view returns (bool); } contract PSM { using SafeERC20 for IERC20; IERC20 public immutable collateral; - IERC4626 public immutable vault; - IDOLA public immutable DOLA; - address public immutable gov; - - address public operator; + IERC20 public immutable DOLA; + address public immutable fed; // PSMFed contract address + + address public gov; + IController public controller; // Controller contract address uint256 public depositFeeBps; // e.g., 50 = 0.5% uint256 public withdrawFeeBps; // e.g., 50 = 0.5% uint256 public constant BPS_DENOMINATOR = 10_000; - uint256 public supply; - uint256 public supplyCap; + uint256 public supply; // Collateral supplied in the PSM (excluding fees and profit) + IERC4626 public vault; - event OperatorChanged(address indexed oldOperator, address indexed newOperator); + event GovChanged(address indexed oldOperator, address indexed newOperator); + event ControllerChanged(address indexed oldController, address indexed newController); + event VaultMigrated(address indexed oldVault, address indexed newVault); event DepositFeeUpdated(uint256 oldFee, uint256 newFee); event WithdrawFeeUpdated(uint256 oldFee, uint256 newFee); event SupplyCapUpdated(uint256 newSupplyCap); @@ -40,25 +43,33 @@ contract PSM { address _gov, uint256 _depositFeeBps, uint256 _withdrawFeeBps, - address _operator + address _controller, + address _chair ) { + require(_depositFeeBps <= BPS_DENOMINATOR && _withdrawFeeBps <= BPS_DENOMINATOR, "Fees too high"); collateral = IERC20(_collateral); vault = IERC4626(_vault); - DOLA = IDOLA(_DOLA); + DOLA = IERC20(_DOLA); gov = _gov; depositFeeBps = _depositFeeBps; withdrawFeeBps = _withdrawFeeBps; - operator = _operator; + controller = IController(_controller); + fed = address(new PSMFed(address(this), _gov, _chair, _DOLA)); + DOLA.approve(fed, type(uint256).max); } - modifier onlyOperator() { - require(msg.sender == operator, "Not operator"); + modifier onlyGov() { + require(msg.sender == gov, "Not gov"); _; } - function buy(address to, uint256 amount) external { + function buy(uint256 amount) external { + buy(msg.sender, amount); + } + + function buy(address to, uint256 amount) public { require(amount > 0, "Amount must be > 0"); - require(supply + amount <= supplyCap, "Supply cap exceeded"); + require(controller.isBuyAllowed(), "Denied by controller"); supply += amount; uint256 amountIn = amount; if (depositFeeBps > 0) { @@ -69,15 +80,18 @@ contract PSM { collateral.safeTransferFrom(msg.sender, address(this), amountIn); collateral.approve(address(vault), amountIn); vault.deposit(amountIn, address(this)); - DOLA.mint(to, amount); + DOLA.safeTransfer(to, amount); emit Buy(msg.sender, amount, amountIn); } - function sell(address to, uint256 amount) external { + function sell(uint256 amount) external { + sell(msg.sender, amount); + } + function sell(address to, uint256 amount) public { require(amount > 0, "Amount must be > 0"); + require(controller.isSellAllowed(), "Denied by controller"); supply -= amount; - DOLA.transferFrom(msg.sender, address(this), amount); - DOLA.burn(address(this), amount); + DOLA.safeTransferFrom(msg.sender, address(this), amount); uint256 amountOut = amount; @@ -90,7 +104,7 @@ contract PSM { emit Sell(msg.sender, amount, amountOut); } - function takeProfit() external { + function takeProfit() public { uint256 vaultBal = vault.balanceOf(address(this)); uint256 amountOut = vault.previewRedeem(vaultBal); uint256 profit = amountOut - supply; @@ -99,10 +113,15 @@ contract PSM { } } - function getTotalReserves() external view returns (uint256) { + // Include profit and fees in total reserves + function getTotalReserves() public view returns (uint256) { return vault.previewRedeem(vault.balanceOf(address(this))); } + function getProfit() external view returns (uint256) { + return getTotalReserves() - supply; + } + function getCollateralIn(uint256 dolaBuyAmount) external view returns (uint256) { uint256 fee = (dolaBuyAmount * depositFeeBps) / BPS_DENOMINATOR; return dolaBuyAmount + fee; @@ -113,31 +132,49 @@ contract PSM { return dolaSellAmount - fee; } - function sweep(IERC20 token) public onlyOperator { - require(token != IERC20(address(vault)), "Vault token cannot be swept"); + function migrate(address newVault) 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)); + } + + address oldVault = address(vault); + vault = IERC4626(newVault); + uint256 collateralBalance = collateral.balanceOf(address(this)); + collateral.approve(address(vault),collateralBalance); + vault.deposit(collateralBalance, address(this)); + emit VaultMigrated(oldVault, newVault); + } + + function sweep(IERC20 token) external onlyGov { token.safeTransfer(gov, token.balanceOf(address(this))); } - function setDepositFeeBps(uint256 newFee) external onlyOperator { + function setDepositFeeBps(uint256 newFee) external onlyGov { require(newFee <= BPS_DENOMINATOR, "Fee too high"); emit DepositFeeUpdated(depositFeeBps, newFee); depositFeeBps = newFee; } - function setWithdrawFeeBps(uint256 newFee) external onlyOperator { + function setWithdrawFeeBps(uint256 newFee) external onlyGov { require(newFee <= BPS_DENOMINATOR, "Fee too high"); emit WithdrawFeeUpdated(withdrawFeeBps, newFee); withdrawFeeBps = newFee; } - function setSupplyCap(uint256 newSupplyCap) external onlyOperator { - supplyCap = newSupplyCap; - emit SupplyCapUpdated(supplyCap); + function setGov(address newGov) external onlyGov { + require(newGov != address(0), "Zero address"); + emit GovChanged(gov, newGov); + gov = newGov; } - function setOperator(address newOperator) external onlyOperator { - require(newOperator != address(0), "Zero address"); - emit OperatorChanged(operator, newOperator); - operator = newOperator; + function setController(address newController) external onlyGov { + require(newController != address(0), "Zero address"); + emit ControllerChanged(address(controller), newController); + controller = IController(newController); } } diff --git a/src/PSMFed.sol b/src/PSMFed.sol new file mode 100644 index 0000000..5cee9a2 --- /dev/null +++ b/src/PSMFed.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +interface IDOLA is IERC20 { + function mint(address to, uint256 amount) external; + function burn(address from, uint256 amount) external; +} + + +contract PSMFed { + address public immutable psm; + address public immutable gov; + IDOLA public immutable DOLA; + + address public chair; + uint256 public supply; // Current DOLA amount supplied to the PSM + uint256 public supplyCap; // Maximum DOLA supply allowed in the PSM + + event ChairChanged(address indexed oldChair, address indexed newChair); + event SupplyCapUpdated(uint256 oldSupplyCap, uint256 newSupplyCap); + + constructor(address _psm, address _gov, address _chair, address _dola) { + psm = _psm; + gov = _gov; + chair = _chair; + DOLA = IDOLA(_dola); + } + + modifier onlyGov() { + require(msg.sender == gov, "Not governance"); + _; + } + modifier onlyChair() { + require(msg.sender == chair, "Not chair"); + _; + } + + function expansion(uint256 amount) external onlyChair { + require(amount > 0, "Amount must be > 0"); + require(amount + supply <= supplyCap, "Supply cap exceeded"); + supply += amount; + DOLA.mint(psm, amount); + } + function contraction(uint256 amount) external onlyChair { + require(amount > 0, "Amount must be > 0"); + supply -= amount; + DOLA.transferFrom(psm, address(this), amount); + DOLA.burn(address(this), amount); + } + + function setSupplyCap(uint256 newSupplyCap) external onlyGov { + uint256 oldSupplyCap = supplyCap; + supplyCap = newSupplyCap; + emit SupplyCapUpdated(oldSupplyCap, newSupplyCap); + } + + function setChair(address newChair) external onlyGov { + require(newChair != address(0), "Invalid address"); + address oldChair = chair; + chair = newChair; + emit ChairChanged(oldChair, newChair); + } + + function resign() external onlyChair() { + address oldChair = chair; + chair = address(0); + emit ChairChanged(oldChair, address(0)); + } +} \ No newline at end of file diff --git a/test/PSM.t.sol b/test/PSM.t.sol index cbfcd7b..fe857a5 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "src/PSM.sol"; +import {Controller} from "src/Controller.sol"; +import {PSMFed} from "src/PSMFed.sol"; import {MockERC4626, ERC20} from "lib/solmate/src/test/utils/mocks/MockERC4626.sol"; // Simple mocks for ERC20 @@ -45,6 +47,8 @@ contract MockERC20 is IERC20 { contract PSMTest is Test { PSM psm; + PSMFed fed; + Controller controller; MockERC20 collateral; MockERC4626 vault; MockERC20 DOLA; @@ -56,6 +60,7 @@ contract PSMTest is Test { collateral = new MockERC20(); vault = new MockERC4626(ERC20(address(collateral)), "MOCK", "MOCK"); DOLA = new MockERC20(); + controller = new Controller(); psm = new PSM( address(collateral), address(vault), @@ -63,128 +68,364 @@ contract PSMTest is Test { gov, 50, // 0.5% deposit fee 100, // 1% withdraw fee - operator + address(controller), + address(this) ); - collateral.mint(user, 1_000_000 ether); + fed = PSMFed(psm.fed()); + + collateral.mint(user, 10_050_000 ether); vm.startPrank(user); collateral.approve(address(psm), type(uint256).max); vm.stopPrank(); - psm.setSupplyCap(1_000_000 ether); // Set supply cap for DOLA + vm.prank(gov); + fed.setSupplyCap(20_000_000 ether); // Set supply cap for DOLA + fed.expansion(10_000_000 ether); // Mint some DOLA to PSMFed } - function testMintDOLAWithFee() public { - uint256 buyAmount = 1000 ether; + function test_BuyDOLAWithFee(uint256 amount) public returns (uint256) { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); vm.startPrank(user); - psm.buy(user, buyAmount); + psm.buy(user, amount); vm.stopPrank(); + + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + assertEq(collateral.balanceOf(address(vault)), amount + buyFee); // 1000 + 5 = 1005 + // User should have DOLA bought + assertEq(DOLA.balanceOf(user), amount); + return buyFee; + } - // Check fee: 0.5% of 1000 = 5 - // Vault should have 1005 collateral - assertEq(collateral.balanceOf(address(vault)), 1005 ether); - // User should have 1000 DOLA (buyAmount) - assertEq(DOLA.balanceOf(user), 1000 ether); + function test_Buy_DOLA(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + vm.startPrank(user); + psm.buy(amount); + assertEq(DOLA.balanceOf(user), amount); } - function testBurnDOLAWithFee() public { - uint256 buyAmount = 1000 ether; - uint256 initialCollateralBal = collateral.balanceOf(user); + function test_Sell_DOLA(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); vm.startPrank(user); - psm.buy(user, buyAmount); + psm.buy(amount); DOLA.approve(address(psm), type(uint256).max); + psm.sell(amount); + vm.stopPrank(); + assertEq(DOLA.balanceOf(user), 0); // User should have no DOLA left + } + + function test_SellDOLAWithFee(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + + uint256 initialCollateralBal = collateral.balanceOf(user); + vm.startPrank(user); + psm.buy(user, amount); + console2.log(DOLA.balanceOf(user)); - // Now burn 1000 DOLA - psm.sell(user, 1000 ether); + // Now burn All DOLA + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, amount); vm.stopPrank(); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 sellFee = amount * psm.withdrawFeeBps() / 10000; // 1% withdraw fee assertEq(collateral.balanceOf(gov), 0); - assertEq(collateral.balanceOf(user), initialCollateralBal - 1005 ether + 990 ether); + assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees // Vault should have only fees left - assertEq(collateral.balanceOf(address(vault)), 15 ether); // deposit and withdraw fees + assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // deposit and withdraw fees } - function testBurnDolaExceedSupply() public { - // Mint some DOLA - uint256 buyAmount = 1000 ether; - vm.startPrank(user); - psm.buy(user, buyAmount); - DOLA.approve(address(psm), type(uint256).max); + function test_SellDolaExceedSupply(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + + vm.prank(user); + psm.buy(user, amount); + vm.stopPrank(); - // Now try to burn more than minted + // Now try to sell more than bought vm.startPrank(user); - psm.sell(user, 1000 ether); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, amount); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 sellFee = amount * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // deposit and withdraw fees - assertEq(collateral.balanceOf(address(vault)), 15 ether); // deposit and withdraw fees DOLA.mint(user, 15 ether); // Mint some DOLA to user vm.expectRevert(); psm.sell(user, 15 ether); vm.stopPrank(); } - function testTakeProfit() public { - // Mint some DOLA - uint256 buyAmount = 1000 ether; + function test_TakeProfit(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + uint256 initialCollateralBal = collateral.balanceOf(user); - vm.startPrank(user); - psm.buy(user, buyAmount); - DOLA.approve(address(psm), type(uint256).max); - vm.stopPrank(); + // Buy some DOLA + vm.prank(user); + psm.buy(user, amount); // Simulate profit in vault uint256 profit = 200 ether; // Assume profit of 200 ether collateral.mint(address(vault), profit); // Add profit to vault + // Take profit vm.prank(operator); psm.takeProfit(); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee // Check that profit was taken - assertEq(collateral.balanceOf(gov), profit + 5 ether); // 5 ether from deposit fee + assertEq(collateral.balanceOf(gov), profit + buyFee); - vm.prank(user); - psm.sell(user, 1000 ether); // User sells DOLA + uint256 sellFee = amount * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + // User sells all DOLA + vm.startPrank(user); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, amount); // User sells DOLA + vm.stopPrank(); assertEq(psm.supply(), 0); // Supply should be zero after selling all DOLA - assertEq(collateral.balanceOf(user), initialCollateralBal - 1005 ether + 990 ether); // User gets back collateral minus fees + assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees + assertEq(DOLA.balanceOf(user), 0); // User should have no DOLA left + // Gov balance has profit + buyFee uint256 govBalanceAfter = collateral.balanceOf(gov); vm.prank(operator); psm.takeProfit(); // Operator tries to take profit again (fees from previous sell) - assertEq(collateral.balanceOf(gov), govBalanceAfter + 10 ether); + assertEq(collateral.balanceOf(gov), govBalanceAfter + sellFee); + } + + function test_Migrate_Vault(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + uint256 fee = test_BuyDOLAWithFee(amount); + // Simulate profit in vault + uint256 profit = 200 ether; // Assume profit of 200 ether + collateral.mint(address(vault), profit); // Add profit to vault + + MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + vm.prank(gov); + psm.migrate(address(newVault)); + assertEq(address(psm.vault()), address(newVault)); + // Profit was taken and transferred to governance plus the deposit fee + assertEq(collateral.balanceOf(gov), profit + fee); + // Check new vault has the correct balance + assertEq(newVault.balanceOf(address(psm)), amount); } - function test_fail_if_exceed_supply_cap() public { - uint256 supplyCap = psm.supplyCap(); - uint256 buyAmount = supplyCap + 1 ether; // Exceeding supply cap + function test_2_users_buy_then_migrate_with_profit_then_contract_and_sell() public { + address user2 = address(0x456); + collateral.mint(user2, 5000 ether); // Give user2 some collateral + + uint256 amount1 = 1000 ether; + uint256 amount2 = 2000 ether; + uint256 user1CollateralBal = collateral.balanceOf(user); + uint256 user2CollateralBal = collateral.balanceOf(user2); + // User1 buys DOLA + vm.startPrank(user); + psm.buy(user, amount1); + vm.stopPrank(); + + // User2 buys DOLA + vm.startPrank(user2); + collateral.approve(address(psm), type(uint256).max); + psm.buy(user2, amount2); + vm.stopPrank(); + + // Check balances after both users bought DOLA + assertEq(DOLA.balanceOf(user), amount1); + assertEq(DOLA.balanceOf(user2), amount2); + + assertEq(collateral.balanceOf(address(gov)),0) ; // Gov should have no collateral yet + // Simulate profit in vault + uint256 profit = 100 ether; // Assume profit of 100 ether + collateral.mint(address(vault), 100 ether); + // Migrate to new vault + MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + vm.prank(gov); + psm.migrate(address(newVault)); + // After migration, profit and fees should be taken and transferred to governance + uint256 fee = (amount1 + amount2) * psm.depositFeeBps() / 10000; // 0.5% deposit fee + assertEq(collateral.balanceOf(gov), profit + fee); // Gov should have profit + deposit fee + + // Check new vault has the correct balance + assertEq(newVault.balanceOf(address(psm)), amount1 + amount2); + + // Full contraction but can still sell DOLA + vm.prank(gov); + fed.contraction(DOLA.balanceOf(address(psm))); + + // User 1 sells DOLA + vm.startPrank(user); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, amount1); + vm.stopPrank(); + // User 2 sells DOLA + vm.startPrank(user2); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user2, amount2); + vm.stopPrank(); + + // Check both users got their collateral back minus fees + uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 sellFee1 = amount1 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 sellFee2 = amount2 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + sellFee1)); + assertEq(collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + sellFee2)); + } + function test_Fail_if_no_DOLA_available() public { + uint256 dolaBalance = DOLA.balanceOf(address(psm)); + fed.contraction(dolaBalance); + assertEq(DOLA.balanceOf(address(psm)), 0); vm.startPrank(user); - vm.expectRevert("Supply cap exceeded"); - psm.buy(user, buyAmount); + vm.expectRevert(); + psm.buy(user, dolaBalance); vm.stopPrank(); } - function testOperatorCanUpdateFees() public { + function test_GovCanUpdateFees() public { + vm.startPrank(gov); psm.setDepositFeeBps(100); assertEq(psm.depositFeeBps(), 100); psm.setWithdrawFeeBps(200); assertEq(psm.withdrawFeeBps(), 200); } - function testNonOperatorCannotUpdateFees() public { + function test_GovCanUpdateController() public { + Controller newController = new Controller(); + vm.startPrank(gov); + psm.setController(address(newController)); + assertEq(address(psm.controller()), address(newController)); + } + + function test_GovChange() public { + address newOp = address(0x456); + vm.prank(gov); + psm.setGov(newOp); + assertEq(psm.gov(), newOp); + } + function test_NonGovCannotUpdateFees() public { vm.startPrank(user); - vm.expectRevert("Not operator"); + vm.expectRevert("Not gov"); psm.setDepositFeeBps(100); - vm.expectRevert("Not operator"); + vm.expectRevert("Not gov"); psm.setWithdrawFeeBps(200); } - function testNonOperatorCannotUpdateSupplyCap() public { + function test_Fail_DepositFee_TooHigh() public { + vm.startPrank(gov); + vm.expectRevert("Fee too high"); + psm.setDepositFeeBps(10001); // 100.1% + vm.stopPrank(); + } + function test_Fail_WithdrawFee_TooHigh() public { + vm.startPrank(gov); + vm.expectRevert("Fee too high"); + psm.setWithdrawFeeBps(10001); // 100.1% + vm.stopPrank(); + } + + + function test_Fail_Zero_Amount() public { vm.startPrank(user); - vm.expectRevert("Not operator"); - psm.setSupplyCap(2_000_000 ether); + vm.expectRevert("Amount must be > 0"); + psm.buy(user, 0); + vm.expectRevert("Amount must be > 0"); + psm.sell(user, 0); + vm.stopPrank(); } - function testOperatorChange() public { - address newOp = address(0x456); - psm.setOperator(newOp); - assertEq(psm.operator(), newOp); + function test_Fail_buy_and_sell_if_denied_by_Controller() public { + vm.mockCall( + address(psm.controller()), + abi.encodeWithSelector(Controller.isBuyAllowed.selector), + abi.encode(false) + ); + vm.mockCall( + address(psm.controller()), + abi.encodeWithSelector(Controller.isSellAllowed.selector), + abi.encode(false) + ); + vm.startPrank(user); + vm.expectRevert("Denied by controller"); + psm.buy(user, 1000 ether); + vm.expectRevert("Denied by controller"); + psm.sell(user, 1000 ether); + vm.stopPrank(); + } + function test_getCollateralOut() public { + uint256 dolaAmount = 1000 ether; + uint256 expectedCollateralOut = dolaAmount - (dolaAmount * psm.withdrawFeeBps() / 10000); // 1% fee + assertEq(psm.getCollateralOut(dolaAmount), expectedCollateralOut); + } + + function test_getCollateralIn() public { + uint256 dolaAmount = 1000 ether; + uint256 expectedCollateralIn = dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000); // 0.5% fee + assertEq(psm.getCollateralIn(dolaAmount), expectedCollateralIn); + } + + function test_getTotalReserves() public { + uint256 dolaAmount = 1000 ether; + vm.startPrank(user); + psm.buy(user, dolaAmount); + vm.stopPrank(); + + uint256 totalReserves = psm.getTotalReserves(); + assertEq(totalReserves, dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000)); // Total reserves should include DOLA supply + deposit fee + } + + function test_getProfit() public { + uint256 dolaAmount = 1000 ether; + vm.startPrank(user); + psm.buy(user, dolaAmount); + vm.stopPrank(); + + uint256 profit = psm.getProfit(); + assertEq(profit, (dolaAmount * psm.depositFeeBps() / 10000)); // Profit should equal to fees collected + } + + function test_PSMFed_expansion(uint256 expansionAmount) public { + uint256 initialSupply = fed.supply(); + vm.assume(expansionAmount > 0 && expansionAmount <= fed.supplyCap() - initialSupply); + + vm.prank(fed.chair()); + fed.expansion(expansionAmount); + + assertEq(fed.supply(), initialSupply + expansionAmount); + assertEq(DOLA.balanceOf(address(psm)), initialSupply + expansionAmount); + } + function test_PSMFed_contraction(uint256 contractionAmount) public { + uint256 initialSupply = fed.supply(); + vm.assume(contractionAmount > 0 && contractionAmount <= initialSupply); + + vm.prank(fed.chair()); + fed.contraction(contractionAmount); + + assertEq(fed.supply(), initialSupply - contractionAmount); + assertEq(DOLA.balanceOf(address(psm)), initialSupply - contractionAmount); + } + + function test_PSMFed_setSupplyCap(uint256 newSupplyCap) public { + vm.assume(newSupplyCap > 0 && newSupplyCap < 100000000 ether); + + vm.prank(gov); + fed.setSupplyCap(newSupplyCap); + + assertEq(fed.supplyCap(), newSupplyCap); + } + function test_PSMFed_setChair(address newChair) public { + vm.assume(newChair != address(0)); + + vm.prank(gov); + fed.setChair(newChair); + + assertEq(fed.chair(), newChair); + } + function test_PSMFed_resign() public { + address initialChair = fed.chair(); + + vm.prank(initialChair); + fed.resign(); + + assertEq(fed.chair(), address(0)); } } From 95b355ad85dae5e99497ad3828935da53a19b1c8 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Fri, 13 Jun 2025 15:24:35 -0500 Subject: [PATCH 04/22] format --- src/Controller.sol | 3 +- src/PSM.sol | 17 +++++------ src/PSMFed.sol | 7 +++-- test/PSM.t.sol | 70 ++++++++++++++++++++++++---------------------- 4 files changed, 50 insertions(+), 47 deletions(-) diff --git a/src/Controller.sol b/src/Controller.sol index 475d34c..ff8818c 100644 --- a/src/Controller.sol +++ b/src/Controller.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.20; contract Controller { - function isBuyAllowed() external pure returns (bool) { return true; // For now, we allow all calls } @@ -10,4 +9,4 @@ contract Controller { function isSellAllowed() external pure returns (bool) { return true; // For now, we allow all calls } -} \ No newline at end of file +} diff --git a/src/PSM.sol b/src/PSM.sol index 7f6077f..dff610b 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -18,7 +18,7 @@ contract PSM { IERC20 public immutable collateral; IERC20 public immutable DOLA; address public immutable fed; // PSMFed contract address - + address public gov; IController public controller; // Controller contract address uint256 public depositFeeBps; // e.g., 50 = 0.5% @@ -55,7 +55,7 @@ contract PSM { withdrawFeeBps = _withdrawFeeBps; controller = IController(_controller); fed = address(new PSMFed(address(this), _gov, _chair, _DOLA)); - DOLA.approve(fed, type(uint256).max); + DOLA.approve(fed, type(uint256).max); } modifier onlyGov() { @@ -87,6 +87,7 @@ contract PSM { function sell(uint256 amount) external { sell(msg.sender, amount); } + function sell(address to, uint256 amount) public { require(amount > 0, "Amount must be > 0"); require(controller.isSellAllowed(), "Denied by controller"); @@ -119,7 +120,7 @@ contract PSM { } function getProfit() external view returns (uint256) { - return getTotalReserves() - supply; + return getTotalReserves() - supply; } function getCollateralIn(uint256 dolaBuyAmount) external view returns (uint256) { @@ -135,21 +136,21 @@ contract PSM { function migrate(address newVault) 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) { + if (vault.balanceOf(address(this)) != 0) { vault.redeem(vault.balanceOf(address(this)), address(this), address(this)); } - + address oldVault = address(vault); vault = IERC4626(newVault); uint256 collateralBalance = collateral.balanceOf(address(this)); - collateral.approve(address(vault),collateralBalance); + collateral.approve(address(vault), collateralBalance); vault.deposit(collateralBalance, address(this)); emit VaultMigrated(oldVault, newVault); } - + function sweep(IERC20 token) external onlyGov { token.safeTransfer(gov, token.balanceOf(address(this))); } diff --git a/src/PSMFed.sol b/src/PSMFed.sol index 5cee9a2..d3c8d2f 100644 --- a/src/PSMFed.sol +++ b/src/PSMFed.sol @@ -8,7 +8,6 @@ interface IDOLA is IERC20 { function burn(address from, uint256 amount) external; } - contract PSMFed { address public immutable psm; address public immutable gov; @@ -32,6 +31,7 @@ contract PSMFed { require(msg.sender == gov, "Not governance"); _; } + modifier onlyChair() { require(msg.sender == chair, "Not chair"); _; @@ -43,6 +43,7 @@ contract PSMFed { supply += amount; DOLA.mint(psm, amount); } + function contraction(uint256 amount) external onlyChair { require(amount > 0, "Amount must be > 0"); supply -= amount; @@ -63,9 +64,9 @@ contract PSMFed { emit ChairChanged(oldChair, newChair); } - function resign() external onlyChair() { + function resign() external onlyChair { address oldChair = chair; chair = address(0); emit ChairChanged(oldChair, address(0)); } -} \ No newline at end of file +} diff --git a/test/PSM.t.sol b/test/PSM.t.sol index fe857a5..c839f53 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -87,7 +87,7 @@ contract PSMTest is Test { vm.startPrank(user); psm.buy(user, amount); vm.stopPrank(); - + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee assertEq(collateral.balanceOf(address(vault)), amount + buyFee); // 1000 + 5 = 1005 // User should have DOLA bought @@ -114,11 +114,11 @@ contract PSMTest is Test { function test_SellDOLAWithFee(uint256 amount) public { vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); - + uint256 initialCollateralBal = collateral.balanceOf(user); vm.startPrank(user); psm.buy(user, amount); - + console2.log(DOLA.balanceOf(user)); // Now burn All DOLA DOLA.approve(address(psm), type(uint256).max); @@ -135,10 +135,10 @@ contract PSMTest is Test { function test_SellDolaExceedSupply(uint256 amount) public { vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); - + vm.prank(user); psm.buy(user, amount); - + vm.stopPrank(); // Now try to sell more than bought @@ -173,7 +173,7 @@ contract PSMTest is Test { uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee // Check that profit was taken - assertEq(collateral.balanceOf(gov), profit + buyFee); + assertEq(collateral.balanceOf(gov), profit + buyFee); uint256 sellFee = amount * psm.withdrawFeeBps() / 10000; // 1% withdraw fee // User sells all DOLA @@ -196,24 +196,24 @@ contract PSMTest is Test { function test_Migrate_Vault(uint256 amount) public { vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); uint256 fee = test_BuyDOLAWithFee(amount); - // Simulate profit in vault + // Simulate profit in vault uint256 profit = 200 ether; // Assume profit of 200 ether collateral.mint(address(vault), profit); // Add profit to vault - + MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); vm.prank(gov); - psm.migrate(address(newVault)); + psm.migrate(address(newVault)); assertEq(address(psm.vault()), address(newVault)); // Profit was taken and transferred to governance plus the deposit fee assertEq(collateral.balanceOf(gov), profit + fee); // Check new vault has the correct balance - assertEq(newVault.balanceOf(address(psm)), amount); + assertEq(newVault.balanceOf(address(psm)), amount); } function test_2_users_buy_then_migrate_with_profit_then_contract_and_sell() public { address user2 = address(0x456); collateral.mint(user2, 5000 ether); // Give user2 some collateral - + uint256 amount1 = 1000 ether; uint256 amount2 = 2000 ether; uint256 user1CollateralBal = collateral.balanceOf(user); @@ -233,7 +233,7 @@ contract PSMTest is Test { assertEq(DOLA.balanceOf(user), amount1); assertEq(DOLA.balanceOf(user2), amount2); - assertEq(collateral.balanceOf(address(gov)),0) ; // Gov should have no collateral yet + assertEq(collateral.balanceOf(address(gov)), 0); // Gov should have no collateral yet // Simulate profit in vault uint256 profit = 100 ether; // Assume profit of 100 ether collateral.mint(address(vault), 100 ether); @@ -244,13 +244,13 @@ contract PSMTest is Test { // After migration, profit and fees should be taken and transferred to governance uint256 fee = (amount1 + amount2) * psm.depositFeeBps() / 10000; // 0.5% deposit fee assertEq(collateral.balanceOf(gov), profit + fee); // Gov should have profit + deposit fee - + // Check new vault has the correct balance assertEq(newVault.balanceOf(address(psm)), amount1 + amount2); - // Full contraction but can still sell DOLA + // Full contraction but can still sell DOLA vm.prank(gov); - fed.contraction(DOLA.balanceOf(address(psm))); + fed.contraction(DOLA.balanceOf(address(psm))); // User 1 sells DOLA vm.startPrank(user); @@ -271,6 +271,7 @@ contract PSMTest is Test { assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + sellFee1)); assertEq(collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + sellFee2)); } + function test_Fail_if_no_DOLA_available() public { uint256 dolaBalance = DOLA.balanceOf(address(psm)); fed.contraction(dolaBalance); @@ -302,6 +303,7 @@ contract PSMTest is Test { psm.setGov(newOp); assertEq(psm.gov(), newOp); } + function test_NonGovCannotUpdateFees() public { vm.startPrank(user); vm.expectRevert("Not gov"); @@ -316,6 +318,7 @@ contract PSMTest is Test { psm.setDepositFeeBps(10001); // 100.1% vm.stopPrank(); } + function test_Fail_WithdrawFee_TooHigh() public { vm.startPrank(gov); vm.expectRevert("Fee too high"); @@ -323,7 +326,6 @@ contract PSMTest is Test { vm.stopPrank(); } - function test_Fail_Zero_Amount() public { vm.startPrank(user); vm.expectRevert("Amount must be > 0"); @@ -335,14 +337,10 @@ contract PSMTest is Test { function test_Fail_buy_and_sell_if_denied_by_Controller() public { vm.mockCall( - address(psm.controller()), - abi.encodeWithSelector(Controller.isBuyAllowed.selector), - abi.encode(false) + address(psm.controller()), abi.encodeWithSelector(Controller.isBuyAllowed.selector), abi.encode(false) ); vm.mockCall( - address(psm.controller()), - abi.encodeWithSelector(Controller.isSellAllowed.selector), - abi.encode(false) + address(psm.controller()), abi.encodeWithSelector(Controller.isSellAllowed.selector), abi.encode(false) ); vm.startPrank(user); vm.expectRevert("Denied by controller"); @@ -351,6 +349,7 @@ contract PSMTest is Test { psm.sell(user, 1000 ether); vm.stopPrank(); } + function test_getCollateralOut() public { uint256 dolaAmount = 1000 ether; uint256 expectedCollateralOut = dolaAmount - (dolaAmount * psm.withdrawFeeBps() / 10000); // 1% fee @@ -368,7 +367,7 @@ contract PSMTest is Test { vm.startPrank(user); psm.buy(user, dolaAmount); vm.stopPrank(); - + uint256 totalReserves = psm.getTotalReserves(); assertEq(totalReserves, dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000)); // Total reserves should include DOLA supply + deposit fee } @@ -378,7 +377,7 @@ contract PSMTest is Test { vm.startPrank(user); psm.buy(user, dolaAmount); vm.stopPrank(); - + uint256 profit = psm.getProfit(); assertEq(profit, (dolaAmount * psm.depositFeeBps() / 10000)); // Profit should equal to fees collected } @@ -386,46 +385,49 @@ contract PSMTest is Test { function test_PSMFed_expansion(uint256 expansionAmount) public { uint256 initialSupply = fed.supply(); vm.assume(expansionAmount > 0 && expansionAmount <= fed.supplyCap() - initialSupply); - + vm.prank(fed.chair()); fed.expansion(expansionAmount); - + assertEq(fed.supply(), initialSupply + expansionAmount); assertEq(DOLA.balanceOf(address(psm)), initialSupply + expansionAmount); } + function test_PSMFed_contraction(uint256 contractionAmount) public { uint256 initialSupply = fed.supply(); vm.assume(contractionAmount > 0 && contractionAmount <= initialSupply); - + vm.prank(fed.chair()); fed.contraction(contractionAmount); - + assertEq(fed.supply(), initialSupply - contractionAmount); assertEq(DOLA.balanceOf(address(psm)), initialSupply - contractionAmount); } function test_PSMFed_setSupplyCap(uint256 newSupplyCap) public { vm.assume(newSupplyCap > 0 && newSupplyCap < 100000000 ether); - + vm.prank(gov); fed.setSupplyCap(newSupplyCap); - + assertEq(fed.supplyCap(), newSupplyCap); } + function test_PSMFed_setChair(address newChair) public { vm.assume(newChair != address(0)); - + vm.prank(gov); fed.setChair(newChair); - + assertEq(fed.chair(), newChair); } + function test_PSMFed_resign() public { address initialChair = fed.chair(); - + vm.prank(initialChair); fed.resign(); - + assertEq(fed.chair(), address(0)); } } From 0ddf1dc446cb23e1fa020b257d57c457b96827c8 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 17 Jun 2025 16:50:14 -0500 Subject: [PATCH 05/22] add migratable gov and controller update --- src/Controller.sol | 4 ++-- src/PSM.sol | 26 +++++++++++++++++--------- src/PSMFed.sol | 17 ++++++++++++++++- test/PSM.t.sol | 25 ++++++++++++++++++------- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/Controller.sol b/src/Controller.sol index ff8818c..6e3a6e9 100644 --- a/src/Controller.sol +++ b/src/Controller.sol @@ -2,11 +2,11 @@ pragma solidity ^0.8.20; contract Controller { - function isBuyAllowed() external pure returns (bool) { + function isBuyAllowed(uint256 amount) external pure returns (bool) { return true; // For now, we allow all calls } - function isSellAllowed() external pure returns (bool) { + function isSellAllowed(uint256 amount) external pure returns (bool) { return true; // For now, we allow all calls } } diff --git a/src/PSM.sol b/src/PSM.sol index dff610b..39ecb05 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -8,8 +8,8 @@ import "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {PSMFed} from "src/PSMFed.sol"; interface IController { - function isBuyAllowed() external view returns (bool); - function isSellAllowed() external view returns (bool); + function isBuyAllowed(uint256 amount) external view returns (bool); + function isSellAllowed(uint256 amount) external view returns (bool); } contract PSM { @@ -20,6 +20,7 @@ contract PSM { address public immutable fed; // PSMFed contract address address public gov; + address public pendingGov; IController public controller; // Controller contract address uint256 public depositFeeBps; // e.g., 50 = 0.5% uint256 public withdrawFeeBps; // e.g., 50 = 0.5% @@ -27,7 +28,8 @@ contract PSM { uint256 public supply; // Collateral supplied in the PSM (excluding fees and profit) IERC4626 public vault; - event GovChanged(address indexed oldOperator, address indexed newOperator); + 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 DepositFeeUpdated(uint256 oldFee, uint256 newFee); @@ -69,7 +71,7 @@ contract PSM { function buy(address to, uint256 amount) public { require(amount > 0, "Amount must be > 0"); - require(controller.isBuyAllowed(), "Denied by controller"); + require(controller.isBuyAllowed(amount), "Denied by controller"); supply += amount; uint256 amountIn = amount; if (depositFeeBps > 0) { @@ -90,7 +92,7 @@ contract PSM { function sell(address to, uint256 amount) public { require(amount > 0, "Amount must be > 0"); - require(controller.isSellAllowed(), "Denied by controller"); + require(controller.isSellAllowed(amount), "Denied by controller"); supply -= amount; DOLA.safeTransferFrom(msg.sender, address(this), amount); @@ -167,10 +169,16 @@ contract PSM { withdrawFeeBps = newFee; } - function setGov(address newGov) external onlyGov { - require(newGov != address(0), "Zero address"); - emit GovChanged(gov, newGov); - gov = newGov; + function setPendingGov(address _pendingGov) external onlyGov { + pendingGov = _pendingGov; + emit PendingGovUpdated(_pendingGov); + } + + function claimPendingGov() external { + require(msg.sender == pendingGov, "Not pending gov"); + emit GovChanged(gov, pendingGov); + gov = pendingGov; + pendingGov = address(0); } function setController(address newController) external onlyGov { diff --git a/src/PSMFed.sol b/src/PSMFed.sol index d3c8d2f..7c2613e 100644 --- a/src/PSMFed.sol +++ b/src/PSMFed.sol @@ -10,13 +10,16 @@ interface IDOLA is IERC20 { contract PSMFed { address public immutable psm; - address public immutable gov; IDOLA public immutable DOLA; + address public gov; + address public pendingGov; address public chair; uint256 public supply; // Current DOLA amount supplied to the PSM uint256 public supplyCap; // Maximum DOLA supply allowed in the PSM + event GovChanged(address indexed oldGov, address indexed newGov); + event PendingGovUpdated(address indexed pendingGov); event ChairChanged(address indexed oldChair, address indexed newChair); event SupplyCapUpdated(uint256 oldSupplyCap, uint256 newSupplyCap); @@ -69,4 +72,16 @@ contract PSMFed { chair = address(0); emit ChairChanged(oldChair, address(0)); } + + function setPendingGov(address _pendingGov) external onlyGov { + pendingGov = _pendingGov; + emit PendingGovUpdated(_pendingGov); + } + + function claimPendingGov() external { + require(msg.sender == pendingGov, "Not pending gov"); + emit GovChanged(gov, pendingGov); + gov = pendingGov; + pendingGov = address(0); + } } diff --git a/test/PSM.t.sol b/test/PSM.t.sol index c839f53..f91ac15 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -148,10 +148,11 @@ contract PSMTest is Test { uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee uint256 sellFee = amount * psm.withdrawFeeBps() / 10000; // 1% withdraw fee assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // deposit and withdraw fees + assertEq(psm.getProfit(), buyFee + sellFee); // No profit taken yet - DOLA.mint(user, 15 ether); // Mint some DOLA to user + DOLA.mint(user, buyFee + sellFee); // Mint some DOLA to user to attempt taking profit by selling vm.expectRevert(); - psm.sell(user, 15 ether); + psm.sell(user, buyFee + sellFee); vm.stopPrank(); } @@ -297,11 +298,21 @@ contract PSMTest is Test { assertEq(address(psm.controller()), address(newController)); } - function test_GovChange() public { - address newOp = address(0x456); + function test_PendingGov() public { + address newGov = address(0x456); vm.prank(gov); - psm.setGov(newOp); - assertEq(psm.gov(), newOp); + psm.setPendingGov(newGov); + assertEq(psm.pendingGov(), newGov); + } + + function test_ClaimPendingGov() public { + address newGov = address(0x456); + vm.prank(gov); + psm.setPendingGov(newGov); + vm.prank(newGov); + psm.claimPendingGov(); + assertEq(psm.gov(), newGov); + assertEq(psm.pendingGov(), address(0)); } function test_NonGovCannotUpdateFees() public { @@ -369,7 +380,7 @@ contract PSMTest is Test { vm.stopPrank(); uint256 totalReserves = psm.getTotalReserves(); - assertEq(totalReserves, dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000)); // Total reserves should include DOLA supply + deposit fee + assertEq(totalReserves, DOLA.balanceOf(address(psm)) + dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000)); // Total reserves should include DOLA supply + deposit fee } function test_getProfit() public { From fe9a61f36a54610f9d1fa43a24433bfe8bb7658c Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 17 Jun 2025 16:53:05 -0500 Subject: [PATCH 06/22] fix test --- test/PSM.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/PSM.t.sol b/test/PSM.t.sol index f91ac15..ad55e56 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -380,7 +380,7 @@ contract PSMTest is Test { vm.stopPrank(); uint256 totalReserves = psm.getTotalReserves(); - assertEq(totalReserves, DOLA.balanceOf(address(psm)) + dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000)); // Total reserves should include DOLA supply + deposit fee + assertEq(totalReserves, dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000)); // Total reserves should include DOLA supply + deposit fee } function test_getProfit() public { From 4989fdd8864739f041b1927203a7db42e1596019 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 17 Jun 2025 16:58:56 -0500 Subject: [PATCH 07/22] add total reserve test with fee and profit --- test/PSM.t.sol | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/PSM.t.sol b/test/PSM.t.sol index ad55e56..77726bf 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -380,7 +380,25 @@ contract PSMTest is Test { vm.stopPrank(); uint256 totalReserves = psm.getTotalReserves(); - assertEq(totalReserves, dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000)); // Total reserves should include DOLA supply + deposit fee + assertEq(totalReserves, dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000)); // Total reserves is USDS supply + deposit fee + assertEq(totalReserves, psm.supply() + psm.getProfit()); // Should equal supply + profit + assertEq(totalReserves, psm.vault().previewRedeem(psm.vault().balanceOf(address(psm)))); // Should match vault balance + } + + function test_getTotalReserves_with_profit() public { + uint256 dolaAmount = 1000 ether; + vm.startPrank(user); + psm.buy(user, dolaAmount); + vm.stopPrank(); + + // Simulate profit in vault + uint256 profit = 200 ether; // Assume profit of 200 ether + collateral.mint(address(vault), profit); // Add profit to vault + + uint256 totalReserves = psm.getTotalReserves(); + assertEq(totalReserves, dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000) + profit); // Total reserves is USDS supply + deposit fee and profit + assertEq(totalReserves, psm.supply() + psm.getProfit()); // Should equal supply + profit + assertEq(totalReserves, psm.vault().previewRedeem(psm.vault().balanceOf(address(psm)))); // Should match vault balance } function test_getProfit() public { From d0be880a75f3e842ffebe91ffd4486f38c005276 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 19 Jun 2025 12:20:45 -0500 Subject: [PATCH 08/22] update buy for collateralIn and deposit fee logic, add natspec --- src/Controller.sol | 19 +++++++- src/PSM.sol | 104 +++++++++++++++++++++++++++++++++------- src/PSMFed.sol | 39 ++++++++++++++- test/PSM.t.sol | 115 +++++++++++++++++++++++++-------------------- 4 files changed, 209 insertions(+), 68 deletions(-) diff --git a/src/Controller.sol b/src/Controller.sol index 6e3a6e9..88b766c 100644 --- a/src/Controller.sol +++ b/src/Controller.sol @@ -2,11 +2,28 @@ pragma solidity ^0.8.20; 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 isBuyAllowed(uint256 amount) external pure returns (bool) { - return true; // For now, we allow all calls + 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 isSellAllowed(uint256 amount) external pure returns (bool) { + amount; // To avoid unused variable warning return true; // For now, we allow all calls } } diff --git a/src/PSM.sol b/src/PSM.sol index 39ecb05..57dcb35 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -65,31 +65,50 @@ contract PSM { _; } + /** + * @notice Allows to buy DOLA minus fee using collateral. + * @param amount Amount of collateral to sell for DOLA. + */ function buy(uint256 amount) external { buy(msg.sender, amount); } - function buy(address to, uint256 amount) public { - require(amount > 0, "Amount must be > 0"); - require(controller.isBuyAllowed(amount), "Denied by controller"); - supply += amount; - uint256 amountIn = amount; + /** + * @notice Allows to buy DOLA minus fee using collateral. + * @param to DOLA receiver. + * @param amountIn Amount of collateral to sell for DOLA. + */ + function buy(address to, uint256 amountIn) public { + require(amountIn > 0, "Amount must be > 0"); + require(controller.isBuyAllowed(amountIn), "Denied by controller"); + uint256 amountOut = amountIn; + if (depositFeeBps > 0) { - uint256 fee = (amount * depositFeeBps) / BPS_DENOMINATOR; - amountIn += fee; + uint256 fee = (amountIn * depositFeeBps) / BPS_DENOMINATOR; + amountOut -= fee; } + supply += amountOut; collateral.safeTransferFrom(msg.sender, address(this), amountIn); collateral.approve(address(vault), amountIn); vault.deposit(amountIn, address(this)); - DOLA.safeTransfer(to, amount); - emit Buy(msg.sender, amount, amountIn); + DOLA.safeTransfer(to, amountOut); + emit Buy(msg.sender, amountIn, amountOut); } + /** + * @notice Allows to sell DOLA for collateral minus fee. + * @param amount Amount of DOLA to sell. + */ function sell(uint256 amount) external { sell(msg.sender, amount); } + /** + * @notice Allows to sell DOLA for collateral minus fee. + * @param to Address to receive the collateral. + * @param amount Amount of DOLA to sell. + */ function sell(address to, uint256 amount) public { require(amount > 0, "Amount must be > 0"); require(controller.isSellAllowed(amount), "Denied by controller"); @@ -107,6 +126,10 @@ contract PSM { emit Sell(msg.sender, amount, 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); @@ -116,25 +139,47 @@ contract PSM { } } - // Include profit and fees in total reserves + /** + * @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) { return getTotalReserves() - supply; } - function getCollateralIn(uint256 dolaBuyAmount) external view returns (uint256) { - uint256 fee = (dolaBuyAmount * depositFeeBps) / BPS_DENOMINATOR; - return dolaBuyAmount + fee; + /** + * @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 * depositFeeBps) / BPS_DENOMINATOR; + return collateralIn - fee; } - function getCollateralOut(uint256 dolaSellAmount) external view returns (uint256) { - uint256 fee = (dolaSellAmount * withdrawFeeBps) / BPS_DENOMINATOR; - return dolaSellAmount - 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 * withdrawFeeBps) / 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. + */ function migrate(address newVault) external onlyGov { require(newVault != address(0), "Zero address"); require(IERC4626(newVault).asset() == address(collateral), "New vault must accept collateral"); @@ -153,27 +198,49 @@ contract PSM { 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 deposit fee. + * @dev The fee is specified in basis points (bps), where 100 bps = 1%. + */ function setDepositFeeBps(uint256 newFee) external onlyGov { require(newFee <= BPS_DENOMINATOR, "Fee too high"); emit DepositFeeUpdated(depositFeeBps, newFee); depositFeeBps = newFee; } + /** + * @notice Allows governance to set the withdraw fee. + * @dev The fee is specified in basis points (bps), where 100 bps = 1%. + */ function setWithdrawFeeBps(uint256 newFee) external onlyGov { require(newFee <= BPS_DENOMINATOR, "Fee too high"); emit WithdrawFeeUpdated(withdrawFeeBps, newFee); withdrawFeeBps = 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); @@ -181,6 +248,11 @@ contract PSM { 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); diff --git a/src/PSMFed.sol b/src/PSMFed.sol index 7c2613e..4f81184 100644 --- a/src/PSMFed.sol +++ b/src/PSMFed.sol @@ -40,6 +40,13 @@ contract PSMFed { _; } + /** + * @notice Allows the chair to expand the supply of DOLA in the PSM. + * @dev This function increases the supply of DOLA in the PSM and mints + * the specified amount of DOLA. + * @dev The amount must be greater than zero and cannot exceed the supply cap. + * @param amount The amount of DOLA to expand. + */ function expansion(uint256 amount) external onlyChair { require(amount > 0, "Amount must be > 0"); require(amount + supply <= supplyCap, "Supply cap exceeded"); @@ -47,6 +54,13 @@ contract PSMFed { DOLA.mint(psm, amount); } + /** + * @notice Allows the chair to contract the supply of DOLA in the PSM. + * @dev This function reduces the supply of DOLA in the PSM and burns the + * specified amount of DOLA. + * @dev The amount must be greater than zero and cannot exceed the current supply. + * @param amount The amount of DOLA to contract. + */ function contraction(uint256 amount) external onlyChair { require(amount > 0, "Amount must be > 0"); supply -= amount; @@ -54,12 +68,22 @@ contract PSMFed { DOLA.burn(address(this), amount); } + + /** + * @notice Allows governance to set a new supply cap. + * @param newSupplyCap The new supply cap for DOLA in the PSM. + */ function setSupplyCap(uint256 newSupplyCap) external onlyGov { uint256 oldSupplyCap = supplyCap; supplyCap = newSupplyCap; emit SupplyCapUpdated(oldSupplyCap, newSupplyCap); } + /** + * @notice Allows governance to set a new chair. + * @dev The new chair must not be the zero address. + * @param newChair Address of the new chair. + */ function setChair(address newChair) external onlyGov { require(newChair != address(0), "Invalid address"); address oldChair = chair; @@ -67,17 +91,30 @@ contract PSMFed { emit ChairChanged(oldChair, newChair); } + /** + * @notice Allows the current chair to resign. + * @dev The chair can resign, leaving the chair address as zero. + */ function resign() external onlyChair { address oldChair = chair; chair = address(0); emit ChairChanged(oldChair, address(0)); } - + + /** + * @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); diff --git a/test/PSM.t.sol b/test/PSM.t.sol index 77726bf..4c0a874 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -89,9 +89,9 @@ contract PSMTest is Test { vm.stopPrank(); uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee - assertEq(collateral.balanceOf(address(vault)), amount + buyFee); // 1000 + 5 = 1005 + assertEq(collateral.balanceOf(address(vault)), amount); // User should have DOLA bought - assertEq(DOLA.balanceOf(user), amount); + assertEq(DOLA.balanceOf(user), amount - buyFee); return buyFee; } @@ -99,17 +99,26 @@ contract PSMTest is Test { vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); vm.startPrank(user); psm.buy(amount); - assertEq(DOLA.balanceOf(user), amount); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + assertEq(DOLA.balanceOf(user), amount - buyFee); // User should have DOLA bought minus fee + assertEq(collateral.balanceOf(address(vault)), amount); // Vault should have the collateral } function test_Sell_DOLA(uint256 amount) public { vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + uint256 initialCollateralBal = collateral.balanceOf(user); vm.startPrank(user); psm.buy(amount); - DOLA.approve(address(psm), type(uint256).max); - psm.sell(amount); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 dolaToSell = DOLA.balanceOf(user); + assertEq(dolaToSell, amount - buyFee); // User should have D + DOLA.approve(address(psm), dolaToSell); + psm.sell(dolaToSell); vm.stopPrank(); + + uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee assertEq(DOLA.balanceOf(user), 0); // User should have no DOLA left + assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees } function test_SellDOLAWithFee(uint256 amount) public { @@ -121,12 +130,15 @@ contract PSMTest is Test { console2.log(DOLA.balanceOf(user)); // Now burn All DOLA - DOLA.approve(address(psm), type(uint256).max); - psm.sell(user, amount); + uint256 dolaToSell = DOLA.balanceOf(user); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA + DOLA.approve(address(psm), dolaToSell); + psm.sell(user, dolaToSell); vm.stopPrank(); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee - uint256 sellFee = amount * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + + uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee assertEq(collateral.balanceOf(gov), 0); assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees // Vault should have only fees left @@ -136,20 +148,20 @@ contract PSMTest is Test { function test_SellDolaExceedSupply(uint256 amount) public { vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); - vm.prank(user); - psm.buy(user, amount); - - vm.stopPrank(); - - // Now try to sell more than bought vm.startPrank(user); - DOLA.approve(address(psm), type(uint256).max); - psm.sell(user, amount); + psm.buy(user, amount); uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee - uint256 sellFee = amount * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + + uint256 dolaToSell = DOLA.balanceOf(user); + assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, dolaToSell); + + uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // deposit and withdraw fees assertEq(psm.getProfit(), buyFee + sellFee); // No profit taken yet + // Try to sell more DOLA than available in PSM DOLA.mint(user, buyFee + sellFee); // Mint some DOLA to user to attempt taking profit by selling vm.expectRevert(); psm.sell(user, buyFee + sellFee); @@ -176,11 +188,12 @@ contract PSMTest is Test { // Check that profit was taken assertEq(collateral.balanceOf(gov), profit + buyFee); - uint256 sellFee = amount * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 dolaToSell = DOLA.balanceOf(user); + uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee // User sells all DOLA vm.startPrank(user); DOLA.approve(address(psm), type(uint256).max); - psm.sell(user, amount); // User sells DOLA + psm.sell(user, dolaToSell); // User sells DOLA vm.stopPrank(); assertEq(psm.supply(), 0); // Supply should be zero after selling all DOLA assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees @@ -206,9 +219,9 @@ contract PSMTest is Test { psm.migrate(address(newVault)); assertEq(address(psm.vault()), address(newVault)); // Profit was taken and transferred to governance plus the deposit fee - assertEq(collateral.balanceOf(gov), profit + fee); + assertEq(collateral.balanceOf(gov), profit + fee, "Not correct profit and fees to gov"); // Check new vault has the correct balance - assertEq(newVault.balanceOf(address(psm)), amount); + assertEq(newVault.balanceOf(address(psm)), amount - fee, "Not correct vault balance after migration"); } function test_2_users_buy_then_migrate_with_profit_then_contract_and_sell() public { @@ -230,9 +243,13 @@ contract PSMTest is Test { psm.buy(user2, amount2); vm.stopPrank(); + uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 dolaToSell1 = DOLA.balanceOf(user); + uint256 dolaToSell2 = DOLA.balanceOf(user2); // Check balances after both users bought DOLA - assertEq(DOLA.balanceOf(user), amount1); - assertEq(DOLA.balanceOf(user2), amount2); + assertEq(dolaToSell1, amount1 - buyFee1); + assertEq(dolaToSell2, amount2 - buyFee2); assertEq(collateral.balanceOf(address(gov)), 0); // Gov should have no collateral yet // Simulate profit in vault @@ -247,7 +264,7 @@ contract PSMTest is Test { assertEq(collateral.balanceOf(gov), profit + fee); // Gov should have profit + deposit fee // Check new vault has the correct balance - assertEq(newVault.balanceOf(address(psm)), amount1 + amount2); + assertEq(newVault.balanceOf(address(psm)), amount1 + amount2 - fee, "Vault balance not correct after migration"); // Full contraction but can still sell DOLA vm.prank(gov); @@ -256,21 +273,17 @@ contract PSMTest is Test { // User 1 sells DOLA vm.startPrank(user); DOLA.approve(address(psm), type(uint256).max); - psm.sell(user, amount1); + psm.sell(user, dolaToSell1); vm.stopPrank(); // User 2 sells DOLA vm.startPrank(user2); DOLA.approve(address(psm), type(uint256).max); - psm.sell(user2, amount2); + psm.sell(user2, dolaToSell2); vm.stopPrank(); // Check both users got their collateral back minus fees - uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee - uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee - uint256 sellFee1 = amount1 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee - uint256 sellFee2 = amount2 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee - assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + sellFee1)); - assertEq(collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + sellFee2)); + assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.withdrawFeeBps() / 10000)); + assertEq(collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.withdrawFeeBps() / 10000)); } function test_Fail_if_no_DOLA_available() public { @@ -361,34 +374,36 @@ contract PSMTest is Test { vm.stopPrank(); } - function test_getCollateralOut() public { - uint256 dolaAmount = 1000 ether; + function test_getCollateralOut(uint256 dolaAmount) public { + vm.assume(dolaAmount > 0 && dolaAmount <= 10000000 ether); uint256 expectedCollateralOut = dolaAmount - (dolaAmount * psm.withdrawFeeBps() / 10000); // 1% fee assertEq(psm.getCollateralOut(dolaAmount), expectedCollateralOut); } - function test_getCollateralIn() public { - uint256 dolaAmount = 1000 ether; - uint256 expectedCollateralIn = dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000); // 0.5% fee - assertEq(psm.getCollateralIn(dolaAmount), expectedCollateralIn); + function test_getDolaOut(uint256 collateralIn) public { + vm.assume(collateralIn > 0 && collateralIn <= 10000000 ether); + uint256 expectedDolaOut = collateralIn - (collateralIn * psm.depositFeeBps() / 10000); // 0.5% fee + assertEq(psm.getDolaOut(collateralIn), expectedDolaOut); } - function test_getTotalReserves() public { - uint256 dolaAmount = 1000 ether; + function test_getTotalReserves(uint256 collateralAmount) public { + vm.assume(collateralAmount > 0 && collateralAmount <= 10000000 ether); + uint256 initialDolaBal = DOLA.balanceOf(address(psm)); vm.startPrank(user); - psm.buy(user, dolaAmount); + psm.buy(user, collateralAmount); vm.stopPrank(); - + // Check total reserves after buying DOLA uint256 totalReserves = psm.getTotalReserves(); - assertEq(totalReserves, dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000)); // Total reserves is USDS supply + deposit fee + assertEq(totalReserves, collateralAmount); // Total reserves is USDS supply assertEq(totalReserves, psm.supply() + psm.getProfit()); // Should equal supply + profit + assertEq(initialDolaBal- DOLA.balanceOf(address(psm)), collateralAmount - psm.getProfit()); // DOLA bought should match collateral supplied minus profit assertEq(totalReserves, psm.vault().previewRedeem(psm.vault().balanceOf(address(psm)))); // Should match vault balance } function test_getTotalReserves_with_profit() public { - uint256 dolaAmount = 1000 ether; + uint256 collateralAmount = 1000 ether; vm.startPrank(user); - psm.buy(user, dolaAmount); + psm.buy(user, collateralAmount); vm.stopPrank(); // Simulate profit in vault @@ -396,19 +411,19 @@ contract PSMTest is Test { collateral.mint(address(vault), profit); // Add profit to vault uint256 totalReserves = psm.getTotalReserves(); - assertEq(totalReserves, dolaAmount + (dolaAmount * psm.depositFeeBps() / 10000) + profit); // Total reserves is USDS supply + deposit fee and profit + assertEq(totalReserves, collateralAmount + profit); // Total reserves is USDS supplied (including fees and profit) assertEq(totalReserves, psm.supply() + psm.getProfit()); // Should equal supply + profit assertEq(totalReserves, psm.vault().previewRedeem(psm.vault().balanceOf(address(psm)))); // Should match vault balance } function test_getProfit() public { - uint256 dolaAmount = 1000 ether; + uint256 collateralAmount = 1000 ether; vm.startPrank(user); - psm.buy(user, dolaAmount); + psm.buy(user, collateralAmount); vm.stopPrank(); uint256 profit = psm.getProfit(); - assertEq(profit, (dolaAmount * psm.depositFeeBps() / 10000)); // Profit should equal to fees collected + assertEq(profit, (collateralAmount * psm.depositFeeBps() / 10000)); // Profit should equal to fees collected } function test_PSMFed_expansion(uint256 expansionAmount) public { From fe88039acd6ea3ee4a6f9d4866c3d108aaeaa3cc Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 19 Jun 2025 12:21:37 -0500 Subject: [PATCH 09/22] format --- src/Controller.sol | 3 +-- src/PSM.sol | 10 +++++----- src/PSMFed.sol | 3 +-- test/PSM.t.sol | 23 +++++++++++++---------- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Controller.sol b/src/Controller.sol index 88b766c..b18e55f 100644 --- a/src/Controller.sol +++ b/src/Controller.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.20; contract Controller { - /** * @notice Checks if a buy operation is allowed. * @dev This function can be modified to include specific conditions for allowing buys. @@ -12,7 +11,7 @@ contract Controller { */ function isBuyAllowed(uint256 amount) external pure returns (bool) { amount; // To avoid unused variable warning - return true; + return true; } /** diff --git a/src/PSM.sol b/src/PSM.sol index 57dcb35..8283075 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -73,11 +73,11 @@ contract PSM { buy(msg.sender, amount); } - /** - * @notice Allows to buy DOLA minus fee using collateral. - * @param to DOLA receiver. - * @param amountIn Amount of collateral to sell for DOLA. - */ + /** + * @notice Allows to buy DOLA minus fee using collateral. + * @param to DOLA receiver. + * @param amountIn Amount of collateral to sell for DOLA. + */ function buy(address to, uint256 amountIn) public { require(amountIn > 0, "Amount must be > 0"); require(controller.isBuyAllowed(amountIn), "Denied by controller"); diff --git a/src/PSMFed.sol b/src/PSMFed.sol index 4f81184..830187c 100644 --- a/src/PSMFed.sol +++ b/src/PSMFed.sol @@ -68,7 +68,6 @@ contract PSMFed { DOLA.burn(address(this), amount); } - /** * @notice Allows governance to set a new supply cap. * @param newSupplyCap The new supply cap for DOLA in the PSM. @@ -100,7 +99,7 @@ contract PSMFed { chair = address(0); emit ChairChanged(oldChair, address(0)); } - + /** * @notice Allows governance to set a new pending governance. * @dev The pending governance must accept the role. diff --git a/test/PSM.t.sol b/test/PSM.t.sol index 4c0a874..fa58828 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -89,9 +89,9 @@ contract PSMTest is Test { vm.stopPrank(); uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee - assertEq(collateral.balanceOf(address(vault)), amount); + assertEq(collateral.balanceOf(address(vault)), amount); // User should have DOLA bought - assertEq(DOLA.balanceOf(user), amount - buyFee); + assertEq(DOLA.balanceOf(user), amount - buyFee); return buyFee; } @@ -134,10 +134,9 @@ contract PSMTest is Test { uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA DOLA.approve(address(psm), dolaToSell); - psm.sell(user, dolaToSell); + psm.sell(user, dolaToSell); vm.stopPrank(); - uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee assertEq(collateral.balanceOf(gov), 0); assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees @@ -151,12 +150,12 @@ contract PSMTest is Test { vm.startPrank(user); psm.buy(user, amount); uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee - + uint256 dolaToSell = DOLA.balanceOf(user); assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA DOLA.approve(address(psm), type(uint256).max); psm.sell(user, dolaToSell); - + uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // deposit and withdraw fees assertEq(psm.getProfit(), buyFee + sellFee); // No profit taken yet @@ -282,8 +281,12 @@ contract PSMTest is Test { vm.stopPrank(); // Check both users got their collateral back minus fees - assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.withdrawFeeBps() / 10000)); - assertEq(collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.withdrawFeeBps() / 10000)); + assertEq( + collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.withdrawFeeBps() / 10000) + ); + assertEq( + collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.withdrawFeeBps() / 10000) + ); } function test_Fail_if_no_DOLA_available() public { @@ -394,9 +397,9 @@ contract PSMTest is Test { vm.stopPrank(); // Check total reserves after buying DOLA uint256 totalReserves = psm.getTotalReserves(); - assertEq(totalReserves, collateralAmount); // Total reserves is USDS supply + assertEq(totalReserves, collateralAmount); // Total reserves is USDS supply assertEq(totalReserves, psm.supply() + psm.getProfit()); // Should equal supply + profit - assertEq(initialDolaBal- DOLA.balanceOf(address(psm)), collateralAmount - psm.getProfit()); // DOLA bought should match collateral supplied minus profit + assertEq(initialDolaBal - DOLA.balanceOf(address(psm)), collateralAmount - psm.getProfit()); // DOLA bought should match collateral supplied minus profit assertEq(totalReserves, psm.vault().previewRedeem(psm.vault().balanceOf(address(psm)))); // Should match vault balance } From 8c99fe460f80774f108e9576a5115902be50a4d0 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 19 Jun 2025 18:08:34 -0500 Subject: [PATCH 10/22] add test for manual migration via gov --- src/PSM.sol | 8 +++-- test/PSM.t.sol | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/PSM.sol b/src/PSM.sol index 8283075..287637d 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -133,7 +133,7 @@ contract PSM { function takeProfit() public { uint256 vaultBal = vault.balanceOf(address(this)); uint256 amountOut = vault.previewRedeem(vaultBal); - uint256 profit = amountOut - supply; + uint256 profit = amountOut > supply ? amountOut - supply : 0; if (profit > 0) { vault.withdraw(profit, gov, address(this)); } @@ -193,8 +193,10 @@ contract PSM { address oldVault = address(vault); vault = IERC4626(newVault); uint256 collateralBalance = collateral.balanceOf(address(this)); - collateral.approve(address(vault), collateralBalance); - vault.deposit(collateralBalance, address(this)); + if (collateralBalance != 0) { + collateral.approve(address(vault), collateralBalance); + vault.deposit(collateralBalance, address(this)); + } emit VaultMigrated(oldVault, newVault); } diff --git a/test/PSM.t.sol b/test/PSM.t.sol index fa58828..e071564 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -289,6 +289,89 @@ contract PSMTest is Test { ); } + function test_migrate_manually_via_gov_if_maxRedeem_lower_than_psm_balance() public { + address user2 = address(0x456); + collateral.mint(user2, 5000 ether); // Give user2 some collateral + + uint256 amount1 = 1000 ether; + uint256 amount2 = 2000 ether; + uint256 user1CollateralBal = collateral.balanceOf(user); + uint256 user2CollateralBal = collateral.balanceOf(user2); + // User1 buys DOLA + vm.startPrank(user); + psm.buy(user, amount1); + vm.stopPrank(); + + // User2 buys DOLA + vm.startPrank(user2); + collateral.approve(address(psm), type(uint256).max); + psm.buy(user2, amount2); + vm.stopPrank(); + + uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 dolaToSell1 = DOLA.balanceOf(user); + uint256 dolaToSell2 = DOLA.balanceOf(user2); + // Check balances after both users bought DOLA + assertEq(dolaToSell1, amount1 - buyFee1); + assertEq(dolaToSell2, amount2 - buyFee2); + + assertEq(collateral.balanceOf(address(gov)), 0); // Gov should have no collateral yet + // Simulate profit in vault + uint256 profit = 100 ether; // Assume profit of 100 ether + collateral.mint(address(vault), 100 ether); + // Migrate to new vault + MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + + uint256 vaultBal = vault.balanceOf(address(psm)); + vm.prank(gov); + psm.sweep(IERC20(address(vault))); // Sweep vault balance to gov + assertEq(vaultBal, vault.balanceOf(gov)); + + vm.prank(gov); + psm.migrate(address(newVault)); + //Block buy and sell(updating controller), users cannot buy or sell while migration is in progress + vm.mockCall(address(controller), abi.encodeWithSelector(Controller.isBuyAllowed.selector), abi.encode(false)); + vm.mockCall(address(controller), abi.encodeWithSelector(Controller.isSellAllowed.selector), abi.encode(false)); + + vm.startPrank(gov); + vault.redeem(vaultBal / 2, gov, gov); // Redeem half vault balance to PSM + vm.warp(block.timestamp + 1 days); // Move time forward to allow migration + vault.redeem(vaultBal / 2, gov, gov); // Redeem half vault balance to PSM + assertEq(collateral.balanceOf(gov), vaultBal + profit); // Gov should have all collateral balance + + collateral.approve(address(newVault), type(uint256).max); + newVault.deposit(vaultBal + profit, address(psm)); // Deposit all collateral to new vault + assertEq(newVault.balanceOf(address(psm)), vaultBal + profit, "New vault balance not correct after migration"); + vm.stopPrank(); + + assertEq(psm.getProfit(), profit + buyFee1 + buyFee2); // Kept previous profit + assertEq(newVault.previewRedeem(newVault.balanceOf(address(psm))), psm.supply() + psm.getProfit()); // New vault should have the correct balance + // Set back controller to allow buy and sell + vm.clearMockedCalls(); + + // User 1 sells DOLA + vm.startPrank(user); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, dolaToSell1); + vm.stopPrank(); + // User 2 sells DOLA + vm.startPrank(user2); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user2, dolaToSell2); + vm.stopPrank(); + uint256 withdrawFee1 = dolaToSell1 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 withdrawFee2 = dolaToSell2 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + withdrawFee1)); // User 1 gets back collateral minus fees + // Profit should include fees from both users + assertEq(psm.getProfit(), profit + buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2); + psm.takeProfit(); // Take profit + + // Check balances after taking profit + assertEq(collateral.balanceOf(gov), profit + buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2); + assertEq(newVault.previewRedeem(newVault.balanceOf(address(psm))), psm.supply()); + } + function test_Fail_if_no_DOLA_available() public { uint256 dolaBalance = DOLA.balanceOf(address(psm)); fed.contraction(dolaBalance); From ff9edfa288a166faf3b6186c1f07068ca9adebe5 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 24 Jun 2025 11:33:29 -0500 Subject: [PATCH 11/22] add sUSDS test, update DOLA interface --- foundry.toml | 8 + src/PSM.sol | 3 +- src/PSMFed.sol | 4 +- test/PSM.t.sol | 4 +- test/PSMUSDSFork.t.sol | 518 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 532 insertions(+), 5 deletions(-) create mode 100644 test/PSMUSDSFork.t.sol diff --git a/foundry.toml b/foundry.toml index a326434..d62d2da 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,12 +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 diff --git a/src/PSM.sol b/src/PSM.sol index 287637d..fd2c4b6 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -152,7 +152,8 @@ contract PSM { * @return Total profit in the PSM. */ function getProfit() external view returns (uint256) { - return getTotalReserves() - supply; + uint256 totalReserves = getTotalReserves(); + return totalReserves > supply ? totalReserves - supply : 0; } /** diff --git a/src/PSMFed.sol b/src/PSMFed.sol index 830187c..36840e7 100644 --- a/src/PSMFed.sol +++ b/src/PSMFed.sol @@ -5,7 +5,7 @@ import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IDOLA is IERC20 { function mint(address to, uint256 amount) external; - function burn(address from, uint256 amount) external; + function burn(uint256 amount) external; } contract PSMFed { @@ -65,7 +65,7 @@ contract PSMFed { require(amount > 0, "Amount must be > 0"); supply -= amount; DOLA.transferFrom(psm, address(this), amount); - DOLA.burn(address(this), amount); + DOLA.burn(amount); } /** diff --git a/test/PSM.t.sol b/test/PSM.t.sol index e071564..235f914 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -39,8 +39,8 @@ contract MockERC20 is IERC20 { totalSupply += amount; } - function burn(address from, uint256 amount) external { - balanceOf[from] -= amount; + function burn(uint256 amount) external { + balanceOf[msg.sender] -= amount; totalSupply -= amount; } } diff --git a/test/PSMUSDSFork.t.sol b/test/PSMUSDSFork.t.sol new file mode 100644 index 0000000..62a58b0 --- /dev/null +++ b/test/PSMUSDSFork.t.sol @@ -0,0 +1,518 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "src/PSM.sol"; +import {Controller} from "src/Controller.sol"; +import {PSMFed} from "src/PSMFed.sol"; +import {MockERC4626, ERC20} from "lib/solmate/src/test/utils/mocks/MockERC4626.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC4626} from "openzeppelin/contracts/interfaces/IERC4626.sol"; +// Simple mocks for ERC20 + +interface IDOLA is IERC20 { + function addMinter(address minter) external; +} + +contract PSMUSDSTest is Test { + PSM psm; + PSMFed fed; + Controller controller; + IERC20 collateral = IERC20(0xdC035D45d973E3EC169d2276DDab16f1e407384F); // USDS + IERC4626 vault = IERC4626(0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD); // sUSDS Vault + IDOLA DOLA = IDOLA(0x865377367054516e17014CcdED1e7d814EDC9ce4); + address gov = address(0x926dF14a23BE491164dCF93f4c468A50ef659D5B); + address user = address(0x123); + + function setUp() public { + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url, 22768961); + controller = new Controller(); + psm = new PSM( + address(collateral), + address(vault), + address(DOLA), + gov, + 50, // 0.5% deposit fee + 100, // 1% withdraw fee + address(controller), + address(this) // chair + ); + fed = PSMFed(psm.fed()); + + deal(address(collateral), user, 10_000_000 ether); // Give user some collateral + vm.startPrank(user); + collateral.approve(address(psm), type(uint256).max); + vm.stopPrank(); + vm.startPrank(gov); + DOLA.addMinter(address(fed)); // Allow PSMFed to mint DOLA + fed.setSupplyCap(20_000_000 ether); // Set supply cap for DOLA + vm.stopPrank(); + fed.expansion(10_000_000 ether); // Mint some DOLA to PSMFed + } + + function test_BuyDOLAWithFee(uint256 amount) public returns (uint256) { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + vm.startPrank(user); + psm.buy(user, amount); + vm.stopPrank(); + + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), amount, 2); + // User should have DOLA bought + assertEq(DOLA.balanceOf(user), amount - buyFee); + return buyFee; + } + + function test_Buy_DOLA(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + vm.startPrank(user); + psm.buy(amount); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + assertEq(DOLA.balanceOf(user), amount - buyFee); // User should have DOLA bought minus fee + assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), amount, 2); // Vault should have the collateral + } + + function test_Sell_DOLA(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + uint256 initialCollateralBal = collateral.balanceOf(user); + vm.startPrank(user); + psm.buy(amount); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 dolaToSell = DOLA.balanceOf(user); + assertEq(dolaToSell, amount - buyFee); // User should have D + DOLA.approve(address(psm), dolaToSell); + psm.sell(dolaToSell); + vm.stopPrank(); + + uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + assertEq(DOLA.balanceOf(user), 0); // User should have no DOLA left + assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees + } + + function test_SellDOLAWithFee(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + + uint256 initialCollateralBal = collateral.balanceOf(user); + vm.startPrank(user); + psm.buy(user, amount); + + // Now burn All DOLA + uint256 dolaToSell = DOLA.balanceOf(user); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA + DOLA.approve(address(psm), dolaToSell); + psm.sell(user, dolaToSell); + vm.stopPrank(); + + uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + assertEq(collateral.balanceOf(gov), 0); + assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees + // Vault should have only fees left + assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), buyFee + sellFee, 3); // deposit and withdraw fees + } + + function test_SellDolaExceedSupply(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + + vm.startPrank(user); + psm.buy(user, amount); + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + + uint256 dolaToSell = DOLA.balanceOf(user); + assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, dolaToSell); + + uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), buyFee + sellFee, 3); // deposit and withdraw fees + assertApproxEqAbs(psm.getProfit(), buyFee + sellFee, 3); // No profit taken yet + // Try to sell more DOLA than available in PSM + deal(address(DOLA), user, buyFee + sellFee); // Mint some DOLA to user to attempt taking profit by selling + vm.expectRevert(); + psm.sell(user, buyFee + sellFee); + vm.stopPrank(); + } + + function test_TakeProfit(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + + uint256 initialCollateralBal = collateral.balanceOf(user); + // Buy some DOLA + vm.prank(user); + psm.buy(user, amount); + + // Take profit + + psm.takeProfit(); + + uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + // Check that profit was taken + assertApproxEqAbs(collateral.balanceOf(gov), buyFee, 2); + + uint256 dolaToSell = DOLA.balanceOf(user); + uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + // User sells all DOLA + vm.startPrank(user); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, dolaToSell); // User sells DOLA + vm.stopPrank(); + assertEq(psm.supply(), 0); // Supply should be zero after selling all DOLA + assertApproxEqAbs(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee), 2); // User gets back collateral minus fees + assertEq(DOLA.balanceOf(user), 0); // User should have no DOLA left + + // Gov balance has profit + buyFee + uint256 govBalanceAfter = collateral.balanceOf(gov); + + psm.takeProfit(); // Try to take profit again (fees from previous sell) + assertApproxEqAbs( + collateral.balanceOf(gov), govBalanceAfter + sellFee, 3, "Gov balance not correct after second take profit" + ); + } + + function test_Migrate_Vault(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + uint256 fee = test_BuyDOLAWithFee(amount); + + MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + vm.prank(gov); + psm.migrate(address(newVault)); + assertEq(address(psm.vault()), address(newVault)); + // Profit was taken and transferred to governance plus the deposit fee + assertApproxEqAbs(collateral.balanceOf(gov), fee, 2, "Not correct profit and fees to gov"); + // Check new vault has the correct balance + assertApproxEqAbs( + newVault.balanceOf(address(psm)), amount - fee, 2, "Not correct vault balance after migration" + ); + } + + function test_2_users_buy_then_migrate_with_profit_then_contract_and_sell() public { + address user2 = address(0x456); + deal(address(collateral), address(user2), 5000 ether); // Give user2 some collateral + + uint256 amount1 = 1000 ether; + uint256 amount2 = 2000 ether; + uint256 user1CollateralBal = collateral.balanceOf(user); + uint256 user2CollateralBal = collateral.balanceOf(user2); + // User1 buys DOLA + vm.startPrank(user); + psm.buy(user, amount1); + vm.stopPrank(); + + // User2 buys DOLA + vm.startPrank(user2); + collateral.approve(address(psm), type(uint256).max); + psm.buy(user2, amount2); + vm.stopPrank(); + + uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 dolaToSell1 = DOLA.balanceOf(user); + uint256 dolaToSell2 = DOLA.balanceOf(user2); + // Check balances after both users bought DOLA + assertEq(dolaToSell1, amount1 - buyFee1); + assertEq(dolaToSell2, amount2 - buyFee2); + + assertEq(collateral.balanceOf(address(gov)), 0); // Gov should have no collateral yet + + deal(address(collateral), address(vault), 100000 ether); // Simulate profit in vault + // Migrate to new vault + MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + vm.prank(gov); + psm.migrate(address(newVault)); + // After migration, profit and fees should be taken and transferred to governance + uint256 fee = (amount1 + amount2) * psm.depositFeeBps() / 10000; // 0.5% deposit fee + assertApproxEqAbs(collateral.balanceOf(gov), fee, 2); // Gov should have profit + deposit fee + + // Check new vault has the correct balance + assertApproxEqAbs( + newVault.balanceOf(address(psm)), amount1 + amount2 - fee, 1, "Vault balance not correct after migration" + ); + + // Full contraction but can still sell DOLA + vm.prank(gov); + fed.contraction(DOLA.balanceOf(address(psm))); + + // User 1 sells DOLA + vm.startPrank(user); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, dolaToSell1); + vm.stopPrank(); + // User 2 sells DOLA + vm.startPrank(user2); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user2, dolaToSell2); + vm.stopPrank(); + + // Check both users got their collateral back minus fees + assertEq( + collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.withdrawFeeBps() / 10000) + ); + assertEq( + collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.withdrawFeeBps() / 10000) + ); + } + + function test_migrate_manually_via_gov_if_maxRedeem_lower_than_psm_balance() public { + address user2 = address(0x456); + deal(address(collateral), user2, 5000 ether); // Give user2 some collateral + + uint256 amount1 = 1000 ether; + uint256 amount2 = 2000 ether; + uint256 user1CollateralBal = collateral.balanceOf(user); + uint256 user2CollateralBal = collateral.balanceOf(user2); + // User1 buys DOLA + vm.startPrank(user); + psm.buy(user, amount1); + vm.stopPrank(); + + // User2 buys DOLA + vm.startPrank(user2); + collateral.approve(address(psm), type(uint256).max); + psm.buy(user2, amount2); + vm.stopPrank(); + + uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 dolaToSell1 = DOLA.balanceOf(user); + uint256 dolaToSell2 = DOLA.balanceOf(user2); + // Check balances after both users bought DOLA + assertEq(dolaToSell1, amount1 - buyFee1); + assertEq(dolaToSell2, amount2 - buyFee2); + + assertEq(collateral.balanceOf(address(gov)), 0); // Gov should have no collateral yet + + // Migrate to new vault + MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + + uint256 vaultBal = vault.balanceOf(address(psm)); + vm.prank(gov); + psm.sweep(IERC20(address(vault))); // Sweep vault balance to gov + assertEq(vaultBal, vault.balanceOf(gov)); + + vm.prank(gov); + psm.migrate(address(newVault)); + //Block buy and sell(updating controller), users cannot buy or sell while migration is in progress + vm.mockCall(address(controller), abi.encodeWithSelector(Controller.isBuyAllowed.selector), abi.encode(false)); + vm.mockCall(address(controller), abi.encodeWithSelector(Controller.isSellAllowed.selector), abi.encode(false)); + vm.startPrank(gov); + vault.redeem(vaultBal / 2, gov, gov); // Redeem half vault balance to PSM + // vm.warp(block.timestamp + 1 days); // Move time forward to allow migration + vault.redeem(vaultBal / 2, gov, gov); // Redeem half vault balance to PSM + assertApproxEqAbs(collateral.balanceOf(gov), vault.previewRedeem(vaultBal), 2, "Gov balance not correct"); // Gov should have all collateral balance + + collateral.approve(address(newVault), type(uint256).max); + uint256 shares = newVault.deposit(collateral.balanceOf(gov), address(psm)); // Deposit all collateral to new vault + assertEq(newVault.balanceOf(address(psm)), shares, "New vault balance not correct after migration"); + vm.stopPrank(); + + assertApproxEqAbs(psm.getProfit(), buyFee1 + buyFee2, 4, "not profit"); // Kept previous profit + assertApproxEqAbs(newVault.previewRedeem(newVault.balanceOf(address(psm))), psm.supply() + psm.getProfit(), 2); // New vault should have the correct balance + // Set back controller to allow buy and sell + vm.clearMockedCalls(); + + // User 1 sells DOLA + vm.startPrank(user); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user, dolaToSell1); + vm.stopPrank(); + // User 2 sells DOLA + vm.startPrank(user2); + DOLA.approve(address(psm), type(uint256).max); + psm.sell(user2, dolaToSell2); + vm.stopPrank(); + uint256 withdrawFee1 = dolaToSell1 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 withdrawFee2 = dolaToSell2 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + assertApproxEqAbs(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + withdrawFee1), 4); // User 1 gets back collateral minus fees + // Profit should include fees from both users + assertApproxEqAbs(psm.getProfit(), buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2, 4); + psm.takeProfit(); // Take profit + + // Check balances after taking profit + assertApproxEqAbs(collateral.balanceOf(gov), buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2, 4); + assertEq(newVault.previewRedeem(newVault.balanceOf(address(psm))), psm.supply()); + } + + function test_Fail_if_no_DOLA_available() public { + uint256 dolaBalance = DOLA.balanceOf(address(psm)); + fed.contraction(dolaBalance); + assertEq(DOLA.balanceOf(address(psm)), 0); + vm.startPrank(user); + vm.expectRevert(); + psm.buy(user, dolaBalance); + vm.stopPrank(); + } + + function test_GovCanUpdateFees() public { + vm.startPrank(gov); + psm.setDepositFeeBps(100); + assertEq(psm.depositFeeBps(), 100); + psm.setWithdrawFeeBps(200); + assertEq(psm.withdrawFeeBps(), 200); + } + + function test_GovCanUpdateController() public { + Controller newController = new Controller(); + vm.startPrank(gov); + psm.setController(address(newController)); + assertEq(address(psm.controller()), address(newController)); + } + + function test_PendingGov() public { + address newGov = address(0x456); + vm.prank(gov); + psm.setPendingGov(newGov); + assertEq(psm.pendingGov(), newGov); + } + + function test_ClaimPendingGov() public { + address newGov = address(0x456); + vm.prank(gov); + psm.setPendingGov(newGov); + vm.prank(newGov); + psm.claimPendingGov(); + assertEq(psm.gov(), newGov); + assertEq(psm.pendingGov(), address(0)); + } + + function test_NonGovCannotUpdateFees() public { + vm.startPrank(user); + vm.expectRevert("Not gov"); + psm.setDepositFeeBps(100); + vm.expectRevert("Not gov"); + psm.setWithdrawFeeBps(200); + } + + function test_Fail_DepositFee_TooHigh() public { + vm.startPrank(gov); + vm.expectRevert("Fee too high"); + psm.setDepositFeeBps(10001); // 100.1% + vm.stopPrank(); + } + + function test_Fail_WithdrawFee_TooHigh() public { + vm.startPrank(gov); + vm.expectRevert("Fee too high"); + psm.setWithdrawFeeBps(10001); // 100.1% + vm.stopPrank(); + } + + function test_Fail_Zero_Amount() public { + vm.startPrank(user); + vm.expectRevert("Amount must be > 0"); + psm.buy(user, 0); + vm.expectRevert("Amount must be > 0"); + psm.sell(user, 0); + vm.stopPrank(); + } + + function test_Fail_buy_and_sell_if_denied_by_Controller() public { + vm.mockCall( + address(psm.controller()), abi.encodeWithSelector(Controller.isBuyAllowed.selector), abi.encode(false) + ); + vm.mockCall( + address(psm.controller()), abi.encodeWithSelector(Controller.isSellAllowed.selector), abi.encode(false) + ); + vm.startPrank(user); + vm.expectRevert("Denied by controller"); + psm.buy(user, 1000 ether); + vm.expectRevert("Denied by controller"); + psm.sell(user, 1000 ether); + vm.stopPrank(); + } + + function test_getCollateralOut(uint256 dolaAmount) public { + vm.assume(dolaAmount > 0 && dolaAmount <= 10000000 ether); + uint256 expectedCollateralOut = dolaAmount - (dolaAmount * psm.withdrawFeeBps() / 10000); // 1% fee + assertEq(psm.getCollateralOut(dolaAmount), expectedCollateralOut); + } + + function test_getDolaOut(uint256 collateralIn) public { + vm.assume(collateralIn > 0 && collateralIn <= 10000000 ether); + uint256 expectedDolaOut = collateralIn - (collateralIn * psm.depositFeeBps() / 10000); // 0.5% fee + assertEq(psm.getDolaOut(collateralIn), expectedDolaOut); + } + + function test_getTotalReserves(uint256 collateralAmount) public { + vm.assume(collateralAmount > 0.0001 ether && collateralAmount <= 10000000 ether); + uint256 initialDolaBal = DOLA.balanceOf(address(psm)); + vm.startPrank(user); + psm.buy(user, collateralAmount); + vm.stopPrank(); + // Check total reserves after buying DOLA + uint256 totalReserves = psm.getTotalReserves(); + assertApproxEqAbs(totalReserves, collateralAmount, 2, "Total reserve doesn't match collateral"); // Total reserves is USDS supply + assertApproxEqAbs(totalReserves, psm.supply() + psm.getProfit(), 1, "Profit not correct"); // Should equal supply + profit + assertApproxEqAbs( + initialDolaBal - DOLA.balanceOf(address(psm)), + collateralAmount - psm.getProfit(), + 2, + "Dola balance not correct" + ); // DOLA bought should match collateral supplied minus profit + assertApproxEqAbs( + totalReserves, + psm.vault().previewRedeem(psm.vault().balanceOf(address(psm))), + 1, + "Vault Balance not correct" + ); // Should match vault balance + } + + function test_getProfit() public { + uint256 collateralAmount = 1000 ether; + vm.startPrank(user); + psm.buy(user, collateralAmount); + vm.stopPrank(); + + uint256 profit = psm.getProfit(); + assertApproxEqAbs(profit, (collateralAmount * psm.depositFeeBps() / 10000), 1); // Profit should equal to fees collected + } + + function test_PSMFed_expansion(uint256 expansionAmount) public { + uint256 initialSupply = fed.supply(); + vm.assume(expansionAmount > 0 && expansionAmount <= fed.supplyCap() - initialSupply); + + vm.prank(fed.chair()); + fed.expansion(expansionAmount); + + assertEq(fed.supply(), initialSupply + expansionAmount); + assertEq(DOLA.balanceOf(address(psm)), initialSupply + expansionAmount); + } + + function test_PSMFed_contraction(uint256 contractionAmount) public { + uint256 initialSupply = fed.supply(); + vm.assume(contractionAmount > 0 && contractionAmount <= initialSupply); + + vm.prank(fed.chair()); + fed.contraction(contractionAmount); + + assertEq(fed.supply(), initialSupply - contractionAmount); + assertEq(DOLA.balanceOf(address(psm)), initialSupply - contractionAmount); + } + + function test_PSMFed_setSupplyCap(uint256 newSupplyCap) public { + vm.assume(newSupplyCap > 0 && newSupplyCap < 100000000 ether); + + vm.prank(gov); + fed.setSupplyCap(newSupplyCap); + + assertEq(fed.supplyCap(), newSupplyCap); + } + + function test_PSMFed_setChair(address newChair) public { + vm.assume(newChair != address(0)); + + vm.prank(gov); + fed.setChair(newChair); + + assertEq(fed.chair(), newChair); + } + + function test_PSMFed_resign() public { + address initialChair = fed.chair(); + + vm.prank(initialChair); + fed.resign(); + + assertEq(fed.chair(), address(0)); + } +} From 103fd28e1790aed2a47d58c7bab9479e11f286c4 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 24 Jun 2025 11:46:09 -0500 Subject: [PATCH 12/22] update test.yml --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34a4a52..e76eff6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,8 @@ jobs: id: build - name: Run Forge tests + env: + RPC_MAINNET: ${{ vars.RPC_MAINNET }} run: | forge test -vvv id: test From af9fd95f70b20f8f77851c38f01cddcd24af2ea1 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 24 Jun 2025 11:49:34 -0500 Subject: [PATCH 13/22] use secret in test --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e76eff6..a2e06e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: - name: Run Forge tests env: - RPC_MAINNET: ${{ vars.RPC_MAINNET }} + RPC_MAINNET: ${{ secrets.RPC_MAINNET }} run: | forge test -vvv id: test From d200808062b4acb6c3c1d619f298526986111f55 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 24 Jun 2025 11:52:53 -0500 Subject: [PATCH 14/22] update test.yml --- .github/workflows/test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a2e06e1..699401e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,9 +5,6 @@ on: pull_request: workflow_dispatch: -env: - FOUNDRY_PROFILE: ci - jobs: check: strategy: From 6dd04c9cbc00924258b4c91e87820c3476c9a9c5 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 1 Jul 2025 10:34:29 -0500 Subject: [PATCH 15/22] update after feedback --- src/Controller.sol | 6 ++-- src/PSM.sol | 68 +++++++++++++++++------------------------- src/PSMFed.sol | 1 - test/PSM.t.sol | 28 ++++++----------- test/PSMUSDSFork.t.sol | 16 ++++------ 5 files changed, 47 insertions(+), 72 deletions(-) diff --git a/src/Controller.sol b/src/Controller.sol index b18e55f..5dd3cba 100644 --- a/src/Controller.sol +++ b/src/Controller.sol @@ -9,7 +9,8 @@ contract Controller { * @return bool Returns true if the buy operation is allowed, false otherwise. * For now, it returns true to allow all buy operations. */ - function isBuyAllowed(uint256 amount) external pure returns (bool) { + function onBuy(address user, uint256 amount) external returns (bool) { + user; amount; // To avoid unused variable warning return true; } @@ -21,7 +22,8 @@ contract Controller { * @return bool Returns true if the sell operation is allowed, false otherwise. * For now, it returns true to allow all sell operations. */ - function isSellAllowed(uint256 amount) external pure returns (bool) { + function onSell(address user, uint256 amount) external returns (bool) { + user; amount; // To avoid unused variable warning return true; // For now, we allow all calls } diff --git a/src/PSM.sol b/src/PSM.sol index fd2c4b6..93efc80 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -8,8 +8,8 @@ import "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {PSMFed} from "src/PSMFed.sol"; interface IController { - function isBuyAllowed(uint256 amount) external view returns (bool); - function isSellAllowed(uint256 amount) external view returns (bool); + function onBuy(address user, uint256 amount) external returns (bool); + function onSell(address user, uint256 amount) external returns (bool); } contract PSM { @@ -38,23 +38,11 @@ contract PSM { 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, - uint256 _depositFeeBps, - uint256 _withdrawFeeBps, - address _controller, - address _chair - ) { - require(_depositFeeBps <= BPS_DENOMINATOR && _withdrawFeeBps <= BPS_DENOMINATOR, "Fees too high"); + constructor(address _collateral, address _vault, address _DOLA, address _gov, address _controller, address _chair) { collateral = IERC20(_collateral); vault = IERC4626(_vault); DOLA = IERC20(_DOLA); gov = _gov; - depositFeeBps = _depositFeeBps; - withdrawFeeBps = _withdrawFeeBps; controller = IController(_controller); fed = address(new PSMFed(address(this), _gov, _chair, _DOLA)); DOLA.approve(fed, type(uint256).max); @@ -67,63 +55,63 @@ contract PSM { /** * @notice Allows to buy DOLA minus fee using collateral. - * @param amount Amount of collateral to sell for DOLA. + * @param collateralAmountIn Amount of collateral to sell for DOLA. */ - function buy(uint256 amount) external { - buy(msg.sender, amount); + function buy(uint256 collateralAmountIn) external { + buy(msg.sender, collateralAmountIn); } /** * @notice Allows to buy DOLA minus fee using collateral. * @param to DOLA receiver. - * @param amountIn Amount of collateral to sell for DOLA. + * @param collateralAmountIn Amount of collateral to sell for DOLA. */ - function buy(address to, uint256 amountIn) public { - require(amountIn > 0, "Amount must be > 0"); - require(controller.isBuyAllowed(amountIn), "Denied by controller"); - uint256 amountOut = amountIn; + 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 (depositFeeBps > 0) { - uint256 fee = (amountIn * depositFeeBps) / BPS_DENOMINATOR; + uint256 fee = (collateralAmountIn * depositFeeBps) / BPS_DENOMINATOR; amountOut -= fee; } supply += amountOut; - collateral.safeTransferFrom(msg.sender, address(this), amountIn); - collateral.approve(address(vault), amountIn); - vault.deposit(amountIn, address(this)); + 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, amountIn, amountOut); + emit Buy(msg.sender, collateralAmountIn, amountOut); } /** * @notice Allows to sell DOLA for collateral minus fee. - * @param amount Amount of DOLA to sell. + * @param dolaAmountIn Amount of DOLA to sell. */ - function sell(uint256 amount) external { - sell(msg.sender, amount); + 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 amount Amount of DOLA to sell. + * @param dolaAmountIn Amount of DOLA to sell. */ - function sell(address to, uint256 amount) public { - require(amount > 0, "Amount must be > 0"); - require(controller.isSellAllowed(amount), "Denied by controller"); - supply -= amount; - DOLA.safeTransferFrom(msg.sender, address(this), amount); + function sell(address to, uint256 dolaAmountIn) public { + require(dolaAmountIn > 0, "Amount must be > 0"); + require(controller.onBuy(msg.sender, dolaAmountIn), "Denied by controller"); + supply -= dolaAmountIn; + DOLA.safeTransferFrom(msg.sender, address(this), dolaAmountIn); - uint256 amountOut = amount; + uint256 amountOut = dolaAmountIn; if (withdrawFeeBps > 0) { - uint256 fee = (amount * withdrawFeeBps) / BPS_DENOMINATOR; + uint256 fee = (dolaAmountIn * withdrawFeeBps) / BPS_DENOMINATOR; amountOut -= fee; } vault.withdraw(amountOut, to, address(this)); - emit Sell(msg.sender, amount, amountOut); + emit Sell(msg.sender, dolaAmountIn, amountOut); } /** diff --git a/src/PSMFed.sol b/src/PSMFed.sol index 36840e7..5e17ed6 100644 --- a/src/PSMFed.sol +++ b/src/PSMFed.sol @@ -84,7 +84,6 @@ contract PSMFed { * @param newChair Address of the new chair. */ function setChair(address newChair) external onlyGov { - require(newChair != address(0), "Invalid address"); address oldChair = chair; chair = newChair; emit ChairChanged(oldChair, newChair); diff --git a/test/PSM.t.sol b/test/PSM.t.sol index 235f914..56a3e83 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -61,24 +61,18 @@ contract PSMTest is Test { vault = new MockERC4626(ERC20(address(collateral)), "MOCK", "MOCK"); DOLA = new MockERC20(); controller = new Controller(); - psm = new PSM( - address(collateral), - address(vault), - address(DOLA), - gov, - 50, // 0.5% deposit fee - 100, // 1% withdraw fee - address(controller), - address(this) - ); + psm = new PSM(address(collateral), address(vault), address(DOLA), gov, address(controller), address(this)); fed = PSMFed(psm.fed()); collateral.mint(user, 10_050_000 ether); vm.startPrank(user); collateral.approve(address(psm), type(uint256).max); vm.stopPrank(); - vm.prank(gov); + vm.startPrank(gov); fed.setSupplyCap(20_000_000 ether); // Set supply cap for DOLA + psm.setDepositFeeBps(50); // 0.5% deposit fee + psm.setWithdrawFeeBps(100); // 1% withdraw fee + vm.stopPrank(); fed.expansion(10_000_000 ether); // Mint some DOLA to PSMFed } @@ -331,8 +325,8 @@ contract PSMTest is Test { vm.prank(gov); psm.migrate(address(newVault)); //Block buy and sell(updating controller), users cannot buy or sell while migration is in progress - vm.mockCall(address(controller), abi.encodeWithSelector(Controller.isBuyAllowed.selector), abi.encode(false)); - vm.mockCall(address(controller), abi.encodeWithSelector(Controller.isSellAllowed.selector), abi.encode(false)); + vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onBuy.selector), abi.encode(false)); + vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onSell.selector), abi.encode(false)); vm.startPrank(gov); vault.redeem(vaultBal / 2, gov, gov); // Redeem half vault balance to PSM @@ -446,12 +440,8 @@ contract PSMTest is Test { } function test_Fail_buy_and_sell_if_denied_by_Controller() public { - vm.mockCall( - address(psm.controller()), abi.encodeWithSelector(Controller.isBuyAllowed.selector), abi.encode(false) - ); - vm.mockCall( - address(psm.controller()), abi.encodeWithSelector(Controller.isSellAllowed.selector), abi.encode(false) - ); + vm.mockCall(address(psm.controller()), abi.encodeWithSelector(Controller.onBuy.selector), abi.encode(false)); + vm.mockCall(address(psm.controller()), abi.encodeWithSelector(Controller.onSell.selector), abi.encode(false)); vm.startPrank(user); vm.expectRevert("Denied by controller"); psm.buy(user, 1000 ether); diff --git a/test/PSMUSDSFork.t.sol b/test/PSMUSDSFork.t.sol index 62a58b0..b250116 100644 --- a/test/PSMUSDSFork.t.sol +++ b/test/PSMUSDSFork.t.sol @@ -33,8 +33,6 @@ contract PSMUSDSTest is Test { address(vault), address(DOLA), gov, - 50, // 0.5% deposit fee - 100, // 1% withdraw fee address(controller), address(this) // chair ); @@ -47,6 +45,8 @@ contract PSMUSDSTest is Test { vm.startPrank(gov); DOLA.addMinter(address(fed)); // Allow PSMFed to mint DOLA fed.setSupplyCap(20_000_000 ether); // Set supply cap for DOLA + psm.setDepositFeeBps(50); // 0.5% deposit fee + psm.setWithdrawFeeBps(100); // 1% withdraw fee vm.stopPrank(); fed.expansion(10_000_000 ether); // Mint some DOLA to PSMFed } @@ -293,8 +293,8 @@ contract PSMUSDSTest is Test { vm.prank(gov); psm.migrate(address(newVault)); //Block buy and sell(updating controller), users cannot buy or sell while migration is in progress - vm.mockCall(address(controller), abi.encodeWithSelector(Controller.isBuyAllowed.selector), abi.encode(false)); - vm.mockCall(address(controller), abi.encodeWithSelector(Controller.isSellAllowed.selector), abi.encode(false)); + vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onBuy.selector), abi.encode(false)); + vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onSell.selector), abi.encode(false)); vm.startPrank(gov); vault.redeem(vaultBal / 2, gov, gov); // Redeem half vault balance to PSM // vm.warp(block.timestamp + 1 days); // Move time forward to allow migration @@ -407,12 +407,8 @@ contract PSMUSDSTest is Test { } function test_Fail_buy_and_sell_if_denied_by_Controller() public { - vm.mockCall( - address(psm.controller()), abi.encodeWithSelector(Controller.isBuyAllowed.selector), abi.encode(false) - ); - vm.mockCall( - address(psm.controller()), abi.encodeWithSelector(Controller.isSellAllowed.selector), abi.encode(false) - ); + vm.mockCall(address(psm.controller()), abi.encodeWithSelector(Controller.onBuy.selector), abi.encode(false)); + vm.mockCall(address(psm.controller()), abi.encodeWithSelector(Controller.onSell.selector), abi.encode(false)); vm.startPrank(user); vm.expectRevert("Denied by controller"); psm.buy(user, 1000 ether); From 08ddd69ad69bfc5c333ad8dbf84bb3c3fa380ee5 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 3 Jul 2025 14:28:42 -0500 Subject: [PATCH 16/22] rename fees, add warning --- src/Controller.sol | 2 ++ src/PSM.sol | 28 +++++++++--------- test/PSM.t.sol | 64 +++++++++++++++++++++--------------------- test/PSMUSDSFork.t.sol | 64 +++++++++++++++++++++--------------------- 4 files changed, 80 insertions(+), 78 deletions(-) diff --git a/src/Controller.sol b/src/Controller.sol index 5dd3cba..12a75ef 100644 --- a/src/Controller.sol +++ b/src/Controller.sol @@ -1,6 +1,8 @@ // 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. diff --git a/src/PSM.sol b/src/PSM.sol index 93efc80..0557073 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -22,8 +22,8 @@ contract PSM { address public gov; address public pendingGov; IController public controller; // Controller contract address - uint256 public depositFeeBps; // e.g., 50 = 0.5% - uint256 public withdrawFeeBps; // e.g., 50 = 0.5% + 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; @@ -71,8 +71,8 @@ contract PSM { require(controller.onBuy(msg.sender, collateralAmountIn), "Denied by controller"); uint256 amountOut = collateralAmountIn; - if (depositFeeBps > 0) { - uint256 fee = (collateralAmountIn * depositFeeBps) / BPS_DENOMINATOR; + if (buyFeeBps > 0) { + uint256 fee = (collateralAmountIn * buyFeeBps) / BPS_DENOMINATOR; amountOut -= fee; } supply += amountOut; @@ -105,8 +105,8 @@ contract PSM { uint256 amountOut = dolaAmountIn; - if (withdrawFeeBps > 0) { - uint256 fee = (dolaAmountIn * withdrawFeeBps) / BPS_DENOMINATOR; + if (sellFeeBps > 0) { + uint256 fee = (dolaAmountIn * sellFeeBps) / BPS_DENOMINATOR; amountOut -= fee; } @@ -150,7 +150,7 @@ contract PSM { * @return Amount of DOLA that will be received after fees. */ function getDolaOut(uint256 collateralIn) external view returns (uint256) { - uint256 fee = (collateralIn * depositFeeBps) / BPS_DENOMINATOR; + uint256 fee = (collateralIn * buyFeeBps) / BPS_DENOMINATOR; return collateralIn - fee; } @@ -160,7 +160,7 @@ contract PSM { * @return Amount of collateral that will be received after fees. */ function getCollateralOut(uint256 dolaIn) external view returns (uint256) { - uint256 fee = (dolaIn * withdrawFeeBps) / BPS_DENOMINATOR; + uint256 fee = (dolaIn * sellFeeBps) / BPS_DENOMINATOR; return dolaIn - fee; } @@ -202,20 +202,20 @@ contract PSM { * @notice Allows governance to set the deposit fee. * @dev The fee is specified in basis points (bps), where 100 bps = 1%. */ - function setDepositFeeBps(uint256 newFee) external onlyGov { + function setBuyFeeBps(uint256 newFee) external onlyGov { require(newFee <= BPS_DENOMINATOR, "Fee too high"); - emit DepositFeeUpdated(depositFeeBps, newFee); - depositFeeBps = newFee; + emit DepositFeeUpdated(buyFeeBps, newFee); + buyFeeBps = newFee; } /** * @notice Allows governance to set the withdraw fee. * @dev The fee is specified in basis points (bps), where 100 bps = 1%. */ - function setWithdrawFeeBps(uint256 newFee) external onlyGov { + function setSellFeeBps(uint256 newFee) external onlyGov { require(newFee <= BPS_DENOMINATOR, "Fee too high"); - emit WithdrawFeeUpdated(withdrawFeeBps, newFee); - withdrawFeeBps = newFee; + emit WithdrawFeeUpdated(sellFeeBps, newFee); + sellFeeBps = newFee; } /** diff --git a/test/PSM.t.sol b/test/PSM.t.sol index 56a3e83..b78e59a 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -70,8 +70,8 @@ contract PSMTest is Test { vm.stopPrank(); vm.startPrank(gov); fed.setSupplyCap(20_000_000 ether); // Set supply cap for DOLA - psm.setDepositFeeBps(50); // 0.5% deposit fee - psm.setWithdrawFeeBps(100); // 1% withdraw fee + psm.setBuyFeeBps(50); // 0.5% buy fee + psm.setSellFeeBps(100); // 1% sell fee vm.stopPrank(); fed.expansion(10_000_000 ether); // Mint some DOLA to PSMFed } @@ -82,7 +82,7 @@ contract PSMTest is Test { psm.buy(user, amount); vm.stopPrank(); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee assertEq(collateral.balanceOf(address(vault)), amount); // User should have DOLA bought assertEq(DOLA.balanceOf(user), amount - buyFee); @@ -93,7 +93,7 @@ contract PSMTest is Test { vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); vm.startPrank(user); psm.buy(amount); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee assertEq(DOLA.balanceOf(user), amount - buyFee); // User should have DOLA bought minus fee assertEq(collateral.balanceOf(address(vault)), amount); // Vault should have the collateral } @@ -103,14 +103,14 @@ contract PSMTest is Test { uint256 initialCollateralBal = collateral.balanceOf(user); vm.startPrank(user); psm.buy(amount); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee uint256 dolaToSell = DOLA.balanceOf(user); assertEq(dolaToSell, amount - buyFee); // User should have D DOLA.approve(address(psm), dolaToSell); psm.sell(dolaToSell); vm.stopPrank(); - uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee assertEq(DOLA.balanceOf(user), 0); // User should have no DOLA left assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees } @@ -125,13 +125,13 @@ contract PSMTest is Test { console2.log(DOLA.balanceOf(user)); // Now burn All DOLA uint256 dolaToSell = DOLA.balanceOf(user); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA DOLA.approve(address(psm), dolaToSell); psm.sell(user, dolaToSell); vm.stopPrank(); - uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee assertEq(collateral.balanceOf(gov), 0); assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees // Vault should have only fees left @@ -143,14 +143,14 @@ contract PSMTest is Test { vm.startPrank(user); psm.buy(user, amount); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee uint256 dolaToSell = DOLA.balanceOf(user); assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA DOLA.approve(address(psm), type(uint256).max); psm.sell(user, dolaToSell); - uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // deposit and withdraw fees assertEq(psm.getProfit(), buyFee + sellFee); // No profit taken yet @@ -177,12 +177,12 @@ contract PSMTest is Test { vm.prank(operator); psm.takeProfit(); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee // Check that profit was taken assertEq(collateral.balanceOf(gov), profit + buyFee); uint256 dolaToSell = DOLA.balanceOf(user); - uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee // User sells all DOLA vm.startPrank(user); DOLA.approve(address(psm), type(uint256).max); @@ -236,8 +236,8 @@ contract PSMTest is Test { psm.buy(user2, amount2); vm.stopPrank(); - uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee - uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee1 = amount1 * psm.buyFeeBps() / 10000; // 0.5% buy fee + uint256 buyFee2 = amount2 * psm.buyFeeBps() / 10000; // 0.5% buy fee uint256 dolaToSell1 = DOLA.balanceOf(user); uint256 dolaToSell2 = DOLA.balanceOf(user2); // Check balances after both users bought DOLA @@ -253,7 +253,7 @@ contract PSMTest is Test { vm.prank(gov); psm.migrate(address(newVault)); // After migration, profit and fees should be taken and transferred to governance - uint256 fee = (amount1 + amount2) * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 fee = (amount1 + amount2) * psm.buyFeeBps() / 10000; // 0.5% buy fee assertEq(collateral.balanceOf(gov), profit + fee); // Gov should have profit + deposit fee // Check new vault has the correct balance @@ -276,10 +276,10 @@ contract PSMTest is Test { // Check both users got their collateral back minus fees assertEq( - collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.withdrawFeeBps() / 10000) + collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.sellFeeBps() / 10000) ); assertEq( - collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.withdrawFeeBps() / 10000) + collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.sellFeeBps() / 10000) ); } @@ -302,8 +302,8 @@ contract PSMTest is Test { psm.buy(user2, amount2); vm.stopPrank(); - uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee - uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee1 = amount1 * psm.buyFeeBps() / 10000; // 0.5% buy fee + uint256 buyFee2 = amount2 * psm.buyFeeBps() / 10000; // 0.5% buy fee uint256 dolaToSell1 = DOLA.balanceOf(user); uint256 dolaToSell2 = DOLA.balanceOf(user2); // Check balances after both users bought DOLA @@ -354,8 +354,8 @@ contract PSMTest is Test { DOLA.approve(address(psm), type(uint256).max); psm.sell(user2, dolaToSell2); vm.stopPrank(); - uint256 withdrawFee1 = dolaToSell1 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee - uint256 withdrawFee2 = dolaToSell2 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 withdrawFee1 = dolaToSell1 * psm.sellFeeBps() / 10000; // 1% sell fee + uint256 withdrawFee2 = dolaToSell2 * psm.sellFeeBps() / 10000; // 1% sell fee assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + withdrawFee1)); // User 1 gets back collateral minus fees // Profit should include fees from both users assertEq(psm.getProfit(), profit + buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2); @@ -378,10 +378,10 @@ contract PSMTest is Test { function test_GovCanUpdateFees() public { vm.startPrank(gov); - psm.setDepositFeeBps(100); - assertEq(psm.depositFeeBps(), 100); - psm.setWithdrawFeeBps(200); - assertEq(psm.withdrawFeeBps(), 200); + psm.setBuyFeeBps(100); + assertEq(psm.buyFeeBps(), 100); + psm.setSellFeeBps(200); + assertEq(psm.sellFeeBps(), 200); } function test_GovCanUpdateController() public { @@ -411,22 +411,22 @@ contract PSMTest is Test { function test_NonGovCannotUpdateFees() public { vm.startPrank(user); vm.expectRevert("Not gov"); - psm.setDepositFeeBps(100); + psm.setBuyFeeBps(100); vm.expectRevert("Not gov"); - psm.setWithdrawFeeBps(200); + psm.setSellFeeBps(200); } function test_Fail_DepositFee_TooHigh() public { vm.startPrank(gov); vm.expectRevert("Fee too high"); - psm.setDepositFeeBps(10001); // 100.1% + psm.setBuyFeeBps(10001); // 100.1% vm.stopPrank(); } function test_Fail_WithdrawFee_TooHigh() public { vm.startPrank(gov); vm.expectRevert("Fee too high"); - psm.setWithdrawFeeBps(10001); // 100.1% + psm.setSellFeeBps(10001); // 100.1% vm.stopPrank(); } @@ -452,13 +452,13 @@ contract PSMTest is Test { function test_getCollateralOut(uint256 dolaAmount) public { vm.assume(dolaAmount > 0 && dolaAmount <= 10000000 ether); - uint256 expectedCollateralOut = dolaAmount - (dolaAmount * psm.withdrawFeeBps() / 10000); // 1% fee + uint256 expectedCollateralOut = dolaAmount - (dolaAmount * psm.sellFeeBps() / 10000); // 1% fee assertEq(psm.getCollateralOut(dolaAmount), expectedCollateralOut); } function test_getDolaOut(uint256 collateralIn) public { vm.assume(collateralIn > 0 && collateralIn <= 10000000 ether); - uint256 expectedDolaOut = collateralIn - (collateralIn * psm.depositFeeBps() / 10000); // 0.5% fee + uint256 expectedDolaOut = collateralIn - (collateralIn * psm.buyFeeBps() / 10000); // 0.5% fee assertEq(psm.getDolaOut(collateralIn), expectedDolaOut); } @@ -499,7 +499,7 @@ contract PSMTest is Test { vm.stopPrank(); uint256 profit = psm.getProfit(); - assertEq(profit, (collateralAmount * psm.depositFeeBps() / 10000)); // Profit should equal to fees collected + assertEq(profit, (collateralAmount * psm.buyFeeBps() / 10000)); // Profit should equal to fees collected } function test_PSMFed_expansion(uint256 expansionAmount) public { diff --git a/test/PSMUSDSFork.t.sol b/test/PSMUSDSFork.t.sol index b250116..0e0465c 100644 --- a/test/PSMUSDSFork.t.sol +++ b/test/PSMUSDSFork.t.sol @@ -45,8 +45,8 @@ contract PSMUSDSTest is Test { vm.startPrank(gov); DOLA.addMinter(address(fed)); // Allow PSMFed to mint DOLA fed.setSupplyCap(20_000_000 ether); // Set supply cap for DOLA - psm.setDepositFeeBps(50); // 0.5% deposit fee - psm.setWithdrawFeeBps(100); // 1% withdraw fee + psm.setBuyFeeBps(50); // 0.5% buy fee + psm.setSellFeeBps(100); // 1% sell fee vm.stopPrank(); fed.expansion(10_000_000 ether); // Mint some DOLA to PSMFed } @@ -57,7 +57,7 @@ contract PSMUSDSTest is Test { psm.buy(user, amount); vm.stopPrank(); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), amount, 2); // User should have DOLA bought assertEq(DOLA.balanceOf(user), amount - buyFee); @@ -68,7 +68,7 @@ contract PSMUSDSTest is Test { vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); vm.startPrank(user); psm.buy(amount); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee assertEq(DOLA.balanceOf(user), amount - buyFee); // User should have DOLA bought minus fee assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), amount, 2); // Vault should have the collateral } @@ -78,14 +78,14 @@ contract PSMUSDSTest is Test { uint256 initialCollateralBal = collateral.balanceOf(user); vm.startPrank(user); psm.buy(amount); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee uint256 dolaToSell = DOLA.balanceOf(user); assertEq(dolaToSell, amount - buyFee); // User should have D DOLA.approve(address(psm), dolaToSell); psm.sell(dolaToSell); vm.stopPrank(); - uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee assertEq(DOLA.balanceOf(user), 0); // User should have no DOLA left assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees } @@ -99,13 +99,13 @@ contract PSMUSDSTest is Test { // Now burn All DOLA uint256 dolaToSell = DOLA.balanceOf(user); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA DOLA.approve(address(psm), dolaToSell); psm.sell(user, dolaToSell); vm.stopPrank(); - uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee assertEq(collateral.balanceOf(gov), 0); assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees // Vault should have only fees left @@ -117,14 +117,14 @@ contract PSMUSDSTest is Test { vm.startPrank(user); psm.buy(user, amount); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee uint256 dolaToSell = DOLA.balanceOf(user); assertEq(dolaToSell, amount - buyFee); // User should have bought DOLA DOLA.approve(address(psm), type(uint256).max); psm.sell(user, dolaToSell); - uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), buyFee + sellFee, 3); // deposit and withdraw fees assertApproxEqAbs(psm.getProfit(), buyFee + sellFee, 3); // No profit taken yet // Try to sell more DOLA than available in PSM @@ -146,12 +146,12 @@ contract PSMUSDSTest is Test { psm.takeProfit(); - uint256 buyFee = amount * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee = amount * psm.buyFeeBps() / 10000; // 0.5% buy fee // Check that profit was taken assertApproxEqAbs(collateral.balanceOf(gov), buyFee, 2); uint256 dolaToSell = DOLA.balanceOf(user); - uint256 sellFee = dolaToSell * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee // User sells all DOLA vm.startPrank(user); DOLA.approve(address(psm), type(uint256).max); @@ -205,8 +205,8 @@ contract PSMUSDSTest is Test { psm.buy(user2, amount2); vm.stopPrank(); - uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee - uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee1 = amount1 * psm.buyFeeBps() / 10000; // 0.5% buy fee + uint256 buyFee2 = amount2 * psm.buyFeeBps() / 10000; // 0.5% buy fee uint256 dolaToSell1 = DOLA.balanceOf(user); uint256 dolaToSell2 = DOLA.balanceOf(user2); // Check balances after both users bought DOLA @@ -221,7 +221,7 @@ contract PSMUSDSTest is Test { vm.prank(gov); psm.migrate(address(newVault)); // After migration, profit and fees should be taken and transferred to governance - uint256 fee = (amount1 + amount2) * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 fee = (amount1 + amount2) * psm.buyFeeBps() / 10000; // 0.5% buy fee assertApproxEqAbs(collateral.balanceOf(gov), fee, 2); // Gov should have profit + deposit fee // Check new vault has the correct balance @@ -246,10 +246,10 @@ contract PSMUSDSTest is Test { // Check both users got their collateral back minus fees assertEq( - collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.withdrawFeeBps() / 10000) + collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.sellFeeBps() / 10000) ); assertEq( - collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.withdrawFeeBps() / 10000) + collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.sellFeeBps() / 10000) ); } @@ -272,8 +272,8 @@ contract PSMUSDSTest is Test { psm.buy(user2, amount2); vm.stopPrank(); - uint256 buyFee1 = amount1 * psm.depositFeeBps() / 10000; // 0.5% deposit fee - uint256 buyFee2 = amount2 * psm.depositFeeBps() / 10000; // 0.5% deposit fee + uint256 buyFee1 = amount1 * psm.buyFeeBps() / 10000; // 0.5% buy fee + uint256 buyFee2 = amount2 * psm.buyFeeBps() / 10000; // 0.5% buy fee uint256 dolaToSell1 = DOLA.balanceOf(user); uint256 dolaToSell2 = DOLA.balanceOf(user2); // Check balances after both users bought DOLA @@ -321,8 +321,8 @@ contract PSMUSDSTest is Test { DOLA.approve(address(psm), type(uint256).max); psm.sell(user2, dolaToSell2); vm.stopPrank(); - uint256 withdrawFee1 = dolaToSell1 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee - uint256 withdrawFee2 = dolaToSell2 * psm.withdrawFeeBps() / 10000; // 1% withdraw fee + uint256 withdrawFee1 = dolaToSell1 * psm.sellFeeBps() / 10000; // 1% sell fee + uint256 withdrawFee2 = dolaToSell2 * psm.sellFeeBps() / 10000; // 1% sell fee assertApproxEqAbs(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + withdrawFee1), 4); // User 1 gets back collateral minus fees // Profit should include fees from both users assertApproxEqAbs(psm.getProfit(), buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2, 4); @@ -345,10 +345,10 @@ contract PSMUSDSTest is Test { function test_GovCanUpdateFees() public { vm.startPrank(gov); - psm.setDepositFeeBps(100); - assertEq(psm.depositFeeBps(), 100); - psm.setWithdrawFeeBps(200); - assertEq(psm.withdrawFeeBps(), 200); + psm.setBuyFeeBps(100); + assertEq(psm.buyFeeBps(), 100); + psm.setSellFeeBps(200); + assertEq(psm.sellFeeBps(), 200); } function test_GovCanUpdateController() public { @@ -378,22 +378,22 @@ contract PSMUSDSTest is Test { function test_NonGovCannotUpdateFees() public { vm.startPrank(user); vm.expectRevert("Not gov"); - psm.setDepositFeeBps(100); + psm.setBuyFeeBps(100); vm.expectRevert("Not gov"); - psm.setWithdrawFeeBps(200); + psm.setSellFeeBps(200); } function test_Fail_DepositFee_TooHigh() public { vm.startPrank(gov); vm.expectRevert("Fee too high"); - psm.setDepositFeeBps(10001); // 100.1% + psm.setBuyFeeBps(10001); // 100.1% vm.stopPrank(); } function test_Fail_WithdrawFee_TooHigh() public { vm.startPrank(gov); vm.expectRevert("Fee too high"); - psm.setWithdrawFeeBps(10001); // 100.1% + psm.setSellFeeBps(10001); // 100.1% vm.stopPrank(); } @@ -419,13 +419,13 @@ contract PSMUSDSTest is Test { function test_getCollateralOut(uint256 dolaAmount) public { vm.assume(dolaAmount > 0 && dolaAmount <= 10000000 ether); - uint256 expectedCollateralOut = dolaAmount - (dolaAmount * psm.withdrawFeeBps() / 10000); // 1% fee + uint256 expectedCollateralOut = dolaAmount - (dolaAmount * psm.sellFeeBps() / 10000); // 1% fee assertEq(psm.getCollateralOut(dolaAmount), expectedCollateralOut); } function test_getDolaOut(uint256 collateralIn) public { vm.assume(collateralIn > 0 && collateralIn <= 10000000 ether); - uint256 expectedDolaOut = collateralIn - (collateralIn * psm.depositFeeBps() / 10000); // 0.5% fee + uint256 expectedDolaOut = collateralIn - (collateralIn * psm.buyFeeBps() / 10000); // 0.5% fee assertEq(psm.getDolaOut(collateralIn), expectedDolaOut); } @@ -460,7 +460,7 @@ contract PSMUSDSTest is Test { vm.stopPrank(); uint256 profit = psm.getProfit(); - assertApproxEqAbs(profit, (collateralAmount * psm.depositFeeBps() / 10000), 1); // Profit should equal to fees collected + assertApproxEqAbs(profit, (collateralAmount * psm.buyFeeBps() / 10000), 1); // Profit should equal to fees collected } function test_PSMFed_expansion(uint256 expansionAmount) public { From e1b7f83635351cb9bbc767fc5764804d6ff978cb Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 3 Jul 2025 14:29:32 -0500 Subject: [PATCH 17/22] format --- src/Controller.sol | 2 +- test/PSM.t.sol | 8 ++------ test/PSMUSDSFork.t.sol | 8 ++------ 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/Controller.sol b/src/Controller.sol index 12a75ef..60806fd 100644 --- a/src/Controller.sol +++ b/src/Controller.sol @@ -1,7 +1,7 @@ // 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. +// 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 { /** diff --git a/test/PSM.t.sol b/test/PSM.t.sol index b78e59a..40ac45b 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -275,12 +275,8 @@ contract PSMTest is Test { vm.stopPrank(); // Check both users got their collateral back minus fees - assertEq( - collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.sellFeeBps() / 10000) - ); - assertEq( - collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.sellFeeBps() / 10000) - ); + assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.sellFeeBps() / 10000)); + assertEq(collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.sellFeeBps() / 10000)); } function test_migrate_manually_via_gov_if_maxRedeem_lower_than_psm_balance() public { diff --git a/test/PSMUSDSFork.t.sol b/test/PSMUSDSFork.t.sol index 0e0465c..fdc5285 100644 --- a/test/PSMUSDSFork.t.sol +++ b/test/PSMUSDSFork.t.sol @@ -245,12 +245,8 @@ contract PSMUSDSTest is Test { vm.stopPrank(); // Check both users got their collateral back minus fees - assertEq( - collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.sellFeeBps() / 10000) - ); - assertEq( - collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.sellFeeBps() / 10000) - ); + assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + dolaToSell1 * psm.sellFeeBps() / 10000)); + assertEq(collateral.balanceOf(user2), user2CollateralBal - (buyFee2 + dolaToSell2 * psm.sellFeeBps() / 10000)); } function test_migrate_manually_via_gov_if_maxRedeem_lower_than_psm_balance() public { From 7e8779e31efe70de8b94fe29010cd71cb203c0a1 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 3 Jul 2025 14:31:08 -0500 Subject: [PATCH 18/22] rename fee events --- src/PSM.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PSM.sol b/src/PSM.sol index 0557073..a452319 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -32,8 +32,8 @@ contract PSM { event PendingGovUpdated(address indexed pendingGov); event ControllerChanged(address indexed oldController, address indexed newController); event VaultMigrated(address indexed oldVault, address indexed newVault); - event DepositFeeUpdated(uint256 oldFee, uint256 newFee); - event WithdrawFeeUpdated(uint256 oldFee, uint256 newFee); + 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); @@ -204,7 +204,7 @@ contract PSM { */ function setBuyFeeBps(uint256 newFee) external onlyGov { require(newFee <= BPS_DENOMINATOR, "Fee too high"); - emit DepositFeeUpdated(buyFeeBps, newFee); + emit BuyFeeUpdated(buyFeeBps, newFee); buyFeeBps = newFee; } @@ -214,7 +214,7 @@ contract PSM { */ function setSellFeeBps(uint256 newFee) external onlyGov { require(newFee <= BPS_DENOMINATOR, "Fee too high"); - emit WithdrawFeeUpdated(sellFeeBps, newFee); + emit SellFeeUpdated(sellFeeBps, newFee); sellFeeBps = newFee; } From 2396c43094d3af8f3370823cbfdfe4fbf664034a Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 3 Jul 2025 14:35:48 -0500 Subject: [PATCH 19/22] fee renaming --- src/PSM.sol | 4 ++-- test/PSM.t.sol | 22 +++++++++++----------- test/PSMUSDSFork.t.sol | 22 +++++++++++----------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/PSM.sol b/src/PSM.sol index a452319..2ea6091 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -199,7 +199,7 @@ contract PSM { } /** - * @notice Allows governance to set the deposit fee. + * @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 { @@ -209,7 +209,7 @@ contract PSM { } /** - * @notice Allows governance to set the withdraw fee. + * @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 { diff --git a/test/PSM.t.sol b/test/PSM.t.sol index 40ac45b..6b62ae2 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -135,7 +135,7 @@ contract PSMTest is Test { assertEq(collateral.balanceOf(gov), 0); assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees // Vault should have only fees left - assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // deposit and withdraw fees + assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // buy and sell fees } function test_SellDolaExceedSupply(uint256 amount) public { @@ -151,7 +151,7 @@ contract PSMTest is Test { psm.sell(user, dolaToSell); uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee - assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // deposit and withdraw fees + assertEq(collateral.balanceOf(address(vault)), buyFee + sellFee); // buy and sell fees assertEq(psm.getProfit(), buyFee + sellFee); // No profit taken yet // Try to sell more DOLA than available in PSM @@ -211,7 +211,7 @@ contract PSMTest is Test { vm.prank(gov); psm.migrate(address(newVault)); assertEq(address(psm.vault()), address(newVault)); - // Profit was taken and transferred to governance plus the deposit fee + // Profit was taken and transferred to governance plus the buy fee assertEq(collateral.balanceOf(gov), profit + fee, "Not correct profit and fees to gov"); // Check new vault has the correct balance assertEq(newVault.balanceOf(address(psm)), amount - fee, "Not correct vault balance after migration"); @@ -254,7 +254,7 @@ contract PSMTest is Test { psm.migrate(address(newVault)); // After migration, profit and fees should be taken and transferred to governance uint256 fee = (amount1 + amount2) * psm.buyFeeBps() / 10000; // 0.5% buy fee - assertEq(collateral.balanceOf(gov), profit + fee); // Gov should have profit + deposit fee + assertEq(collateral.balanceOf(gov), profit + fee); // Gov should have profit + buy fee // Check new vault has the correct balance assertEq(newVault.balanceOf(address(psm)), amount1 + amount2 - fee, "Vault balance not correct after migration"); @@ -350,15 +350,15 @@ contract PSMTest is Test { DOLA.approve(address(psm), type(uint256).max); psm.sell(user2, dolaToSell2); vm.stopPrank(); - uint256 withdrawFee1 = dolaToSell1 * psm.sellFeeBps() / 10000; // 1% sell fee - uint256 withdrawFee2 = dolaToSell2 * psm.sellFeeBps() / 10000; // 1% sell fee - assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + withdrawFee1)); // User 1 gets back collateral minus fees + uint256 sellFee1 = dolaToSell1 * psm.sellFeeBps() / 10000; // 1% sell fee + uint256 sellFee2 = dolaToSell2 * psm.sellFeeBps() / 10000; // 1% sell fee + assertEq(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + sellFee1)); // User 1 gets back collateral minus fees // Profit should include fees from both users - assertEq(psm.getProfit(), profit + buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2); + assertEq(psm.getProfit(), profit + buyFee1 + buyFee2 + sellFee1 + sellFee2); psm.takeProfit(); // Take profit // Check balances after taking profit - assertEq(collateral.balanceOf(gov), profit + buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2); + assertEq(collateral.balanceOf(gov), profit + buyFee1 + buyFee2 + sellFee1 + sellFee2); assertEq(newVault.previewRedeem(newVault.balanceOf(address(psm))), psm.supply()); } @@ -412,14 +412,14 @@ contract PSMTest is Test { psm.setSellFeeBps(200); } - function test_Fail_DepositFee_TooHigh() public { + function test_Fail_BuyFee_TooHigh() public { vm.startPrank(gov); vm.expectRevert("Fee too high"); psm.setBuyFeeBps(10001); // 100.1% vm.stopPrank(); } - function test_Fail_WithdrawFee_TooHigh() public { + function test_Fail_SellFee_TooHigh() public { vm.startPrank(gov); vm.expectRevert("Fee too high"); psm.setSellFeeBps(10001); // 100.1% diff --git a/test/PSMUSDSFork.t.sol b/test/PSMUSDSFork.t.sol index fdc5285..9a38c77 100644 --- a/test/PSMUSDSFork.t.sol +++ b/test/PSMUSDSFork.t.sol @@ -109,7 +109,7 @@ contract PSMUSDSTest is Test { assertEq(collateral.balanceOf(gov), 0); assertEq(collateral.balanceOf(user), initialCollateralBal - (buyFee + sellFee)); // User gets back collateral minus fees // Vault should have only fees left - assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), buyFee + sellFee, 3); // deposit and withdraw fees + assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), buyFee + sellFee, 3); // buy and sell fees } function test_SellDolaExceedSupply(uint256 amount) public { @@ -125,7 +125,7 @@ contract PSMUSDSTest is Test { psm.sell(user, dolaToSell); uint256 sellFee = dolaToSell * psm.sellFeeBps() / 10000; // 1% sell fee - assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), buyFee + sellFee, 3); // deposit and withdraw fees + assertApproxEqAbs(vault.previewRedeem(vault.balanceOf(address(psm))), buyFee + sellFee, 3); // buy and sell fees assertApproxEqAbs(psm.getProfit(), buyFee + sellFee, 3); // No profit taken yet // Try to sell more DOLA than available in PSM deal(address(DOLA), user, buyFee + sellFee); // Mint some DOLA to user to attempt taking profit by selling @@ -178,7 +178,7 @@ contract PSMUSDSTest is Test { vm.prank(gov); psm.migrate(address(newVault)); assertEq(address(psm.vault()), address(newVault)); - // Profit was taken and transferred to governance plus the deposit fee + // Profit was taken and transferred to governance plus the buy fee assertApproxEqAbs(collateral.balanceOf(gov), fee, 2, "Not correct profit and fees to gov"); // Check new vault has the correct balance assertApproxEqAbs( @@ -222,7 +222,7 @@ contract PSMUSDSTest is Test { psm.migrate(address(newVault)); // After migration, profit and fees should be taken and transferred to governance uint256 fee = (amount1 + amount2) * psm.buyFeeBps() / 10000; // 0.5% buy fee - assertApproxEqAbs(collateral.balanceOf(gov), fee, 2); // Gov should have profit + deposit fee + assertApproxEqAbs(collateral.balanceOf(gov), fee, 2); // Gov should have profit + buy fee // Check new vault has the correct balance assertApproxEqAbs( @@ -317,15 +317,15 @@ contract PSMUSDSTest is Test { DOLA.approve(address(psm), type(uint256).max); psm.sell(user2, dolaToSell2); vm.stopPrank(); - uint256 withdrawFee1 = dolaToSell1 * psm.sellFeeBps() / 10000; // 1% sell fee - uint256 withdrawFee2 = dolaToSell2 * psm.sellFeeBps() / 10000; // 1% sell fee - assertApproxEqAbs(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + withdrawFee1), 4); // User 1 gets back collateral minus fees + uint256 sellFee1 = dolaToSell1 * psm.sellFeeBps() / 10000; // 1% sell fee + uint256 sellFee2 = dolaToSell2 * psm.sellFeeBps() / 10000; // 1% sell fee + assertApproxEqAbs(collateral.balanceOf(user), user1CollateralBal - (buyFee1 + sellFee1), 4); // User 1 gets back collateral minus fees // Profit should include fees from both users - assertApproxEqAbs(psm.getProfit(), buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2, 4); + assertApproxEqAbs(psm.getProfit(), buyFee1 + buyFee2 + sellFee1 + sellFee2, 4); psm.takeProfit(); // Take profit // Check balances after taking profit - assertApproxEqAbs(collateral.balanceOf(gov), buyFee1 + buyFee2 + withdrawFee1 + withdrawFee2, 4); + assertApproxEqAbs(collateral.balanceOf(gov), buyFee1 + buyFee2 + sellFee1 + sellFee2, 4); assertEq(newVault.previewRedeem(newVault.balanceOf(address(psm))), psm.supply()); } @@ -379,14 +379,14 @@ contract PSMUSDSTest is Test { psm.setSellFeeBps(200); } - function test_Fail_DepositFee_TooHigh() public { + function test_Fail_BuyFee_TooHigh() public { vm.startPrank(gov); vm.expectRevert("Fee too high"); psm.setBuyFeeBps(10001); // 100.1% vm.stopPrank(); } - function test_Fail_WithdrawFee_TooHigh() public { + function test_Fail_SellFee_TooHigh() public { vm.startPrank(gov); vm.expectRevert("Fee too high"); psm.setSellFeeBps(10001); // 100.1% From dbc93052584bde359f19ff59ce62a28c8d743a93 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 3 Jul 2025 17:04:55 -0500 Subject: [PATCH 20/22] add minCollateralAmount param in migrate --- src/PSM.sol | 5 +++-- test/PSM.t.sol | 15 ++++++++++----- test/PSMUSDSFork.t.sol | 11 +++++++---- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/PSM.sol b/src/PSM.sol index 2ea6091..37465df 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -168,8 +168,9 @@ contract PSM { * @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) external onlyGov { + 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"); @@ -182,7 +183,7 @@ contract PSM { address oldVault = address(vault); vault = IERC4626(newVault); uint256 collateralBalance = collateral.balanceOf(address(this)); - if (collateralBalance != 0) { + if (collateralBalance >= minCollateralAmount) { collateral.approve(address(vault), collateralBalance); vault.deposit(collateralBalance, address(this)); } diff --git a/test/PSM.t.sol b/test/PSM.t.sol index 6b62ae2..952dd65 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -203,13 +203,16 @@ contract PSMTest is Test { function test_Migrate_Vault(uint256 amount) public { vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); uint256 fee = test_BuyDOLAWithFee(amount); + uint256 vaultBalBeforeProfit = vault.balanceOf(address(psm)); + uint256 minCollateralAmount = vault.previewRedeem(vaultBalBeforeProfit); // Simulate profit in vault uint256 profit = 200 ether; // Assume profit of 200 ether collateral.mint(address(vault), profit); // Add profit to vault MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); - vm.prank(gov); - psm.migrate(address(newVault)); + + vm.startPrank(gov); + psm.migrate(address(newVault), minCollateralAmount * 995 / 1000); // take into account fees which will be taken in profit assertEq(address(psm.vault()), address(newVault)); // Profit was taken and transferred to governance plus the buy fee assertEq(collateral.balanceOf(gov), profit + fee, "Not correct profit and fees to gov"); @@ -250,8 +253,9 @@ contract PSMTest is Test { collateral.mint(address(vault), 100 ether); // Migrate to new vault MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + uint256 minCollateralAmount = vault.previewRedeem(vault.balanceOf(address(psm))); vm.prank(gov); - psm.migrate(address(newVault)); + psm.migrate(address(newVault), minCollateralAmount / 2); // After migration, profit and fees should be taken and transferred to governance uint256 fee = (amount1 + amount2) * psm.buyFeeBps() / 10000; // 0.5% buy fee assertEq(collateral.balanceOf(gov), profit + fee); // Gov should have profit + buy fee @@ -317,9 +321,10 @@ contract PSMTest is Test { vm.prank(gov); psm.sweep(IERC20(address(vault))); // Sweep vault balance to gov assertEq(vaultBal, vault.balanceOf(gov)); - + uint256 minCollateralAmount = vault.previewRedeem(vaultBal); vm.prank(gov); - psm.migrate(address(newVault)); + // Won't deposit in the new vault, just migrate because minCollateralAmount is lower than PSM balance + psm.migrate(address(newVault), minCollateralAmount); //Block buy and sell(updating controller), users cannot buy or sell while migration is in progress vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onBuy.selector), abi.encode(false)); vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onSell.selector), abi.encode(false)); diff --git a/test/PSMUSDSFork.t.sol b/test/PSMUSDSFork.t.sol index 9a38c77..88e9ed6 100644 --- a/test/PSMUSDSFork.t.sol +++ b/test/PSMUSDSFork.t.sol @@ -175,8 +175,9 @@ contract PSMUSDSTest is Test { uint256 fee = test_BuyDOLAWithFee(amount); MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); - vm.prank(gov); - psm.migrate(address(newVault)); + uint256 minCollateralAmount = vault.previewRedeem(vault.balanceOf(address(psm))); + vm.startPrank(gov); + psm.migrate(address(newVault), minCollateralAmount / 2); assertEq(address(psm.vault()), address(newVault)); // Profit was taken and transferred to governance plus the buy fee assertApproxEqAbs(collateral.balanceOf(gov), fee, 2, "Not correct profit and fees to gov"); @@ -218,8 +219,9 @@ contract PSMUSDSTest is Test { deal(address(collateral), address(vault), 100000 ether); // Simulate profit in vault // Migrate to new vault MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + uint256 minCollateralAmount = vault.previewRedeem(vault.balanceOf(address(psm))); vm.prank(gov); - psm.migrate(address(newVault)); + psm.migrate(address(newVault), minCollateralAmount / 2); // After migration, profit and fees should be taken and transferred to governance uint256 fee = (amount1 + amount2) * psm.buyFeeBps() / 10000; // 0.5% buy fee assertApproxEqAbs(collateral.balanceOf(gov), fee, 2); // Gov should have profit + buy fee @@ -286,8 +288,9 @@ contract PSMUSDSTest is Test { psm.sweep(IERC20(address(vault))); // Sweep vault balance to gov assertEq(vaultBal, vault.balanceOf(gov)); + uint256 minCollateralAmount = vault.previewRedeem(vaultBal); vm.prank(gov); - psm.migrate(address(newVault)); + psm.migrate(address(newVault), minCollateralAmount); //Block buy and sell(updating controller), users cannot buy or sell while migration is in progress vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onBuy.selector), abi.encode(false)); vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onSell.selector), abi.encode(false)); From 485612fd9884ed40eae54e1671544493f5b95b56 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 10 Jul 2025 15:32:38 +0200 Subject: [PATCH 21/22] use onSell --- src/PSM.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PSM.sol b/src/PSM.sol index 37465df..2b7365c 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -99,7 +99,7 @@ contract PSM { */ function sell(address to, uint256 dolaAmountIn) public { require(dolaAmountIn > 0, "Amount must be > 0"); - require(controller.onBuy(msg.sender, dolaAmountIn), "Denied by controller"); + require(controller.onSell(msg.sender, dolaAmountIn), "Denied by controller"); supply -= dolaAmountIn; DOLA.safeTransferFrom(msg.sender, address(this), dolaAmountIn); From 4a419d52c23217cc5a8fa2095d5cea4afd77352d Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 10 Jul 2025 18:00:06 +0200 Subject: [PATCH 22/22] revert if below minCollateralOut --- src/PSM.sol | 3 ++- test/PSM.t.sol | 14 +++++++++++++- test/PSMUSDSFork.t.sol | 14 +++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/PSM.sol b/src/PSM.sol index 2b7365c..b756e3f 100644 --- a/src/PSM.sol +++ b/src/PSM.sol @@ -183,7 +183,8 @@ contract PSM { address oldVault = address(vault); vault = IERC4626(newVault); uint256 collateralBalance = collateral.balanceOf(address(this)); - if (collateralBalance >= minCollateralAmount) { + require(collateralBalance >= minCollateralAmount, "Insufficient collateral balance for migration"); + if (collateralBalance > 0) { collateral.approve(address(vault), collateralBalance); vault.deposit(collateralBalance, address(this)); } diff --git a/test/PSM.t.sol b/test/PSM.t.sol index 952dd65..d9c438f 100644 --- a/test/PSM.t.sol +++ b/test/PSM.t.sol @@ -220,6 +220,18 @@ contract PSMTest is Test { assertEq(newVault.balanceOf(address(psm)), amount - fee, "Not correct vault balance after migration"); } + function test_Fail_Migrate_Vault_if_below_minCollateralOut(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + test_BuyDOLAWithFee(amount); + uint256 minCollateralAmount = vault.previewRedeem(vault.balanceOf(address(psm))); + + MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + + vm.prank(gov); + vm.expectRevert("Insufficient collateral balance for migration"); + psm.migrate(address(newVault), minCollateralAmount * 2); + } + function test_2_users_buy_then_migrate_with_profit_then_contract_and_sell() public { address user2 = address(0x456); collateral.mint(user2, 5000 ether); // Give user2 some collateral @@ -324,7 +336,7 @@ contract PSMTest is Test { uint256 minCollateralAmount = vault.previewRedeem(vaultBal); vm.prank(gov); // Won't deposit in the new vault, just migrate because minCollateralAmount is lower than PSM balance - psm.migrate(address(newVault), minCollateralAmount); + psm.migrate(address(newVault), 0); //Block buy and sell(updating controller), users cannot buy or sell while migration is in progress vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onBuy.selector), abi.encode(false)); vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onSell.selector), abi.encode(false)); diff --git a/test/PSMUSDSFork.t.sol b/test/PSMUSDSFork.t.sol index 88e9ed6..fb21fa8 100644 --- a/test/PSMUSDSFork.t.sol +++ b/test/PSMUSDSFork.t.sol @@ -187,6 +187,18 @@ contract PSMUSDSTest is Test { ); } + function test_Fail_Migrate_Vault_if_below_minCollateralOut(uint256 amount) public { + vm.assume(amount > 0.000001 ether && amount <= 10000000 ether); + test_BuyDOLAWithFee(amount); + uint256 minCollateralAmount = vault.previewRedeem(vault.balanceOf(address(psm))); + + MockERC4626 newVault = new MockERC4626(ERC20(address(collateral)), "New Vault", "NEW"); + + vm.prank(gov); + vm.expectRevert("Insufficient collateral balance for migration"); + psm.migrate(address(newVault), minCollateralAmount * 2); // Try to migrate with less than minCollateralAmount + } + function test_2_users_buy_then_migrate_with_profit_then_contract_and_sell() public { address user2 = address(0x456); deal(address(collateral), address(user2), 5000 ether); // Give user2 some collateral @@ -290,7 +302,7 @@ contract PSMUSDSTest is Test { uint256 minCollateralAmount = vault.previewRedeem(vaultBal); vm.prank(gov); - psm.migrate(address(newVault), minCollateralAmount); + psm.migrate(address(newVault), 0); //Block buy and sell(updating controller), users cannot buy or sell while migration is in progress vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onBuy.selector), abi.encode(false)); vm.mockCall(address(controller), abi.encodeWithSelector(Controller.onSell.selector), abi.encode(false));