diff --git a/.github/workflows/test-echidna.yaml b/.github/workflows/test-echidna.yaml new file mode 100644 index 00000000..f88564c4 --- /dev/null +++ b/.github/workflows/test-echidna.yaml @@ -0,0 +1,36 @@ +name: Echidna Test + +on: + push: + branches: [ main, staging ] + pull_request: + branches: [ main, staging ] + +env: + FOUNDRY_PROFILE: ci + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Compile contracts + run: | + forge build --build-info + + - name: Run Echidna + uses: crytic/echidna-action@v2 + with: + files: . + contract: MarketEchidnaAdvanced + config: tests/invariants/MarketEchidnaAdvanced.yaml + crytic-args: --ignore-compile --foundry-out-directory forge-out \ No newline at end of file diff --git a/contracts/OverlayV1Market.sol b/contracts/OverlayV1Market.sol index 34f6f5db..bf7f05a0 100644 --- a/contracts/OverlayV1Market.sol +++ b/contracts/OverlayV1Market.sol @@ -387,6 +387,11 @@ contract OverlayV1Market is IOverlayV1Market, Pausable { // oiShares and fraction remaining of initial position pos.oiShares -= uint240(pos.oiSharesCurrent(fraction)); pos.fractionRemaining = pos.updatedFractionRemaining(fraction); + // ensure there are no dead shares left + if (pos.fractionRemaining == 0 && pos.oiShares > 0) { + _reduceOIAndOIShares(pos, ONE); + pos.oiShares = 0; + } positions.set(msg.sender, positionId, pos); } diff --git a/medusa.json b/medusa.json new file mode 100644 index 00000000..ba49142c --- /dev/null +++ b/medusa.json @@ -0,0 +1,77 @@ +{ + "fuzzing": { + "workers": 10, + "workerResetLimit": 50, + "timeout": 0, + "testLimit": 0, + "callSequenceLength": 100, + "corpusDirectory": "", + "coverageEnabled": true, + "deploymentOrder": ["MarketEchidnaAdvanced"], + "constructorArgs": {}, + "deployerAddress": "0x30000", + "senderAddresses": [ + "0x1000000000000000000000000000000000000000", + "0x2000000000000000000000000000000000000000" + ], + "blockNumberDelayMax": 60480, + "blockTimestampDelayMax": 604800, + "blockGasLimit": 125000000, + "transactionGasLimit": 12500000, + "testing": { + "stopOnFailedTest": true, + "stopOnFailedContractMatching": true, + "stopOnNoTests": true, + "testAllContracts": false, + "traceAll": false, + "assertionTesting": { + "enabled": true, + "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": false, + "testPrefixes": [ + "fuzz_" + ] + }, + "optimizationTesting": { + "enabled": false, + "testPrefixes": [ + "optimize_" + ] + } + }, + "chainConfig": { + "codeSizeCheckDisabled": true, + "cheatCodes": { + "cheatCodesEnabled": true, + "enableFFI": false + } + } + }, + "compilation": { + "platform": "crytic-compile", + "platformConfig": { + "target": "tests/invariants/MarketEchidnaAdvanced.t.sol", + "solcVersion": "", + "exportDirectory": "", + "args": ["--foundry-out-directory", "forge-out"] + } + }, + "logging": { + "level": "info", + "logDirectory": "" + } +} \ No newline at end of file diff --git a/tests/invariants/MarketEchidna.t.sol b/tests/invariants/MarketEchidna.t.sol index f27df14b..8f719c3b 100644 --- a/tests/invariants/MarketEchidna.t.sol +++ b/tests/invariants/MarketEchidna.t.sol @@ -5,6 +5,7 @@ import {OverlayV1Factory} from "../../contracts/OverlayV1Factory.sol"; import {OverlayV1Market} from "../../contracts/OverlayV1Market.sol"; import {OverlayV1Token} from "../../contracts/OverlayV1Token.sol"; import {OverlayV1FeedFactoryMock} from "../../contracts/mocks/OverlayV1FeedFactoryMock.sol"; +import {OverlayV1FeedMock} from "../../contracts/mocks/OverlayV1FeedMock.sol"; import {AggregatorMock} from "../../contracts/mocks/AggregatorMock.sol"; import {GOVERNOR_ROLE, MINTER_ROLE} from "../../contracts/interfaces/IOverlayV1Token.sol"; import {TestUtils} from "./TestUtils.sol"; @@ -18,22 +19,26 @@ interface IHevm { // solc-select install 0.8.10 // solc-select use 0.8.10 // -// run from base project directory with: +// run with echidna from base project directory with: // echidna tests/invariants/MarketEchidna.t.sol --contract MarketEchidna --config tests/invariants/MarketEchidna.yaml // +// run with medusa from base project directory with: +// medusa fuzz +// // Reference: https://github.com/overlay-market/v1-core/blob/main/tests/markets/conftest.py contract MarketEchidna { - IHevm hevm = IHevm(address(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D)); + IHevm hevm = IHevm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); // contracts required for test OverlayV1Factory factory; OverlayV1Market market; OverlayV1Token ovl; + OverlayV1FeedMock feed; AggregatorMock sequencer_oracle; // make these constant to match Echidna config - address public constant ALICE = address(0x1000000000000000000000000000000000000000); - address public constant BOB = address(0x2000000000000000000000000000000000000000); + address constant ALICE = address(0x1000000000000000000000000000000000000000); + address constant BOB = address(0x2000000000000000000000000000000000000000); uint256 constant MIN_COLLATERAL = 1e14; uint256 constant CAP_NOTIONAL = 8e23; @@ -46,13 +51,10 @@ contract MarketEchidna { factory = new OverlayV1Factory(address(ovl), address(0x111), address(sequencer_oracle), 0); // market will be later deployed by factory - // ovl config - uint256 ovlSupply = 8_000_000e18; + // ovl config; fund test accounts ovl.grantRole(MINTER_ROLE, address(this)); - ovl.mint(address(this), ovlSupply); - ovl.renounceRole(MINTER_ROLE, address(this)); - ovl.transfer(ALICE, ovlSupply / 2); - ovl.transfer(BOB, ovlSupply / 2); + ovl.mint(ALICE, 4_000_000e18); + ovl.mint(BOB, 4_000_000e18); // factory config ovl.grantRole(GOVERNOR_ROLE, address(this)); @@ -62,7 +64,7 @@ contract MarketEchidna { factory.addFeedFactory(address(feedFactory)); // market config and deployment - address feed = feedFactory.deployFeed({price: 1e29, reserve: 2_000_000e18}); + feed = OverlayV1FeedMock(feedFactory.deployFeed({price: 1e25, reserve: 2_000_000e18})); uint256[15] memory params = [ uint256(122000000000), // k 500000000000000000, // lmbda @@ -78,22 +80,21 @@ contract MarketEchidna { 750000000000000, // tradingFeeRate MIN_COLLATERAL, // minCollateral 25000000000000, // priceDriftUpperLimit - 250 // averageBlockTime // FIXME: this will be different in Arbitrum + 250 // averageBlockTime ]; - market = OverlayV1Market(factory.deployMarket(address(feedFactory), feed, params)); + market = OverlayV1Market(factory.deployMarket(address(feedFactory), address(feed), params)); hevm.prank(ALICE); - ovl.approve(address(market), ovlSupply / 2); + ovl.approve(address(market), type(uint256).max); hevm.prank(BOB); - ovl.approve(address(market), ovlSupply / 2); + ovl.approve(address(market), type(uint256).max); } // Invariant 1) `oiOverweight * oiUnderweight` remains constant after funding payments // event to raise if the invariant is broken - event OiAfterFunding(uint256 oiProductBefore, uint256 oiProductAfter); + event OiAfterFunding(uint256 oiProductBefore, uint256 oiProductAfter, uint256 price); - function invariant_oi_product_after_funding() public returns (bool) { - uint256 lastUpdate = market.timestampUpdateLast(); + function check_oi_product_after_funding(uint256 timeElapsed) public { uint256 oiLong = market.oiLong(); uint256 oiShort = market.oiShort(); uint256 oiOverweightBefore = oiLong > oiShort ? oiLong : oiShort; @@ -104,17 +105,17 @@ contract MarketEchidna { (uint256 oiOverweightAfter, uint256 oiUnderweightAfter) = market.oiAfterFunding({ oiOverweight: oiOverweightBefore, oiUnderweight: oiUnderweightBefore, - // FIXME: block.timestamp is always 0 in the test - // timeElapsed: block.timestamp - lastUpdate - timeElapsed: 1 + timeElapsed: timeElapsed }); uint256 oiProductAfter = oiOverweightAfter * oiUnderweightAfter; // only visible when invariant fails - emit OiAfterFunding(oiProductBefore, oiProductAfter); + emit OiAfterFunding(oiProductBefore, oiProductAfter, feed.price()); + + // 10% tolerance + assert(TestUtils.isApproxEqRel(oiProductBefore, oiProductAfter, 10e16)); - // 0.5% tolerance - return (TestUtils.isApproxEqRel(oiProductBefore, oiProductAfter, 0.5e16)); + revert(); // revert state changes during the invariant check } } diff --git a/tests/invariants/MarketEchidna.yaml b/tests/invariants/MarketEchidna.yaml index 96702d87..b69c640c 100644 --- a/tests/invariants/MarketEchidna.yaml +++ b/tests/invariants/MarketEchidna.yaml @@ -11,5 +11,5 @@ sender: ["0x1000000000000000000000000000000000000000", "0x2000000000000000000000 # fuzzer executes corpusDir: "./tests/invariants/coverage-echidna" -# use same prefix as Foundry invariant tests -prefix: "invariant_" +# use assertion mode to include dynamic parameters in the invariant checks +testMode: assertion diff --git a/tests/invariants/MarketEchidnaAdvanced.t.sol b/tests/invariants/MarketEchidnaAdvanced.t.sol index 123f481b..5969d307 100644 --- a/tests/invariants/MarketEchidnaAdvanced.t.sol +++ b/tests/invariants/MarketEchidnaAdvanced.t.sol @@ -8,26 +8,223 @@ import {TestUtils} from "./TestUtils.sol"; // solc-select install 0.8.10 // solc-select use 0.8.10 // -// run from base project directory with: +// run with echidna from base project directory with: // echidna tests/invariants/MarketEchidnaAdvanced.t.sol --contract MarketEchidnaAdvanced --config tests/invariants/MarketEchidnaAdvanced.yaml +// +// run with medusa from base project directory with: +// medusa fuzz contract MarketEchidnaAdvanced is MarketEchidna { - // wrapper around market.build() to "guide" the fuzz test - event BuildWrapper(bool isLong, uint256 collateral); + // ghost variables used to verify invariants + mapping(address => uint256[]) positions; // owner => positionIds + + // same as the ones specified in the fuzzer config + address[] senders = [ALICE, BOB]; - function buildWrapper(bool isLong, uint256 collateral) public { + // Handler functions to "guide" the stateful fuzz test + // Note: handlers have to be public in order to be called by Echidna + + // wrapper around market.build() + function buildWrapper(bool isLong, uint256 collateral) public returns (uint256 posId) { // bound collateral to avoid reverts collateral = TestUtils.clampBetween(collateral, MIN_COLLATERAL, CAP_NOTIONAL); // use the senders specified in the yaml config + ovl.mint(msg.sender, collateral); hevm.prank(msg.sender); - market.build({ + posId = market.build({ collateral: collateral, leverage: 1e18, isLong: isLong, priceLimit: isLong ? type(uint256).max : 0 }); + + // save positions built by the sender to use in other handlers + positions[msg.sender].push(posId); + } + + function setPrice(uint256 price) public { + // bound market price to a reasonable range + price = TestUtils.clampBetween(price, 1e1, 1e25); + feed.setPrice(price); + } + + function unwind(uint256 fraction) public { + fraction = TestUtils.clampBetween(fraction, 1e14, 1e18); + + uint256[] storage posIds = positions[msg.sender]; + require(posIds.length > 0, "No positions to unwind"); + uint256 posId = posIds[posIds.length - 1]; + + (,,,, bool isLong,,,) = market.positions(keccak256(abi.encodePacked(msg.sender, posId))); + + hevm.prank(msg.sender); + market.unwind(posId, fraction, isLong ? 0 : type(uint256).max); + + (,,,,,,, uint16 fractionRemaining) = + market.positions(keccak256(abi.encodePacked(msg.sender, posId))); + + // remove the position if fully unwound + if (fractionRemaining == 0) posIds.pop(); + } + + function liquidate() public { + uint256[] storage posIds = positions[msg.sender]; + require(posIds.length > 0, "No positions to liquidate"); + uint256 posId = posIds[posIds.length - 1]; + + (,,,, bool isLong,,,) = market.positions(keccak256(abi.encodePacked(msg.sender, posId))); + + // adjust the price so that position becomes liquidatable + setPrice(isLong ? feed.price() / 2 : feed.price() * 2); + + // liquidate the position + market.liquidate(msg.sender, posId); + + // remove the liquidated position + posIds.pop(); } - // invariants inherited from base contract + // Helper functions + + function _getOiAndShares(bool isLong) internal view returns (uint256 oi, uint256 oiShares) { + oi = isLong ? market.oiLong() : market.oiShort(); + oiShares = isLong ? market.oiLongShares() : market.oiShortShares(); + } + + // Invariants inherited from base contract + + // Invariant 2) Market's oi and oi shares should increase after a build + + function check_oi_after_build(bool isLong, uint256 collateral) public { + // bound collateral here to use in oi calculations + collateral = TestUtils.clampBetween(collateral, MIN_COLLATERAL, CAP_NOTIONAL); + + // trigger funding payment so that it doesn't affect the test + market.update(); + + (uint256 oiBefore, uint256 oiSharesBefore) = _getOiAndShares(isLong); + + // build a position + buildWrapper(isLong, collateral); + + // calculate the expected oi and oi shares + uint256 posOi = collateral * 1e18 / feed.price(); + uint256 posOiShares = + (oiBefore == 0 || oiSharesBefore == 0) ? posOi : oiSharesBefore * posOi / oiBefore; + + (uint256 oiAfter, uint256 oiSharesAfter) = _getOiAndShares(isLong); + + assert(oiAfter == oiBefore + posOi); + assert(oiSharesAfter == oiSharesBefore + posOiShares); + + revert(); // revert state changes during the invariant check + } + + // Invariant 3) Market's oi and oi shares should decrease after an unwind + + function check_oi_after_unwind(bool isLong, uint256 collateral) public { + // build a position + uint256 posId = buildWrapper(isLong, collateral); + + (uint256 oiBefore, uint256 oiSharesBefore) = _getOiAndShares(isLong); + + (,,,,,, uint240 posOiShares,) = + market.positions(keccak256(abi.encodePacked(msg.sender, posId))); + uint256 posOi = oiSharesBefore == 0 ? 0 : oiBefore * posOiShares / oiSharesBefore; + + // unwind the whole position + hevm.prank(msg.sender); + market.unwind(posId, 1e18, isLong ? 0 : type(uint256).max); + + (uint256 oiAfter, uint256 oiSharesAfter) = _getOiAndShares(isLong); + + assert(oiAfter == oiBefore - posOi); + assert(oiSharesAfter == oiSharesBefore - posOiShares); + + revert(); // revert state changes during the invariant check + } + + // Invariant 4) Market's oi and oi shares should decrease after a liquidation + + function check_oi_after_liquidate(bool isLong, uint256 collateral) public { + // build a position + uint256 posId = buildWrapper(isLong, collateral); + + (uint256 oiBefore, uint256 oiSharesBefore) = _getOiAndShares(isLong); + + (,,,,,, uint240 posOiShares,) = + market.positions(keccak256(abi.encodePacked(msg.sender, posId))); + uint256 posOi = oiSharesBefore == 0 ? 0 : oiBefore * posOiShares / oiSharesBefore; + + // adjust the price so that position becomes liquidatable + feed.setPrice(isLong ? feed.price() / 2 : feed.price() * 2); + + // liquidate the position (don't need to prank the sender here) + market.liquidate(msg.sender, posId); + + (uint256 oiAfter, uint256 oiSharesAfter) = _getOiAndShares(isLong); + + assert(oiAfter == oiBefore - posOi); + assert(oiSharesAfter == oiSharesBefore - posOiShares); + + revert(); // revert state changes during the invariant check + } + + // Invariant 5) Sum of open position's oi shares should be equal to market's total oi shares + + event InvariantOiSharesSum( + uint256 numPositions, + uint256 sumLongExpected, + uint256 sumLong, + uint256 sumShortExpected, + uint256 sumShort + ); + + function check_oi_shares_sum() public { + uint256 numPositions; + uint256 sumOiLongShares; + uint256 sumOiShortShares; + + // iterate over all the senders + for (uint256 i = 0; i < senders.length; i++) { + // iterate over all the positions of the sender + for (uint256 j = 0; j < positions[senders[i]].length; j++) { + (,,,, bool isLong,, uint240 oiShares,) = market.positions( + keccak256(abi.encodePacked(senders[i], positions[senders[i]][j])) + ); + + if (isLong) { + sumOiLongShares += oiShares; + } else { + sumOiShortShares += oiShares; + } + + numPositions++; + } + } + + // FIXME: echidna breaks the invariant (parameters are not bounded on this output). + // check_oi_shares_sum(): failed!💥 + // Call sequence, shrinking 1675/5000: + // buildWrapper(false,0) + // unwind(29678335934853632302057079988033485840348142661768149768981112452983) + // unwind(7311677140176116913925425463138979182172112599739844530104983646231913891) + // check_oi_shares_sum() + // Event sequence: + // InvariantOiSharesSum(0, 0, 0, 275, 0) + // + // The issue is that, after unwinding a position, its fractionRemaining can be set to 0 (ie. the position is effectively closed) while its oiShares are still greater than 0. + // Example position: fractionRemaining = 0 ; oiShares = 1.73e12 + emit InvariantOiSharesSum( + numPositions, + market.oiLongShares(), + sumOiLongShares, + market.oiShortShares(), + sumOiShortShares + ); + + assert(sumOiLongShares == market.oiLongShares()); + assert(sumOiShortShares == market.oiShortShares()); + } } diff --git a/tests/invariants/MarketEchidnaAdvanced.yaml b/tests/invariants/MarketEchidnaAdvanced.yaml index 43a2323e..22fa62a2 100644 --- a/tests/invariants/MarketEchidnaAdvanced.yaml +++ b/tests/invariants/MarketEchidnaAdvanced.yaml @@ -13,8 +13,8 @@ sender: ["0x1000000000000000000000000000000000000000", "0x2000000000000000000000 # fuzzer executes corpusDir: "./tests/invariants/coverage-echidna" -# use same prefix as Foundry invariant tests -prefix: "invariant_" +# use assertion mode to include dynamic parameters in the invariant checks +testMode: assertion # increase number of works to speed up test workers: 10