├── .github └── workflows │ ├── master.yml │ └── pr.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── Makefile ├── README.md ├── audits ├── 20240909-cantina-audit.pdf ├── 20241022-chainsecurity-audit.pdf └── 20241023-cantina-audit.pdf ├── deploy └── PSM3Deploy.sol ├── foundry.toml ├── script └── Deploy.s.sol ├── src ├── PSM3.sol └── interfaces │ ├── IPSM3.sol │ └── IRateProviderLike.sol └── test ├── PSMTestBase.sol ├── invariant ├── Invariants.t.sol └── handlers │ ├── HandlerBase.sol │ ├── LpHandler.sol │ ├── OwnerHandler.sol │ ├── RateSetterHandler.sol │ ├── SwapperHandler.sol │ ├── TimeBasedRateHandler.sol │ └── TransferHandler.sol ├── mocks └── MockRateProvider.sol └── unit ├── Constructor.t.sol ├── Conversions.t.sol ├── Deployment.t.sol ├── Deposit.t.sol ├── DoSAttack.t.sol ├── Events.t.sol ├── Getters.t.sol ├── InflationAttack.t.sol ├── PreviewDeposit.t.sol ├── PreviewWithdraw.t.sol ├── Rounding.t.sol ├── SetPocket.t.sol ├── SwapExactIn.t.sol ├── SwapExactOut.t.sol ├── SwapPreviews.t.sol ├── Withdraw.t.sol └── harnesses └── PSM3Harness.sol /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | env: 8 | FOUNDRY_PROFILE: ci 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Install Foundry 17 | uses: foundry-rs/foundry-toolchain@v1 18 | 19 | - name: Build contracts 20 | run: | 21 | forge --version 22 | forge build --sizes 23 | 24 | test: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v3 28 | 29 | - name: Install Foundry 30 | uses: foundry-rs/foundry-toolchain@v1 31 | 32 | - name: Run tests 33 | env: 34 | MAINNET_RPC_URL: ${{secrets.MAINNET_RPC_URL}} 35 | OPTIMISM_RPC_URL: ${{secrets.OPTIMISM_RPC_URL}} 36 | ARBITRUM_ONE_RPC_URL: ${{secrets.ARBITRUM_ONE_RPC_URL}} 37 | ARBITRUM_NOVA_RPC_URL: ${{secrets.ARBITRUM_NOVA_RPC_URL}} 38 | GNOSIS_CHAIN_RPC_URL: ${{secrets.GNOSIS_CHAIN_RPC_URL}} 39 | BASE_RPC_URL: ${{secrets.BASE_RPC_URL}} 40 | run: FOUNDRY_PROFILE=master forge test -vv --show-progress 41 | 42 | # coverage: 43 | # runs-on: ubuntu-latest 44 | # steps: 45 | # - uses: actions/checkout@v3 46 | 47 | # - name: Install Foundry 48 | # uses: foundry-rs/foundry-toolchain@v1 49 | 50 | # - name: Run coverage 51 | # env: 52 | # MAINNET_RPC_URL: ${{secrets.MAINNET_RPC_URL}} 53 | # OPTIMISM_RPC_URL: ${{secrets.OPTIMISM_RPC_URL}} 54 | # ARBITRUM_ONE_RPC_URL: ${{secrets.ARBITRUM_ONE_RPC_URL}} 55 | # ARBITRUM_NOVA_RPC_URL: ${{secrets.ARBITRUM_NOVA_RPC_URL}} 56 | # GNOSIS_CHAIN_RPC_URL: ${{secrets.GNOSIS_CHAIN_RPC_URL}} 57 | # BASE_RPC_URL: ${{secrets.BASE_RPC_URL}} 58 | # run: forge coverage --report summary --report lcov 59 | 60 | # # To ignore coverage for certain directories modify the paths in this step as needed. The 61 | # # below default ignores coverage results for the test and script directories. Alternatively, 62 | # # to include coverage in all directories, comment out this step. Note that because this 63 | # # filtering applies to the lcov file, the summary table generated in the previous step will 64 | # # still include all files and directories. 65 | # # The `--rc lcov_branch_coverage=1` part keeps branch info in the filtered report, since lcov 66 | # # defaults to removing branch info. 67 | # - name: Filter directories 68 | # run: | 69 | # sudo apt update && sudo apt install -y lcov 70 | # lcov --remove lcov.info 'test/*' 'script/*' --output-file lcov.info --rc lcov_branch_coverage=1 71 | 72 | # # This step posts a detailed coverage report as a comment and deletes previous comments on 73 | # # each push. The below step is used to fail coverage if the specified coverage threshold is 74 | # # not met. The below step can post a comment (when it's `github-token` is specified) but it's 75 | # # not as useful, and this action cannot fail CI based on a minimum coverage threshold, which 76 | # # is why we use both in this way. 77 | # - name: Post coverage report 78 | # if: github.event_name == 'pull_request' # This action fails when ran outside of a pull request. 79 | # uses: romeovs/lcov-reporter-action@v0.3.1 80 | # with: 81 | # delete-old-comments: true 82 | # lcov-file: ./lcov.info 83 | # github-token: ${{ secrets.GITHUB_TOKEN }} # Adds a coverage summary comment to the PR. 84 | 85 | # - name: Verify minimum coverage 86 | # uses: zgosalvez/github-actions-report-lcov@v2 87 | # with: 88 | # coverage-files: ./lcov.info 89 | # minimum-coverage: 90 # Set coverage threshold. 90 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [pull_request] 4 | 5 | env: 6 | FOUNDRY_PROFILE: ci 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Install Foundry 15 | uses: foundry-rs/foundry-toolchain@v1 16 | 17 | - name: Build contracts 18 | run: | 19 | forge --version 20 | forge build --sizes 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | 27 | - name: Install Foundry 28 | uses: foundry-rs/foundry-toolchain@v1 29 | 30 | - name: Run tests 31 | env: 32 | MAINNET_RPC_URL: ${{secrets.MAINNET_RPC_URL}} 33 | OPTIMISM_RPC_URL: ${{secrets.OPTIMISM_RPC_URL}} 34 | ARBITRUM_ONE_RPC_URL: ${{secrets.ARBITRUM_ONE_RPC_URL}} 35 | ARBITRUM_NOVA_RPC_URL: ${{secrets.ARBITRUM_NOVA_RPC_URL}} 36 | GNOSIS_CHAIN_RPC_URL: ${{secrets.GNOSIS_CHAIN_RPC_URL}} 37 | BASE_RPC_URL: ${{secrets.BASE_RPC_URL}} 38 | run: FOUNDRY_PROFILE=pr forge test -vv --show-progress 39 | 40 | coverage: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v3 44 | 45 | - name: Install Foundry 46 | uses: foundry-rs/foundry-toolchain@v1 47 | 48 | - name: Run coverage 49 | env: 50 | MAINNET_RPC_URL: ${{secrets.MAINNET_RPC_URL}} 51 | OPTIMISM_RPC_URL: ${{secrets.OPTIMISM_RPC_URL}} 52 | ARBITRUM_ONE_RPC_URL: ${{secrets.ARBITRUM_ONE_RPC_URL}} 53 | ARBITRUM_NOVA_RPC_URL: ${{secrets.ARBITRUM_NOVA_RPC_URL}} 54 | GNOSIS_CHAIN_RPC_URL: ${{secrets.GNOSIS_CHAIN_RPC_URL}} 55 | BASE_RPC_URL: ${{secrets.BASE_RPC_URL}} 56 | run: forge coverage --report summary --report lcov 57 | 58 | # To ignore coverage for certain directories modify the paths in this step as needed. The 59 | # below default ignores coverage results for the test and script directories. Alternatively, 60 | # to include coverage in all directories, comment out this step. Note that because this 61 | # filtering applies to the lcov file, the summary table generated in the previous step will 62 | # still include all files and directories. 63 | # The `--rc lcov_branch_coverage=1` part keeps branch info in the filtered report, since lcov 64 | # defaults to removing branch info. 65 | - name: Filter directories 66 | run: | 67 | sudo apt update && sudo apt install -y lcov 68 | lcov --remove lcov.info 'test/*' 'script/*' --output-file lcov.info --rc lcov_branch_coverage=1 69 | 70 | # This step posts a detailed coverage report as a comment and deletes previous comments on 71 | # each push. The below step is used to fail coverage if the specified coverage threshold is 72 | # not met. The below step can post a comment (when it's `github-token` is specified) but it's 73 | # not as useful, and this action cannot fail CI based on a minimum coverage threshold, which 74 | # is why we use both in this way. 75 | - name: Post coverage report 76 | if: github.event_name == 'pull_request' # This action fails when ran outside of a pull request. 77 | uses: romeovs/lcov-reporter-action@v0.3.1 78 | with: 79 | delete-old-comments: true 80 | lcov-file: ./lcov.info 81 | github-token: ${{ secrets.GITHUB_TOKEN }} # Adds a coverage summary comment to the PR. 82 | 83 | - name: Verify minimum coverage 84 | uses: zgosalvez/github-actions-report-lcov@v2 85 | with: 86 | coverage-files: ./lcov.info 87 | minimum-coverage: 90 # Set coverage threshold. 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | out/ 4 | 5 | # Ignores broadcast 6 | /broadcast 7 | 8 | # Docs 9 | docs/ 10 | 11 | # Dotenv file 12 | .env 13 | 14 | lcov.info 15 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | [submodule "lib/erc20-helpers"] 5 | path = lib/erc20-helpers 6 | url = https://github.com/marsfoundation/erc20-helpers 7 | [submodule "lib/openzeppelin-contracts"] 8 | path = lib/openzeppelin-contracts 9 | url = https://github.com/openzeppelin/openzeppelin-contracts 10 | [submodule "lib/xchain-ssr-oracle"] 11 | path = lib/xchain-ssr-oracle 12 | url = https://github.com/marsfoundation/xchain-ssr-oracle 13 | [submodule "lib/spark-address-registry"] 14 | path = lib/spark-address-registry 15 | url = https://github.com/marsfoundation/spark-address-registry 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: deploy 2 | deploy-arbitrum-one :; forge script script/Deploy.s.sol:DeployArbitrumOne --sender ${ETH_FROM} --broadcast --verify 3 | deploy-base :; forge script script/Deploy.s.sol:DeployBase --sender ${ETH_FROM} --broadcast --verify 4 | deploy-optimism :; forge script script/Deploy.s.sol:DeployOptimism --sender ${ETH_FROM} --broadcast --verify 5 | deploy-unichain :; forge script script/Deploy.s.sol:DeployUnichain --sender ${ETH_FROM} --broadcast --verify 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚡ Spark PSM ⚡ 2 | 3 | ![Foundry CI](https://github.com/marsfoundation/spark-psm/actions/workflows/master.yml/badge.svg) 4 | [![Foundry][foundry-badge]][foundry] 5 | [![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://github.com/marsfoundation/spark-psm/blob/master/LICENSE) 6 | 7 | [foundry]: https://getfoundry.sh/ 8 | [foundry-badge]: https://img.shields.io/badge/Built%20with-Foundry-FFDB1C.svg 9 | 10 | ## Overview 11 | 12 | This repository contains the implementation of a Peg Stability Module (PSM) contract, which facilitates the swapping, depositing, and withdrawing of three given assets to maintain stability and ensure the peg of involved assets. The PSM supports both yield-bearing and non-yield-bearing assets. 13 | 14 | The PSM contract allows users to swap between USDC, USDS, and sUSDS, deposit any of the assets to mint shares, and withdraw any of the assets by burning shares. 15 | 16 | The conversion between a stablecoin and `susds` is provided by a rate provider contract. The rate provider returns the conversion rate between `susds` and the stablecoin in 1e27 precision. The conversion between the stablecoins is one to one. 17 | 18 | The conversion rate between assets and shares is based on the total value of assets within the PSM. This includes USDS and sUSDS held custody by the PSM, and USDC held custody by the `pocket`. The total value is calculated by converting the assets to their equivalent value in USD with 18 decimal precision. The shares represent the ownership of the underlying assets in the PSM. Since three assets are used, each with different precisions and values, they are converted to a common USD-denominated value for share conversions. 19 | 20 | For detailed implementation, refer to the contract code and `IPSM3` interface documentation. 21 | 22 | ## Contracts 23 | 24 | - **`src/PSM3.sol`**: The core contract implementing the `IPSM3` interface, providing functionality for swapping, depositing, and withdrawing assets. 25 | - **`src/interfaces/IPSM3.sol`**: Defines the essential functions and events that the PSM contract implements. 26 | 27 | ## [CRITICAL]: First Depositor Attack Prevention on Deployment 28 | 29 | On the deployment of the PSM, the deployer **MUST make an initial deposit to get AT LEAST 1e18 shares in order to protect the first depositor from getting attacked with a share inflation attack or DOS attack**. Share inflation attack is outlined further [here](https://github.com/marsfoundation/spark-automations/assets/44272939/9472a6d2-0361-48b0-b534-96a0614330d3). Technical details related to this can be found in `test/InflationAttack.t.sol`. 30 | 31 | The DOS attack is performed by: 32 | 1. Attacker sends funds directly to the PSM. `totalAssets` now returns a non-zero value. 33 | 2. Victim calls deposit. `convertToShares` returns `amount * totalShares / totalValue`. In this case, `totalValue` is non-zero and `totalShares` is zero, so it performs `amount * 0 / totalValue` and returns zero. 34 | 3. The victim has `transferFrom` called moving their funds into the PSM, but they receive zero shares so they cannot recover any of their underlying assets. This renders the PSM unusable for all users since this issue will persist. `totalShares` can never be increased in this state. 35 | 36 | The deployment library (`deploy/PSM3Deploy.sol`) in this repo contains logic for the deployer to perform this initial deposit, so it is **HIGHLY RECOMMENDED** to use this deployment library when deploying the PSM. Reasoning for the technical implementation approach taken is outlined in more detail [here](https://github.com/marsfoundation/spark-psm/pull/2). 37 | 38 | ## PSM Contract Details 39 | 40 | ### State Variables and Immutables 41 | 42 | - **`usdc`**: IERC20 interface of USDC. 43 | - **`usds`**: IERC20 interface of USDS. 44 | - **`susds`**: IERC20 interface of sUSDS. Note that this is an ERC20 and not a ERC4626 because it's not on mainnet. 45 | - **`pocket`**: Address that holds custody of USDC. The `pocket` can deploy USDC to yield-bearing strategies. Defaulted to the address of the PSM itself. 46 | - **`rateProvider`**: Contract that returns a conversion rate between and sUSDS and USD in 1e27 precision. 47 | - **`totalShares`**: Total shares in the PSM. Shares represent the ownership of the underlying assets in the PSM. 48 | - **`shares`**: Mapping of user addresses to their shares. 49 | 50 | ### Functions 51 | 52 | #### Admin Functions 53 | 54 | - **`setPocket`**: Sets the `pocket` address. Only the `owner` can call this function. This is a very important and sensitive action because it transfers the entire balance of USDC to the new `pocket` address. OZ Ownable is used for this function, and `owner` will always be set to the governance proxy. 55 | 56 | #### Swap Functions 57 | 58 | - **`swapExactIn`**: Allows swapping of assets based on current conversion rates, specifying an `amountIn` of the asset to swap. Ensures the derived output amount is above the `minAmountOut` specified by the user before executing the transfer and emitting the swap event. Includes a referral code. 59 | - **`swapExactOut`**: Allows swapping of assets based on current conversion rates, specifying an `amountOut` of the asset to receive from the swap. Ensures the derived input amount is below the `maxAmountIn` specified by the user before executing the transfer and emitting the swap event. Includes a referral code. 60 | 61 | #### Liquidity Provision Functions 62 | 63 | - **`deposit`**: Deposits assets into the PSM, minting new shares. Includes a referral code. 64 | - **`withdraw`**: Withdraws assets from the PSM by burning shares. Ensures the user has sufficient shares for the withdrawal and adjusts the total shares accordingly. Includes a referral code. 65 | 66 | #### Preview Functions 67 | 68 | - **`previewDeposit`**: Estimates the number of shares minted for a given deposit amount. 69 | - **`previewWithdraw`**: Estimates the number of shares burned and the amount of assets withdrawn for a specified amount. 70 | - **`previewSwapExactIn`**: Estimates the amount of `assetOut` received for a given amount of `assetIn` in a swap. 71 | - **`previewSwapExactOut`**: Estimates the amount of `assetIn` required to receive a given amount of `assetOut` in a swap. 72 | 73 | #### Conversion Functions 74 | 75 | NOTE: These functions do not round in the same way as preview functions, so they are meant to be used for general quoting purposes. 76 | 77 | - **`convertToAssets`**: Converts shares to the equivalent amount of a specified asset. 78 | - **`convertToAssetValue`**: Converts shares to their equivalent value in USD terms with 18 decimal precision. 79 | - **`convertToShares`**: Converts asset values to shares based on the current exchange rate. 80 | 81 | #### Asset Value Functions 82 | 83 | - **`totalAssets`**: Returns the total value of all assets held by the PSM denominated in USD with 18 decimal precision. 84 | 85 | ### Events 86 | 87 | - **`Swap`**: Emitted on asset swaps. 88 | - **`Deposit`**: Emitted on asset deposits. 89 | - **`Withdraw`**: Emitted on asset withdrawals. 90 | 91 | ## Running Tests 92 | 93 | To run tests in this repo, run: 94 | 95 | ```bash 96 | forge test 97 | ``` 98 | -------------------------------------------------------------------------------- /audits/20240909-cantina-audit.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkdotfi/spark-psm/2b1a72a7c24f95db0c4bda13ea9062ce9824577b/audits/20240909-cantina-audit.pdf -------------------------------------------------------------------------------- /audits/20241022-chainsecurity-audit.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkdotfi/spark-psm/2b1a72a7c24f95db0c4bda13ea9062ce9824577b/audits/20241022-chainsecurity-audit.pdf -------------------------------------------------------------------------------- /audits/20241023-cantina-audit.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sparkdotfi/spark-psm/2b1a72a7c24f95db0c4bda13ea9062ce9824577b/audits/20241023-cantina-audit.pdf -------------------------------------------------------------------------------- /deploy/PSM3Deploy.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { IERC20 } from "erc20-helpers/interfaces/IERC20.sol"; 5 | 6 | import { PSM3 } from "src/PSM3.sol"; 7 | 8 | library PSM3Deploy { 9 | 10 | function deploy( 11 | address owner, 12 | address usdc, 13 | address usds, 14 | address susds, 15 | address rateProvider 16 | ) 17 | internal returns (address psm) 18 | { 19 | psm = address(new PSM3(owner, usdc, usds, susds, rateProvider)); 20 | 21 | IERC20(usdc).approve(psm, 1e6); 22 | PSM3(psm).deposit(usdc, address(0), 1e6); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = "src" 3 | out = "out" 4 | libs = ["lib"] 5 | solc_version = '0.8.20' 6 | optimizer = true 7 | optimizer_runs = 200 8 | 9 | [fuzz] 10 | runs = 1000 11 | 12 | [invariant] 13 | runs = 20 14 | depth = 1000 15 | shrink_run_limit = 100 16 | fail_on_revert = true 17 | 18 | [profile.pr.invariant] 19 | runs = 200 20 | depth = 1000 21 | shrink_run_limit = 50_000 22 | 23 | [profile.pr.fuzz] 24 | runs = 100_000 25 | 26 | [profile.master.invariant] 27 | runs = 250 28 | depth = 2500 29 | shrink_run_limit = 50_000 30 | 31 | [profile.master.fuzz] 32 | runs = 1_000_000 33 | 34 | # See more config options https://github.com/foundry-rs/foundry/tree/master/config 35 | 36 | remappings = [ 37 | "ds-test/=lib/erc20-helpers/lib/forge-std/lib/ds-test/src/", 38 | "erc20-helpers/=lib/erc20-helpers/src/", 39 | "forge-std/=lib/forge-std/src/", 40 | "openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/", 41 | ] 42 | 43 | [etherscan] 44 | mainnet = { key = "${MAINNET_API_KEY}" } 45 | optimism = { key = "${OPTIMISMSCAN_API_KEY}" } 46 | base = { key = "${BASESCAN_API_KEY}" } 47 | gnosis_chain = { key = "${GNOSISSCAN_API_KEY}", url = "https://api.gnosisscan.io/api" } 48 | arbitrum_one = { key = "${ARBISCAN_API_KEY}" } 49 | world_chain = { key = "${WORLD_CHAIN_API_KEY}", chain = 480, url = "https://worldchain-mainnet-explorer.alchemy.com/api" } 50 | unichain = { key = "${UNICHAIN_API_KEY}", chain = 130, url = "https://unichain.blockscout.com/api" } 51 | -------------------------------------------------------------------------------- /script/Deploy.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0 2 | pragma solidity ^0.8.0; 3 | 4 | import "forge-std/Script.sol"; 5 | 6 | import { Arbitrum } from "lib/spark-address-registry/src/Arbitrum.sol"; 7 | import { Base } from "lib/spark-address-registry/src/Base.sol"; 8 | import { Optimism } from "lib/spark-address-registry/src/Optimism.sol"; 9 | import { Unichain } from "lib/spark-address-registry/src/Unichain.sol"; 10 | 11 | import { PSM3Deploy } from "deploy/PSM3Deploy.sol"; 12 | 13 | contract DeployArbitrumOne is Script { 14 | 15 | function run() external { 16 | vm.createSelectFork(getChain("arbitrum_one").rpcUrl); 17 | 18 | console.log("Deploying PSM..."); 19 | 20 | vm.startBroadcast(); 21 | 22 | address psm = PSM3Deploy.deploy({ 23 | owner : Arbitrum.SPARK_EXECUTOR, 24 | usdc : Arbitrum.USDC, 25 | usds : Arbitrum.USDS, 26 | susds : Arbitrum.SUSDS, 27 | rateProvider : Arbitrum.SSR_AUTH_ORACLE 28 | }); 29 | 30 | vm.stopBroadcast(); 31 | 32 | console.log("PSM3 deployed at:", psm); 33 | } 34 | 35 | } 36 | 37 | contract DeployBase is Script { 38 | 39 | function run() external { 40 | vm.createSelectFork(getChain("base").rpcUrl); 41 | 42 | console.log("Deploying PSM..."); 43 | 44 | vm.startBroadcast(); 45 | 46 | address psm = PSM3Deploy.deploy({ 47 | owner : Base.SPARK_EXECUTOR, 48 | usdc : Base.USDC, 49 | usds : Base.USDS, 50 | susds : Base.SUSDS, 51 | rateProvider : Base.SSR_AUTH_ORACLE 52 | }); 53 | 54 | vm.stopBroadcast(); 55 | 56 | console.log("PSM3 deployed at:", psm); 57 | } 58 | 59 | } 60 | 61 | contract DeployOptimism is Script { 62 | 63 | function run() external { 64 | vm.createSelectFork(getChain("optimism").rpcUrl); 65 | 66 | console.log("Deploying PSM..."); 67 | 68 | vm.startBroadcast(); 69 | 70 | address psm = PSM3Deploy.deploy({ 71 | owner : Optimism.SPARK_EXECUTOR, 72 | usdc : Optimism.USDC, 73 | usds : Optimism.USDS, 74 | susds : Optimism.SUSDS, 75 | rateProvider : Optimism.SSR_AUTH_ORACLE 76 | }); 77 | 78 | vm.stopBroadcast(); 79 | 80 | console.log("PSM3 deployed at:", psm); 81 | } 82 | 83 | } 84 | 85 | contract DeployUnichain is Script { 86 | 87 | function run() external { 88 | vm.createSelectFork(vm.envString("UNICHAIN_RPC_URL")); 89 | 90 | console.log("Deploying PSM..."); 91 | 92 | vm.startBroadcast(); 93 | 94 | address psm = PSM3Deploy.deploy({ 95 | owner : Unichain.SPARK_EXECUTOR, 96 | usdc : Unichain.USDC, 97 | usds : Unichain.USDS, 98 | susds : Unichain.SUSDS, 99 | rateProvider : Unichain.SSR_AUTH_ORACLE 100 | }); 101 | 102 | vm.stopBroadcast(); 103 | 104 | console.log("PSM3 deployed at:", psm); 105 | } 106 | 107 | } 108 | 109 | -------------------------------------------------------------------------------- /src/PSM3.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { IERC20 } from "erc20-helpers/interfaces/IERC20.sol"; 5 | 6 | import { SafeERC20 } from "erc20-helpers/SafeERC20.sol"; 7 | 8 | import { Ownable } from "openzeppelin-contracts/contracts/access/Ownable.sol"; 9 | import { Math } from "openzeppelin-contracts/contracts/utils/math/Math.sol"; 10 | 11 | import { IPSM3 } from "src/interfaces/IPSM3.sol"; 12 | import { IRateProviderLike } from "src/interfaces/IRateProviderLike.sol"; 13 | 14 | /* 15 | ███████╗██████╗ █████╗ ██████╗ ██╗ ██╗ ██████╗ ███████╗███╗ ███╗ 16 | ██╔════╝██╔══██╗██╔══██╗██╔══██╗██║ ██╔╝ ██╔══██╗██╔════╝████╗ ████║ 17 | ███████╗██████╔╝███████║██████╔╝█████╔╝ ██████╔╝███████╗██╔████╔██║ 18 | ╚════██║██╔═══╝ ██╔══██║██╔══██╗██╔═██╗ ██╔═══╝ ╚════██║██║╚██╔╝██║ 19 | ███████║██║ ██║ ██║██║ ██║██║ ██╗ ██║ ███████║██║ ╚═╝ ██║ 20 | ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ 21 | */ 22 | 23 | contract PSM3 is IPSM3, Ownable { 24 | 25 | using SafeERC20 for IERC20; 26 | 27 | uint256 internal immutable _usdcPrecision; 28 | uint256 internal immutable _usdsPrecision; 29 | uint256 internal immutable _susdsPrecision; 30 | 31 | IERC20 public override immutable usdc; 32 | IERC20 public override immutable usds; 33 | IERC20 public override immutable susds; 34 | 35 | address public override immutable rateProvider; 36 | 37 | address public override pocket; 38 | 39 | uint256 public override totalShares; 40 | 41 | mapping(address user => uint256 shares) public override shares; 42 | 43 | constructor( 44 | address owner_, 45 | address usdc_, 46 | address usds_, 47 | address susds_, 48 | address rateProvider_ 49 | ) 50 | Ownable(owner_) 51 | { 52 | require(usdc_ != address(0), "PSM3/invalid-usdc"); 53 | require(usds_ != address(0), "PSM3/invalid-usds"); 54 | require(susds_ != address(0), "PSM3/invalid-susds"); 55 | require(rateProvider_ != address(0), "PSM3/invalid-rateProvider"); 56 | 57 | require(usdc_ != usds_, "PSM3/usdc-usds-same"); 58 | require(usdc_ != susds_, "PSM3/usdc-susds-same"); 59 | require(usds_ != susds_, "PSM3/usds-susds-same"); 60 | 61 | usdc = IERC20(usdc_); 62 | usds = IERC20(usds_); 63 | susds = IERC20(susds_); 64 | 65 | rateProvider = rateProvider_; 66 | pocket = address(this); 67 | 68 | require( 69 | IRateProviderLike(rateProvider_).getConversionRate() != 0, 70 | "PSM3/rate-provider-returns-zero" 71 | ); 72 | 73 | _usdcPrecision = 10 ** IERC20(usdc_).decimals(); 74 | _usdsPrecision = 10 ** IERC20(usds_).decimals(); 75 | _susdsPrecision = 10 ** IERC20(susds_).decimals(); 76 | 77 | // Necessary to ensure rounding works as expected 78 | require(_usdcPrecision <= 1e18, "PSM3/usdc-precision-too-high"); 79 | require(_usdsPrecision <= 1e18, "PSM3/usds-precision-too-high"); 80 | } 81 | 82 | /**********************************************************************************************/ 83 | /*** Owner functions ***/ 84 | /**********************************************************************************************/ 85 | 86 | function setPocket(address newPocket) external override onlyOwner { 87 | require(newPocket != address(0), "PSM3/invalid-pocket"); 88 | 89 | address pocket_ = pocket; 90 | 91 | require(newPocket != pocket_, "PSM3/same-pocket"); 92 | 93 | uint256 amountToTransfer = usdc.balanceOf(pocket_); 94 | 95 | if (pocket_ == address(this)) { 96 | usdc.safeTransfer(newPocket, amountToTransfer); 97 | } else { 98 | usdc.safeTransferFrom(pocket_, newPocket, amountToTransfer); 99 | } 100 | 101 | pocket = newPocket; 102 | 103 | emit PocketSet(pocket_, newPocket, amountToTransfer); 104 | } 105 | 106 | /**********************************************************************************************/ 107 | /*** Swap functions ***/ 108 | /**********************************************************************************************/ 109 | 110 | function swapExactIn( 111 | address assetIn, 112 | address assetOut, 113 | uint256 amountIn, 114 | uint256 minAmountOut, 115 | address receiver, 116 | uint256 referralCode 117 | ) 118 | external override returns (uint256 amountOut) 119 | { 120 | require(amountIn != 0, "PSM3/invalid-amountIn"); 121 | require(receiver != address(0), "PSM3/invalid-receiver"); 122 | 123 | amountOut = previewSwapExactIn(assetIn, assetOut, amountIn); 124 | 125 | require(amountOut >= minAmountOut, "PSM3/amountOut-too-low"); 126 | 127 | _pullAsset(assetIn, amountIn); 128 | _pushAsset(assetOut, receiver, amountOut); 129 | 130 | emit Swap(assetIn, assetOut, msg.sender, receiver, amountIn, amountOut, referralCode); 131 | } 132 | 133 | function swapExactOut( 134 | address assetIn, 135 | address assetOut, 136 | uint256 amountOut, 137 | uint256 maxAmountIn, 138 | address receiver, 139 | uint256 referralCode 140 | ) 141 | external override returns (uint256 amountIn) 142 | { 143 | require(amountOut != 0, "PSM3/invalid-amountOut"); 144 | require(receiver != address(0), "PSM3/invalid-receiver"); 145 | 146 | amountIn = previewSwapExactOut(assetIn, assetOut, amountOut); 147 | 148 | require(amountIn <= maxAmountIn, "PSM3/amountIn-too-high"); 149 | 150 | _pullAsset(assetIn, amountIn); 151 | _pushAsset(assetOut, receiver, amountOut); 152 | 153 | emit Swap(assetIn, assetOut, msg.sender, receiver, amountIn, amountOut, referralCode); 154 | } 155 | 156 | /**********************************************************************************************/ 157 | /*** Liquidity provision functions ***/ 158 | /**********************************************************************************************/ 159 | 160 | function deposit(address asset, address receiver, uint256 assetsToDeposit) 161 | external override returns (uint256 newShares) 162 | { 163 | require(assetsToDeposit != 0, "PSM3/invalid-amount"); 164 | 165 | newShares = previewDeposit(asset, assetsToDeposit); 166 | 167 | shares[receiver] += newShares; 168 | totalShares += newShares; 169 | 170 | _pullAsset(asset, assetsToDeposit); 171 | 172 | emit Deposit(asset, msg.sender, receiver, assetsToDeposit, newShares); 173 | } 174 | 175 | function withdraw(address asset, address receiver, uint256 maxAssetsToWithdraw) 176 | external override returns (uint256 assetsWithdrawn) 177 | { 178 | require(maxAssetsToWithdraw != 0, "PSM3/invalid-amount"); 179 | 180 | uint256 sharesToBurn; 181 | 182 | ( sharesToBurn, assetsWithdrawn ) = previewWithdraw(asset, maxAssetsToWithdraw); 183 | 184 | // `previewWithdraw` ensures that `sharesToBurn` <= `shares[msg.sender]` 185 | unchecked { 186 | shares[msg.sender] -= sharesToBurn; 187 | totalShares -= sharesToBurn; 188 | } 189 | 190 | _pushAsset(asset, receiver, assetsWithdrawn); 191 | 192 | emit Withdraw(asset, msg.sender, receiver, assetsWithdrawn, sharesToBurn); 193 | } 194 | 195 | /**********************************************************************************************/ 196 | /*** Deposit/withdraw preview functions ***/ 197 | /**********************************************************************************************/ 198 | 199 | function previewDeposit(address asset, uint256 assetsToDeposit) 200 | public view override returns (uint256) 201 | { 202 | // Convert amount to 1e18 precision denominated in value of USD then convert to shares. 203 | // NOTE: Don't need to check valid asset here since `_getAssetValue` will revert if invalid 204 | return convertToShares(_getAssetValue(asset, assetsToDeposit, false)); // Round down 205 | } 206 | 207 | function previewWithdraw(address asset, uint256 maxAssetsToWithdraw) 208 | public view override returns (uint256 sharesToBurn, uint256 assetsWithdrawn) 209 | { 210 | require(_isValidAsset(asset), "PSM3/invalid-asset"); 211 | 212 | uint256 assetBalance = IERC20(asset).balanceOf(_getAssetCustodian(asset)); 213 | 214 | assetsWithdrawn = assetBalance < maxAssetsToWithdraw 215 | ? assetBalance 216 | : maxAssetsToWithdraw; 217 | 218 | // Get shares to burn, rounding up for both calculations 219 | sharesToBurn = _convertToSharesRoundUp(_getAssetValue(asset, assetsWithdrawn, true)); 220 | 221 | uint256 userShares = shares[msg.sender]; 222 | 223 | if (sharesToBurn > userShares) { 224 | assetsWithdrawn = convertToAssets(asset, userShares); 225 | sharesToBurn = userShares; 226 | } 227 | } 228 | 229 | /**********************************************************************************************/ 230 | /*** Swap preview functions ***/ 231 | /**********************************************************************************************/ 232 | 233 | function previewSwapExactIn(address assetIn, address assetOut, uint256 amountIn) 234 | public view override returns (uint256 amountOut) 235 | { 236 | // Round down to get amountOut 237 | amountOut = _getSwapQuote(assetIn, assetOut, amountIn, false); 238 | } 239 | 240 | function previewSwapExactOut(address assetIn, address assetOut, uint256 amountOut) 241 | public view override returns (uint256 amountIn) 242 | { 243 | // Round up to get amountIn 244 | amountIn = _getSwapQuote(assetOut, assetIn, amountOut, true); 245 | } 246 | 247 | /**********************************************************************************************/ 248 | /*** Conversion functions ***/ 249 | /**********************************************************************************************/ 250 | 251 | function convertToAssets(address asset, uint256 numShares) 252 | public view override returns (uint256) 253 | { 254 | require(_isValidAsset(asset), "PSM3/invalid-asset"); 255 | 256 | uint256 assetValue = convertToAssetValue(numShares); 257 | 258 | if (asset == address(usdc)) return assetValue * _usdcPrecision / 1e18; 259 | else if (asset == address(usds)) return assetValue * _usdsPrecision / 1e18; 260 | 261 | // NOTE: Multiplying by 1e27 and dividing by 1e18 cancels to 1e9 in numerator 262 | return assetValue 263 | * 1e9 264 | * _susdsPrecision 265 | / IRateProviderLike(rateProvider).getConversionRate(); 266 | } 267 | 268 | function convertToAssetValue(uint256 numShares) public view override returns (uint256) { 269 | uint256 totalShares_ = totalShares; 270 | 271 | if (totalShares_ != 0) { 272 | return numShares * totalAssets() / totalShares_; 273 | } 274 | return numShares; 275 | } 276 | 277 | function convertToShares(uint256 assetValue) public view override returns (uint256) { 278 | uint256 totalAssets_ = totalAssets(); 279 | if (totalAssets_ != 0) { 280 | return assetValue * totalShares / totalAssets_; 281 | } 282 | return assetValue; 283 | } 284 | 285 | function convertToShares(address asset, uint256 assets) public view override returns (uint256) { 286 | require(_isValidAsset(asset), "PSM3/invalid-asset"); 287 | return convertToShares(_getAssetValue(asset, assets, false)); // Round down 288 | } 289 | 290 | /**********************************************************************************************/ 291 | /*** Asset value functions ***/ 292 | /**********************************************************************************************/ 293 | 294 | function totalAssets() public view override returns (uint256) { 295 | return _getUsdcValue(usdc.balanceOf(pocket)) 296 | + _getUsdsValue(usds.balanceOf(address(this))) 297 | + _getSUsdsValue(susds.balanceOf(address(this)), false); // Round down 298 | } 299 | 300 | /**********************************************************************************************/ 301 | /*** Internal valuation functions (deposit/withdraw) ***/ 302 | /**********************************************************************************************/ 303 | 304 | function _getAssetValue(address asset, uint256 amount, bool roundUp) internal view returns (uint256) { 305 | if (asset == address(usdc)) return _getUsdcValue(amount); 306 | else if (asset == address(usds)) return _getUsdsValue(amount); 307 | else if (asset == address(susds)) return _getSUsdsValue(amount, roundUp); 308 | else revert("PSM3/invalid-asset-for-value"); 309 | } 310 | 311 | function _getUsdcValue(uint256 amount) internal view returns (uint256) { 312 | return amount * 1e18 / _usdcPrecision; 313 | } 314 | 315 | function _getUsdsValue(uint256 amount) internal view returns (uint256) { 316 | return amount * 1e18 / _usdsPrecision; 317 | } 318 | 319 | function _getSUsdsValue(uint256 amount, bool roundUp) internal view returns (uint256) { 320 | // NOTE: Multiplying by 1e18 and dividing by 1e27 cancels to 1e9 in denominator 321 | if (!roundUp) return amount 322 | * IRateProviderLike(rateProvider).getConversionRate() 323 | / 1e9 324 | / _susdsPrecision; 325 | 326 | return Math.ceilDiv( 327 | Math.ceilDiv(amount * IRateProviderLike(rateProvider).getConversionRate(), 1e9), 328 | _susdsPrecision 329 | ); 330 | } 331 | 332 | /**********************************************************************************************/ 333 | /*** Internal preview functions (swaps) ***/ 334 | /**********************************************************************************************/ 335 | 336 | function _getSwapQuote(address asset, address quoteAsset, uint256 amount, bool roundUp) 337 | internal view returns (uint256 quoteAmount) 338 | { 339 | if (asset == address(usdc)) { 340 | if (quoteAsset == address(usds)) return _convertOneToOne(amount, _usdcPrecision, _usdsPrecision, roundUp); 341 | else if (quoteAsset == address(susds)) return _convertToSUsds(amount, _usdcPrecision, roundUp); 342 | } 343 | 344 | else if (asset == address(usds)) { 345 | if (quoteAsset == address(usdc)) return _convertOneToOne(amount, _usdsPrecision, _usdcPrecision, roundUp); 346 | else if (quoteAsset == address(susds)) return _convertToSUsds(amount, _usdsPrecision, roundUp); 347 | } 348 | 349 | else if (asset == address(susds)) { 350 | if (quoteAsset == address(usdc)) return _convertFromSUsds(amount, _usdcPrecision, roundUp); 351 | else if (quoteAsset == address(usds)) return _convertFromSUsds(amount, _usdsPrecision, roundUp); 352 | } 353 | 354 | revert("PSM3/invalid-asset"); 355 | } 356 | 357 | function _convertToSUsds(uint256 amount, uint256 assetPrecision, bool roundUp) 358 | internal view returns (uint256) 359 | { 360 | uint256 rate = IRateProviderLike(rateProvider).getConversionRate(); 361 | 362 | if (!roundUp) return amount * 1e27 / rate * _susdsPrecision / assetPrecision; 363 | 364 | return Math.ceilDiv( 365 | Math.ceilDiv(amount * 1e27, rate) * _susdsPrecision, 366 | assetPrecision 367 | ); 368 | } 369 | 370 | function _convertFromSUsds(uint256 amount, uint256 assetPrecision, bool roundUp) 371 | internal view returns (uint256) 372 | { 373 | uint256 rate = IRateProviderLike(rateProvider).getConversionRate(); 374 | 375 | if (!roundUp) return amount * rate / 1e27 * assetPrecision / _susdsPrecision; 376 | 377 | return Math.ceilDiv( 378 | Math.ceilDiv(amount * rate, 1e27) * assetPrecision, 379 | _susdsPrecision 380 | ); 381 | } 382 | 383 | function _convertOneToOne( 384 | uint256 amount, 385 | uint256 assetPrecision, 386 | uint256 convertAssetPrecision, 387 | bool roundUp 388 | ) 389 | internal pure returns (uint256) 390 | { 391 | if (!roundUp) return amount * convertAssetPrecision / assetPrecision; 392 | 393 | return Math.ceilDiv(amount * convertAssetPrecision, assetPrecision); 394 | } 395 | 396 | /**********************************************************************************************/ 397 | /*** Internal helper functions ***/ 398 | /**********************************************************************************************/ 399 | 400 | function _convertToSharesRoundUp(uint256 assetValue) internal view returns (uint256) { 401 | uint256 totalValue = totalAssets(); 402 | if (totalValue != 0) { 403 | return Math.ceilDiv(assetValue * totalShares, totalValue); 404 | } 405 | return assetValue; 406 | } 407 | 408 | function _isValidAsset(address asset) internal view returns (bool) { 409 | return asset == address(usdc) || asset == address(usds) || asset == address(susds); 410 | } 411 | 412 | function _getAssetCustodian(address asset) internal view returns (address custodian) { 413 | custodian = asset == address(usdc) ? pocket : address(this); 414 | } 415 | 416 | function _pullAsset(address asset, uint256 amount) internal { 417 | IERC20(asset).safeTransferFrom(msg.sender, _getAssetCustodian(asset), amount); 418 | } 419 | 420 | function _pushAsset(address asset, address receiver, uint256 amount) internal { 421 | if (asset == address(usdc) && pocket != address(this)) { 422 | usdc.safeTransferFrom(pocket, receiver, amount); 423 | } else { 424 | IERC20(asset).safeTransfer(receiver, amount); 425 | } 426 | } 427 | 428 | } 429 | -------------------------------------------------------------------------------- /src/interfaces/IPSM3.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { IERC20 } from "erc20-helpers/interfaces/IERC20.sol"; 5 | 6 | interface IPSM3 { 7 | 8 | /**********************************************************************************************/ 9 | /*** Events ***/ 10 | /**********************************************************************************************/ 11 | 12 | /** 13 | * @dev Emitted when a new pocket is set in the PSM, transferring the balance of USDC. 14 | * of the old pocket to the new pocket. 15 | * @param oldPocket Address of the old `pocket`. 16 | * @param newPocket Address of the new `pocket`. 17 | * @param amountTransferred Amount of USDC transferred from the old pocket to the new pocket. 18 | */ 19 | event PocketSet( 20 | address indexed oldPocket, 21 | address indexed newPocket, 22 | uint256 amountTransferred 23 | ); 24 | 25 | /** 26 | * @dev Emitted when an asset is swapped in the PSM. 27 | * @param assetIn Address of the asset swapped in. 28 | * @param assetOut Address of the asset swapped out. 29 | * @param sender Address of the sender of the swap. 30 | * @param receiver Address of the receiver of the swap. 31 | * @param amountIn Amount of the asset swapped in. 32 | * @param amountOut Amount of the asset swapped out. 33 | * @param referralCode Referral code for the swap. 34 | */ 35 | event Swap( 36 | address indexed assetIn, 37 | address indexed assetOut, 38 | address sender, 39 | address indexed receiver, 40 | uint256 amountIn, 41 | uint256 amountOut, 42 | uint256 referralCode 43 | ); 44 | 45 | /** 46 | * @dev Emitted when an asset is deposited into the PSM. 47 | * @param asset Address of the asset deposited. 48 | * @param user Address of the user that deposited the asset. 49 | * @param receiver Address of the receiver of the resulting shares from the deposit. 50 | * @param assetsDeposited Amount of the asset deposited. 51 | * @param sharesMinted Number of shares minted to the user. 52 | */ 53 | event Deposit( 54 | address indexed asset, 55 | address indexed user, 56 | address indexed receiver, 57 | uint256 assetsDeposited, 58 | uint256 sharesMinted 59 | ); 60 | 61 | /** 62 | * @dev Emitted when an asset is withdrawn from the PSM. 63 | * @param asset Address of the asset withdrawn. 64 | * @param user Address of the user that withdrew the asset. 65 | * @param receiver Address of the receiver of the withdrawn assets. 66 | * @param assetsWithdrawn Amount of the asset withdrawn. 67 | * @param sharesBurned Number of shares burned from the user. 68 | */ 69 | event Withdraw( 70 | address indexed asset, 71 | address indexed user, 72 | address indexed receiver, 73 | uint256 assetsWithdrawn, 74 | uint256 sharesBurned 75 | ); 76 | 77 | /**********************************************************************************************/ 78 | /*** State variables and immutables ***/ 79 | /**********************************************************************************************/ 80 | 81 | /** 82 | * @dev Returns the IERC20 interface representing USDC. 83 | * @return The IERC20 interface of USDC. 84 | */ 85 | function usdc() external view returns (IERC20); 86 | 87 | /** 88 | * @dev Returns the IERC20 interface representing USDS. 89 | * @return The IERC20 interface of USDS. 90 | */ 91 | function usds() external view returns (IERC20); 92 | 93 | /** 94 | * @dev Returns the IERC20 interface representing sUSDS. This asset is the yield-bearing 95 | * asset in the PSM. The value of this asset is queried from the rate provider. 96 | * @return The IERC20 interface of sUSDS. 97 | */ 98 | function susds() external view returns (IERC20); 99 | 100 | /** 101 | * @dev Returns the address of the pocket, an address that holds custody of USDC in the 102 | * PSM and can deploy it to yield-bearing strategies. Settable by the owner. 103 | * @return The address of the pocket. 104 | */ 105 | function pocket() external view returns (address); 106 | 107 | /** 108 | * @dev Returns the address of the rate provider, a contract that provides the conversion 109 | * rate between sUSDS and the other two assets in the PSM (e.g., sUSDS to USD). 110 | * @return The address of the rate provider. 111 | */ 112 | function rateProvider() external view returns (address); 113 | 114 | /** 115 | * @dev Returns the total number of shares in the PSM. Shares represent ownership of the 116 | * assets in the PSM and can be converted to assets at any time. 117 | * @return The total number of shares. 118 | */ 119 | function totalShares() external view returns (uint256); 120 | 121 | /** 122 | * @dev Returns the number of shares held by a specific user. 123 | * @param user The address of the user. 124 | * @return The number of shares held by the user. 125 | */ 126 | function shares(address user) external view returns (uint256); 127 | 128 | /**********************************************************************************************/ 129 | /*** Owner functions ***/ 130 | /**********************************************************************************************/ 131 | 132 | /** 133 | * @dev Sets the address of the pocket, an address that holds custody of USDC in the PSM 134 | * and can deploy it to yield-bearing strategies. This function will transfer the 135 | * balance of USDC in the PSM to the new pocket. Callable only by the owner. 136 | * @param newPocket Address of the new pocket. 137 | */ 138 | function setPocket(address newPocket) external; 139 | 140 | /**********************************************************************************************/ 141 | /*** Swap functions ***/ 142 | /**********************************************************************************************/ 143 | 144 | /** 145 | * @dev Swaps a specified amount of assetIn for assetOut in the PSM. The amount swapped is 146 | * converted based on the current value of the two assets used in the swap. This 147 | * function will revert if there is not enough balance in the PSM to facilitate the 148 | * swap. Both assets must be supported in the PSM in order to succeed. 149 | * @param assetIn Address of the ERC-20 asset to swap in. 150 | * @param assetOut Address of the ERC-20 asset to swap out. 151 | * @param amountIn Amount of the asset to swap in. 152 | * @param minAmountOut Minimum amount of the asset to receive. 153 | * @param receiver Address of the receiver of the swapped assets. 154 | * @param referralCode Referral code for the swap. 155 | * @return amountOut Resulting amount of the asset that will be received in the swap. 156 | */ 157 | function swapExactIn( 158 | address assetIn, 159 | address assetOut, 160 | uint256 amountIn, 161 | uint256 minAmountOut, 162 | address receiver, 163 | uint256 referralCode 164 | ) external returns (uint256 amountOut); 165 | 166 | /** 167 | * @dev Swaps a derived amount of assetIn for a specific amount of assetOut in the PSM. The 168 | * amount swapped is converted based on the current value of the two assets used in 169 | * the swap. This function will revert if there is not enough balance in the PSM to 170 | * facilitate the swap. Both assets must be supported in the PSM in order to succeed. 171 | * @param assetIn Address of the ERC-20 asset to swap in. 172 | * @param assetOut Address of the ERC-20 asset to swap out. 173 | * @param amountOut Amount of the asset to receive from the swap. 174 | * @param maxAmountIn Max amount of the asset to use for the swap. 175 | * @param receiver Address of the receiver of the swapped assets. 176 | * @param referralCode Referral code for the swap. 177 | * @return amountIn Resulting amount of the asset swapped in. 178 | */ 179 | function swapExactOut( 180 | address assetIn, 181 | address assetOut, 182 | uint256 amountOut, 183 | uint256 maxAmountIn, 184 | address receiver, 185 | uint256 referralCode 186 | ) external returns (uint256 amountIn); 187 | 188 | /**********************************************************************************************/ 189 | /*** Liquidity provision functions ***/ 190 | /**********************************************************************************************/ 191 | 192 | /** 193 | * @dev Deposits an amount of a given asset into the PSM. Must be one of the supported 194 | * assets in order to succeed. The amount deposited is converted to shares based on 195 | * the current exchange rate. 196 | * @param asset Address of the ERC-20 asset to deposit. 197 | * @param receiver Address of the receiver of the resulting shares from the deposit. 198 | * @param assetsToDeposit Amount of the asset to deposit into the PSM. 199 | * @return newShares Number of shares minted to the user. 200 | */ 201 | function deposit(address asset, address receiver, uint256 assetsToDeposit) 202 | external returns (uint256 newShares); 203 | 204 | /** 205 | * @dev Withdraws an amount of a given asset from the PSM up to `maxAssetsToWithdraw`. 206 | * Must be one of the supported assets in order to succeed. The amount withdrawn is 207 | * the minimum of the balance of the PSM, the max amount, and the max amount of assets 208 | * that the user's shares can be converted to. 209 | * @param asset Address of the ERC-20 asset to withdraw. 210 | * @param receiver Address of the receiver of the withdrawn assets. 211 | * @param maxAssetsToWithdraw Max amount that the user is willing to withdraw. 212 | * @return assetsWithdrawn Resulting amount of the asset withdrawn from the PSM. 213 | */ 214 | function withdraw( 215 | address asset, 216 | address receiver, 217 | uint256 maxAssetsToWithdraw 218 | ) external returns (uint256 assetsWithdrawn); 219 | 220 | /**********************************************************************************************/ 221 | /*** Deposit/withdraw preview functions ***/ 222 | /**********************************************************************************************/ 223 | 224 | /** 225 | * @dev View function that returns the exact number of shares that would be minted for a 226 | * given asset and amount to deposit. 227 | * @param asset Address of the ERC-20 asset to deposit. 228 | * @param assets Amount of the asset to deposit into the PSM. 229 | * @return shares Number of shares to be minted to the user. 230 | */ 231 | function previewDeposit(address asset, uint256 assets) external view returns (uint256 shares); 232 | 233 | /** 234 | * @dev View function that returns the exact number of assets that would be withdrawn and 235 | * corresponding shares that would be burned in a withdrawal for a given asset and max 236 | * withdraw amount. The amount returned is the minimum of the balance of the PSM, 237 | * the max amount, and the max amount of assets that the user's shares 238 | * can be converted to. 239 | * @param asset Address of the ERC-20 asset to withdraw. 240 | * @param maxAssetsToWithdraw Max amount that the user is willing to withdraw. 241 | * @return sharesToBurn Number of shares that would be burned in the withdrawal. 242 | * @return assetsWithdrawn Resulting amount of the asset withdrawn from the PSM. 243 | */ 244 | function previewWithdraw(address asset, uint256 maxAssetsToWithdraw) 245 | external view returns (uint256 sharesToBurn, uint256 assetsWithdrawn); 246 | 247 | /**********************************************************************************************/ 248 | /*** Swap preview functions ***/ 249 | /**********************************************************************************************/ 250 | 251 | /** 252 | * @dev View function that returns the exact amount of assetOut that would be received for a 253 | * given amount of assetIn in a swap. The amount returned is converted based on the 254 | * current value of the two assets used in the swap. 255 | * @param assetIn Address of the ERC-20 asset to swap in. 256 | * @param assetOut Address of the ERC-20 asset to swap out. 257 | * @param amountIn Amount of the asset to swap in. 258 | * @return amountOut Amount of the asset that will be received in the swap. 259 | */ 260 | function previewSwapExactIn(address assetIn, address assetOut, uint256 amountIn) 261 | external view returns (uint256 amountOut); 262 | 263 | /** 264 | * @dev View function that returns the exact amount of assetIn that would be required to 265 | * receive a given amount of assetOut in a swap. The amount returned is 266 | * converted based on the current value of the two assets used in the swap. 267 | * @param assetIn Address of the ERC-20 asset to swap in. 268 | * @param assetOut Address of the ERC-20 asset to swap out. 269 | * @param amountOut Amount of the asset to receive from the swap. 270 | * @return amountIn Amount of the asset that is required to receive amountOut. 271 | */ 272 | function previewSwapExactOut(address assetIn, address assetOut, uint256 amountOut) 273 | external view returns (uint256 amountIn); 274 | 275 | /**********************************************************************************************/ 276 | /*** Conversion functions ***/ 277 | /**********************************************************************************************/ 278 | 279 | /** 280 | * @dev View function that converts an amount of a given shares to the equivalent amount of 281 | * assets for a specified asset. 282 | * @param asset Address of the asset to use to convert. 283 | * @param numShares Number of shares to convert to assets. 284 | * @return assets Value of assets in asset-native units. 285 | */ 286 | function convertToAssets(address asset, uint256 numShares) external view returns (uint256); 287 | 288 | /** 289 | * @dev View function that converts an amount of a given shares to the equivalent 290 | * amount of assetValue. 291 | * @param numShares Number of shares to convert to assetValue. 292 | * @return assetValue Value of assets in USDC denominated in 18 decimals. 293 | */ 294 | function convertToAssetValue(uint256 numShares) external view returns (uint256); 295 | 296 | /** 297 | * @dev View function that converts an amount of assetValue (18 decimal value denominated in 298 | * USDC and USDS) to shares in the PSM based on the current exchange rate. 299 | * Note that this rounds down on calculation so is intended to be used for quoting the 300 | * current exchange rate. 301 | * @param assetValue 18 decimal value denominated in USDC (e.g., 1e6 USDC = 1e18) 302 | * @return shares Number of shares that the assetValue is equivalent to. 303 | */ 304 | function convertToShares(uint256 assetValue) external view returns (uint256); 305 | 306 | /** 307 | * @dev View function that converts an amount of a given asset to shares in the PSM based 308 | * on the current exchange rate. Note that this rounds down on calculation so is 309 | * intended to be used for quoting the current exchange rate. 310 | * @param asset Address of the ERC-20 asset to convert to shares. 311 | * @param assets Amount of assets in asset-native units. 312 | * @return shares Number of shares that the assetValue is equivalent to. 313 | */ 314 | function convertToShares(address asset, uint256 assets) external view returns (uint256); 315 | 316 | /**********************************************************************************************/ 317 | /*** Asset value functions ***/ 318 | /**********************************************************************************************/ 319 | 320 | /** 321 | * @dev View function that returns the total value of the balance of all assets in the PSM 322 | * converted to USDC/USDS terms denominated in 18 decimal precision. 323 | */ 324 | function totalAssets() external view returns (uint256); 325 | 326 | } 327 | -------------------------------------------------------------------------------- /src/interfaces/IRateProviderLike.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | interface IRateProviderLike { 5 | function getConversionRate() external view returns (uint256); 6 | } 7 | -------------------------------------------------------------------------------- /test/PSMTestBase.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { PSM3 } from "src/PSM3.sol"; 7 | 8 | import { IRateProviderLike } from "src/interfaces/IRateProviderLike.sol"; 9 | 10 | import { MockERC20 } from "erc20-helpers/MockERC20.sol"; 11 | 12 | import { MockRateProvider } from "test/mocks/MockRateProvider.sol"; 13 | 14 | contract PSMTestBase is Test { 15 | 16 | address public owner = makeAddr("owner"); 17 | address public pocket = makeAddr("pocket"); 18 | 19 | PSM3 public psm; 20 | 21 | MockERC20 public usdc; 22 | MockERC20 public usds; 23 | MockERC20 public susds; 24 | 25 | IRateProviderLike public rateProvider; // Can be overridden by ssrOracle using same interface 26 | 27 | MockRateProvider public mockRateProvider; // Interface used for mocking 28 | 29 | modifier assertAtomicPsmValueDoesNotChange { 30 | uint256 beforeValue = _getPsmValue(); 31 | _; 32 | assertEq(_getPsmValue(), beforeValue); 33 | } 34 | 35 | // 1,000,000,000,000 of each token 36 | uint256 public constant USDS_TOKEN_MAX = 1e30; 37 | uint256 public constant SUSDS_TOKEN_MAX = 1e30; 38 | uint256 public constant USDC_TOKEN_MAX = 1e18; 39 | 40 | function setUp() public virtual { 41 | usdc = new MockERC20("usdc", "usdc", 6); 42 | usds = new MockERC20("usds", "usds", 18); 43 | susds = new MockERC20("susds", "susds", 18); 44 | 45 | mockRateProvider = new MockRateProvider(); 46 | 47 | // NOTE: Using 1.25 for easy two way conversions 48 | mockRateProvider.__setConversionRate(1.25e27); 49 | 50 | rateProvider = IRateProviderLike(address(mockRateProvider)); 51 | 52 | psm = new PSM3(owner, address(usdc), address(usds), address(susds), address(rateProvider)); 53 | 54 | vm.prank(owner); 55 | psm.setPocket(pocket); 56 | 57 | vm.prank(pocket); 58 | usdc.approve(address(psm), type(uint256).max); 59 | 60 | vm.label(address(usds), "USDS"); 61 | vm.label(address(usdc), "USDC"); 62 | vm.label(address(susds), "sUSDS"); 63 | } 64 | 65 | function _getPsmValue() internal view returns (uint256) { 66 | return (susds.balanceOf(address(psm)) * rateProvider.getConversionRate() / 1e27) 67 | + usdc.balanceOf(psm.pocket()) * 1e12 68 | + usds.balanceOf(address(psm)); 69 | } 70 | 71 | function _deposit(address asset, address user, uint256 amount) internal { 72 | _deposit(asset, user, user, amount); 73 | } 74 | 75 | function _deposit(address asset, address user, address receiver, uint256 amount) internal { 76 | vm.startPrank(user); 77 | MockERC20(asset).mint(user, amount); 78 | MockERC20(asset).approve(address(psm), amount); 79 | psm.deposit(asset, receiver, amount); 80 | vm.stopPrank(); 81 | } 82 | 83 | function _withdraw(address asset, address user, uint256 amount) internal { 84 | _withdraw(asset, user, user, amount); 85 | } 86 | 87 | function _withdraw(address asset, address user, address receiver, uint256 amount) internal { 88 | vm.prank(user); 89 | psm.withdraw(asset, receiver, amount); 90 | vm.stopPrank(); 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /test/invariant/handlers/HandlerBase.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { CommonBase } from "forge-std/Base.sol"; 5 | import { console } from "forge-std/console.sol"; 6 | import { StdCheatsSafe } from "forge-std/StdCheats.sol"; 7 | import { stdMath } from "forge-std/StdMath.sol"; 8 | import { StdUtils } from "forge-std/StdUtils.sol"; 9 | 10 | import { PSM3 } from "src/PSM3.sol"; 11 | 12 | contract HandlerBase is CommonBase, StdCheatsSafe, StdUtils { 13 | 14 | PSM3 public psm; 15 | 16 | constructor(PSM3 psm_) { 17 | psm = psm_; 18 | } 19 | 20 | function _hash(uint256 number_, string memory salt) internal pure returns (uint256 hash_) { 21 | hash_ = uint256(keccak256(abi.encode(number_, salt))); 22 | } 23 | 24 | /**********************************************************************************************/ 25 | /*** Assertion helpers (copied from ds-test and modified to revert) ***/ 26 | /**********************************************************************************************/ 27 | 28 | function assertEq(uint256 a, uint256 b, string memory err) internal view { 29 | if (a != b) { 30 | console.log("Error: a == b not satisfied [uint256]"); 31 | console.log(" Left", a); 32 | console.log(" Right", b); 33 | revert(err); 34 | } 35 | } 36 | 37 | function assertGe(uint256 a, uint256 b, string memory err) internal view { 38 | if (a < b) { 39 | console.log("Error: a >= b not satisfied [uint256]"); 40 | console.log(" Left", a); 41 | console.log(" Right", b); 42 | revert(err); 43 | } 44 | } 45 | 46 | function assertLe(uint256 a, uint256 b, string memory err) internal view { 47 | if (a > b) { 48 | console.log("Error: a <= b not satisfied [uint256]"); 49 | console.log(" Left", a); 50 | console.log(" Right", b); 51 | revert(err); 52 | } 53 | } 54 | 55 | function assertApproxEqAbs(uint256 a, uint256 b, uint256 maxDelta, string memory err) 56 | internal view 57 | { 58 | uint256 delta = stdMath.delta(a, b); 59 | 60 | if (delta > maxDelta) { 61 | console.log("Error: a ~= b not satisfied [uint]"); 62 | console.log(" Left", a); 63 | console.log(" Right", b); 64 | console.log(" Max Delta", maxDelta); 65 | console.log(" Delta", delta); 66 | revert(err); 67 | } 68 | } 69 | 70 | function assertApproxEqRel( 71 | uint256 a, 72 | uint256 b, 73 | uint256 maxPercentDelta, // An 18 decimal fixed point number, where 1e18 == 100% 74 | string memory err 75 | ) internal virtual { 76 | // If the left is 0, right must be too. 77 | if (b == 0) return assertEq(a, b, string(abi.encodePacked("assertEq - ", err))); 78 | 79 | uint256 percentDelta = stdMath.percentDelta(a, b); 80 | 81 | if (percentDelta > maxPercentDelta) { 82 | console.log("Error: a ~= b not satisfied [uint]"); 83 | console.log(" Left", a); 84 | console.log(" Right", b); 85 | console.log(" Max % Delta [wad]", maxPercentDelta); 86 | console.log(" % Delta [wad]", percentDelta); 87 | revert(err); 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /test/invariant/handlers/LpHandler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { MockERC20 } from "erc20-helpers/MockERC20.sol"; 5 | 6 | import { HandlerBase, PSM3 } from "test/invariant/handlers/HandlerBase.sol"; 7 | 8 | contract LpHandler is HandlerBase { 9 | 10 | MockERC20[3] public assets; 11 | 12 | address[] public lps; 13 | 14 | uint256 public depositCount; 15 | uint256 public withdrawCount; 16 | 17 | mapping(address user => mapping(address asset => uint256 deposits)) public lpDeposits; 18 | mapping(address user => mapping(address asset => uint256 withdrawals)) public lpWithdrawals; 19 | 20 | constructor( 21 | PSM3 psm_, 22 | MockERC20 usdc, 23 | MockERC20 usds, 24 | MockERC20 susds, 25 | uint256 lpCount 26 | ) HandlerBase(psm_) { 27 | assets[0] = usdc; 28 | assets[1] = usds; 29 | assets[2] = susds; 30 | 31 | for (uint256 i = 0; i < lpCount; i++) { 32 | lps.push(makeAddr(string(abi.encodePacked("lp-", vm.toString(i))))); 33 | } 34 | } 35 | 36 | function _getAsset(uint256 indexSeed) internal view returns (MockERC20) { 37 | return assets[indexSeed % assets.length]; 38 | } 39 | 40 | function _getLP(uint256 indexSeed) internal view returns (address) { 41 | return lps[indexSeed % lps.length]; 42 | } 43 | 44 | function deposit(uint256 assetSeed, uint256 lpSeed, uint256 amount) public { 45 | // 1. Setup and bounds 46 | MockERC20 asset = _getAsset(assetSeed); 47 | address lp = _getLP(lpSeed); 48 | 49 | amount = _bound(amount, 1, 1e12 * 10 ** asset.decimals()); 50 | 51 | // 2. Cache starting state 52 | uint256 startingConversion = psm.convertToAssetValue(1e18); 53 | uint256 startingValue = psm.totalAssets(); 54 | 55 | // 3. Perform action against protocol 56 | vm.startPrank(lp); 57 | asset.mint(lp, amount); 58 | asset.approve(address(psm), amount); 59 | psm.deposit(address(asset), lp, amount); 60 | vm.stopPrank(); 61 | 62 | // 4. Update ghost variable(s) 63 | lpDeposits[lp][address(asset)] += amount; 64 | 65 | // 5. Perform action-specific assertions 66 | 67 | // Larger tolerance for rounding errors because of asset valuation changing 68 | assertApproxEqAbs( 69 | psm.convertToAssetValue(1e18), 70 | startingConversion, 71 | 1e12, 72 | "LpHandler/deposit/conversion-rate-change" 73 | ); 74 | 75 | // Exchange rate always increases, never decreases from rounding 76 | assertGe( 77 | psm.convertToAssetValue(1e18), 78 | startingConversion, 79 | "LpHandler/deposit/conversion-rate-decrease" 80 | ); 81 | 82 | assertGe( 83 | psm.totalAssets() + 1, 84 | startingValue, 85 | "LpHandler/deposit/psm-total-value-decrease" 86 | ); 87 | 88 | // 6. Update metrics tracking state 89 | depositCount++; 90 | } 91 | 92 | function withdraw(uint256 assetSeed, uint256 lpSeed, uint256 amount) public { 93 | // 1. Setup and bounds 94 | MockERC20 asset = _getAsset(assetSeed); 95 | address lp = _getLP(lpSeed); 96 | 97 | amount = _bound(amount, 1, 1e12 * 10 ** asset.decimals()); 98 | 99 | // 2. Cache starting state 100 | uint256 startingConversion = psm.convertToAssetValue(1e18); 101 | uint256 startingValue = psm.totalAssets(); 102 | 103 | // 3. Perform action against protocol 104 | vm.prank(lp); 105 | uint256 withdrawAmount = psm.withdraw(address(asset), lp, amount); 106 | vm.stopPrank(); 107 | 108 | // 4. Update ghost variable(s) 109 | lpWithdrawals[lp][address(asset)] += withdrawAmount; 110 | 111 | // 5. Perform action-specific assertions 112 | 113 | // Larger tolerance for rounding errors because of burning more shares on USDC withdraw 114 | assertApproxEqAbs( 115 | psm.convertToAssetValue(1e18), 116 | startingConversion, 117 | 1e12, 118 | "LpHandler/withdraw/conversion-rate-change" 119 | ); 120 | 121 | // Exchange rate always increases, never decreases from rounding 122 | assertGe( 123 | psm.convertToAssetValue(1e18), 124 | startingConversion, 125 | "LpHandler/withdraw/conversion-rate-decrease" 126 | ); 127 | 128 | assertLe( 129 | psm.totalAssets(), 130 | startingValue + 1, 131 | "LpHandler/withdraw/psm-total-value-increase" 132 | ); 133 | 134 | // 6. Update metrics tracking state 135 | withdrawCount++; 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /test/invariant/handlers/OwnerHandler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { MockERC20 } from "erc20-helpers/MockERC20.sol"; 5 | 6 | import { HandlerBase, PSM3 } from "test/invariant/handlers/HandlerBase.sol"; 7 | 8 | contract OwnerHandler is HandlerBase { 9 | 10 | MockERC20 public usdc; 11 | 12 | constructor(PSM3 psm_, MockERC20 usdc_) HandlerBase(psm_) { 13 | usdc = usdc_; 14 | } 15 | 16 | function setPocket(string memory salt) public { 17 | address newPocket = makeAddr(salt); 18 | 19 | // Avoid "same pocket" error 20 | if (newPocket == psm.pocket()) { 21 | newPocket = makeAddr(string(abi.encodePacked(salt, "salt"))); 22 | } 23 | 24 | // Assumption is made that the pocket will always infinite approve the PSM 25 | vm.prank(newPocket); 26 | usdc.approve(address(psm), type(uint256).max); 27 | 28 | uint256 oldPocketBalance = usdc.balanceOf(psm.pocket()); 29 | uint256 newPocketBalance = usdc.balanceOf(newPocket); 30 | uint256 totalAssets = psm.totalAssets(); 31 | uint256 startingConversion = psm.convertToAssetValue(1e18); 32 | 33 | address oldPocket = psm.pocket(); 34 | 35 | psm.setPocket(newPocket); 36 | 37 | // Old pocket should be cleared of USDC 38 | assertEq( 39 | usdc.balanceOf(oldPocket), 40 | 0, 41 | "OwnerHandler/old-pocket-balance" 42 | ); 43 | 44 | // New pocket should get full pocket balance 45 | assertEq( 46 | usdc.balanceOf(newPocket), 47 | newPocketBalance + oldPocketBalance, 48 | "OwnerHandler/new-pocket-balance" 49 | ); 50 | 51 | // Total assets should be exactly the same 52 | assertEq( 53 | psm.totalAssets(), 54 | totalAssets, 55 | "OwnerHandler/total-assets" 56 | ); 57 | 58 | // Conversion rate should be exactly the same 59 | assertEq( 60 | psm.convertToAssetValue(1e18), 61 | startingConversion, 62 | "OwnerHandler/starting-conversion" 63 | ); 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /test/invariant/handlers/RateSetterHandler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { HandlerBase, PSM3 } from "test/invariant/handlers/HandlerBase.sol"; 5 | 6 | import { MockRateProvider } from "test/mocks/MockRateProvider.sol"; 7 | 8 | contract RateSetterHandler is HandlerBase { 9 | 10 | uint256 public rate; 11 | 12 | MockRateProvider public rateProvider; 13 | 14 | uint256 public setRateCount; 15 | 16 | constructor(PSM3 psm_, address rateProvider_, uint256 initialRate) HandlerBase(psm_) { 17 | rateProvider = MockRateProvider(rateProvider_); 18 | rate = initialRate; 19 | } 20 | 21 | function setRate(uint256 rateIncrease) external { 22 | // 1. Setup and bounds 23 | 24 | // Increase the rate by up to 5% 25 | rate += _bound(rateIncrease, 0, 0.05e27); 26 | 27 | // 2. Cache starting state 28 | uint256 startingConversion = psm.convertToAssetValue(1e18); 29 | uint256 startingValue = psm.totalAssets(); 30 | 31 | // 3. Perform action against protocol 32 | rateProvider.__setConversionRate(rate); 33 | 34 | // 4. Perform action-specific assertions 35 | assertGe( 36 | psm.convertToAssetValue(1e18) + 1, 37 | startingConversion, 38 | "RateSetterHandler/setRate/conversion-rate-decrease" 39 | ); 40 | 41 | assertGe( 42 | psm.totalAssets() + 1, 43 | startingValue, 44 | "RateSetterHandler/setRate/psm-total-value-decrease" 45 | ); 46 | 47 | // 5. Update metrics tracking state 48 | setRateCount++; 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /test/invariant/handlers/SwapperHandler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { MockERC20 } from "erc20-helpers/MockERC20.sol"; 5 | 6 | import { HandlerBase, PSM3 } from "test/invariant/handlers/HandlerBase.sol"; 7 | 8 | import { IRateProviderLike } from "src/interfaces/IRateProviderLike.sol"; 9 | 10 | contract SwapperHandler is HandlerBase { 11 | 12 | MockERC20[3] public assets; 13 | 14 | address[] public swappers; 15 | 16 | IRateProviderLike public rateProvider; 17 | 18 | mapping(address user => mapping(address asset => uint256 deposits)) public swapsIn; 19 | mapping(address user => mapping(address asset => uint256 deposits)) public swapsOut; 20 | 21 | mapping(address user => uint256) public valueSwappedIn; 22 | mapping(address user => uint256) public valueSwappedOut; 23 | mapping(address user => uint256) public swapperSwapCount; 24 | 25 | // Used for assertions, assumption made that LpHandler is used with at least 1 LP. 26 | address public lp0; 27 | 28 | uint256 public swapCount; 29 | uint256 public zeroBalanceCount; 30 | 31 | constructor( 32 | PSM3 psm_, 33 | MockERC20 usdc, 34 | MockERC20 usds, 35 | MockERC20 susds, 36 | uint256 swapperCount 37 | ) HandlerBase(psm_) { 38 | assets[0] = usdc; 39 | assets[1] = usds; 40 | assets[2] = susds; 41 | 42 | rateProvider = IRateProviderLike(psm.rateProvider()); 43 | 44 | for (uint256 i = 0; i < swapperCount; i++) { 45 | swappers.push(makeAddr(string(abi.encodePacked("swapper-", vm.toString(i))))); 46 | } 47 | 48 | // Derive LP-0 address for assertion 49 | lp0 = makeAddr("lp-0"); 50 | } 51 | 52 | function _getAsset(uint256 indexSeed) internal view returns (MockERC20) { 53 | return assets[indexSeed % assets.length]; 54 | } 55 | 56 | function _getSwapper(uint256 indexSeed) internal view returns (address) { 57 | return swappers[indexSeed % swappers.length]; 58 | } 59 | 60 | function swapExactIn( 61 | uint256 assetInSeed, 62 | uint256 assetOutSeed, 63 | uint256 swapperSeed, 64 | uint256 amountIn, 65 | uint256 minAmountOut 66 | ) 67 | public 68 | { 69 | // 1. Setup and bounds 70 | 71 | // Prevent overflow in if statement below 72 | assetOutSeed = _bound(assetOutSeed, 0, type(uint256).max - 2); 73 | 74 | MockERC20 assetIn = _getAsset(assetInSeed); 75 | MockERC20 assetOut = _getAsset(assetOutSeed); 76 | address swapper = _getSwapper(swapperSeed); 77 | 78 | // Handle case where randomly selected assets match 79 | if (assetIn == assetOut) { 80 | assetOut = _getAsset(assetOutSeed + 2); 81 | } 82 | 83 | address assetOutCustodian 84 | = address(assetOut) == address(assets[0]) ? psm.pocket() : address(psm); 85 | 86 | // By calculating the amount of assetIn we can get from the max asset out, we can 87 | // determine the max amount of assetIn we can swap since its the same both ways. 88 | uint256 maxAmountIn = psm.previewSwapExactIn( 89 | address(assetOut), 90 | address(assetIn), 91 | assetOut.balanceOf(assetOutCustodian) 92 | ); 93 | 94 | // If there's zero balance a swap can't be performed 95 | if (maxAmountIn == 0) { 96 | zeroBalanceCount++; 97 | return; 98 | } 99 | 100 | amountIn = _bound(amountIn, 1, maxAmountIn); 101 | 102 | // Fuzz between zero and the expected amount out from the swap 103 | minAmountOut = _bound( 104 | minAmountOut, 105 | 0, 106 | psm.previewSwapExactIn(address(assetIn), address(assetOut), amountIn) 107 | ); 108 | 109 | // 2. Cache starting state 110 | uint256 startingConversion = psm.convertToAssetValue(1e18); 111 | uint256 startingConversionMillion = psm.convertToAssetValue(1e6 * 1e18); 112 | uint256 startingConversionLp0 = psm.convertToAssetValue(psm.shares(lp0)); 113 | uint256 startingValue = psm.totalAssets(); 114 | 115 | // 3. Perform action against protocol 116 | vm.startPrank(swapper); 117 | assetIn.mint(swapper, amountIn); 118 | assetIn.approve(address(psm), amountIn); 119 | uint256 amountOut = psm.swapExactIn( 120 | address(assetIn), 121 | address(assetOut), 122 | amountIn, 123 | minAmountOut, 124 | swapper, 125 | 0 126 | ); 127 | vm.stopPrank(); 128 | 129 | // 4. Update ghost variable(s) 130 | swapsIn[swapper][address(assetIn)] += amountIn; 131 | swapsOut[swapper][address(assetOut)] += amountOut; 132 | 133 | uint256 valueIn = _getAssetValue(address(assetIn), amountIn); 134 | uint256 valueOut = _getAssetValue(address(assetOut), amountOut); 135 | 136 | valueSwappedIn[swapper] += valueIn; 137 | valueSwappedOut[swapper] += valueOut; 138 | 139 | // 5. Perform action-specific assertions 140 | 141 | // Rounding because of USDC precision, the conversion rate of a 142 | // user's position can fluctuate by up to 2e12 per 1e18 shares 143 | assertApproxEqAbs( 144 | psm.convertToAssetValue(1e18), 145 | startingConversion, 146 | 3e12, 147 | "SwapperHandler/swapExactIn/conversion-rate-change" 148 | ); 149 | 150 | // Demonstrate rounding scales with shares 151 | assertApproxEqAbs( 152 | psm.convertToAssetValue(1_000_000e18), 153 | startingConversionMillion, 154 | 3_000_000e12, // 2e18 of value 155 | "SwapperHandler/swapExactIn/conversion-rate-change-million" 156 | ); 157 | 158 | // Rounding is always in favour of the protocol 159 | assertGe( 160 | psm.convertToAssetValue(1_000_000e18), 161 | startingConversionMillion, 162 | "SwapperHandler/swapExactIn/conversion-rate-million-decrease" 163 | ); 164 | 165 | // Disregard this assertion if the LP has less than a dollar of value 166 | if (startingConversionLp0 > 1e18) { 167 | // Position values can fluctuate by up to 0.00000002% on swaps 168 | assertApproxEqRel( 169 | psm.convertToAssetValue(psm.shares(lp0)), 170 | startingConversionLp0, 171 | 0.000002e18, 172 | "SwapperHandler/swapExactIn/conversion-rate-change-lp" 173 | ); 174 | } 175 | 176 | // Rounding is always in favour of the user 177 | assertGe( 178 | psm.convertToAssetValue(psm.shares(lp0)), 179 | startingConversionLp0, 180 | "SwapperHandler/swapExactIn/conversion-rate-lp-decrease" 181 | ); 182 | 183 | // PSM value can fluctuate by up to 0.00000002% on swaps because of USDC rounding 184 | assertApproxEqRel( 185 | psm.totalAssets(), 186 | startingValue, 187 | 0.000002e18, 188 | "SwapperHandler/swapExactIn/psm-total-value-change" 189 | ); 190 | 191 | // Rounding is always in favour of the protocol 192 | assertGe( 193 | psm.totalAssets(), 194 | startingValue, 195 | "SwapperHandler/swapExactIn/psm-total-value-decrease" 196 | ); 197 | 198 | // High rates introduce larger rounding errors 199 | uint256 rateIntroducedRounding = rateProvider.getConversionRate() / 1e27; 200 | 201 | assertApproxEqAbs( 202 | valueIn, 203 | valueOut, 1e12 + rateIntroducedRounding * 1e12, 204 | "SwapperHandler/swapExactIn/value-mismatch" 205 | ); 206 | 207 | assertGe(valueIn, valueOut, "SwapperHandler/swapExactIn/value-out-greater-than-in"); 208 | 209 | // 6. Update metrics tracking state 210 | swapperSwapCount[swapper]++; 211 | swapCount++; 212 | } 213 | 214 | function swapExactOut( 215 | uint256 assetInSeed, 216 | uint256 assetOutSeed, 217 | uint256 swapperSeed, 218 | uint256 amountOut 219 | ) 220 | public 221 | { 222 | // 1. Setup and bounds 223 | 224 | // Prevent overflow in if statement below 225 | assetOutSeed = _bound(assetOutSeed, 0, type(uint256).max - 2); 226 | 227 | MockERC20 assetIn = _getAsset(assetInSeed); 228 | MockERC20 assetOut = _getAsset(assetOutSeed); 229 | address swapper = _getSwapper(swapperSeed); 230 | 231 | // Handle case where randomly selected assets match 232 | if (assetIn == assetOut) { 233 | assetOut = _getAsset(assetOutSeed + 2); 234 | } 235 | 236 | address assetOutCustodian 237 | = address(assetOut) == address(assets[0]) ? psm.pocket() : address(psm); 238 | 239 | // If there's zero balance a swap can't be performed 240 | if (assetOut.balanceOf(assetOutCustodian) == 0) { 241 | zeroBalanceCount++; 242 | return; 243 | } 244 | 245 | amountOut = _bound(amountOut, 1, assetOut.balanceOf(assetOutCustodian)); 246 | 247 | // Not testing this functionality, just want a successful swap 248 | uint256 maxAmountIn = type(uint256).max; 249 | 250 | // 2. Cache starting state 251 | uint256 startingConversion = psm.convertToAssetValue(1e18); 252 | uint256 startingConversionMillion = psm.convertToAssetValue(1e6 * 1e18); 253 | uint256 startingConversionLp0 = psm.convertToAssetValue(psm.shares(lp0)); 254 | uint256 startingValue = psm.totalAssets(); 255 | 256 | // 3. Perform action against protocol 257 | uint256 amountInNeeded = psm.previewSwapExactOut( 258 | address(assetIn), 259 | address(assetOut), 260 | amountOut 261 | ); 262 | 263 | vm.startPrank(swapper); 264 | assetIn.mint(swapper, amountInNeeded); 265 | assetIn.approve(address(psm), amountInNeeded); 266 | uint256 amountIn = psm.swapExactOut( 267 | address(assetIn), 268 | address(assetOut), 269 | amountOut, 270 | maxAmountIn, 271 | swapper, 272 | 0 273 | ); 274 | vm.stopPrank(); 275 | 276 | // 4. Update ghost variable(s) 277 | swapsIn[swapper][address(assetIn)] += amountIn; 278 | swapsOut[swapper][address(assetOut)] += amountOut; 279 | 280 | uint256 valueIn = _getAssetValue(address(assetIn), amountIn); 281 | uint256 valueOut = _getAssetValue(address(assetOut), amountOut); 282 | 283 | valueSwappedIn[swapper] += valueIn; 284 | valueSwappedOut[swapper] += valueOut; 285 | 286 | // 5. Perform action-specific assertions 287 | 288 | // Rounding because of USDC precision, the conversion rate of a 289 | // user's position can fluctuate by up to 2e12 per 1e18 shares 290 | assertApproxEqAbs( 291 | psm.convertToAssetValue(1e18), 292 | startingConversion, 293 | 3e12, 294 | "SwapperHandler/swapExactOut/conversion-rate-change" 295 | ); 296 | 297 | // Demonstrate rounding scales with shares 298 | assertApproxEqAbs( 299 | psm.convertToAssetValue(1_000_000e18), 300 | startingConversionMillion, 301 | 3_000_000e12, // 2e18 of value 302 | "SwapperHandler/swapExactOut/conversion-rate-change-million" 303 | ); 304 | 305 | // Rounding is always in favour of the protocol 306 | assertGe( 307 | psm.convertToAssetValue(1_000_000e18), 308 | startingConversionMillion, 309 | "SwapperHandler/swapExactOut/conversion-rate-million-decrease" 310 | ); 311 | 312 | // Disregard this assertion if the LP has less than a dollar of value 313 | if (startingConversionLp0 > 1e18) { 314 | // Position values can fluctuate by up to 0.00000003% on swaps 315 | assertApproxEqRel( 316 | psm.convertToAssetValue(psm.shares(lp0)), 317 | startingConversionLp0, 318 | 0.000003e18, 319 | "SwapperHandler/swapExactOut/conversion-rate-change-lp" 320 | ); 321 | } 322 | 323 | // Rounding is always in favour of the user 324 | assertGe( 325 | psm.convertToAssetValue(psm.shares(lp0)), 326 | startingConversionLp0, 327 | "SwapperHandler/swapExactOut/conversion-rate-lp-decrease" 328 | ); 329 | 330 | // PSM value can fluctuate by up to 0.00000003% on swaps because of USDC rounding 331 | assertApproxEqRel( 332 | psm.totalAssets(), 333 | startingValue, 334 | 0.000003e18, 335 | "SwapperHandler/swapExactOut/psm-total-value-change" 336 | ); 337 | 338 | // Rounding is always in favour of the protocol 339 | assertGe( 340 | psm.totalAssets(), 341 | startingValue, 342 | "SwapperHandler/swapExactOut/psm-total-value-decrease" 343 | ); 344 | 345 | // High rates introduce larger rounding errors 346 | uint256 rateIntroducedRounding = rateProvider.getConversionRate() / 1e27; 347 | 348 | assertApproxEqAbs( 349 | valueIn, 350 | valueOut, 1e12 + rateIntroducedRounding * 1e12, 351 | "SwapperHandler/swapExactOut/value-mismatch" 352 | ); 353 | 354 | assertGe(valueIn, valueOut, "SwapperHandler/swapExactOut/value-out-greater-than-in"); 355 | 356 | // 6. Update metrics tracking state 357 | swapperSwapCount[swapper]++; 358 | swapCount++; 359 | } 360 | 361 | function _getAssetValue(address asset, uint256 amount) internal view returns (uint256) { 362 | if (asset == address(assets[0])) return amount * 1e12; 363 | else if (asset == address(assets[1])) return amount; 364 | else if (asset == address(assets[2])) return amount * rateProvider.getConversionRate() / 1e27; 365 | else revert("SwapperHandler/asset-not-found"); 366 | } 367 | 368 | } 369 | -------------------------------------------------------------------------------- /test/invariant/handlers/TimeBasedRateHandler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { HandlerBase, PSM3 } from "test/invariant/handlers/HandlerBase.sol"; 5 | 6 | import { StdCheats } from "forge-std/StdCheats.sol"; 7 | 8 | import { SSRAuthOracle } from "lib/xchain-ssr-oracle/src/SSRAuthOracle.sol"; 9 | import { ISSROracle } from "lib/xchain-ssr-oracle/src/interfaces/ISSROracle.sol"; 10 | 11 | contract TimeBasedRateHandler is HandlerBase, StdCheats { 12 | 13 | uint256 public ssr; 14 | 15 | uint256 constant TWENTY_PCT_APY_SSR = 1.000000005781378656804591712e27; 16 | 17 | SSRAuthOracle public ssrOracle; 18 | 19 | uint256 public setSUSDSDataCount; 20 | uint256 public warpCount; 21 | 22 | constructor(PSM3 psm_, SSRAuthOracle ssrOracle_) HandlerBase(psm_) { 23 | ssrOracle = ssrOracle_; 24 | } 25 | 26 | // This acts as a receiver on an L2. 27 | function setSUSDSData(uint256 newSsr) external { 28 | // 1. Setup and bounds 29 | ssr = _bound(newSsr, 1e27, TWENTY_PCT_APY_SSR); 30 | 31 | // Update rho to be current, update chi based on current rate 32 | uint256 rho = block.timestamp; 33 | uint256 chi = ssrOracle.getConversionRate(rho); 34 | 35 | // 2. Cache starting state 36 | uint256 startingConversion = psm.convertToAssetValue(1e18); 37 | uint256 startingValue = psm.totalAssets(); 38 | 39 | // 3. Perform action against protocol 40 | ssrOracle.setSUSDSData(ISSROracle.SUSDSData({ 41 | ssr: uint96(ssr), 42 | chi: uint120(chi), 43 | rho: uint40(rho) 44 | })); 45 | 46 | // 4. Perform action-specific assertions 47 | assertGe( 48 | psm.convertToAssetValue(1e18) + 1, 49 | startingConversion, 50 | "TimeBasedRateHandler/getSUSDSData/conversion-rate-decrease" 51 | ); 52 | 53 | assertGe( 54 | psm.totalAssets() + 1, 55 | startingValue, 56 | "TimeBasedRateHandler/getSUSDSData/psm-total-value-decrease" 57 | ); 58 | 59 | // 5. Update metrics tracking state 60 | setSUSDSDataCount++; 61 | } 62 | 63 | function warp(uint256 skipTime) external { 64 | // 1. Setup and bounds 65 | uint256 warpTime = _bound(skipTime, 0, 10 days); 66 | 67 | // 2. Cache starting state 68 | uint256 startingConversion = psm.convertToAssetValue(1e18); 69 | uint256 startingValue = psm.totalAssets(); 70 | 71 | // 3. Perform action against protocol 72 | skip(warpTime); 73 | 74 | // 4. Perform action-specific assertions 75 | assertGe( 76 | psm.convertToAssetValue(1e18), 77 | startingConversion, 78 | "RateSetterHandler/warp/conversion-rate-decrease" 79 | ); 80 | 81 | assertGe( 82 | psm.totalAssets(), 83 | startingValue, 84 | "RateSetterHandler/warp/psm-total-value-decrease" 85 | ); 86 | 87 | // 5. Update metrics tracking state 88 | warpCount++; 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /test/invariant/handlers/TransferHandler.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { MockERC20 } from "erc20-helpers/MockERC20.sol"; 5 | 6 | import { HandlerBase } from "test/invariant/handlers/HandlerBase.sol"; 7 | 8 | import { PSM3 } from "src/PSM3.sol"; 9 | 10 | contract TransferHandler is HandlerBase { 11 | 12 | MockERC20[3] public assets; 13 | 14 | uint256 public transferCount; 15 | 16 | mapping(address asset => uint256) public transfersIn; 17 | 18 | constructor( 19 | PSM3 psm_, 20 | MockERC20 usdc, 21 | MockERC20 usds, 22 | MockERC20 susds 23 | ) HandlerBase(psm_) { 24 | assets[0] = usdc; 25 | assets[1] = usds; 26 | assets[2] = susds; 27 | } 28 | 29 | function _getAsset(uint256 indexSeed) internal view returns (MockERC20) { 30 | return assets[indexSeed % assets.length]; 31 | } 32 | 33 | function transfer(uint256 assetSeed, string memory senderSeed, uint256 amount) external { 34 | // 1. Setup and bounds 35 | MockERC20 asset = _getAsset(assetSeed); 36 | address sender = makeAddr(senderSeed); 37 | 38 | // 2. Cache starting state 39 | uint256 startingConversion = psm.convertToAssetValue(1e18); 40 | uint256 startingValue = psm.totalAssets(); 41 | 42 | // Bounding to 10 million here because 1 trillion introduces unrealistic conditions with 43 | // large rounding errors. Would rather keep tolerances smaller with a lower upper bound 44 | // on transfer amounts. 45 | amount = _bound(amount, 1, 10_000_000 * 10 ** asset.decimals()); 46 | 47 | address custodian = address(asset) == address(assets[0]) ? psm.pocket() : address(psm); 48 | 49 | // 3. Perform action against protocol 50 | asset.mint(sender, amount); 51 | vm.prank(sender); 52 | asset.transfer(custodian, amount); 53 | 54 | // 4. Update ghost variable(s) 55 | transfersIn[address(asset)] += amount; 56 | 57 | // 5. Perform action-specific assertions 58 | assertGe( 59 | psm.convertToAssetValue(1e18) + 1, 60 | startingConversion, 61 | "TransferHandler/transfer/conversion-rate-decrease" 62 | ); 63 | 64 | assertGe( 65 | psm.totalAssets() + 1, 66 | startingValue, 67 | "TransferHandler/transfer/psm-total-value-decrease" 68 | ); 69 | 70 | // 6. Update metrics tracking state 71 | transferCount += 1; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /test/mocks/MockRateProvider.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | contract MockRateProvider { 5 | 6 | uint256 public conversionRate; 7 | 8 | function __setConversionRate(uint256 conversionRate_) external { 9 | conversionRate = conversionRate_; 10 | } 11 | 12 | function getConversionRate() external view returns (uint256) { 13 | return conversionRate; 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /test/unit/Constructor.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { MockERC20 } from "erc20-helpers/MockERC20.sol"; 7 | 8 | import { PSM3 } from "src/PSM3.sol"; 9 | 10 | import { PSMTestBase } from "test/PSMTestBase.sol"; 11 | 12 | import { MockRateProvider } from "test/mocks/MockRateProvider.sol"; 13 | 14 | contract PSMConstructorTests is PSMTestBase { 15 | 16 | function test_constructor_invalidOwner() public { 17 | vm.expectRevert(abi.encodeWithSignature("OwnableInvalidOwner(address)", address(0))); 18 | new PSM3(address(0), address(usdc), address(usds), address(susds), address(rateProvider)); 19 | } 20 | 21 | function test_constructor_invalidUsdc() public { 22 | vm.expectRevert("PSM3/invalid-usdc"); 23 | new PSM3(owner, address(0), address(usds), address(susds), address(rateProvider)); 24 | } 25 | 26 | function test_constructor_invalidUsds() public { 27 | vm.expectRevert("PSM3/invalid-usds"); 28 | new PSM3(owner, address(usdc), address(0), address(susds), address(rateProvider)); 29 | } 30 | 31 | function test_constructor_invalidSUsds() public { 32 | vm.expectRevert("PSM3/invalid-susds"); 33 | new PSM3(owner, address(usdc), address(usds), address(0), address(rateProvider)); 34 | } 35 | 36 | function test_constructor_invalidRateProvider() public { 37 | vm.expectRevert("PSM3/invalid-rateProvider"); 38 | new PSM3(owner, address(usdc), address(usds), address(susds), address(0)); 39 | } 40 | 41 | function test_constructor_usdcUsdsMatch() public { 42 | vm.expectRevert("PSM3/usdc-usds-same"); 43 | new PSM3(owner, address(usdc), address(usdc), address(susds), address(rateProvider)); 44 | } 45 | 46 | function test_constructor_usdcSUsdsMatch() public { 47 | vm.expectRevert("PSM3/usdc-susds-same"); 48 | new PSM3(owner, address(usdc), address(usds), address(usdc), address(rateProvider)); 49 | } 50 | 51 | function test_constructor_usdsSUsdsMatch() public { 52 | vm.expectRevert("PSM3/usds-susds-same"); 53 | new PSM3(owner, address(usdc), address(usds), address(usds), address(rateProvider)); 54 | } 55 | 56 | function test_constructor_rateProviderZero() public { 57 | MockRateProvider(address(rateProvider)).__setConversionRate(0); 58 | vm.expectRevert("PSM3/rate-provider-returns-zero"); 59 | new PSM3(owner, address(usdc), address(usds), address(susds), address(rateProvider)); 60 | } 61 | 62 | function test_constructor_usdcDecimalsToHighBoundary() public { 63 | MockERC20 usdc = new MockERC20("USDC", "USDC", 19); 64 | 65 | vm.expectRevert("PSM3/usdc-precision-too-high"); 66 | new PSM3(owner, address(usdc), address(usds), address(susds), address(rateProvider)); 67 | 68 | usdc = new MockERC20("USDC", "USDC", 18); 69 | 70 | new PSM3(owner, address(usdc), address(usds), address(susds), address(rateProvider)); 71 | } 72 | 73 | function test_constructor_usdsDecimalsToHighBoundary() public { 74 | MockERC20 usds = new MockERC20("USDS", "USDS", 19); 75 | 76 | vm.expectRevert("PSM3/usds-precision-too-high"); 77 | new PSM3(owner, address(usdc), address(usds), address(susds), address(rateProvider)); 78 | 79 | usds = new MockERC20("USDS", "USDS", 18); 80 | 81 | new PSM3(owner, address(usdc), address(usds), address(susds), address(rateProvider)); 82 | } 83 | 84 | function test_constructor() public { 85 | // Deploy new PSM to get test coverage 86 | psm = new PSM3(owner, address(usdc), address(usds), address(susds), address(rateProvider)); 87 | 88 | assertEq(address(psm.owner()), address(owner)); 89 | assertEq(address(psm.usdc()), address(usdc)); 90 | assertEq(address(psm.usds()), address(usds)); 91 | assertEq(address(psm.susds()), address(susds)); 92 | assertEq(address(psm.rateProvider()), address(rateProvider)); 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /test/unit/Deployment.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { PSM3Deploy } from "deploy/PSM3Deploy.sol"; 7 | 8 | import { PSM3 } from "src/PSM3.sol"; 9 | 10 | import { PSMTestBase } from "test/PSMTestBase.sol"; 11 | 12 | contract PSMDeployTests is PSMTestBase { 13 | 14 | function test_deploy() public { 15 | deal(address(usdc), address(this), 1e6); 16 | 17 | PSM3 newPsm = PSM3(PSM3Deploy.deploy( 18 | address(owner), 19 | address(usdc), 20 | address(usds), 21 | address(susds), 22 | address(rateProvider) 23 | )); 24 | 25 | assertEq(address(newPsm.owner()), address(owner)); 26 | assertEq(address(newPsm.usdc()), address(usdc)); 27 | assertEq(address(newPsm.usds()), address(usds)); 28 | assertEq(address(newPsm.susds()), address(susds)); 29 | assertEq(address(newPsm.rateProvider()), address(rateProvider)); 30 | 31 | assertEq(usdc.allowance(address(this), address(newPsm)), 0); 32 | 33 | assertEq(usdc.balanceOf(address(this)), 0); 34 | assertEq(usdc.balanceOf(address(newPsm)), 1e6); 35 | 36 | assertEq(newPsm.totalAssets(), 1e18); 37 | assertEq(newPsm.totalShares(), 1e18); 38 | assertEq(newPsm.shares(address(this)), 0); 39 | assertEq(newPsm.shares(address(0)), 1e18); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /test/unit/Deposit.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { PSM3 } from "src/PSM3.sol"; 7 | 8 | import { MockRateProvider, PSMTestBase } from "test/PSMTestBase.sol"; 9 | 10 | contract PSMDepositTests is PSMTestBase { 11 | 12 | address user1 = makeAddr("user1"); 13 | address user2 = makeAddr("user2"); 14 | address receiver1 = makeAddr("receiver1"); 15 | address receiver2 = makeAddr("receiver2"); 16 | 17 | function test_deposit_zeroAmount() public { 18 | vm.expectRevert("PSM3/invalid-amount"); 19 | psm.deposit(address(usdc), user1, 0); 20 | } 21 | 22 | function test_deposit_invalidAsset() public { 23 | // NOTE: This reverts in _getAssetValue 24 | vm.expectRevert("PSM3/invalid-asset-for-value"); 25 | psm.deposit(makeAddr("new-asset"), user1, 100e6); 26 | } 27 | 28 | function test_deposit_insufficientApproveBoundary() public { 29 | usds.mint(user1, 100e18); 30 | 31 | vm.startPrank(user1); 32 | 33 | usds.approve(address(psm), 100e18 - 1); 34 | 35 | vm.expectRevert("SafeERC20/transfer-from-failed"); 36 | psm.deposit(address(usds), user1, 100e18); 37 | 38 | usds.approve(address(psm), 100e18); 39 | 40 | psm.deposit(address(usds), user1, 100e18); 41 | } 42 | 43 | function test_deposit_insufficientBalanceBoundary() public { 44 | usds.mint(user1, 100e18 - 1); 45 | 46 | vm.startPrank(user1); 47 | 48 | usds.approve(address(psm), 100e18); 49 | 50 | vm.expectRevert("SafeERC20/transfer-from-failed"); 51 | psm.deposit(address(usds), user1, 100e18); 52 | 53 | usds.mint(user1, 1); 54 | 55 | psm.deposit(address(usds), user1, 100e18); 56 | } 57 | 58 | function test_deposit_firstDepositUsds() public { 59 | usds.mint(user1, 100e18); 60 | 61 | vm.startPrank(user1); 62 | 63 | usds.approve(address(psm), 100e18); 64 | 65 | assertEq(usds.allowance(user1, address(psm)), 100e18); 66 | assertEq(usds.balanceOf(user1), 100e18); 67 | assertEq(usds.balanceOf(address(psm)), 0); 68 | 69 | assertEq(psm.totalShares(), 0); 70 | assertEq(psm.shares(user1), 0); 71 | assertEq(psm.shares(receiver1), 0); 72 | 73 | assertEq(psm.convertToShares(1e18), 1e18); 74 | 75 | uint256 newShares = psm.deposit(address(usds), receiver1, 100e18); 76 | 77 | assertEq(newShares, 100e18); 78 | 79 | assertEq(usds.allowance(user1, address(psm)), 0); 80 | assertEq(usds.balanceOf(user1), 0); 81 | assertEq(usds.balanceOf(address(psm)), 100e18); 82 | 83 | assertEq(psm.totalShares(), 100e18); 84 | assertEq(psm.shares(user1), 0); 85 | assertEq(psm.shares(receiver1), 100e18); 86 | 87 | assertEq(psm.convertToShares(1e18), 1e18); 88 | } 89 | 90 | function test_deposit_firstDepositUsdc() public { 91 | usdc.mint(user1, 100e6); 92 | 93 | vm.startPrank(user1); 94 | 95 | usdc.approve(address(psm), 100e6); 96 | 97 | assertEq(usdc.allowance(user1, address(psm)), 100e6); 98 | assertEq(usdc.balanceOf(user1), 100e6); 99 | assertEq(usdc.balanceOf(pocket), 0); 100 | 101 | assertEq(psm.totalShares(), 0); 102 | assertEq(psm.shares(user1), 0); 103 | assertEq(psm.shares(receiver1), 0); 104 | 105 | assertEq(psm.convertToShares(1e18), 1e18); 106 | 107 | uint256 newShares = psm.deposit(address(usdc), receiver1, 100e6); 108 | 109 | assertEq(newShares, 100e18); 110 | 111 | assertEq(usdc.allowance(user1, address(psm)), 0); 112 | assertEq(usdc.balanceOf(user1), 0); 113 | assertEq(usdc.balanceOf(pocket), 100e6); 114 | 115 | assertEq(psm.totalShares(), 100e18); 116 | assertEq(psm.shares(user1), 0); 117 | assertEq(psm.shares(receiver1), 100e18); 118 | 119 | assertEq(psm.convertToShares(1e18), 1e18); 120 | } 121 | 122 | function test_deposit_firstDepositUsdc_pocketIsPsm() public { 123 | vm.prank(owner); 124 | psm.setPocket(address(psm)); 125 | 126 | usdc.mint(user1, 100e6); 127 | 128 | vm.startPrank(user1); 129 | 130 | usdc.approve(address(psm), 100e6); 131 | 132 | assertEq(usdc.allowance(user1, address(psm)), 100e6); 133 | assertEq(usdc.balanceOf(user1), 100e6); 134 | assertEq(usdc.balanceOf(address(psm)), 0); 135 | 136 | assertEq(psm.totalShares(), 0); 137 | assertEq(psm.shares(user1), 0); 138 | assertEq(psm.shares(receiver1), 0); 139 | 140 | assertEq(psm.convertToShares(1e18), 1e18); 141 | 142 | uint256 newShares = psm.deposit(address(usdc), receiver1, 100e6); 143 | 144 | assertEq(newShares, 100e18); 145 | 146 | assertEq(usdc.allowance(user1, address(psm)), 0); 147 | assertEq(usdc.balanceOf(user1), 0); 148 | assertEq(usdc.balanceOf(address(psm)), 100e6); 149 | 150 | assertEq(psm.totalShares(), 100e18); 151 | assertEq(psm.shares(user1), 0); 152 | assertEq(psm.shares(receiver1), 100e18); 153 | 154 | assertEq(psm.convertToShares(1e18), 1e18); 155 | } 156 | 157 | function test_deposit_firstDepositSUsds() public { 158 | susds.mint(user1, 100e18); 159 | 160 | vm.startPrank(user1); 161 | 162 | susds.approve(address(psm), 100e18); 163 | 164 | assertEq(susds.allowance(user1, address(psm)), 100e18); 165 | assertEq(susds.balanceOf(user1), 100e18); 166 | assertEq(susds.balanceOf(address(psm)), 0); 167 | 168 | assertEq(psm.totalShares(), 0); 169 | assertEq(psm.shares(user1), 0); 170 | assertEq(psm.shares(receiver1), 0); 171 | 172 | assertEq(psm.convertToShares(1e18), 1e18); 173 | 174 | uint256 newShares = psm.deposit(address(susds), receiver1, 100e18); 175 | 176 | assertEq(newShares, 125e18); 177 | 178 | assertEq(susds.allowance(user1, address(psm)), 0); 179 | assertEq(susds.balanceOf(user1), 0); 180 | assertEq(susds.balanceOf(address(psm)), 100e18); 181 | 182 | assertEq(psm.totalShares(), 125e18); 183 | assertEq(psm.shares(user1), 0); 184 | assertEq(psm.shares(receiver1), 125e18); 185 | 186 | assertEq(psm.convertToShares(1e18), 1e18); 187 | } 188 | 189 | function test_deposit_usdcThenSUsds() public { 190 | usdc.mint(user1, 100e6); 191 | 192 | vm.startPrank(user1); 193 | 194 | usdc.approve(address(psm), 100e6); 195 | 196 | uint256 newShares = psm.deposit(address(usdc), receiver1, 100e6); 197 | 198 | assertEq(newShares, 100e18); 199 | 200 | susds.mint(user1, 100e18); 201 | susds.approve(address(psm), 100e18); 202 | 203 | assertEq(usdc.balanceOf(pocket), 100e6); 204 | 205 | assertEq(susds.allowance(user1, address(psm)), 100e18); 206 | assertEq(susds.balanceOf(user1), 100e18); 207 | assertEq(susds.balanceOf(address(psm)), 0); 208 | 209 | assertEq(psm.totalShares(), 100e18); 210 | assertEq(psm.shares(user1), 0); 211 | assertEq(psm.shares(receiver1), 100e18); 212 | 213 | assertEq(psm.convertToShares(1e18), 1e18); 214 | 215 | newShares = psm.deposit(address(susds), receiver1, 100e18); 216 | 217 | assertEq(newShares, 125e18); 218 | 219 | assertEq(usdc.balanceOf(pocket), 100e6); 220 | 221 | assertEq(susds.allowance(user1, address(psm)), 0); 222 | assertEq(susds.balanceOf(user1), 0); 223 | assertEq(susds.balanceOf(address(psm)), 100e18); 224 | 225 | assertEq(psm.totalShares(), 225e18); 226 | assertEq(psm.shares(user1), 0); 227 | assertEq(psm.shares(receiver1), 225e18); 228 | 229 | assertEq(psm.convertToShares(1e18), 1e18); 230 | } 231 | 232 | function testFuzz_deposit_usdcThenSUsds(uint256 usdcAmount, uint256 susdsAmount) public { 233 | // Zero amounts revert 234 | usdcAmount = _bound(usdcAmount, 1, USDC_TOKEN_MAX); 235 | susdsAmount = _bound(susdsAmount, 1, SUSDS_TOKEN_MAX); 236 | 237 | usdc.mint(user1, usdcAmount); 238 | 239 | vm.startPrank(user1); 240 | 241 | usdc.approve(address(psm), usdcAmount); 242 | 243 | uint256 newShares = psm.deposit(address(usdc), receiver1, usdcAmount); 244 | 245 | assertEq(newShares, usdcAmount * 1e12); 246 | 247 | susds.mint(user1, susdsAmount); 248 | susds.approve(address(psm), susdsAmount); 249 | 250 | assertEq(usdc.balanceOf(pocket), usdcAmount); 251 | 252 | assertEq(susds.allowance(user1, address(psm)), susdsAmount); 253 | assertEq(susds.balanceOf(user1), susdsAmount); 254 | assertEq(susds.balanceOf(address(psm)), 0); 255 | 256 | assertEq(psm.totalShares(), usdcAmount * 1e12); 257 | assertEq(psm.shares(user1), 0); 258 | assertEq(psm.shares(receiver1), usdcAmount * 1e12); 259 | 260 | assertEq(psm.convertToShares(1e18), 1e18); 261 | 262 | newShares = psm.deposit(address(susds), receiver1, susdsAmount); 263 | 264 | assertEq(newShares, susdsAmount * 125/100); 265 | 266 | assertEq(usdc.balanceOf(pocket), usdcAmount); 267 | 268 | assertEq(susds.allowance(user1, address(psm)), 0); 269 | assertEq(susds.balanceOf(user1), 0); 270 | assertEq(susds.balanceOf(address(psm)), susdsAmount); 271 | 272 | assertEq(psm.totalShares(), usdcAmount * 1e12 + susdsAmount * 125/100); 273 | assertEq(psm.shares(user1), 0); 274 | assertEq(psm.shares(receiver1), usdcAmount * 1e12 + susdsAmount * 125/100); 275 | 276 | assertEq(psm.convertToShares(1e18), 1e18); 277 | } 278 | 279 | function test_deposit_multiUser_changeConversionRate() public { 280 | usdc.mint(user1, 100e6); 281 | 282 | vm.startPrank(user1); 283 | 284 | usdc.approve(address(psm), 100e6); 285 | 286 | uint256 newShares = psm.deposit(address(usdc), receiver1, 100e6); 287 | 288 | assertEq(newShares, 100e18); 289 | 290 | susds.mint(user1, 100e18); 291 | susds.approve(address(psm), 100e18); 292 | 293 | newShares = psm.deposit(address(susds), receiver1, 100e18); 294 | 295 | assertEq(newShares, 125e18); 296 | 297 | vm.stopPrank(); 298 | 299 | assertEq(usdc.balanceOf(pocket), 100e6); 300 | 301 | assertEq(susds.allowance(user1, address(psm)), 0); 302 | assertEq(susds.balanceOf(user1), 0); 303 | assertEq(susds.balanceOf(address(psm)), 100e18); 304 | 305 | assertEq(psm.totalShares(), 225e18); 306 | assertEq(psm.shares(user1), 0); 307 | assertEq(psm.shares(receiver1), 225e18); 308 | 309 | assertEq(psm.convertToShares(1e18), 1e18); 310 | 311 | assertEq(psm.convertToAssetValue(psm.shares(receiver1)), 225e18); 312 | 313 | mockRateProvider.__setConversionRate(1.5e27); 314 | 315 | // Total shares / (100 USDC + 150 sUSDS value) 316 | uint256 expectedConversionRate = 225 * 1e18 / 250; 317 | 318 | assertEq(expectedConversionRate, 0.9e18); 319 | 320 | assertEq(psm.convertToShares(1e18), expectedConversionRate); 321 | 322 | vm.startPrank(user2); 323 | 324 | susds.mint(user2, 100e18); 325 | susds.approve(address(psm), 100e18); 326 | 327 | assertEq(susds.allowance(user2, address(psm)), 100e18); 328 | assertEq(susds.balanceOf(user2), 100e18); 329 | assertEq(susds.balanceOf(address(psm)), 100e18); 330 | 331 | assertEq(psm.convertToAssetValue(psm.shares(receiver1)), 250e18); 332 | assertEq(psm.convertToAssetValue(psm.shares(receiver2)), 0); 333 | 334 | assertEq(psm.totalAssets(), 250e18); 335 | 336 | newShares = psm.deposit(address(susds), receiver2, 100e18); 337 | 338 | assertEq(newShares, 135e18); 339 | 340 | assertEq(susds.allowance(user2, address(psm)), 0); 341 | assertEq(susds.balanceOf(user2), 0); 342 | assertEq(susds.balanceOf(address(psm)), 200e18); 343 | 344 | // Depositing 150 dollars of value at 0.9 exchange rate 345 | uint256 expectedShares = 150e18 * 9/10; 346 | 347 | assertEq(expectedShares, 135e18); 348 | 349 | assertEq(psm.totalShares(), 360e18); 350 | assertEq(psm.shares(user1), 0); 351 | assertEq(psm.shares(user2), 0); 352 | assertEq(psm.shares(receiver1), 225e18); 353 | assertEq(psm.shares(receiver2), 135e18); 354 | 355 | // Receiver 1 earned $25 on 225, Receiver 2 has earned nothing 356 | assertEq(psm.convertToAssetValue(psm.shares(receiver1)), 250e18); 357 | assertEq(psm.convertToAssetValue(psm.shares(receiver2)), 150e18); 358 | 359 | assertEq(psm.totalAssets(), 400e18); 360 | } 361 | 362 | function testFuzz_deposit_multiUser_changeConversionRate( 363 | uint256 usdcAmount, 364 | uint256 susdsAmount1, 365 | uint256 susdsAmount2, 366 | uint256 newRate 367 | ) 368 | public 369 | { 370 | // Zero amounts revert 371 | usdcAmount = _bound(usdcAmount, 1, USDC_TOKEN_MAX); 372 | susdsAmount1 = _bound(susdsAmount1, 1, SUSDS_TOKEN_MAX); 373 | susdsAmount2 = _bound(susdsAmount2, 1, SUSDS_TOKEN_MAX); 374 | newRate = _bound(newRate, 1.25e27, 1000e27); 375 | 376 | uint256 user1DepositValue = usdcAmount * 1e12 + susdsAmount1 * 125/100; 377 | 378 | usdc.mint(user1, usdcAmount); 379 | 380 | vm.startPrank(user1); 381 | 382 | usdc.approve(address(psm), usdcAmount); 383 | 384 | uint256 newShares = psm.deposit(address(usdc), receiver1, usdcAmount); 385 | 386 | assertEq(newShares, usdcAmount * 1e12); 387 | 388 | susds.mint(user1, susdsAmount1); 389 | susds.approve(address(psm), susdsAmount1); 390 | 391 | newShares = psm.deposit(address(susds), receiver1, susdsAmount1); 392 | 393 | assertEq(newShares, susdsAmount1 * 125/100); 394 | 395 | vm.stopPrank(); 396 | 397 | assertEq(usdc.balanceOf(pocket), usdcAmount); 398 | 399 | assertEq(susds.balanceOf(user1), 0); 400 | assertEq(susds.balanceOf(address(psm)), susdsAmount1); 401 | 402 | // Deposited at 1:1 conversion 403 | uint256 receiver1Shares = user1DepositValue; 404 | 405 | assertEq(psm.totalShares(), receiver1Shares); 406 | assertEq(psm.shares(user1), 0); 407 | assertEq(psm.shares(receiver1), receiver1Shares); 408 | 409 | mockRateProvider.__setConversionRate(newRate); 410 | 411 | vm.startPrank(user2); 412 | 413 | susds.mint(user2, susdsAmount2); 414 | susds.approve(address(psm), susdsAmount2); 415 | 416 | assertEq(susds.allowance(user2, address(psm)), susdsAmount2); 417 | assertEq(susds.balanceOf(user2), susdsAmount2); 418 | assertEq(susds.balanceOf(address(psm)), susdsAmount1); 419 | 420 | // Receiver1 has gained from conversion change 421 | uint256 receiver1NewValue = user1DepositValue + susdsAmount1 * (newRate - 1.25e27) / 1e27; 422 | 423 | // Receiver1 has gained from conversion change 424 | assertApproxEqAbs( 425 | psm.convertToAssetValue(psm.shares(receiver1)), 426 | receiver1NewValue, 427 | 1 428 | ); 429 | 430 | assertEq(psm.convertToAssetValue(psm.shares(receiver2)), 0); 431 | 432 | assertApproxEqAbs(psm.totalAssets(), receiver1NewValue, 1); 433 | 434 | newShares = psm.deposit(address(susds), receiver2, susdsAmount2); 435 | 436 | // Using queried values here instead of derived to avoid larger errors getting introduced 437 | // Assertions above prove that these values are as expected. 438 | uint256 receiver2Shares 439 | = (susdsAmount2 * newRate / 1e27) * psm.totalShares() / psm.totalAssets(); 440 | 441 | assertApproxEqAbs(newShares, receiver2Shares, 2); 442 | 443 | assertEq(susds.allowance(user2, address(psm)), 0); 444 | assertEq(susds.balanceOf(user2), 0); 445 | assertEq(susds.balanceOf(address(psm)), susdsAmount1 + susdsAmount2); 446 | 447 | assertEq(psm.shares(user1), 0); 448 | assertEq(psm.shares(user2), 0); 449 | 450 | assertApproxEqAbs(psm.totalShares(), receiver1Shares + receiver2Shares, 2); 451 | assertApproxEqAbs(psm.shares(receiver1), receiver1Shares, 2); 452 | assertApproxEqAbs(psm.shares(receiver2), receiver2Shares, 2); 453 | 454 | uint256 receiver2NewValue = susdsAmount2 * newRate / 1e27; 455 | 456 | // Rate change of up to 1000x introduces errors 457 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(receiver1)), receiver1NewValue, 1000); 458 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(receiver2)), receiver2NewValue, 1000); 459 | 460 | assertApproxEqAbs(psm.totalAssets(), receiver1NewValue + receiver2NewValue, 1000); 461 | } 462 | 463 | } 464 | -------------------------------------------------------------------------------- /test/unit/DoSAttack.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { PSMTestBase } from "test/PSMTestBase.sol"; 7 | 8 | contract InflationAttackTests is PSMTestBase { 9 | 10 | address user1 = makeAddr("user1"); 11 | address user2 = makeAddr("user2"); 12 | 13 | function test_dos_sendFundsBeforeFirstDeposit() public { 14 | // Attack pool sending funds in before the first deposit 15 | usdc.mint(address(this), 100e6); 16 | usdc.transfer(pocket, 100e6); 17 | 18 | assertEq(usdc.balanceOf(pocket), 100e6); 19 | 20 | assertEq(psm.totalShares(), 0); 21 | assertEq(psm.shares(user1), 0); 22 | assertEq(psm.shares(user2), 0); 23 | 24 | _deposit(address(usdc), address(user1), 1_000_000e6); 25 | 26 | // Since exchange rate is zero, convertToShares returns 1m * 0 / 100e6 27 | // because totalValue is not zero so it enters that if statement. 28 | // This results in the funds going in the pool with no way for the user 29 | // to recover them. 30 | assertEq(usdc.balanceOf(pocket), 1_000_100e6); 31 | 32 | assertEq(psm.totalShares(), 0); 33 | assertEq(psm.shares(user1), 0); 34 | assertEq(psm.shares(user2), 0); 35 | 36 | // This issue is not related to the first deposit only because totalShares cannot 37 | // get above zero. 38 | _deposit(address(usdc), address(user2), 1_000_000e6); 39 | 40 | assertEq(usdc.balanceOf(pocket), 2_000_100e6); 41 | 42 | assertEq(psm.totalShares(), 0); 43 | assertEq(psm.shares(user1), 0); 44 | assertEq(psm.shares(user2), 0); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /test/unit/Events.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { MockERC20, PSMTestBase } from "test/PSMTestBase.sol"; 7 | 8 | contract PSMEventTests is PSMTestBase { 9 | 10 | event Swap( 11 | address indexed assetIn, 12 | address indexed assetOut, 13 | address sender, 14 | address indexed receiver, 15 | uint256 amountIn, 16 | uint256 amountOut, 17 | uint256 referralCode 18 | ); 19 | 20 | event Deposit( 21 | address indexed asset, 22 | address indexed user, 23 | address indexed receiver, 24 | uint256 assetsDeposited, 25 | uint256 sharesMinted 26 | ); 27 | 28 | event Withdraw( 29 | address indexed asset, 30 | address indexed user, 31 | address indexed receiver, 32 | uint256 assetsWithdrawn, 33 | uint256 sharesBurned 34 | ); 35 | 36 | address sender = makeAddr("sender"); 37 | address receiver = makeAddr("receiver"); 38 | 39 | function test_deposit_events() public { 40 | vm.startPrank(sender); 41 | 42 | usds.mint(sender, 100e18); 43 | usds.approve(address(psm), 100e18); 44 | 45 | vm.expectEmit(address(psm)); 46 | emit Deposit(address(usds), sender, receiver, 100e18, 100e18); 47 | psm.deposit(address(usds), receiver, 100e18); 48 | 49 | usdc.mint(sender, 100e6); 50 | usdc.approve(address(psm), 100e6); 51 | 52 | vm.expectEmit(address(psm)); 53 | emit Deposit(address(usdc), sender, receiver, 100e6, 100e18); 54 | psm.deposit(address(usdc), receiver, 100e6); 55 | 56 | susds.mint(sender, 100e18); 57 | susds.approve(address(psm), 100e18); 58 | 59 | vm.expectEmit(address(psm)); 60 | emit Deposit(address(susds), sender, receiver, 100e18, 125e18); 61 | psm.deposit(address(susds), receiver, 100e18); 62 | } 63 | 64 | function test_withdraw_events() public { 65 | _deposit(address(usds), sender, 100e18); 66 | _deposit(address(usdc), sender, 100e6); 67 | _deposit(address(susds), sender, 100e18); 68 | 69 | vm.startPrank(sender); 70 | 71 | vm.expectEmit(address(psm)); 72 | emit Withdraw(address(usds), sender, receiver, 100e18, 100e18); 73 | psm.withdraw(address(usds), receiver, 100e18); 74 | 75 | vm.expectEmit(address(psm)); 76 | emit Withdraw(address(usdc), sender, receiver, 100e6, 100e18); 77 | psm.withdraw(address(usdc), receiver, 100e6); 78 | 79 | vm.expectEmit(address(psm)); 80 | emit Withdraw(address(susds), sender, receiver, 100e18, 125e18); 81 | psm.withdraw(address(susds), receiver, 100e18); 82 | } 83 | 84 | function test_swap_events() public { 85 | usds.mint(address(psm), 1000e18); 86 | usdc.mint(pocket, 1000e6); 87 | susds.mint(address(psm), 1000e18); 88 | 89 | vm.startPrank(sender); 90 | 91 | _swapEventTest(address(usds), address(usdc), 100e18, 100e6, 1); 92 | _swapEventTest(address(usds), address(susds), 100e18, 80e18, 2); 93 | 94 | _swapEventTest(address(usdc), address(usds), 100e6, 100e18, 3); 95 | _swapEventTest(address(usdc), address(susds), 100e6, 80e18, 4); 96 | 97 | _swapEventTest(address(susds), address(usds), 100e18, 125e18, 5); 98 | _swapEventTest(address(susds), address(usdc), 100e18, 125e6, 6); 99 | } 100 | 101 | function _swapEventTest( 102 | address assetIn, 103 | address assetOut, 104 | uint256 amountIn, 105 | uint256 expectedAmountOut, 106 | uint16 referralCode 107 | ) internal { 108 | MockERC20(assetIn).mint(sender, amountIn); 109 | MockERC20(assetIn).approve(address(psm), amountIn); 110 | 111 | vm.expectEmit(address(psm)); 112 | emit Swap(assetIn, assetOut, sender, receiver, amountIn, expectedAmountOut, referralCode); 113 | psm.swapExactIn(assetIn, assetOut, amountIn, 0, receiver, referralCode); 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /test/unit/Getters.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { MockRateProvider, PSMTestBase } from "test/PSMTestBase.sol"; 7 | 8 | import { PSM3Harness } from "test/unit/harnesses/PSM3Harness.sol"; 9 | 10 | contract PSMHarnessTests is PSMTestBase { 11 | 12 | PSM3Harness psmHarness; 13 | 14 | function setUp() public override { 15 | super.setUp(); 16 | psmHarness = new PSM3Harness( 17 | address(owner), 18 | address(usdc), 19 | address(usds), 20 | address(susds), 21 | address(rateProvider) 22 | ); 23 | 24 | vm.prank(owner); 25 | psmHarness.setPocket(pocket); 26 | } 27 | 28 | function test_getUsdsValue() public view { 29 | assertEq(psmHarness.getUsdsValue(1), 1); 30 | assertEq(psmHarness.getUsdsValue(2), 2); 31 | assertEq(psmHarness.getUsdsValue(3), 3); 32 | 33 | assertEq(psmHarness.getUsdsValue(100e18), 100e18); 34 | assertEq(psmHarness.getUsdsValue(200e18), 200e18); 35 | assertEq(psmHarness.getUsdsValue(300e18), 300e18); 36 | 37 | assertEq(psmHarness.getUsdsValue(100_000_000_000e18), 100_000_000_000e18); 38 | assertEq(psmHarness.getUsdsValue(200_000_000_000e18), 200_000_000_000e18); 39 | assertEq(psmHarness.getUsdsValue(300_000_000_000e18), 300_000_000_000e18); 40 | } 41 | 42 | function testFuzz_getUsdsValue(uint256 amount) public view { 43 | amount = _bound(amount, 0, 1e45); 44 | 45 | assertEq(psmHarness.getUsdsValue(amount), amount); 46 | } 47 | 48 | function test_getUsdcValue() public view { 49 | assertEq(psmHarness.getUsdcValue(1), 1e12); 50 | assertEq(psmHarness.getUsdcValue(2), 2e12); 51 | assertEq(psmHarness.getUsdcValue(3), 3e12); 52 | 53 | assertEq(psmHarness.getUsdcValue(100e6), 100e18); 54 | assertEq(psmHarness.getUsdcValue(200e6), 200e18); 55 | assertEq(psmHarness.getUsdcValue(300e6), 300e18); 56 | 57 | assertEq(psmHarness.getUsdcValue(100_000_000_000e6), 100_000_000_000e18); 58 | assertEq(psmHarness.getUsdcValue(200_000_000_000e6), 200_000_000_000e18); 59 | assertEq(psmHarness.getUsdcValue(300_000_000_000e6), 300_000_000_000e18); 60 | } 61 | 62 | function testFuzz_getUsdcValue(uint256 amount) public view { 63 | amount = _bound(amount, 0, 1e45); 64 | 65 | assertEq(psmHarness.getUsdcValue(amount), amount * 1e12); 66 | } 67 | 68 | function test_getSUsdsValue() public { 69 | assertEq(psmHarness.getSUsdsValue(1, false), 1); 70 | assertEq(psmHarness.getSUsdsValue(2, false), 2); 71 | assertEq(psmHarness.getSUsdsValue(3, false), 3); 72 | assertEq(psmHarness.getSUsdsValue(4, false), 5); 73 | 74 | // Rounding up 75 | assertEq(psmHarness.getSUsdsValue(1, true), 2); 76 | assertEq(psmHarness.getSUsdsValue(2, true), 3); 77 | assertEq(psmHarness.getSUsdsValue(3, true), 4); 78 | assertEq(psmHarness.getSUsdsValue(4, true), 5); 79 | 80 | assertEq(psmHarness.getSUsdsValue(1e18, false), 1.25e18); 81 | assertEq(psmHarness.getSUsdsValue(2e18, false), 2.5e18); 82 | assertEq(psmHarness.getSUsdsValue(3e18, false), 3.75e18); 83 | assertEq(psmHarness.getSUsdsValue(4e18, false), 5e18); 84 | 85 | // No rounding but shows why rounding occurred at lower values 86 | assertEq(psmHarness.getSUsdsValue(1e18, true), 1.25e18); 87 | assertEq(psmHarness.getSUsdsValue(2e18, true), 2.5e18); 88 | assertEq(psmHarness.getSUsdsValue(3e18, true), 3.75e18); 89 | assertEq(psmHarness.getSUsdsValue(4e18, true), 5e18); 90 | 91 | mockRateProvider.__setConversionRate(1.6e27); 92 | 93 | assertEq(psmHarness.getSUsdsValue(1, false), 1); 94 | assertEq(psmHarness.getSUsdsValue(2, false), 3); 95 | assertEq(psmHarness.getSUsdsValue(3, false), 4); 96 | assertEq(psmHarness.getSUsdsValue(4, false), 6); 97 | 98 | // Rounding up 99 | assertEq(psmHarness.getSUsdsValue(1, true), 2); 100 | assertEq(psmHarness.getSUsdsValue(2, true), 4); 101 | assertEq(psmHarness.getSUsdsValue(3, true), 5); 102 | assertEq(psmHarness.getSUsdsValue(4, true), 7); 103 | 104 | assertEq(psmHarness.getSUsdsValue(1e18, false), 1.6e18); 105 | assertEq(psmHarness.getSUsdsValue(2e18, false), 3.2e18); 106 | assertEq(psmHarness.getSUsdsValue(3e18, false), 4.8e18); 107 | assertEq(psmHarness.getSUsdsValue(4e18, false), 6.4e18); 108 | 109 | // No rounding but shows why rounding occurred at lower values 110 | assertEq(psmHarness.getSUsdsValue(1e18, true), 1.6e18); 111 | assertEq(psmHarness.getSUsdsValue(2e18, true), 3.2e18); 112 | assertEq(psmHarness.getSUsdsValue(3e18, true), 4.8e18); 113 | assertEq(psmHarness.getSUsdsValue(4e18, true), 6.4e18); 114 | 115 | mockRateProvider.__setConversionRate(0.8e27); 116 | 117 | assertEq(psmHarness.getSUsdsValue(1, false), 0); 118 | assertEq(psmHarness.getSUsdsValue(2, false), 1); 119 | assertEq(psmHarness.getSUsdsValue(3, false), 2); 120 | assertEq(psmHarness.getSUsdsValue(4, false), 3); 121 | 122 | // Rounding up 123 | assertEq(psmHarness.getSUsdsValue(1, true), 1); 124 | assertEq(psmHarness.getSUsdsValue(2, true), 2); 125 | assertEq(psmHarness.getSUsdsValue(3, true), 3); 126 | assertEq(psmHarness.getSUsdsValue(4, true), 4); 127 | 128 | assertEq(psmHarness.getSUsdsValue(1e18, false), 0.8e18); 129 | assertEq(psmHarness.getSUsdsValue(2e18, false), 1.6e18); 130 | assertEq(psmHarness.getSUsdsValue(3e18, false), 2.4e18); 131 | assertEq(psmHarness.getSUsdsValue(4e18, false), 3.2e18); 132 | 133 | // No rounding but shows why rounding occurred at lower values 134 | assertEq(psmHarness.getSUsdsValue(1e18, true), 0.8e18); 135 | assertEq(psmHarness.getSUsdsValue(2e18, true), 1.6e18); 136 | assertEq(psmHarness.getSUsdsValue(3e18, true), 2.4e18); 137 | assertEq(psmHarness.getSUsdsValue(4e18, true), 3.2e18); 138 | } 139 | 140 | function testFuzz_getSUsdsValue_roundDown(uint256 conversionRate, uint256 amount) public { 141 | conversionRate = _bound(conversionRate, 0, 1000e27); 142 | amount = _bound(amount, 0, SUSDS_TOKEN_MAX); 143 | 144 | mockRateProvider.__setConversionRate(conversionRate); 145 | 146 | assertEq(psmHarness.getSUsdsValue(amount, false), amount * conversionRate / 1e27); 147 | } 148 | 149 | function test_getAssetValue() public view { 150 | assertEq(psmHarness.getAssetValue(address(usdc), 1, false), psmHarness.getUsdcValue(1)); 151 | assertEq(psmHarness.getAssetValue(address(usdc), 2, false), psmHarness.getUsdcValue(2)); 152 | assertEq(psmHarness.getAssetValue(address(usdc), 3, false), psmHarness.getUsdcValue(3)); 153 | 154 | assertEq(psmHarness.getAssetValue(address(usdc), 1, true), psmHarness.getUsdcValue(1)); 155 | assertEq(psmHarness.getAssetValue(address(usdc), 2, true), psmHarness.getUsdcValue(2)); 156 | assertEq(psmHarness.getAssetValue(address(usdc), 3, true), psmHarness.getUsdcValue(3)); 157 | 158 | assertEq(psmHarness.getAssetValue(address(usdc), 1e6, false), psmHarness.getUsdcValue(1e6)); 159 | assertEq(psmHarness.getAssetValue(address(usdc), 2e6, false), psmHarness.getUsdcValue(2e6)); 160 | assertEq(psmHarness.getAssetValue(address(usdc), 3e6, false), psmHarness.getUsdcValue(3e6)); 161 | 162 | assertEq(psmHarness.getAssetValue(address(usdc), 1e6, true), psmHarness.getUsdcValue(1e6)); 163 | assertEq(psmHarness.getAssetValue(address(usdc), 2e6, true), psmHarness.getUsdcValue(2e6)); 164 | assertEq(psmHarness.getAssetValue(address(usdc), 3e6, true), psmHarness.getUsdcValue(3e6)); 165 | 166 | assertEq(psmHarness.getAssetValue(address(usds), 1, false), psmHarness.getUsdsValue(1)); 167 | assertEq(psmHarness.getAssetValue(address(usds), 2, false), psmHarness.getUsdsValue(2)); 168 | assertEq(psmHarness.getAssetValue(address(usds), 3, false), psmHarness.getUsdsValue(3)); 169 | 170 | assertEq(psmHarness.getAssetValue(address(usds), 1, true), psmHarness.getUsdsValue(1)); 171 | assertEq(psmHarness.getAssetValue(address(usds), 2, true), psmHarness.getUsdsValue(2)); 172 | assertEq(psmHarness.getAssetValue(address(usds), 3, true), psmHarness.getUsdsValue(3)); 173 | 174 | assertEq(psmHarness.getAssetValue(address(usds), 1e18, false), psmHarness.getUsdsValue(1e18)); 175 | assertEq(psmHarness.getAssetValue(address(usds), 2e18, false), psmHarness.getUsdsValue(2e18)); 176 | assertEq(psmHarness.getAssetValue(address(usds), 3e18, false), psmHarness.getUsdsValue(3e18)); 177 | 178 | assertEq(psmHarness.getAssetValue(address(usds), 1e18, true), psmHarness.getUsdsValue(1e18)); 179 | assertEq(psmHarness.getAssetValue(address(usds), 2e18, true), psmHarness.getUsdsValue(2e18)); 180 | assertEq(psmHarness.getAssetValue(address(usds), 3e18, true), psmHarness.getUsdsValue(3e18)); 181 | 182 | assertEq(psmHarness.getAssetValue(address(susds), 1, false), psmHarness.getSUsdsValue(1, false)); 183 | assertEq(psmHarness.getAssetValue(address(susds), 2, false), psmHarness.getSUsdsValue(2, false)); 184 | assertEq(psmHarness.getAssetValue(address(susds), 3, false), psmHarness.getSUsdsValue(3, false)); 185 | 186 | assertEq(psmHarness.getAssetValue(address(susds), 1e18, false), psmHarness.getSUsdsValue(1e18, false)); 187 | assertEq(psmHarness.getAssetValue(address(susds), 2e18, false), psmHarness.getSUsdsValue(2e18, false)); 188 | assertEq(psmHarness.getAssetValue(address(susds), 3e18, false), psmHarness.getSUsdsValue(3e18, false)); 189 | 190 | assertEq(psmHarness.getAssetValue(address(susds), 1, true), psmHarness.getSUsdsValue(1, true)); 191 | assertEq(psmHarness.getAssetValue(address(susds), 2, true), psmHarness.getSUsdsValue(2, true)); 192 | assertEq(psmHarness.getAssetValue(address(susds), 3, true), psmHarness.getSUsdsValue(3, true)); 193 | 194 | assertEq(psmHarness.getAssetValue(address(susds), 1e18, true), psmHarness.getSUsdsValue(1e18, true)); 195 | assertEq(psmHarness.getAssetValue(address(susds), 2e18, true), psmHarness.getSUsdsValue(2e18, true)); 196 | assertEq(psmHarness.getAssetValue(address(susds), 3e18, true), psmHarness.getSUsdsValue(3e18, true)); 197 | } 198 | 199 | function testFuzz_getAssetValue(uint256 amount) public view { 200 | amount = _bound(amount, 0, SUSDS_TOKEN_MAX); 201 | 202 | // `usdc` and `usds` return the same values whether `roundUp` is true or false 203 | assertEq(psmHarness.getAssetValue(address(usdc), amount, true), psmHarness.getUsdcValue(amount)); 204 | assertEq(psmHarness.getAssetValue(address(usdc), amount, true), psmHarness.getUsdcValue(amount)); 205 | assertEq(psmHarness.getAssetValue(address(usds), amount, false), psmHarness.getUsdsValue(amount)); 206 | assertEq(psmHarness.getAssetValue(address(usds), amount, false), psmHarness.getUsdsValue(amount)); 207 | 208 | // `susds` returns different values depending on the value of `roundUp`, but always same as underlying function 209 | assertEq(psmHarness.getAssetValue(address(susds), amount, false), psmHarness.getSUsdsValue(amount, false)); 210 | assertEq(psmHarness.getAssetValue(address(susds), amount, true), psmHarness.getSUsdsValue(amount, true)); 211 | } 212 | 213 | function test_getAssetValue_zeroAddress() public { 214 | vm.expectRevert("PSM3/invalid-asset-for-value"); 215 | psmHarness.getAssetValue(address(0), 1, false); 216 | } 217 | 218 | function test_getAssetCustodian() public view { 219 | assertEq(psmHarness.getAssetCustodian(address(usdc)), address(pocket)); 220 | assertEq(psmHarness.getAssetCustodian(address(usds)), address(psmHarness)); 221 | assertEq(psmHarness.getAssetCustodian(address(susds)), address(psmHarness)); 222 | } 223 | 224 | } 225 | 226 | contract GetPsmTotalValueTests is PSMTestBase { 227 | 228 | function test_totalAssets_balanceChanges() public { 229 | usds.mint(address(psm), 1e18); 230 | 231 | assertEq(psm.totalAssets(), 1e18); 232 | 233 | usdc.mint(address(pocket), 1e6); 234 | 235 | assertEq(psm.totalAssets(), 2e18); 236 | 237 | susds.mint(address(psm), 1e18); 238 | 239 | assertEq(psm.totalAssets(), 3.25e18); 240 | 241 | usds.burn(address(psm), 1e18); 242 | 243 | assertEq(psm.totalAssets(), 2.25e18); 244 | 245 | usdc.burn(address(pocket), 1e6); 246 | 247 | assertEq(psm.totalAssets(), 1.25e18); 248 | 249 | susds.burn(address(psm), 1e18); 250 | 251 | assertEq(psm.totalAssets(), 0); 252 | } 253 | 254 | function test_totalAssets_conversionRateChanges() public { 255 | assertEq(psm.totalAssets(), 0); 256 | 257 | usds.mint(address(psm), 1e18); 258 | usdc.mint(address(pocket), 1e6); 259 | susds.mint(address(psm), 1e18); 260 | 261 | assertEq(psm.totalAssets(), 3.25e18); 262 | 263 | mockRateProvider.__setConversionRate(1.5e27); 264 | 265 | assertEq(psm.totalAssets(), 3.5e18); 266 | 267 | mockRateProvider.__setConversionRate(0.8e27); 268 | 269 | assertEq(psm.totalAssets(), 2.8e18); 270 | } 271 | 272 | function test_totalAssets_bothChange() public { 273 | assertEq(psm.totalAssets(), 0); 274 | 275 | usds.mint(address(psm), 1e18); 276 | usdc.mint(address(pocket), 1e6); 277 | susds.mint(address(psm), 1e18); 278 | 279 | assertEq(psm.totalAssets(), 3.25e18); 280 | 281 | mockRateProvider.__setConversionRate(1.5e27); 282 | 283 | assertEq(psm.totalAssets(), 3.5e18); 284 | 285 | susds.mint(address(psm), 1e18); 286 | 287 | assertEq(psm.totalAssets(), 5e18); 288 | } 289 | 290 | function testFuzz_totalAssets( 291 | uint256 usdsAmount, 292 | uint256 usdcAmount, 293 | uint256 susdsAmount, 294 | uint256 conversionRate 295 | ) 296 | public 297 | { 298 | usdsAmount = _bound(usdsAmount, 0, USDS_TOKEN_MAX); 299 | usdcAmount = _bound(usdcAmount, 0, USDC_TOKEN_MAX); 300 | susdsAmount = _bound(susdsAmount, 0, SUSDS_TOKEN_MAX); 301 | conversionRate = _bound(conversionRate, 0.0001e27, 1000e27); 302 | 303 | usds.mint(address(psm), usdsAmount); 304 | usdc.mint(address(pocket), usdcAmount); 305 | susds.mint(address(psm), susdsAmount); 306 | 307 | mockRateProvider.__setConversionRate(conversionRate); 308 | 309 | assertEq( 310 | psm.totalAssets(), 311 | usdsAmount + (usdcAmount * 1e12) + (susdsAmount * conversionRate / 1e27) 312 | ); 313 | } 314 | 315 | } 316 | -------------------------------------------------------------------------------- /test/unit/InflationAttack.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { PSMTestBase } from "test/PSMTestBase.sol"; 7 | 8 | contract InflationAttackTests is PSMTestBase { 9 | 10 | address firstDepositor = makeAddr("firstDepositor"); 11 | address frontRunner = makeAddr("frontRunner"); 12 | address deployer = makeAddr("deployer"); 13 | 14 | function test_inflationAttack_noInitialDeposit_susds() public { 15 | // Step 1: Front runner deposits 1 sUSDS to get 1 share 16 | 17 | // Have to use susds because 1 USDC mints 1e12 shares 18 | _deposit(address(susds), frontRunner, 1); 19 | 20 | _runInflationAttack_noInitialDepositTest(); 21 | } 22 | 23 | function test_inflationAttack_noInitialDeposit_usds() public { 24 | // Step 1: Front runner deposits 1 sUSDS to get 1 share 25 | 26 | // Have to use USDS because 1 USDC mints 1e12 shares 27 | _deposit(address(usds), frontRunner, 1); 28 | 29 | _runInflationAttack_noInitialDepositTest(); 30 | } 31 | 32 | function test_inflationAttack_useInitialDeposit_susds() public { 33 | _deposit(address(susds), address(deployer), 0.8e18); // 1e18 shares 34 | 35 | // Step 1: Front runner deposits sUSDS to get 1 share 36 | 37 | // User tries to do the same attack, depositing one sUSDS for 1 share 38 | _deposit(address(susds), frontRunner, 1); 39 | 40 | _runInflationAttack_useInitialDepositTest(); 41 | } 42 | 43 | function test_inflationAttack_useInitialDeposit_usds() public { 44 | _deposit(address(usds), address(deployer), 1e18); // 1e18 shares 45 | 46 | // Step 1: Front runner deposits usds to get 1 share 47 | 48 | // User tries to do the same attack, depositing one sUSDS for 1 share 49 | _deposit(address(usds), frontRunner, 1); 50 | 51 | _runInflationAttack_useInitialDepositTest(); 52 | } 53 | 54 | function _runInflationAttack_noInitialDepositTest() internal { 55 | assertEq(psm.shares(frontRunner), 1); 56 | 57 | // Step 2: Front runner transfers 10m USDC to inflate the exchange rate to 1:(10m + 1) 58 | 59 | deal(address(usdc), frontRunner, 10_000_000e6); 60 | 61 | assertEq(psm.convertToAssetValue(1), 1); 62 | 63 | vm.prank(frontRunner); 64 | usdc.transfer(pocket, 10_000_000e6); 65 | 66 | // Highly inflated exchange rate 67 | assertEq(psm.convertToAssetValue(1), 10_000_000e18 + 1); 68 | 69 | // Step 3: First depositor deposits 20 million USDC, only gets one share because rounding 70 | // error gives them 1 instead of 2 shares, worth 15m USDC 71 | 72 | _deposit(address(usdc), firstDepositor, 20_000_000e6); 73 | 74 | assertEq(psm.shares(firstDepositor), 1); 75 | 76 | // 1 share = 3 million USDC / 2 shares = 1.5 million USDC 77 | assertEq(psm.convertToAssetValue(1), 15_000_000e18); 78 | 79 | // Step 4: Both users withdraw the max amount of funds they can 80 | 81 | _withdraw(address(usdc), firstDepositor, type(uint256).max); 82 | _withdraw(address(usdc), frontRunner, type(uint256).max); 83 | 84 | assertEq(usdc.balanceOf(pocket), 0); 85 | 86 | // Front runner profits 5m USDC, first depositor loses 5m USDC 87 | assertEq(usdc.balanceOf(firstDepositor), 15_000_000e6); 88 | assertEq(usdc.balanceOf(frontRunner), 15_000_000e6); 89 | } 90 | 91 | function _runInflationAttack_useInitialDepositTest() internal { 92 | assertEq(psm.shares(frontRunner), 1); 93 | 94 | // Step 2: Front runner transfers 10m USDC to inflate the exchange rate to 1:(10m + 1) 95 | 96 | assertEq(psm.convertToAssetValue(1), 1); 97 | 98 | deal(address(usdc), frontRunner, 10_000_000e6); 99 | 100 | vm.prank(frontRunner); 101 | usdc.transfer(pocket, 10_000_000e6); 102 | 103 | // Still inflated, but all value is transferred to existing holder, deployer 104 | assertEq(psm.convertToAssetValue(1), 0.00000000001e18); 105 | 106 | // Step 3: First depositor deposits 20 million USDC, this time rounding is not an issue 107 | // so value reflected is much more accurate 108 | 109 | _deposit(address(usdc), firstDepositor, 20_000_000e6); 110 | 111 | assertEq(psm.shares(firstDepositor), 1.999999800000020001e18); 112 | 113 | // Higher amount of initial shares means lower rounding error 114 | assertEq(psm.convertToAssetValue(1.999999800000020001e18), 19_999_999.999999999996673334e18); 115 | 116 | // Step 4: Both users withdraw the max amount of funds they can 117 | 118 | _withdraw(address(usdc), firstDepositor, type(uint256).max); 119 | _withdraw(address(usdc), frontRunner, type(uint256).max); 120 | _withdraw(address(usdc), deployer, type(uint256).max); 121 | 122 | // Front runner loses full 10m USDC to the deployer that had all shares at the beginning, first depositor loses nothing (1e-6 USDC) 123 | assertEq(usdc.balanceOf(firstDepositor), 19_999_999.999999e6); 124 | assertEq(usdc.balanceOf(frontRunner), 0); 125 | assertEq(usdc.balanceOf(deployer), 10_000_000.000001e6); 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /test/unit/PreviewDeposit.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { MockRateProvider, PSMTestBase } from "test/PSMTestBase.sol"; 7 | 8 | contract PSMPreviewDeposit_FailureTests is PSMTestBase { 9 | 10 | function test_previewDeposit_invalidAsset() public { 11 | vm.expectRevert("PSM3/invalid-asset-for-value"); 12 | psm.previewDeposit(makeAddr("other-token"), 1); 13 | } 14 | 15 | } 16 | 17 | contract PSMPreviewDeposit_SuccessTests is PSMTestBase { 18 | 19 | address depositor = makeAddr("depositor"); 20 | 21 | function test_previewDeposit_usds_firstDeposit() public view { 22 | assertEq(psm.previewDeposit(address(usds), 1), 1); 23 | assertEq(psm.previewDeposit(address(usds), 2), 2); 24 | assertEq(psm.previewDeposit(address(usds), 3), 3); 25 | 26 | assertEq(psm.previewDeposit(address(usds), 1e18), 1e18); 27 | assertEq(psm.previewDeposit(address(usds), 2e18), 2e18); 28 | assertEq(psm.previewDeposit(address(usds), 3e18), 3e18); 29 | } 30 | 31 | function testFuzz_previewDeposit_usds_firstDeposit(uint256 amount) public view { 32 | amount = _bound(amount, 0, USDS_TOKEN_MAX); 33 | assertEq(psm.previewDeposit(address(usds), amount), amount); 34 | } 35 | 36 | function test_previewDeposit_usdc_firstDeposit() public view { 37 | assertEq(psm.previewDeposit(address(usdc), 1), 1e12); 38 | assertEq(psm.previewDeposit(address(usdc), 2), 2e12); 39 | assertEq(psm.previewDeposit(address(usdc), 3), 3e12); 40 | 41 | assertEq(psm.previewDeposit(address(usdc), 1e6), 1e18); 42 | assertEq(psm.previewDeposit(address(usdc), 2e6), 2e18); 43 | assertEq(psm.previewDeposit(address(usdc), 3e6), 3e18); 44 | } 45 | 46 | function testFuzz_previewDeposit_usdc_firstDeposit(uint256 amount) public view { 47 | amount = _bound(amount, 0, USDC_TOKEN_MAX); 48 | assertEq(psm.previewDeposit(address(usdc), amount), amount * 1e12); 49 | } 50 | 51 | function test_previewDeposit_susds_firstDeposit() public view { 52 | assertEq(psm.previewDeposit(address(susds), 1), 1); 53 | assertEq(psm.previewDeposit(address(susds), 2), 2); 54 | assertEq(psm.previewDeposit(address(susds), 3), 3); 55 | assertEq(psm.previewDeposit(address(susds), 4), 5); 56 | 57 | assertEq(psm.previewDeposit(address(susds), 1e18), 1.25e18); 58 | assertEq(psm.previewDeposit(address(susds), 2e18), 2.50e18); 59 | assertEq(psm.previewDeposit(address(susds), 3e18), 3.75e18); 60 | assertEq(psm.previewDeposit(address(susds), 4e18), 5.00e18); 61 | } 62 | 63 | function testFuzz_previewDeposit_susds_firstDeposit(uint256 amount) public view { 64 | amount = _bound(amount, 0, SUSDS_TOKEN_MAX); 65 | assertEq(psm.previewDeposit(address(susds), amount), amount * 1.25e27 / 1e27); 66 | } 67 | 68 | function test_previewDeposit_afterDepositsAndExchangeRateIncrease() public { 69 | _assertOneToOne(); 70 | 71 | _deposit(address(usds), depositor, 1e18); 72 | _assertOneToOne(); 73 | 74 | _deposit(address(usdc), depositor, 1e6); 75 | _assertOneToOne(); 76 | 77 | _deposit(address(susds), depositor, 0.8e18); 78 | _assertOneToOne(); 79 | 80 | mockRateProvider.__setConversionRate(2e27); 81 | 82 | // $300 dollars of value deposited, 300 shares minted. 83 | // sUSDS portion becomes worth $160, full pool worth $360, each share worth $1.20 84 | // 1 USDC = 1/1.20 = 0.833... 85 | assertEq(psm.previewDeposit(address(usds), 1e18), 0.833333333333333333e18); 86 | assertEq(psm.previewDeposit(address(usdc), 1e6), 0.833333333333333333e18); 87 | assertEq(psm.previewDeposit(address(susds), 1e18), 1.666666666666666666e18); // 1 sUSDS = $2 88 | } 89 | 90 | function testFuzz_previewDeposit_afterDepositsAndExchangeRateIncrease( 91 | uint256 amount1, 92 | uint256 amount2, 93 | uint256 amount3, 94 | uint256 conversionRate, 95 | uint256 previewAmount 96 | ) public { 97 | amount1 = _bound(amount1, 1, USDS_TOKEN_MAX); 98 | amount2 = _bound(amount2, 1, USDC_TOKEN_MAX); 99 | amount3 = _bound(amount3, 1, SUSDS_TOKEN_MAX); 100 | conversionRate = _bound(conversionRate, 1.00e27, 1000e27); 101 | previewAmount = _bound(previewAmount, 0, USDS_TOKEN_MAX); 102 | 103 | _assertOneToOne(); 104 | 105 | _deposit(address(usds), depositor, amount1); 106 | _assertOneToOne(); 107 | 108 | _deposit(address(usdc), depositor, amount2); 109 | _assertOneToOne(); 110 | 111 | _deposit(address(susds), depositor, amount3); 112 | _assertOneToOne(); 113 | 114 | mockRateProvider.__setConversionRate(conversionRate); 115 | 116 | uint256 totalSharesMinted = amount1 + amount2 * 1e12 + amount3 * 1.25e27 / 1e27; 117 | uint256 totalValue = amount1 + amount2 * 1e12 + amount3 * conversionRate / 1e27; 118 | uint256 usdcPreviewAmount = previewAmount / 1e12; 119 | 120 | assertEq(psm.previewDeposit(address(usds), previewAmount), previewAmount * totalSharesMinted / totalValue); 121 | assertEq(psm.previewDeposit(address(usdc), usdcPreviewAmount), usdcPreviewAmount * 1e12 * totalSharesMinted / totalValue); // Divide then multiply to replicate rounding 122 | assertEq(psm.previewDeposit(address(susds), previewAmount), (previewAmount * conversionRate / 1e27) * totalSharesMinted / totalValue); 123 | } 124 | 125 | function _assertOneToOne() internal view { 126 | assertEq(psm.previewDeposit(address(usds), 1e18), 1e18); 127 | assertEq(psm.previewDeposit(address(usdc), 1e6), 1e18); 128 | assertEq(psm.previewDeposit(address(susds), 1e18), 1.25e18); 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /test/unit/PreviewWithdraw.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { MockRateProvider, PSMTestBase } from "test/PSMTestBase.sol"; 7 | 8 | contract PSMPreviewWithdraw_FailureTests is PSMTestBase { 9 | 10 | function test_previewWithdraw_invalidAsset() public { 11 | vm.expectRevert("PSM3/invalid-asset"); 12 | psm.previewWithdraw(makeAddr("other-token"), 1); 13 | } 14 | 15 | } 16 | 17 | contract PSMPreviewWithdraw_ZeroAssetsTests is PSMTestBase { 18 | 19 | // Always returns zero because there is no balance of assets in the PSM in this case 20 | function test_previewWithdraw_zeroTotalAssets() public { 21 | ( uint256 shares1, uint256 assets1 ) = psm.previewWithdraw(address(usds), 1e18); 22 | ( uint256 shares2, uint256 assets2 ) = psm.previewWithdraw(address(usdc), 1e6); 23 | ( uint256 shares3, uint256 assets3 ) = psm.previewWithdraw(address(susds), 1e18); 24 | 25 | assertEq(shares1, 0); 26 | assertEq(assets1, 0); 27 | assertEq(shares2, 0); 28 | assertEq(assets2, 0); 29 | assertEq(shares3, 0); 30 | assertEq(assets3, 0); 31 | 32 | mockRateProvider.__setConversionRate(2e27); 33 | 34 | ( shares1, assets1 ) = psm.previewWithdraw(address(usds), 1e18); 35 | ( shares2, assets2 ) = psm.previewWithdraw(address(usdc), 1e6); 36 | ( shares3, assets3 ) = psm.previewWithdraw(address(susds), 1e18); 37 | 38 | assertEq(shares1, 0); 39 | assertEq(assets1, 0); 40 | assertEq(shares2, 0); 41 | assertEq(assets2, 0); 42 | assertEq(shares3, 0); 43 | assertEq(assets3, 0); 44 | } 45 | 46 | } 47 | 48 | contract PSMPreviewWithdraw_SuccessTests is PSMTestBase { 49 | 50 | function setUp() public override { 51 | super.setUp(); 52 | // Setup so that address(this) has the most shares, higher underlying balance than PSM 53 | // balance of sUSDS and USDC 54 | _deposit(address(usds), address(this), 100e18); 55 | _deposit(address(usdc), makeAddr("usdc-user"), 10e6); 56 | _deposit(address(susds), makeAddr("susds-user"), 1e18); 57 | } 58 | 59 | function test_previewWithdraw_usds_amountLtUnderlyingBalance() public view { 60 | ( uint256 shares, uint256 assets ) = psm.previewWithdraw(address(usds), 100e18 - 1); 61 | assertEq(shares, 100e18 - 1); 62 | assertEq(assets, 100e18 - 1); 63 | } 64 | 65 | function test_previewWithdraw_usds_amountEqUnderlyingBalance() public view { 66 | ( uint256 shares, uint256 assets ) = psm.previewWithdraw(address(usds), 100e18); 67 | assertEq(shares, 100e18); 68 | assertEq(assets, 100e18); 69 | } 70 | 71 | function test_previewWithdraw_usds_amountGtUnderlyingBalance() public view { 72 | ( uint256 shares, uint256 assets ) = psm.previewWithdraw(address(usds), 100e18 + 1); 73 | assertEq(shares, 100e18); 74 | assertEq(assets, 100e18); 75 | } 76 | 77 | function test_previewWithdraw_usdc_amountLtUnderlyingBalanceAndLtPsmBalance() public view { 78 | ( uint256 shares, uint256 assets ) = psm.previewWithdraw(address(usdc), 10e6 - 1); 79 | assertEq(shares, 10e18 - 1e12); 80 | assertEq(assets, 10e6 - 1); 81 | } 82 | 83 | function test_previewWithdraw_usdc_amountLtUnderlyingBalanceAndEqPsmBalance() public view { 84 | ( uint256 shares, uint256 assets ) = psm.previewWithdraw(address(usdc), 10e6); 85 | assertEq(shares, 10e18); 86 | assertEq(assets, 10e6); 87 | } 88 | 89 | function test_previewWithdraw_usdc_amountLtUnderlyingBalanceAndGtPsmBalance() public view { 90 | ( uint256 shares, uint256 assets ) = psm.previewWithdraw(address(usdc), 10e6 + 1); 91 | assertEq(shares, 10e18); 92 | assertEq(assets, 10e6); 93 | } 94 | 95 | function test_previewWithdraw_susds_amountLtUnderlyingBalanceAndLtPsmBalance() public view { 96 | ( uint256 shares, uint256 assets ) = psm.previewWithdraw(address(susds), 1e18 - 1); 97 | assertEq(shares, 1.25e18 - 1); 98 | assertEq(assets, 1e18 - 1); 99 | } 100 | 101 | function test_previewWithdraw_susds_amountLtUnderlyingBalanceAndEqPsmBalance() public view { 102 | ( uint256 shares, uint256 assets ) = psm.previewWithdraw(address(susds), 1e18); 103 | assertEq(shares, 1.25e18); 104 | assertEq(assets, 1e18); 105 | } 106 | 107 | function test_previewWithdraw_susds_amountLtUnderlyingBalanceAndGtPsmBalance() public view { 108 | ( uint256 shares, uint256 assets ) = psm.previewWithdraw(address(susds), 1e18 + 1); 109 | assertEq(shares, 1.25e18); 110 | assertEq(assets, 1e18); 111 | } 112 | 113 | } 114 | 115 | contract PSMPreviewWithdraw_SuccessFuzzTests is PSMTestBase { 116 | 117 | struct TestParams { 118 | uint256 amount1; 119 | uint256 amount2; 120 | uint256 amount3; 121 | uint256 previewAmount1; 122 | uint256 previewAmount2; 123 | uint256 previewAmount3; 124 | uint256 conversionRate; 125 | } 126 | 127 | function testFuzz_previewWithdraw(TestParams memory params) public { 128 | params.amount1 = _bound(params.amount1, 1, USDS_TOKEN_MAX); 129 | params.amount2 = _bound(params.amount2, 1, USDC_TOKEN_MAX); 130 | params.amount3 = _bound(params.amount3, 1, SUSDS_TOKEN_MAX); 131 | 132 | // Only covering case of amount being below underlying to focus on value conversion 133 | // and avoid reimplementation of contract logic for dealing with capping amounts 134 | params.previewAmount1 = _bound(params.previewAmount1, 0, params.amount1); 135 | params.previewAmount2 = _bound(params.previewAmount2, 0, params.amount2); 136 | params.previewAmount3 = _bound(params.previewAmount3, 0, params.amount3); 137 | 138 | _deposit(address(usds), address(this), params.amount1); 139 | _deposit(address(usdc), address(this), params.amount2); 140 | _deposit(address(susds), address(this), params.amount3); 141 | 142 | ( uint256 shares1, uint256 assets1 ) = psm.previewWithdraw(address(usds), params.previewAmount1); 143 | ( uint256 shares2, uint256 assets2 ) = psm.previewWithdraw(address(usdc), params.previewAmount2); 144 | ( uint256 shares3, uint256 assets3 ) = psm.previewWithdraw(address(susds), params.previewAmount3); 145 | 146 | uint256 totalSharesMinted = params.amount1 + params.amount2 * 1e12 + params.amount3 * 1.25e27 / 1e27; 147 | uint256 totalValue = totalSharesMinted; 148 | 149 | // Assert shares are always rounded up, max of 1 wei difference except for sUSDS 150 | assertLe(shares1 - (params.previewAmount1 * totalSharesMinted / totalValue), 1); 151 | assertLe(shares2 - (params.previewAmount2 * 1e12 * totalSharesMinted / totalValue), 1); 152 | assertLe(shares3 - (params.previewAmount3 * 1.25e27 / 1e27 * totalSharesMinted / totalValue), 3); 153 | 154 | assertEq(assets1, params.previewAmount1); 155 | assertEq(assets2, params.previewAmount2); 156 | assertEq(assets3, params.previewAmount3); 157 | 158 | params.conversionRate = _bound(params.conversionRate, 0.001e27, 1000e27); 159 | mockRateProvider.__setConversionRate(params.conversionRate); 160 | 161 | // susds value accrual changes the value of shares in the PSM 162 | totalValue = params.amount1 + params.amount2 * 1e12 + params.amount3 * params.conversionRate / 1e27; 163 | 164 | ( shares1, assets1 ) = psm.previewWithdraw(address(usds), params.previewAmount1); 165 | ( shares2, assets2 ) = psm.previewWithdraw(address(usdc), params.previewAmount2); 166 | ( shares3, assets3 ) = psm.previewWithdraw(address(susds), params.previewAmount3); 167 | 168 | uint256 susdsConvertedAmount = params.previewAmount3 * params.conversionRate / 1e27; 169 | 170 | // Assert shares are always rounded up, max of 1 wei difference except for sUSDS 171 | // totalSharesMinted / totalValue is an integer amount that scales as the rate scales by orders of magnitude 172 | assertLe(shares1 - (params.previewAmount1 * totalSharesMinted / totalValue), 1); 173 | assertLe(shares2 - (params.previewAmount2 * 1e12 * totalSharesMinted / totalValue), 1); 174 | assertLe(shares3 - (susdsConvertedAmount * totalSharesMinted / totalValue), 3 + totalSharesMinted / totalValue); 175 | 176 | assertApproxEqAbs(assets1, params.previewAmount1, 1); 177 | assertApproxEqAbs(assets2, params.previewAmount2, 1); 178 | assertApproxEqAbs(assets3, params.previewAmount3, 1); 179 | } 180 | 181 | } 182 | -------------------------------------------------------------------------------- /test/unit/Rounding.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { MockRateProvider, PSMTestBase } from "test/PSMTestBase.sol"; 7 | 8 | import { MockERC20 } from "erc20-helpers/MockERC20.sol"; 9 | 10 | contract RoundingTests is PSMTestBase { 11 | 12 | address user = makeAddr("user"); 13 | 14 | function setUp() public override { 15 | super.setUp(); 16 | 17 | // Seed the PSM with max liquidity so withdrawals can always be performed 18 | _deposit(address(usds), address(this), USDS_TOKEN_MAX); 19 | _deposit(address(susds), address(this), SUSDS_TOKEN_MAX); 20 | _deposit(address(usdc), address(this), USDC_TOKEN_MAX); 21 | 22 | // Set an exchange rate that will cause rounding 23 | mockRateProvider.__setConversionRate(1.25e27 * uint256(100) / 99); 24 | } 25 | 26 | function test_roundAgainstUser_usds() public { 27 | _deposit(address(usds), address(user), 1e18); 28 | 29 | assertEq(usds.balanceOf(address(user)), 0); 30 | 31 | vm.prank(user); 32 | psm.withdraw(address(usds), address(user), 1e18); 33 | 34 | assertEq(usds.balanceOf(address(user)), 1e18 - 1); // Rounds against user 35 | } 36 | 37 | function test_roundAgainstUser_usdc() public { 38 | _deposit(address(usdc), address(user), 1e6); 39 | 40 | assertEq(usdc.balanceOf(address(user)), 0); 41 | 42 | vm.prank(user); 43 | psm.withdraw(address(usdc), address(user), 1e6); 44 | 45 | assertEq(usdc.balanceOf(address(user)), 1e6 - 1); // Rounds against user 46 | } 47 | 48 | function test_roundAgainstUser_susds() public { 49 | _deposit(address(susds), address(user), 1e18); 50 | 51 | assertEq(susds.balanceOf(address(user)), 0); 52 | 53 | vm.prank(user); 54 | psm.withdraw(address(susds), address(user), 1e18); 55 | 56 | assertEq(susds.balanceOf(address(user)), 1e18 - 1); // Rounds against user 57 | } 58 | 59 | function testFuzz_roundingAgainstUser_multiUser_usds( 60 | uint256 rate1, 61 | uint256 rate2, 62 | uint256 amount1, 63 | uint256 amount2 64 | ) 65 | public 66 | { 67 | _runRoundingAgainstUsersFuzzTest( 68 | usds, 69 | USDS_TOKEN_MAX, 70 | rate1, 71 | rate2, 72 | amount1, 73 | amount2, 74 | 4 75 | ); 76 | } 77 | 78 | function testFuzz_roundingAgainstUser_multiUser_usdc( 79 | uint256 rate1, 80 | uint256 rate2, 81 | uint256 amount1, 82 | uint256 amount2 83 | ) 84 | public 85 | { 86 | _runRoundingAgainstUsersFuzzTest( 87 | usdc, 88 | USDC_TOKEN_MAX, 89 | rate1, 90 | rate2, 91 | amount1, 92 | amount2, 93 | 1 // Lower precision so rounding errors are lower 94 | ); 95 | } 96 | 97 | function testFuzz_roundingAgainstUser_multiUser_susds( 98 | uint256 rate1, 99 | uint256 rate2, 100 | uint256 amount1, 101 | uint256 amount2 102 | ) 103 | public 104 | { 105 | _runRoundingAgainstUsersFuzzTest( 106 | susds, 107 | SUSDS_TOKEN_MAX, 108 | rate1, 109 | rate2, 110 | amount1, 111 | amount2, 112 | 4 // susds has higher rounding errors that can be introduced because of rate conversion 113 | ); 114 | } 115 | 116 | function _runRoundingAgainstUsersFuzzTest( 117 | MockERC20 asset, 118 | uint256 tokenMax, 119 | uint256 rate1, 120 | uint256 rate2, 121 | uint256 amount1, 122 | uint256 amount2, 123 | uint256 roundingTolerance 124 | ) internal { 125 | address user1 = makeAddr("user1"); 126 | address user2 = makeAddr("user2"); 127 | 128 | rate1 = _bound(rate1, 1e27, 10e27); 129 | rate2 = _bound(rate2, rate1, 10e27); 130 | 131 | amount1 = _bound(amount1, 1, tokenMax); 132 | amount2 = _bound(amount2, 1, tokenMax); 133 | 134 | mockRateProvider.__setConversionRate(rate1); 135 | 136 | _deposit(address(asset), address(user1), amount1); 137 | 138 | assertEq(asset.balanceOf(address(user1)), 0); 139 | 140 | vm.prank(user1); 141 | psm.withdraw(address(asset), address(user1), amount1); 142 | 143 | // Rounds against user up to one unit, always rounding down 144 | assertApproxEqAbs(asset.balanceOf(address(user1)), amount1, roundingTolerance); 145 | assertLe(asset.balanceOf(address(user1)), amount1); 146 | 147 | mockRateProvider.__setConversionRate(rate2); 148 | 149 | _deposit(address(asset), address(user2), amount2); 150 | 151 | assertEq(asset.balanceOf(address(user2)), 0); 152 | 153 | vm.prank(user2); 154 | psm.withdraw(address(asset), address(user2), amount2); 155 | 156 | // Rounds against user up to one unit, always rounding down 157 | 158 | assertApproxEqAbs(asset.balanceOf(address(user2)), amount2, roundingTolerance); 159 | assertLe(asset.balanceOf(address(user2)), amount2); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /test/unit/SetPocket.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { PSMTestBase } from "test/PSMTestBase.sol"; 5 | 6 | contract PSMSetPocketFailureTests is PSMTestBase { 7 | 8 | function test_setPocket_invalidOwner() public { 9 | vm.expectRevert( 10 | abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", 11 | address(this)) 12 | ); 13 | psm.setPocket(address(1)); 14 | } 15 | 16 | function test_setPocket_invalidPocket() public { 17 | vm.prank(owner); 18 | vm.expectRevert("PSM3/invalid-pocket"); 19 | psm.setPocket(address(0)); 20 | } 21 | 22 | function test_setPocket_samePocket() public { 23 | vm.prank(owner); 24 | vm.expectRevert("PSM3/same-pocket"); 25 | psm.setPocket(pocket); 26 | } 27 | 28 | 29 | // NOTE: In practice this won't happen because pockets will infinite approve PSM 30 | function test_setPocket_insufficientAllowanceBoundary() public { 31 | address pocket1 = makeAddr("pocket1"); 32 | address pocket2 = makeAddr("pocket2"); 33 | 34 | vm.prank(owner); 35 | psm.setPocket(pocket1); 36 | 37 | vm.prank(pocket1); 38 | usdc.approve(address(psm), 1_000_000e6); 39 | 40 | deal(address(usdc), pocket1, 1_000_000e6 + 1); 41 | 42 | vm.prank(owner); 43 | vm.expectRevert("SafeERC20/transfer-from-failed"); 44 | psm.setPocket(pocket2); 45 | 46 | deal(address(usdc), pocket1, 1_000_000e6); 47 | 48 | vm.prank(owner); 49 | psm.setPocket(pocket2); 50 | } 51 | 52 | } 53 | 54 | contract PSMSetPocketSuccessTests is PSMTestBase { 55 | 56 | address pocket1 = makeAddr("pocket1"); 57 | address pocket2 = makeAddr("pocket2"); 58 | 59 | event PocketSet( 60 | address indexed oldPocket, 61 | address indexed newPocket, 62 | uint256 amountTransferred 63 | ); 64 | 65 | function test_setPocket_pocketIsPsm() public { 66 | vm.prank(owner); 67 | psm.setPocket(address(psm)); 68 | 69 | deal(address(usdc), address(psm), 1_000_000e6); 70 | 71 | assertEq(usdc.balanceOf(address(psm)), 1_000_000e6); 72 | assertEq(usdc.balanceOf(pocket1), 0); 73 | 74 | assertEq(psm.totalAssets(), 1_000_000e18); 75 | 76 | assertEq(psm.pocket(), address(psm)); 77 | 78 | vm.prank(owner); 79 | vm.expectEmit(address(psm)); 80 | emit PocketSet(address(psm), pocket1, 1_000_000e6); 81 | psm.setPocket(pocket1); 82 | 83 | assertEq(usdc.balanceOf(address(psm)), 0); 84 | assertEq(usdc.balanceOf(pocket1), 1_000_000e6); 85 | 86 | assertEq(psm.totalAssets(), 1_000_000e18); 87 | 88 | assertEq(psm.pocket(), pocket1); 89 | } 90 | 91 | function test_setPocket_pocketIsNotPsm() public { 92 | vm.prank(owner); 93 | psm.setPocket(pocket1); 94 | 95 | vm.prank(pocket1); 96 | usdc.approve(address(psm), 1_000_000e6); 97 | 98 | deal(address(usdc), address(pocket1), 1_000_000e6); 99 | 100 | assertEq(usdc.allowance(pocket1, address(psm)), 1_000_000e6); 101 | 102 | assertEq(usdc.balanceOf(pocket1), 1_000_000e6); 103 | assertEq(usdc.balanceOf(pocket2), 0); 104 | 105 | assertEq(psm.totalAssets(), 1_000_000e18); 106 | 107 | assertEq(psm.pocket(), pocket1); 108 | 109 | vm.prank(owner); 110 | vm.expectEmit(address(psm)); 111 | emit PocketSet(pocket1, pocket2, 1_000_000e6); 112 | psm.setPocket(pocket2); 113 | 114 | assertEq(usdc.allowance(pocket1, address(psm)), 0); 115 | 116 | assertEq(usdc.balanceOf(pocket1), 0); 117 | assertEq(usdc.balanceOf(pocket2), 1_000_000e6); 118 | 119 | assertEq(psm.totalAssets(), 1_000_000e18); 120 | 121 | assertEq(psm.pocket(), pocket2); 122 | } 123 | 124 | function test_setPocket_valueStaysConstant() public { 125 | // NOTE: Need to set pocket to PSM because setUp sets pocket to `pocket`, and zero funds 126 | // are transferred from `pocket1` to `pocket` 127 | vm.prank(owner); 128 | psm.setPocket(address(psm)); 129 | 130 | _deposit(address(usdc), owner, 1_000_000e6); 131 | _deposit(address(usds), owner, 1_000_000e18); 132 | _deposit(address(susds), owner, 800_000e18); 133 | 134 | assertEq(psm.totalAssets(), 3_000_000e18); 135 | 136 | vm.prank(owner); 137 | psm.setPocket(pocket1); 138 | 139 | assertEq(psm.totalAssets(), 3_000_000e18); 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /test/unit/SwapExactIn.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { PSM3 } from "src/PSM3.sol"; 7 | 8 | import { MockERC20, PSMTestBase } from "test/PSMTestBase.sol"; 9 | 10 | contract PSMSwapExactInFailureTests is PSMTestBase { 11 | 12 | address public swapper = makeAddr("swapper"); 13 | address public receiver = makeAddr("receiver"); 14 | 15 | function setUp() public override { 16 | super.setUp(); 17 | 18 | // Needed for boundary success conditions 19 | usdc.mint(pocket, 100e6); 20 | susds.mint(address(psm), 100e18); 21 | } 22 | 23 | function test_swapExactIn_amountZero() public { 24 | vm.expectRevert("PSM3/invalid-amountIn"); 25 | psm.swapExactIn(address(usdc), address(susds), 0, 0, receiver, 0); 26 | } 27 | 28 | function test_swapExactIn_receiverZero() public { 29 | vm.expectRevert("PSM3/invalid-receiver"); 30 | psm.swapExactIn(address(usdc), address(susds), 100e6, 80e18, address(0), 0); 31 | } 32 | 33 | function test_swapExactIn_invalid_assetIn() public { 34 | vm.expectRevert("PSM3/invalid-asset"); 35 | psm.swapExactIn(makeAddr("other-token"), address(susds), 100e6, 80e18, receiver, 0); 36 | } 37 | 38 | function test_swapExactIn_invalid_assetOut() public { 39 | vm.expectRevert("PSM3/invalid-asset"); 40 | psm.swapExactIn(address(usdc), makeAddr("other-token"), 100e6, 80e18, receiver, 0); 41 | } 42 | 43 | function test_swapExactIn_bothUsdc() public { 44 | vm.expectRevert("PSM3/invalid-asset"); 45 | psm.swapExactIn(address(usdc), address(usdc), 100e6, 80e18, receiver, 0); 46 | } 47 | 48 | function test_swapExactIn_bothUsds() public { 49 | vm.expectRevert("PSM3/invalid-asset"); 50 | psm.swapExactIn(address(usds), address(usds), 100e6, 80e18, receiver, 0); 51 | } 52 | 53 | function test_swapExactIn_bothSUsds() public { 54 | vm.expectRevert("PSM3/invalid-asset"); 55 | psm.swapExactIn(address(susds), address(susds), 100e6, 80e18, receiver, 0); 56 | } 57 | 58 | function test_swapExactIn_minAmountOutBoundary() public { 59 | usdc.mint(swapper, 100e6); 60 | 61 | vm.startPrank(swapper); 62 | 63 | usdc.approve(address(psm), 100e6); 64 | 65 | uint256 expectedAmountOut = psm.previewSwapExactIn(address(usdc), address(susds), 100e6); 66 | 67 | assertEq(expectedAmountOut, 80e18); 68 | 69 | vm.expectRevert("PSM3/amountOut-too-low"); 70 | psm.swapExactIn(address(usdc), address(susds), 100e6, 80e18 + 1, receiver, 0); 71 | 72 | psm.swapExactIn(address(usdc), address(susds), 100e6, 80e18, receiver, 0); 73 | } 74 | 75 | function test_swapExactIn_insufficientApproveBoundary() public { 76 | usdc.mint(swapper, 100e6); 77 | 78 | vm.startPrank(swapper); 79 | 80 | usdc.approve(address(psm), 100e6 - 1); 81 | 82 | vm.expectRevert("SafeERC20/transfer-from-failed"); 83 | psm.swapExactIn(address(usdc), address(susds), 100e6, 80e18, receiver, 0); 84 | 85 | usdc.approve(address(psm), 100e6); 86 | 87 | psm.swapExactIn(address(usdc), address(susds), 100e6, 80e18, receiver, 0); 88 | } 89 | 90 | function test_swapExactIn_insufficientUserBalanceBoundary() public { 91 | usdc.mint(swapper, 100e6 - 1); 92 | 93 | vm.startPrank(swapper); 94 | 95 | usdc.approve(address(psm), 100e6); 96 | 97 | vm.expectRevert("SafeERC20/transfer-from-failed"); 98 | psm.swapExactIn(address(usdc), address(susds), 100e6, 80e18, receiver, 0); 99 | 100 | usdc.mint(swapper, 1); 101 | 102 | psm.swapExactIn(address(usdc), address(susds), 100e6, 80e18, receiver, 0); 103 | } 104 | 105 | function test_swapExactIn_insufficientPsmBalanceBoundary() public { 106 | // NOTE: Using 2 instead of 1 here because 1/1.25 rounds to 0, 2/1.25 rounds to 1 107 | // this is because the conversion rate is divided out before the precision conversion 108 | // is done. 109 | usdc.mint(swapper, 125e6 + 2); 110 | 111 | vm.startPrank(swapper); 112 | 113 | usdc.approve(address(psm), 125e6 + 2); 114 | 115 | uint256 expectedAmountOut = psm.previewSwapExactIn(address(usdc), address(susds), 125e6 + 2); 116 | 117 | assertEq(expectedAmountOut, 100.000001e18); // More than balance of sUSDS 118 | 119 | vm.expectRevert("SafeERC20/transfer-failed"); 120 | psm.swapExactIn(address(usdc), address(susds), 125e6 + 2, 100e18, receiver, 0); 121 | 122 | psm.swapExactIn(address(usdc), address(susds), 125e6, 100e18, receiver, 0); 123 | } 124 | 125 | } 126 | 127 | contract PSMSwapExactInSuccessTestsBase is PSMTestBase { 128 | 129 | address public swapper = makeAddr("swapper"); 130 | address public receiver = makeAddr("receiver"); 131 | 132 | function setUp() public override { 133 | super.setUp(); 134 | 135 | // Mint 100x higher than max amount for each token (max conversion rate) 136 | // Covers both lower and upper bounds of conversion rate (1% to 10,000% are both 100x) 137 | usds.mint(address(psm), USDS_TOKEN_MAX * 100); 138 | usdc.mint(pocket, USDC_TOKEN_MAX * 100); 139 | susds.mint(address(psm), SUSDS_TOKEN_MAX * 100); 140 | } 141 | 142 | function _swapExactInTest( 143 | MockERC20 assetIn, 144 | MockERC20 assetOut, 145 | uint256 amountIn, 146 | uint256 amountOut, 147 | address swapper_, 148 | address receiver_ 149 | ) internal { 150 | // 100 trillion of each token corresponds to original mint amount 151 | uint256 psmAssetInBalance = 100_000_000_000_000 * 10 ** assetIn.decimals(); 152 | uint256 psmAssetOutBalance = 100_000_000_000_000 * 10 ** assetOut.decimals(); 153 | 154 | address assetInCustodian = address(assetIn) == address(usdc) ? pocket : address(psm); 155 | address assetOutCustodian = address(assetOut) == address(usdc) ? pocket : address(psm); 156 | 157 | assetIn.mint(swapper_, amountIn); 158 | 159 | vm.startPrank(swapper_); 160 | 161 | assetIn.approve(address(psm), amountIn); 162 | 163 | assertEq(assetIn.allowance(swapper_, address(psm)), amountIn); 164 | 165 | assertEq(assetIn.balanceOf(swapper_), amountIn); 166 | assertEq(assetIn.balanceOf(assetInCustodian), psmAssetInBalance); 167 | 168 | assertEq(assetOut.balanceOf(receiver_), 0); 169 | assertEq(assetOut.balanceOf(assetOutCustodian), psmAssetOutBalance); 170 | 171 | uint256 returnedAmountOut = psm.swapExactIn( 172 | address(assetIn), 173 | address(assetOut), 174 | amountIn, 175 | amountOut, 176 | receiver_, 177 | 0 178 | ); 179 | 180 | assertEq(returnedAmountOut, amountOut); 181 | 182 | assertEq(assetIn.allowance(swapper_, address(psm)), 0); 183 | 184 | assertEq(assetIn.balanceOf(swapper_), 0); 185 | assertEq(assetIn.balanceOf(assetInCustodian), psmAssetInBalance + amountIn); 186 | 187 | assertEq(assetOut.balanceOf(receiver_), amountOut); 188 | assertEq(assetOut.balanceOf(assetOutCustodian), psmAssetOutBalance - amountOut); 189 | } 190 | 191 | } 192 | 193 | contract PSMSwapExactInUsdsAssetInTests is PSMSwapExactInSuccessTestsBase { 194 | 195 | function test_swapExactIn_usdsToUsdc_sameReceiver() public assertAtomicPsmValueDoesNotChange { 196 | _swapExactInTest(usds, usdc, 100e18, 100e6, swapper, swapper); 197 | } 198 | 199 | function test_swapExactIn_usdsToSUsds_sameReceiver() public assertAtomicPsmValueDoesNotChange { 200 | _swapExactInTest(usds, susds, 100e18, 80e18, swapper, swapper); 201 | } 202 | 203 | function test_swapExactIn_usdsToUsdc_differentReceiver() public assertAtomicPsmValueDoesNotChange { 204 | _swapExactInTest(usds, usdc, 100e18, 100e6, swapper, receiver); 205 | } 206 | 207 | function test_swapExactIn_usdsToSUsds_differentReceiver() public assertAtomicPsmValueDoesNotChange { 208 | _swapExactInTest(usds, susds, 100e18, 80e18, swapper, receiver); 209 | } 210 | 211 | function testFuzz_swapExactIn_usdsToUsdc( 212 | uint256 amountIn, 213 | address fuzzSwapper, 214 | address fuzzReceiver 215 | ) public { 216 | vm.assume(fuzzSwapper != address(psm)); 217 | vm.assume(fuzzSwapper != address(pocket)); 218 | vm.assume(fuzzReceiver != address(psm)); 219 | vm.assume(fuzzReceiver != address(pocket)); 220 | vm.assume(fuzzReceiver != address(0)); 221 | 222 | amountIn = _bound(amountIn, 1, USDS_TOKEN_MAX); // Zero amount reverts 223 | uint256 amountOut = amountIn / 1e12; 224 | _swapExactInTest(usds, usdc, amountIn, amountOut, fuzzSwapper, fuzzReceiver); 225 | } 226 | 227 | function testFuzz_swapExactIn_usdsToSUsds( 228 | uint256 amountIn, 229 | uint256 conversionRate, 230 | address fuzzSwapper, 231 | address fuzzReceiver 232 | ) public { 233 | vm.assume(fuzzSwapper != address(psm)); 234 | vm.assume(fuzzSwapper != address(pocket)); 235 | vm.assume(fuzzReceiver != address(psm)); 236 | vm.assume(fuzzReceiver != address(pocket)); 237 | vm.assume(fuzzReceiver != address(0)); 238 | 239 | amountIn = _bound(amountIn, 1, USDS_TOKEN_MAX); 240 | conversionRate = _bound(conversionRate, 0.01e27, 100e27); // 1% to 10,000% conversion rate 241 | mockRateProvider.__setConversionRate(conversionRate); 242 | 243 | uint256 amountOut = amountIn * 1e27 / conversionRate; 244 | 245 | _swapExactInTest(usds, susds, amountIn, amountOut, fuzzSwapper, fuzzReceiver); 246 | } 247 | 248 | } 249 | 250 | contract PSMSwapExactInUsdcAssetInTests is PSMSwapExactInSuccessTestsBase { 251 | 252 | function test_swapExactIn_usdcToUsds_sameReceiver() public assertAtomicPsmValueDoesNotChange { 253 | _swapExactInTest(usdc, usds, 100e6, 100e18, swapper, swapper); 254 | } 255 | 256 | function test_swapExactIn_usdcToSUsds_sameReceiver() public assertAtomicPsmValueDoesNotChange { 257 | _swapExactInTest(usdc, susds, 100e6, 80e18, swapper, swapper); 258 | } 259 | 260 | function test_swapExactIn_usdcToUsds_differentReceiver() public assertAtomicPsmValueDoesNotChange { 261 | _swapExactInTest(usdc, usds, 100e6, 100e18, swapper, receiver); 262 | } 263 | 264 | function test_swapExactIn_usdcToSUsds_differentReceiver() public assertAtomicPsmValueDoesNotChange { 265 | _swapExactInTest(usdc, susds, 100e6, 80e18, swapper, receiver); 266 | } 267 | 268 | function testFuzz_swapExactIn_usdcToUsds( 269 | uint256 amountIn, 270 | address fuzzSwapper, 271 | address fuzzReceiver 272 | ) public { 273 | vm.assume(fuzzSwapper != address(psm)); 274 | vm.assume(fuzzSwapper != address(pocket)); 275 | vm.assume(fuzzReceiver != address(psm)); 276 | vm.assume(fuzzReceiver != address(pocket)); 277 | vm.assume(fuzzReceiver != address(0)); 278 | 279 | amountIn = _bound(amountIn, 1, USDC_TOKEN_MAX); // Zero amount reverts 280 | uint256 amountOut = amountIn * 1e12; 281 | _swapExactInTest(usdc, usds, amountIn, amountOut, fuzzSwapper, fuzzReceiver); 282 | } 283 | 284 | function testFuzz_swapExactIn_usdcToSUsds( 285 | uint256 amountIn, 286 | uint256 conversionRate, 287 | address fuzzSwapper, 288 | address fuzzReceiver 289 | ) public { 290 | vm.assume(fuzzSwapper != address(psm)); 291 | vm.assume(fuzzSwapper != address(pocket)); 292 | vm.assume(fuzzReceiver != address(psm)); 293 | vm.assume(fuzzReceiver != address(pocket)); 294 | vm.assume(fuzzReceiver != address(0)); 295 | 296 | amountIn = _bound(amountIn, 1, USDC_TOKEN_MAX); 297 | conversionRate = _bound(conversionRate, 0.01e27, 100e27); // 1% to 10,000% conversion rate 298 | 299 | mockRateProvider.__setConversionRate(conversionRate); 300 | 301 | uint256 amountOut = amountIn * 1e27 / conversionRate * 1e12; 302 | 303 | _swapExactInTest(usdc, susds, amountIn, amountOut, fuzzSwapper, fuzzReceiver); 304 | } 305 | 306 | } 307 | 308 | contract PSMSwapExactInSUsdsAssetInTests is PSMSwapExactInSuccessTestsBase { 309 | 310 | function test_swapExactIn_susdsToUsds_sameReceiver() public assertAtomicPsmValueDoesNotChange { 311 | _swapExactInTest(susds, usds, 100e18, 125e18, swapper, swapper); 312 | } 313 | 314 | function test_swapExactIn_susdsToUsdc_sameReceiver() public assertAtomicPsmValueDoesNotChange { 315 | _swapExactInTest(susds, usdc, 100e18, 125e6, swapper, swapper); 316 | } 317 | 318 | function test_swapExactIn_susdsToUsds_differentReceiver() public assertAtomicPsmValueDoesNotChange { 319 | _swapExactInTest(susds, usds, 100e18, 125e18, swapper, receiver); 320 | } 321 | 322 | function test_swapExactIn_susdsToUsdc_differentReceiver() public assertAtomicPsmValueDoesNotChange { 323 | _swapExactInTest(susds, usdc, 100e18, 125e6, swapper, receiver); 324 | } 325 | 326 | function testFuzz_swapExactIn_susdsToUsds( 327 | uint256 amountIn, 328 | uint256 conversionRate, 329 | address fuzzSwapper, 330 | address fuzzReceiver 331 | ) public { 332 | vm.assume(fuzzSwapper != address(psm)); 333 | vm.assume(fuzzSwapper != address(pocket)); 334 | vm.assume(fuzzReceiver != address(psm)); 335 | vm.assume(fuzzReceiver != address(pocket)); 336 | vm.assume(fuzzReceiver != address(0)); 337 | 338 | amountIn = _bound(amountIn, 1, SUSDS_TOKEN_MAX); 339 | conversionRate = _bound(conversionRate, 0.01e27, 100e27); // 1% to 10,000% conversion rate 340 | 341 | mockRateProvider.__setConversionRate(conversionRate); 342 | 343 | uint256 amountOut = amountIn * conversionRate / 1e27; 344 | 345 | _swapExactInTest(susds, usds, amountIn, amountOut, fuzzSwapper, fuzzReceiver); 346 | } 347 | 348 | function testFuzz_swapExactIn_susdsToUsdc( 349 | uint256 amountIn, 350 | uint256 conversionRate, 351 | address fuzzSwapper, 352 | address fuzzReceiver 353 | ) public { 354 | vm.assume(fuzzSwapper != address(psm)); 355 | vm.assume(fuzzSwapper != address(pocket)); 356 | vm.assume(fuzzReceiver != address(psm)); 357 | vm.assume(fuzzReceiver != address(pocket)); 358 | vm.assume(fuzzReceiver != address(0)); 359 | 360 | amountIn = _bound(amountIn, 1, SUSDS_TOKEN_MAX); 361 | conversionRate = _bound(conversionRate, 0.01e27, 100e27); // 1% to 10,000% conversion rate 362 | 363 | mockRateProvider.__setConversionRate(conversionRate); 364 | 365 | uint256 amountOut = amountIn * conversionRate / 1e27 / 1e12; 366 | 367 | _swapExactInTest(susds, usdc, amountIn, amountOut, fuzzSwapper, fuzzReceiver); 368 | } 369 | 370 | } 371 | 372 | contract PSMSwapExactInFuzzTests is PSMTestBase { 373 | 374 | address lp0 = makeAddr("lp0"); 375 | address lp1 = makeAddr("lp1"); 376 | address lp2 = makeAddr("lp2"); 377 | 378 | address swapper = makeAddr("swapper"); 379 | 380 | struct FuzzVars { 381 | uint256 lp0StartingValue; 382 | uint256 lp1StartingValue; 383 | uint256 lp2StartingValue; 384 | uint256 psmStartingValue; 385 | uint256 lp0CachedValue; 386 | uint256 lp1CachedValue; 387 | uint256 lp2CachedValue; 388 | uint256 psmCachedValue; 389 | } 390 | 391 | /// forge-config: default.fuzz.runs = 10 392 | /// forge-config: pr.fuzz.runs = 100 393 | /// forge-config: master.fuzz.runs = 1000 394 | function testFuzz_swapExactIn( 395 | uint256 conversionRate, 396 | uint256 depositSeed 397 | ) public { 398 | // 1% to 200% conversion rate 399 | mockRateProvider.__setConversionRate(_bound(conversionRate, 0.01e27, 2e27)); 400 | 401 | _deposit(address(usds), lp0, _bound(_hash(depositSeed, "lp0-usds"), 1, USDS_TOKEN_MAX)); 402 | 403 | _deposit(address(usdc), lp1, _bound(_hash(depositSeed, "lp1-usdc"), 1, USDC_TOKEN_MAX)); 404 | _deposit(address(susds), lp1, _bound(_hash(depositSeed, "lp1-susds"), 1, SUSDS_TOKEN_MAX)); 405 | 406 | _deposit(address(usds), lp2, _bound(_hash(depositSeed, "lp2-usds"), 1, USDS_TOKEN_MAX)); 407 | _deposit(address(usdc), lp2, _bound(_hash(depositSeed, "lp2-usdc"), 1, USDC_TOKEN_MAX)); 408 | _deposit(address(susds), lp2, _bound(_hash(depositSeed, "lp2-susds"), 1, SUSDS_TOKEN_MAX)); 409 | 410 | FuzzVars memory vars; 411 | 412 | vars.lp0StartingValue = psm.convertToAssetValue(psm.shares(lp0)); 413 | vars.lp1StartingValue = psm.convertToAssetValue(psm.shares(lp1)); 414 | vars.lp2StartingValue = psm.convertToAssetValue(psm.shares(lp2)); 415 | vars.psmStartingValue = psm.totalAssets(); 416 | 417 | vm.startPrank(swapper); 418 | 419 | for (uint256 i; i < 1000; ++i) { 420 | MockERC20 assetIn = _getAsset(_hash(i, "assetIn")); 421 | MockERC20 assetOut = _getAsset(_hash(i, "assetOut")); 422 | 423 | if (assetIn == assetOut) { 424 | assetOut = _getAsset(_hash(i, "assetOut") + 1); 425 | } 426 | 427 | address assetOutCustodian = address(assetOut) == address(usdc) ? pocket : address(psm); 428 | 429 | // Calculate the maximum amount that can be swapped by using the inverse conversion rate 430 | uint256 maxAmountIn = psm.previewSwapExactOut( 431 | address(assetIn), 432 | address(assetOut), 433 | assetOut.balanceOf(assetOutCustodian) 434 | ); 435 | 436 | uint256 amountIn = _bound(_hash(i, "amountIn"), 0, maxAmountIn - 1); // Rounding 437 | 438 | vars.lp0CachedValue = psm.convertToAssetValue(psm.shares(lp0)); 439 | vars.lp1CachedValue = psm.convertToAssetValue(psm.shares(lp1)); 440 | vars.lp2CachedValue = psm.convertToAssetValue(psm.shares(lp2)); 441 | vars.psmCachedValue = psm.totalAssets(); 442 | 443 | assetIn.mint(swapper, amountIn); 444 | assetIn.approve(address(psm), amountIn); 445 | psm.swapExactIn(address(assetIn), address(assetOut), amountIn, 0, swapper, 0); 446 | 447 | // Rounding is always in favour of the LPs 448 | assertGe(psm.convertToAssetValue(psm.shares(lp0)), vars.lp0CachedValue); 449 | assertGe(psm.convertToAssetValue(psm.shares(lp1)), vars.lp1CachedValue); 450 | assertGe(psm.convertToAssetValue(psm.shares(lp2)), vars.lp2CachedValue); 451 | assertGe(psm.totalAssets(), vars.psmCachedValue); 452 | 453 | // Up to 2e12 rounding on each swap 454 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp0)), vars.lp0CachedValue, 2e12); 455 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp1)), vars.lp1CachedValue, 2e12); 456 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp2)), vars.lp2CachedValue, 2e12); 457 | assertApproxEqAbs(psm.totalAssets(), vars.psmCachedValue, 2e12); 458 | } 459 | 460 | // Rounding is always in favour of the LPs 461 | assertGe(psm.convertToAssetValue(psm.shares(lp0)), vars.lp0StartingValue); 462 | assertGe(psm.convertToAssetValue(psm.shares(lp1)), vars.lp1StartingValue); 463 | assertGe(psm.convertToAssetValue(psm.shares(lp2)), vars.lp2StartingValue); 464 | assertGe(psm.totalAssets(), vars.psmStartingValue); 465 | 466 | // Up to 2e12 rounding on each swap, for 1000 swaps 467 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp0)), vars.lp0StartingValue, 2000e12); 468 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp1)), vars.lp1StartingValue, 2000e12); 469 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp2)), vars.lp2StartingValue, 2000e12); 470 | assertApproxEqAbs(psm.totalAssets(), vars.psmStartingValue, 2000e12); 471 | } 472 | 473 | function _hash(uint256 number_, string memory salt) internal pure returns (uint256 hash_) { 474 | hash_ = uint256(keccak256(abi.encode(number_, salt))); 475 | } 476 | 477 | function _getAsset(uint256 indexSeed) internal view returns (MockERC20) { 478 | uint256 index = indexSeed % 3; 479 | 480 | if (index == 0) return usds; 481 | if (index == 1) return usdc; 482 | if (index == 2) return susds; 483 | 484 | else revert("Invalid index"); 485 | } 486 | 487 | } 488 | -------------------------------------------------------------------------------- /test/unit/SwapExactOut.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { PSM3 } from "src/PSM3.sol"; 7 | 8 | import { MockERC20, PSMTestBase } from "test/PSMTestBase.sol"; 9 | 10 | contract PSMSwapExactOutFailureTests is PSMTestBase { 11 | 12 | address public swapper = makeAddr("swapper"); 13 | address public receiver = makeAddr("receiver"); 14 | 15 | function setUp() public override { 16 | super.setUp(); 17 | 18 | // Needed for boundary success conditions 19 | usdc.mint(pocket, 100e6); 20 | susds.mint(address(psm), 100e18); 21 | } 22 | 23 | function test_swapExactOut_amountZero() public { 24 | vm.expectRevert("PSM3/invalid-amountOut"); 25 | psm.swapExactOut(address(usdc), address(susds), 0, 0, receiver, 0); 26 | } 27 | 28 | function test_swapExactOut_receiverZero() public { 29 | vm.expectRevert("PSM3/invalid-receiver"); 30 | psm.swapExactOut(address(usdc), address(susds), 100e6, 80e18, address(0), 0); 31 | } 32 | 33 | function test_swapExactOut_invalid_assetIn() public { 34 | vm.expectRevert("PSM3/invalid-asset"); 35 | psm.swapExactOut(makeAddr("other-token"), address(susds), 100e6, 80e18, receiver, 0); 36 | } 37 | 38 | function test_swapExactOut_invalid_assetOut() public { 39 | vm.expectRevert("PSM3/invalid-asset"); 40 | psm.swapExactOut(address(usdc), makeAddr("other-token"), 100e6, 80e18, receiver, 0); 41 | } 42 | 43 | function test_swapExactOut_bothUsdc() public { 44 | vm.expectRevert("PSM3/invalid-asset"); 45 | psm.swapExactOut(address(usdc), address(usdc), 100e6, 80e18, receiver, 0); 46 | } 47 | 48 | function test_swapExactOut_bothUsds() public { 49 | vm.expectRevert("PSM3/invalid-asset"); 50 | psm.swapExactOut(address(usds), address(usds), 100e6, 80e18, receiver, 0); 51 | } 52 | 53 | function test_swapExactOut_bothSUsds() public { 54 | vm.expectRevert("PSM3/invalid-asset"); 55 | psm.swapExactOut(address(susds), address(susds), 100e6, 80e18, receiver, 0); 56 | } 57 | 58 | function test_swapExactOut_maxAmountBoundary() public { 59 | usdc.mint(swapper, 100e6); 60 | 61 | vm.startPrank(swapper); 62 | 63 | usdc.approve(address(psm), 100e6); 64 | 65 | uint256 expectedAmountIn = psm.previewSwapExactOut(address(usdc), address(susds), 80e18); 66 | 67 | assertEq(expectedAmountIn, 100e6); 68 | 69 | vm.expectRevert("PSM3/amountIn-too-high"); 70 | psm.swapExactOut(address(usdc), address(susds), 80e18, 100e6 - 1, receiver, 0); 71 | 72 | psm.swapExactOut(address(usdc), address(susds), 80e18, 100e6, receiver, 0); 73 | } 74 | 75 | function test_swapExactOut_insufficientApproveBoundary() public { 76 | usdc.mint(swapper, 100e6); 77 | 78 | vm.startPrank(swapper); 79 | 80 | usdc.approve(address(psm), 100e6 - 1); 81 | 82 | vm.expectRevert("SafeERC20/transfer-from-failed"); 83 | psm.swapExactOut(address(usdc), address(susds), 80e18, 100e6, receiver, 0); 84 | 85 | usdc.approve(address(psm), 100e6); 86 | 87 | psm.swapExactOut(address(usdc), address(susds), 80e18, 100e6, receiver, 0); 88 | } 89 | 90 | function test_swapExactOut_insufficientUserBalanceBoundary() public { 91 | usdc.mint(swapper, 100e6 - 1); 92 | 93 | vm.startPrank(swapper); 94 | 95 | usdc.approve(address(psm), 100e6); 96 | 97 | vm.expectRevert("SafeERC20/transfer-from-failed"); 98 | psm.swapExactOut(address(usdc), address(susds), 80e18, 100e6, receiver, 0); 99 | 100 | usdc.mint(swapper, 1); 101 | 102 | psm.swapExactOut(address(usdc), address(susds), 80e18, 100e6, receiver, 0); 103 | } 104 | 105 | function test_swapExactOut_insufficientPsmBalanceBoundary() public { 106 | // NOTE: Using higher amount so transfer fails 107 | usdc.mint(swapper, 125e6 + 1); 108 | 109 | vm.startPrank(swapper); 110 | 111 | usdc.approve(address(psm), 125e6 + 1); 112 | 113 | vm.expectRevert("SafeERC20/transfer-failed"); 114 | psm.swapExactOut(address(usdc), address(susds), 100e18 + 1, 125e6 + 1, receiver, 0); 115 | 116 | psm.swapExactOut(address(usdc), address(susds), 100e18, 125e6 + 1, receiver, 0); 117 | } 118 | 119 | } 120 | 121 | contract PSMSwapExactOutSuccessTestsBase is PSMTestBase { 122 | 123 | address public swapper = makeAddr("swapper"); 124 | address public receiver = makeAddr("receiver"); 125 | 126 | function setUp() public override { 127 | super.setUp(); 128 | 129 | // Mint 100x higher than max amount for each token (max conversion rate) 130 | // Covers both lower and upper bounds of conversion rate (1% to 10,000% are both 100x) 131 | usds.mint(address(psm), USDS_TOKEN_MAX * 100); 132 | usdc.mint(pocket, USDC_TOKEN_MAX * 100); 133 | susds.mint(address(psm), SUSDS_TOKEN_MAX * 100); 134 | } 135 | 136 | function _swapExactOutTest( 137 | MockERC20 assetIn, 138 | MockERC20 assetOut, 139 | uint256 amountOut, 140 | uint256 amountIn, 141 | address swapper_, 142 | address receiver_ 143 | ) internal { 144 | // 100 trillion of each token corresponds to original mint amount 145 | uint256 psmAssetInBalance = 100_000_000_000_000 * 10 ** assetIn.decimals(); 146 | uint256 psmAssetOutBalance = 100_000_000_000_000 * 10 ** assetOut.decimals(); 147 | 148 | address assetInCustodian = address(assetIn) == address(usdc) ? pocket : address(psm); 149 | address assetOutCustodian = address(assetOut) == address(usdc) ? pocket : address(psm); 150 | 151 | assetIn.mint(swapper_, amountIn); 152 | 153 | vm.startPrank(swapper_); 154 | 155 | assetIn.approve(address(psm), amountIn); 156 | 157 | assertEq(assetIn.allowance(swapper_, address(psm)), amountIn); 158 | 159 | assertEq(assetIn.balanceOf(swapper_), amountIn); 160 | assertEq(assetIn.balanceOf(assetInCustodian), psmAssetInBalance); 161 | 162 | assertEq(assetOut.balanceOf(receiver_), 0); 163 | assertEq(assetOut.balanceOf(assetOutCustodian), psmAssetOutBalance); 164 | 165 | uint256 returnedAmountIn = psm.swapExactOut( 166 | address(assetIn), 167 | address(assetOut), 168 | amountOut, 169 | amountIn, 170 | receiver_, 171 | 0 172 | ); 173 | 174 | assertEq(returnedAmountIn, amountIn); 175 | 176 | assertEq(assetIn.allowance(swapper_, address(psm)), 0); 177 | 178 | assertEq(assetIn.balanceOf(swapper_), 0); 179 | assertEq(assetIn.balanceOf(assetInCustodian), psmAssetInBalance + amountIn); 180 | 181 | assertEq(assetOut.balanceOf(receiver_), amountOut); 182 | assertEq(assetOut.balanceOf(assetOutCustodian), psmAssetOutBalance - amountOut); 183 | } 184 | 185 | } 186 | 187 | contract PSMSwapExactOutUsdsAssetInTests is PSMSwapExactOutSuccessTestsBase { 188 | 189 | function test_swapExactOut_usdsToUsdc_sameReceiver() public assertAtomicPsmValueDoesNotChange { 190 | _swapExactOutTest(usds, usdc, 100e6, 100e18, swapper, swapper); 191 | } 192 | 193 | function test_swapExactOut_usdsToSUsds_sameReceiver() public assertAtomicPsmValueDoesNotChange { 194 | _swapExactOutTest(usds, susds, 80e18, 100e18, swapper, swapper); 195 | } 196 | 197 | function test_swapExactOut_usdsToUsdc_differentReceiver() public assertAtomicPsmValueDoesNotChange { 198 | _swapExactOutTest(usds, usdc, 100e6, 100e18, swapper, receiver); 199 | } 200 | 201 | function test_swapExactOut_usdsToSUsds_differentReceiver() public assertAtomicPsmValueDoesNotChange { 202 | _swapExactOutTest(usds, susds, 80e18, 100e18, swapper, receiver); 203 | } 204 | 205 | function testFuzz_swapExactOut_usdsToUsdc( 206 | uint256 amountOut, 207 | address fuzzSwapper, 208 | address fuzzReceiver 209 | ) public { 210 | vm.assume(fuzzSwapper != address(psm)); 211 | vm.assume(fuzzSwapper != address(pocket)); 212 | vm.assume(fuzzReceiver != address(psm)); 213 | vm.assume(fuzzReceiver != address(pocket)); 214 | vm.assume(fuzzReceiver != address(0)); 215 | 216 | amountOut = _bound(amountOut, 1, USDC_TOKEN_MAX); // Zero amount reverts 217 | uint256 amountIn = amountOut * 1e12; 218 | _swapExactOutTest(usds, usdc, amountOut, amountIn, fuzzSwapper, fuzzReceiver); 219 | } 220 | 221 | function testFuzz_swapExactOut_usdsToSUsds( 222 | uint256 amountOut, 223 | uint256 conversionRate, 224 | address fuzzSwapper, 225 | address fuzzReceiver 226 | ) public { 227 | vm.assume(fuzzSwapper != address(psm)); 228 | vm.assume(fuzzSwapper != address(pocket)); 229 | vm.assume(fuzzReceiver != address(psm)); 230 | vm.assume(fuzzReceiver != address(pocket)); 231 | vm.assume(fuzzReceiver != address(0)); 232 | 233 | amountOut = _bound(amountOut, 1, USDS_TOKEN_MAX); 234 | conversionRate = _bound(conversionRate, 0.01e27, 100e27); // 1% to 10,000% conversion rate 235 | mockRateProvider.__setConversionRate(conversionRate); 236 | 237 | uint256 amountIn = amountOut * conversionRate / 1e27; 238 | 239 | uint256 returnedAmountIn = psm.previewSwapExactOut(address(usds), address(susds), amountOut); 240 | 241 | // Assert that returnedAmount is within 1 of the expected amount and rounding up 242 | // Use returnedAmountIn in helper function so all values are exact 243 | assertLe(returnedAmountIn - amountIn, 1); 244 | 245 | _swapExactOutTest(usds, susds, amountOut, returnedAmountIn, fuzzSwapper, fuzzReceiver); 246 | } 247 | 248 | } 249 | 250 | contract PSMSwapExactOutUsdcAssetInTests is PSMSwapExactOutSuccessTestsBase { 251 | 252 | function test_swapExactOut_usdcToUsds_sameReceiver() public assertAtomicPsmValueDoesNotChange { 253 | _swapExactOutTest(usdc, usds, 100e18, 100e6, swapper, swapper); 254 | } 255 | 256 | function test_swapExactOut_usdcToSUsds_sameReceiver() public assertAtomicPsmValueDoesNotChange { 257 | _swapExactOutTest(usdc, susds, 80e18, 100e6, swapper, swapper); 258 | } 259 | 260 | function test_swapExactOut_usdcToUsds_differentReceiver() public assertAtomicPsmValueDoesNotChange { 261 | _swapExactOutTest(usdc, usds, 100e18, 100e6, swapper, receiver); 262 | } 263 | 264 | function test_swapExactOut_usdcToSUsds_differentReceiver() public assertAtomicPsmValueDoesNotChange { 265 | _swapExactOutTest(usdc, susds, 80e18, 100e6, swapper, receiver); 266 | } 267 | 268 | function testFuzz_swapExactOut_usdcToUsds( 269 | uint256 amountOut, 270 | address fuzzSwapper, 271 | address fuzzReceiver 272 | ) public { 273 | vm.assume(fuzzSwapper != address(psm)); 274 | vm.assume(fuzzSwapper != address(pocket)); 275 | vm.assume(fuzzReceiver != address(psm)); 276 | vm.assume(fuzzReceiver != address(pocket)); 277 | vm.assume(fuzzReceiver != address(0)); 278 | 279 | amountOut = _bound(amountOut, 1, USDS_TOKEN_MAX); // Zero amount reverts 280 | uint256 amountIn = amountOut / 1e12; 281 | 282 | uint256 returnedAmountIn = psm.previewSwapExactOut(address(usdc), address(usds), amountOut); 283 | 284 | // Assert that returnedAmount is within 1 of the expected amount and rounding up 285 | // Use returnedAmountIn in helper function so all values are exact 286 | assertLe(returnedAmountIn - amountIn, 1); 287 | 288 | _swapExactOutTest(usdc, usds, amountOut, returnedAmountIn, fuzzSwapper, fuzzReceiver); 289 | } 290 | 291 | function testFuzz_swapExactOut_usdcToSUsds( 292 | uint256 amountOut, 293 | uint256 conversionRate, 294 | address fuzzSwapper, 295 | address fuzzReceiver 296 | ) public { 297 | vm.assume(fuzzSwapper != address(psm)); 298 | vm.assume(fuzzSwapper != address(pocket)); 299 | vm.assume(fuzzReceiver != address(psm)); 300 | vm.assume(fuzzReceiver != address(pocket)); 301 | vm.assume(fuzzReceiver != address(0)); 302 | 303 | amountOut = _bound(amountOut, 1, SUSDS_TOKEN_MAX); 304 | conversionRate = _bound(conversionRate, 0.01e27, 100e27); // 1% to 10,000% conversion rate 305 | 306 | mockRateProvider.__setConversionRate(conversionRate); 307 | 308 | uint256 amountIn = amountOut * conversionRate / 1e27 / 1e12; 309 | 310 | uint256 returnedAmountIn = psm.previewSwapExactOut(address(usdc), address(susds), amountOut); 311 | 312 | // Assert that returnedAmount is within 1 of the expected amount and rounding up 313 | // Use returnedAmountIn in helper function so all values are exact 314 | assertLe(returnedAmountIn - amountIn, 1); 315 | 316 | _swapExactOutTest(usdc, susds, amountOut, returnedAmountIn, fuzzSwapper, fuzzReceiver); 317 | } 318 | 319 | } 320 | 321 | contract PSMSwapExactOutSUsdsAssetInTests is PSMSwapExactOutSuccessTestsBase { 322 | 323 | function test_swapExactOut_susdsToUsds_sameReceiver() public assertAtomicPsmValueDoesNotChange { 324 | _swapExactOutTest(susds, usds, 125e18, 100e18, swapper, swapper); 325 | } 326 | 327 | function test_swapExactOut_susdsToUsdc_sameReceiver() public assertAtomicPsmValueDoesNotChange { 328 | _swapExactOutTest(susds, usdc, 125e6, 100e18, swapper, swapper); 329 | } 330 | 331 | function test_swapExactOut_susdsToUsds_differentReceiver() public assertAtomicPsmValueDoesNotChange { 332 | _swapExactOutTest(susds, usds, 125e18, 100e18, swapper, receiver); 333 | } 334 | 335 | function test_swapExactOut_susdsToUsdc_differentReceiver() public assertAtomicPsmValueDoesNotChange { 336 | _swapExactOutTest(susds, usdc, 125e6, 100e18, swapper, receiver); 337 | } 338 | 339 | function testFuzz_swapExactOut_susdsToUsds( 340 | uint256 amountOut, 341 | uint256 conversionRate, 342 | address fuzzSwapper, 343 | address fuzzReceiver 344 | ) public { 345 | vm.assume(fuzzSwapper != address(psm)); 346 | vm.assume(fuzzSwapper != address(pocket)); 347 | vm.assume(fuzzReceiver != address(psm)); 348 | vm.assume(fuzzReceiver != address(pocket)); 349 | vm.assume(fuzzReceiver != address(0)); 350 | 351 | amountOut = _bound(amountOut, 1, USDS_TOKEN_MAX); 352 | conversionRate = _bound(conversionRate, 0.01e27, 100e27); // 1% to 10,000% conversion rate 353 | 354 | mockRateProvider.__setConversionRate(conversionRate); 355 | 356 | uint256 amountIn = amountOut * 1e27 / conversionRate; 357 | 358 | uint256 returnedAmountIn = psm.previewSwapExactOut(address(susds), address(usds), amountOut); 359 | 360 | // Assert that returnedAmount is within 1 of the expected amount and rounding up 361 | // Use returnedAmountIn in helper function so all values are exact 362 | assertLe(returnedAmountIn - amountIn, 1); 363 | 364 | _swapExactOutTest(susds, usds, amountOut, returnedAmountIn, fuzzSwapper, fuzzReceiver); 365 | } 366 | 367 | function testFuzz_swapExactOut_susdsToUsdc( 368 | uint256 amountOut, 369 | uint256 conversionRate, 370 | address fuzzSwapper, 371 | address fuzzReceiver 372 | ) public { 373 | vm.assume(fuzzSwapper != address(psm)); 374 | vm.assume(fuzzSwapper != address(pocket)); 375 | vm.assume(fuzzReceiver != address(psm)); 376 | vm.assume(fuzzReceiver != address(pocket)); 377 | vm.assume(fuzzReceiver != address(0)); 378 | 379 | amountOut = _bound(amountOut, 1, USDC_TOKEN_MAX); 380 | conversionRate = _bound(conversionRate, 0.01e27, 100e27); // 1% to 10,000% conversion rate 381 | 382 | mockRateProvider.__setConversionRate(conversionRate); 383 | 384 | uint256 amountIn = amountOut * 1e27 / conversionRate * 1e12; 385 | 386 | uint256 returnedAmountIn = psm.previewSwapExactOut(address(susds), address(usdc), amountOut); 387 | 388 | // Assert that returnedAmount is within 1 of the expected amount and rounding up 389 | // Use returnedAmountIn in helper function so all asserted values are exact 390 | // Rounding can cause returnedAmountIn to be up to 1e12 higher than naive calculation 391 | assertLe(returnedAmountIn - amountIn, 1e12); 392 | 393 | _swapExactOutTest(susds, usdc, amountOut, returnedAmountIn, fuzzSwapper, fuzzReceiver); 394 | } 395 | 396 | } 397 | 398 | contract PSMSwapExactOutFuzzTests is PSMTestBase { 399 | 400 | address lp0 = makeAddr("lp0"); 401 | address lp1 = makeAddr("lp1"); 402 | address lp2 = makeAddr("lp2"); 403 | 404 | address swapper = makeAddr("swapper"); 405 | 406 | struct FuzzVars { 407 | uint256 lp0StartingValue; 408 | uint256 lp1StartingValue; 409 | uint256 lp2StartingValue; 410 | uint256 psmStartingValue; 411 | uint256 lp0CachedValue; 412 | uint256 lp1CachedValue; 413 | uint256 lp2CachedValue; 414 | uint256 psmCachedValue; 415 | } 416 | 417 | /// forge-config: default.fuzz.runs = 10 418 | /// forge-config: pr.fuzz.runs = 100 419 | /// forge-config: master.fuzz.runs = 10000 420 | function testFuzz_swapExactOut( 421 | uint256 conversionRate, 422 | uint256 depositSeed 423 | ) public { 424 | // 1% to 200% conversion rate 425 | mockRateProvider.__setConversionRate(_bound(conversionRate, 0.01e27, 2e27)); 426 | 427 | _deposit(address(usds), lp0, _bound(_hash(depositSeed, "lp0-usds"), 1, USDS_TOKEN_MAX)); 428 | 429 | _deposit(address(usdc), lp1, _bound(_hash(depositSeed, "lp1-usdc"), 1, USDC_TOKEN_MAX)); 430 | _deposit(address(susds), lp1, _bound(_hash(depositSeed, "lp1-susds"), 1, SUSDS_TOKEN_MAX)); 431 | 432 | _deposit(address(usds), lp2, _bound(_hash(depositSeed, "lp2-usds"), 1, USDS_TOKEN_MAX)); 433 | _deposit(address(usdc), lp2, _bound(_hash(depositSeed, "lp2-usdc"), 1, USDC_TOKEN_MAX)); 434 | _deposit(address(susds), lp2, _bound(_hash(depositSeed, "lp2-susds"), 1, SUSDS_TOKEN_MAX)); 435 | 436 | FuzzVars memory vars; 437 | 438 | vars.lp0StartingValue = psm.convertToAssetValue(psm.shares(lp0)); 439 | vars.lp1StartingValue = psm.convertToAssetValue(psm.shares(lp1)); 440 | vars.lp2StartingValue = psm.convertToAssetValue(psm.shares(lp2)); 441 | vars.psmStartingValue = psm.totalAssets(); 442 | 443 | vm.startPrank(swapper); 444 | 445 | for (uint256 i; i < 10; ++i) { 446 | MockERC20 assetIn = _getAsset(_hash(i, "assetIn")); 447 | MockERC20 assetOut = _getAsset(_hash(i, "assetOut")); 448 | 449 | if (assetIn == assetOut) { 450 | assetOut = _getAsset(_hash(i, "assetOut") + 1); 451 | } 452 | 453 | address assetOutCustodian = address(assetOut) == address(usdc) ? pocket : address(psm); 454 | 455 | uint256 amountOut 456 | = _bound(_hash(i, "amountOut"), 0, assetOut.balanceOf(assetOutCustodian)); 457 | 458 | uint256 amountIn 459 | = psm.previewSwapExactOut(address(assetIn), address(assetOut), amountOut); 460 | 461 | vars.lp0CachedValue = psm.convertToAssetValue(psm.shares(lp0)); 462 | vars.lp1CachedValue = psm.convertToAssetValue(psm.shares(lp1)); 463 | vars.lp2CachedValue = psm.convertToAssetValue(psm.shares(lp2)); 464 | vars.psmCachedValue = psm.totalAssets(); 465 | 466 | assetIn.mint(swapper, amountIn); 467 | assetIn.approve(address(psm), amountIn); 468 | psm.swapExactOut(address(assetIn), address(assetOut), amountOut, amountIn, swapper, 0); 469 | 470 | // Rounding is always in favour of the LPs 471 | assertGe(psm.convertToAssetValue(psm.shares(lp0)), vars.lp0CachedValue); 472 | assertGe(psm.convertToAssetValue(psm.shares(lp1)), vars.lp1CachedValue); 473 | assertGe(psm.convertToAssetValue(psm.shares(lp2)), vars.lp2CachedValue); 474 | assertGe(psm.totalAssets(), vars.psmCachedValue); 475 | 476 | // Up to 2e12 rounding on each swap 477 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp0)), vars.lp0CachedValue, 2e12); 478 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp1)), vars.lp1CachedValue, 2e12); 479 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp2)), vars.lp2CachedValue, 2e12); 480 | assertApproxEqAbs(psm.totalAssets(), vars.psmCachedValue, 2e12); 481 | } 482 | 483 | // Rounding is always in favour of the LPs 484 | assertGe(psm.convertToAssetValue(psm.shares(lp0)), vars.lp0StartingValue); 485 | assertGe(psm.convertToAssetValue(psm.shares(lp1)), vars.lp1StartingValue); 486 | assertGe(psm.convertToAssetValue(psm.shares(lp2)), vars.lp2StartingValue); 487 | assertGe(psm.totalAssets(), vars.psmStartingValue); 488 | 489 | // Up to 2e12 rounding on each swap, for 1000 swaps 490 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp0)), vars.lp0StartingValue, 2000e12); 491 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp1)), vars.lp1StartingValue, 2000e12); 492 | assertApproxEqAbs(psm.convertToAssetValue(psm.shares(lp2)), vars.lp2StartingValue, 2000e12); 493 | assertApproxEqAbs(psm.totalAssets(), vars.psmStartingValue, 2000e12); 494 | } 495 | 496 | function _hash(uint256 number_, string memory salt) internal pure returns (uint256 hash_) { 497 | hash_ = uint256(keccak256(abi.encode(number_, salt))); 498 | } 499 | 500 | function _getAsset(uint256 indexSeed) internal view returns (MockERC20) { 501 | uint256 index = indexSeed % 3; 502 | 503 | if (index == 0) return usds; 504 | if (index == 1) return usdc; 505 | if (index == 2) return susds; 506 | 507 | else revert("Invalid index"); 508 | } 509 | 510 | } 511 | -------------------------------------------------------------------------------- /test/unit/SwapPreviews.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { MockRateProvider, PSMTestBase } from "test/PSMTestBase.sol"; 7 | 8 | contract PSMPreviewSwapExactIn_FailureTests is PSMTestBase { 9 | 10 | function test_previewSwapExactIn_invalidAssetIn() public { 11 | vm.expectRevert("PSM3/invalid-asset"); 12 | psm.previewSwapExactIn(makeAddr("other-token"), address(usdc), 1); 13 | } 14 | 15 | function test_previewSwapExactIn_invalidAssetOut() public { 16 | vm.expectRevert("PSM3/invalid-asset"); 17 | psm.previewSwapExactIn(address(usdc), makeAddr("other-token"), 1); 18 | } 19 | 20 | function test_previewSwapExactIn_bothUsdc() public { 21 | vm.expectRevert("PSM3/invalid-asset"); 22 | psm.previewSwapExactIn(address(usdc), address(usdc), 1); 23 | } 24 | 25 | function test_previewSwapExactIn_bothUsds() public { 26 | vm.expectRevert("PSM3/invalid-asset"); 27 | psm.previewSwapExactIn(address(usds), address(usds), 1); 28 | } 29 | 30 | function test_previewSwapExactIn_bothSUsds() public { 31 | vm.expectRevert("PSM3/invalid-asset"); 32 | psm.previewSwapExactIn(address(susds), address(susds), 1); 33 | } 34 | 35 | } 36 | 37 | contract PSMPreviewSwapExactOut_FailureTests is PSMTestBase { 38 | 39 | function test_previewSwapExactIn_invalidAssetIn() public { 40 | vm.expectRevert("PSM3/invalid-asset"); 41 | psm.previewSwapExactOut(makeAddr("other-token"), address(usdc), 1); 42 | } 43 | 44 | function test_previewSwapExactOut_invalidAssetOut() public { 45 | vm.expectRevert("PSM3/invalid-asset"); 46 | psm.previewSwapExactOut(address(usdc), makeAddr("other-token"), 1); 47 | } 48 | 49 | function test_previewSwapExactOut_bothUsdc() public { 50 | vm.expectRevert("PSM3/invalid-asset"); 51 | psm.previewSwapExactOut(address(usds), address(usds), 1); 52 | } 53 | 54 | function test_previewSwapExactOut_bothUsds() public { 55 | vm.expectRevert("PSM3/invalid-asset"); 56 | psm.previewSwapExactOut(address(usdc), address(usdc), 1); 57 | } 58 | 59 | function test_previewSwapExactOut_bothSUsds() public { 60 | vm.expectRevert("PSM3/invalid-asset"); 61 | psm.previewSwapExactOut(address(susds), address(susds), 1); 62 | } 63 | 64 | } 65 | 66 | contract PSMPreviewSwapExactIn_UsdsAssetInTests is PSMTestBase { 67 | 68 | function test_previewSwapExactIn_usdsToUsdc() public view { 69 | // Demo rounding down 70 | assertEq(psm.previewSwapExactIn(address(usds), address(usdc), 1e18 - 1), 1e6 - 1); 71 | assertEq(psm.previewSwapExactIn(address(usds), address(usdc), 1e18), 1e6); 72 | assertEq(psm.previewSwapExactIn(address(usds), address(usdc), 1e18 + 1), 1e6); 73 | 74 | assertEq(psm.previewSwapExactIn(address(usds), address(usdc), 1e12 - 1), 0); 75 | assertEq(psm.previewSwapExactIn(address(usds), address(usdc), 1e12), 1); 76 | 77 | assertEq(psm.previewSwapExactIn(address(usds), address(usdc), 1e18), 1e6); 78 | assertEq(psm.previewSwapExactIn(address(usds), address(usdc), 2e18), 2e6); 79 | assertEq(psm.previewSwapExactIn(address(usds), address(usdc), 3e18), 3e6); 80 | } 81 | 82 | function testFuzz_previewSwapExactIn_usdsToUsdc(uint256 amountIn) public view { 83 | amountIn = _bound(amountIn, 0, USDS_TOKEN_MAX); 84 | 85 | assertEq(psm.previewSwapExactIn(address(usds), address(usdc), amountIn), amountIn / 1e12); 86 | } 87 | 88 | function test_previewSwapExactIn_usdsToSUsds() public view { 89 | // Demo rounding down 90 | assertEq(psm.previewSwapExactIn(address(usds), address(susds), 1e18 - 1), 0.8e18 - 1); 91 | assertEq(psm.previewSwapExactIn(address(usds), address(susds), 1e18), 0.8e18); 92 | assertEq(psm.previewSwapExactIn(address(usds), address(susds), 1e18 + 1), 0.8e18); 93 | 94 | assertEq(psm.previewSwapExactIn(address(usds), address(susds), 1e18), 0.8e18); 95 | assertEq(psm.previewSwapExactIn(address(usds), address(susds), 2e18), 1.6e18); 96 | assertEq(psm.previewSwapExactIn(address(usds), address(susds), 3e18), 2.4e18); 97 | } 98 | 99 | function testFuzz_previewSwapExactIn_usdsToSUsds(uint256 amountIn, uint256 conversionRate) public { 100 | amountIn = _bound(amountIn, 1, USDS_TOKEN_MAX); 101 | conversionRate = _bound(conversionRate, 0.0001e27, 1000e27); // 0.01% to 100,000% conversion rate 102 | 103 | mockRateProvider.__setConversionRate(conversionRate); 104 | 105 | uint256 amountOut = amountIn * 1e27 / conversionRate; 106 | 107 | assertEq(psm.previewSwapExactIn(address(usds), address(susds), amountIn), amountOut); 108 | } 109 | 110 | } 111 | 112 | contract PSMPreviewSwapExactOut_UsdsAssetInTests is PSMTestBase { 113 | 114 | function test_previewSwapExactOut_usdsToUsdc() public view { 115 | // Demo rounding up 116 | assertEq(psm.previewSwapExactOut(address(usds), address(usdc), 1e6 - 1), 0.999999e18); 117 | assertEq(psm.previewSwapExactOut(address(usds), address(usdc), 1e6), 1e18); 118 | assertEq(psm.previewSwapExactOut(address(usds), address(usdc), 1e6 + 1), 1.000001e18); 119 | 120 | assertEq(psm.previewSwapExactOut(address(usds), address(usdc), 1e6), 1e18); 121 | assertEq(psm.previewSwapExactOut(address(usds), address(usdc), 2e6), 2e18); 122 | assertEq(psm.previewSwapExactOut(address(usds), address(usdc), 3e6), 3e18); 123 | } 124 | 125 | function testFuzz_previewSwapExactOut_usdsToUsdc(uint256 amountOut) public view { 126 | amountOut = _bound(amountOut, 0, USDC_TOKEN_MAX); 127 | 128 | assertEq(psm.previewSwapExactOut(address(usds), address(usdc), amountOut), amountOut * 1e12); 129 | } 130 | 131 | function test_previewSwapExactOut_usdsToSUsds() public view { 132 | // Demo rounding up 133 | assertEq(psm.previewSwapExactOut(address(usds), address(susds), 1e18 - 1), 1.25e18 - 1); 134 | assertEq(psm.previewSwapExactOut(address(usds), address(susds), 1e18), 1.25e18); 135 | assertEq(psm.previewSwapExactOut(address(usds), address(susds), 1e18 + 1), 1.25e18 + 2); 136 | 137 | assertEq(psm.previewSwapExactOut(address(usds), address(susds), 0.8e18), 1e18); 138 | assertEq(psm.previewSwapExactOut(address(usds), address(susds), 1.6e18), 2e18); 139 | assertEq(psm.previewSwapExactOut(address(usds), address(susds), 2.4e18), 3e18); 140 | } 141 | 142 | function testFuzz_previewSwapExactOut_usdsToSUsds(uint256 amountOut, uint256 conversionRate) public { 143 | amountOut = _bound(amountOut, 1, USDC_TOKEN_MAX); 144 | conversionRate = _bound(conversionRate, 0.0001e27, 1000e27); // 0.01% to 100,000% conversion rate 145 | 146 | mockRateProvider.__setConversionRate(conversionRate); 147 | 148 | uint256 expectedAmountIn = amountOut * conversionRate / 1e27; 149 | 150 | uint256 amountIn = psm.previewSwapExactOut(address(usds), address(susds), amountOut); 151 | 152 | // Allow for rounding error of 1 unit upwards 153 | assertLe(amountIn - expectedAmountIn, 1); 154 | } 155 | 156 | } 157 | 158 | contract PSMPreviewSwapExactIn_USDCAssetInTests is PSMTestBase { 159 | 160 | function test_previewSwapExactIn_usdcToUsds() public view { 161 | // Demo rounding down 162 | assertEq(psm.previewSwapExactIn(address(usdc), address(usds), 1e6 - 1), 0.999999e18); 163 | assertEq(psm.previewSwapExactIn(address(usdc), address(usds), 1e6), 1e18); 164 | assertEq(psm.previewSwapExactIn(address(usdc), address(usds), 1e6 + 1), 1.000001e18); 165 | 166 | assertEq(psm.previewSwapExactIn(address(usdc), address(usds), 1e6), 1e18); 167 | assertEq(psm.previewSwapExactIn(address(usdc), address(usds), 2e6), 2e18); 168 | assertEq(psm.previewSwapExactIn(address(usdc), address(usds), 3e6), 3e18); 169 | } 170 | 171 | function testFuzz_previewSwapExactIn_usdcToUsds(uint256 amountIn) public view { 172 | amountIn = _bound(amountIn, 0, USDC_TOKEN_MAX); 173 | 174 | assertEq(psm.previewSwapExactIn(address(usdc), address(usds), amountIn), amountIn * 1e12); 175 | } 176 | 177 | function test_previewSwapExactIn_usdcToSUsds() public view { 178 | // Demo rounding down 179 | assertEq(psm.previewSwapExactIn(address(usdc), address(susds), 1e6 - 1), 0.799999e18); 180 | assertEq(psm.previewSwapExactIn(address(usdc), address(susds), 1e6), 0.8e18); 181 | assertEq(psm.previewSwapExactIn(address(usdc), address(susds), 1e6 + 1), 0.8e18); 182 | 183 | assertEq(psm.previewSwapExactIn(address(usdc), address(susds), 1e6), 0.8e18); 184 | assertEq(psm.previewSwapExactIn(address(usdc), address(susds), 2e6), 1.6e18); 185 | assertEq(psm.previewSwapExactIn(address(usdc), address(susds), 3e6), 2.4e18); 186 | } 187 | 188 | function testFuzz_previewSwapExactIn_usdcToSUsds(uint256 amountIn, uint256 conversionRate) public { 189 | amountIn = _bound(amountIn, 1, USDC_TOKEN_MAX); 190 | conversionRate = _bound(conversionRate, 0.0001e27, 1000e27); // 0.01% to 100,000% conversion rate 191 | 192 | mockRateProvider.__setConversionRate(conversionRate); 193 | 194 | uint256 amountOut = amountIn * 1e27 / conversionRate * 1e12; 195 | 196 | assertEq(psm.previewSwapExactIn(address(usdc), address(susds), amountIn), amountOut); 197 | } 198 | 199 | } 200 | 201 | contract PSMPreviewSwapExactOut_USDCAssetInTests is PSMTestBase { 202 | 203 | function test_previewSwapExactOut_usdcToUsds() public view { 204 | // Demo rounding up 205 | assertEq(psm.previewSwapExactOut(address(usdc), address(usds), 1e18 - 1), 1e6); 206 | assertEq(psm.previewSwapExactOut(address(usdc), address(usds), 1e18), 1e6); 207 | assertEq(psm.previewSwapExactOut(address(usdc), address(usds), 1e18 + 1), 1e6 + 1); 208 | 209 | assertEq(psm.previewSwapExactOut(address(usdc), address(usds), 1e18), 1e6); 210 | assertEq(psm.previewSwapExactOut(address(usdc), address(usds), 2e18), 2e6); 211 | assertEq(psm.previewSwapExactOut(address(usdc), address(usds), 3e18), 3e6); 212 | } 213 | 214 | function testFuzz_previewSwapExactOut_usdcToUsds(uint256 amountOut) public view { 215 | amountOut = _bound(amountOut, 0, USDS_TOKEN_MAX); 216 | 217 | uint256 amountIn = psm.previewSwapExactOut(address(usdc), address(usds), amountOut); 218 | 219 | // Allow for rounding error of 1 unit upwards 220 | assertLe(amountIn - amountOut / 1e12, 1); 221 | } 222 | 223 | function test_previewSwapExactOut_usdcToSUsds() public view { 224 | // Demo rounding up 225 | assertEq(psm.previewSwapExactOut(address(usdc), address(susds), 1e18 - 1), 1.25e6); 226 | assertEq(psm.previewSwapExactOut(address(usdc), address(susds), 1e18), 1.25e6); 227 | assertEq(psm.previewSwapExactOut(address(usdc), address(susds), 1e18 + 1), 1.25e6 + 1); 228 | 229 | assertEq(psm.previewSwapExactOut(address(usdc), address(susds), 0.8e18), 1e6); 230 | assertEq(psm.previewSwapExactOut(address(usdc), address(susds), 1.6e18), 2e6); 231 | assertEq(psm.previewSwapExactOut(address(usdc), address(susds), 2.4e18), 3e6); 232 | } 233 | 234 | function testFuzz_previewSwapExactOut_usdcToSUsds(uint256 amountOut, uint256 conversionRate) public { 235 | amountOut = _bound(amountOut, 1, SUSDS_TOKEN_MAX); 236 | conversionRate = _bound(conversionRate, 0.0001e27, 1000e27); // 0.01% to 100,000% conversion rate 237 | 238 | mockRateProvider.__setConversionRate(conversionRate); 239 | 240 | // Using raw calculation to demo rounding 241 | uint256 expectedAmountIn = amountOut * conversionRate / 1e27 / 1e12; 242 | 243 | uint256 amountIn = psm.previewSwapExactOut(address(usdc), address(susds), amountOut); 244 | 245 | // Allow for rounding error of 1 unit upwards 246 | assertLe(amountIn - expectedAmountIn, 1); 247 | } 248 | 249 | function test_demoRoundingUp_usdcToSUsds() public view { 250 | uint256 expectedAmountIn1 = psm.previewSwapExactOut(address(usdc), address(susds), 0.8e18); 251 | uint256 expectedAmountIn2 = psm.previewSwapExactOut(address(usdc), address(susds), 0.8e18 + 1); 252 | uint256 expectedAmountIn3 = psm.previewSwapExactOut(address(usdc), address(susds), 0.8e18 + 0.8e12); 253 | uint256 expectedAmountIn4 = psm.previewSwapExactOut(address(usdc), address(susds), 0.8e18 + 0.8e12 + 1); 254 | 255 | assertEq(expectedAmountIn1, 1e6); 256 | assertEq(expectedAmountIn2, 1e6 + 1); 257 | assertEq(expectedAmountIn3, 1e6 + 1); 258 | assertEq(expectedAmountIn4, 1e6 + 2); 259 | } 260 | 261 | function test_demoRoundingUp_usdcToUsds() public view { 262 | uint256 expectedAmountIn1 = psm.previewSwapExactOut(address(usdc), address(usds), 1e18); 263 | uint256 expectedAmountIn2 = psm.previewSwapExactOut(address(usdc), address(usds), 1e18 + 1); 264 | uint256 expectedAmountIn3 = psm.previewSwapExactOut(address(usdc), address(usds), 1e18 + 1e12); 265 | uint256 expectedAmountIn4 = psm.previewSwapExactOut(address(usdc), address(usds), 1e18 + 1e12 + 1); 266 | 267 | assertEq(expectedAmountIn1, 1e6); 268 | assertEq(expectedAmountIn2, 1e6 + 1); 269 | assertEq(expectedAmountIn3, 1e6 + 1); 270 | assertEq(expectedAmountIn4, 1e6 + 2); 271 | } 272 | 273 | } 274 | 275 | contract PSMPreviewSwapExactIn_SUsdsAssetInTests is PSMTestBase { 276 | 277 | function test_previewSwapExactIn_susdsToUsds() public view { 278 | // Demo rounding down 279 | assertEq(psm.previewSwapExactIn(address(susds), address(usds), 1e18 - 1), 1.25e18 - 2); 280 | assertEq(psm.previewSwapExactIn(address(susds), address(usds), 1e18), 1.25e18); 281 | assertEq(psm.previewSwapExactIn(address(susds), address(usds), 1e18 + 1), 1.25e18 + 1); 282 | 283 | assertEq(psm.previewSwapExactIn(address(susds), address(usds), 1e18), 1.25e18); 284 | assertEq(psm.previewSwapExactIn(address(susds), address(usds), 2e18), 2.5e18); 285 | assertEq(psm.previewSwapExactIn(address(susds), address(usds), 3e18), 3.75e18); 286 | } 287 | 288 | function testFuzz_previewSwapExactIn_susdsToUsds(uint256 amountIn, uint256 conversionRate) public { 289 | amountIn = _bound(amountIn, 1, SUSDS_TOKEN_MAX); 290 | conversionRate = _bound(conversionRate, 0.0001e27, 1000e27); // 0.01% to 100,000% conversion rate 291 | 292 | mockRateProvider.__setConversionRate(conversionRate); 293 | 294 | uint256 amountOut = amountIn * conversionRate / 1e27; 295 | 296 | assertEq(psm.previewSwapExactIn(address(susds), address(usds), amountIn), amountOut); 297 | } 298 | 299 | function test_previewSwapExactIn_susdsToUsdc() public view { 300 | // Demo rounding down 301 | assertEq(psm.previewSwapExactIn(address(susds), address(usdc), 1e18 - 1), 1.25e6 - 1); 302 | assertEq(psm.previewSwapExactIn(address(susds), address(usdc), 1e18), 1.25e6); 303 | assertEq(psm.previewSwapExactIn(address(susds), address(usdc), 1e18 + 1), 1.25e6); 304 | 305 | assertEq(psm.previewSwapExactIn(address(susds), address(usdc), 1e18), 1.25e6); 306 | assertEq(psm.previewSwapExactIn(address(susds), address(usdc), 2e18), 2.5e6); 307 | assertEq(psm.previewSwapExactIn(address(susds), address(usdc), 3e18), 3.75e6); 308 | } 309 | 310 | function testFuzz_previewSwapExactIn_susdsToUsdc(uint256 amountIn, uint256 conversionRate) public { 311 | amountIn = _bound(amountIn, 1, SUSDS_TOKEN_MAX); 312 | conversionRate = _bound(conversionRate, 0.0001e27, 1000e27); // 0.01% to 100,000% conversion rate 313 | 314 | mockRateProvider.__setConversionRate(conversionRate); 315 | 316 | uint256 amountOut = amountIn * conversionRate / 1e27 / 1e12; 317 | 318 | assertEq(psm.previewSwapExactIn(address(susds), address(usdc), amountIn), amountOut); 319 | } 320 | 321 | } 322 | 323 | contract PSMPreviewSwapExactOut_SUsdsAssetInTests is PSMTestBase { 324 | 325 | function test_previewSwapExactOut_susdsToUsds() public view { 326 | // Demo rounding up 327 | assertEq(psm.previewSwapExactOut(address(susds), address(usds), 1e18 - 1), 0.8e18); 328 | assertEq(psm.previewSwapExactOut(address(susds), address(usds), 1e18), 0.8e18); 329 | assertEq(psm.previewSwapExactOut(address(susds), address(usds), 1e18 + 1), 0.8e18 + 1); 330 | 331 | assertEq(psm.previewSwapExactOut(address(susds), address(usds), 1.25e18), 1e18); 332 | assertEq(psm.previewSwapExactOut(address(susds), address(usds), 2.5e18), 2e18); 333 | assertEq(psm.previewSwapExactOut(address(susds), address(usds), 3.75e18), 3e18); 334 | } 335 | 336 | function testFuzz_previewSwapExactOut_susdsToUsds(uint256 amountOut, uint256 conversionRate) public { 337 | amountOut = _bound(amountOut, 1, USDS_TOKEN_MAX); 338 | conversionRate = _bound(conversionRate, 0.0001e27, 1000e27); // 0.01% to 100,000% conversion rate 339 | 340 | mockRateProvider.__setConversionRate(conversionRate); 341 | 342 | uint256 expectedAmountIn = amountOut * 1e27 / conversionRate; 343 | 344 | uint256 amountIn = psm.previewSwapExactOut(address(susds), address(usds), amountOut); 345 | 346 | // Allow for rounding error of 1 unit upwards 347 | assertLe(amountIn - expectedAmountIn, 1); 348 | } 349 | 350 | function test_previewSwapExactOut_susdsToUsdc() public view { 351 | // Demo rounding up 352 | assertEq(psm.previewSwapExactOut(address(susds), address(usdc), 1e6 - 1), 0.8e18); 353 | assertEq(psm.previewSwapExactOut(address(susds), address(usdc), 1e6), 0.8e18); 354 | assertEq(psm.previewSwapExactOut(address(susds), address(usdc), 1e6 + 1), 0.800001e18); 355 | 356 | assertEq(psm.previewSwapExactOut(address(susds), address(usdc), 1.25e6), 1e18); 357 | assertEq(psm.previewSwapExactOut(address(susds), address(usdc), 2.5e6), 2e18); 358 | assertEq(psm.previewSwapExactOut(address(susds), address(usdc), 3.75e6), 3e18); 359 | } 360 | 361 | function testFuzz_previewSwapExactOut_susdsToUsdc(uint256 amountOut, uint256 conversionRate) public { 362 | amountOut = bound(amountOut, 1, USDC_TOKEN_MAX); 363 | conversionRate = bound(conversionRate, 0.0001e27, 1000e27); // 0.01% to 100,000% conversion rate 364 | 365 | mockRateProvider.__setConversionRate(conversionRate); 366 | 367 | uint256 expectedAmountIn = amountOut * 1e27 / conversionRate * 1e12; 368 | 369 | uint256 amountIn = psm.previewSwapExactOut(address(susds), address(usdc), amountOut); 370 | 371 | // Allow for rounding error of 1e12 upwards 372 | assertLe(amountIn - expectedAmountIn, 1e12); 373 | } 374 | 375 | } 376 | -------------------------------------------------------------------------------- /test/unit/harnesses/PSM3Harness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-or-later 2 | pragma solidity ^0.8.13; 3 | 4 | import { PSM3 } from "src/PSM3.sol"; 5 | 6 | contract PSM3Harness is PSM3 { 7 | 8 | constructor( 9 | address owner_, 10 | address usdc_, 11 | address usds_, 12 | address susds_, 13 | address rateProvider_ 14 | ) 15 | PSM3(owner_, usdc_, usds_, susds_, rateProvider_) {} 16 | 17 | function getAssetValue(address asset, uint256 amount, bool roundUp) 18 | external view returns (uint256) 19 | { 20 | return _getAssetValue(asset, amount, roundUp); 21 | } 22 | 23 | function getUsdcValue(uint256 amount) external view returns (uint256) { 24 | return _getUsdcValue(amount); 25 | } 26 | 27 | function getUsdsValue(uint256 amount) external view returns (uint256) { 28 | return _getUsdsValue(amount); 29 | } 30 | 31 | function getSUsdsValue(uint256 amount, bool roundUp) external view returns (uint256) { 32 | return _getSUsdsValue(amount, roundUp); 33 | } 34 | 35 | function getAssetCustodian(address asset) external view returns (address) { 36 | return _getAssetCustodian(asset); 37 | } 38 | 39 | } 40 | --------------------------------------------------------------------------------