Repository: devdacian/solidity-fuzzing-comparison Branch: main Commit: 125071f38b63 Files: 156 Total size: 419.9 KB Directory structure: gitextract_67c2mjes/ ├── .github/ │ └── workflows/ │ └── test.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── foundry.toml ├── src/ │ ├── 01-naive-receiver/ │ │ ├── FlashLoanReceiver.sol │ │ └── NaiveReceiverLenderPool.sol │ ├── 02-unstoppable/ │ │ ├── ReceiverUnstoppable.sol │ │ └── UnstoppableLender.sol │ ├── 03-proposal/ │ │ └── Proposal.sol │ ├── 04-voting-nft/ │ │ ├── VotingNft.sol │ │ └── VotingNftForFuzz.sol │ ├── 05-token-sale/ │ │ └── TokenSale.sol │ ├── 06-rarely-false/ │ │ └── RarelyFalse.sol │ ├── 07-byte-battle/ │ │ └── ByteBattle.sol │ ├── 08-omni-protocol/ │ │ ├── IRM.sol │ │ ├── OmniOracle.sol │ │ ├── OmniPool.sol │ │ ├── OmniToken.sol │ │ ├── OmniTokenNoBorrow.sol │ │ ├── SubAccount.sol │ │ ├── WETHGateway.sol │ │ ├── WithUnderlying.sol │ │ ├── interfaces/ │ │ │ ├── IBandReference.sol │ │ │ ├── IChainlinkAggregator.sol │ │ │ ├── ICustomOmniOracle.sol │ │ │ ├── IIRM.sol │ │ │ ├── IOmniOracle.sol │ │ │ ├── IOmniPool.sol │ │ │ ├── IOmniToken.sol │ │ │ ├── IOmniTokenBase.sol │ │ │ ├── IOmniTokenNoBorrow.sol │ │ │ ├── IWETH9.sol │ │ │ └── IWithUnderlying.sol │ │ └── oracles/ │ │ └── WstETHCustomOracle.sol │ ├── 09-vesting/ │ │ └── Vesting.sol │ ├── 10-vesting-ext/ │ │ └── VestingExt.sol │ ├── 11-op-reg/ │ │ └── OperatorRegistry.sol │ ├── 12-liquidate-dos/ │ │ └── LiquidateDos.sol │ ├── 13-stability-pool/ │ │ └── StabilityPool.sol │ ├── 14-priority/ │ │ └── Priority.sol │ ├── MockERC20.sol │ ├── TestToken.sol │ └── TestToken2.sol └── test/ ├── 01-naive-receiver/ │ ├── NaiveReceiverAdvancedEchidna.t.sol │ ├── NaiveReceiverAdvancedEchidna.yaml │ ├── NaiveReceiverAdvancedFoundry.t.sol │ ├── NaiveReceiverAdvancedMedusa.json │ ├── NaiveReceiverBasicEchidna.t.sol │ ├── NaiveReceiverBasicEchidna.yaml │ ├── NaiveReceiverBasicFoundry.t.sol │ └── NaiveReceiverBasicMedusa.json ├── 02-unstoppable/ │ ├── UnstoppableBasicEchidna.t.sol │ ├── UnstoppableBasicEchidna.yaml │ ├── UnstoppableBasicFoundry.t.sol │ ├── UnstoppableBasicMedusa.json │ ├── certora.conf │ └── certora.spec ├── 03-proposal/ │ ├── Properties.sol │ ├── ProposalCryticTester.sol │ ├── ProposalCryticTesterToFoundry.sol │ ├── Setup.sol │ ├── certora.conf │ ├── certora.spec │ ├── echidna.yaml │ └── medusa.json ├── 04-voting-nft/ │ ├── Properties.sol │ ├── Setup.sol │ ├── VotingNftCryticTester.sol │ ├── VotingNftCryticToFoundry.sol │ ├── certora.conf │ ├── certora.spec │ ├── echidna.yaml │ └── medusa.json ├── 05-token-sale/ │ ├── TokenSaleAdvancedEchidna.t.sol │ ├── TokenSaleAdvancedEchidna.yaml │ ├── TokenSaleAdvancedFoundry.t.sol │ ├── TokenSaleBasicEchidna.t.sol │ ├── TokenSaleBasicEchidna.yaml │ ├── TokenSaleBasicFoundry.t.sol │ ├── TokenSaleBasicMedusa.json │ ├── certora.conf │ └── certora.spec ├── 06-rarely-false/ │ ├── RarelyFalseCryticTester.sol │ ├── RarelyFalseCryticToFoundry.sol │ ├── TargetFunctions.sol │ ├── certora.conf │ ├── certora.spec │ ├── echidna.yaml │ └── medusa.json ├── 07-byte-battle/ │ ├── ByteBattleCryticTester.sol │ ├── ByteBattleCryticToFoundry.sol │ ├── TargetFunctions.sol │ ├── certora.conf │ ├── certora.spec │ ├── echidna.yaml │ └── medusa.json ├── 08-omni-protocol/ │ ├── MockOracle.sol │ ├── OmniAdvancedEchidna.yaml │ ├── OmniAdvancedFoundry.t.sol │ ├── OmniAdvancedMedusa.json │ └── OmniAdvancedMedusa.t.sol ├── 09-vesting/ │ ├── Properties.sol │ ├── Setup.sol │ ├── TargetFunctions.sol │ ├── VestingCryticTester.sol │ ├── VestingCryticToFoundry.sol │ ├── certora.conf │ ├── certora.spec │ ├── echidna.yaml │ └── medusa.json ├── 10-vesting-ext/ │ ├── Properties.sol │ ├── Setup.sol │ ├── TargetFunctions.sol │ ├── VestingExtCryticTester.sol │ ├── VestingExtCryticToFoundry.sol │ ├── certora.conf │ ├── certora.spec │ ├── echidna.yaml │ └── medusa.json ├── 11-op-reg/ │ ├── OpRegCryticTester.sol │ ├── OpRegCryticToFoundry.sol │ ├── Properties.sol │ ├── Setup.sol │ ├── TargetFunctions.sol │ ├── certora.conf │ ├── certora.spec │ ├── echidna.yaml │ └── medusa.json ├── 12-liquidate-dos/ │ ├── LiquidateDosCryticTester.sol │ ├── LiquidateDosCryticToFoundry.sol │ ├── Properties.sol │ ├── Setup.sol │ ├── TargetFunctions.sol │ ├── echidna.yaml │ └── medusa.json ├── 13-stability-pool/ │ ├── Properties.sol │ ├── Setup.sol │ ├── StabilityPoolCryticTester.sol │ ├── StabilityPoolCryticToFoundry.sol │ ├── TargetFunctions.sol │ ├── certora.conf │ ├── certora.spec │ ├── echidna.yaml │ └── medusa.json ├── 14-priority/ │ ├── PriorityCryticTester.sol │ ├── PriorityCryticToFoundry.sol │ ├── Properties.sol │ ├── Setup.sol │ ├── TargetFunctions.sol │ ├── certora.conf │ ├── certora.spec │ ├── echidna.yaml │ └── medusa.json └── TestUtils.sol ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/test.yml ================================================ name: test on: workflow_dispatch env: FOUNDRY_PROFILE: ci jobs: check: strategy: fail-fast: true name: Foundry project runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: version: nightly - name: Run Forge build run: | forge --version forge build --sizes id: build - name: Run Forge tests run: | forge test -vvv id: test ================================================ FILE: .gitignore ================================================ # Compiler files cache/ out/ crytic-export/ # Ignores development broadcast logs !/broadcast /broadcast/*/31337/ /broadcast/**/dry-run/ # Docs docs/ # Dotenv file .env # test coverage files **/coverage** # misc .DS_Store .certora_internal slither_results.json ================================================ FILE: .gitmodules ================================================ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts [submodule "lib/openzeppelin-contracts-upgradeable"] path = lib/openzeppelin-contracts-upgradeable url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable [submodule "lib/chimera"] path = lib/chimera url = https://github.com/Recon-Fuzz/chimera ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Dacian Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Solidity Fuzzing Challenge: Foundry vs Echidna vs Medusa (plus Halmos & Certora) # A comparison of solidity fuzzing tools [Foundry](https://book.getfoundry.sh/), [Echidna](https://secure-contracts.com/program-analysis/echidna/index.html) & [Medusa](https://github.com/crytic/medusa) also considering Formal Verification tools such as [Halmos](https://github.com/a16z/halmos) and [Certora](https://docs.certora.com/en/latest/docs/user-guide/tutorials.html). This challenge set is not intended to be an academically rigorous benchmark but rather to present the experiences of an auditor "in the trenches"; the primary goal is finding the best performance "out of the box" with as little guidance & tweaking as possible. Many of the challenges are simplified versions of audit findings from my private audits at [Cyfrin](https://www.cyfrin.io). These findings could have been found by the protocol developers themselves prior to an external audit if the protocol had written the correct [fuzz testing invariants](https://dacian.me/find-highs-before-external-auditors-using-invariant-fuzz-testing). Hence a secondary goal of this repo is to show developers how to write better fuzz testing invariants to improve their protocol security prior to engaging external auditors. ## Setup ## Ensure you are using recent versions of [Foundry](https://github.com/foundry-rs/foundry), [Echidna](https://github.com/crytic/echidna) and [Medusa](https://github.com/crytic/medusa). Configure [solc-select](https://github.com/crytic/solc-select) for Echidna & Medusa: `solc-select install 0.8.23`\ `solc-select use 0.8.23` To compile this project: `forge build` Every exercise has a `basic` configuration and/or `advanced` fuzz configuration for Foundry, Echidna & Medusa. The `basic` configuration does not guide the fuzzer at all; it simply sets up the scenario and allows the fuzzer to do whatever it wants. The `advanced` configuration guides the fuzzer to the functions it should call and helps to eliminate invalid inputs which result in useless fuzz runs. ## Results ## ### Challenge #1 Naive Receiver: (Winner TIED ALL) ### In `basic` configuration Foundry, Echidna & Medusa are able to break the simpler invariant but not the more valuable and difficult one. In `advanced` configuration all 3 fuzzers can break both invariants. All 3 fuzzers reduce the exploit chain to a very concise & optimized transaction set and present this to the user in an easy to understand output. As a result they are tied and there is no clear winner. ### Challenge #2 Unstoppable: (Winner TIED ALL) ### All Fuzzers in `basic` configuration can break both invariants; Foundry appears to be the slightly faster. ### Challenge #3 Proposal: (Winner TIED ALL) ### Foundry, Echidna & Medusa in `basic` mode are able to easily break the invariant, resulting in a tie. ### Challenge #4 Voting NFT: (Winner TIED ALL) ### In `basic` configuration Foundry, Echidna & Medusa are all able to break the easier invariant but not the more difficult one. All Fuzzers are able to provide the user with a minimal transaction set to generate the exploit. Hence they are tied, there is no clear winner. ### Challenge #5 Token Sale: (Winner MEDUSA) ### In `basic` configuration Foundry & Echidna can only break the easier and more valuable invariant which leads to a Critical exploit but not the harder though less valuable invariant which leads to a High/Medium. However Medusa is able to almost immediately break both invariants in unguided `basic` mode, making Medusa the clear winner. ### Challenge #6 Rarely False: (Winner TIED HALMOS & CERTORA) ### Both Echidna & Foundry are unable to break the assertion in this stateless fuzzing challenge. Medusa [used](https://twitter.com/DevDacian/status/1732199452344221913) to be able to break it almost instantly but has [regressed](https://github.com/crytic/medusa/issues/305) in performance after recent changes and is now unable to break it. Halmos and Certora can break it so they are the winners. ### Challenge #7 Byte Battle: (Winner TIED ALL) All tools are able to quickly break this challenge. ### Challenge #8 Omni Protocol: (Winner MEDUSA) All 3 Fuzzers configured in `advanced` guided mode attempted to break 16 invariants on Beta Finance [Omni Protocol](https://github.com/beta-finance/Omni-Protocol). Medusa is typically able to break 2 invariants within 5 minutes (often much sooner on subsequent runs) though on the first run can take a bit longer. Echidna can sometimes break 1 invariant within 5 minutes and Foundry appears to never be able to break any invariants within 5 minutes. Hence Medusa is the clear winner. The fuzzers written for this challenge were [contributed](https://github.com/beta-finance/Omni-Protocol/pull/2) to Beta Finance. ### Challenge #9 -> #14 Some additional solvers have been added based upon real-world findings from my private audits. ================================================ FILE: foundry.toml ================================================ [profile.default] src = "src" out = "out" libs = ["lib"] show_progress = true # include remappings remappings = [ "@openzeppelin/=lib/openzeppelin-contracts/", "@openzeppelin-upgradeable/=lib/openzeppelin-contracts-upgradeable/", "@chimera/=lib/chimera/src/", ] [fuzz] runs = 500 max_test_rejects = 999999999 # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options ================================================ FILE: src/01-naive-receiver/FlashLoanReceiver.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/utils/Address.sol"; /** * @title FlashLoanReceiver * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz) */ contract FlashLoanReceiver { using Address for address payable; address payable private pool; constructor(address payable poolAddress) { pool = poolAddress; } // Function called by the pool during flash loan function receiveEther(uint256 fee) public payable { require(msg.sender == pool, "Sender must be pool"); uint256 amountToBeRepaid = msg.value + fee; require(address(this).balance >= amountToBeRepaid, "Cannot borrow that much"); _executeActionDuringFlashLoan(); // Return funds to pool pool.sendValue(amountToBeRepaid); } // Internal function where the funds received are used function _executeActionDuringFlashLoan() internal { } // Allow deposits of ETH receive () external payable {} } ================================================ FILE: src/01-naive-receiver/NaiveReceiverLenderPool.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/utils/Address.sol"; /** * @title NaiveReceiverLenderPool * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz) */ contract NaiveReceiverLenderPool is ReentrancyGuard { using Address for address; uint256 private constant FIXED_FEE = 1 ether; // not the cheapest flash loan function fixedFee() external pure returns (uint256) { return FIXED_FEE; } function flashLoan(address borrower, uint256 borrowAmount) external nonReentrant { uint256 balanceBefore = address(this).balance; require(balanceBefore >= borrowAmount, "Not enough ETH in pool"); require(borrower.code.length > 0, "Borrower must be a deployed contract"); // Transfer ETH and handle control to receiver borrower.functionCallWithValue( abi.encodeWithSignature( "receiveEther(uint256)", FIXED_FEE ), borrowAmount ); require( address(this).balance >= balanceBefore + FIXED_FEE, "Flash loan hasn't been paid back" ); } // Allow deposits of ETH receive () external payable {} } ================================================ FILE: src/02-unstoppable/ReceiverUnstoppable.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "./UnstoppableLender.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /** * @title ReceiverUnstoppable * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz) */ contract ReceiverUnstoppable { UnstoppableLender private immutable pool; address private immutable owner; constructor(address poolAddress) { pool = UnstoppableLender(poolAddress); owner = msg.sender; } // Pool will call this function during the flash loan function receiveTokens(address tokenAddress, uint256 amount) external { require(msg.sender == address(pool), "Sender must be pool"); // Return all tokens to the pool require(IERC20(tokenAddress).transfer(msg.sender, amount), "Transfer of tokens failed"); } function executeFlashLoan(uint256 amount) external { require(msg.sender == owner, "Only owner can execute flash loan"); pool.flashLoan(amount); } } ================================================ FILE: src/02-unstoppable/UnstoppableLender.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; interface IReceiver { function receiveTokens(address tokenAddress, uint256 amount) external; } /** * @title UnstoppableLender * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz) */ contract UnstoppableLender is ReentrancyGuard { IERC20 public immutable damnValuableToken; uint256 public poolBalance; constructor(address tokenAddress) { require(tokenAddress != address(0), "Token address cannot be zero"); damnValuableToken = IERC20(tokenAddress); } function depositTokens(uint256 amount) external nonReentrant { require(amount > 0, "Must deposit at least one token"); // Transfer token from sender. Sender must have first approved them. damnValuableToken.transferFrom(msg.sender, address(this), amount); poolBalance = poolBalance + amount; } function flashLoan(uint256 borrowAmount) external nonReentrant { require(borrowAmount > 0, "Must borrow at least one token"); uint256 balanceBefore = damnValuableToken.balanceOf(address(this)); require(balanceBefore >= borrowAmount, "Not enough tokens in pool"); // Ensured by the protocol via the `depositTokens` function assert(poolBalance == balanceBefore); damnValuableToken.transfer(msg.sender, borrowAmount); IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount); uint256 balanceAfter = damnValuableToken.balanceOf(address(this)); require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back"); } } ================================================ FILE: src/03-proposal/Proposal.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/utils/math/Math.sol"; // // This contract is a simplified version of a real contract which // was audited by Cyfrin in a private audit and contained the same bug. // // Your mission, should you choose to accept it, is to find that bug! // // This contract allows the creator to invite a select group of people // to vote on something and provides an eth reward to the `for` voters // if the proposal passes, otherwise refunds the reward to the creator. // The creator of the contract is considered "Trusted". // // This contract has been intentionally simplified to remove much of // the extra complexity in order to help you find the particular bug without // other distractions. Please read the comments carefully as they note // specific findings that are excluded as the implementation has been // purposefully kept simple to help you focus on finding the harder // to find and more interesting bug. // // This contract intentionally has no time-out period for the voting // to complete; lack of a time-out period resulting in voting never // completing is not a valid finding as this has been intentionally // omitted to simplify the codebase. // // This contract should only contain 1 intentional High finding, but // if you find others they were not intentional :-) This contract should // not be used in any live/production environment; it is purely an // educational bug-hunting exercise based on a real-world example. // contract Proposal { // smallest amount proposal creator can fund contract with uint256 private constant MIN_FUNDING = 1 ether; // min/max number of voters uint256 private constant MIN_VOTERS = 3; uint256 private constant MAX_VOTERS = 9; // min quorum uint256 private constant MIN_QUORUM = 51; // constants used for `voterState` in `s_voters` mapping uint8 private constant DISALLOWED = 0; uint8 private constant ALLOWED = 1; uint8 private constant VOTED = 2; // only permitted addresses can vote, each address gets 1 vote mapping(address voter => uint8 voterState) private s_voters; // creator of this proposal. Any findings related to the creator // not being able to update this address are invalid; this has // intentionally been omitted to simplify the contract so you can // focus on finding the cool bug instead of lame/easy stuff. Proposal // creator is trusted to create the proposal from an address that // can receive eth address private s_creator; // total number of allowed voters uint256 private s_totalAllowedVoters; // total number of current votes uint256 private s_totalCurrentVotes; // list of users who voted for address[] private s_votersFor; // list of users who votes against address[] private s_votersAgainst; // whether voting has been completed bool private s_votingComplete; // create the contract constructor(address[] memory allowList) payable { // require minimum eth proposal reward require(msg.value >= MIN_FUNDING, "DP: Minimum 1 eth proposal reward required"); // cache list length uint256 allowListLength = allowList.length; // perform some sanity checks. NOTE: checks for duplicate inputs // are performed by entity creating the proposal who is // supplying the eth and is trusted, so the contract intentionally // does not re-check for duplicate inputs. Findings related to // not checking for duplicate inputs are invalid. require(allowListLength >= MIN_VOTERS, "DP: Minimum 3 voters required"); require(allowListLength <= MAX_VOTERS, "DP: Maximum 9 voters allowed"); // odd number of voters required to simplify quorum check require(allowListLength % 2 != 0, "DP: Odd number of voters required"); // cache total voters to prevent multiple storage writes uint256 totalVoters; // store addresses allowed to vote on this proposal for(; totalVoters votesAgainst (1). // // This system of voting doesn't require a strict majority to // pass the proposal (it didn't require 3 For votes), it just // requires the quorum to be reached (enough people to vote) // if(totalCurrentVotes * 100 / s_totalAllowedVoters >= MIN_QUORUM) { // mark voting as having been completed s_votingComplete = true; // distribute the voting rewards _distributeRewards(); } } // distributes rewards to the `for` voters if the proposal has // passed or refunds the rewards back to the creator if the proposal // failed function _distributeRewards() private { // get number of voters for & against uint256 totalVotesFor = s_votersFor.length; uint256 totalVotesAgainst = s_votersAgainst.length; uint256 totalVotes = totalVotesFor + totalVotesAgainst; // rewards to distribute or refund. This is guaranteed to be // greater or equal to the minimum funding amount by a check // in the constructor, and there is intentionally by design // no way to decrease or increase this amount. Any findings // related to not being able to increase/decrease the total // reward amount are invalid uint256 totalRewards = address(this).balance; // if the proposal was defeated refund reward back to the creator // for the proposal to be successful it must have had more `For` votes // than `Against` votes if(totalVotesAgainst >= totalVotesFor) { // proposal creator is trusted to create a proposal from an address // that can receive ETH. See comment before declaration of `s_creator` _sendEth(s_creator, totalRewards); } // otherwise the proposal passed so distribute rewards to the `For` voters else{ uint256 rewardPerVoter = totalRewards / totalVotes; for(uint256 i; i NftInfo) s_nftInfo; // create the contract constructor( uint256 requiredCollateral, uint256 powerCalcTimestamp, uint256 maxNftPower, uint256 nftPowerReductionPercent) ERC721("VNFT", "VNFT") Ownable(msg.sender) { // input sanity checks require(requiredCollateral > 0, "VNFT: required collateral must be > 0"); require(powerCalcTimestamp > block.timestamp, "VNFT: power calc timestamp must be in the future"); require(maxNftPower > 0, "VNFT: max nft power must be > 0"); require(nftPowerReductionPercent > 0, "VNFT: nft power reduction must be > 0"); require(nftPowerReductionPercent < PERCENTAGE_100, "VNFT: nft power reduction too big"); s_requiredCollateral = requiredCollateral; s_powerCalcTimestamp = powerCalcTimestamp; s_maxNftPower = maxNftPower; s_nftPowerReductionPercent = nftPowerReductionPercent; } // some operations can only be performed before // power calculation has started modifier onlyBeforePowerCalc() { _onlyBeforePowerCalc(); _; } function _onlyBeforePowerCalc() private view { require( block.timestamp < s_powerCalcTimestamp, "VNFT: power calculation has already started" ); } // allows contract owner to mint nfts to an address // can only be called before power calculation starts // all new nfts start at max power function safeMint(address to, uint256 tokenId) external onlyOwner onlyBeforePowerCalc { _safeMint(to, tokenId, ""); s_totalPower += s_maxNftPower; } // allows nft holders to deposit collateral for their nft // nfts which have their required collateral deposited // don't lose power function addCollateral(uint256 tokenId) external payable { // sanity checks require(ownerOf(tokenId) == msg.sender, "VNFT: only nft owner can deposit collateral"); uint256 amount = msg.value; require(amount > 0, "VNFT: collateral deposit amount must be > 0"); require(s_nftInfo[tokenId].currentCollateral + amount <= s_requiredCollateral, "VNFT: collateral deposit must not exceeed required collateral"); // recalculation intentionally takes place before storage update recalculateNftPower(tokenId); // update storage s_nftInfo[tokenId].currentCollateral += amount; s_totalCollateral += amount; } // allows nft holders to withdraw collateral which had been // deposited for their nfts. This will cause those nfts // to subsequently lose power function removeCollateral(uint256 tokenId) external payable { // sanity checks address tokenOwner = ownerOf(tokenId); require(tokenOwner == msg.sender, "VNFT: only nft owner can remove collateral"); uint256 amount = msg.value; require(amount > 0, "VNFT: collateral remove amount must be > 0"); // recalculation intentionally takes place before storage update recalculateNftPower(tokenId); // update storage s_nftInfo[tokenId].currentCollateral -= amount; s_totalCollateral -= amount; // send withdrawn collateral to token owner _sendEth(tokenOwner, amount); } // recalculated nft power. Used internally and also called externally by // other operations within the DAO function recalculateNftPower(uint256 tokenId) public returns (uint256 newPower) { // nfts have no power until power calculation starts if (block.timestamp < s_powerCalcTimestamp) { return 0; } newPower = getNftPower(tokenId); NftInfo storage nftInfo = s_nftInfo[tokenId]; s_totalPower -= nftInfo.lastUpdate != 0 ? nftInfo.currentPower : s_maxNftPower; s_totalPower += newPower; nftInfo.lastUpdate = block.timestamp; nftInfo.currentPower = newPower; } // make sure to recalculate nft power if these nfts are transferred function _update(address to, uint256 tokenId, address auth) internal override returns (address) { // if users have collateral deposited for their nfts when they transfer // them to another user, the other users effectively becomes the owner // of the collateral. The nfts are mostly worthless without the collateral since // their voting power drops without it, so by design the deposited // collateral moves with the nfts recalculateNftPower(tokenId); return super._update(to, tokenId, auth); } // bunch of getter functions function getRequiredCollateral() public view returns (uint256) { return s_requiredCollateral; } function getPowerCalcTimestamp() public view returns (uint256) { return s_powerCalcTimestamp; } function getMaxNftPower() public view returns (uint256) { return s_maxNftPower; } function getNftPowerReductionPercent() public view returns (uint256) { return s_nftPowerReductionPercent; } function getTotalPower() public view returns (uint256) { return s_totalPower; } function getTotalCollateral() public view returns (uint256) { return s_totalCollateral; } function getDepositedCollateral(uint256 tokenId) public view returns (uint256) { _requireOwned(tokenId); return s_nftInfo[tokenId].currentCollateral; } function getNftPower(uint256 tokenId) public view returns (uint256) { // ensure token has already been minted _requireOwned(tokenId); if (block.timestamp <= s_powerCalcTimestamp) { return 0; } uint256 collateral = s_nftInfo[tokenId].currentCollateral; // Calculate the minimum possible power based on the collateral of the nft uint256 maxNftPower = s_maxNftPower; uint256 minNftPower = maxNftPower * collateral / s_requiredCollateral; minNftPower = Math.min(maxNftPower, minNftPower); // Get last update and current power. Or set them to default if it is first iteration uint256 lastUpdate = s_nftInfo[tokenId].lastUpdate; uint256 currentPower = s_nftInfo[tokenId].currentPower; if (lastUpdate == 0) { lastUpdate = s_powerCalcTimestamp; currentPower = maxNftPower; } // Calculate reduction amount uint256 powerReductionPercent = s_nftPowerReductionPercent * (block.timestamp - lastUpdate); uint256 powerReduction = Math.min(currentPower, (maxNftPower * powerReductionPercent) / PERCENTAGE_100); uint256 newPotentialPower = currentPower - powerReduction; if (minNftPower <= newPotentialPower) { return newPotentialPower; } if (minNftPower <= currentPower) { return minNftPower; } return currentPower; } // sends eth using low-level call as we don't care about returned data function _sendEth(address dest, uint256 amount) private { bool sendStatus; assembly { sendStatus := call(gas(), dest, amount, 0, 0, 0, 0) } require(sendStatus, "VNFT: failed to send eth"); } } ================================================ FILE: src/04-voting-nft/VotingNftForFuzz.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; // using regular and old Ownable for simplicity, any findings // related to using newer Ownable or 2-step are invalid import "@openzeppelin/contracts/access/Ownable.sol"; // // This contract is a simplified version of a real contract which // was audited by Cyfrin in a private audit and contained the same bug. // // Your mission, should you choose to accept it, is to find that bug! // // This is an nft contract that allows users to have nfts which // have voting power in a DAO. These nfts can lose power over time if // the required collateral is not deposited. The power of these nfts // can never increase, only decrease or remain the same. // // This contract has been intentionally simplified to remove much of // the extra complexity in order to help you find the particular bug without // other distractions. Please read the comments carefully as they note // specific findings that are excluded as the implementation has been // purposefully kept simple to help you focus on finding the harder // to find and more interesting bug. // // This contract should only contain 1 intentional High finding, but // if you find others they were not intentional :-) This contract should // not be used in any live/production environment; it is purely an // educational bug-hunting exercise based on a real-world example. // // This contract has had changes made for fuzzing test its initial // power calculation state; instead of using block.timestamp it uses // a hard-coded value that the test setup sets. This allows a fuzzer to // explore the initial power calculation state without skipping past it // by changing block.timestamp contract VotingNftForFuzz is ERC721, Ownable { // useful constants uint256 private constant PERCENTAGE_100 = 10 ** 27; // required collateral in eth to be deposited per NFT in order to // prevent voting power from decreasing uint256 private s_requiredCollateral; // time when power calculation begins uint256 private s_powerCalcTimestamp; // max power an nft can have. Power can only decrease or stay the same uint256 private s_maxNftPower; // % by which nft power decreases if required collateral not deposited uint256 private s_nftPowerReductionPercent; // current total power; will increase before power calculation starts // as nfts are created. once power calculation starts can only decrease // if nfts don't have the required collateral deposited uint256 private s_totalPower; // current total collateral which has been deposited for nfts uint256 private s_totalCollateral; // required for fuzz testing initial state once power calculation starts // replaces block.timestamp when fuzzing uint256 private s_FuzzerConstantBlockTimestamp; struct NftInfo { uint256 lastUpdate; uint256 currentPower; uint256 currentCollateral; } // keeps track of contract-specific nft info mapping(uint256 tokenId => NftInfo) s_nftInfo; // create the contract constructor( uint256 requiredCollateral, uint256 powerCalcTimestamp, uint256 maxNftPower, uint256 nftPowerReductionPercent) ERC721("VNFT", "VNFT") Ownable(msg.sender) { // input sanity checks require(requiredCollateral > 0, "VNFT: required collateral must be > 0"); require(powerCalcTimestamp > block.timestamp, "VNFT: power calc timestamp must be in the future"); require(maxNftPower > 0, "VNFT: max nft power must be > 0"); require(nftPowerReductionPercent > 0, "VNFT: nft power reduction must be > 0"); require(nftPowerReductionPercent < PERCENTAGE_100, "VNFT: nft power reduction too big"); s_requiredCollateral = requiredCollateral; s_powerCalcTimestamp = powerCalcTimestamp; s_maxNftPower = maxNftPower; s_nftPowerReductionPercent = nftPowerReductionPercent; } // some operations can only be performed before // power calculation has started modifier onlyBeforePowerCalc() { _onlyBeforePowerCalc(); _; } function _onlyBeforePowerCalc() private view { require( // replace block.timestamp with constant value // for fuzzing initial power calc state //block.timestamp < s_powerCalcTimestamp, s_FuzzerConstantBlockTimestamp < s_powerCalcTimestamp, "VNFT: power calculation has already started" ); } // allows contract owner to mint nfts to an address // can only be called before power calculation starts // all new nfts start at max power function safeMint(address to, uint256 tokenId) external onlyOwner onlyBeforePowerCalc { _safeMint(to, tokenId, ""); s_totalPower += s_maxNftPower; } // allows nft holders to deposit collateral for their nft // nfts which have their required collateral deposited // don't lose power function addCollateral(uint256 tokenId) external payable { // sanity checks require(ownerOf(tokenId) == msg.sender, "VNFT: only nft owner can deposit collateral"); uint256 amount = msg.value; require(amount > 0, "VNFT: collateral deposit amount must be > 0"); require(s_nftInfo[tokenId].currentCollateral + amount <= s_requiredCollateral, "VNFT: collateral deposit must not exceeed required collateral"); // recalculation intentionally takes place before storage update recalculateNftPower(tokenId); // update storage s_nftInfo[tokenId].currentCollateral += amount; s_totalCollateral += amount; } // allows nft holders to withdraw collateral which had been // deposited for their nfts. This will cause those nfts // to subsequently lose power function removeCollateral(uint256 tokenId) external payable { // sanity checks address tokenOwner = ownerOf(tokenId); require(tokenOwner == msg.sender, "VNFT: only nft owner can remove collateral"); uint256 amount = msg.value; require(amount > 0, "VNFT: collateral remove amount must be > 0"); // recalculation intentionally takes place before storage update recalculateNftPower(tokenId); // update storage s_nftInfo[tokenId].currentCollateral -= amount; s_totalCollateral -= amount; // send withdrawn collateral to token owner _sendEth(tokenOwner, amount); } // recalculated nft power. Used internally and also called externally by // other operations within the DAO function recalculateNftPower(uint256 tokenId) public returns (uint256 newPower) { // nfts have no power until power calculation starts // replace block.timestamp with constant value // for fuzzing initial power calc state // if (block.timestamp < s_powerCalcTimestamp) { if(s_FuzzerConstantBlockTimestamp < s_powerCalcTimestamp) { return 0; } newPower = getNftPower(tokenId); NftInfo storage nftInfo = s_nftInfo[tokenId]; s_totalPower -= nftInfo.lastUpdate != 0 ? nftInfo.currentPower : s_maxNftPower; s_totalPower += newPower; // replace block.timestamp with constant value // for fuzzing initial power calc state // nftInfo.lastUpdate = block.timestamp; nftInfo.lastUpdate = s_FuzzerConstantBlockTimestamp; nftInfo.currentPower = newPower; } // make sure to recalculate nft power if these nfts are transferred function _update(address to, uint256 tokenId, address auth) internal override returns (address) { // if users have collateral deposited for their nfts when they transfer // them to another user, the other users effectively becomes the owner // of the collateral. The nfts are mostly worthless without the collateral since // their voting power drops without it, so by design the deposited // collateral moves with the nfts recalculateNftPower(tokenId); return super._update(to, tokenId, auth); } // bunch of getter functions function getRequiredCollateral() public view returns (uint256) { return s_requiredCollateral; } function getPowerCalcTimestamp() public view returns (uint256) { return s_powerCalcTimestamp; } function getMaxNftPower() public view returns (uint256) { return s_maxNftPower; } function getNftPowerReductionPercent() public view returns (uint256) { return s_nftPowerReductionPercent; } function getTotalPower() public view returns (uint256) { return s_totalPower; } function getTotalCollateral() public view returns (uint256) { return s_totalCollateral; } function getDepositedCollateral(uint256 tokenId) public view returns (uint256) { _requireOwned(tokenId); return s_nftInfo[tokenId].currentCollateral; } function getNftPower(uint256 tokenId) public view returns (uint256) { // ensure token has already been minted _requireOwned(tokenId); // replace block.timestamp with constant value // for fuzzing initial power calc state // if (block.timestamp <= s_powerCalcTimestamp) { if(s_FuzzerConstantBlockTimestamp <= s_powerCalcTimestamp) { return 0; } uint256 collateral = s_nftInfo[tokenId].currentCollateral; // Calculate the minimum possible power based on the collateral of the nft uint256 maxNftPower = s_maxNftPower; uint256 minNftPower = maxNftPower * collateral / s_requiredCollateral; minNftPower = Math.min(maxNftPower, minNftPower); // Get last update and current power. Or set them to default if it is first iteration uint256 lastUpdate = s_nftInfo[tokenId].lastUpdate; uint256 currentPower = s_nftInfo[tokenId].currentPower; if (lastUpdate == 0) { lastUpdate = s_powerCalcTimestamp; currentPower = maxNftPower; } // Calculate reduction amount // replace block.timestamp with constant value // for fuzzing initial power calc state // uint256 powerReductionPercent = s_nftPowerReductionPercent * (block.timestamp - lastUpdate); uint256 powerReductionPercent = s_nftPowerReductionPercent * (s_FuzzerConstantBlockTimestamp - lastUpdate); uint256 powerReduction = Math.min(currentPower, (maxNftPower * powerReductionPercent) / PERCENTAGE_100); uint256 newPotentialPower = currentPower - powerReduction; if (minNftPower <= newPotentialPower) { return newPotentialPower; } if (minNftPower <= currentPower) { return minNftPower; } return currentPower; } // sends eth using low-level call as we don't care about returned data function _sendEth(address dest, uint256 amount) private { bool sendStatus; assembly { sendStatus := call(gas(), dest, amount, 0, 0, 0, 0) } require(sendStatus, "VNFT: failed to send eth"); } // used by the fuzzer when fuzz testing function setFuzzerConstantBlockTimestamp(uint256 input) onlyOwner public { s_FuzzerConstantBlockTimestamp = input; } } ================================================ FILE: src/05-token-sale/TokenSale.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; // // This contract is a simplified version of a real contract which // was audited by Cyfrin in a private audit and contained the same bugs. // // Your mission, should you choose to accept it, is to find those bugs! // // This contract allows the creator to invite a select group of people // to participate in a token sale. Users can exchange an allowed token // for the token being sold. This could be used by a DAO to distribute // their governance token in exchange for DAI, but having a lot more control // over how that distribution takes place compared to using a uniswap pool. // // This contract has been intentionally simplified to remove much of // the extra complexity in order to help you find the particular bugs without // other distractions. Please read the comments carefully as they note // specific findings that are excluded as the implementation has been // purposefully kept simple to help you focus on finding the harder // to find and more interesting bugs. // // This contract intentionally has no time-out period for the token sale // to complete; lack of a time-out period resulting in the token sale never // completing is not a valid finding as this has been intentionally // omitted to simplify the codebase. // // This contract intentionally does not support fee-on-transfer, rebasing // ERC777 or any non-standard, weird ERC20s. It only supports ERC20s // that conform to the ERC20 implementation. Any findings related to // weird/non-standard ERC20s are invalid. Any findings related to blacklists are invalid. // // This contract intentionally has no rescue function; any tokens that // are sent to this contract are lost forever. Once this contract is // created the fixed amount of tokens to be sold can't be changed. Any // findings related to these issues are invalid. // // This contract should only contain 2 intentional High findings, but // if you find others they were not intentional :-) This contract should // not be used in any live/production environment; it is purely an // educational bug-hunting exercise based on a real-world example. // contract TokenSale { // min buy precision enforced uint256 public constant MIN_BUY_PRECISION = 6; // sell precision always 18 uint256 public constant SELL_PRECISION = 18; // smallest amount proposal creator can fund contract with uint256 public constant MIN_FUNDING = 100; // min number of buyers uint256 public constant MIN_BUYERS = 3; // creator of this proposal. Any findings related to the creator // not being able to update this address are invalid; this has // intentionally been omitted to simplify the contract so you can // focus on finding the cool bug instead of lame/easy stuff. // Eg: all findings related to blacklists are invalid; Proposal // creator can always receive and send tokens. address private immutable s_creator; // token to be sold by creator ERC20 private immutable s_sellToken; // token which buyers can use to buy the token being sold // for simplicity sake exchange rate is always 1:1 // any findings related to not having a dynamic exchange rate are invalid ERC20 private immutable s_buyToken; // maximum amount any single buyer should be able to buy uint256 private immutable s_maxTokensPerBuyer; // total amount of tokens to be sold uint256 private immutable s_sellTokenTotalAmount; // total amount of tokens currently sold uint256 private s_sellTokenSoldAmount; // total number of allowed buyers uint256 private s_totalBuyers; // only permitted addresses can buy enum BuyerState { DISALLOWED, ALLOWED } mapping(address buyer => BuyerState) private s_buyers; // create the contract constructor(address[] memory allowList, address sellToken, address buyToken, uint256 maxTokensPerBuyer, uint256 sellTokenAmount) { require(sellToken != address(0), "TS: invalid sell token"); require(buyToken != address(0), "TS: invalid sell token"); // save tokens to storage s_sellToken = ERC20(sellToken); s_buyToken = ERC20(buyToken); // save tokens to stack since to prevent multiple storage reads during constructor ERC20 sToken = ERC20(sellToken); ERC20 bToken = ERC20(buyToken); // enforce precision require(sToken.decimals() == SELL_PRECISION, "TS: sell token invalid precision"); require(bToken.decimals() >= MIN_BUY_PRECISION, "TS: buy token invalid min precision"); require(bToken.decimals() <= SELL_PRECISION, "TS: buy token precision must <= sell token"); // require minimum sell token amount require(sellTokenAmount >= MIN_FUNDING * 10 ** sToken.decimals(), "TS: Minimum funding required"); // sanity check require(maxTokensPerBuyer <= sellTokenAmount, "TS: invalid max tokens per buyer"); s_maxTokensPerBuyer = maxTokensPerBuyer; // cache list length uint256 allowListLength = allowList.length; // perform some sanity checks. NOTE: checks for duplicate inputs // are performed by entity creating the proposal who is trusted, // so the contract intentionally does not re-check for duplicate inputs. // Findings related to not checking for duplicate inputs are invalid. require(allowListLength >= MIN_BUYERS, "TS: Minimum 3 buyers required"); uint256 totalBuyers; // store addresses allowed to buy for(; totalBuyers remainingSellTokens) { amountToBuy = remainingSellTokens; } // prevent user from buying more than max require(amountToBuy <= s_maxTokensPerBuyer, "TS: buy over max per user"); // update storage to increase total bought s_sellTokenSoldAmount += amountToBuy; // transfer purchase tokens from buyer to creator SafeERC20.safeTransferFrom(s_buyToken, buyer, s_creator, _convert(amountToBuy, s_buyToken.decimals())); // transfer sell tokens from this contract to buyer SafeERC20.safeTransfer(s_sellToken, buyer, amountToBuy); } // ends the sale and refunds creator remaining unsold tokens function endSale() external { // cache creator address address creator = s_creator; // only creator can end the sale require(msg.sender == s_creator, "TS: only creator can end sale"); // sale must not have been completed uint256 remainingSellTokens = getRemainingSellTokens(); require(remainingSellTokens != 0, "TS: token sale is complete"); // update storage with tokens sent to creator to mark the // sale as closed s_sellTokenSoldAmount += remainingSellTokens; // send remaining unsold tokens back to creator SafeERC20.safeTransfer(s_sellToken, creator, remainingSellTokens); } // used internally and externally to determine whether the sale // has been completed (no tokens remain unsold) function getRemainingSellTokens() public view returns(uint256) { return s_sellTokenTotalAmount - s_sellTokenSoldAmount; } // bunch of getters function getBuyTokenAddress() external view returns(address) { return address(s_buyToken); } function getSellTokenAddress() external view returns(address) { return address(s_sellToken); } function getMaxTokensPerBuyer() external view returns(uint256) { return s_maxTokensPerBuyer; } function getSellTokenTotalAmount() external view returns(uint256) { return s_sellTokenTotalAmount; } function getSellTokenSoldAmount() external view returns(uint256) { return s_sellTokenSoldAmount; } function getTotalAllowedBuyers() external view returns(uint256) { return s_totalBuyers; } function getCreator() external view returns(address) { return s_creator; } // handles conversions function _convert(uint256 amount_, uint256 destDecimals_) internal pure returns (uint256) { if (SELL_PRECISION > destDecimals_) { amount_ = amount_ / 10 ** (SELL_PRECISION - destDecimals_); } else if (SELL_PRECISION < destDecimals_) { amount_ = amount_ * 10 ** (destDecimals_ - SELL_PRECISION); } return amount_; } } ================================================ FILE: src/06-rarely-false/RarelyFalse.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; // // // stateless tests in test/06-rarely-false // // placeholder so nothing else gets put in this folder ================================================ FILE: src/07-byte-battle/ByteBattle.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; // // // stateless tests in test/07-byte-battle // // placeholder so nothing else gets put in this folder ================================================ FILE: src/08-omni-protocol/IRM.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; import "./interfaces/IIRM.sol"; /** * @title Interest Rate Model (IRM) Contract * @notice This contract defines the interest rate model for different markets and tranches. * @dev It inherits from the IIRM interface and the AccessControl contract from the OpenZeppelin library. * @dev It is important that contracts that integrate this IRM appropriately scale interest rate values. */ contract IRM is IIRM, AccessControl, Initializable { uint256 public constant UTILIZATION_SCALE = 1e9; uint256 public constant MAX_INTEREST_RATE = 10e9; // Scale must match OmniToken.sol, 1e9 mapping(address => mapping(uint8 => IRMConfig)) public marketIRMConfigs; /** * @notice Initializes the admin role with the contract deployer/upgrader. * @param _admin The address of the multisig admin. */ function initialize(address _admin) external initializer { _grantRole(DEFAULT_ADMIN_ROLE, _admin); } /** * @notice Calculates the interest rate for a specific OmniToken market, tranche, total deposit and total borrow. * @param _market The address of the market * @param _tranche The tranche number * @param _totalDeposit The total amount deposited in the market * @param _totalBorrow The total amount borrowed from the market * @return The calculated interest rate */ function getInterestRate(address _market, uint8 _tranche, uint256 _totalDeposit, uint256 _totalBorrow) external view returns (uint256) { uint256 utilization; if (_totalBorrow <= _totalDeposit) { utilization = _totalDeposit == 0 ? 0 : (_totalBorrow * UTILIZATION_SCALE) / _totalDeposit; } else { utilization = UTILIZATION_SCALE; } return _getInterestRateLinear(marketIRMConfigs[_market][_tranche], utilization); } /** * @notice Internal function to calculate the interest rate linearly based on utilization and IRMConfig. * @param _config The IRM configuration structure * @param _utilization The current utilization rate * @return interestRate The calculated interest rate */ function _getInterestRateLinear(IRMConfig memory _config, uint256 _utilization) internal pure returns (uint256 interestRate) { if (_config.kink == 0) { revert("IRM::_getInterestRateLinear: Interest config not set."); } if (_utilization <= _config.kink) { interestRate = _config.start; interestRate += (_utilization * (_config.mid - _config.start)) / _config.kink; } else { interestRate = _config.mid; interestRate += ((_utilization - _config.kink) * (_config.end - _config.mid)) / (UTILIZATION_SCALE - _config.kink); } } /** * @notice Sets the IRM configuration for a specific OmniToken market and tranches. * @param _market The address of the market * @param _tranches An array of tranche numbers * @param _configs An array of IRMConfig configurations */ function setIRMForMarket(address _market, uint8[] calldata _tranches, IRMConfig[] calldata _configs) external onlyRole(DEFAULT_ADMIN_ROLE) { if (_tranches.length != _configs.length) { revert("IRM::setIRMForMarket: Tranches and configs length mismatch."); } for (uint256 i = 0; i < _tranches.length; ++i) { if (_configs[i].kink == 0 || _configs[i].kink >= UTILIZATION_SCALE) { revert("IRM::setIRMForMarket: Bad kink value."); } if ( _configs[i].start > _configs[i].mid || _configs[i].mid > _configs[i].end || _configs[i].end > MAX_INTEREST_RATE ) { revert("IRM::setIRMForMarket: Bad interest value."); } marketIRMConfigs[_market][_tranches[i]] = _configs[i]; } emit SetIRMForMarket(_market, _tranches, _configs); } } ================================================ FILE: src/08-omni-protocol/OmniOracle.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; import "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; import "./interfaces/IBandReference.sol"; import "./interfaces/IChainlinkAggregator.sol"; import "./interfaces/ICustomOmniOracle.sol"; import "./interfaces/IOmniOracle.sol"; /** * @title OmniOracle contract * @notice This contract facilitates USD base price retrieval from Chainlink, Band, or a custom oracle integrating the IOmniOracle interface. * Special attention must be paid by the admin to ensure oracle configurations are valid given the below specifications. * @dev Inherits from AccessControl and implements IOmniOracle interface. * Makes assumptions about oracle feeds, e.g. the decimals, delay, and base currency. Configurator must pay special attention. * @dev This oracle contract does not handle Chainlink L2 Sequencer Uptime Feeds requirements, and should only be used for L1 deployments. */ contract OmniOracle is IOmniOracle, AccessControl, Initializable { uint256 public constant PRICE_SCALE = 1e36; // Gives enough precision for the price of one base unit of token, as most tokens have at most 18 decimals string private constant USD = "USD"; mapping(address => OracleConfig) public oracleConfigs; mapping(address => string) public oracleSymbols; /** * @notice Initializes the admin role with the contract deployer/upgrader. * @param _admin The address of the multisig admin. */ function initialize(address _admin) external initializer { _grantRole(DEFAULT_ADMIN_ROLE, _admin); } /** * @notice Fetches the price of the specified asset in USD for the base unit of the underlying token. * @dev Band oracle documentation says they always return price multiplied by 1e18 (https://docs.bandchain.org/products/band-standard-dataset/using-band-standard-dataset/contract#getreferencedata) * @param _underlying The address of the asset. * @return The price of the asset in USD, in the base unit of the underlying token. */ function getPrice(address _underlying) external view returns (uint256) { OracleConfig memory config = oracleConfigs[_underlying]; if (config.provider == Provider.Band) { IStdReference.ReferenceData memory data; data = IStdReference(config.oracleAddress).getReferenceData(oracleSymbols[_underlying], USD); require( data.lastUpdatedBase >= block.timestamp - config.delay, "OmniOracle::getPrice: Stale price for base." ); require( data.lastUpdatedQuote >= block.timestamp - config.delayQuote, "OmniOracle::getPrice: Stale price for quote." ); return data.rate * (PRICE_SCALE / 1e18) / (10 ** config.underlyingDecimals); // Price in one base unit with 1e36 precision } else if (config.provider == Provider.Chainlink) { (, int256 answer,, uint256 updatedAt,) = IChainlinkAggregator(config.oracleAddress).latestRoundData(); require( answer > 0 && updatedAt >= block.timestamp - config.delay, "OmniOracle::getPrice: Invalid chainlink price." ); return uint256(answer) * (PRICE_SCALE / (10 ** IChainlinkAggregator(config.oracleAddress).decimals())) / (10 ** config.underlyingDecimals); } else if (config.provider == Provider.Other) { return ICustomOmniOracle(config.oracleAddress).getPrice(_underlying) * (PRICE_SCALE / 1e18) / (10 ** config.underlyingDecimals); } else { revert("OmniOracle::getPrice: Invalid provider."); } } /** * @notice Sets the oracle configuration for the specified asset. Chainlink addresses must use the USD price feed. * @param _underlying The address of the asset. * @param _oracleConfig The oracle configuration for the asset. Must be Chainlink, Band, or implement the IOmniOracle interface. */ function setOracleConfig(address _underlying, OracleConfig calldata _oracleConfig, string calldata _symbol) external onlyRole(DEFAULT_ADMIN_ROLE) { require( _oracleConfig.oracleAddress != address(0) && _underlying != address(0), "OmniOracle::setOracleConfig: Can never use zero address." ); require(_oracleConfig.provider != Provider.Invalid, "OmniOracle::setOracleConfig: Invalid provider."); require(_oracleConfig.delay > 0, "OmniOracle::setOracleConfig: Invalid delay."); require(_oracleConfig.delayQuote > 0, "OmniOracle::setOracleConfig: Invalid delay quote."); oracleConfigs[_underlying] = _oracleConfig; oracleSymbols[_underlying] = _symbol; emit SetOracle( _underlying, _oracleConfig.oracleAddress, _oracleConfig.provider, _oracleConfig.delay, _oracleConfig.delayQuote, _oracleConfig.underlyingDecimals ); } /** * @notice Removes the oracle configuration for the specified asset. * @param _underlying The address of the asset. */ function removeOracleConfig(address _underlying) external onlyRole(DEFAULT_ADMIN_ROLE) { delete oracleConfigs[_underlying]; emit RemoveOracle(_underlying); } } ================================================ FILE: src/08-omni-protocol/OmniPool.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; import "@openzeppelin-upgradeable/contracts/access/AccessControlUpgradeable.sol"; import "@openzeppelin-upgradeable/contracts/utils/ReentrancyGuardUpgradeable.sol"; import "@openzeppelin-upgradeable/contracts/utils/PausableUpgradeable.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; import "./interfaces/IOmniOracle.sol"; import "./interfaces/IOmniPool.sol"; import "./interfaces/IOmniToken.sol"; import "./interfaces/IOmniTokenNoBorrow.sol"; import "./interfaces/IWithUnderlying.sol"; import "./SubAccount.sol"; /** * @title OmniPool * @notice This contract implements a manager for handling loans, protocol market, mode, and account configurations, and liquidations. * @dev This contract implements a lending pool with various modes and market configurations. * It utilizes different structs to keep track of market, mode, account configurations, evaluations, * and liquidation bonuses. It has a variety of external and public functions to manage and interact with * the lending pool, along with internal utility functions. Includes AccessContral, Pausable, and ReentrancyGuardUpgradeable (includes Initializable) * from OpenZeppelin. */ contract OmniPool is IOmniPool, AccessControlUpgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable { using SubAccount for address; bytes32 public constant SOFT_LIQUIDATION_ROLE = keccak256("SOFT_LIQUIDATION_ROLE"); bytes32 public constant MARKET_CONFIGURATOR_ROLE = keccak256("MARKET_CONFIGURATOR_ROLE"); uint256 public constant SELF_COLLATERALIZATION_FACTOR = 0.96e9; // 0.96 uint256 public constant FACTOR_PRECISION_SCALE = 1e9; uint256 public constant LIQ_BONUS_PRECISION_SCALE = 1e9; uint256 public constant HEALTH_FACTOR_SCALE = 1e9; uint256 public constant MAX_BASE_SOFT_LIQUIDATION = 1.4e9; uint256 public constant MAX_LIQ_KINK = 0.2e9; // Borrow value exceeds deposit value by 20% uint256 public constant PRICE_SCALE = 1e18; // Must match up with PRICE_SCALE in OmniOracle uint256 public constant MAX_MARKETS_PER_ACCOUNT = 9; // Will be 10 including isolated collateral market mapping(bytes32 => AccountInfo) public accountInfos; mapping(bytes32 => address[]) public accountMarkets; uint256 public modeCount; mapping(uint256 => ModeConfiguration) public modeConfigurations; mapping(address => MarketConfiguration) public marketConfigurations; mapping(address => LiquidationBonusConfiguration) public liquidationBonusConfigurations; address public oracle; uint8 public pauseTranche; bytes32 public reserveReceiver; /** * @notice Initializes a new instance of the contract, setting up the oracle, reserve receiver, pause tranche, and various roles. * This constructor sets the oracle address, initializes the pause tranche to its maximum value, and sets the reserve receiver to the provided address. * It also sets up the DEFAULT_ADMIN_ROLE, SOFT_LIQUIDATION_ROLE, and MARKET_CONFIGURATOR_ROLE, assigning them to the account deploying the contract. * @param _oracle The address of the oracle contract to be used for price information. * @param _reserveReceiver The address of the reserve receiver. This address will be converted to an account with a subId of 0. * @param _admin The address of the multisig admin */ function initialize(address _oracle, address _reserveReceiver, address _admin) external initializer { __ReentrancyGuard_init(); __AccessControl_init(); __Pausable_init(); oracle = _oracle; pauseTranche = type(uint8).max; reserveReceiver = _reserveReceiver.toAccount(0); _grantRole(DEFAULT_ADMIN_ROLE, _admin); // Additionally set up other roles? _grantRole(SOFT_LIQUIDATION_ROLE, _admin); _grantRole(MARKET_CONFIGURATOR_ROLE, _admin); } /** * @notice Allows a user to enter an isolated market, the market configuration must be for isolated collateral. * @dev The function checks whether the market is valid for isolation and updates the account's isolatedCollateralMarket field. * A subaccount is only allowed to have 1 isolated collateral market at a time. * @param _subId The sub-account identifier. * @param _isolatedMarket The address of the isolated market to enter. */ function enterIsolatedMarket(uint96 _subId, address _isolatedMarket) external { bytes32 accountId = msg.sender.toAccount(_subId); AccountInfo memory account = accountInfos[accountId]; require(account.modeId == 0, "OmniPool::enterIsolatedMarket: Already in a mode."); require( account.isolatedCollateralMarket == address(0), "OmniPool::enterIsolatedMarket: Already has isolated collateral." ); MarketConfiguration memory marketConfig = marketConfigurations[_isolatedMarket]; if (marketConfig.expirationTimestamp <= block.timestamp || !marketConfig.isIsolatedCollateral) { revert("OmniPool::enterIsolatedMarket: Isolated market invalid."); } Evaluation memory eval = evaluateAccount(accountId); require(eval.numBorrow == 0, "OmniPool::enterIsolatedMarket: Non-zero borrow count."); accountInfos[accountId].isolatedCollateralMarket = _isolatedMarket; emit EnteredIsolatedMarket(accountId, _isolatedMarket); } /** * @notice Allows a user to enter multiple unique markets, none of them are isolated collateral markets. * @dev The function checks the validity of each market and updates the account's market list. Markets must not already be entered. * @param _subId The sub-account identifier. * @param _markets The addresses of the markets to enter. */ function enterMarkets(uint96 _subId, address[] calldata _markets) external { bytes32 accountId = msg.sender.toAccount(_subId); require(accountInfos[accountId].modeId == 0, "OmniPool::enterMarkets: Already in a mode."); address[] memory existingMarkets = accountMarkets[accountId]; address[] memory newMarkets = new address[](existingMarkets.length + _markets.length); require(newMarkets.length <= MAX_MARKETS_PER_ACCOUNT, "OmniPool::enterMarkets: Too many markets."); for (uint256 i = 0; i < existingMarkets.length; ++i) { // Copy over existing markets newMarkets[i] = existingMarkets[i]; } for (uint256 i = 0; i < _markets.length; ++i) { address market = _markets[i]; MarketConfiguration memory marketConfig = marketConfigurations[market]; require( marketConfig.expirationTimestamp > block.timestamp && !marketConfig.isIsolatedCollateral, "OmniPool::enterMarkets: Market invalid." ); require(!_contains(newMarkets, market), "OmniPool::enterMarkets: Already in the market."); require( IOmniToken(market).getBorrowCap(0) > 0, "OmniPool::enterMarkets: Market has no borrow cap for 0 tranche." ); newMarkets[i + existingMarkets.length] = market; } accountMarkets[accountId] = newMarkets; emit EnteredMarkets(accountId, _markets); } /** * @notice Allows a user to exit multiple markets including their isolated market. There must be no borrows active on the subaccount to exit a market. * @dev The function removes the specified markets from the account's market list after ensuring the account has no outstanding borrows. * @param _subId The sub-account identifier. * @param _market The address of the market to exit. */ function exitMarket(uint96 _subId, address _market) external { bytes32 accountId = msg.sender.toAccount(_subId); AccountInfo memory account = accountInfos[accountId]; require(account.modeId == 0, "OmniPool::exitMarkets: In a mode, need to call exitMode."); address[] memory markets_ = getAccountPoolMarkets(accountId, account); Evaluation memory eval = _evaluateAccountInternal(accountId, markets_, account); require(eval.numBorrow == 0, "OmniPool::exitMarkets: Non-zero borrow count."); if (_market == account.isolatedCollateralMarket) { accountInfos[accountId].isolatedCollateralMarket = address(0); } else { require(markets_.length > 0, "OmniPool::exitMarkets: No markets to exit"); require(_contains(markets_, _market), "OmniPool::exitMarkets: Market not entered"); uint256 newMarketsLength = markets_.length - 1; if (newMarketsLength > 0) { address[] memory newMarkets = new address[](markets_.length - 1); uint256 newIndex = 0; for (uint256 i = 0; i < markets_.length; ++i) { if (markets_[i] != _market) { newMarkets[newIndex] = markets_[i]; ++newIndex; } } delete accountMarkets[accountId]; // Gas refund? accountMarkets[accountId] = newMarkets; } else { delete accountMarkets[accountId]; } } emit ExitedMarket(accountId, _market); } /** * @notice Clears all markets for a user including isolated collateral. The subaccount must have no active borrows to clear markets. * @dev The function checks that the account has no outstanding borrows before clearing all markets. * @param _subId The sub-account identifier. */ function clearMarkets(uint96 _subId) external { bytes32 accountId = msg.sender.toAccount(_subId); AccountInfo memory account = accountInfos[accountId]; require(account.modeId == 0, "OmniPool::clearMarkets: Already in a mode."); Evaluation memory eval = evaluateAccount(accountId); require(eval.numBorrow == 0, "OmniPool::clearMarkets: Non-zero borrow count."); accountInfos[accountId].isolatedCollateralMarket = address(0); delete accountMarkets[accountId]; emit ClearedMarkets(accountId); } /** * @notice Allows a user to enter a mode. The subaccount must not already be in a mode. The mode must not have expired. * @dev The function sets the modeId field in the account's info and emits an EnteredMode event. * @param _subId The sub-account identifier. * @param _modeId The mode identifier to enter. */ function enterMode(uint96 _subId, uint8 _modeId) external { bytes32 accountId = msg.sender.toAccount(_subId); require(_modeId > 0 && _modeId <= modeCount, "OmniPool::enterMode: Invalid mode ID."); AccountInfo memory account = accountInfos[accountId]; require(account.modeId == 0, "OmniPool::enterMode: Already in a mode."); require( accountMarkets[accountId].length == 0 && account.isolatedCollateralMarket == address(0), "OmniPool::enterMode: Non-zero market count." ); require(modeConfigurations[_modeId].expirationTimestamp > block.timestamp, "OmniPool::enterMode: Mode expired."); account.modeId = _modeId; accountInfos[accountId] = account; emit EnteredMode(accountId, _modeId); } /** * @notice Allows a user to exit a mode. There must be no active borrows in the subaccount to exit. * @dev The function resets the modeId field in the account's info and emits an ExitedMode event. * @param _subId The sub-account identifier. */ function exitMode(uint96 _subId) external { bytes32 accountId = msg.sender.toAccount(_subId); AccountInfo memory account = accountInfos[accountId]; require(account.modeId != 0, "OmniPool::exitMode: Not in a mode."); Evaluation memory eval = evaluateAccount(accountId); require(eval.numBorrow == 0, "OmniPool::exitMode: Non-zero borrow count."); account.modeId = 0; accountInfos[accountId] = account; emit ExitedMode(accountId); } /** * @notice Evaluates an account's deposits and borrows values. * @dev The function computes the true and adjusted values of deposits and borrows for the account. * @param _accountId The account identifier. * @return eval An Evaluation struct containing the account's financial information. */ function evaluateAccount(bytes32 _accountId) public returns (Evaluation memory eval) { AccountInfo memory account = accountInfos[_accountId]; address[] memory poolMarkets = getAccountPoolMarkets(_accountId, account); return _evaluateAccountInternal(_accountId, poolMarkets, account); } /** * @notice Evaluates an account's financial standing within a lending pool. * @dev This function accrues interest, computes market prices, deposit and borrow balances, and calculates the adjusted values of * deposits and borrows based on the account's mode and market configurations. * @param _accountId The unique identifier of the account to be evaluated. * @param _poolMarkets An array of addresses representing the markets in which the account has activity. Excludes the isolated collateral market if it exists. * @param _account The AccountInfo struct containing the account's mode, isolated collateral market, and other relevant data. * @return eval An Evaluation struct containing data on the account's deposit and borrow balances, both true and adjusted values. */ function _evaluateAccountInternal(bytes32 _accountId, address[] memory _poolMarkets, AccountInfo memory _account) internal returns (Evaluation memory eval) { ModeConfiguration memory mode; if (_account.modeId != 0) mode = modeConfigurations[_account.modeId]; for (uint256 i = 0; i < _poolMarkets.length; ++i) { // Accrue interest for all borrowable markets IOmniToken(_poolMarkets[i]).accrue(); } uint256 marketCount = _poolMarkets.length; if (_account.isolatedCollateralMarket != address(0)) { ++marketCount; } for (uint256 i = 0; i < marketCount; ++i) { address market; // A market is either a pool market or the isolated collateral market (last index). if (i < _poolMarkets.length) { market = _poolMarkets[i]; } else { market = _account.isolatedCollateralMarket; } MarketConfiguration memory marketConfiguration_ = marketConfigurations[market]; if (marketConfiguration_.expirationTimestamp <= block.timestamp) { eval.isExpired = true; // Must repay all debts and exit market to get rid of unhealthy account status if expired } address underlying = IWithUnderlying(market).underlying(); uint256 price = IOmniOracle(oracle).getPrice(underlying); // Returns price in base units multiplied by 1e36 uint256 depositAmount = IOmniTokenBase(market).getAccountDepositInUnderlying(_accountId); if (depositAmount != 0) { ++eval.numDeposit; uint256 depositValue = (depositAmount * price) / PRICE_SCALE; // Rounds down eval.depositTrueValue += depositValue; uint256 collateralFactor = marketCount == 1 ? SELF_COLLATERALIZATION_FACTOR : _account.modeId == 0 ? uint256(marketConfiguration_.collateralFactor) : uint256(mode.collateralFactor); eval.depositAdjValue += (depositValue * collateralFactor) / FACTOR_PRECISION_SCALE; // Rounds down } if (i >= _poolMarkets.length) { // Isolated collateral market. No borrow. continue; } uint8 borrowTier = getAccountBorrowTier(_account); uint256 borrowAmount = IOmniToken(market).getAccountBorrowInUnderlying(_accountId, borrowTier); if (borrowAmount != 0) { ++eval.numBorrow; uint256 borrowValue = (borrowAmount * price) / PRICE_SCALE; // Rounds down eval.borrowTrueValue += borrowValue; uint256 borrowFactor = marketCount == 1 ? SELF_COLLATERALIZATION_FACTOR : _account.modeId == 0 ? uint256(marketConfiguration_.borrowFactor) : uint256(mode.borrowFactor); eval.borrowAdjValue += (borrowValue * FACTOR_PRECISION_SCALE) / borrowFactor; // Rounds down } } } /** * @notice Allows an account to borrow funds from a specified market the subaccount has entered, provided the account remains in a healthy financial standing post-borrow. * @param _subId The sub-account identifier from which to borrow. * @param _market The address of the market from which to borrow. * @param _amount The amount of funds to borrow. */ function borrow(uint96 _subId, address _market, uint256 _amount) external nonReentrant whenNotPaused { bytes32 accountId = msg.sender.toAccount(_subId); AccountInfo memory account = accountInfos[accountId]; address[] memory poolMarkets = getAccountPoolMarkets(accountId, account); require(_contains(poolMarkets, _market), "OmniPool::borrow: Not in pool markets."); uint8 borrowTier = getAccountBorrowTier(account); IOmniToken(_market).borrow(accountId, borrowTier, _amount); Evaluation memory eval = _evaluateAccountInternal(accountId, poolMarkets, account); require( eval.depositAdjValue >= eval.borrowAdjValue && !eval.isExpired, "OmniPool::borrow: Not healthy after borrow." ); } /** * @notice Allows an account to repay borrowed funds to a specified market the subaccount has entered. * @param _subId The sub-account identifier from which to repay. * @param _market The address of the market to which to repay. * @param _amount The amount of funds to repay. If _amount is 0, the contract will repay the entire borrow balance. */ function repay(uint96 _subId, address _market, uint256 _amount) external { bytes32 accountId = msg.sender.toAccount(_subId); AccountInfo memory account = accountInfos[accountId]; address[] memory poolMarkets = getAccountPoolMarkets(accountId, account); require(_contains(poolMarkets, _market), "OmniPool::repay: Not in pool markets."); uint8 borrowTier = getAccountBorrowTier(account); IOmniToken(_market).repay(accountId, msg.sender, borrowTier, _amount); } /** * @notice Initiates the liquidation process on an undercollateralized or expired account, repaying some or all of the target account's borrow balance * while seizing a portion of the target's collateral. The amount of collateral seized is determined by the liquidation bonus and the price of the * assets involved. Soft liquidation is only allowed if there is no bad debt, otherwise if bad debt exists a full liquidation is bypassed. * @dev Liquidation configuration must be set for the _collateralMarket or else will revert. * The seized amount of shares is not guaranteed to compensate the value of the repayment during liquidation. Liquidators should check the returned value if they have a * minimum expectation of payout from liquidating, and perform necessary logic to revert if necessary. * @param _params The LiquidationParams struct containing the target account's identifier, the liquidator's identifier, the market to be liquidated, * @return seizedShares The amount of shares seized from the liquidated account. */ function liquidate(LiquidationParams calldata _params) external whenNotPaused nonReentrant returns (uint256[] memory seizedShares) { AccountInfo memory targetAccount = accountInfos[_params.targetAccountId]; address[] memory poolMarkets = getAccountPoolMarkets(_params.targetAccountId, targetAccount); require( _contains(poolMarkets, _params.liquidateMarket), "OmniPool::liquidate: LiquidateMarket not in pool markets." ); require( _contains(poolMarkets, _params.collateralMarket) || targetAccount.isolatedCollateralMarket == _params.collateralMarket, "OmniPool::liquidate: CollateralMarket not available to seize." ); Evaluation memory evalBefore = _evaluateAccountInternal(_params.targetAccountId, poolMarkets, targetAccount); require(evalBefore.numBorrow > 0, "OmniPool::liquidate: No borrow to liquidate."); require( (evalBefore.depositAdjValue < evalBefore.borrowAdjValue) || marketConfigurations[_params.collateralMarket].expirationTimestamp <= block.timestamp, "OmniPool::liquidate: Account still healthy." ); uint8 borrowTier = getAccountBorrowTier(targetAccount); uint256 amount = IOmniToken(_params.liquidateMarket).repay(_params.targetAccountId, msg.sender, borrowTier, _params.amount); (uint256 liquidationBonus, uint256 softThreshold) = getLiquidationBonusAndThreshold( evalBefore.depositAdjValue, evalBefore.borrowAdjValue, _params.collateralMarket ); { // Avoid stack too deep uint256 borrowPrice = IOmniOracle(oracle).getPrice(IWithUnderlying(_params.liquidateMarket).underlying()); uint256 depositPrice = IOmniOracle(oracle).getPrice(IWithUnderlying(_params.collateralMarket).underlying()); uint256 seizeAmount = Math.ceilDiv( Math.ceilDiv(amount * borrowPrice, depositPrice) * (LIQ_BONUS_PRECISION_SCALE + liquidationBonus), // Need to add base since liquidationBonus < LIQ_BONUS_PRECISION_SCALE LIQ_BONUS_PRECISION_SCALE ); // round up seizedShares = IOmniTokenBase(_params.collateralMarket).seize( _params.targetAccountId, _params.liquidatorAccountId, seizeAmount ); } Evaluation memory evalAfter = _evaluateAccountInternal(_params.targetAccountId, poolMarkets, targetAccount); if (evalAfter.borrowTrueValue > evalAfter.depositTrueValue) { pauseTranche = borrowTier > pauseTranche ? pauseTranche : borrowTier; emit PausedTranche(pauseTranche); } else if (!evalAfter.isExpired) { // If expired, no liquidation threshold require( checkSoftLiquidation(evalAfter.depositAdjValue, evalAfter.borrowAdjValue, softThreshold, targetAccount), "OmniPool::liquidate: Too much has been liquidated." ); } emit Liquidated( msg.sender, _params.targetAccountId, _params.liquidatorAccountId, _params.liquidateMarket, _params.collateralMarket, amount ); } /** * @notice Checks whether a soft liquidation condition is met based on the account's adjusted deposit and borrow values. * @param _depositAdjValue The adjusted value of the account's deposits. * @param _borrowAdjValue The adjusted value of the account's borrows. * @param _softThreshold The threshold value for soft liquidation. * @param _account The AccountInfo struct containing the account's mode, isolated collateral market, and other relevant data. * @return A boolean indicating whether a soft liquidation condition is met. */ function checkSoftLiquidation( uint256 _depositAdjValue, uint256 _borrowAdjValue, uint256 _softThreshold, AccountInfo memory _account ) public pure returns (bool) { if (_borrowAdjValue == 0) { return false; } uint256 healthFactor = (_depositAdjValue * HEALTH_FACTOR_SCALE) / _borrowAdjValue; // Round down uint256 threshold = _account.softThreshold != 0 ? _account.softThreshold : _softThreshold; return healthFactor <= threshold; } /** * @notice Initiates the process of socializing a fully liquidated account's remaining loss to the users of the specified market and tranche, discretion to admin. * @dev There is a separate call that must be made to unpause the tranches, discretion to admin. Due to potential problems w/ a full liquidation * allow for 0.1bps ($10 for $1M) difference in deposit and borrow values. However, it is expected that admin calls liquidate prior to calling socializeLoss in script. * @param _market The address of the market in which the loss is socialized. * @param _account The unique identifier of the fully liquidated account. */ function socializeLoss(address _market, bytes32 _account) external onlyRole(DEFAULT_ADMIN_ROLE) { uint8 borrowTier = getAccountBorrowTier(accountInfos[_account]); Evaluation memory eval = evaluateAccount(_account); uint256 percentDiff = eval.depositTrueValue * 1e18 / eval.borrowTrueValue; require( percentDiff < 0.00001e18, "OmniPool::socializeLoss: Account not fully liquidated, please call liquidate prior to fully liquidate account." ); IOmniToken(_market).socializeLoss(_account, borrowTier); emit SocializedLoss(_market, borrowTier, _account); } /** * @notice Determines the risk tier associated with an subaccount's borrow activity. The tier is derived from the subaccount's isolated collateral market. * @param _account The AccountInfo struct containing the subaccount's mode, isolated collateral market, and other relevant data. * @return The risk tier associated with the subaccount's borrow activity. */ function getAccountBorrowTier(AccountInfo memory _account) public view returns (uint8) { address isolatedCollateralMarket = _account.isolatedCollateralMarket; if (_account.modeId == 0) { if (isolatedCollateralMarket == address(0)) { // Account has no isolated collateral market. Use tier 0 (lowest risk). return 0; } else { // Account has isolated collateral market. Use the market's risk tranche. return marketConfigurations[isolatedCollateralMarket].riskTranche; } } else { // Account is in a mode. Use the mode's risk tranche. return modeConfigurations[_account.modeId].modeTranche; } } /** * @notice Retrieves all markets, except for the isolated collateral market, associated with an subaccount. * @param _accountId The unique identifier of the subaccount whose markets are to be retrieved. * @param _account The AccountInfo struct containing the subaccount's mode, isolated collateral market, and other relevant data. * @return An array of addresses representing the markets associated with the subaccount. */ function getAccountPoolMarkets(bytes32 _accountId, AccountInfo memory _account) public view returns (address[] memory) { if (_account.modeId == 0) { // Account is not in a mode. Use the account's markets. return accountMarkets[_accountId]; } else { // Account is in a mode. Use the mode's markets. assert(_account.modeId <= modeCount); return modeConfigurations[_account.modeId].markets; } } /** * @notice Computes the liquidation bonus and soft threshold values based on the account's adjusted deposit and borrow values and the specified collateral market. * @param _depositAdjValue The adjusted value of the account's deposits. * @param _borrowAdjValue The adjusted value of the account's borrows. * @param _collateralMarket The address of the collateral market. * @return bonus The computed liquidation bonus value. * @return softThreshold The computed soft threshold value. */ function getLiquidationBonusAndThreshold( uint256 _depositAdjValue, uint256 _borrowAdjValue, address _collateralMarket ) public view returns (uint256 bonus, uint256 softThreshold) { if (_borrowAdjValue > _depositAdjValue) { // Prioritize unhealthiness over expiry in case where is expired and unhealthy is true LiquidationBonusConfiguration memory liquidationBonusConfiguration_ = liquidationBonusConfigurations[_collateralMarket]; softThreshold = liquidationBonusConfiguration_.softThreshold; uint256 pctDiff = Math.ceilDiv(_borrowAdjValue * LIQ_BONUS_PRECISION_SCALE, _depositAdjValue) - LIQ_BONUS_PRECISION_SCALE; // Round up if (pctDiff <= liquidationBonusConfiguration_.kink) { bonus = liquidationBonusConfiguration_.start; bonus += Math.ceilDiv( pctDiff * (liquidationBonusConfiguration_.end - liquidationBonusConfiguration_.start), liquidationBonusConfiguration_.kink ); } else { bonus = liquidationBonusConfiguration_.end; } } else if (marketConfigurations[_collateralMarket].expirationTimestamp <= block.timestamp) { LiquidationBonusConfiguration memory liquidationBonusConfiguration_ = liquidationBonusConfigurations[_collateralMarket]; softThreshold = liquidationBonusConfiguration_.softThreshold; bonus = liquidationBonusConfiguration_.expiredBonus; } else { revert("OmniPool::getLiquidationBonus: No liquidation bonus, account is not liquidatable "); } } /** * @notice Determines if an account is healthy by comparing the factor adjusted price weighted values of deposits and borrows. * @dev The function evaluates the account and returns true if the account is healthy. Intentionally do not check expiration here. * @param _accountId The account identifier. * @return A boolean indicating whether the account is healthy. */ function isAccountHealthy(bytes32 _accountId) external returns (bool) { Evaluation memory eval = evaluateAccount(_accountId); return eval.depositAdjValue >= eval.borrowAdjValue && !eval.isExpired; } /** * @notice Resets the pause tranche to its default value. This function should only be called after all bad debt is resolved. * Must be called by an account with the DEFAULT_ADMIN_ROLE. */ function resetPauseTranche() public onlyRole(DEFAULT_ADMIN_ROLE) { pauseTranche = type(uint8).max; emit UnpausedTranche(); } /** * @notice Configures a market with specific parameters. This function can only be called by an account with the MARKET_CONFIGURATOR_ROLE. * It validates the configuration provided especially focusing on isolated collateral settings, borrow factors and risk tranches. * Should never configure a IOmniTokenNoBorrow (non-borrwable) token with a borrowFactor > 0 and not as isolated, otherwise will break. * @dev Setting markets to the 0 riskTranche comes with special privileges and should be used carefully after strict risk analysis. * @param _market The address of the market to be configured. * @param _marketConfig The MarketConfiguration struct containing the market's configurations. */ function setMarketConfiguration(address _market, MarketConfiguration calldata _marketConfig) external onlyRole(MARKET_CONFIGURATOR_ROLE) { // Set to block.timestamp value to have the market expire in that block for emergencies if (_marketConfig.expirationTimestamp <= block.timestamp) { revert("OmniPool::setMarketConfiguration: Bad expiration timestamp."); } if (_marketConfig.isIsolatedCollateral && (_marketConfig.borrowFactor > 0 || _marketConfig.riskTranche == 0)) { revert("OmniPool::setMarketConfiguration: Bad configuration for isolated collateral."); } if ( _marketConfig.collateralFactor == 0 && (_marketConfig.borrowFactor == 0 || _marketConfig.riskTranche != type(uint8).max) ) { revert("OmniPool::setMarketConfiguration: Invalid configuration for borrowable long tail asset."); } MarketConfiguration memory currentConfig = marketConfigurations[_market]; if (currentConfig.collateralFactor != 0) { require( _marketConfig.isIsolatedCollateral == currentConfig.isIsolatedCollateral, "OmniPool::setMarketConfiguration: Cannot change isolated collateral status." ); } marketConfigurations[_market] = _marketConfig; emit SetMarketConfiguration(_market, _marketConfig); } /** * @notice Removes the market configuration for a specified market. * @dev This function can only be called by an account with the `MARKET_CONFIGURATOR_ROLE` role. * It checks if the market's underlying asset balance is zero before allowing removal. * @param _market The address of the market whose configuration is to be removed. */ function removeMarketConfiguration(address _market) external onlyRole(MARKET_CONFIGURATOR_ROLE) { require( IERC20(IWithUnderlying(_market).underlying()).balanceOf(_market) == 0, "OmniPool::removeMarketConfiguration: Market still has balance." ); delete marketConfigurations[_market]; emit RemovedMarketConfiguration(_market); } /** * @notice Sets the configurations for a mode. This function can only be called by an account with the MARKET_CONFIGURATOR_ROLE. * Each mode configuration overrides all borrow and collateral factors for markets within that mode and should be used cautiously. * @dev This is a privileged function that should be used carefully after strict risk analysis, as it overrides factors for all markets in the mode. * Modes should never include markets that are considered isolated assets. * @param _modeConfiguration A ModeConfiguration struct containing the configuration for the mode. */ function setModeConfiguration(ModeConfiguration calldata _modeConfiguration) external onlyRole(MARKET_CONFIGURATOR_ROLE) { if (_modeConfiguration.expirationTimestamp <= block.timestamp) { revert("OmniPool::setModeConfiguration: Bad expiration timestamp."); } for (uint256 i = 0; i < _modeConfiguration.markets.length; ++i) { for (uint256 j = i + 1; j < _modeConfiguration.markets.length; j++) { if (_modeConfiguration.markets[i] == _modeConfiguration.markets[j]) { revert("OmniPool:setModeConfiguration: No duplicate markets allowed."); } } } modeCount++; modeConfigurations[modeCount] = _modeConfiguration; emit SetModeConfiguration(modeCount, _modeConfiguration); } /** * @notice Sets the expiration timestamp for a specified mode. This expiration only signifies the mode can no longer be entered, but does not force exit exisitng subaccounts from the mode. * This function allows for updating the expiration timestamp of a specific mode, given its mode ID. * It reverts if the provided expiration timestamp is in the past or if the mode ID is invalid. * Only an account with the MARKET_CONFIGURATOR_ROLE can call this function. * @param _modeId The ID of the mode whose expiration timestamp is to be updated. * @param _expirationTimestamp The new expiration timestamp for the mode. */ function setModeExpiration(uint256 _modeId, uint32 _expirationTimestamp) external onlyRole(MARKET_CONFIGURATOR_ROLE) { require(_expirationTimestamp > block.timestamp, "OmniPool::setModeExpiration: Bad expiration timestamp."); require(_modeId != 0 && _modeId <= modeCount, "OmniPool::setModeExpiration: Bad mode ID."); modeConfigurations[_modeId].expirationTimestamp = _expirationTimestamp; } /** * @notice Sets a specific soft liquidation threshold for an account. This function can only be called by an account with the SOFT_LIQUIDATION_ROLE. * The soft liquidation threshold determines the health factor below which an account is considered for soft liquidation. * @dev The soft liquidation role should only be assigned to the admin or a smart contract that implements a strategy for why a user should receive a special soft liquidation. * @param _accountId The unique identifier of the account for which to set the soft liquidation threshold. * @param _softThreshold The soft liquidation threshold to set for the account. */ function setAccountSoftLiquidation(bytes32 _accountId, uint32 _softThreshold) external onlyRole(SOFT_LIQUIDATION_ROLE) { if (_softThreshold > MAX_BASE_SOFT_LIQUIDATION || _softThreshold < HEALTH_FACTOR_SCALE) { revert( "OmniPool::setSoftLiquidation: Soft liquidation health factor threshold cannot be greater than the standard max and must be greater than 1." ); } accountInfos[_accountId].softThreshold = _softThreshold; } /** * @notice Sets the configuration for liquidation bonuses for a specific market. This function can only be called by an account with the MARKET_CONFIGURATOR_ROLE. * The configuration includes parameters that affect the calculation of liquidation bonuses during the liquidation process. * @param _market The address of the market for which to set the liquidation bonus configuration. * @param _config The LiquidationBonusConfiguration struct containing the configuration for liquidation bonuses. */ function setLiquidationBonusConfiguration(address _market, LiquidationBonusConfiguration calldata _config) external onlyRole(MARKET_CONFIGURATOR_ROLE) { require( _config.kink <= MAX_LIQ_KINK, "OmniPool::setLiquidationBonusConfiguration: Bad kink for maximum liquidation." ); require( _config.start <= _config.end && _config.end <= LIQ_BONUS_PRECISION_SCALE, "OmniPool::setLiquidationBonusConfiguration: Bad start and end bonus values." ); if (_config.expiredBonus > LIQ_BONUS_PRECISION_SCALE) { revert("OmniPool::setLiquidationBonusConfiguration: Bad expired bonus value."); } if (_config.softThreshold > MAX_BASE_SOFT_LIQUIDATION || _config.softThreshold < HEALTH_FACTOR_SCALE) { revert( "OmniPool::setSoftLiquidation: Soft liquidation health factor threshold cannot be greater than the standard max and must be greater than 1." ); } liquidationBonusConfigurations[_market] = _config; } /** * @notice Sets the tranche count for a specific market. * @dev This function allows to set the number of tranches for a given market. * It's an external function that can only be called by an account with the `MARKET_CONFIGURATOR_ROLE`. * @param _market The address of the market contract. * @param _trancheCount The number of tranches to be set for the market. */ function setTrancheCount(address _market, uint8 _trancheCount) external onlyRole(MARKET_CONFIGURATOR_ROLE) { IOmniToken(_market).setTrancheCount(_trancheCount); } /** * @notice Sets the borrow cap for each tranche of a specific market. * @dev This function can only be called by an account with the MARKET_CONFIGURATOR_ROLE. * It invokes the setTrancheBorrowCaps function of the IOmniToken contract associated with the specified market. * @param _market The address of the market for which to set the borrow caps. * @param _borrowCaps An array of borrow cap values, one for each tranche of the market. */ function setBorrowCap(address _market, uint256[] calldata _borrowCaps) external onlyRole(MARKET_CONFIGURATOR_ROLE) { for (uint256 i = 0; i < _borrowCaps.length - 1; ++i) { require(_borrowCaps[i] >= _borrowCaps[i + 1], "OmniPool::setBorrowCap: Invalid borrow cap."); } IOmniToken(_market).setTrancheBorrowCaps(_borrowCaps); } /** * @notice Sets the supply cap for a market that doesn't allow borrowing. * @dev This function can only be called by an account with the MARKET_CONFIGURATOR_ROLE. * It invokes the setSupplyCap function of the IOmniTokenNoBorrow contract associated with the specified market. * @param _market The address of the market for which to set the no-borrow supply cap. * @param _noBorrowSupplyCap The value of the no-borrow supply cap to set. */ function setNoBorrowSupplyCap(address _market, uint256 _noBorrowSupplyCap) external onlyRole(MARKET_CONFIGURATOR_ROLE) { IOmniTokenNoBorrow(_market).setSupplyCap(_noBorrowSupplyCap); } /** * @notice Sets the reserve receiver's address. This function can only be called by an account with the DEFAULT_ADMIN_ROLE. * @dev The reserve receiver's address is converted to a bytes32 account identifier using the toAccount function with a subId of 0. * @param _reserveReceiver The address of the reserve receiver to be set. */ function setReserveReceiver(address _reserveReceiver) external onlyRole(DEFAULT_ADMIN_ROLE) { reserveReceiver = _reserveReceiver.toAccount(0); } /** * @notice Pauses the protocol, halting certain functionalities, i.e. withdraw, borrow, repay, liquidate. * @dev This function triggers the `_pause()` internal function and sets `pauseTranche` to 0. * It's an external function that can only be called by an account with the `DEFAULT_ADMIN_ROLE`. * The function can only be executed when the contract is not already paused, * which is checked by the `whenNotPaused` modifier. */ function pause() external whenNotPaused onlyRole(DEFAULT_ADMIN_ROLE) { _pause(); pauseTranche = 0; emit PausedTranche(0); } /** * @notice Unpauses the protocol, re-enabling certain functionalities, i.e. withdraw, borrow, repay, liquidate. * @dev This function triggers the `_unpause()` internal function and calls `resetPauseTranche()` to reset tranche pause state. * It's an external function that can only be called by an account with the `DEFAULT_ADMIN_ROLE`. * The function can only be executed when the contract is paused, * which is checked by the `whenPaused` modifier. */ function unpause() external whenPaused onlyRole(DEFAULT_ADMIN_ROLE) { _unpause(); resetPauseTranche(); } /** * @dev Internal utility function to check if a specific value exists within an array of addresses. * @param _arr The array of addresses to search. * @param _value The address value to look for within the array. * @return A boolean indicating whether the value exists within the array. */ function _contains(address[] memory _arr, address _value) internal pure returns (bool) { for (uint256 i = 0; i < _arr.length; ++i) { if (_arr[i] == _value) { return true; } } return false; } } ================================================ FILE: src/08-omni-protocol/OmniToken.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin-upgradeable/contracts/utils/ReentrancyGuardUpgradeable.sol"; import "@openzeppelin/contracts/utils/math/Math.sol"; import "./interfaces/IIRM.sol"; import "./interfaces/IOmniPool.sol"; import "./interfaces/IOmniToken.sol"; import "./SubAccount.sol"; import "./WithUnderlying.sol"; /** * @title OmniToken Contract * @notice This contract manages deposits, withdrawals, borrowings, and repayments within the Omni protocol. There is only borrow caps, no supply caps. * @dev It has multiple tranches, each with its own borrowing and depositing conditions. This contract does not handle rebasing tokens. * Inherits from IOmniToken, WithUnderlying, and ReentrancyGuardUpgradeable (includes Initializable) from the OpenZeppelin library. * Utilizes the SafeERC20, SubAccount libraries for safe token transfers and account management. * Emits events for significant state changes like deposits, withdrawals, borrowings, repayments, and tranches updates. */ contract OmniToken is IOmniToken, WithUnderlying, ReentrancyGuardUpgradeable { struct OmniTokenTranche { uint256 totalDepositAmount; uint256 totalBorrowAmount; uint256 totalDepositShare; uint256 totalBorrowShare; } using SafeERC20 for IERC20; using SubAccount for address; using SubAccount for bytes32; uint256 public constant RESERVE_FEE = 0.1e9; uint256 public constant FEE_SCALE = 1e9; uint256 public constant IRM_SCALE = 1e9; // Must match IRM.sol uint256 private constant MAX_VIEW_ACCOUNTS = 25; address public omniPool; address public irm; uint256 public lastAccrualTime; uint8 public trancheCount; bytes32 public reserveReceiver; mapping(uint8 => mapping(bytes32 => uint256)) private trancheAccountDepositShares; mapping(uint8 => mapping(bytes32 => uint256)) private trancheAccountBorrowShares; uint256[] public trancheBorrowCaps; OmniTokenTranche[] public tranches; /** * @notice Contract initializes the OmniToken with required parameters. * @param _omniPool Address of the OmniPool contract. * @param _underlying Address of the underlying asset. * @param _irm Address of the Interest Rate Model contract. * @param _borrowCaps Initial borrow caps for each tranche. */ function initialize(address _omniPool, address _underlying, address _irm, uint256[] calldata _borrowCaps) external initializer { __ReentrancyGuard_init(); __WithUnderlying_init(_underlying); omniPool = _omniPool; irm = _irm; lastAccrualTime = block.timestamp; trancheBorrowCaps = _borrowCaps; trancheCount = uint8(_borrowCaps.length); for (uint8 i = 0; i < _borrowCaps.length; ++i) { tranches.push(OmniTokenTranche(0, 0, 0, 0)); } reserveReceiver = IOmniPool(omniPool).reserveReceiver(); } /** * @notice Accrues interest for all tranches, calculates and distributes the interest among the depositors and updates tranche balances. * The function also handles reserve payments. This method needs to be called before any deposit, withdrawal, borrow, or repayment actions to update the state of the contract. * @dev Interest is paid out proportionately to more risky tranche deposits per tranche */ function accrue() public { uint256 timePassed = block.timestamp - lastAccrualTime; if (timePassed == 0) { return; } uint8 trancheIndex = trancheCount; uint256 totalBorrow = 0; uint256 totalDeposit = 0; uint256[] memory trancheDepositAmounts_ = new uint256[](trancheIndex); // trancheIndeex == trancheCount initially uint256[] memory trancheAccruedDepositCache = new uint256[](trancheIndex); uint256[] memory reserveFeeCache = new uint256[](trancheIndex); while (trancheIndex != 0) { unchecked { --trancheIndex; } OmniTokenTranche storage tranche = tranches[trancheIndex]; uint256 trancheDepositAmount_ = tranche.totalDepositAmount; uint256 trancheBorrowAmount_ = tranche.totalBorrowAmount; totalBorrow += trancheBorrowAmount_; totalDeposit += trancheDepositAmount_; trancheDepositAmounts_[trancheIndex] = trancheDepositAmount_; trancheAccruedDepositCache[trancheIndex] = trancheDepositAmount_; if (trancheBorrowAmount_ == 0) { continue; } uint256 interestAmount; { uint256 interestRate = IIRM(irm).getInterestRate(address(this), trancheIndex, totalDeposit, totalBorrow); interestAmount = (trancheBorrowAmount_ * interestRate * timePassed) / 365 days / IRM_SCALE; } // Handle reserve payments uint256 reserveInterestAmount = interestAmount * RESERVE_FEE / FEE_SCALE; reserveFeeCache[trancheIndex] = reserveInterestAmount; // Handle deposit interest interestAmount -= reserveInterestAmount; { uint256 depositInterestAmount = 0; uint256 interestAmountProportion; for (uint8 ti = trancheCount; ti > trancheIndex;) { unchecked { --ti; } interestAmountProportion = interestAmount * trancheDepositAmounts_[ti] / totalDeposit; trancheAccruedDepositCache[ti] += interestAmountProportion; depositInterestAmount += interestAmountProportion; } tranche.totalBorrowAmount = trancheBorrowAmount_ + depositInterestAmount + reserveInterestAmount; } } for (uint8 ti = 0; ti < trancheCount; ++ti) { OmniTokenTranche memory tranche_ = tranches[ti]; // Pay the reserve uint256 reserveShare; if (reserveFeeCache[ti] > 0) { if (trancheAccruedDepositCache[ti] == 0) { reserveShare = reserveFeeCache[ti]; } else { reserveShare = (reserveFeeCache[ti] * tranche_.totalDepositShare) / trancheAccruedDepositCache[ti]; } trancheAccruedDepositCache[ti] += reserveFeeCache[ti]; trancheAccountDepositShares[ti][reserveReceiver] += reserveShare; tranche_.totalDepositShare += reserveShare; } tranche_.totalDepositAmount = trancheAccruedDepositCache[ti]; tranches[ti] = tranche_; } lastAccrualTime = block.timestamp; emit Accrue(); } /** * @notice Allows a user to deposit a specified amount into a specified tranche. * @param _subId Sub-account identifier for the depositor. * @param _trancheId Identifier of the tranche to deposit into. * @param _amount Amount to deposit. * @return share Amount of deposit shares received in exchange for the deposit. */ function deposit(uint96 _subId, uint8 _trancheId, uint256 _amount) external nonReentrant returns (uint256 share) { require(_trancheId < IOmniPool(omniPool).pauseTranche(), "OmniToken::deposit: Tranche paused."); require(_trancheId < trancheCount, "OmniToken::deposit: Invalid tranche id."); accrue(); bytes32 account = msg.sender.toAccount(_subId); uint256 amount = _inflowTokens(account.toAddress(), _amount); OmniTokenTranche storage tranche = tranches[_trancheId]; uint256 totalDepositShare_ = tranche.totalDepositShare; uint256 totalDepositAmount_ = tranche.totalDepositAmount; if (totalDepositShare_ == 0) { share = amount; } else { assert(totalDepositAmount_ > 0); share = (amount * totalDepositShare_) / totalDepositAmount_; } tranche.totalDepositAmount = totalDepositAmount_ + amount; tranche.totalDepositShare = totalDepositShare_ + share; trancheAccountDepositShares[_trancheId][account] += share; emit Deposit(account, _trancheId, amount, share); } /** * @notice Allows a user to withdraw their funds from a specified tranche. * @param _subId The ID of the sub-account. * @param _trancheId The ID of the tranche. * @param _share The share of the user in the tranche. * @return amount The amount of funds withdrawn. */ function withdraw(uint96 _subId, uint8 _trancheId, uint256 _share) external nonReentrant returns (uint256 amount) { require(_trancheId < IOmniPool(omniPool).pauseTranche(), "OmniToken::withdraw: Tranche paused."); require(_trancheId < trancheCount, "OmniToken::withdraw: Invalid tranche id."); accrue(); bytes32 account = msg.sender.toAccount(_subId); OmniTokenTranche storage tranche = tranches[_trancheId]; uint256 totalDepositAmount_ = tranche.totalDepositAmount; uint256 totalDepositShare_ = tranche.totalDepositShare; uint256 accountDepositShares_ = trancheAccountDepositShares[_trancheId][account]; if (_share == 0) { _share = accountDepositShares_; } amount = (_share * totalDepositAmount_) / totalDepositShare_; tranche.totalDepositAmount = totalDepositAmount_ - amount; tranche.totalDepositShare = totalDepositShare_ - _share; trancheAccountDepositShares[_trancheId][account] = accountDepositShares_ - _share; require(_checkBorrowAllocationOk(), "OmniToken::withdraw: Insufficient withdrawals available."); _outflowTokens(account.toAddress(), amount); require(IOmniPool(omniPool).isAccountHealthy(account), "OmniToken::withdraw: Not healthy."); emit Withdraw(account, _trancheId, amount, _share); } /** * @notice Allows a user to borrow funds from a specified tranche. * @param _account The account of the user. * @param _trancheId The ID of the tranche. * @param _amount The amount to borrow. * @return share The share of the borrowed amount in the tranche. */ function borrow(bytes32 _account, uint8 _trancheId, uint256 _amount) external nonReentrant returns (uint256 share) { require(_trancheId < IOmniPool(omniPool).pauseTranche(), "OmniToken::borrow: Tranche paused."); require(msg.sender == omniPool, "OmniToken::borrow: Bad caller."); accrue(); OmniTokenTranche storage tranche = tranches[_trancheId]; uint256 totalBorrowAmount_ = tranche.totalBorrowAmount; uint256 totalBorrowShare_ = tranche.totalBorrowShare; require(totalBorrowAmount_ + _amount <= trancheBorrowCaps[_trancheId], "OmniToken::borrow: Borrow cap reached."); if (totalBorrowShare_ == 0) { share = _amount; } else { assert(totalBorrowAmount_ > 0); // Should only happen if bad debt exists & all other debts repaid share = Math.ceilDiv(_amount * totalBorrowShare_, totalBorrowAmount_); } tranche.totalBorrowAmount = totalBorrowAmount_ + _amount; tranche.totalBorrowShare = totalBorrowShare_ + share; trancheAccountBorrowShares[_trancheId][_account] += share; require(_checkBorrowAllocationOk(), "OmniToken::borrow: Invalid borrow allocation."); _outflowTokens(_account.toAddress(), _amount); emit Borrow(_account, _trancheId, _amount, share); } /** * @notice Allows a user or another account to repay borrowed funds. * @param _account The account of the user. * @param _payer The account that will pay the borrowed amount. * @param _trancheId The ID of the tranche. * @param _amount The amount to repay. * @return amount The amount of the repaid amount in the tranche. */ function repay(bytes32 _account, address _payer, uint8 _trancheId, uint256 _amount) external nonReentrant returns (uint256 amount) { require(msg.sender == omniPool, "OmniToken::repay: Bad caller."); accrue(); OmniTokenTranche storage tranche = tranches[_trancheId]; uint256 totalBorrowAmount_ = tranche.totalBorrowAmount; uint256 totalBorrowShare_ = tranche.totalBorrowShare; uint256 accountBorrowShares_ = trancheAccountBorrowShares[_trancheId][_account]; if (_amount == 0) { _amount = Math.ceilDiv(accountBorrowShares_ * totalBorrowAmount_, totalBorrowShare_); } amount = _inflowTokens(_payer, _amount); uint256 share = (amount * totalBorrowShare_) / totalBorrowAmount_; tranche.totalBorrowAmount = totalBorrowAmount_ - amount; tranche.totalBorrowShare = totalBorrowShare_ - share; trancheAccountBorrowShares[_trancheId][_account] = accountBorrowShares_ - share; emit Repay(_account, _payer, _trancheId, amount, share); } /** * @notice Transfers specified shares from one account to another within a specified tranche. * @dev This function can only be called externally and is protected against reentrancy. * Requires the tranche to be unpaused and the sender account to remain healthy post-transfer. * @param _subId The subscription ID related to the sender's account. * @param _to The account identifier to which shares are being transferred. * @param _trancheId The identifier of the tranche where the transfer is occurring. * @param _shares The amount of shares to transfer. * @return A boolean value indicating whether the transfer was successful. */ function transfer(uint96 _subId, bytes32 _to, uint8 _trancheId, uint256 _shares) external nonReentrant returns (bool) { require(_trancheId < IOmniPool(omniPool).pauseTranche(), "OmniToken::transfer: Tranche paused."); accrue(); bytes32 from = msg.sender.toAccount(_subId); trancheAccountDepositShares[_trancheId][from] -= _shares; trancheAccountDepositShares[_trancheId][_to] += _shares; require(IOmniPool(omniPool).isAccountHealthy(from), "OmniToken::transfer: Not healthy."); emit Transfer(from, _to, _trancheId, _shares); return true; } /** * @notice Allows the a liquidator to seize funds from a user's account. OmniPool is responsible for defining how this function is called. * Greedily seizes as much collateral as possible, does not revert if no more collateral is left to seize and _amount is nonzero. * @param _account The account from which funds will be seized. * @param _to The account to which seized funds will be sent. * @param _amount The amount of funds to seize. * @return seizedShares The shares seized from each tranche. */ function seize(bytes32 _account, bytes32 _to, uint256 _amount) external override nonReentrant returns (uint256[] memory) { require(msg.sender == omniPool, "OmniToken::seize: Bad caller"); accrue(); uint256 amount_ = _amount; uint256[] memory seizedShares = new uint256[](trancheCount); for (uint8 ti = 0; ti < trancheCount; ++ti) { uint256 totalShare = tranches[ti].totalDepositShare; if (totalShare == 0) { continue; } uint256 totalAmount = tranches[ti].totalDepositAmount; uint256 share = trancheAccountDepositShares[ti][_account]; uint256 amount = (share * totalAmount) / totalShare; if (amount_ > amount) { amount_ -= amount; trancheAccountDepositShares[ti][_account] = 0; trancheAccountDepositShares[ti][_to] += share; seizedShares[ti] = share; } else { uint256 transferShare = (share * amount_) / amount; trancheAccountDepositShares[ti][_account] = share - transferShare; trancheAccountDepositShares[ti][_to] += transferShare; seizedShares[ti] = transferShare; break; } } emit Seize(_account, _to, _amount, seizedShares); return seizedShares; } /** * @notice Distributes the bad debt loss in a tranche among all tranche members in cases of bad debt. OmniPool is responsible for defining how this function is called. * @dev This should only be called when the _account does not have any collateral left to seize. * @param _account The account that incurred a loss. * @param _trancheId The ID of the tranche. */ function socializeLoss(bytes32 _account, uint8 _trancheId) external nonReentrant { require(msg.sender == omniPool, "OmniToken::socializeLoss: Bad caller"); uint256 totalDeposits = 0; for (uint8 i = _trancheId; i < trancheCount; ++i) { totalDeposits += tranches[i].totalDepositAmount; } OmniTokenTranche storage tranche = tranches[_trancheId]; uint256 share = trancheAccountBorrowShares[_trancheId][_account]; uint256 amount = Math.ceilDiv(share * tranche.totalBorrowAmount, tranche.totalBorrowShare); // Represents amount of bad debt there still is (need to ensure user's account is emptied of collateral before this is called) uint256 leftoverAmount = amount; for (uint8 ti = trancheCount - 1; ti > _trancheId; --ti) { OmniTokenTranche storage upperTranche = tranches[ti]; uint256 amountProp = (amount * upperTranche.totalDepositAmount) / totalDeposits; upperTranche.totalDepositAmount -= amountProp; leftoverAmount -= amountProp; } tranche.totalDepositAmount -= leftoverAmount; tranche.totalBorrowAmount -= amount; tranche.totalBorrowShare -= share; trancheAccountBorrowShares[_trancheId][_account] = 0; emit SocializedLoss(_account, _trancheId, amount, share); } /** * @notice Computes the borrowing amount of a specific account in the underlying asset for a given borrow tier. * @dev The division is ceiling division. * @param _account The account identifier for which the borrowing amount is to be computed. * @param _borrowTier The borrow tier identifier from which the borrowing amount is to be computed. * @return The borrowing amount of the account in the underlying asset for the given borrow tier. */ function getAccountBorrowInUnderlying(bytes32 _account, uint8 _borrowTier) external view returns (uint256) { OmniTokenTranche storage tranche = tranches[_borrowTier]; uint256 share = trancheAccountBorrowShares[_borrowTier][_account]; if (share == 0) { return 0; } else { return Math.ceilDiv(share * tranche.totalBorrowAmount, tranche.totalBorrowShare); } } /** * @notice Retrieves the total deposit amount for a specific account across all tranches. * @param _account The account identifier. * @return The total deposit amount. */ function getAccountDepositInUnderlying(bytes32 _account) public view returns (uint256) { uint256 totalDeposit = 0; for (uint8 trancheIndex = 0; trancheIndex < trancheCount; ++trancheIndex) { OmniTokenTranche storage tranche = tranches[trancheIndex]; uint256 share = trancheAccountDepositShares[trancheIndex][_account]; if (share > 0) { totalDeposit += (share * tranche.totalDepositAmount) / tranche.totalDepositShare; } } return totalDeposit; } /** * @notice Retrieves the deposit and borrow shares for a specific account in a specific tranche. * @param _account The account identifier. * @param _trancheId The tranche identifier. * @return depositShare The deposit share. * @return borrowShare The borrow share. */ function getAccountSharesByTranche(bytes32 _account, uint8 _trancheId) external view returns (uint256 depositShare, uint256 borrowShare) { depositShare = trancheAccountDepositShares[_trancheId][_account]; borrowShare = trancheAccountBorrowShares[_trancheId][_account]; } /** * @notice Gets the borrow cap for a specific tranche. * @param _trancheId The ID of the tranche for which to retrieve the borrow cap. * @return The borrow cap for the specified tranche. */ function getBorrowCap(uint8 _trancheId) external view returns (uint256) { return trancheBorrowCaps[_trancheId]; } /** * @notice Sets the borrow caps for each tranche. * @param _borrowCaps An array of borrow caps in the underlying's decimals. */ function setTrancheBorrowCaps(uint256[] calldata _borrowCaps) external { require(msg.sender == omniPool, "OmniToken::setTrancheBorrowCaps: Bad caller."); require(_borrowCaps.length == trancheCount, "OmniToken::setTrancheBorrowCaps: Invalid borrow caps length."); require( _borrowCaps[0] > 0, "OmniToken::setTrancheBorrowCaps: Invalid borrow caps, must always allow 0 to borrow." ); trancheBorrowCaps = _borrowCaps; emit SetTrancheBorrowCaps(_borrowCaps); } /** * @notice Sets the number of tranches. Can only increase the number of tranches by one at a time, never decrease. * @param _trancheCount The new tranche count. */ function setTrancheCount(uint8 _trancheCount) external { require(msg.sender == omniPool, "OmniToken::setTrancheCount: Bad caller."); require(_trancheCount == trancheCount + 1, "OmniToken::setTrancheCount: Invalid tranche count."); trancheCount = _trancheCount; OmniTokenTranche memory tranche = OmniTokenTranche(0, 0, 0, 0); tranches.push(tranche); emit SetTrancheCount(_trancheCount); } /** * @notice Fetches and updates the reserve receiver from the OmniPool contract. Anyone can call. */ function fetchReserveReceiver() external { reserveReceiver = IOmniPool(omniPool).reserveReceiver(); } /** * @notice Calculates the total deposited amount for a specific owner across MAX_VIEW_ACCOUNTS sub-accounts. Above will be excluded, function is imperfect. * @dev This is just for wallets and Etherscan to pick up the deposit balance of a user for the first MAX_VIEW_ACCOUNTS sub-accounts. * @param _owner The address of the owner. * @return The total deposited amount. */ function balanceOf(address _owner) external view returns (uint256) { uint256 totalDeposit = 0; for (uint96 i = 0; i < MAX_VIEW_ACCOUNTS; ++i) { totalDeposit += getAccountDepositInUnderlying(_owner.toAccount(i)); } return totalDeposit; } /** * @notice Checks if the borrow allocation is valid across all tranches, through the invariant cumulative totalBorrow <= totalDeposit from highest to lowest tranche. * @return A boolean value indicating the validity of the borrow allocation. */ function _checkBorrowAllocationOk() internal view returns (bool) { uint8 trancheIndex = trancheCount; uint256 totalBorrow = 0; uint256 totalDeposit = 0; while (trancheIndex != 0) { unchecked { --trancheIndex; } totalBorrow += tranches[trancheIndex].totalBorrowAmount; totalDeposit += tranches[trancheIndex].totalDepositAmount; if (totalBorrow > totalDeposit) { return false; } } return true; } } ================================================ FILE: src/08-omni-protocol/OmniTokenNoBorrow.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; import "@openzeppelin-upgradeable/contracts/utils/ReentrancyGuardUpgradeable.sol"; import "./interfaces/IOmniPool.sol"; import "./interfaces/IOmniTokenNoBorrow.sol"; import "./SubAccount.sol"; import "./WithUnderlying.sol"; /** * @title OmniTokenNoBorrow * @notice This contract represents a token pool with deposit and withdrawal capabilities, without borrowing features. Should only be used for isolated collateral, never borrowable. There is only supply caps. * @dev It inherits functionalities from WithUnderlying, ReentrancyGuardUpgradeable (includes Initializable), and implements IOmniTokenNoBorrow interface. * The contract allows depositors to deposit and withdraw their funds, and for the OmniPool to seize funds if necessary. * It keeps track of the total supply and individual balances, and enforces a supply cap. This contract does not handle rebasing tokens. */ contract OmniTokenNoBorrow is IOmniTokenNoBorrow, WithUnderlying, ReentrancyGuardUpgradeable { using SubAccount for address; uint256 private constant MAX_VIEW_ACCOUNTS = 25; address public omniPool; uint256 public totalSupply; uint256 public supplyCap; mapping(bytes32 => uint256) public balanceOfAccount; /** * @notice Contract initializes the OmniTokenNoBorrow with required parameters. * @param _omniPool Address of the OmniPool contract. * @param _underlying Address of the underlying asset. * @param _supplyCap Initial supply cap. */ function initialize(address _omniPool, address _underlying, uint256 _supplyCap) external initializer { __ReentrancyGuard_init(); __WithUnderlying_init(_underlying); omniPool = _omniPool; supplyCap = _supplyCap; } /** * @notice Deposits a specified amount to the account associated with the message sender and the specified subId. * @param _subId The sub-account identifier. * @param _amount The amount to deposit. * @return amount The actual amount deposited. */ function deposit(uint96 _subId, uint256 _amount) external nonReentrant returns (uint256 amount) { bytes32 account = msg.sender.toAccount(_subId); amount = _inflowTokens(msg.sender, _amount); require(totalSupply + amount <= supplyCap, "OmniTokenNoBorrow::deposit: Supply cap exceeded."); totalSupply += amount; balanceOfAccount[account] += amount; emit Deposit(account, amount); } /** * @notice Withdraws a specified amount from the account associated with the message sender and the specified subId. * @param _subId The sub-account identifier. * @param _amount The amount to withdraw. * @return amount The actual amount withdrawn. */ function withdraw(uint96 _subId, uint256 _amount) external nonReentrant returns (uint256 amount) { bytes32 account = msg.sender.toAccount(_subId); if (_amount == 0) { _amount = balanceOfAccount[account]; } balanceOfAccount[account] -= _amount; totalSupply -= _amount; amount = _outflowTokens(msg.sender, _amount); require(IOmniPool(omniPool).isAccountHealthy(account), "OmniTokenNoBorrow::withdraw: Not healthy."); emit Withdraw(account, amount); } /** * @notice Transfers a specified amount of tokens from the sender's account to another account. * The transfer operation is subject to the sender's account remaining healthy post-transfer. * @dev This function can only be called externally and is protected against reentrant calls. * @param _subId The subscription ID associated with the sender's account. * @param _to The account identifier to which the tokens are being transferred. * @param _amount The amount of tokens to transfer. * @return A boolean value indicating whether the transfer was successful. */ function transfer(uint96 _subId, bytes32 _to, uint256 _amount) external nonReentrant returns (bool) { bytes32 from = msg.sender.toAccount(_subId); balanceOfAccount[from] -= _amount; balanceOfAccount[_to] += _amount; require(IOmniPool(omniPool).isAccountHealthy(from), "OmniTokenNoBorrow::transfer: Not healthy."); emit Transfer(from, _to, _amount); return true; } /** * @notice Allows the a liquidator to seize funds from a user's account. OmniPool is responsible for defining how this function is called. Should be called carefully, as it has strong privileges. * @param _account The account from which funds are seized. * @param _to The account to which funds are transferred. * @param _amount The amount of funds to seize. * @return seizedShares The shares corresponding to the seized amount. */ function seize(bytes32 _account, bytes32 _to, uint256 _amount) external override nonReentrant returns (uint256[] memory) { require(msg.sender == omniPool, "OmniTokenNoBorrow::seize: Bad caller."); uint256 accountBalance = balanceOfAccount[_account]; if (accountBalance < _amount) { _amount = accountBalance; balanceOfAccount[_account] = 0; balanceOfAccount[_to] += accountBalance; } else { balanceOfAccount[_account] -= _amount; balanceOfAccount[_to] += _amount; } uint256[] memory seizedShares = new uint256[](1); seizedShares[0] = _amount; emit Seize(_account, _to, _amount, seizedShares); return seizedShares; } /** * @notice Returns the deposit balance of a specific account. * @param _account The account identifier. * @return The deposit balance of the account. */ function getAccountDepositInUnderlying(bytes32 _account) external view override returns (uint256) { return balanceOfAccount[_account]; } /** * @notice Sets a new supply cap for the contract. * @param _supplyCap The new supply cap amount. */ function setSupplyCap(uint256 _supplyCap) external { require(msg.sender == omniPool, "OmniTokenNoBorrow::setSupplyCap: Bad caller."); supplyCap = _supplyCap; emit SetSupplyCap(_supplyCap); } /** * @notice Calculates the total deposited amount for a specific owner across MAX_VIEW_ACCOUNTS sub-accounts. Above will be excluded, function is imperfect. * @dev This is just for wallets and Etherscan to pick up the deposit balance of a user for the first MAX_VIEW_ACCOUNTS sub-accounts. * @param _owner The address of the owner. * @return The total deposited amount. */ function balanceOf(address _owner) external view returns (uint256) { uint256 totalDeposit = 0; for (uint96 i = 0; i < MAX_VIEW_ACCOUNTS; ++i) { totalDeposit += balanceOfAccount[_owner.toAccount(i)]; } return totalDeposit; } } ================================================ FILE: src/08-omni-protocol/SubAccount.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; /** * @title SubAccount * @notice This library provides utility functions to handle sub-accounts using bytes32 types, where id is most significant bytes. */ library SubAccount { /** * @notice Combines an address and a sub-account identifier into a bytes32 account representation. * @param _sender The address component. * @param _subId The sub-account identifier component. * @return A bytes32 representation of the account. */ function toAccount(address _sender, uint96 _subId) internal pure returns (bytes32) { return bytes32(uint256(uint160(_sender)) | (uint256(_subId) << 160)); } /** * @notice Extracts the address component from a bytes32 account representation. * @param _account The bytes32 representation of the account. * @return The address component. */ function toAddress(bytes32 _account) internal pure returns (address) { return address(uint160(uint256(_account))); } /** * @notice Extracts the sub-account identifier component from a bytes32 account representation. * @param _account The bytes32 representation of the account. * @return The sub-account identifier component. */ function toSubId(bytes32 _account) internal pure returns (uint96) { return uint96(uint256(_account) >> 160); } } ================================================ FILE: src/08-omni-protocol/WETHGateway.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; import "./interfaces/IOmniToken.sol"; import "./interfaces/IWETH9.sol"; import "./interfaces/IWithUnderlying.sol"; import "./SubAccount.sol"; /** * @title WETHGateway * @notice Handles native ETH deposits directly to contract through WETH, but does not handle native ETH withdrawals. * @dev This contract serves as a gateway for handling deposits of native ETH, which are then wrapped into WETH tokens. */ contract WETHGateway is Initializable { using SubAccount for address; address public oweth; address public weth; uint96 private constant SUBACCOUNT_ID = 0; event Deposit(bytes32 indexed account, uint8 indexed trancheId, uint256 amount, uint256 share); /** * @notice Initializes the contract with the OWETH contract address. * @param _oweth The address of the OWETH contract. */ function initialize(address _oweth) external initializer { address _weth = IWithUnderlying(_oweth).underlying(); IWETH9(_weth).approve(_oweth, type(uint256).max); oweth = _oweth; weth = _weth; } /** * @notice Deposits native ETH to the contract, wraps it into WETH tokens, and handles the deposit operation * through the Omni Token contract. * @dev The function is payable to accept ETH deposits. * @param _subId The subscription ID related to the depositor's account. * @param _trancheId The identifier of the tranche where the deposit is occurring. * @return share The number of shares received in exchange for the deposited ETH. */ function deposit(uint96 _subId, uint8 _trancheId) external payable returns (uint256 share) { bytes32 to = msg.sender.toAccount(_subId); IWETH9(weth).deposit{value: msg.value}(); share = IOmniToken(oweth).deposit(SUBACCOUNT_ID, _trancheId, msg.value); IOmniToken(oweth).transfer(SUBACCOUNT_ID, to, _trancheId, share); emit Deposit(to, _trancheId, msg.value, share); } /** * @notice Fallback function that reverts if ETH is sent directly to the contract. * @dev Any attempts to send ETH directly to the contract will cause a transaction revert. */ receive() external payable { revert("This contract should not accept ETH directly."); } } ================================================ FILE: src/08-omni-protocol/WithUnderlying.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import "@openzeppelin-upgradeable/contracts/proxy/utils/Initializable.sol"; import "./interfaces/IWithUnderlying.sol"; /** * @title WithUnderlying * @notice A helper contract to handle the inflow and outflow of ERC20 tokens. * @dev Utilizes OpenZeppelin's SafeERC20 library to handle ERC20 transactions. */ abstract contract WithUnderlying is Initializable, IWithUnderlying { using SafeERC20 for IERC20; address public underlying; /** * @notice Initialies the abstract contract instance. * @param _underlying The address of the underlying ERC20 token. */ function __WithUnderlying_init(address _underlying) internal onlyInitializing { underlying = _underlying; } /** * @notice Retrieves the name of the token. * @return The name of the token, either prefixed from the underlying token or the default "Omni Token". */ function name() external view returns (string memory) { try IERC20Metadata(underlying).name() returns (string memory data) { return string(abi.encodePacked("Omni ", data)); } catch (bytes memory) { return "Omni Token"; } } /** * @notice Retrieves the symbol of the token. * @return The symbol of the token, either prefixed from the underlying token or the default "oToken". */ function symbol() external view returns (string memory) { try IERC20Metadata(underlying).symbol() returns (string memory data) { return string(abi.encodePacked("o", data)); } catch (bytes memory) { return "oToken"; } } /** * @notice Retrieves the number of decimals the token uses. * @return The number of decimals of the token, either from the underlying token or the default 18. */ function decimals() external view returns (uint8) { try IERC20Metadata(underlying).decimals() returns (uint8 data) { return data; } catch (bytes memory) { return 18; } } /** * @notice Handles the inflow of tokens to the contract. * @dev Transfers `_amount` tokens from `_from` to this contract and returns the actual amount received. * @param _from The address from which tokens are transferred. * @param _amount The amount of tokens to transfer. * @return The actual amount of tokens received by the contract. */ function _inflowTokens(address _from, uint256 _amount) internal returns (uint256) { uint256 balanceBefore = IERC20(underlying).balanceOf(address(this)); IERC20(underlying).safeTransferFrom(_from, address(this), _amount); uint256 balanceAfter = IERC20(underlying).balanceOf(address(this)); return balanceAfter - balanceBefore; } /** * @notice Handles the outflow of tokens from the contract. * @dev Transfers `_amount` tokens from this contract to `_to` and returns the actual amount sent. * @param _to The address to which tokens are transferred. * @param _amount The amount of tokens to transfer. * @return The actual amount of tokens sent from the contract. */ function _outflowTokens(address _to, uint256 _amount) internal returns (uint256) { uint256 balanceBefore = IERC20(underlying).balanceOf(address(this)); IERC20(underlying).safeTransfer(_to, _amount); uint256 balanceAfter = IERC20(underlying).balanceOf(address(this)); return balanceBefore - balanceAfter; } } ================================================ FILE: src/08-omni-protocol/interfaces/IBandReference.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; interface IStdReference { /// A structure returned whenever someone requests for standard reference data. struct ReferenceData { uint256 rate; // base/quote exchange rate, multiplied by 1e18. uint256 lastUpdatedBase; // UNIX epoch of the last time when base price gets updated. uint256 lastUpdatedQuote; // UNIX epoch of the last time when quote price gets updated. } /// @dev Returns the price data for the given base/quote pair. Revert if not available. function getReferenceData(string memory _base, string memory _quote) external view returns (ReferenceData memory); /// @dev Similar to getReferenceData, but with multiple base/quote pairs at once. function getReferenceDataBulk(string[] memory _bases, string[] memory _quotes) external view returns (ReferenceData[] memory); } ================================================ FILE: src/08-omni-protocol/interfaces/IChainlinkAggregator.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; interface IChainlinkAggregator { function decimals() external view returns (uint8); function description() external view returns (string memory); function version() external view returns (uint256); function getRoundData(uint80 _roundId) external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); } ================================================ FILE: src/08-omni-protocol/interfaces/ICustomOmniOracle.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; /** * @title ICustomOmniOracle Interface * @notice Interface for the custom oracle used by OmniOracle contract. */ interface ICustomOmniOracle { /** * @notice Fetches the price of the specified asset. * @param _underlying The address of the asset. * @return The price of the asset, normalized to 1e18. */ function getPrice(address _underlying) external view returns (uint256); } ================================================ FILE: src/08-omni-protocol/interfaces/IIRM.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; /** * @title Interest Rate Model (IRM) Interface * @notice This interface describes the publicly accessible functions implemented by the IRM contract. */ interface IIRM { /// Events event SetIRMForMarket(address indexed market, uint8[] tranches, IRMConfig[] configs); /** * @notice This structure defines the configuration for the interest rate model. * @dev It contains the kink utilization point, and the interest rates at 0%, kink, and 100% utilization. */ struct IRMConfig { uint64 kink; // utilization at mid point (1e9 is 100%) uint64 start; // interest rate at 0% utlization uint64 mid; // interest rate at kink utlization uint64 end; // interest rate at 100% utlization } /** * @notice Calculates the interest rate for a specific market, tranche, total deposit, and total borrow. * @param _market The address of the market * @param _tranche The tranche number * @param _totalDeposit The total amount deposited in the market * @param _totalBorrow The total amount borrowed from the market * @return The calculated interest rate */ function getInterestRate(address _market, uint8 _tranche, uint256 _totalDeposit, uint256 _totalBorrow) external view returns (uint256); /** * @notice Sets the IRM configuration for a specific market and tranches. * @param _market The address of the market * @param _tranches An array of tranche numbers * @param _configs An array of IRMConfig structures */ function setIRMForMarket(address _market, uint8[] calldata _tranches, IRMConfig[] calldata _configs) external; } ================================================ FILE: src/08-omni-protocol/interfaces/IOmniOracle.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; /** * @title IOmniOracle Interface * @notice Interface for the OmniOracle contract. */ interface IOmniOracle { /// Events event SetOracle( address indexed underlying, address indexed oracle, Provider provider, uint32 delay, uint32 delayQuote, uint8 underlyingDecimals ); event RemoveOracle(address indexed underlying); /// Structs enum Provider { Invalid, Band, Chainlink, Other // Must implement the ICustomOmniOracle interface, use very carefully should return 1 full unit price multiplied by 1e18 } struct OracleConfig { // One storage slot address oracleAddress; // 160 bits Provider provider; // 8 bits uint32 delay; // 32 bits, because this is time-based in unix uint32 delayQuote; // 32 bits, for Band quote delay uint8 underlyingDecimals; // 8 bits, decimals of underlying token } /** * @notice Fetches the price of the specified asset. * @param _underlying The address of the asset. * @return The price of the asset, normalized to 1e18. */ function getPrice(address _underlying) external view returns (uint256); } ================================================ FILE: src/08-omni-protocol/interfaces/IOmniPool.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; /** * @title IOmniPool Interface * @dev This interface outlines the functions available in the OmniPool contract. */ interface IOmniPool { /// Events event ClearedMarkets(bytes32 indexed account); event EnteredIsolatedMarket(bytes32 indexed account, address market); event EnteredMarkets(bytes32 indexed account, address[] markets); event EnteredMode(bytes32 indexed account, uint256 modeId); event ExitedMarket(bytes32 indexed account, address market); event ExitedMode(bytes32 indexed account); event Liquidated( address indexed liquidator, bytes32 indexed targetAccount, bytes32 liquidatorAccount, address liquidateMarket, address collateralMarket, uint256 amount ); event PausedTranche(uint8 trancheId); event UnpausedTranche(); event SetMarketConfiguration(address indexed market, MarketConfiguration marketConfig); event RemovedMarketConfiguration(address indexed market); event SetModeConfiguration(uint256 indexed modeId, ModeConfiguration modeConfig); event SocializedLoss(address indexed market, uint8 trancheId, bytes32 account); // Structs /** * @dev Structure to hold market configuration data. */ struct MarketConfiguration { uint32 collateralFactor; uint32 borrowFactor; // Set to 0 if not borrowable. uint32 expirationTimestamp; uint8 riskTranche; bool isIsolatedCollateral; // If this is false, riskTranche must be 0 } /** * @dev Structure to hold mode configuration data. */ struct ModeConfiguration { uint32 collateralFactor; uint32 borrowFactor; uint8 modeTranche; uint32 expirationTimestamp; // Only prevents people from entering a mode, does not affect users already in existing mode address[] markets; } /** * @dev Structure to hold account specific data. */ struct AccountInfo { uint8 modeId; address isolatedCollateralMarket; uint32 softThreshold; } /** * @dev Structure to hold evaluation data for an account. */ struct Evaluation { uint256 depositTrueValue; uint256 borrowTrueValue; uint256 depositAdjValue; uint256 borrowAdjValue; uint64 numDeposit; // To combine into 1 storage slot uint64 numBorrow; bool isExpired; } /** * @dev Structure to hold liquidation bonus configuration data. */ struct LiquidationBonusConfiguration { uint64 start; // 1e9 precision uint64 end; // 1e9 precision uint64 kink; // 1e9 precision uint32 expiredBonus; // 1e9 precision uint32 softThreshold; // 1e9 precision } /** * @dev Structure to hold liquidation arguments. */ struct LiquidationParams { bytes32 targetAccountId; // The unique identifier of the target account to be liquidated. bytes32 liquidatorAccountId; // The unique identifier of the account initiating the liquidation. address liquidateMarket; // The address of the market from which to repay the borrow. address collateralMarket; // The address of the market from which to seize collateral. uint256 amount; // The amount of the target account's borrow balance to repay. If _amount is 0, liquidator will repay the entire borrow balance, and will error if the repayment is too large. } // Function Signatures /** * @dev Returns the address of the oracle contract. * @return The address of the oracle. */ function oracle() external view returns (address); /** * @dev Returns the pause tranche value. * @return The pause tranche value. */ function pauseTranche() external view returns (uint8); /** * @dev Returns the reserve receiver. * @return The reserve receiver identifier. */ function reserveReceiver() external view returns (bytes32); /** * @dev Allows a user to enter an isolated market, the market configuration must be for isolated collateral. * @param _subId The identifier of the sub-account. * @param _isolatedMarket The address of the isolated market to enter. */ function enterIsolatedMarket(uint96 _subId, address _isolatedMarket) external; /** * @dev Allows a user to enter multiple unique markets, none of them are isolated collateral markets. * @param _subId The identifier of the sub-account. * @param _markets The addresses of the markets to enter. */ function enterMarkets(uint96 _subId, address[] calldata _markets) external; /** * @dev Allows a user to exit a single market including their isolated market. There must be no borrows active on the subaccount to exit a market. * @param _subId The identifier of the sub-account. * @param _market The addresses of the markets to exit. */ function exitMarket(uint96 _subId, address _market) external; /** * @dev Clears all markets for a user. The subaccount must have no active borrows to clear markets. * @param _subId The identifier of the sub-account. */ function clearMarkets(uint96 _subId) external; /** * @dev Sets a mode for a sub-account. * @param _subId The identifier of the sub-account. * @param _modeId The identifier of the mode to enter. */ function enterMode(uint96 _subId, uint8 _modeId) external; /** * @dev Exits the mode currently set for a sub-account. * @param _subId The identifier of the sub-account. */ function exitMode(uint96 _subId) external; /** * @dev Evaluates an account's financial metrics. * @param _accountId The identifier of the account. * @return eval A struct containing the evaluated metrics of the account. */ function evaluateAccount(bytes32 _accountId) external returns (Evaluation memory eval); /** * @dev Allows a sub-account to borrow assets from a specified market. * @param _subId The identifier of the sub-account. * @param _market The address of the market to borrow from. * @param _amount The amount of assets to borrow. */ function borrow(uint96 _subId, address _market, uint256 _amount) external; /** * @dev Allows a sub-account to repay borrowed assets to a specified market. * @param _subId The identifier of the sub-account. * @param _market The address of the market to repay to. * @param _amount The amount of assets to repay. */ function repay(uint96 _subId, address _market, uint256 _amount) external; /** * @dev Initiates a liquidation process to recover assets from an under-collateralized account. * @param _params The liquidation parameters. * @return seizedShares The amount of shares seized from the liquidated account. */ function liquidate(LiquidationParams calldata _params) external returns (uint256[] memory seizedShares); /** * @dev Distributes loss incurred in a market to a specified tranche of accounts. * @param _market The address of the market where the loss occurred. * @param _account The account identifier to record the loss. */ function socializeLoss(address _market, bytes32 _account) external; /** * @dev Retrieves the borrow tier of an account. * @param _account The account info struct containing the account's details. * @return The borrowing tier of the account. */ function getAccountBorrowTier(AccountInfo memory _account) external view returns (uint8); /** * @dev Retrieves the market addresses associated with an account. * @param _accountId The identifier of the account. * @param _account The account info struct containing the account's details. * @return A list of market addresses associated with the account. */ function getAccountPoolMarkets(bytes32 _accountId, AccountInfo memory _account) external view returns (address[] memory); /** * @dev Retrieves the liquidation bonus and soft threshold values for a market. * @param _depositAdjValue The adjusted value of deposits in the market. * @param _borrowAdjValue The adjusted value of borrows in the market. * @param _collateralMarket The address of the collateral market. * @return bonus The liquidation bonus value. * @return softThreshold The soft liquidation threshold value. */ function getLiquidationBonusAndThreshold( uint256 _depositAdjValue, uint256 _borrowAdjValue, address _collateralMarket ) external view returns (uint256 bonus, uint256 softThreshold); /** * @dev Checks if an account is healthy based on its financial metrics. * @param _accountId The identifier of the account. * @return A boolean indicating whether the account is healthy. */ function isAccountHealthy(bytes32 _accountId) external returns (bool); /** * @dev Resets the pause tranche to its initial state. */ function resetPauseTranche() external; /** * @dev Updates the market configuration. * @param _market The address of the market. * @param _marketConfig The market configuration data. */ function setMarketConfiguration(address _market, MarketConfiguration calldata _marketConfig) external; /** * @dev Updates mode configurations one at a time. * @param _modeConfiguration An single mode configuration. */ function setModeConfiguration(ModeConfiguration calldata _modeConfiguration) external; /** * @dev Updates the soft liquidation threshold for an account. * @param _accountId The account identifier. * @param _softThreshold The soft liquidation threshold value. */ function setAccountSoftLiquidation(bytes32 _accountId, uint32 _softThreshold) external; /** * @dev Updates the liquidation bonus configuration for a market. * @param _market The address of the market. * @param _config The liquidation bonus configuration data. */ function setLiquidationBonusConfiguration(address _market, LiquidationBonusConfiguration calldata _config) external; /** * @notice Sets the tranche count for a specific market. * @dev This function allows to set the number of tranches for a given market. * It's an external function that can only be called by an account with the `MARKET_CONFIGURATOR_ROLE`. * @param _market The address of the market contract. * @param _trancheCount The number of tranches to be set for the market. */ function setTrancheCount(address _market, uint8 _trancheCount) external; /** * @dev This function can only be called by an account with the MARKET_CONFIGURATOR_ROLE. * It invokes the setTrancheBorrowCaps function of the IOmniToken contract associated with the specified market. * @param _market The address of the market for which to set the borrow caps. * @param _borrowCaps An array of borrow cap values, one for each tranche of the market. */ function setBorrowCap(address _market, uint256[] calldata _borrowCaps) external; /** * @dev This function can only be called by an account with the MARKET_CONFIGURATOR_ROLE. * It invokes the setSupplyCap function of the IOmniTokenNoBorrow contract associated with the specified market. * @param _market The address of the market for which to set the no-borrow supply cap. * @param _noBorrowSupplyCap The value of the no-borrow supply cap to set. */ function setNoBorrowSupplyCap(address _market, uint256 _noBorrowSupplyCap) external; /** * @notice Sets the reserve receiver's address. This function can only be called by an account with the DEFAULT_ADMIN_ROLE. * @dev The reserve receiver's address is converted to a bytes32 account identifier using the toAccount function with a subId of 0. * @param _reserveReceiver The address of the reserve receiver to be set. */ function setReserveReceiver(address _reserveReceiver) external; } ================================================ FILE: src/08-omni-protocol/interfaces/IOmniToken.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; import "./IOmniTokenBase.sol"; /** * @title IOmniToken * @notice Interface for the OmniToken contract which manages deposits, withdrawals, borrowings, and repayments within the Omni protocol. */ interface IOmniToken is IOmniTokenBase { /// Events event Accrue(); event Deposit(bytes32 indexed account, uint8 indexed trancheId, uint256 amount, uint256 share); event Withdraw(bytes32 indexed account, uint8 indexed trancheId, uint256 amount, uint256 share); event Borrow(bytes32 indexed account, uint8 indexed trancheId, uint256 amount, uint256 share); event Repay(bytes32 indexed account, address indexed payer, uint8 indexed trancheId, uint256 amount, uint256 share); event Seize(bytes32 indexed account, bytes32 indexed to, uint256 amount, uint256[] seizedShares); event SetTrancheCount(uint8 trancheCount); event SetTrancheBorrowCaps(uint256[] borrowCaps); event SocializedLoss(bytes32 indexed account, uint8 indexed trancheId, uint256 amount, uint256 share); event Transfer(bytes32 indexed from, bytes32 indexed to, uint8 indexed trancheId, uint256 share); /** * @notice Gets the address of the OmniPool contract. * @return The address of the OmniPool contract. */ function omniPool() external view returns (address); /** * @notice Gets the address of the Interest Rate Model (IRM) contract. * @return The address of the IRM contract. */ function irm() external view returns (address); /** * @notice Gets the last accrual time. * @return The timestamp of the last accrual time. */ function lastAccrualTime() external view returns (uint256); /** * @notice Gets the count of tranches. * @return The total number of tranches. */ function trancheCount() external view returns (uint8); /** * @notice Gets the reserve receiver. * @return The bytes32 identifier of the reserve receiver. */ function reserveReceiver() external view returns (bytes32); /** * @notice Gets the borrow cap for a specific tranche. * @param _trancheId The ID of the tranche for which to retrieve the borrow cap. * @return The borrow cap for the specified tranche. */ function getBorrowCap(uint8 _trancheId) external view returns (uint256); /** * @notice Accrues interest for all tranches, calculates and distributes the interest among the depositors and updates tranche balances. * The function also handles reserve payments. This method needs to be called before any deposit, withdrawal, borrow, or repayment actions to update the state of the contract. * @dev Interest is paid out proportionately to more risky tranche deposits per tranche */ function accrue() external; /** * @notice Deposits a specified amount into a specified tranche. * @param _subId Sub-account identifier for the depositor. * @param _trancheId Identifier of the tranche to deposit into. * @param _amount Amount to deposit. * @return share Amount of deposit shares received in exchange for the deposit. */ function deposit(uint96 _subId, uint8 _trancheId, uint256 _amount) external returns (uint256 share); /** * @notice Withdraws funds from a specified tranche. * @param _subId The ID of the sub-account. * @param _trancheId The ID of the tranche. * @param _share The share of the user in the tranche. * @return amount The amount of funds withdrawn. */ function withdraw(uint96 _subId, uint8 _trancheId, uint256 _share) external returns (uint256 amount); /** * @notice Borrows funds from a specified tranche. * @param _account The account of the user. * @param _trancheId The ID of the tranche. * @param _amount The amount to borrow. * @return share The share of the borrowed amount in the tranche. */ function borrow(bytes32 _account, uint8 _trancheId, uint256 _amount) external returns (uint256 share); /** * @notice Repays borrowed funds. * @param _account The account of the user. * @param _payer The account that will pay the borrowed amount. * @param _trancheId The ID of the tranche. * @param _amount The amount to repay. * @return amount The amount of the repaid amount in the tranche. */ function repay(bytes32 _account, address _payer, uint8 _trancheId, uint256 _amount) external returns (uint256 amount); /** * @notice Transfers specified shares from one account to another within a specified tranche. * @param _subId The subscription ID related to the sender's account. * @param _to The account identifier to which shares are being transferred. * @param _trancheId The identifier of the tranche where the transfer is occurring. * @param _shares The amount of shares to transfer. * @return A boolean value indicating whether the transfer was successful. */ function transfer(uint96 _subId, bytes32 _to, uint8 _trancheId, uint256 _shares) external returns (bool); /** * @notice Distributes the bad debt loss in a tranche among all tranche members. This function should only be called by the OmniPool. * @param _account The account that incurred a loss. * @param _trancheId The ID of the tranche. */ function socializeLoss(bytes32 _account, uint8 _trancheId) external; /** * @notice Computes the borrowing amount of a specific account in the underlying asset for a given borrow tier. * @dev The division is ceiling division. * @param _account The account identifier for which the borrowing amount is to be computed. * @param _borrowTier The borrow tier identifier from which the borrowing amount is to be computed. * @return The borrowing amount of the account in the underlying asset for the given borrow tier. */ function getAccountBorrowInUnderlying(bytes32 _account, uint8 _borrowTier) external view returns (uint256); /** * @notice Retrieves the deposit and borrow shares for a specific account in a specific tranche. * @param _account The account identifier. * @param _trancheId The tranche identifier. * @return depositShare The deposit share. * @return borrowShare The borrow share. */ function getAccountSharesByTranche(bytes32 _account, uint8 _trancheId) external view returns (uint256 depositShare, uint256 borrowShare); /** * @notice Sets the borrow caps for each tranche. * @param _borrowCaps An array of borrow caps in the underlying's decimals. */ function setTrancheBorrowCaps(uint256[] calldata _borrowCaps) external; /** * @notice Sets the number of tranches. * @param _trancheCount The new tranche count. */ function setTrancheCount(uint8 _trancheCount) external; /** * @notice Fetches and updates the reserve receiver from the OmniPool contract. */ function fetchReserveReceiver() external; } ================================================ FILE: src/08-omni-protocol/interfaces/IOmniTokenBase.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; /** * @title IOmniTokenBase * @notice Base interface shared by the IOmniToken and IOmniTokenNoBorrow interfaces. */ interface IOmniTokenBase { /** * @notice Retrieves the total deposit amount for a specific account. * @param _account The account identifier. * @return The total deposit amount. */ function getAccountDepositInUnderlying(bytes32 _account) external view returns (uint256); /** * @notice Calculates the total deposited amount for a specific owner across sub-accounts. This funciton is for wallets and Etherscan to pick up balances. * @param _owner The address of the owner. * @return The total deposited amount. */ function balanceOf(address _owner) external view returns (uint256); /** * @notice Seizes funds from a user's account in the event of a liquidation. This is a priveleged function only callable by the OmniPool and must be implemented carefully. * @param _account The account from which funds will be seized. * @param _to The account to which seized funds will be sent. * @param _amount The amount of funds to seize. * @return The shares seized from each tranche. */ function seize(bytes32 _account, bytes32 _to, uint256 _amount) external returns (uint256[] memory); } ================================================ FILE: src/08-omni-protocol/interfaces/IOmniTokenNoBorrow.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; import "./IOmniTokenBase.sol"; /** * @title IOmniTokenNoBorrow * @notice Interface for the OmniTokenNoBorrow contract which provides deposit and withdrawal features, without borrowing features. */ interface IOmniTokenNoBorrow is IOmniTokenBase { /// Events event Deposit(bytes32 indexed account, uint256 amount); event Withdraw(bytes32 indexed account, uint256 amount); event Seize(bytes32 indexed account, bytes32 indexed to, uint256 amount, uint256[] seizeShares); event SetSupplyCap(uint256 supplyCap); event Transfer(bytes32 indexed from, bytes32 indexed to, uint256 amount); /** * @notice Deposits a specified amount to the account. * @param _subId The sub-account identifier. * @param _amount The amount to deposit. * @return amount The actual amount deposited. */ function deposit(uint96 _subId, uint256 _amount) external returns (uint256 amount); /** * @notice Withdraws a specified amount from the account. * @param _subId The sub-account identifier. * @param _amount The amount to withdraw. * @return amount The actual amount withdrawn. */ function withdraw(uint96 _subId, uint256 _amount) external returns (uint256 amount); /** * @notice Transfers a specified amount of tokens from the sender's account to another account. * @param _subId The subscription ID associated with the sender's account. * @param _to The account identifier to which the tokens are being transferred. * @param _amount The amount of tokens to transfer. * @return A boolean value indicating whether the transfer was successful. */ function transfer(uint96 _subId, bytes32 _to, uint256 _amount) external returns (bool); /** * @notice Sets a new supply cap for the contract. * @param _supplyCap The new supply cap amount. */ function setSupplyCap(uint256 _supplyCap) external; } ================================================ FILE: src/08-omni-protocol/interfaces/IWETH9.sol ================================================ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /// @title Interface for WETH9 interface IWETH9 is IERC20 { /// @notice Deposit ether to get wrapped ether function deposit() external payable; /// @notice Withdraw wrapped ether to get ether function withdraw(uint256) external; } ================================================ FILE: src/08-omni-protocol/interfaces/IWithUnderlying.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /** * @title IWithUnderlying * @notice Interface for the WithUnderlying contract to handle the inflow and outflow of ERC20 tokens. */ interface IWithUnderlying { /** * @notice Gets the address of the underlying ERC20 token. * @return The address of the underlying ERC20 token. */ function underlying() external view returns (address); } ================================================ FILE: src/08-omni-protocol/oracles/WstETHCustomOracle.sol ================================================ // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.23; import "../interfaces/ICustomOmniOracle.sol"; import "../interfaces/IChainlinkAggregator.sol"; interface ILidoETH { function getPooledEthByShares(uint256 _sharesAmount) external view returns (uint256); } contract WstETHCustomOracle is ICustomOmniOracle { address public immutable stETH; address public immutable wstETH; address public immutable chainlinkStETHUSD; uint256 private constant MAX_DELAY = 1 days; /** * @notice Constructor for the WstETHCustomOracle * @param _stETH The address of the stETH contract. * @param _wstETH The address of the wstETH contract. * @param _chainlinkStETHUSD The address of the Chainlink aggregator contract. */ constructor(address _stETH, address _wstETH, address _chainlinkStETHUSD) { stETH = _stETH; wstETH = _wstETH; chainlinkStETHUSD = _chainlinkStETHUSD; } /** * @notice Fetches the price of the specified asset. * @param _underlying The address of the asset. * @return The price of the asset, normalized to 1e18. */ function getPrice(address _underlying) external view returns (uint256) { require(_underlying == wstETH, "Invalid address for oracle"); (, int256 stETHPrice,,uint256 updatedAt,) = IChainlinkAggregator(chainlinkStETHUSD).latestRoundData(); if (stETHPrice <= 0) return 0; require(updatedAt >= block.timestamp - MAX_DELAY, "Stale price for stETH"); uint256 stEthPerWstETH = ILidoETH(stETH).getPooledEthByShares(1e18); return (stEthPerWstETH * uint256(stETHPrice)) / (10 ** IChainlinkAggregator(chainlinkStETHUSD).decimals()); } } ================================================ FILE: src/09-vesting/Vesting.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; contract Vesting { uint24 public constant TOTAL_POINTS_PCT = 100_000; struct AllocationInput { address recipient; uint24 points; uint8 vestingWeeks; } struct AllocationData { uint24 points; uint8 vestingWeeks; bool claimed; } mapping(address recipient => AllocationData data) public allocations; constructor(AllocationInput[] memory allocInput) { uint256 inputLength = allocInput.length; require(inputLength > 0, "No allocations"); uint24 totalPoints; for(uint256 i; i= points, "Insufficient points"); require(!fromAllocation.claimed, "Already claimed"); AllocationData memory toAllocation = allocations[to]; require(!toAllocation.claimed, "Already claimed"); // enforce identical vesting periods if `to` has an active vesting period if(toAllocation.vestingWeeks != 0) { require(fromAllocation.vestingWeeks == toAllocation.vestingWeeks, "Vesting mismatch"); } allocations[msg.sender].points = fromAllocation.points - points; allocations[to].points = toAllocation.points + points; // if `to` had no active vesting period, copy from `from` if (toAllocation.vestingWeeks == 0) { allocations[to].vestingWeeks = fromAllocation.vestingWeeks; } } } ================================================ FILE: src/10-vesting-ext/VestingExt.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; contract VestingExt { uint24 public constant TOTAL_POINTS_PCT = 100_000; uint256 public constant TOTAL_PRECLAIM_PCT = 100; uint256 public constant MAX_PRECLAIM_PCT = 10; uint96 public constant TOTAL_TOKEN_ALLOCATION = 1_000_000e18; struct AllocationInput { address recipient; uint24 points; uint8 vestingWeeks; } struct AllocationData { uint24 points; uint8 vestingWeeks; bool claimed; uint96 preclaimed; } mapping(address recipient => AllocationData data) public allocations; constructor(AllocationInput[] memory allocInput) { uint256 inputLength = allocInput.length; require(inputLength > 0, "No allocations"); uint24 totalPoints; for(uint256 i; i= points, "Insufficient points"); require(!fromAllocation.claimed, "Already claimed"); AllocationData memory toAllocation = allocations[to]; require(!toAllocation.claimed, "Already claimed"); // enforce identical vesting periods if `to` has an active vesting period if(toAllocation.vestingWeeks != 0) { require(fromAllocation.vestingWeeks == toAllocation.vestingWeeks, "Vesting mismatch"); } allocations[msg.sender].points = fromAllocation.points - points; allocations[to].points = toAllocation.points + points; // if `to` had no active vesting period, copy from `from` if (toAllocation.vestingWeeks == 0) { allocations[to].vestingWeeks = fromAllocation.vestingWeeks; } } // calculates how many tokens user is entitled to based on their points function getUserTokenAllocation(uint24 points) public pure returns(uint96 allocatedTokens) { allocatedTokens = (points * TOTAL_TOKEN_ALLOCATION) / TOTAL_POINTS_PCT; } // calculates max preclaimable token amount given a user's total allocated tokens function getUserMaxPreclaimable(uint96 allocatedTokens) public pure returns(uint96 maxPreclaimable) { // unsafe cast OK here maxPreclaimable = uint96(MAX_PRECLAIM_PCT * allocatedTokens/ TOTAL_PRECLAIM_PCT); } // allows users to preclaim part of their token allocation function preclaim() external returns(uint96 userPreclaimAmount) { AllocationData memory userAllocation = allocations[msg.sender]; require(!userAllocation.claimed, "Already claimed"); require(userAllocation.preclaimed == 0, "Already preclaimed"); userPreclaimAmount = getUserMaxPreclaimable(getUserTokenAllocation(userAllocation.points)); require(userPreclaimAmount > 0, "Zero preclaim amount"); allocations[msg.sender].preclaimed = userPreclaimAmount; } } ================================================ FILE: src/11-op-reg/OperatorRegistry.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; contract OperatorRegistry { uint128 public numOperators; mapping(uint128 operatorId => address operatorAddress) public operatorIdToAddress; mapping(address operatorAddress => uint128 operatorId) public operatorAddressToId; // anyone can register their address as an operator function register() external returns(uint128 newOperatorId) { require(operatorAddressToId[msg.sender] == 0, "Address already registered"); newOperatorId = ++numOperators; operatorAddressToId[msg.sender] = newOperatorId; operatorIdToAddress[newOperatorId] = msg.sender; } // an operator can update their address function updateAddress(address newOperatorAddress) external { require(msg.sender != newOperatorAddress, "Updated address must be different"); uint128 operatorId = _getOperatorIdSafe(msg.sender); operatorAddressToId[newOperatorAddress] = operatorId; operatorIdToAddress[operatorId] = newOperatorAddress; delete operatorAddressToId[msg.sender]; } function _getOperatorIdSafe(address operatorAddress) internal view returns (uint128 operatorId) { operatorId = operatorAddressToId[operatorAddress]; require(operatorId != 0, "Operator not registered"); } } ================================================ FILE: src/12-liquidate-dos/LiquidateDos.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; interface ILiquidateDos { error InvalidMarketId(); error UserAlreadyInMarket(); error LiquidationsDisabled(); error LiquidateUserNotInAnyMarkets(); } contract LiquidateDos is ILiquidateDos { using EnumerableSet for EnumerableSet.UintSet; // 10 possible markets for users to trade in uint8 public constant MIN_MARKET_ID = 1; uint8 public constant MAX_MARKET_ID = 10; bool liquidationsEnabled; // tracks open markets for each user mapping(address user => EnumerableSet.UintSet activeMarkets) userActiveMarkets; // users can only have 1 open position in each market function openPosition(uint8 marketId) external { if(marketId < MIN_MARKET_ID || marketId > MAX_MARKET_ID) revert InvalidMarketId(); if(!userActiveMarkets[msg.sender].add(marketId)) revert UserAlreadyInMarket(); } function toggleLiquidations(bool toggle) external { liquidationsEnabled = toggle; } function liquidate(address user) external { if(!liquidationsEnabled) revert LiquidationsDisabled(); uint8 userActiveMarketsNum = uint8(userActiveMarkets[user].length()); if(userActiveMarketsNum == 0) revert LiquidateUserNotInAnyMarkets(); // in our simple implementation users are always liquidated for(uint8 i; i AccountDeposit) public accountDeposits; mapping(address depositor => Snapshots) public depositSnapshots; mapping(address depositor => uint256 deposits) public depositSums; mapping(address depositor => uint80 gains) public collateralGainsByDepositor; mapping(uint128 epoch => mapping(uint128 scale => uint256 sumS)) public epochToScaleToSums; // structs struct AccountDeposit { uint128 amount; uint128 timestamp; // timestamp of the last deposit } struct Snapshots { uint256 P; uint128 scale; uint128 epoch; } constructor(IERC20 _debtTokenAddress, IERC20 _collateralToken) { debtToken = _debtTokenAddress; collateralToken = _collateralToken; } // provides collateral tokens to the stability pool function provideToSP(uint256 _amount) external { require(_amount > 0, "StabilityPool: Amount must be non-zero"); _accrueDepositorCollateralGain(msg.sender); uint256 compoundedDebtDeposit = getCompoundedDebtDeposit(msg.sender); debtToken.safeTransferFrom(msg.sender, address(this), _amount); uint256 newTotalDebtTokenDeposits = totalDebtTokenDeposits + _amount; totalDebtTokenDeposits = newTotalDebtTokenDeposits; uint256 newTotalDeposited = compoundedDebtDeposit + _amount; accountDeposits[msg.sender] = AccountDeposit({ amount: SafeCast.toUint128(newTotalDeposited), timestamp: uint128(block.timestamp) }); _updateSnapshots(msg.sender, newTotalDeposited); } function registerLiquidation(uint256 _debtToOffset, uint256 _collToAdd) external { uint256 totalDebt = totalDebtTokenDeposits; if (totalDebt == 0 || _debtToOffset == 0) { return; } (uint256 collateralGainPerUnitStaked, uint256 debtLossPerUnitStaked) = _computeRewardsPerUnitStaked( _collToAdd, _debtToOffset, totalDebt ); _updateRewardSumAndProduct(collateralGainPerUnitStaked, debtLossPerUnitStaked); _decreaseDebt(_debtToOffset); } function _computeRewardsPerUnitStaked( uint256 _collToAdd, uint256 _debtToOffset, uint256 _totalDebtTokenDeposits ) internal returns (uint256 collateralGainPerUnitStaked, uint256 debtLossPerUnitStaked) { /* * Compute the Debt and collateral rewards. Uses a "feedback" error correction, to keep * the cumulative error in the P and S state variables low: * * 1) Form numerators which compensate for the floor division errors that occurred the last time this * function was called. * 2) Calculate "per-unit-staked" ratios. * 3) Multiply each ratio back by its denominator, to reveal the current floor division error. * 4) Store these errors for use in the next correction when this function is called. * 5) Note: static analysis tools complain about this "division before multiplication", however, it is intended. */ uint256 collateralNumerator = (_collToAdd * DECIMAL_PRECISION) + lastCollateralError_Offset; if (_debtToOffset == _totalDebtTokenDeposits) { debtLossPerUnitStaked = DECIMAL_PRECISION; // When the Pool depletes to 0, so does each deposit lastDebtLossError_Offset = 0; } else { uint256 debtLossNumerator = (_debtToOffset * DECIMAL_PRECISION) - lastDebtLossError_Offset; /* * Add 1 to make error in quotient positive. We want "slightly too much" Debt loss, * which ensures the error in any given compoundedDebtDeposit favors the Stability Pool. */ debtLossPerUnitStaked = (debtLossNumerator / _totalDebtTokenDeposits) + 1; lastDebtLossError_Offset = (debtLossPerUnitStaked * _totalDebtTokenDeposits) - debtLossNumerator; } collateralGainPerUnitStaked = collateralNumerator / _totalDebtTokenDeposits; lastCollateralError_Offset = collateralNumerator - (collateralGainPerUnitStaked * _totalDebtTokenDeposits); } // Update the Stability Pool reward sum S and product P function _updateRewardSumAndProduct( uint256 _collateralGainPerUnitStaked, uint256 _debtLossPerUnitStaked ) internal { uint256 currentP = P; uint256 newP; /* * The newProductFactor is the factor by which to change all deposits, due to the depletion of Stability Pool Debt in the liquidation. * We make the product factor 0 if there was a pool-emptying. Otherwise, it is (1 - DebtLossPerUnitStaked) */ uint256 newProductFactor = DECIMAL_PRECISION - _debtLossPerUnitStaked; uint128 currentScaleCached = currentScale; uint128 currentEpochCached = currentEpoch; uint256 currentS = epochToScaleToSums[currentEpochCached][currentScaleCached]; /* * Calculate the new S first, before we update P. * The collateral gain for any given depositor from a liquidation depends on the value of their deposit * (and the value of totalDeposits) prior to the Stability being depleted by the debt in the liquidation. * * Since S corresponds to collateral gain, and P to deposit loss, we update S first. */ uint256 marginalCollateralGain = _collateralGainPerUnitStaked * currentP; uint256 newS = currentS + marginalCollateralGain; epochToScaleToSums[currentEpochCached][currentScaleCached] = newS; // If the Stability Pool was emptied, increment the epoch, and reset the scale and product P if (newProductFactor == 0) { currentEpoch = currentEpochCached + 1; currentScale = 0; newP = DECIMAL_PRECISION; // If multiplying P by a non-zero product factor would reduce P below the scale boundary, increment the scale } else if ((currentP * newProductFactor) / DECIMAL_PRECISION < SCALE_FACTOR) { newP = (currentP * newProductFactor * SCALE_FACTOR) / DECIMAL_PRECISION; currentScale = currentScaleCached + 1; } else { newP = (currentP * newProductFactor) / DECIMAL_PRECISION; } require(newP > 0, "NewP"); P = newP; } function _decreaseDebt(uint256 _amount) internal { uint256 newTotalDebtTokenDeposits = totalDebtTokenDeposits - _amount; totalDebtTokenDeposits = newTotalDebtTokenDeposits; } // --- Reward calculator functions for depositor and front end --- /* Calculates the collateral gain earned by the deposit since its last snapshots were taken. * Given by the formula: E = d0 * (S - S(0))/P(0) * where S(0) and P(0) are the depositor's snapshots of the sum S and product P, respectively. * d0 is the last recorded deposit value. */ function getDepositorCollateralGain(address _depositor) external view returns (uint256 collateralGains) { uint256 P_Snapshot = depositSnapshots[_depositor].P; if (P_Snapshot == 0) return collateralGains; collateralGains = collateralGainsByDepositor[_depositor]; uint256 initialDeposit = accountDeposits[_depositor].amount; uint128 epochSnapshot = depositSnapshots[_depositor].epoch; uint128 scaleSnapshot = depositSnapshots[_depositor].scale; uint256 sums = epochToScaleToSums[epochSnapshot][scaleSnapshot]; uint256 nextSums = epochToScaleToSums[epochSnapshot][scaleSnapshot + 1]; uint256 depSums = depositSums[_depositor]; if (sums != 0) { uint256 firstPortion = sums - depSums; uint256 secondPortion = nextSums / SCALE_FACTOR; collateralGains += (initialDeposit * (firstPortion + secondPortion)) / P_Snapshot / DECIMAL_PRECISION; } } function _accrueDepositorCollateralGain(address _depositor) private returns (bool hasGains) { // cache user's initial deposit amount uint256 initialDeposit = accountDeposits[_depositor].amount; if(initialDeposit != 0) { uint128 epochSnapshot = depositSnapshots[_depositor].epoch; uint128 scaleSnapshot = depositSnapshots[_depositor].scale; uint256 P_Snapshot = depositSnapshots[_depositor].P; uint256 sumS = epochToScaleToSums[epochSnapshot][scaleSnapshot]; uint256 nextSumS = epochToScaleToSums[epochSnapshot][scaleSnapshot + 1]; uint256 depSums = depositSums[_depositor]; if (sumS != 0) { hasGains = true; uint256 firstPortion = sumS - depSums; uint256 secondPortion = nextSumS / SCALE_FACTOR; collateralGainsByDepositor[_depositor] += SafeCast.toUint80( (initialDeposit * (firstPortion + secondPortion)) / P_Snapshot / DECIMAL_PRECISION ); } } } function getCompoundedDebtDeposit(address _depositor) public view returns (uint256 compoundedDeposit) { compoundedDeposit = accountDeposits[_depositor].amount; if (compoundedDeposit != 0) { Snapshots memory snapshots = depositSnapshots[_depositor]; compoundedDeposit = _getCompoundedStakeFromSnapshots(compoundedDeposit, snapshots); } } function _getCompoundedStakeFromSnapshots( uint256 initialStake, Snapshots memory snapshots ) internal view returns (uint256 compoundedStake) { if(snapshots.epoch >= currentEpoch) { uint128 scaleDiff = currentScale - snapshots.scale; if (scaleDiff == 0) { compoundedStake = (initialStake * P) / snapshots.P; } else if (scaleDiff == 1) { compoundedStake = (initialStake * P) / snapshots.P / SCALE_FACTOR; } } } function claimCollateralGains() external { _accrueDepositorCollateralGain(msg.sender); uint80 depositorGains = collateralGainsByDepositor[msg.sender]; if (depositorGains > 0) { collateralGainsByDepositor[msg.sender] = 0; collateralToken.safeTransfer(msg.sender, depositorGains); } } function _updateSnapshots(address _depositor, uint256 _newValue) internal { if (_newValue == 0) { delete depositSnapshots[_depositor]; depositSums[_depositor] = 0; } else { uint128 currentScaleCached = currentScale; uint128 currentEpochCached = currentEpoch; uint256 currentP = P; // Get S and G for the current epoch and current scale uint256 currentS = epochToScaleToSums[currentEpochCached][currentScaleCached]; // Record new snapshots of the latest running product P, sum S, and sum G, for the depositor depositSnapshots[_depositor].P = currentP; depositSnapshots[_depositor].scale = currentScaleCached; depositSnapshots[_depositor].epoch = currentEpochCached; depositSums[_depositor] = currentS; } } } ================================================ FILE: src/14-priority/Priority.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; // A simplified collateral priority queue used in multi-collateral // borrowing protocols. The queue ensures that the riskiest collateral // at the start of the queue is liquidated first such that the // borrower's remaining collateral basket is more stable post-liquidation // // Challenge: write an invariant to test whether the collateral priority // order is always maintained contract Priority { using EnumerableSet for EnumerableSet.UintSet; error InvalidCollateralId(); error CollateralAlreadyAdded(); error CollateralNotAdded(); error InvalidIndex(); uint8 public constant MIN_COLLATERAL_ID = 1; uint8 public constant MAX_COLLATERAL_ID = 4; EnumerableSet.UintSet collateralPriority; function addCollateral(uint8 collateralId) external { if(collateralId < MIN_COLLATERAL_ID || collateralId > MAX_COLLATERAL_ID) revert InvalidCollateralId(); if(!collateralPriority.add(collateralId)) revert CollateralAlreadyAdded(); } function removeCollateral(uint8 collateralId) external { if(collateralId < MIN_COLLATERAL_ID || collateralId > MAX_COLLATERAL_ID) revert InvalidCollateralId(); if(!collateralPriority.remove(collateralId)) revert CollateralNotAdded(); } function getCollateralAtPriority(uint8 index) external view returns(uint8 val) { if(index >= MAX_COLLATERAL_ID) revert InvalidIndex(); val = uint8(collateralPriority.at(index)); } function containsCollateral(uint8 collateralId) external view returns(bool result) { if(collateralId < MIN_COLLATERAL_ID || collateralId > MAX_COLLATERAL_ID) revert InvalidCollateralId(); result = collateralPriority.contains(collateralId); } function numCollateral() external view returns(uint256 length) { length = collateralPriority.length(); } } ================================================ FILE: src/MockERC20.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.23; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MockERC20 is AccessControl, ERC20 { bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); uint8 private __decimals = 18; constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); _grantRole(MINTER_ROLE, msg.sender); } function decimals() public view override returns (uint8) { return __decimals; } function setDecimals(uint8 _decimals) external onlyRole(DEFAULT_ADMIN_ROLE) { __decimals = _decimals; } function mint(address _to, uint256 _value) external onlyRole(MINTER_ROLE) { _mint(_to, _value); } } ================================================ FILE: src/TestToken.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract TestToken is ERC20 { uint8 immutable private s_decimals; // mint initial supply to msg.sender. Used in test // setups so test setup can then distribute initial // tokens to different participants constructor(uint256 initialMint, uint8 decimal) ERC20("TTKN", "TTKN") { _mint(msg.sender, initialMint); s_decimals = decimal; } function decimals() public view override returns (uint8) { return s_decimals; } } ================================================ FILE: src/TestToken2.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract TestToken2 is ERC20 { uint8 immutable private s_decimals; // mint initial supply to msg.sender. Used in test // setups so test setup can then distribute initial // tokens to different participants constructor(uint256 initialMint, uint8 decimal) ERC20("TTKN", "TTKN") { _mint(msg.sender, initialMint); s_decimals = decimal; } function decimals() public view override returns (uint8) { return s_decimals; } } ================================================ FILE: test/01-naive-receiver/NaiveReceiverAdvancedEchidna.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "./NaiveReceiverBasicEchidna.t.sol"; // configure solc-select to use compiler version: // solc-select use 0.8.23 // // run from base project directory with: // echidna --config test/01-naive-receiver/NaiveReceiverAdvancedEchidna.yaml ./ --contract NaiveReceiverAdvancedEchidna // medusa --config test/01-naive-receiver/NaiveReceiverAdvancedMedusa.json fuzz contract NaiveReceiverAdvancedEchidna is NaiveReceiverBasicEchidna { // constructor has to be payable if balanceContract > 0 in yaml config constructor() payable NaiveReceiverBasicEchidna() { // advanced test with guiding of the fuzzer // // make this contract into a handler to wrap the pool's flashLoan() // function and instruct echidna to call it passing receiver's // address as the parameter. // // This is done in the yaml configuration file by setting // `allContracts: false` then creating a wrapper function in this // contract. With `allContracts: false` fuzzing will only call // functions in this or parent contracts. // // advanced echidna is able to break both invariants and find // much more simplified exploit chains than advanced foundry! } // wrapper around pool.flashLoan() to "guide" the fuzz test function flashLoanWrapper(uint256 borrowAmount) public { // instruct fuzzer to cap borrowAmount under pool's // available amount to prevent wasted runs // // commented out as echidna is faster at breaking the invariant // without this //borrowAmount = borrowAmount % INIT_ETH_POOL; // call underlying function being tested with the receiver address // to prevent wasted runs. Initially tried it with address as fuzz // input parameter but this was unable to break the harder invariant pool.flashLoan(address(receiver), borrowAmount); } // invariants inherited from base contract } ================================================ FILE: test/01-naive-receiver/NaiveReceiverAdvancedEchidna.yaml ================================================ # 1010 ether is placed in the echidna testing contract # which then transfers ether to contracts being tested # as part of setup in constructor. Constructor must be # payable! This value should be in 18 decimals balanceContract: 1010000000000000000000 # Don't allow fuzzer to use public/external functions # from all contracts as advanced version wraps specific # functions to focus on allContracts: false # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/01-naive-receiver/coverage-echidna-advanced" # use same prefix as Foundry invariant tests prefix: "invariant_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/01-naive-receiver/NaiveReceiverAdvancedFoundry.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "./NaiveReceiverBasicFoundry.t.sol"; // run from base project directory with: // forge test --match-contract NaiveReceiverAdvancedFoundry // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // 1) forge coverage --report lcov --report-file test/01-naive-receiver/coverage-foundry-advanced.lcov --match-contract NaiveReceiverAdvancedFoundry // 2) genhtml test/01-naive-receiver/coverage-foundry-advanced.lcov -o test/01-naive-receiver/coverage-foundry-advanced // 3) open test/01-naive-receiver/coverage-foundry-advanced/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records contract NaiveReceiverAdvancedFoundry is NaiveReceiverBasicFoundry { function setUp() public override { // call parent first to setup test environment super.setUp(); // advanced test with guiding of the fuzzer // // make this contract into a handler to wrap the pool's flashLoan() // function and instruct foundry to call it passing receiver's // address as the parameter. This significantly reduces // the amount of useless fuzz runs // // advanced foundry is able to break both invariants targetContract(address(this)); // functions to target during invariant tests bytes4[] memory selectors = new bytes4[](1); selectors[0] = this.flashLoanWrapper.selector; targetSelector(FuzzSelector({ addr: address(this), selectors: selectors })); } // wrapper around pool.flashLoan() to "guide" the fuzz test function flashLoanWrapper(uint256 borrowAmount) public { // instruct fuzzer to cap borrowAmount under pool's // available amount to prevent wasted runs vm.assume(borrowAmount <= INIT_ETH_POOL); // call underlying function being tested with the receiver address // to prevent wasted runs. Initially tried it with address as fuzz // input parameter but this was unable to break the harder invariant pool.flashLoan(address(receiver), borrowAmount); } // invariants inherited from base contract } ================================================ FILE: test/01-naive-receiver/NaiveReceiverAdvancedMedusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 10, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa-basic", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["NaiveReceiverAdvancedEchidna"], "targetContractsBalances": ["0x36c090d0ca68880000"], "constructorArgs": {}, "deployerAddress": "0x30000", "_COMMENT_TESTING_4": "changed senderAddresses to use permissionless attacker address", "senderAddresses": ["0x1337000000000000000000000000000000000000"], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "_COMMENT_TESTING_4": "stopOnFailedTest to false as there are 2 invariants to break", "stopOnFailedTest": false, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "_COMMENT_TESTING_5": "changed testAllContracts to true", "testAllContracts": true, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to use existing Echidna test files", "testPrefixes": [ "invariant_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] } }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/01-naive-receiver/NaiveReceiverBasicEchidna.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "../../src/01-naive-receiver/NaiveReceiverLenderPool.sol"; import "../../src/01-naive-receiver/FlashLoanReceiver.sol"; import "@openzeppelin/contracts/utils/Address.sol"; // configure solc-select to use compiler version: // solc-select use 0.8.23 // // run from base project directory with: // echidna --config test/01-naive-receiver/NaiveReceiverBasicEchidna.yaml ./ --contract NaiveReceiverBasicEchidna // medusa --config test/01-naive-receiver/NaiveReceiverBasicMedusa.json fuzz contract NaiveReceiverBasicEchidna { using Address for address payable; // initial eth flash loan pool uint256 constant INIT_ETH_POOL = 1000e18; // initial eth flash loan receiver uint256 constant INIT_ETH_RECEIVER = 10e18; // contracts required for test NaiveReceiverLenderPool pool; FlashLoanReceiver receiver; // constructor has to be payable if balanceContract > 0 in yaml config constructor() payable { // create contracts to be tested pool = new NaiveReceiverLenderPool(); receiver = new FlashLoanReceiver(payable(address(pool))); // set their initial eth balances by sending them ether. This contract // starts with `balanceContract` defined in yaml config payable(address(pool)).sendValue(INIT_ETH_POOL); payable(address(receiver)).sendValue(INIT_ETH_RECEIVER); // basic test with no advanced guiding of the fuzzer // echidna doesn't tell us how many fuzz runs reverted // // echidna is able to break invariant 2) but not 1) } // two possible invariants in order of importance: // // 1) receiver's balance is not 0 // breaking this invariant is very valuable but much harder function invariant_receiver_balance_not_zero() public view returns (bool) { return(address(receiver).balance != 0); } // 2) receiver's balance is not less than starting balance // breaking this invariant is less valuable but much easier function invariant_receiver_balance_not_less_initial() public view returns (bool) { return(address(receiver).balance >= INIT_ETH_RECEIVER); } } ================================================ FILE: test/01-naive-receiver/NaiveReceiverBasicEchidna.yaml ================================================ # 1010 ether is placed in the echidna testing contract # which then transfers ether to contract's being tested # as part of setup in constructor. Constructor must be # payable! This value should be in 18 decimals balanceContract: 1010000000000000000000 # Allow fuzzer to use public/external functions from all contracts allContracts: true # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/01-naive-receiver/coverage-echidna-basic" # use same prefix as Foundry invariant tests prefix: "invariant_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/01-naive-receiver/NaiveReceiverBasicFoundry.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "../../src/01-naive-receiver/NaiveReceiverLenderPool.sol"; import "../../src/01-naive-receiver/FlashLoanReceiver.sol"; import "forge-std/Test.sol"; // run from base project directory with: // forge test --match-contract NaiveReceiverBasicFoundry // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // 1) forge coverage --report lcov --report-file test/01-naive-receiver/coverage-foundry-basic.lcov --match-contract NaiveReceiverBasicFoundry // 2) genhtml test/01-naive-receiver/coverage-foundry-basic.lcov -o test/01-naive-receiver/coverage-foundry-basic // 3) open test/01-naive-receiver/coverage-foundry-basic/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records contract NaiveReceiverBasicFoundry is Test { // initial eth flash loan pool uint256 constant INIT_ETH_POOL = 1000e18; // initial eth flash loan receiver uint256 constant INIT_ETH_RECEIVER = 10e18; // contracts required for test NaiveReceiverLenderPool pool; FlashLoanReceiver receiver; function setUp() public virtual { // setup contracts to be tested pool = new NaiveReceiverLenderPool(); receiver = new FlashLoanReceiver(payable(address(pool))); // set their initial eth balances deal(address(pool), INIT_ETH_POOL); deal(address(receiver), INIT_ETH_RECEIVER); // basic test with no advanced guiding of the fuzzer // most of the fuzz runs revert and are useless // // basic foundry is able to break invariant 2) but not 1) } // two possible invariants in order of importance: // // 1) receiver's balance is not 0 // breaking this invariant is very valuable but much harder function invariant_receiver_balance_not_zero() public view { assert(address(receiver).balance != 0); } // 2) receiver's balance is not less than starting balance // breaking this invariant is less valuable but much easier function invariant_receiver_balance_not_less_initial() public view { assert(address(receiver).balance >= INIT_ETH_RECEIVER); } } ================================================ FILE: test/01-naive-receiver/NaiveReceiverBasicMedusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 10, "testLimit": 0, "shrinkLimit": 5000, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa-basic", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["NaiveReceiverBasicEchidna"], "predeployedContracts": {}, "targetContractsBalances": ["0x36c090d0ca68880000"], "constructorArgs": {}, "deployerAddress": "0x30000", "_COMMENT_TESTING_4": "changed senderAddresses to use permissionless attacker address", "senderAddresses": ["0x1337000000000000000000000000000000000000"], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "_COMMENT_TESTING_4": "stopOnFailedTest to false as there are 2 invariants to break", "stopOnFailedTest": false, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "_COMMENT_TESTING_5": "changed testAllContracts to true", "testAllContracts": true, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "assertionModes": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to use existing Echidna test files", "testPrefixes": [ "invariant_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/02-unstoppable/UnstoppableBasicEchidna.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "../../src/02-unstoppable/UnstoppableLender.sol"; import "../../src/02-unstoppable/ReceiverUnstoppable.sol"; import "../../src/TestToken.sol"; // configure solc-select to use compiler version: // solc-select use 0.8.23 // // run from base project directory with: // echidna --config test/02-unstoppable/UnstoppableBasicEchidna.yaml ./ --contract UnstoppableBasicEchidna // medusa --config test/02-unstoppable/UnstoppableBasicMedusa.json fuzz contract UnstoppableBasicEchidna { // initial tokens in pool uint256 constant INIT_TOKENS_POOL = 1000000e18; // initial tokens attacker uint256 constant INIT_TOKENS_ATTACKER = 100e18; // contracts required for test ERC20 token; UnstoppableLender pool; ReceiverUnstoppable receiver; address attacker = address(0x1337000000000000000000000000000000000000); // constructor has to be payable if balanceContract > 0 in yaml config constructor() payable { // setup contracts to be tested token = new TestToken(INIT_TOKENS_POOL + INIT_TOKENS_ATTACKER, 18); pool = new UnstoppableLender(address(token)); receiver = new ReceiverUnstoppable(payable(address(pool))); // transfer deposit initial tokens into pool token.approve(address(pool), INIT_TOKENS_POOL); pool.depositTokens(INIT_TOKENS_POOL); // transfer remaining tokens to the attacker token.transfer(attacker, INIT_TOKENS_ATTACKER); // attacker configured as msg.sender in yaml config } // invariant #1 very generic but Echidna can still break it even // if this is the only invariant function invariant_receiver_can_take_flash_loan() public returns (bool) { receiver.executeFlashLoan(10); return true; } // invariant #2 is more specific and Echidna can easily break it function invariant_pool_bal_equal_token_pool_bal() public view returns(bool) { return(pool.poolBalance() == token.balanceOf(address(pool))); } } ================================================ FILE: test/02-unstoppable/UnstoppableBasicEchidna.yaml ================================================ # no initial eth required balanceContract: 0 # increase test limit testLimit: 100000 # Allow fuzzer to use public/external functions from all contracts allContracts: true # specify address to use for fuzz transactions sender: ["0x1337000000000000000000000000000000000000"] # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/02-unstoppable/coverage-echidna-basic" # use same prefix as Foundry invariant tests prefix: "invariant_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/02-unstoppable/UnstoppableBasicFoundry.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "../../src/02-unstoppable/UnstoppableLender.sol"; import "../../src/02-unstoppable/ReceiverUnstoppable.sol"; import "../../src/TestToken.sol"; import "forge-std/Test.sol"; // run from base project directory with: // forge test --match-contract UnstoppableBasicFoundry // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // 1) forge coverage --report lcov --report-file test/02-unstoppable/coverage-foundry-basic.lcov --match-contract UnstoppableBasicFoundry // 2) genhtml test/02-unstoppable/coverage-foundry-basic.lcov -o test/02-unstoppable/coverage-foundry-basic // 3) open test/02-unstoppable/coverage-foundry-basic/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records contract UnstoppableBasicFoundry is Test { // initial tokens in pool uint256 constant INIT_TOKENS_POOL = 1000000e18; // initial tokens attacker uint256 constant INIT_TOKENS_ATTACKER = 100e18; // contracts required for test ERC20 token; UnstoppableLender pool; ReceiverUnstoppable receiver; address attacker = address(0x1337); function setUp() public virtual { // setup contracts to be tested token = new TestToken(INIT_TOKENS_POOL + INIT_TOKENS_ATTACKER, 18); pool = new UnstoppableLender(address(token)); receiver = new ReceiverUnstoppable(payable(address(pool))); // transfer deposit initial tokens into pool token.approve(address(pool), INIT_TOKENS_POOL); pool.depositTokens(INIT_TOKENS_POOL); // transfer remaining tokens to the attacker token.transfer(attacker, INIT_TOKENS_ATTACKER); // only one attacker targetSender(attacker); } // invariant #1 very generic, harder to break function invariant_receiver_can_take_flash_loan() public { receiver.executeFlashLoan(10); assert(true); } // invariant #2 more specific, should be easier to break function invariant_pool_bal_equal_token_pool_bal() public view { assert(pool.poolBalance() == token.balanceOf(address(pool))); } } ================================================ FILE: test/02-unstoppable/UnstoppableBasicMedusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 10, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa-basic", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["UnstoppableBasicEchidna"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "_COMMENT_TESTING_3": "changed senderAddresses to use custom senders", "senderAddresses": ["0x1337000000000000000000000000000000000000"], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "_COMMENT_TESTING_4": "stopOnFailedTest to false as there are 2 invariants to break", "stopOnFailedTest": false, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "_COMMENT_TESTING_5": "changed testAllContracts to true", "testAllContracts": true, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to use existing Echidna test files", "testPrefixes": [ "invariant_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/02-unstoppable/certora.conf ================================================ { "files": [ "src/02-unstoppable/ReceiverUnstoppable.sol", "src/02-unstoppable/UnstoppableLender.sol", "src/TestToken.sol" ], "verify": "ReceiverUnstoppable:test/02-unstoppable/certora.spec", "link": [ "ReceiverUnstoppable:pool=UnstoppableLender", "UnstoppableLender:damnValuableToken=TestToken" ], "packages":[ "@openzeppelin=lib/openzeppelin-contracts" ], "optimistic_loop": true } ================================================ FILE: test/02-unstoppable/certora.spec ================================================ // run from base folder: // certoraRun test/02-unstoppable/certora.conf using ReceiverUnstoppable as receiver; using UnstoppableLender as lender; using TestToken as token; methods { // `dispatcher` summary to prevent HAVOC function _.receiveTokens(address tokenAddress, uint256 amount) external => DISPATCHER(true); // `envfree` definitions to call functions without explicit `env` function token.balanceOf(address) external returns (uint256) envfree; } // executeFlashLoan() -> f() -> executeFlashLoan() should always succeed rule executeFlashLoan_mustNotRevert(uint256 loanAmount) { // enforce valid msg.sender: // 1) not a protocol contract // 2) equal to ReceiverUnstoppable::owner env e1; require e1.msg.sender != currentContract && e1.msg.sender != lender && e1.msg.sender != receiver && e1.msg.sender != token && e1.msg.sender == receiver.owner && e1.msg.value == 0; // not payable // enforce sufficient tokens exist to take out flash loan require loanAmount > 0 && loanAmount <= token.balanceOf(lender); // first executeFlashLoan() succeeds executeFlashLoan(e1, loanAmount); // perform another arbitrary successful transaction f() env e2; require e2.msg.sender != currentContract && e2.msg.sender != lender && e2.msg.sender != receiver && e2.msg.sender != token; method f; calldataarg args; f(e2, args); // second executeFlashLoan() should always succeed; there should // exist no previous transaction f() that could make it fail executeFlashLoan@withrevert(e1, loanAmount); assert !lastReverted; } ================================================ FILE: test/03-proposal/Properties.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {Asserts} from "@chimera/Asserts.sol"; import {Setup} from "./Setup.sol"; abstract contract Properties is Setup, Asserts { // event to raise if invariant broken to see interesting state event ProposalBalance(uint256 balance); // once the proposal has completed, all the eth should be distributed // either to the owner if the proposal failed or to the winners if // the proposal succeeded. no eth should remain forever stuck in the // contract function property_proposal_complete_all_rewards_distributed() public returns(bool) { uint256 proposalBalance = address(prop).balance; // only visible when invariant fails emit ProposalBalance(proposalBalance); return( // either proposal is active and contract balance > 0 (prop.isActive() && proposalBalance > 0) || // or proposal is not active and contract balance == 0 (!prop.isActive() && proposalBalance == 0) ); } } ================================================ FILE: test/03-proposal/ProposalCryticTester.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {Properties} from "./Properties.sol"; import {CryticAsserts} from "@chimera/CryticAsserts.sol"; // run from base project directory with: // echidna --config test/03-proposal/echidna.yaml ./ --contract ProposalCryticTester // medusa --config test/03-proposal/medusa.json fuzz contract ProposalCryticTester is Properties, CryticAsserts { constructor() payable { setup(); } } ================================================ FILE: test/03-proposal/ProposalCryticTesterToFoundry.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {Properties} from "./Properties.sol"; import {FoundryAsserts} from "@chimera/FoundryAsserts.sol"; import {Test} from "forge-std/Test.sol"; // run from base project directory with: // forge test --match-contract ProposalCryticTesterToFoundry -vvv // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // 1) forge coverage --report lcov --report-file test/03-proposal/coverage-foundry.lcov --match-contract ProposalCryticTesterToFoundry // 2) genhtml test/03-proposal/coverage-foundry.lcov -o test/03-proposal/coverage-foundry // 3) open test/03-proposal/coverage-foundry/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records contract ProposalCryticTesterToFoundry is Test, Properties, FoundryAsserts { function setUp() public virtual { setup(); // constrain fuzz test senders to the set of allowed voting addresses for(uint256 i; i= min_funding OR // 2) not active with balance == 0 invariant proposal_complete_all_rewards_distributed() (isActive() && nativeBalances[currentContract] >= MIN_FUNDING()) || (!isActive() && nativeBalances[currentContract] == 0) { // enforce state requirements to prevent HAVOC into invalid state preserved { // enforce valid total allowed voters require(currentContract.s_totalAllowedVoters >= MIN_VOTERS() && currentContract.s_totalAllowedVoters <= MAX_VOTERS() && // must be odd number currentContract.s_totalAllowedVoters % 2 == 1); // enforce valid for/against votes matches total current votes require(currentContract.s_votersFor.length + currentContract.s_votersAgainst.length == currentContract.s_totalCurrentVotes); // enforce that when a proposal is active, the total number of current // votes must be at maximum half the total allowed voters, since proposal // is automatically finalized once >= 51% votes are cast require(!isActive() || (isActive() && currentContract.s_totalCurrentVotes <= currentContract.s_totalAllowedVoters/2) ); } } ================================================ FILE: test/03-proposal/echidna.yaml ================================================ # 10 ether is placed in the echidna testing contract # which then transfers ether to contracts being tested # as part of setup in constructor. Constructor must be # payable! This value should be in 18 decimals balanceContract: 10000000000000000000 # Allow fuzzer to use public/external functions from all contracts allContracts: true # specify address to use for fuzz transations # limit this to the allowed voting addresses sender: ["0x1000000000000000000000000000000000000000", "0x2000000000000000000000000000000000000000", "0x3000000000000000000000000000000000000000", "0x4000000000000000000000000000000000000000", "0x5000000000000000000000000000000000000000"] # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/03-proposal/coverage-echidna" # common invariant prefix prefix: "property_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/03-proposal/medusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 10, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["ProposalCryticTester"], "predeployedContracts": {}, "targetContractsBalances": ["0x8ac7230489e80000"], "constructorArgs": {}, "deployerAddress": "0x99999", "_COMMENT_TESTING_3": "changed senderAddresses to use custom senders", "senderAddresses": ["0x1000000000000000000000000000000000000000", "0x2000000000000000000000000000000000000000", "0x3000000000000000000000000000000000000000", "0x4000000000000000000000000000000000000000", "0x5000000000000000000000000000000000000000"], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "_COMMENT_TESTING_4": "stopOnFailedTest to false as there are 2 invariants to break", "stopOnFailedTest": false, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "_COMMENT_TESTING_5": "changed testAllContracts to true", "testAllContracts": true, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to use existing Echidna test files", "testPrefixes": [ "property_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/04-voting-nft/Properties.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {Asserts} from "@chimera/Asserts.sol"; import {Setup} from "./Setup.sol"; abstract contract Properties is Setup, Asserts { // two possible invariants in order of importance: // // 1) at power calculation timestamp, total voting power is not 0 // breaking this invariant is very valuable but much harder // if it can break this invariant, it has pulled off the epic hack function property_total_power_gt_zero_power_calc_start() public view returns(bool) { return votingNft.getTotalPower() != 0; } // 2) at power calculation timestamp, total voting power is equal // to the initial max nft power // breaking this invariant is less valuable but much easier // if it can break this invariant, it has found the problem that would // then lead a human auditor to the big hack function property_total_power_eq_init_max_power_calc_start() public view returns(bool) { return votingNft.getTotalPower() == initMaxNftPower; } } ================================================ FILE: test/04-voting-nft/Setup.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {VotingNftForFuzz} from "../../src/04-voting-nft/VotingNftForFuzz.sol"; import {BaseSetup} from "@chimera/BaseSetup.sol"; abstract contract Setup is BaseSetup { uint256 constant requiredCollateral = 100000000000000000000; uint256 constant maxNftPower = 1000000000000000000000000000; uint256 constant nftPowerReductionPercent = 100000000000000000000000000; uint256 constant nftsToMint = 10; uint256 constant initMaxNftPower = maxNftPower * nftsToMint; uint256 constant timeUntilPowerCalc = 1000; uint256 powerCalcTimestamp; // contracts required for test VotingNftForFuzz votingNft; function setup() internal override { powerCalcTimestamp = block.timestamp + timeUntilPowerCalc; // setup contract to be tested votingNft = new VotingNftForFuzz(requiredCollateral, powerCalcTimestamp, maxNftPower, nftPowerReductionPercent); // no nfts deployed yet so total power should be 0 assert(votingNft.getTotalPower() == 0); // create 10 power nfts for(uint i=1; i<11; ++i) { votingNft.safeMint(address(0x1234), i); } // verify max power has been correctly increased assert(votingNft.getTotalPower() == initMaxNftPower); // this contract is the owner assert(votingNft.owner() == address(this)); // advance time to power calculation start; we modify the // contract to use hard-coded constant instead of block.timestamp // such that the fuzzer can focus on probing the initial power // calculation state, without the fuzzer moving block.timestamp // passed the initial power calculation timestamp votingNft.setFuzzerConstantBlockTimestamp(powerCalcTimestamp); } } ================================================ FILE: test/04-voting-nft/VotingNftCryticTester.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {Properties} from "./Properties.sol"; import {CryticAsserts} from "@chimera/CryticAsserts.sol"; // run from base project directory with: // echidna --config test/04-voting-nft/echidna.yaml ./ --contract VotingNftCryticTester // medusa --config test/04-voting-nft/medusa.json fuzz contract VotingNftCryticTester is Properties, CryticAsserts { constructor() payable { setup(); } } ================================================ FILE: test/04-voting-nft/VotingNftCryticToFoundry.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {Properties} from "./Properties.sol"; import {FoundryAsserts} from "@chimera/FoundryAsserts.sol"; import {Test} from "forge-std/Test.sol"; // run from base project directory with: // forge test --match-contract VotingNftCryticToFoundry // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // 1) forge coverage --report lcov --report-file test/04-voting-nft/coverage-foundry.lcov --match-contract VotingNftCryticToFoundry // 2) genhtml test/04-voting-nft/coverage-foundry.lcov -o test/04-voting-nft/coverage-foundry // 3) open test/04-voting-nft/coverage-foundry/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records contract VotingNftCryticToFoundry is Test, Properties, FoundryAsserts { function setUp() public virtual { setup(); // use specific attacker address; attacker has no assets or // any special permissions for the contract being attacked targetSender(address(0x1337)); } // wrap common invariants for foundry function invariant_total_power_gt_zero_power_calc_start() external { t(property_total_power_gt_zero_power_calc_start(), "Total voting power not zero when power calculation starts"); } function invariant_total_power_eq_init_max_power_calc_start() external { t(property_total_power_eq_init_max_power_calc_start(), "Total voting power correct when power calculation starts"); } } ================================================ FILE: test/04-voting-nft/certora.conf ================================================ { "files": [ "src/04-voting-nft/VotingNft.sol" ], "verify": "VotingNft:test/04-voting-nft/certora.spec", "packages":[ "@openzeppelin=lib/openzeppelin-contracts" ], "optimistic_fallback": true, "optimistic_loop": true } ================================================ FILE: test/04-voting-nft/certora.spec ================================================ // run from base folder: // certoraRun test/04-voting-nft/certora.conf methods { // `envfree` definitions to call functions without explicit `env` function getTotalPower() external returns (uint256) envfree; function totalSupply() external returns (uint256) envfree; function owner() external returns (address) envfree; function ownerOf(uint256) external returns (address) envfree; function balanceOf(address) external returns (uint256) envfree; } // define constants and require them later to prevent HAVOC into invalid state definition PERCENTAGE_100() returns uint256 = 1000000000000000000000000000; // given: safeMint() -> power calculation start time -> f() // there should exist no f() where a permissionless attacker // could nuke total power to 0 when power calculation starts rule total_power_gt_zero_power_calc_start(address to, uint256 tokenId) { // enforce basic sanity checks on variables set during constructor require currentContract.s_requiredCollateral > 0 && currentContract.s_powerCalcTimestamp > 0 && currentContract.s_maxNftPower > 0 && currentContract.s_nftPowerReductionPercent > 0 && currentContract.s_nftPowerReductionPercent < PERCENTAGE_100(); // enforce no nfts have yet been created; in practice some may // exist in storage though due to certora havoc require totalSupply() == 0 && getTotalPower() == 0 && balanceOf(to) == 0; // enforce msg.sender as owner required to mint nfts env e1; require e1.msg.sender == currentContract.owner(); // enforce block.timestamp < power calculation start time // so new nfts can still be minted require e1.block.timestamp < currentContract.s_powerCalcTimestamp; // first safeMint() succeeds safeMint(e1, to, tokenId); // sanity check results of first mint assert totalSupply() == 1 && balanceOf(to) == 1 && ownerOf(tokenId) == to && getTotalPower() == currentContract.s_maxNftPower; // perform any arbitrary successful transaction at power calculation // start time, where msg.sender is not an nft owner or an admin env e2; require e2.msg.sender != currentContract && e2.msg.sender != to && e2.msg.sender != currentContract.owner() && balanceOf(e2.msg.sender) == 0 && e2.block.timestamp == currentContract.s_powerCalcTimestamp; method f; calldataarg args; f(e2, args); // total power should not equal to 0 assert getTotalPower() != 0; } ================================================ FILE: test/04-voting-nft/echidna.yaml ================================================ # no eth required balanceContract: 0 # Allow fuzzer to use public/external functions from all contracts allContracts: true # specify address to use for fuzz transactions; for this test # we want only one sender who has no assets or permissions on # the contract being fuzzed; a permission-less attacker sender: ["0x1337000000000000000000000000000000000000"] # common invariant prefix prefix: "property_" # increase number of works to speed up test workers: 10 # increase test limit to around 1 minute testLimit: 3300000 # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/04-voting-nft/coverage-echidna" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/04-voting-nft/medusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to give fuzzer 1 minute", "timeout": 60, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["VotingNftCryticTester"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "_COMMENT_TESTING_3": "changed senderAddresses to use permissionless attacker address", "senderAddresses": ["0x1337000000000000000000000000000000000000"], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "_COMMENT_TESTING_4": "stopOnFailedTest to false as there are 2 invariants to break", "stopOnFailedTest": false, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "_COMMENT_TESTING_5": "changed testAllContracts to true", "testAllContracts": true, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": true, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "testPrefixes": [ "property_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/05-token-sale/TokenSaleAdvancedEchidna.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "./TokenSaleBasicEchidna.t.sol"; // configure solc-select to use compiler version: // solc-select use 0.8.23 // // run from base project directory with: // echidna --config test/05-token-sale/TokenSaleAdvancedEchidna.yaml ./ --contract TokenSaleAdvancedEchidna contract TokenSaleAdvancedEchidna is TokenSaleBasicEchidna { // constructor has to be payable if balanceContract > 0 in yaml config constructor() payable TokenSaleBasicEchidna() { // advanced test with guiding of the fuzzer // // ideally we would like a quick way to just point Echidna // at only the `tokenSale` contract, but since I'm not aware // of one we just wrap every function from that contract // into this one. // // Also in the yaml config set `allContracts: false` // // advanced echidna is able to break both invariants and find // much more simplified exploit chains than advanced foundry! } // dumb wrappers around the non-view `tokenSale` contract functions // would be nice if there was a simple way to just point Echidna // at the contract function buy(uint256 amountToBuy) public { hevm.prank(msg.sender); tokenSale.buy(amountToBuy); } function endSale() public { hevm.prank(msg.sender); tokenSale.endSale(); } // invariants inherited from base contract } ================================================ FILE: test/05-token-sale/TokenSaleAdvancedEchidna.yaml ================================================ # no eth required balanceContract: 0 # constraint fuzzer to token sale contract functions allContracts: false # specify address to use for fuzz transations # limit this to the allowed buyer addresses sender: ["0x1000000000000000000000000000000000000000", "0x2000000000000000000000000000000000000000", "0x3000000000000000000000000000000000000000", "0x4000000000000000000000000000000000000000", "0x5000000000000000000000000000000000000000"] # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/05-token-sale/coverage-echidna-advanced" # use same prefix as Foundry invariant tests prefix: "invariant_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/05-token-sale/TokenSaleAdvancedFoundry.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "./TokenSaleBasicFoundry.t.sol"; // run from base project directory with: // forge test --match-contract TokenSaleAdvancedFoundry // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // 1) forge coverage --report lcov --report-file test/05-token-sale/coverage-foundry-advanced.lcov --match-contract TokenSaleAdvancedFoundry // 2) genhtml test/05-token-sale/coverage-foundry-advanced.lcov -o test/05-token-sale/coverage-foundry-advanced // 3) open test/05-token-sale/coverage-foundry-advanced/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records contract TokenSaleAdvancedFoundry is TokenSaleBasicFoundry { function setUp() public override { // call parent first to setup test environment super.setUp(); // advanced test with guiding of the fuzzer // // guide Foundry to focus only on the `tokenSale` contract // // advanced foundry is able to break both invariants! targetContract(address(tokenSale)); } // invariants inherited from base contract } ================================================ FILE: test/05-token-sale/TokenSaleBasicEchidna.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "../../src/05-token-sale/TokenSale.sol"; import "../../src/TestToken.sol"; // configure solc-select to use compiler version: // solc-select use 0.8.23 // // run from base project directory with: // echidna --config test/05-token-sale/TokenSaleBasicEchidna.yaml ./ --contract TokenSaleBasicEchidna // medusa --config test/05-token-sale/TokenSaleBasicMedusa.json fuzz // used for HEVM cheat codes // https://github.com/crytic/building-secure-contracts/blob/master/program-analysis/echidna/advanced/on-using-cheat-codes.md // https://hevm.dev/controlling-the-unit-testing-environment.html#cheat-codes interface IHevm { function prank(address) external; } contract TokenSaleBasicEchidna { IHevm hevm = IHevm(address(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D)); uint8 private constant SELL_DECIMALS = 18; uint8 private constant BUY_DECIMALS = 6; // total tokens to sell uint256 private constant SELL_TOKENS = 1000e18; // buy tokens to give each buyer uint256 private constant BUY_TOKENS = 500e6; // number of buyers allowed in the token sale uint8 private constant NUM_BUYERS = 5; // max each buyer can buy uint256 private constant MAX_TOKENS_PER_BUYER = 200e18; // allowed buyers address[] buyers; // contracts required for test ERC20 sellToken; ERC20 buyToken; TokenSale tokenSale; // constructor has to be payable if balanceContract > 0 in yaml config constructor() payable { sellToken = new TestToken(SELL_TOKENS, SELL_DECIMALS); buyToken = new TestToken(BUY_TOKENS*NUM_BUYERS, BUY_DECIMALS); // setup the allowed list of buyers // make sure to use full address not just shorthand as Echidna // expands the address differently to Foundry & make sure to // use full addresses in yaml config `sender` list buyers.push(address(0x1000000000000000000000000000000000000000)); buyers.push(address(0x2000000000000000000000000000000000000000)); buyers.push(address(0x3000000000000000000000000000000000000000)); buyers.push(address(0x4000000000000000000000000000000000000000)); buyers.push(address(0x5000000000000000000000000000000000000000)); assert(buyers.length == NUM_BUYERS); // setup contract to be tested tokenSale = new TokenSale(buyers, address(sellToken), address(buyToken), MAX_TOKENS_PER_BUYER, SELL_TOKENS); // fund the contract sellToken.transfer(address(tokenSale), SELL_TOKENS); // verify setup // // token sale tokens & parameters assert(sellToken.balanceOf(address(tokenSale)) == SELL_TOKENS); assert(tokenSale.getSellTokenTotalAmount() == SELL_TOKENS); assert(tokenSale.getSellTokenAddress() == address(sellToken)); assert(tokenSale.getBuyTokenAddress() == address(buyToken)); assert(tokenSale.getMaxTokensPerBuyer() == MAX_TOKENS_PER_BUYER); assert(tokenSale.getTotalAllowedBuyers() == NUM_BUYERS); // no tokens have yet been sold assert(tokenSale.getRemainingSellTokens() == SELL_TOKENS); // this contract is the creator assert(tokenSale.getCreator() == address(this)); // constrain fuzz test senders to the set of allowed buying addresses // done in yaml config for echidna // distribute tokens to buyers for(uint256 i; i MAX_TOKENS_PER_BUYER) { return false; } } return true; } // this test case shows the major problem; the decimal precision // conversion code is assuming the input amount is formatted // with 18 decimals, even if the underlying token does not have // 18 decimals. Hence by sending a amount small enough the // conversion will round down to zero and the buyer can buy free // tokens from the token sale, since the conversion isn't checking // if the conversion of the buyer's input returned 0 & ERC20 // will happily transfer 0 tokens! // /* commented out by default since the invariants are what we are testing, this is just here to more clearly show the major bug function testBuy() public { address buyer = buyers[0]; uint256 amount = 200e6; hevm.prank(buyer); tokenSale.buy(amount); // buyer still has all their tokens assertEq(buyToken.balanceOf(buyer), BUY_TOKENS); // buyer got some sell tokens for free! assertEq(sellToken.balanceOf(buyer), 200e6); } */ } ================================================ FILE: test/05-token-sale/TokenSaleBasicEchidna.yaml ================================================ # no eth required balanceContract: 0 # Allow fuzzer to use public/external functions from all contracts allContracts: true # specify address to use for fuzz transations # limit this to the allowed buyer addresses sender: ["0x1000000000000000000000000000000000000000", "0x2000000000000000000000000000000000000000", "0x3000000000000000000000000000000000000000", "0x4000000000000000000000000000000000000000", "0x5000000000000000000000000000000000000000"] # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/05-token-sale/coverage-echidna-basic" # use same prefix as Foundry invariant tests prefix: "invariant_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/05-token-sale/TokenSaleBasicFoundry.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "../../src/05-token-sale/TokenSale.sol"; import "../../src/TestToken.sol"; import "forge-std/Test.sol"; // run from base project directory with: // forge test --match-contract TokenSaleBasicFoundry // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // 1) forge coverage --report lcov --report-file test/05-token-sale/coverage-foundry-basic.lcov --match-contract TokenSaleBasicFoundry // 2) genhtml test/05-token-sale/coverage-foundry-basic.lcov -o test/05-token-sale/coverage-foundry-basic // 3) open test/05-token-sale/coverage-foundry-basic/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records contract TokenSaleBasicFoundry is Test { uint8 private constant SELL_DECIMALS = 18; uint8 private constant BUY_DECIMALS = 6; // total tokens to sell uint256 private constant SELL_TOKENS = 1000e18; // buy tokens to give each buyer uint256 private constant BUY_TOKENS = 500e6; // number of buyers allowed in the token sale uint8 private constant NUM_BUYERS = 5; // max each buyer can buy uint256 private constant MAX_TOKENS_PER_BUYER = 200e18; // allowed buyers address[] buyers; // contracts required for test ERC20 sellToken; ERC20 buyToken; TokenSale tokenSale; function setUp() public virtual { sellToken = new TestToken(SELL_TOKENS, SELL_DECIMALS); buyToken = new TestToken(BUY_TOKENS*NUM_BUYERS, BUY_DECIMALS); // setup the allowed list of buyers buyers.push(address(0x1)); buyers.push(address(0x2)); buyers.push(address(0x3)); buyers.push(address(0x4)); buyers.push(address(0x5)); assert(buyers.length == NUM_BUYERS); // setup contract to be tested tokenSale = new TokenSale(buyers, address(sellToken), address(buyToken), MAX_TOKENS_PER_BUYER, SELL_TOKENS); // fund the contract sellToken.transfer(address(tokenSale), SELL_TOKENS); // verify setup // // token sale tokens & parameters assert(sellToken.balanceOf(address(tokenSale)) == SELL_TOKENS); assert(tokenSale.getSellTokenTotalAmount() == SELL_TOKENS); assert(tokenSale.getSellTokenAddress() == address(sellToken)); assert(tokenSale.getBuyTokenAddress() == address(buyToken)); assert(tokenSale.getMaxTokensPerBuyer() == MAX_TOKENS_PER_BUYER); assert(tokenSale.getTotalAllowedBuyers() == NUM_BUYERS); // no tokens have yet been sold assert(tokenSale.getRemainingSellTokens() == SELL_TOKENS); // this contract is the creator assert(tokenSale.getCreator() == address(this)); // constrain fuzz test senders to the set of allowed buying addresses for(uint256 i; i= MIN_PRECISION_BUY() && buyTokenDecimals <= PRECISION_SELL() && currentContract.s_sellTokenTotalAmount >= FUNDING_MIN() * 10 ^ sellTokenDecimals && currentContract.s_maxTokensPerBuyer <= currentContract.s_sellTokenTotalAmount && currentContract.s_totalBuyers >= BUYERS_MIN(); // enforce valid msg.sender require e1.msg.sender != currentContract.s_creator && e1.msg.sender != currentContract.s_buyToken && e1.msg.sender != currentContract.s_sellToken && e1.msg.value == 0; // enforce buyer has not yet bought any tokens being sold require currentContract.s_sellToken.balanceOf(e1, e1.msg.sender) == 0 && getSellTokenSoldAmount() == 0; // enforce buyer has tokens with which to buy tokens being sold uint256 buyerBuyTokenBalPre = currentContract.s_buyToken.balanceOf(e1, e1.msg.sender); require buyerBuyTokenBalPre > 0 && amountToBuy > 0; // perform a successful `buy` transaction buy(e1, amountToBuy); // buyer must have received some tokens from the sale assert getSellTokenSoldAmount() > 0; uint256 buyerSellTokensBalPost = currentContract.s_sellToken.balanceOf(e1, e1.msg.sender); assert buyerSellTokensBalPost > 0; uint256 buyerBuyTokenBalPost = currentContract.s_buyToken.balanceOf(e1, e1.msg.sender); // verify buyer paid 1:1 for the tokens they bought when accounting for decimal difference assert getSellTokenSoldAmount() == (buyerBuyTokenBalPre - buyerBuyTokenBalPost) * 10 ^ (sellTokenDecimals - buyTokenDecimals); } // this rule was provided by https://x.com/alexzoid_eth rule max_token_buy_per_user(env e1, env e2, uint256 amountToBuy1, uint256 amountToBuy2) { // enforce valid initial state require(currentContract.s_maxTokensPerBuyer <= currentContract.s_sellTokenTotalAmount); // enforce same buyer in different calls require e1.msg.sender == e2.msg.sender; require e1.msg.sender != currentContract && e1.msg.sender != currentContract.s_creator; // save initial balance mathint balanceBefore = currentContract.s_sellToken.balanceOf(e1, e1.msg.sender); // prevent over-flow in ERC20 (otherwise totalSupply and balances synchronization required) require balanceBefore < max_uint128 && amountToBuy1 < max_uint128 && amountToBuy2 < max_uint128; // perform two separate buy transactions buy(e1, amountToBuy1); buy(e2, amountToBuy2); // save final balance mathint balanceAfter = currentContract.s_sellToken.balanceOf(e1, e1.msg.sender); // verify total bought by same user must not exceed max limit per user mathint totalBuy = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0; assert totalBuy <= currentContract.s_maxTokensPerBuyer; } ================================================ FILE: test/06-rarely-false/RarelyFalseCryticTester.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {TargetFunctions} from "./TargetFunctions.sol"; import {CryticAsserts} from "@chimera/CryticAsserts.sol"; // run from base project directory with: // echidna --config test/06-rarely-false/echidna.yaml ./ --contract RarelyFalseCryticTester // medusa --config test/06-rarely-false/medusa.json fuzz contract RarelyFalseCryticTester is TargetFunctions, CryticAsserts { } ================================================ FILE: test/06-rarely-false/RarelyFalseCryticToFoundry.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {TargetFunctions} from "./TargetFunctions.sol"; import {FoundryAsserts} from "@chimera/FoundryAsserts.sol"; import {Test} from "forge-std/Test.sol"; // run from base project directory with: // forge test --match-contract RarelyFalseCryticToFoundry --fuzz-runs 2000000 // // get coverage report ( can be imported into https://lcov-viewer.netlify.app/ ) // forge coverage --report lcov --report-file test/06-rarely-false/coverage-foundry.lcov --match-contract RarelyFalseCryticToFoundry // // run halmos from base project directory: // halmos --function test_ --match-contract RarelyFalseCryticToFoundry contract RarelyFalseCryticToFoundry is Test, TargetFunctions, FoundryAsserts { } ================================================ FILE: test/06-rarely-false/TargetFunctions.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {Asserts} from "@chimera/Asserts.sol"; // target functions to test abstract contract TargetFunctions is Asserts { uint256 constant private OFFSET = 1234; uint256 constant private POW = 80; uint256 constant private LIMIT = type(uint256).max - OFFSET; // fuzzers call this function function test_RarelyFalse(uint256 n) external { // input preconditions n = between(n, 1, LIMIT); // assertion to break t(_rarelyFalse(n + OFFSET, POW), "Should not be false"); } // actual implementation to test function _rarelyFalse(uint256 n, uint256 e) private pure returns(bool) { if(n % 2**e == 0) return false; return true; } } ================================================ FILE: test/06-rarely-false/certora.conf ================================================ { "files": [ "test/06-rarely-false/RarelyFalseCryticToFoundry.sol" ], "verify": "RarelyFalseCryticToFoundry:test/06-rarely-false/certora.spec", "packages":[ "@chimera=lib/chimera/src", "forge-std=lib/forge-std/src" ], "foundry_tests_mode": true } ================================================ FILE: test/06-rarely-false/certora.spec ================================================ // run from base folder: // certoraRun test/06-rarely-false/certora.conf use builtin rule verifyFoundryFuzzTests; ================================================ FILE: test/06-rarely-false/echidna.yaml ================================================ # no eth required balanceContract: 0 # Allow fuzzer to use public/external functions from all contracts allContracts: false # using assertion mode testMode: "assertion" # increase number of works to speed up test workers: 10 # increase test limit to around 1 minute testLimit: 10000000 # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/06-rarely-false/coverage-echidna" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/06-rarely-false/medusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 60, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["RarelyFalseCryticTester"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "_COMMENT_TESTING_3": "changed senderAddresses to use custom senders", "senderAddresses": ["0x1337000000000000000000000000000000000000"], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "stopOnFailedTest": true, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "testAllContracts": false, "traceAll": false, "assertionTesting": { "_COMMENT_TESTING_5": "enabled assertion mode", "enabled": true, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to use existing Echidna test files", "testPrefixes": [ "invariant_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/07-byte-battle/ByteBattleCryticTester.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {TargetFunctions} from "./TargetFunctions.sol"; import {CryticAsserts} from "@chimera/CryticAsserts.sol"; // configure solc-select to use compiler version: // solc-select use 0.8.23 // // run from base project directory with: // echidna --config test/07-byte-battle/echidna.yaml ./ --contract ByteBattleCryticTester // medusa --config test/07-byte-battle/medusa.json fuzz contract ByteBattleCryticTester is TargetFunctions, CryticAsserts { } ================================================ FILE: test/07-byte-battle/ByteBattleCryticToFoundry.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {TargetFunctions} from "./TargetFunctions.sol"; import {FoundryAsserts} from "@chimera/FoundryAsserts.sol"; import {Test} from "forge-std/Test.sol"; // run from base project directory with: // forge test --match-contract ByteBattleCryticToFoundry // // get coverage report ( can be imported into https://lcov-viewer.netlify.app/ ) // forge coverage --report lcov --report-file test/07-byte-battle/coverage-foundry.lcov --match-contract ByteBattleCryticToFoundry // // run halmos from base project directory: // halmos --function test_ --match-contract ByteBattleCryticToFoundry contract ByteBattleCryticToFoundry is Test, TargetFunctions, FoundryAsserts { } ================================================ FILE: test/07-byte-battle/TargetFunctions.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import {Asserts} from "@chimera/Asserts.sol"; // target functions to test abstract contract TargetFunctions is Asserts { // fuzzers call this function function test_ByteBattle(bytes32 a, bytes32 b) external { // input precondition precondition(a != b); // assertion to break t(_convertIt(a) != _convertIt(b), "Different inputs should not convert to the same value"); } // actual implementation to test function _convertIt(bytes32 b) private pure returns (uint96) { return uint96(uint256(b) >> 160); } } ================================================ FILE: test/07-byte-battle/certora.conf ================================================ { "files": [ "test/07-byte-battle/ByteBattleCryticToFoundry.sol" ], "verify": "ByteBattleCryticToFoundry:test/07-byte-battle/certora.spec", "packages":[ "@chimera=lib/chimera/src", "forge-std=lib/forge-std/src" ], "foundry_tests_mode": true } ================================================ FILE: test/07-byte-battle/certora.spec ================================================ // run from base folder: // certoraRun test/07-byte-battle/certora.conf use builtin rule verifyFoundryFuzzTests; ================================================ FILE: test/07-byte-battle/echidna.yaml ================================================ # no eth required balanceContract: 0 # Allow fuzzer to use public/external functions from all contracts allContracts: false # using assertion mode testMode: "assertion" # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/07-byte-battle/coverage-echidna" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/07-byte-battle/medusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 10, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["ByteBattleCryticTester"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "_COMMENT_TESTING_3": "changed senderAddresses to use custom senders", "senderAddresses": ["0x1337000000000000000000000000000000000000"], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "stopOnFailedTest": true, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "testAllContracts": false, "traceAll": false, "assertionTesting": { "_COMMENT_TESTING_5": "enabled assertion mode", "enabled": true, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to use existing Echidna test files", "testPrefixes": [ "invariant_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/08-omni-protocol/MockOracle.sol ================================================ // SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.19; import "@openzeppelin/contracts/access/AccessControl.sol"; import "../../src/08-omni-protocol/interfaces/IOmniOracle.sol"; contract MockOracle is AccessControl, IOmniOracle { event SetPrice(address underlying, uint256 price); bytes32 public constant UPDATER_ROLE = keccak256("UPDATER_ROLE"); mapping(address => uint256) public prices; constructor() { _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); _grantRole(UPDATER_ROLE, msg.sender); } function setPrices(address[] calldata _underlyings, uint256[] calldata _prices) external onlyRole(UPDATER_ROLE) { require(_underlyings.length == _prices.length, "MockOracle::setPrices: bad data length"); for (uint256 index = 0; index < _underlyings.length; ++index) { prices[_underlyings[index]] = _prices[index]; emit SetPrice(_underlyings[index], _prices[index]); } } function getPrice(address _underlying) external view returns (uint256) { uint256 price = prices[_underlying]; require(price != 0, "MockOracle::getPrice: no price available"); return price; } } ================================================ FILE: test/08-omni-protocol/OmniAdvancedEchidna.yaml ================================================ # no eth required balanceContract: 0 # constraint fuzzer as we are using handlers allContracts: false # specify address to use for fuzz transactions sender: ["0x1000000000000000000000000000000000000000", "0x2000000000000000000000000000000000000000"] # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/08-omni-protocol/coverage-echidna" # changed prefix prefix: "medusa_" # increase number of works to speed up test workers: 10 # limit attempts to shrink broken invariant transaction chain shrinkLimit: 500 # increase test limit to give fuzzer approx 5 minutes testLimit: 900000 # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/08-omni-protocol/OmniAdvancedFoundry.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "../../src/MockERC20.sol"; import "./MockOracle.sol"; import "../../src/08-omni-protocol/IRM.sol"; import "../../src/08-omni-protocol/OmniPool.sol"; import "../../src/08-omni-protocol/OmniToken.sol"; import "../../src/08-omni-protocol/OmniTokenNoBorrow.sol"; import "../../src/08-omni-protocol/interfaces/IOmniToken.sol"; import "../../src/08-omni-protocol/interfaces/IOmniPool.sol"; import "../../src/08-omni-protocol/SubAccount.sol"; import "forge-std/Test.sol"; // // Foundry Fuzzer Info: // // change foundry.toml fuzz run to 5000 // run from base project directory with: // forge test --match-contract OmniAdvancedFoundry // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // 1) forge coverage --report lcov --report-file test/08-omni-protocol/coverage-foundry.lcov --match-contract OmniAdvancedFoundry // 2) genhtml test/08-omni-protocol/coverage-foundry.lcov -o test/08-omni-protocol/coverage-foundry // 3) open test/08-omni-protocol/coverage-foundry/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records // // Foundry is unable to break any invariants even when Foundry.toml // is configured with "runs = 40000" which takes ~5min to run. // // In contrast Echidna can sometimes break 1 invariant within 5 minutes and // Medusa can almost always break 2 invariants within 2 minutes, often // much faster. // contract OmniAdvancedFoundry is Test { using SubAccount for address; // make these constant to match Echidna & Medusa configs, left same for Foundry address public constant ALICE = address(0x1000000000000000000000000000000000000000); address public constant BOB = address(0x2000000000000000000000000000000000000000); // used for input restriction during fuzzing uint8 public constant MAX_TRANCH_ID = 1; // only 2 tranches uint8 public constant MIN_MODE_ID = 1; uint8 public constant MAX_MODE_ID = 2; uint96 public constant MAX_SUB_ID = 2; // used for price oracle uint8 public constant PRICES_COUNT = 3; // maximum price move % each time for Oracle assets uint8 public constant MIN_PRICE_MOVE = 2; uint8 public constant MAX_PRICE_MOVE = 10; // misc constants uint256 public constant USER_TOKENS = 1_000_000; // multiplied by token decimals uint256 public constant BORROW_CAP = 1_000_000; // multiplied by token decimals OmniPool pool; OmniToken oToken; OmniToken oToken2; OmniTokenNoBorrow oToken3; OmniTokenNoBorrow oToken4; IRM irm; MockERC20 uToken; MockERC20 uToken2; MockERC20 uToken3; MockOracle oracle; // used to update oracle prices address[] underlyings = new address[](PRICES_COUNT); uint256[] prices = new uint256[](PRICES_COUNT); // ghost variables used to verify invariants struct SubAccountGhost { uint8 numEnteredIsolatedMarkets; uint8 numEnteredMarkets; uint8 numEnteredModes; bool enteredIsolatedMarketWithActiveBorrows; bool exitedMarketOrModeWithActiveBorrows; bool enteredModeWithEnteredMarkets; bool enteredExpiredMarketOrMode; bool depositReceivedZeroShares; bool depositReceivedIncorrectAmount; bool withdrawReceivedIncorrectAmount; bool withdrawDecreasedZeroShares; bool repayDidntDecreaseBorrowShares; bool repayIncorrectBorrowAmountDecrease; bool borrowIncorrectBorrowAmountIncrease; bool borrowDidntIncreaseBorrowShares; } mapping(bytes32 accountId => SubAccountGhost) ghost_subAccount; // changed from constructor() to setUp() for Foundry function setUp() public { // Init contracts oracle = new MockOracle(); irm = new IRM(); irm.initialize(address(this)); pool = new OmniPool(); pool.initialize(address(oracle), address(this), address(this)); uToken = new MockERC20('USD Coin', 'USDC'); uToken2 = new MockERC20('Wrapped Ethereum', 'WETH'); uToken3 = new MockERC20('Shiba Inu', 'SHIB'); // Initial Oracle configs underlyings[0] = address(uToken); prices[0] = 1e18; // USDC underlyings[1] = address(uToken2); prices[1] = 2000e18; // WETH underlyings[2] = address(uToken3); prices[2] = 0.00001e18; // SHIB oracle.setPrices(underlyings, prices); // Configs for oTokens IIRM.IRMConfig[] memory configs = new IIRM.IRMConfig[](MAX_TRANCH_ID+1); configs[0] = IIRM.IRMConfig(0.9e9, 0.01e9, 0.035e9, 0.635e9); configs[1] = IIRM.IRMConfig(0.8e9, 0.03e9, 0.1e9, 1.2e9); IIRM.IRMConfig[] memory configs2 = new IIRM.IRMConfig[](MAX_TRANCH_ID+1); configs2[0] = IIRM.IRMConfig(0.85e9, 0.02e9, 0.055e9, 0.825e9); configs2[1] = IIRM.IRMConfig(0.75e9, 0.04e9, 0.12e9, 1.2e9); uint8[] memory tranches = new uint8[](MAX_TRANCH_ID+1); tranches[0] = 0; tranches[1] = 1; uint256[] memory borrowCaps = new uint256[](MAX_TRANCH_ID+1); borrowCaps[0] = BORROW_CAP * (10 ** uToken.decimals()); borrowCaps[1] = BORROW_CAP * (10 ** uToken.decimals()); // Init oTokens oToken = new OmniToken(); oToken.initialize(address(pool), address(uToken), address(irm), borrowCaps); oToken2 = new OmniToken(); oToken2.initialize(address(pool), address(uToken2), address(irm), borrowCaps); oToken3 = new OmniTokenNoBorrow(); oToken3.initialize(address(pool), address(uToken3), borrowCaps[0]); oToken4 = new OmniTokenNoBorrow(); oToken4.initialize(address(pool), address(uToken3), borrowCaps[0]); irm.setIRMForMarket(address(oToken), tranches, configs); irm.setIRMForMarket(address(oToken2), tranches, configs2); // Set MarketConfigs for Pool // expiration times made lower to trigger more liquidations IOmniPool.MarketConfiguration memory mConfig1 = IOmniPool.MarketConfiguration(0.9e9, 0.9e9, uint32(block.timestamp + 100 days), 0, false); IOmniPool.MarketConfiguration memory mConfig2 = IOmniPool.MarketConfiguration(0.8e9, 0.8e9, uint32(block.timestamp + 100 days), 0, false); IOmniPool.MarketConfiguration memory mConfig3 = IOmniPool.MarketConfiguration(0.4e9, 0, uint32(block.timestamp + 5 days), 1, true); IOmniPool.MarketConfiguration memory mConfig4 = IOmniPool.MarketConfiguration(0.4e9, 0, uint32(block.timestamp + 2 days), 1, true); pool.setMarketConfiguration(address(oToken), mConfig1); pool.setMarketConfiguration(address(oToken2), mConfig2); pool.setMarketConfiguration(address(oToken3), mConfig3); pool.setMarketConfiguration(address(oToken4), mConfig4); // Set ModeConfigs for Pool address[] memory modeMarkets = new address[](2); modeMarkets[0] = address(oToken); modeMarkets[1] = address(oToken2); IOmniPool.ModeConfiguration memory modeStableMode = IOmniPool.ModeConfiguration(0.95e9, 0.95e9, 0, uint32(block.timestamp + 7 days), modeMarkets); pool.setModeConfiguration(modeStableMode); pool.setModeConfiguration(modeStableMode); // mint user tokens uToken.mint(address(ALICE), USER_TOKENS * (10 ** uToken.decimals())); uToken.mint(address(BOB), USER_TOKENS * (10 ** uToken.decimals())); uToken2.mint(address(ALICE), USER_TOKENS * (10 ** uToken2.decimals())); uToken2.mint(address(BOB), USER_TOKENS * (10 ** uToken2.decimals())); uToken3.mint(address(ALICE), USER_TOKENS * (10 ** uToken3.decimals())); uToken3.mint(address(BOB), USER_TOKENS * (10 ** uToken3.decimals())); // setup user token approvals vm.startPrank(ALICE); uToken.approve(address(oToken), type(uint256).max); uToken2.approve(address(oToken2), type(uint256).max); uToken3.approve(address(oToken3), type(uint256).max); uToken3.approve(address(oToken4), type(uint256).max); vm.stopPrank(); vm.startPrank(BOB); uToken.approve(address(oToken), type(uint256).max); uToken2.approve(address(oToken2), type(uint256).max); uToken3.approve(address(oToken3), type(uint256).max); uToken3.approve(address(oToken4), type(uint256).max); vm.stopPrank(); // foundry-specific sender setup targetSender(ALICE); targetSender(BOB); // foundry-specific fuzz targeting targetContract(address(this)); bytes4[] memory selectors = new bytes4[](13); selectors[0] = this.enterIsolatedMarket.selector; selectors[1] = this.enterMarkets.selector; selectors[2] = this.exitMarket.selector; selectors[3] = this.clearMarkets.selector; selectors[4] = this.enterMode.selector; selectors[5] = this.exitMode.selector; selectors[6] = this.borrow.selector; selectors[7] = this.repay.selector; selectors[8] = this.liquidate.selector; selectors[9] = this.deposit.selector; selectors[10] = this.withdraw.selector; selectors[11] = this.transfer.selector; selectors[12] = this.updateOraclePrice.selector; targetSelector(FuzzSelector({ addr: address(this), selectors: selectors })); } /* DEFINE INVARIANTS HERE */ // // changed invariants to use assertions for Foundry // // INVARIANT 1) tranche should never reach a state where: // `tranche.totalBorrowShare > 0 && tranche.totalBorrowAmount == 0` or // `tranche.totalDepositShare > 0 && tranche.totalDepositAmount == 0` // // if these states are reached borrows/deposits in that tranche will permanently // be bricked. Either both == 0 or both > 0 function _getTranchBorrowDepositShareIntegrity(address _token, uint8 _tranche) private view returns(bool) { OmniToken.OmniTokenTranche memory trancheData = _getOmniTokenTranche(_token, _tranche); return ((trancheData.totalBorrowShare == 0 && trancheData.totalBorrowAmount == 0) || (trancheData.totalBorrowShare > 0 && trancheData.totalBorrowAmount > 0)) && ((trancheData.totalDepositShare == 0 && trancheData.totalDepositAmount == 0) || (trancheData.totalDepositShare > 0 && trancheData.totalDepositAmount > 0)); } function invariant_tranche_borrow_deposit_shares_integrity() public view { assert(_getTranchBorrowDepositShareIntegrity(address(oToken), 0) && _getTranchBorrowDepositShareIntegrity(address(oToken), 1) && _getTranchBorrowDepositShareIntegrity(address(oToken2), 0) && _getTranchBorrowDepositShareIntegrity(address(oToken2), 1)); } // INVARIANT 2) each subaccount may only enter max 1 isolated market at the same time function _inMoreThanOneIsolatedMarket(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].numEnteredIsolatedMarkets >= 2) return true; } return false; } function invariant_subaccount_one_isolated_market() public view { assert(!_inMoreThanOneIsolatedMarket(ALICE) && !_inMoreThanOneIsolatedMarket(BOB)); } // INVARIANT 3) each subaccount many only enter max 1 mode at the same time function _inMoreThanOneMode(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].numEnteredModes >= 2) return true; } return false; } function invariant_subaccount_one_mode() public view { assert(!_inMoreThanOneMode(ALICE) && !_inMoreThanOneMode(BOB)); } // INVARIANT 4) subaccount can't enter isolated collateral market with active borrows function _hasEnteredIsolatedMarketWithActiveBorrows(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].enteredIsolatedMarketWithActiveBorrows) return true; } return false; } function invariant_cant_enter_isolated_market_with_active_borrows() public view { assert(!_hasEnteredIsolatedMarketWithActiveBorrows(ALICE) && !_hasEnteredIsolatedMarketWithActiveBorrows(BOB)); } // INVARIANT 5) subaccount can't exit market or mode with active borrows function _hasExitedMarketOrModeWithActiveBorrows(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].exitedMarketOrModeWithActiveBorrows) return true; } return false; } function invariant_cant_exit_market_or_mode_with_active_borrows() public view { assert(!_hasExitedMarketOrModeWithActiveBorrows(ALICE) && !_hasExitedMarketOrModeWithActiveBorrows(BOB)); } // INVARIANT 6) subaccount can't enter a mode when it has already entered a market function _hasEnteredModeWithEnteredMarkets(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].enteredModeWithEnteredMarkets) return true; } return false; } function invariant_cant_enter_mode_with_entered_markets() public view { assert(!_hasEnteredModeWithEnteredMarkets(ALICE) && !_hasEnteredModeWithEnteredMarkets(BOB)); } // INVARIANT 7) subaccount can't enter an expired market or mode function _hasEnteredExpiredMarketOrMode(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].enteredExpiredMarketOrMode) return true; } return false; } function invariant_cant_enter_expired_market_or_mode() public view { assert(!_hasEnteredExpiredMarketOrMode(ALICE) && !_hasEnteredExpiredMarketOrMode(BOB)); } // INVARIANT 8) subaccount must have entered market/mode to take a loan function _hasLoanWithoutEnteringMarketOrMode(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(_hasActiveBorrows(accountId) && ghost_subAccount[accountId].numEnteredModes == 0 && ghost_subAccount[accountId].numEnteredMarkets == 0) return true; } return false; } function invariant_cant_borrow_without_entering_market_or_mode() public view { assert(!_hasLoanWithoutEnteringMarketOrMode(ALICE) && !_hasLoanWithoutEnteringMarketOrMode(BOB)); } // INVARIANT 9) subaccount should receive shares when making a deposit // Medusa is able to break this invariant when the fuzzer does small deposits // due to a rounding-down-to-zero precision loss at: // https://github.com/beta-finance/Omni-Protocol/blob/main/src/OmniToken.sol#L172 // Foundry is unable to break it function _hasDepositWhichReceivedZeroShares(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].depositReceivedZeroShares) return true; } return false; } function invariant_deposit_receives_shares() public view { assert(!_hasDepositWhichReceivedZeroShares(ALICE) && !_hasDepositWhichReceivedZeroShares(BOB)); } // INVARIANT 10) subaccount should receive amount when making a deposit function _hasDepositWhichReceivedIncorrectAmount(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].depositReceivedIncorrectAmount) return true; } return false; } function invariant_deposit_receives_correct_amount() public view { assert(!_hasDepositWhichReceivedIncorrectAmount(ALICE) && !_hasDepositWhichReceivedIncorrectAmount(BOB)); } // INVARIANT 11) subaccount should have shares decreased when withdrawing function _hasWithdrawWhichDecreasedZeroShares(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].withdrawDecreasedZeroShares) return true; } return false; } function invariant_withdraw_decreases_shares() public view { assert(!_hasWithdrawWhichDecreasedZeroShares(ALICE) && !_hasWithdrawWhichDecreasedZeroShares(BOB)); } // INVARIANT 12) subaccount should receive correct amount when withdrawing function _hasWithdrawWhichReceivedIncorrectAmount(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].withdrawReceivedIncorrectAmount) return true; } return false; } function invariant_withdraw_receives_correct_amount() public view { assert(!_hasWithdrawWhichReceivedIncorrectAmount(ALICE) && !_hasWithdrawWhichReceivedIncorrectAmount(BOB)); } // INVARIANT 13) repay should decrease borrow shares // Medusa is able to break this invariant when the fuzzer does small repayments // due to a rounding-down-to-zero precision loss at: // https://github.com/beta-finance/Omni-Protocol/blob/main/src/OmniToken.sol#L265 // Foundry is unable to break it function _hasRepayWhichDidntDecreaseBorrowShares(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].repayDidntDecreaseBorrowShares) return true; } return false; } function invariant_repay_decreases_borrow_shares() public view { assert(!_hasRepayWhichDidntDecreaseBorrowShares(ALICE) && !_hasRepayWhichDidntDecreaseBorrowShares(BOB)); } // INVARIANT 14) repay should decrease borrow amount by correct amount function _hasRepayWhichIncorrectlyDecreasedBorrowAmount(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].repayIncorrectBorrowAmountDecrease) return true; } return false; } function invariant_repay_correctly_decreases_borrow_amount() public view { assert(!_hasRepayWhichIncorrectlyDecreasedBorrowAmount(ALICE) && !_hasRepayWhichIncorrectlyDecreasedBorrowAmount(BOB)); } // INVARIANT 15) borrow should increase borrow shares function _hasBorrowWhichDidntIncreaseBorrowShares(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].borrowDidntIncreaseBorrowShares) return true; } return false; } function invariant_borrow_increases_borrow_shares() public view { assert(!_hasBorrowWhichDidntIncreaseBorrowShares(ALICE) && !_hasBorrowWhichDidntIncreaseBorrowShares(BOB)); } // INVARIANT 16) borrow should increase borrow amount by correct amount function _hasBorrowWhichIncorrectlyIncreasedBorrowAmount(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].borrowIncorrectBorrowAmountIncrease) return true; } return false; } function invariant_borrow_correctly_increases_borrow_amount() public view { assert(!_hasRepayWhichIncorrectlyDecreasedBorrowAmount(ALICE) && !_hasRepayWhichIncorrectlyDecreasedBorrowAmount(BOB)); } /* OmniPool HANDLER FUNCTIONS */ // // Handlers use input filtering to reduce but *not* to completely // eliminate invalid runs; there is still an element of randomness // where some inputs will be invalid function enterIsolatedMarket(uint96 _subId, uint8 _market) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketIncIsolated(_market); vm.prank(msg.sender); pool.enterIsolatedMarket(_subId, market); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredMarkets++; if(_isIsolatedMarket(market)) { ghost_subAccount[accountId].numEnteredIsolatedMarkets++; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].enteredIsolatedMarketWithActiveBorrows = true; } } if(_marketExpired(market)) { ghost_subAccount[accountId].enteredExpiredMarketOrMode = true; } } function enterMarkets(uint96 _subId, uint8 _market) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketIncIsolated(_market); address[] memory markets = new address[](1); markets[0] = market; vm.prank(msg.sender); pool.enterMarkets(_subId, markets); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredMarkets++; if(_isIsolatedMarket(market)) { ghost_subAccount[accountId].numEnteredIsolatedMarkets++; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].enteredIsolatedMarketWithActiveBorrows = true; } } if(_marketExpired(market)) { ghost_subAccount[accountId].enteredExpiredMarketOrMode = true; } } function exitMarket(uint96 _subId, uint8 _market) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketIncIsolated(_market); vm.prank(msg.sender); pool.exitMarket(_subId, market); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredMarkets--; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].exitedMarketOrModeWithActiveBorrows = true; } if(_isIsolatedMarket(market)) { ghost_subAccount[accountId].numEnteredIsolatedMarkets--; } } function clearMarkets(uint96 _subId) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); vm.prank(msg.sender); pool.clearMarkets(_subId); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredMarkets = 0; ghost_subAccount[accountId].numEnteredIsolatedMarkets = 0; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].exitedMarketOrModeWithActiveBorrows = true; } } function enterMode(uint96 _subId, uint8 _modeId) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); _modeId = _clampBetweenU8(_modeId, MIN_MODE_ID, MAX_MODE_ID); vm.prank(msg.sender); pool.enterMode(_subId, _modeId); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredModes++; if(ghost_subAccount[accountId].numEnteredMarkets > 0) { ghost_subAccount[accountId].enteredModeWithEnteredMarkets = true; } if(_modeExpired(_modeId)) { ghost_subAccount[accountId].enteredExpiredMarketOrMode = true; } } function exitMode(uint96 _subId) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); vm.prank(msg.sender); pool.exitMode(_subId); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredModes--; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].exitedMarketOrModeWithActiveBorrows = true; } } function borrow(uint96 _subId, uint8 _market, uint256 _amount) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketExcIsolated(_market); // save borrow amount & shares before calling borrow, used in invariant checks OmniToken token = OmniToken(market); // accrue() first so it cant change storage during the next txn token.accrue(); bytes32 accountId = msg.sender.toAccount(_subId); uint8 trancheId = pool.getAccountBorrowTier(_getAccountInfo(accountId)); ( , uint256 totalBorrowAmountPrev, , uint256 totalBorrowSharePrev) = token.tranches(trancheId); vm.prank(msg.sender); pool.borrow(_subId, market, _amount); ( , uint256 totalBorrowAmountAfter, , uint256 totalBorrowShareAfter) = token.tranches(trancheId); // update ghost variables uint256 borrowIncrease = totalBorrowAmountAfter - totalBorrowAmountPrev; if(_amount > 0) { if(borrowIncrease != _amount) { ghost_subAccount[accountId].borrowIncorrectBorrowAmountIncrease = true; } if(totalBorrowShareAfter == totalBorrowSharePrev) { ghost_subAccount[accountId].borrowDidntIncreaseBorrowShares = true; } } } function repay(uint96 _subId, uint8 _market, uint256 _amount) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketExcIsolated(_market); // save borrow amount & shares before calling repay, used in invariant checks OmniToken token = OmniToken(market); // accrue() first so it cant change storage during the next txn token.accrue(); bytes32 accountId = msg.sender.toAccount(_subId); uint8 trancheId = pool.getAccountBorrowTier(_getAccountInfo(accountId)); ( , uint256 totalBorrowAmountPrev, , uint256 totalBorrowSharePrev) = token.tranches(trancheId); vm.prank(msg.sender); pool.repay(_subId, market, _amount); ( , uint256 totalBorrowAmountAfter, , uint256 totalBorrowShareAfter) = token.tranches(trancheId); // update ghost variables uint256 borrowReduction = totalBorrowAmountPrev-totalBorrowAmountAfter; if(_amount > 0) { if(borrowReduction != _amount) { ghost_subAccount[accountId].repayIncorrectBorrowAmountDecrease = true; } if(totalBorrowShareAfter == totalBorrowSharePrev) { ghost_subAccount[accountId].repayDidntDecreaseBorrowShares = true; } } } function liquidate(uint96 _targetSubId, uint96 _liquidatorSubId, uint8 _targetAccount, uint8 _liquidateMarket, uint8 _collateralMarket, uint256 _amount, bool giveTokens) public { _targetSubId = _clampBetweenU96(_targetSubId, 0, MAX_SUB_ID); _liquidatorSubId = _clampBetweenU96(_liquidatorSubId, 0, MAX_SUB_ID); address liqMarket = _getMarketExcIsolated(_liquidateMarket); address colMarket = _getMarketIncIsolated(_collateralMarket); bytes32 targetAccountId = (_getActor(_targetAccount)).toAccount(_targetSubId); bytes32 liqAccountId = msg.sender.toAccount(_liquidatorSubId); // introduce some randomness into whether the test ensures account // has sufficent tokens to liquidate or not. This allows some invalid runs through // where account won't have enough tokens to liquidate but also helps ensure // there will be some valid liquidations if(giveTokens) { (MockERC20((OmniToken(liqMarket)).underlying())).mint(msg.sender, _amount); } vm.prank(msg.sender); pool.liquidate( IOmniPool.LiquidationParams(targetAccountId, liqAccountId, liqMarket, colMarket, _amount)); // no prank here, has to be called by admin. If it fails don't worry, just // trying to call it after liquidation to get some more coverage if liquidation // totally liquidates a user. Not fully working yet try pool.socializeLoss(liqMarket, targetAccountId) {} catch {} } /* OmniToken HANDLER FUNCTIONS */ // function deposit(uint96 _subId, uint8 _trancheId, uint256 _amount, uint8 _token, bool giveTokens) public { _subId = _clampBetweenU96(_subId , 0, MAX_SUB_ID); _trancheId = _clampBetweenU8(_trancheId, 0, MAX_TRANCH_ID); OmniToken token = OmniToken(_getMarketIncIsolated(_token)); // introduce some randomness into whether the test ensures account // has sufficent tokens to deposit or not. This allows some invalid // runs through where account won't have enough tokens to deposit. // Accounts can also have their tokens replenished this way if(giveTokens) { (MockERC20(token.underlying())).mint(msg.sender, _amount); } // accrue() first so it cant change storage during the next txn token.accrue(); // save deposit amount & shares before calling deposit, used in invariant checks (uint256 totalDepositAmountPrev, , uint256 totalDepositSharePrev, ) = token.tranches(_trancheId); vm.prank(msg.sender); token.deposit(_subId, _trancheId, _amount); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); (uint256 totalDepositAmountAfter, , uint256 totalDepositShareAfter, ) = token.tranches(_trancheId); if(_amount > 0 && totalDepositShareAfter == totalDepositSharePrev) { ghost_subAccount[accountId].depositReceivedZeroShares = true; } if(totalDepositAmountAfter-totalDepositAmountPrev != _amount) { ghost_subAccount[accountId].depositReceivedIncorrectAmount = true; } } function withdraw(uint96 _subId, uint8 _trancheId, uint256 _share, uint8 _token) public { _subId = _clampBetweenU96(_subId , 0, MAX_SUB_ID); _trancheId = _clampBetweenU8(_trancheId, 0, MAX_TRANCH_ID); OmniToken token = OmniToken(_getMarketIncIsolated(_token)); // accrue() first so it cant change storage during the next txn token.accrue(); // save deposit amount & shares before calling withdraw, used in invariant checks (uint256 totalDepositAmountPrev, , uint256 totalDepositSharePrev, ) = token.tranches(_trancheId); vm.prank(msg.sender); uint256 amount = token.withdraw(_subId, _trancheId, _share); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); (uint256 totalDepositAmountAfter, , uint256 totalDepositShareAfter, ) = token.tranches(_trancheId); uint256 actualDifference = totalDepositAmountPrev-totalDepositAmountAfter; if(_share > 0 && (actualDifference == 0 || actualDifference != amount)) { ghost_subAccount[accountId].withdrawReceivedIncorrectAmount = true; } if(_share > 0 && totalDepositShareAfter == totalDepositSharePrev) { ghost_subAccount[accountId].withdrawDecreasedZeroShares = true; } } function transfer(uint96 _subId, bytes32 _to, uint8 _trancheId, uint256 _shares, uint8 _token) public { _subId = _clampBetweenU96(_subId , 0, MAX_SUB_ID); _trancheId = _clampBetweenU8(_trancheId, 0, MAX_TRANCH_ID); IOmniToken token = IOmniToken(_getMarketIncIsolated(_token)); vm.prank(msg.sender); token.transfer(_subId, _to, _trancheId, _shares); } /* Price Oracle UTILITY FUNCTION */ // // function which changes oracle pricing of underlying tokens // will be called randomly by fuzzer. This enables positions to become // subject to liquidation enabling greater coverage function updateOraclePrice(uint8 _priceIndex, uint8 _percentMove, bool _increasePrice) public { _priceIndex = _clampBetweenU8(_priceIndex, 0, PRICES_COUNT-1); // price can move in a set % range _percentMove = _clampBetweenU8(_percentMove, MIN_PRICE_MOVE, MAX_PRICE_MOVE); // calculate price delta uint256 priceDelta = prices[_priceIndex] * _percentMove / 100; // apply direction if(_increasePrice) prices[_priceIndex] += priceDelta; else prices[_priceIndex] -= priceDelta; // save new pricing oracle.setPrices(underlyings, prices); } /* Helper functions to fetch data used in invariant checks */ // function _getOmniTokenTranche(address _market, uint8 _tranche) private view returns (OmniToken.OmniTokenTranche memory) { (uint256 totalDeposit, uint256 totalBorrow, uint256 totalDepositShares, uint256 totalBorrowShares) = OmniToken(_market).tranches(_tranche); return OmniToken.OmniTokenTranche(totalDeposit, totalBorrow, totalDepositShares, totalBorrowShares); } function _getAccountInfo(bytes32 account) internal view returns (IOmniPool.AccountInfo memory) { (uint8 modeId, address isolatedCollateralMarket, uint32 softThreshold) = pool.accountInfos(account); return IOmniPool.AccountInfo(modeId, isolatedCollateralMarket, softThreshold); } function _marketExpired(address _market) private view returns(bool) { ( , , uint32 expirationTimestamp, , ) = pool.marketConfigurations(_market); return block.timestamp >= expirationTimestamp; } function _modeExpired(uint8 _modeId) private view returns(bool) { ( , , , uint32 expirationTimestamp ) = pool.modeConfigurations(_modeId); return block.timestamp >= expirationTimestamp; } function _hasActiveBorrows(bytes32 accountId) private view returns(bool) { return (oToken.getAccountBorrowInUnderlying(accountId, 0) + oToken.getAccountBorrowInUnderlying(accountId, 1) + oToken2.getAccountBorrowInUnderlying(accountId, 0) + oToken2.getAccountBorrowInUnderlying(accountId, 1)) > 0; } /* Helper functions to choose between valid entities to interact with */ // function _getMarketExcIsolated(uint8 _market) private view returns (address marketOut) { _market = _clampBetweenU8(_market, 0, 1); if(_market == 0) marketOut = address(oToken); else if(_market == 1) marketOut = address(oToken2); } function _getMarketIncIsolated(uint8 _market) private view returns (address marketOut) { _market = _clampBetweenU8(_market, 0, 3); if(_market == 0) marketOut = address(oToken); else if(_market == 1) marketOut = address(oToken2); else if(_market == 2) marketOut = address(oToken3); else if(_market == 3) marketOut = address(oToken4); } function _getMarketOnlyIsolated(uint8 _market) private view returns (address marketOut) { _market = _clampBetweenU8(_market, 0, 1); if(_market == 0) marketOut = address(oToken3); else if(_market == 1) marketOut = address(oToken4); } function _getActor(uint8 _actor) private pure returns (address actorOut) { _actor = _clampBetweenU8(_actor, 0, 1); if(_actor == 0) actorOut = ALICE; else if(_actor == 1) actorOut = BOB; } function _isIsolatedMarket(address _market) private view returns(bool) { if(_market == address(oToken3) || _market == address(oToken4)) return true; return false; } /* Helper functions for platform-agnostic input restriction */ // function _clampBetweenU256(uint256 value, uint256 low, uint256 high) private pure returns (uint256) { if (value < low || value > high) { return (low + (value % (high - low + 1))); } return value; } function _clampBetweenU96(uint96 value, uint96 low, uint96 high) private pure returns (uint96) { if (value < low || value > high) { return (low + (value % (high - low + 1))); } return value; } function _clampBetweenU8(uint8 value, uint8 low, uint8 high) private pure returns (uint8) { if (value < low || value > high) { return (low + (value % (high - low + 1))); } return value; } } ================================================ FILE: test/08-omni-protocol/OmniAdvancedMedusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "increase timeout to give fuzzer approx 5 minutes", "timeout": 300, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["OmniAdvancedMedusa"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "_COMMENT_TESTING_3": "changed senderAddresses to use custom senders", "senderAddresses": [ "0x1000000000000000000000000000000000000000", "0x2000000000000000000000000000000000000000" ], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "_COMMENT_TESTING_4": "stopOnFailedTest to false as there are multiple invariants to break", "stopOnFailedTest": false, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "_COMMENT_TESTING_5": "changed testAllContracts to false as using handlers", "testAllContracts": false, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix", "testPrefixes": [ "medusa_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/08-omni-protocol/OmniAdvancedMedusa.t.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "../../src/MockERC20.sol"; import "./MockOracle.sol"; import "../../src/08-omni-protocol/IRM.sol"; import "../../src/08-omni-protocol/OmniPool.sol"; import "../../src/08-omni-protocol/OmniToken.sol"; import "../../src/08-omni-protocol/OmniTokenNoBorrow.sol"; import "../../src/08-omni-protocol/interfaces/IOmniToken.sol"; import "../../src/08-omni-protocol/interfaces/IOmniPool.sol"; import "../../src/08-omni-protocol/SubAccount.sol"; // // Medusa & Echidna Fuzzer info: // // Medusa is working best, recommend to use it by compiling from // source @ https://github.com/crytic/medusa/ since the current // official release is missing some fixes. // // configure solc-select to use compiler version: // solc-select use 0.8.23 // // run from base project directory with: // medusa --config test/08-omni-protocol/OmniAdvancedMedusa.json fuzz // echidna --config test/08-omni-protocol/OmniAdvancedEchidna.yaml ./ --contract OmniAdvancedMedusa // // view html coverage reports: // test/08-omni-protocol/coverage-medusa-advanced/coverage_report.html // test/08-omni-protocol/coverage-echidna-advanced/covered.X.html (biggest X = latest run) // // in the reports search for OmniPool & OmniToken // Medusa coverage: OmniPool 78% OmniToken 82% including successful liquidations // Echidna coverage: not as good, liquidation isn't working // // Using Medusa most of the important user functionality inc liquidation gets executed // // 2/16 invariants can be broken; Medusa typically breaks both in 1 run // within 2 minutes and often much faster, while Echidna sometimes breaks 1 invariant // within 5 minutes. Foundry is unable to break any invariants within 5 minutes. // interface IHevm { function prank(address) external; } contract OmniAdvancedMedusa { IHevm hevm = IHevm(address(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D)); using SubAccount for address; // make these constant to match Echidna & Medusa configs address public constant ALICE = address(0x1000000000000000000000000000000000000000); address public constant BOB = address(0x2000000000000000000000000000000000000000); // used for input restriction during fuzzing uint8 public constant MAX_TRANCH_ID = 1; // only 2 tranches uint8 public constant MIN_MODE_ID = 1; uint8 public constant MAX_MODE_ID = 2; uint96 public constant MAX_SUB_ID = 2; // used for price oracle uint8 public constant PRICES_COUNT = 3; // maximum price move % each time for Oracle assets uint8 public constant MIN_PRICE_MOVE = 2; uint8 public constant MAX_PRICE_MOVE = 10; // misc constants uint256 public constant USER_TOKENS = 1_000_000; // multiplied by token decimals uint256 public constant BORROW_CAP = 1_000_000; // multiplied by token decimals OmniPool pool; OmniToken oToken; OmniToken oToken2; OmniTokenNoBorrow oToken3; OmniTokenNoBorrow oToken4; IRM irm; MockERC20 uToken; MockERC20 uToken2; MockERC20 uToken3; MockOracle oracle; // used to update oracle prices address[] underlyings = new address[](PRICES_COUNT); uint256[] prices = new uint256[](PRICES_COUNT); // ghost variables used to verify invariants struct SubAccountGhost { uint8 numEnteredIsolatedMarkets; uint8 numEnteredMarkets; uint8 numEnteredModes; bool enteredIsolatedMarketWithActiveBorrows; bool exitedMarketOrModeWithActiveBorrows; bool enteredModeWithEnteredMarkets; bool enteredExpiredMarketOrMode; bool depositReceivedZeroShares; bool depositReceivedIncorrectAmount; bool withdrawReceivedIncorrectAmount; bool withdrawDecreasedZeroShares; bool repayDidntDecreaseBorrowShares; bool repayIncorrectBorrowAmountDecrease; bool borrowIncorrectBorrowAmountIncrease; bool borrowDidntIncreaseBorrowShares; } mapping(bytes32 accountId => SubAccountGhost) ghost_subAccount; constructor() { // Init contracts oracle = new MockOracle(); irm = new IRM(); irm.initialize(address(this)); pool = new OmniPool(); pool.initialize(address(oracle), address(this), address(this)); uToken = new MockERC20('USD Coin', 'USDC'); uToken2 = new MockERC20('Wrapped Ethereum', 'WETH'); uToken3 = new MockERC20('Shiba Inu', 'SHIB'); // Initial Oracle configs underlyings[0] = address(uToken); prices[0] = 1e18; // USDC underlyings[1] = address(uToken2); prices[1] = 2000e18; // WETH underlyings[2] = address(uToken3); prices[2] = 0.00001e18; // SHIB oracle.setPrices(underlyings, prices); // Configs for oTokens IIRM.IRMConfig[] memory configs = new IIRM.IRMConfig[](MAX_TRANCH_ID+1); configs[0] = IIRM.IRMConfig(0.9e9, 0.01e9, 0.035e9, 0.635e9); configs[1] = IIRM.IRMConfig(0.8e9, 0.03e9, 0.1e9, 1.2e9); IIRM.IRMConfig[] memory configs2 = new IIRM.IRMConfig[](MAX_TRANCH_ID+1); configs2[0] = IIRM.IRMConfig(0.85e9, 0.02e9, 0.055e9, 0.825e9); configs2[1] = IIRM.IRMConfig(0.75e9, 0.04e9, 0.12e9, 1.2e9); uint8[] memory tranches = new uint8[](MAX_TRANCH_ID+1); tranches[0] = 0; tranches[1] = 1; uint256[] memory borrowCaps = new uint256[](MAX_TRANCH_ID+1); borrowCaps[0] = BORROW_CAP * (10 ** uToken.decimals()); borrowCaps[1] = BORROW_CAP * (10 ** uToken.decimals()); // Init oTokens oToken = new OmniToken(); oToken.initialize(address(pool), address(uToken), address(irm), borrowCaps); oToken2 = new OmniToken(); oToken2.initialize(address(pool), address(uToken2), address(irm), borrowCaps); oToken3 = new OmniTokenNoBorrow(); oToken3.initialize(address(pool), address(uToken3), borrowCaps[0]); oToken4 = new OmniTokenNoBorrow(); oToken4.initialize(address(pool), address(uToken3), borrowCaps[0]); irm.setIRMForMarket(address(oToken), tranches, configs); irm.setIRMForMarket(address(oToken2), tranches, configs2); // Set MarketConfigs for Pool // expiration times made lower to trigger more liquidations IOmniPool.MarketConfiguration memory mConfig1 = IOmniPool.MarketConfiguration(0.9e9, 0.9e9, uint32(block.timestamp + 100 days), 0, false); IOmniPool.MarketConfiguration memory mConfig2 = IOmniPool.MarketConfiguration(0.8e9, 0.8e9, uint32(block.timestamp + 100 days), 0, false); IOmniPool.MarketConfiguration memory mConfig3 = IOmniPool.MarketConfiguration(0.4e9, 0, uint32(block.timestamp + 5 days), 1, true); IOmniPool.MarketConfiguration memory mConfig4 = IOmniPool.MarketConfiguration(0.4e9, 0, uint32(block.timestamp + 2 days), 1, true); pool.setMarketConfiguration(address(oToken), mConfig1); pool.setMarketConfiguration(address(oToken2), mConfig2); pool.setMarketConfiguration(address(oToken3), mConfig3); pool.setMarketConfiguration(address(oToken4), mConfig4); // Set ModeConfigs for Pool address[] memory modeMarkets = new address[](2); modeMarkets[0] = address(oToken); modeMarkets[1] = address(oToken2); IOmniPool.ModeConfiguration memory modeStableMode = IOmniPool.ModeConfiguration(0.95e9, 0.95e9, 0, uint32(block.timestamp + 7 days), modeMarkets); pool.setModeConfiguration(modeStableMode); pool.setModeConfiguration(modeStableMode); // mint user tokens uToken.mint(address(ALICE), USER_TOKENS * (10 ** uToken.decimals())); uToken.mint(address(BOB), USER_TOKENS * (10 ** uToken.decimals())); uToken2.mint(address(ALICE), USER_TOKENS * (10 ** uToken2.decimals())); uToken2.mint(address(BOB), USER_TOKENS * (10 ** uToken2.decimals())); uToken3.mint(address(ALICE), USER_TOKENS * (10 ** uToken3.decimals())); uToken3.mint(address(BOB), USER_TOKENS * (10 ** uToken3.decimals())); // setup user token approvals hevm.prank(ALICE); uToken.approve(address(oToken), type(uint256).max); hevm.prank(ALICE); uToken2.approve(address(oToken2), type(uint256).max); hevm.prank(ALICE); uToken3.approve(address(oToken3), type(uint256).max); hevm.prank(ALICE); uToken3.approve(address(oToken4), type(uint256).max); hevm.prank(BOB); uToken.approve(address(oToken), type(uint256).max); hevm.prank(BOB); uToken2.approve(address(oToken2), type(uint256).max); hevm.prank(BOB); uToken3.approve(address(oToken3), type(uint256).max); hevm.prank(BOB); uToken3.approve(address(oToken4), type(uint256).max); } /* DEFINE INVARIANTS HERE */ // // INVARIANT 1) tranche should never reach a state where: // `tranche.totalBorrowShare > 0 && tranche.totalBorrowAmount == 0` or // `tranche.totalDepositShare > 0 && tranche.totalDepositAmount == 0` // // if these states are reached borrows/deposits in that tranche will permanently // be bricked. Either both == 0 or both > 0 function _getTranchBorrowDepositShareIntegrity(address _token, uint8 _tranche) private view returns(bool) { OmniToken.OmniTokenTranche memory trancheData = _getOmniTokenTranche(_token, _tranche); return ((trancheData.totalBorrowShare == 0 && trancheData.totalBorrowAmount == 0) || (trancheData.totalBorrowShare > 0 && trancheData.totalBorrowAmount > 0)) && ((trancheData.totalDepositShare == 0 && trancheData.totalDepositAmount == 0) || (trancheData.totalDepositShare > 0 && trancheData.totalDepositAmount > 0)); } function medusa_tranche_borrow_deposit_shares_integrity() public view returns(bool) { return _getTranchBorrowDepositShareIntegrity(address(oToken), 0) && _getTranchBorrowDepositShareIntegrity(address(oToken), 1) && _getTranchBorrowDepositShareIntegrity(address(oToken2), 0) && _getTranchBorrowDepositShareIntegrity(address(oToken2), 1); } // INVARIANT 2) each subaccount may only enter max 1 isolated market at the same time function _inMoreThanOneIsolatedMarket(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].numEnteredIsolatedMarkets >= 2) return true; } return false; } function medusa_subaccount_one_isolated_market() public view returns(bool) { return !_inMoreThanOneIsolatedMarket(ALICE) && !_inMoreThanOneIsolatedMarket(BOB); } // INVARIANT 3) each subaccount many only enter max 1 mode at the same time function _inMoreThanOneMode(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].numEnteredModes >= 2) return true; } return false; } function medusa_subaccount_one_mode() public view returns(bool) { return !_inMoreThanOneMode(ALICE) && !_inMoreThanOneMode(BOB); } // INVARIANT 4) subaccount can't enter isolated collateral market with active borrows function _hasEnteredIsolatedMarketWithActiveBorrows(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].enteredIsolatedMarketWithActiveBorrows) return true; } return false; } function medusa_cant_enter_isolated_market_with_active_borrows() public view returns(bool) { return !_hasEnteredIsolatedMarketWithActiveBorrows(ALICE) && !_hasEnteredIsolatedMarketWithActiveBorrows(BOB); } // INVARIANT 5) subaccount can't exit market or mode with active borrows function _hasExitedMarketOrModeWithActiveBorrows(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].exitedMarketOrModeWithActiveBorrows) return true; } return false; } function medusa_cant_exit_market_or_mode_with_active_borrows() public view returns(bool) { return !_hasExitedMarketOrModeWithActiveBorrows(ALICE) && !_hasExitedMarketOrModeWithActiveBorrows(BOB); } // INVARIANT 6) subaccount can't enter a mode when it has already entered a market function _hasEnteredModeWithEnteredMarkets(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].enteredModeWithEnteredMarkets) return true; } return false; } function medusa_cant_enter_mode_with_entered_markets() public view returns(bool) { return !_hasEnteredModeWithEnteredMarkets(ALICE) && !_hasEnteredModeWithEnteredMarkets(BOB); } // INVARIANT 7) subaccount can't enter an expired market or mode function _hasEnteredExpiredMarketOrMode(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].enteredExpiredMarketOrMode) return true; } return false; } function medusa_cant_enter_expired_market_or_mode() public view returns(bool) { return !_hasEnteredExpiredMarketOrMode(ALICE) && !_hasEnteredExpiredMarketOrMode(BOB); } // INVARIANT 8) subaccount must have entered market/mode to take a loan function _hasLoanWithoutEnteringMarketOrMode(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(_hasActiveBorrows(accountId) && ghost_subAccount[accountId].numEnteredModes == 0 && ghost_subAccount[accountId].numEnteredMarkets == 0) return true; } return false; } function medusa_cant_borrow_without_entering_market_or_mode() public view returns(bool) { return !_hasLoanWithoutEnteringMarketOrMode(ALICE) && !_hasLoanWithoutEnteringMarketOrMode(BOB); } // INVARIANT 9) subaccount should receive shares when making a deposit // note: this invariant is currently failing during some runs when the fuzzer // does small deposits due to a rounding-down-to-zero precision loss at // https://github.com/beta-finance/Omni-Protocol/blob/main/src/OmniToken.sol#L172 function _hasDepositWhichReceivedZeroShares(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].depositReceivedZeroShares) return true; } return false; } function medusa_deposit_receives_shares() public view returns(bool) { return !_hasDepositWhichReceivedZeroShares(ALICE) && !_hasDepositWhichReceivedZeroShares(BOB); } // INVARIANT 10) subaccount should receive amount when making a deposit function _hasDepositWhichReceivedIncorrectAmount(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].depositReceivedIncorrectAmount) return true; } return false; } function medusa_deposit_receives_correct_amount() public view returns(bool) { return !_hasDepositWhichReceivedIncorrectAmount(ALICE) && !_hasDepositWhichReceivedIncorrectAmount(BOB); } // INVARIANT 11) subaccount should have shares decreased when withdrawing function _hasWithdrawWhichDecreasedZeroShares(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].withdrawDecreasedZeroShares) return true; } return false; } function medusa_withdraw_decreases_shares() public view returns(bool) { return !_hasWithdrawWhichDecreasedZeroShares(ALICE) && !_hasWithdrawWhichDecreasedZeroShares(BOB); } // INVARIANT 12) subaccount should receive correct amount when withdrawing function _hasWithdrawWhichReceivedIncorrectAmount(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].withdrawReceivedIncorrectAmount) return true; } return false; } function medusa_withdraw_receives_correct_amount() public view returns(bool) { return !_hasWithdrawWhichReceivedIncorrectAmount(ALICE) && !_hasWithdrawWhichReceivedIncorrectAmount(BOB); } // INVARIANT 13) repay should decrease borrow shares // note: this invariant is currently failing during some runs when the fuzzer // does small repayments due to a rounding-down-to-zero precision loss at // https://github.com/beta-finance/Omni-Protocol/blob/main/src/OmniToken.sol#L265 function _hasRepayWhichDidntDecreaseBorrowShares(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].repayDidntDecreaseBorrowShares) return true; } return false; } function medusa_repay_decreases_borrow_shares() public view returns(bool) { return !_hasRepayWhichDidntDecreaseBorrowShares(ALICE) && !_hasRepayWhichDidntDecreaseBorrowShares(BOB); } // INVARIANT 14) repay should decrease borrow amount by correct amount function _hasRepayWhichIncorrectlyDecreasedBorrowAmount(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].repayIncorrectBorrowAmountDecrease) return true; } return false; } function medusa_repay_correctly_decreases_borrow_amount() public view returns(bool) { return !_hasRepayWhichIncorrectlyDecreasedBorrowAmount(ALICE) && !_hasRepayWhichIncorrectlyDecreasedBorrowAmount(BOB); } // INVARIANT 15) borrow should increase borrow shares function _hasBorrowWhichDidntIncreaseBorrowShares(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].borrowDidntIncreaseBorrowShares) return true; } return false; } function medusa_borrow_increases_borrow_shares() public view returns(bool) { return !_hasBorrowWhichDidntIncreaseBorrowShares(ALICE) && !_hasBorrowWhichDidntIncreaseBorrowShares(BOB); } // INVARIANT 16) borrow should increase borrow amount by correct amount function _hasBorrowWhichIncorrectlyIncreasedBorrowAmount(address account) private view returns(bool) { for(uint96 subId; subId<=MAX_SUB_ID; ++subId) { bytes32 accountId = account.toAccount(subId); if(ghost_subAccount[accountId].borrowIncorrectBorrowAmountIncrease) return true; } return false; } function medusa_borrow_correctly_increases_borrow_amount() public view returns(bool) { return !_hasRepayWhichIncorrectlyDecreasedBorrowAmount(ALICE) && !_hasRepayWhichIncorrectlyDecreasedBorrowAmount(BOB); } /* OmniPool HANDLER FUNCTIONS */ // // Handlers use input filtering to reduce but *not* to completely // eliminate invalid runs; there is still an element of randomness // where some inputs will be invalid function enterIsolatedMarket(uint96 _subId, uint8 _market) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketIncIsolated(_market); hevm.prank(msg.sender); pool.enterIsolatedMarket(_subId, market); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredMarkets++; if(_isIsolatedMarket(market)) { ghost_subAccount[accountId].numEnteredIsolatedMarkets++; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].enteredIsolatedMarketWithActiveBorrows = true; } } if(_marketExpired(market)) { ghost_subAccount[accountId].enteredExpiredMarketOrMode = true; } } function enterMarkets(uint96 _subId, uint8 _market) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketIncIsolated(_market); address[] memory markets = new address[](1); markets[0] = market; hevm.prank(msg.sender); pool.enterMarkets(_subId, markets); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredMarkets++; if(_isIsolatedMarket(market)) { ghost_subAccount[accountId].numEnteredIsolatedMarkets++; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].enteredIsolatedMarketWithActiveBorrows = true; } } if(_marketExpired(market)) { ghost_subAccount[accountId].enteredExpiredMarketOrMode = true; } } function exitMarket(uint96 _subId, uint8 _market) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketIncIsolated(_market); hevm.prank(msg.sender); pool.exitMarket(_subId, market); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredMarkets--; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].exitedMarketOrModeWithActiveBorrows = true; } if(_isIsolatedMarket(market)) { ghost_subAccount[accountId].numEnteredIsolatedMarkets--; } } function clearMarkets(uint96 _subId) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); hevm.prank(msg.sender); pool.clearMarkets(_subId); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredMarkets = 0; ghost_subAccount[accountId].numEnteredIsolatedMarkets = 0; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].exitedMarketOrModeWithActiveBorrows = true; } } function enterMode(uint96 _subId, uint8 _modeId) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); _modeId = _clampBetweenU8(_modeId, MIN_MODE_ID, MAX_MODE_ID); hevm.prank(msg.sender); pool.enterMode(_subId, _modeId); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredModes++; if(ghost_subAccount[accountId].numEnteredMarkets > 0) { ghost_subAccount[accountId].enteredModeWithEnteredMarkets = true; } if(_modeExpired(_modeId)) { ghost_subAccount[accountId].enteredExpiredMarketOrMode = true; } } function exitMode(uint96 _subId) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); hevm.prank(msg.sender); pool.exitMode(_subId); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); ghost_subAccount[accountId].numEnteredModes--; if(_hasActiveBorrows(accountId)) { ghost_subAccount[accountId].exitedMarketOrModeWithActiveBorrows = true; } } function borrow(uint96 _subId, uint8 _market, uint256 _amount) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketExcIsolated(_market); // save borrow amount & shares before calling borrow, used in invariant checks OmniToken token = OmniToken(market); // accrue() first so it cant change storage during the next txn token.accrue(); bytes32 accountId = msg.sender.toAccount(_subId); uint8 trancheId = pool.getAccountBorrowTier(_getAccountInfo(accountId)); ( , uint256 totalBorrowAmountPrev, , uint256 totalBorrowSharePrev) = token.tranches(trancheId); hevm.prank(msg.sender); pool.borrow(_subId, market, _amount); ( , uint256 totalBorrowAmountAfter, , uint256 totalBorrowShareAfter) = token.tranches(trancheId); // update ghost variables uint256 borrowIncrease = totalBorrowAmountAfter - totalBorrowAmountPrev; if(_amount > 0) { if(borrowIncrease != _amount) { ghost_subAccount[accountId].borrowIncorrectBorrowAmountIncrease = true; } if(totalBorrowShareAfter == totalBorrowSharePrev) { ghost_subAccount[accountId].borrowDidntIncreaseBorrowShares = true; } } } function repay(uint96 _subId, uint8 _market, uint256 _amount) public { _subId = _clampBetweenU96(_subId, 0, MAX_SUB_ID); address market = _getMarketExcIsolated(_market); // save borrow amount & shares before calling repay, used in invariant checks OmniToken token = OmniToken(market); // accrue() first so it cant change storage during the next txn token.accrue(); bytes32 accountId = msg.sender.toAccount(_subId); uint8 trancheId = pool.getAccountBorrowTier(_getAccountInfo(accountId)); ( , uint256 totalBorrowAmountPrev, , uint256 totalBorrowSharePrev) = token.tranches(trancheId); hevm.prank(msg.sender); pool.repay(_subId, market, _amount); ( , uint256 totalBorrowAmountAfter, , uint256 totalBorrowShareAfter) = token.tranches(trancheId); // update ghost variables uint256 borrowReduction = totalBorrowAmountPrev-totalBorrowAmountAfter; if(_amount > 0) { if(borrowReduction != _amount) { ghost_subAccount[accountId].repayIncorrectBorrowAmountDecrease = true; } if(totalBorrowShareAfter == totalBorrowSharePrev) { ghost_subAccount[accountId].repayDidntDecreaseBorrowShares = true; } } } function liquidate(uint96 _targetSubId, uint96 _liquidatorSubId, uint8 _targetAccount, uint8 _liquidateMarket, uint8 _collateralMarket, uint256 _amount, bool giveTokens) public { _targetSubId = _clampBetweenU96(_targetSubId, 0, MAX_SUB_ID); _liquidatorSubId = _clampBetweenU96(_liquidatorSubId, 0, MAX_SUB_ID); address liqMarket = _getMarketExcIsolated(_liquidateMarket); address colMarket = _getMarketIncIsolated(_collateralMarket); bytes32 targetAccountId = (_getActor(_targetAccount)).toAccount(_targetSubId); bytes32 liqAccountId = msg.sender.toAccount(_liquidatorSubId); // introduce some randomness into whether the test ensures account // has sufficent tokens to liquidate or not. This allows some invalid runs through // where account won't have enough tokens to liquidate but also helps ensure // there will be some valid liquidations if(giveTokens) { (MockERC20((OmniToken(liqMarket)).underlying())).mint(msg.sender, _amount); } hevm.prank(msg.sender); pool.liquidate( IOmniPool.LiquidationParams(targetAccountId, liqAccountId, liqMarket, colMarket, _amount)); // no prank here, has to be called by admin. If it fails don't worry, just // trying to call it after liquidation to get some more coverage if liquidation // totally liquidates a user. Not fully working yet try pool.socializeLoss(liqMarket, targetAccountId) {} catch {} } /* OmniToken HANDLER FUNCTIONS */ // function deposit(uint96 _subId, uint8 _trancheId, uint256 _amount, uint8 _token, bool giveTokens) public { _subId = _clampBetweenU96(_subId , 0, MAX_SUB_ID); _trancheId = _clampBetweenU8(_trancheId, 0, MAX_TRANCH_ID); OmniToken token = OmniToken(_getMarketIncIsolated(_token)); // introduce some randomness into whether the test ensures account // has sufficent tokens to deposit or not. This allows some invalid // runs through where account won't have enough tokens to deposit. // Accounts can also have their tokens replenished this way if(giveTokens) { (MockERC20(token.underlying())).mint(msg.sender, _amount); } // accrue() first so it cant change storage during the next txn token.accrue(); // save deposit amount & shares before calling deposit, used in invariant checks (uint256 totalDepositAmountPrev, , uint256 totalDepositSharePrev, ) = token.tranches(_trancheId); hevm.prank(msg.sender); token.deposit(_subId, _trancheId, _amount); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); (uint256 totalDepositAmountAfter, , uint256 totalDepositShareAfter, ) = token.tranches(_trancheId); if(_amount > 0 && totalDepositShareAfter == totalDepositSharePrev) { ghost_subAccount[accountId].depositReceivedZeroShares = true; } if(totalDepositAmountAfter-totalDepositAmountPrev != _amount) { ghost_subAccount[accountId].depositReceivedIncorrectAmount = true; } } function withdraw(uint96 _subId, uint8 _trancheId, uint256 _share, uint8 _token) public { _subId = _clampBetweenU96(_subId , 0, MAX_SUB_ID); _trancheId = _clampBetweenU8(_trancheId, 0, MAX_TRANCH_ID); OmniToken token = OmniToken(_getMarketIncIsolated(_token)); // accrue() first so it cant change storage during the next txn token.accrue(); // save deposit amount & shares before calling withdraw, used in invariant checks (uint256 totalDepositAmountPrev, , uint256 totalDepositSharePrev, ) = token.tranches(_trancheId); hevm.prank(msg.sender); uint256 amount = token.withdraw(_subId, _trancheId, _share); // update ghost variables bytes32 accountId = msg.sender.toAccount(_subId); (uint256 totalDepositAmountAfter, , uint256 totalDepositShareAfter, ) = token.tranches(_trancheId); uint256 actualDifference = totalDepositAmountPrev-totalDepositAmountAfter; if(_share > 0 && (actualDifference == 0 || actualDifference != amount)) { ghost_subAccount[accountId].withdrawReceivedIncorrectAmount = true; } if(_share > 0 && totalDepositShareAfter == totalDepositSharePrev) { ghost_subAccount[accountId].withdrawDecreasedZeroShares = true; } } function transfer(uint96 _subId, bytes32 _to, uint8 _trancheId, uint256 _shares, uint8 _token) public { _subId = _clampBetweenU96(_subId , 0, MAX_SUB_ID); _trancheId = _clampBetweenU8(_trancheId, 0, MAX_TRANCH_ID); IOmniToken token = IOmniToken(_getMarketIncIsolated(_token)); hevm.prank(msg.sender); token.transfer(_subId, _to, _trancheId, _shares); } /* Price Oracle UTILITY FUNCTION */ // // function which changes oracle pricing of underlying tokens // will be called randomly by fuzzer. This enables positions to become // subject to liquidation enabling greater coverage function updateOraclePrice(uint8 _priceIndex, uint8 _percentMove, bool _increasePrice) public { _priceIndex = _clampBetweenU8(_priceIndex, 0, PRICES_COUNT-1); // price can move in a set % range _percentMove = _clampBetweenU8(_percentMove, MIN_PRICE_MOVE, MAX_PRICE_MOVE); // calculate price delta uint256 priceDelta = prices[_priceIndex] * _percentMove / 100; // apply direction if(_increasePrice) prices[_priceIndex] += priceDelta; else prices[_priceIndex] -= priceDelta; // save new pricing oracle.setPrices(underlyings, prices); } /* Helper functions to fetch data used in invariant checks */ // function _getOmniTokenTranche(address _market, uint8 _tranche) private view returns (OmniToken.OmniTokenTranche memory) { (uint256 totalDeposit, uint256 totalBorrow, uint256 totalDepositShares, uint256 totalBorrowShares) = OmniToken(_market).tranches(_tranche); return OmniToken.OmniTokenTranche(totalDeposit, totalBorrow, totalDepositShares, totalBorrowShares); } function _getAccountInfo(bytes32 account) internal view returns (IOmniPool.AccountInfo memory) { (uint8 modeId, address isolatedCollateralMarket, uint32 softThreshold) = pool.accountInfos(account); return IOmniPool.AccountInfo(modeId, isolatedCollateralMarket, softThreshold); } function _marketExpired(address _market) private view returns(bool) { ( , , uint32 expirationTimestamp, , ) = pool.marketConfigurations(_market); return block.timestamp >= expirationTimestamp; } function _modeExpired(uint8 _modeId) private view returns(bool) { ( , , , uint32 expirationTimestamp ) = pool.modeConfigurations(_modeId); return block.timestamp >= expirationTimestamp; } function _hasActiveBorrows(bytes32 accountId) private view returns(bool) { return (oToken.getAccountBorrowInUnderlying(accountId, 0) + oToken.getAccountBorrowInUnderlying(accountId, 1) + oToken2.getAccountBorrowInUnderlying(accountId, 0) + oToken2.getAccountBorrowInUnderlying(accountId, 1)) > 0; } /* Helper functions to choose between valid entities to interact with */ // function _getMarketExcIsolated(uint8 _market) private view returns (address marketOut) { _market = _clampBetweenU8(_market, 0, 1); if(_market == 0) marketOut = address(oToken); else if(_market == 1) marketOut = address(oToken2); } function _getMarketIncIsolated(uint8 _market) private view returns (address marketOut) { _market = _clampBetweenU8(_market, 0, 3); if(_market == 0) marketOut = address(oToken); else if(_market == 1) marketOut = address(oToken2); else if(_market == 2) marketOut = address(oToken3); else if(_market == 3) marketOut = address(oToken4); } function _getMarketOnlyIsolated(uint8 _market) private view returns (address marketOut) { _market = _clampBetweenU8(_market, 0, 1); if(_market == 0) marketOut = address(oToken3); else if(_market == 1) marketOut = address(oToken4); } function _getActor(uint8 _actor) private pure returns (address actorOut) { _actor = _clampBetweenU8(_actor, 0, 1); if(_actor == 0) actorOut = ALICE; else if(_actor == 1) actorOut = BOB; } function _isIsolatedMarket(address _market) private view returns(bool) { if(_market == address(oToken3) || _market == address(oToken4)) return true; return false; } /* Helper functions for platform-agnostic input restriction */ // function _clampBetweenU256(uint256 value, uint256 low, uint256 high) private pure returns (uint256) { if (value < low || value > high) { return (low + (value % (high - low + 1))); } return value; } function _clampBetweenU96(uint96 value, uint96 low, uint96 high) private pure returns (uint96) { if (value < low || value > high) { return (low + (value % (high - low + 1))); } return value; } function _clampBetweenU8(uint8 value, uint8 low, uint8 high) private pure returns (uint8) { if (value < low || value > high) { return (low + (value % (high - low + 1))); } return value; } } ================================================ FILE: test/09-vesting/Properties.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { Setup } from "./Setup.sol"; import { Asserts } from "@chimera/Asserts.sol"; abstract contract Properties is Setup, Asserts { function property_users_points_sum_eq_total_points() public view returns(bool result) { uint24 totalPoints; // sum up all user points for(uint256 i; i 0; // user performs any arbitrary successful transaction f() env e; require e.msg.sender == user; method f; calldataarg args; f(e, args); // verify that no transaction exists which allows user to // increase their allocated points assert userPointsPre >= currentContract.allocations[user].points; } // the same property can also be expressed in another way: // that the sum of users' individual points should always remain equal to TOTAL_POINTS // solution provided by https://x.com/alexzoid_eth methods { function TOTAL_POINTS_PCT() external returns uint24 envfree => ALWAYS(100000); } // tracks the address of user whose points have increased ghost address targetUser; // ghost mapping to track points for each user address ghost mapping (address => mathint) ghostPoints { axiom forall address user. ghostPoints[user] >= 0 && ghostPoints[user] <= max_uint24; } // hook to verify storage reads match ghost state hook Sload uint24 val allocations[KEY address user].points { require(require_uint24(ghostPoints[user]) == val); } // hook to update ghost state on storage writes // also tracks first user to receive a points increase hook Sstore allocations[KEY address user].points uint24 val { // Update targetUser only if not set and points are increasing targetUser = (targetUser == 0 && val > ghostPoints[user]) ? user : targetUser; ghostPoints[user] = val; } function initialize_constructor(address user1, address user2, address user3) { // Only user1, user2, and user3 can have non-zero points require(forall address user. user != user1 && user != user2 && user != user3 => ghostPoints[user] == 0); // Sum of their points must equal total allocation (100%) require(ghostPoints[user1] + ghostPoints[user2] + ghostPoints[user3] == TOTAL_POINTS_PCT()); } function initialize_env(env e) { // Ensure message sender is a valid address require(e.msg.sender != 0); } function initialize_users(address user1, address user2, address user3) { // Validate user addresses: // - Must be non-zero addresses require(user1 != 0 && user2 != 0 && user3 != 0); // - Must be unique addresses require(user1 != user2 && user1 != user3 && user2 != user3); // Initialize targetUser to zero address require(targetUser == 0); } // Verify that total points always equal TOTAL_POINTS_PCT (100%) rule users_points_sum_eq_total_points(env e, address user1, address user2, address user3) { // Set up initial state initialize_constructor(user1, user2, user3); initialize_env(e); initialize_users(user1, user2, user3); // Execute any method with any arguments method f; calldataarg args; f(e, args); // Calculate points for targetUser if it's not one of the initial users mathint targetUserPoints = targetUser != user1 && targetUser != user2 && targetUser != user3 ? ghostPoints[targetUser] : 0; // Assert total points remain constant at 100% assert(ghostPoints[user1] + ghostPoints[user2] + ghostPoints[user3] + targetUserPoints == TOTAL_POINTS_PCT()); // All other addresses must have zero points assert(forall address user. user != user1 && user != user2 && user != user3 && user != targetUser => ghostPoints[user] == 0 ); } ================================================ FILE: test/09-vesting/echidna.yaml ================================================ # don't allow fuzzer to use all functions # since we are using handlers allContracts: false # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/09-vesting/coverage-echidna" # prefix of invariant function prefix: "property_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/09-vesting/medusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 10, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["VestingCryticTester"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "senderAddresses": [ "0x10000", "0x20000", "0x30000" ], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "stopOnFailedTest": true, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "testAllContracts": false, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to match invariant function", "testPrefixes": [ "property_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/10-vesting-ext/Properties.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { Setup } from "./Setup.sol"; import { Asserts } from "@chimera/Asserts.sol"; abstract contract Properties is Setup, Asserts { function property_users_points_sum_eq_total_points() public view returns(bool result) { uint24 totalPoints; // sum up all user points for(uint256 i; i 0) { address[] memory values = foundAddresses.values(); for(uint256 i; i 0) { // operator ids start at 1 for(uint128 operatorId = 1; operatorId <= numOperators; operatorId++) { if(!foundAddresses.add(operatorRegistry.operatorIdToAddress(operatorId))) { return false; } } } result = true; } } ================================================ FILE: test/11-op-reg/Setup.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { OperatorRegistry } from "../../src/11-op-reg/OperatorRegistry.sol"; import { BaseSetup } from "@chimera/BaseSetup.sol"; abstract contract Setup is BaseSetup { // contract being tested OperatorRegistry operatorRegistry; // ghost variables address[] addressPool; uint8 internal ADDRESS_POOL_LENGTH; function setup() internal virtual override { addressPool.push(address(0x1111)); addressPool.push(address(0x2222)); addressPool.push(address(0x3333)); addressPool.push(address(0x4444)); addressPool.push(address(0x5555)); addressPool.push(address(0x6666)); addressPool.push(address(0x7777)); addressPool.push(address(0x8888)); addressPool.push(address(0x9999)); ADDRESS_POOL_LENGTH = uint8(addressPool.length); operatorRegistry = new OperatorRegistry(); } } ================================================ FILE: test/11-op-reg/TargetFunctions.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { Properties } from "./Properties.sol"; import { BaseTargetFunctions } from "@chimera/BaseTargetFunctions.sol"; import { IHevm, vm } from "@chimera/Hevm.sol"; abstract contract TargetFunctions is BaseTargetFunctions, Properties { // gets a random non-zero address from `Setup::addressPool` function _getRandomAddress(uint8 index) internal returns(address addr) { index = uint8(between(index, 0, ADDRESS_POOL_LENGTH - 1)); addr = addressPool[index]; } function handler_register(uint8 callerIndex) external { address caller = _getRandomAddress(callerIndex); vm.prank(caller); operatorRegistry.register(); } function handler_updateAddress(uint8 callerIndex, uint8 updateIndex) external { address caller = _getRandomAddress(callerIndex); address update = _getRandomAddress(updateIndex); vm.prank(caller); operatorRegistry.updateAddress(update); } } ================================================ FILE: test/11-op-reg/certora.conf ================================================ { "files": [ "src/11-op-reg/OperatorRegistry.sol" ], "verify": "OperatorRegistry:test/11-op-reg/certora.spec" } ================================================ FILE: test/11-op-reg/certora.spec ================================================ // run from base folder: // certoraRun test/11-op-reg/certora.conf // given two registered operators, there should be no f() that could // corrupt the unique relationship between operator_id : operator_address rule operator_addresses_have_unique_ids(address opAddr1, address opAddr2) { // enforce unique addresses in `operatorAddressToId` mapping require opAddr1 != opAddr2; uint128 op1AddrToId = currentContract.operatorAddressToId[opAddr1]; uint128 op2AddrToId = currentContract.operatorAddressToId[opAddr2]; // enforce valid and unique operator_ids in `operatorAddressToId` mapping require op1AddrToId != op2AddrToId && op1AddrToId > 0 && op2AddrToId > 0; // enforce matching addresses in `operatorIdToAddress` mapping require currentContract.operatorIdToAddress[op1AddrToId] == opAddr1 && currentContract.operatorIdToAddress[op2AddrToId] == opAddr2; // perform any arbitrary successful transaction f() env e; method f; calldataarg args; f(e, args); // verify that no transaction exists which corrupts the uniqueness // property between operator_id : operator_address assert currentContract.operatorIdToAddress[op1AddrToId] != currentContract.operatorIdToAddress[op2AddrToId]; } ================================================ FILE: test/11-op-reg/echidna.yaml ================================================ # don't allow fuzzer to use all functions # since we are using handlers allContracts: false # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/11-op-reg/coverage-echidna" # prefix of invariant function prefix: "property_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/11-op-reg/medusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 10, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["OpRegCryticTester"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "senderAddresses": [ "0x10000", "0x20000", "0x30000" ], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "stopOnFailedTest": true, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "testAllContracts": false, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": true, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to match invariant function", "testPrefixes": [ "property_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/12-liquidate-dos/LiquidateDosCryticTester.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { TargetFunctions } from "./TargetFunctions.sol"; import { CryticAsserts } from "@chimera/CryticAsserts.sol"; // configure solc-select to use compiler version: // solc-select install 0.8.23 // solc-select use 0.8.23 // // run from base project directory with: // echidna . --contract LiquidateDosCryticTester --config test/12-liquidate-dos/echidna.yaml // medusa --config test/12-liquidate-dos/medusa.json fuzz contract LiquidateDosCryticTester is TargetFunctions, CryticAsserts { constructor() payable { setup(); } } ================================================ FILE: test/12-liquidate-dos/LiquidateDosCryticToFoundry.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { TargetFunctions } from "./TargetFunctions.sol"; import { FoundryAsserts } from "@chimera/FoundryAsserts.sol"; import { Test } from "forge-std/Test.sol"; // run from base project directory with: // forge test --match-contract LiquidateDosCryticToFoundry // (if an invariant fails add -vvvvv on the end to see what failed) // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // // 1) forge coverage --report lcov --report-file test/12-liquidate-dos/coverage-foundry.lcov --match-contract LiquidateDosCryticToFoundry // 2) genhtml test/12-liquidate-dos/coverage-foundry.lcov -o test/12-liquidate-dos/coverage-foundry // 3) open test/12-liquidate-dos/coverage-foundry/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records contract LiquidateDosCryticToFoundry is Test, TargetFunctions, FoundryAsserts { function setUp() public { setup(); // Foundry doesn't use config files but does // the setup programmatically here // target the fuzzer on this contract as it will // contain the handler functions targetContract(address(this)); // handler functions to target during invariant tests bytes4[] memory selectors = new bytes4[](3); selectors[0] = this.handler_openPosition.selector; selectors[1] = this.handler_toggleLiquidations.selector; selectors[2] = this.handler_liquidate.selector; targetSelector(FuzzSelector({ addr: address(this), selectors: selectors })); } function invariant_user_active_markets_correct() public { t(property_user_active_markets_correct(), "User active markets correct"); } function invariant_property_liquidate_no_unexpected_error() public { t(property_liquidate_no_unexpected_error(), "Liquidate failed with unexpected error"); } } ================================================ FILE: test/12-liquidate-dos/Properties.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { Setup } from "./Setup.sol"; import { Asserts } from "@chimera/Asserts.sol"; abstract contract Properties is Setup, Asserts { function property_user_active_markets_correct() public view returns(bool result) { // for each possible user for(uint8 i; i uint8 activeMarketCount) userActiveMarketsCount; mapping(address user => mapping(uint8 marketId => bool userInMarket)) userActiveMarkets; // track unexpected errors bool liquidateUnexpectedError; function setup() internal virtual override { addressPool.push(address(0x1111)); addressPool.push(address(0x2222)); addressPool.push(address(0x3333)); addressPool.push(address(0x4444)); addressPool.push(address(0x5555)); addressPool.push(address(0x6666)); addressPool.push(address(0x7777)); addressPool.push(address(0x8888)); addressPool.push(address(0x9999)); ADDRESS_POOL_LENGTH = uint8(addressPool.length); liquidateDos = new LiquidateDos(); } } ================================================ FILE: test/12-liquidate-dos/TargetFunctions.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { ILiquidateDos } from "../../src/12-liquidate-dos/LiquidateDos.sol"; import { Properties } from "./Properties.sol"; import { BaseTargetFunctions } from "@chimera/BaseTargetFunctions.sol"; import { IHevm, vm } from "@chimera/Hevm.sol"; abstract contract TargetFunctions is BaseTargetFunctions, Properties { // gets a random non-zero address from `Setup::addressPool` function _getRandomAddress(uint8 index) internal returns(address addr) { index = uint8(between(index, 0, ADDRESS_POOL_LENGTH - 1)); addr = addressPool[index]; } function handler_openPosition(uint8 callerIndex, uint8 marketId) external { address caller = _getRandomAddress(callerIndex); vm.prank(caller); liquidateDos.openPosition(marketId); // update ghost variables ++userActiveMarketsCount[caller]; userActiveMarkets[caller][marketId] = true; } function handler_toggleLiquidations(bool toggle) external { liquidateDos.toggleLiquidations(toggle); } function handler_liquidate(uint8 victimIndex) external { address victim = _getRandomAddress(victimIndex); try liquidateDos.liquidate(victim) { // update ghost variables delete userActiveMarketsCount[victim]; for(uint8 marketId = liquidateDos.MIN_MARKET_ID(); marketId <= liquidateDos.MAX_MARKET_ID(); marketId++) { delete userActiveMarkets[victim][marketId]; } } catch(bytes memory err) { bytes4[] memory allowedErrors = new bytes4[](2); allowedErrors[0] = ILiquidateDos.LiquidationsDisabled.selector; allowedErrors[1] = ILiquidateDos.LiquidateUserNotInAnyMarkets.selector; if(_isUnexpectedError(bytes4(err), allowedErrors)) { liquidateUnexpectedError = true; } } } // returns whether error was unexpected function _isUnexpectedError( bytes4 errorSelector, bytes4[] memory allowedErrors ) internal pure returns(bool isUnexpectedError) { for (uint256 i; i < allowedErrors.length; i++) { if (errorSelector == allowedErrors[i]) { return false; } } isUnexpectedError = true; } } ================================================ FILE: test/12-liquidate-dos/echidna.yaml ================================================ # don't allow fuzzer to use all functions # since we are using handlers allContracts: false # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/12-liquidate-dos/coverage-echidna" # prefix of invariant function prefix: "property_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/12-liquidate-dos/medusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 30, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["LiquidateDosCryticTester"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "senderAddresses": [ "0x10000", "0x20000", "0x30000" ], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "stopOnFailedTest": true, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "testAllContracts": false, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": false, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to match invariant function", "testPrefixes": [ "property_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/13-stability-pool/Properties.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { Setup } from "./Setup.sol"; import { Asserts } from "@chimera/Asserts.sol"; abstract contract Properties is Setup, Asserts { function property_stability_pool_solvent() public view returns(bool result) { uint256 totalClaimableRewards; // sum total claimable rewards for each possible user for(uint8 i; i= 1000000000000000000 && user2DebtTokens >= 1000000000000000000 && user1DebtTokens == user2DebtTokens; // both users deposit their debt tokens into the stability pool env e1; require e1.msg.sender == spDep1; provideToSP(e1, user1DebtTokens); env e2; require e2.msg.sender == spDep2; provideToSP(e2, user2DebtTokens); // stability pool is used to offset debt from a liquidation uint256 debtTokensToOffset = require_uint256(user1DebtTokens + user2DebtTokens); uint256 seizedCollateral = debtTokensToOffset; // 1:1 env e3; registerLiquidation(e3, debtTokensToOffset, seizedCollateral); require(currentContract.collateralToken.balanceOf(e, currentContract) == seizedCollateral); // enforce each user is owed same reward since they deposited the same uint256 rewardPerUser = getDepositorCollateralGain(spDep1); require rewardPerUser > 0; require rewardPerUser == getDepositorCollateralGain(spDep2); // enforce contract has enough reward tokens to pay both users require(currentContract.collateralToken.balanceOf(e, currentContract) >= require_uint256(rewardPerUser * 2)); // first user withdraws their reward env e4; require e4.msg.sender == spDep1; claimCollateralGains(e4); // enforce contract has enough reward tokens to pay second user require(currentContract.collateralToken.balanceOf(e, currentContract) >= rewardPerUser); // first user perform any arbitrary successful transaction f() env e5; require e5.msg.sender == spDep1; method f; calldataarg args; f(e5, args); // second user withdraws their reward env e6; require e6.msg.sender == spDep2 && e6.msg.value == 0; claimCollateralGains@withrevert(e6); // verify first user was not able to do anything that would make // second user's withdrawal revert assert !lastReverted; } ================================================ FILE: test/13-stability-pool/echidna.yaml ================================================ # don't allow fuzzer to use all functions # since we are using handlers allContracts: false # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/13-stability-pool/coverage-echidna" # prefix of invariant function prefix: "property_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/13-stability-pool/medusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 30, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["StabilityPoolCryticTester"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "senderAddresses": [ "0x10000", "0x20000", "0x30000" ], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "stopOnFailedTest": true, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "testAllContracts": false, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": false, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to match invariant function", "testPrefixes": [ "property_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/14-priority/PriorityCryticTester.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { TargetFunctions } from "./TargetFunctions.sol"; import { CryticAsserts } from "@chimera/CryticAsserts.sol"; // configure solc-select to use compiler version: // solc-select install 0.8.23 // solc-select use 0.8.23 // // run from base project directory with: // echidna . --contract PriorityCryticTester --config test/14-priority/echidna.yaml // medusa --config test/14-priority/medusa.json fuzz contract PriorityCryticTester is TargetFunctions, CryticAsserts { constructor() payable { setup(); } } ================================================ FILE: test/14-priority/PriorityCryticToFoundry.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { TargetFunctions } from "./TargetFunctions.sol"; import { FoundryAsserts } from "@chimera/FoundryAsserts.sol"; import { Test } from "forge-std/Test.sol"; // run from base project directory with: // forge test --match-contract PriorityCryticToFoundry // (if an invariant fails add -vvvvv on the end to see what failed) // // get coverage report (see https://medium.com/@rohanzarathustra/forge-coverage-overview-744d967e112f): // // 1) forge coverage --report lcov --report-file test/14-priority/coverage-foundry.lcov --match-contract PriorityCryticToFoundry // 2) genhtml test/14-priority/coverage-foundry.lcov -o test/14-priority/coverage-foundry // 3) open test/14-priority/coverage-foundry/index.html in your browser and // navigate to the relevant source file to see line-by-line execution records contract PriorityCryticToFoundry is Test, TargetFunctions, FoundryAsserts { function setUp() public { setup(); // Foundry doesn't use config files but does // the setup programmatically here // target the fuzzer on this contract as it will // contain the handler functions targetContract(address(this)); // handler functions to target during invariant tests bytes4[] memory selectors = new bytes4[](2); selectors[0] = this.handler_addCollateral.selector; selectors[1] = this.handler_removeCollateral.selector; targetSelector(FuzzSelector({ addr: address(this), selectors: selectors })); } function invariant_property_priority_order_correct() public { t(property_priority_order_correct(), "Collateral priority order maintained"); } } ================================================ FILE: test/14-priority/Properties.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { Setup } from "./Setup.sol"; import { Asserts } from "@chimera/Asserts.sol"; abstract contract Properties is Setup, Asserts { function property_priority_order_correct() public view returns(bool result) { if(priority0 != 0) { if(priority.getCollateralAtPriority(0) != priority0) return false; } if(priority1 != 0) { if(priority.getCollateralAtPriority(1) != priority1) return false; } if(priority2 != 0) { if(priority.getCollateralAtPriority(2) != priority2) return false; } if(priority3 != 0) { if(priority.getCollateralAtPriority(3) != priority3) return false; } result = true; } } ================================================ FILE: test/14-priority/Setup.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { Priority } from "../../src/14-priority/Priority.sol"; import { BaseSetup } from "@chimera/BaseSetup.sol"; abstract contract Setup is BaseSetup { // contract being tested Priority priority; // ghost variables uint8 priority0; uint8 priority1; uint8 priority2; uint8 priority3; function setup() internal virtual override { priority = new Priority(); } } ================================================ FILE: test/14-priority/TargetFunctions.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import { Properties } from "./Properties.sol"; import { BaseTargetFunctions } from "@chimera/BaseTargetFunctions.sol"; import { IHevm, vm } from "@chimera/Hevm.sol"; abstract contract TargetFunctions is BaseTargetFunctions, Properties { function handler_addCollateral(uint8 collateralId) external { collateralId = uint8(between(collateralId, priority.MIN_COLLATERAL_ID(), priority.MAX_COLLATERAL_ID())); priority.addCollateral(collateralId); // update ghost variables with expected order if(priority0 == 0) priority0 = collateralId; else if(priority1 == 0) priority1 = collateralId; else if(priority2 == 0) priority2 = collateralId; else priority3 = collateralId; } function handler_removeCollateral(uint8 collateralId) external { collateralId = uint8(between(collateralId, priority.MIN_COLLATERAL_ID(), priority.MAX_COLLATERAL_ID())); priority.removeCollateral(collateralId); // update ghost variables with expected order if(priority0 == collateralId) { priority0 = priority1; priority1 = priority2; priority2 = priority3; } else if(priority1 == collateralId) { priority1 = priority2; priority2 = priority3; } else if(priority2 == collateralId) { priority2 = priority3; } delete priority3; } } ================================================ FILE: test/14-priority/certora.conf ================================================ { "files": [ "src/14-priority/Priority.sol" ], "verify": "Priority:test/14-priority/certora.spec", "packages":[ "@openzeppelin=lib/openzeppelin-contracts" ], } ================================================ FILE: test/14-priority/certora.spec ================================================ // run from base folder: // certoraRun test/14-priority/certora.conf methods { // `envfree` definitions to call functions without explicit `env` function getCollateralAtPriority(uint8) external returns(uint8) envfree; function containsCollateral(uint8) external returns(bool) envfree; function numCollateral() external returns(uint256) envfree; } // verify priority queue order is correctly maintained: // - `addCollateral` adds new id to end of the queue // - `removeCollateral` maintains existing order minus removed id rule priority_order_correct() { // require no initial collateral to prevent HAVOC corrupting // EnumerableSet _positions -> _values references require numCollateral() == 0 && !containsCollateral(1) && !containsCollateral(2) && !containsCollateral(3); // setup initial state with collateral_id order: 1,2,3 which ensures // EnumerableSet _positions -> _values references are correct env e1; addCollateral(e1, 1); addCollateral(e1, 2); addCollateral(e1, 3); // sanity check initial state assert numCollateral() == 3 && containsCollateral(1) && containsCollateral(2) && containsCollateral(3); // sanity check initial order; this also verifies that // `addCollateral` worked as expected assert getCollateralAtPriority(0) == 1 && getCollateralAtPriority(1) == 2 && getCollateralAtPriority(2) == 3; // successfully remove first id 1 removeCollateral(e1, 1); // verify it was removed and other ids still exist assert numCollateral() == 2 && !containsCollateral(1) && containsCollateral(2) && containsCollateral(3); // assert existing order 2,3 preserved minus the removed first id 1 assert getCollateralAtPriority(0) == 2 && getCollateralAtPriority(1) == 3; } ================================================ FILE: test/14-priority/echidna.yaml ================================================ # don't allow fuzzer to use all functions # since we are using handlers allContracts: false # record fuzzer coverage to see what parts of the code # fuzzer executes corpusDir: "./test/14-priority/coverage-echidna" # prefix of invariant function prefix: "property_" # instruct foundry to compile tests cryticArgs: ["--foundry-compile-all"] ================================================ FILE: test/14-priority/medusa.json ================================================ { "fuzzing": { "workers": 10, "workerResetLimit": 50, "_COMMENT_TESTING_1": "changed timeout to limit fuzzing time", "timeout": 30, "testLimit": 0, "shrinkLimit": 500, "callSequenceLength": 100, "_COMMENT_TESTING_8": "added directory to store coverage data", "corpusDirectory": "coverage-medusa", "coverageEnabled": true, "_COMMENT_TESTING_2": "added test contract to deploymentOrder", "targetContracts": ["PriorityCryticTester"], "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", "senderAddresses": [ "0x10000", "0x20000", "0x30000" ], "blockNumberDelayMax": 60480, "blockTimestampDelayMax": 604800, "blockGasLimit": 125000000, "transactionGasLimit": 12500000, "testing": { "stopOnFailedTest": true, "stopOnFailedContractMatching": true, "stopOnNoTests": true, "testAllContracts": false, "traceAll": false, "assertionTesting": { "enabled": false, "testViewMethods": false, "panicCodeConfig": { "failOnCompilerInsertedPanic": false, "failOnAssertion": false, "failOnArithmeticUnderflow": false, "failOnDivideByZero": false, "failOnEnumTypeConversionOutOfBounds": false, "failOnIncorrectStorageAccess": false, "failOnPopEmptyArray": false, "failOnOutOfBoundsArrayAccess": false, "failOnAllocateTooMuchMemory": false, "failOnCallUninitializedVariable": false } }, "propertyTesting": { "enabled": true, "_COMMENT_TESTING_6": "changed prefix to match invariant function", "testPrefixes": [ "property_" ] }, "optimizationTesting": { "enabled": false, "testPrefixes": [ "optimize_" ] }, "targetFunctionSignatures": [], "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "_COMMENT_TESTING_7": "changed target to point to main directory where command is run from", "target": "./../../.", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } }, "logging": { "level": "info", "logDirectory": "" } } ================================================ FILE: test/TestUtils.sol ================================================ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; // adapted from https://github.com/crytic/properties/blob/main/contracts/util/PropertiesHelper.sol#L240-L259 library TestUtils { // platform-agnostic input restriction to easily // port fuzz tests between different fuzzers function clampBetween(uint256 value, uint256 low, uint256 high ) internal pure returns (uint256) { if (value < low || value > high) { return (low + (value % (high - low + 1))); } return value; } }