Particle Leverage AMM Protocol Invitational - immeas's results

A permissionless protocol to leverage trade any ERC20 tokens.

General Information

Platform: Code4rena

Start Date: 11/12/2023

Pot Size: $24,150 USDC

Total HM: 19

Participants: 5

Period: 10 days

Judge: 0xLeastwood

Total Solo HM: 6

Id: 312

League: ETH

Particle Protocol

Findings Distribution

Researcher Performance

Rank: 3/5

Findings: 8

Award: $0.00

QA:
grade-a

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: bin2chen

Also found by: adriro, immeas, ladboy233

Labels

bug
3 (High Risk)
partial-50
upgraded by judge
duplicate-26

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/main/contracts/protocol/ParticlePositionManager.sol#L399-L406 https://github.com/code-423n4/2023-12-particle/blob/main/contracts/protocol/ParticlePositionManager.sol#L415-L420

Vulnerability details

Impact

A liquidator can manipulate the pool they are swapping in to take any potential left over premium from the borrower.

Proof of Concept

When liquidating a position the liquidator essentially closes the position on behalf of the borrower for a liquidation reward.

When closing a position the token held by the borrower is potentially traded back to cover the debt to the liquidity provider:

ParticlePositionManager::_closePosition#L399-L406:

File: contracts/protocol/ParticlePositionManager.sol

399:        (cache.amountSpent, cache.amountReceived) = Base.swap(
400:            cache.tokenFrom,
401:            cache.tokenTo,
402:            params.amountSwap,
403:            0, /// @dev we check cache.amountReceived is sufficient to repay LP in below
404:            DEX_AGGREGATOR,
405:            params.data
406:        );

Then these amounts are used to check that the amount swapped will cover repaying the lender:

ParticlePositionManager::_closePosition#L415-L420:

File: contracts/protocol/ParticlePositionManager.sol

415:        if (
416:            cache.amountFromAdd > cache.collateralFrom + cache.tokenFromPremium - cache.amountSpent ||
417:            cache.amountToAdd > cache.amountReceived + cache.tokenToPremium
418:        ) {
419:            revert Errors.InsufficientRepay();
420:        }

Where amountTo/FromAdd is the amounts needed to repay the liquidity borrowed from the liquidity provider.

The issue here is that there can be tokenFrom/ToPremium left. The liquidation requirement is that only one of the sides needs to be under water. These leftovers will however be taken in the swap. The liquidator can manipulate the pool being swapped in such that amountSpent and amountReceived uses all the premium from the borrower.

This would have otherwise been returned to the borrower in the refunds at the end of _closePosition.

Tools Used

Manual audit

Consider if this is a feature or not.

If it is intended behavior for the liquidator to take what is left of the borrowers premium, consider refunding the liquidator instead of borrower in case of liquidation.

If this is not a feature and the borrower is entitled to whatever premium they have left, consider enforcing better slippage protection on the swap in _closePosition.

Assessed type

MEV

#0 - c4-judge

2023-12-21T22:01:13Z

0xleastwood marked the issue as duplicate of #56

#1 - c4-judge

2023-12-21T22:16:44Z

0xleastwood marked the issue as duplicate of #26

#2 - c4-judge

2023-12-23T22:27:44Z

0xleastwood changed the severity to 3 (High Risk)

#3 - c4-judge

2023-12-23T22:27:48Z

0xleastwood marked the issue as satisfactory

#4 - 0xleastwood

2023-12-29T13:13:36Z

No clear POC outlined here even if the issue is valid. It's not clear how this would be exploited by an attacker. Giving partial credit.

#5 - c4-judge

2023-12-29T13:13:40Z

0xleastwood marked the issue as partial-50

Findings Information

🌟 Selected for report: adriro

Also found by: bin2chen, immeas

Labels

2 (Med Risk)
satisfactory
duplicate-61

Awards

Data not available

External Links

Judge has assessed an item in Issue #49 as 2 risk. The relevant finding follows:

L-05 Some tokens revert on 0 amount transfer ParticlePositionManager::liquidatePosition:

File: protocol/ParticlePositionManager.sol

376: // reward liquidator 377: TransferHelper.safeTransfer(closeCache.tokenFrom, msg.sender, liquidateCache.liquidationRewardFrom); 378: TransferHelper.safeTransfer(closeCache.tokenTo, msg.sender, liquidateCache.liquidationRewardTo); Some tokens, like LEND revert on 0 amount transfer. If the liquidation amount is 0 for this token this would make liquidation impossible for this position.

Recommendation Consider checking if the liquidationAmount > 0 before transferring liquidation reward.

#0 - c4-judge

2023-12-31T13:07:38Z

0xleastwood marked the issue as duplicate of #61

#1 - c4-judge

2023-12-31T13:07:48Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: adriro

Also found by: immeas

Labels

bug
2 (Med Risk)
satisfactory
duplicate-59

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/main/contracts/libraries/LiquidityPosition.sol#L132-L146 https://github.com/code-423n4/2023-12-particle/blob/main/contracts/libraries/LiquidityPosition.sol#L190-L199 https://github.com/code-423n4/2023-12-particle/blob/main/contracts/libraries/LiquidityPosition.sol#L254-L262

Vulnerability details

Impact

Lack of deadline can cause a transaction to be executed at an unfavorable time. Causing loss for the trader/liquidity provider.

Proof of Concept

In all three liquidity interactions with uniswap there is a lack of deadline parameter as block.timestamp is passed:

LiquidityPosition::mint:

File: contracts/libraries/LiquidityPosition.sol

132:        (tokenId, liquidity, amount0Minted, amount1Minted) = Base.UNI_POSITION_MANAGER.mint(
133:            INonfungiblePositionManager.MintParams({
...                 // ... other params
144:                deadline: block.timestamp
145:            })
146:        );

LiquidityPosition::increaseLiquidity:

File: contracts/libraries/LiquidityPosition.sol

190:        (liquidity, amount0Added, amount1Added) = Base.UNI_POSITION_MANAGER.increaseLiquidity(
191:            INonfungiblePositionManager.IncreaseLiquidityParams({
...                 // ... other params
197:                deadline: block.timestamp
198:            })
199:        );

LiquidityPosition::decreaseLiquidity:

File: contracts/libraries/LiquidityPosition.sol

254:        (amount0, amount1) = Base.UNI_POSITION_MANAGER.decreaseLiquidity(
255:            INonfungiblePositionManager.DecreaseLiquidityParams({
...                 // ... other params
260:                deadline: block.timestamp
261:            })
262:        );

As you see above, all liquidity interactions done with uniswap are done without any deadline set. These are called through ParticlePositionManager::increaseLiquidity, decreaseLiquidity, mint, openPosition, closePosition and liqidatePosition. Which makes any of these calls vulnerable to late inclusion by validators.

Tools Used

Manual audit

Consider adding a deadline parameter to all of the AMM interactions through ParticlePositionManager::increaseLiquidity, decreaseLiquidity, mint, openPosition, closePosition and liqidatePosition as all these do interactions with uniswap.

Assessed type

Timing

#0 - c4-judge

2023-12-21T22:07:39Z

0xleastwood marked the issue as duplicate of #59

#1 - c4-judge

2023-12-23T22:37:31Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: adriro

Also found by: bin2chen, immeas, ladboy233

Labels

bug
2 (Med Risk)
satisfactory
duplicate-52

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/main/contracts/protocol/ParticlePositionManager.sol#L365 https://github.com/code-423n4/2023-12-particle/blob/main/contracts/protocol/ParticlePositionManager.sol#L581-L585

Vulnerability details

Impact

If the protocol updates the loan terms, this will affect existing loans taken under different terms.

Proof of Concept

LOAN_TERM is a guarantee for the lender (liquidity provider) to eventually get their liquidity back. After loan term has passed the lender can cause the loan to be possible to liquidate by claiming their liquidity.

In a similar fashion the borrower is guaranteed (as long as they keep their premium up) to not be liquidated during the LOAN_TERM.

This is enforced in ParticlePositionManager::liquidatePosition:

File: contracts/protocol/ParticlePositionManager.sol

365:                    lien.startTime + LOAN_TERM < block.timestamp))

Since only the startTime of the lien is stored, the LOAN_TERM is read at time of liquidation.

The protocol can change loan term. This would break all the previous agreements between lenders and borrowers. Which could possibly cause unwanted liquidations for borrowers or lenders to be forced to wait longer to get their liquidity back.

Tools Used

Manual audit

Consider storing the loan term in the lien.

Assessed type

Other

#0 - c4-judge

2023-12-21T22:04:17Z

0xleastwood marked the issue as duplicate of #52

#1 - c4-judge

2023-12-23T22:40:54Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: immeas

Also found by: bin2chen

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
M-07

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/main/contracts/protocol/ParticlePositionManager.sol#L212

Vulnerability details

Description

marginTo/From is a way to both cover your position and increase your premium when opening a position. There is however a unintended limit on how much marginTo you can provide when opening a position.

When doing the swap to increase leverage, the amountToMinimum (minimum received amount) is calculated:

ParticlePositionManager::openPosition:

File: contracts/protocol/ParticlePositionManager.sol

212:            collateralTo - cache.amountToBorrowed - params.marginTo, // amount needed to meet requirement

As you see here, a marginTo > collateralTo - cache.amountToBorrowed will underflow this calculation. Thus there is no way to provide a bigger margin than this.

Impact

A user cannot supply more margin than collateralTo - amountToBorrowed before the opening of the position reverts.

There is a workaround to supply no marginTo and then use addPremium to increase the premium but I believe this issue still breaks the intended purpose of marginTo in the openPosition call.

Proof of Concept

Test in OpenPosition.t.sol:

    function testOpenLongPositionTooMuchMargin() public {
        // test based on `testBaseOpenLongPosition` and `_borrowToLong`
        uint128 borrowerLiquidity = _liquidity / _borrowerLiquidityPorition;
        (uint256 amount0ToBorrow, uint256 amount1ToBorrow) = LiquidityAmounts.getAmountsForLiquidity(
            _sqrtRatioX96,
            _sqrtRatioAX96,
            _sqrtRatioBX96,
            borrowerLiquidity
        );
        (, uint256 requiredEth) = particleInfoReader.getRequiredCollateral(borrowerLiquidity, _tickLower, _tickUpper);
        uint256 amountNeeded = QUOTER.quoteExactOutputSingle(
            address(USDC),
            address(WETH),
            FEE,
            requiredEth - amount1ToBorrow,
            0
        );
        uint256 amountFrom = amountNeeded + amountNeeded / 1e6 - amount0ToBorrow;
        uint256 amountSwap = amountFrom + amount0ToBorrow;
        amountFrom += (amountSwap * FEE_FACTOR) / (BASIS_POINT - FEE_FACTOR);

        // user supplies a large `marginTo` to get a big premium
        uint256 marginTo = requiredEth - amount1ToBorrow + 1;

        vm.startPrank(WHALE);
        USDC.transfer(SWAPPER, amountFrom);
        WETH.transfer(SWAPPER, marginTo);
        vm.stopPrank();

        vm.startPrank(SWAPPER);
        TransferHelper.safeApprove(address(USDC), address(particlePositionManager), amountFrom);
        TransferHelper.safeApprove(address(WETH), address(particlePositionManager), marginTo);

        // impossible as it will revert on underflow
        vm.expectRevert(stdError.arithmeticError);
        particlePositionManager.openPosition(
            DataStruct.OpenPositionParams({
                tokenId: _tokenId,
                marginFrom: amountFrom,
                marginTo: marginTo,
                amountSwap: amountSwap,
                liquidity: borrowerLiquidity,
                tokenFromPremiumPortionMin: 0,
                tokenToPremiumPortionMin: 0,
                marginPremiumRatio: type(uint8).max,
                zeroForOne: true,
                data: new bytes(0) // will not make it to the swap
            })
        );
        vm.stopPrank();
    }

Tools Used

Manual audit

Consider not counting marginTo towards expected output from the swap. As shown in another issue, there are further problems with this. marginTo is the premium for the "to"-side of the position. Hence should not be part of the expected output of the swap as it is the safety for the position.

Assessed type

Under/Overflow

#0 - c4-judge

2023-12-21T22:02:23Z

0xleastwood marked the issue as primary issue

#1 - wukong-particle

2023-12-23T06:14:18Z

Oh this is very interesting and careful point! So instead of the current check, we can do

params.marginTo > collateralTo - cache.amountToBorrowed ? 0 : collateralTo - cache.amountToBorrowed - params.marginTo

we still need marginTo to make the minimum amount for the swap right, happy to go back to the figures in https://excalidraw.com/#json=TcmwLn2W4K9H_UlCExFXa,J_yKjXNaowF0gYL8uvPruA for further discussion

#2 - c4-judge

2023-12-23T23:02:15Z

0xleastwood marked the issue as selected for report

#3 - wukong-particle

2023-12-24T09:36:21Z

confirm with the issue but our intended fix is slightly different from suggested.

#4 - c4-sponsor

2023-12-24T09:36:27Z

wukong-particle (sponsor) confirmed

#5 - romeroadrian

2023-12-29T14:27:26Z

Good catch 👍

Findings Information

🌟 Selected for report: bin2chen

Also found by: adriro, immeas

Labels

bug
2 (Med Risk)
satisfactory
duplicate-35

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/main/contracts/protocol/ParticlePositionManager.sol#L170-L173

Vulnerability details

Description

When a liquidity provider wants to withdraw their liquidity they can call ParticlePositionManager::reclaimLiquidity. This will prevent any renewals:

ParticlePositionManager::addPremium:

File: protocol/ParticlePositionManager.sol

508:        // check LP allows extension of this lien
509:        if (lps.getRenewalCutoffTime(lien.tokenId) > lien.startTime) revert Errors.RenewalDisabled();

getRenewalCutoffTime is however never checked when opening a new position.

Hence a borrower can instead of renewing by adding premium simply close their position and open a new one. Since this opens a new loan with a new lien.startTime there's nothing the liquidity provider can do to prevent this.

Impact

A borrower can prevent a liquidity provider from withdrawing their liquidity by closing and opening a new position.

Proof of Concept

PoC test, can be added to ClosePosition.t.sol:

    function testStopWithdrawLiquidity() public {
        _openLongPosition();

        // LP decides to withdraw their liquidity
        vm.prank(LP);
        particlePositionManager.reclaimLiquidity(_tokenId);

        vm.warp(block.timestamp + LOAN_TERM - 1);

        // borrower doesn't want that and can continue borrowing liquidity
        // by closing then opening a new position against same LP position
        _closeLongPosition(0, true, true);
        _openLongPosition();

        // 1 second after loan term
        vm.warp(block.timestamp + 2);

        // LP cannot claim all of their liquidity
        vm.prank(LP);
        vm.expectRevert();
        particlePositionManager.decreaseLiquidity(_tokenId, _liquidity);
    } 

Tools Used

Manual audit

Consider blocking openPosition if renewalCutoffTime is set to allow a liquidity provider to completely withdraw.

Assessed type

DoS

#0 - c4-judge

2023-12-21T22:09:44Z

0xleastwood marked the issue as primary issue

#1 - c4-judge

2023-12-21T22:11:24Z

0xleastwood marked the issue as duplicate of #35

#2 - c4-judge

2023-12-23T23:14:01Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: bin2chen

Also found by: immeas

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sponsor confirmed
duplicate-27

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/main/contracts/protocol/ParticlePositionManager.sol#L240-L243

Vulnerability details

Description

Premium in ParticlePositionManager is used to cover trading fees accrued for the liquidity borrowed. When liquidating, a portion of the premium is also used for the liquidation reward.

The issue is that a borrower can open a position without any premium at all:

ParticlePositionManager::openPosition:

File: contracts/protocol/ParticlePositionManager.sol

240:            if (
241:                cache.token0PremiumPortion < params.tokenToPremiumPortionMin ||
242:                cache.token1PremiumPortion < params.tokenFromPremiumPortionMin
243:            ) revert Errors.InsufficientPremium();

params.tokenFrom/ToPremiumPortionMin are supplied by the borrower hence can be 0.

This removes any liquidation reward, since closeCache.tokenFrom/ToPremium is 0: ParticlePositionManager::liquidatePosition:

File: contracts/protocol/ParticlePositionManager.sol

349:        liquidateCache.liquidationRewardFrom =
350:            ((closeCache.tokenFromPremium) * LIQUIDATION_REWARD_FACTOR) /
351:            uint128(Base.BASIS_POINT);
352:        liquidateCache.liquidationRewardTo =
353:            ((closeCache.tokenToPremium) * LIQUIDATION_REWARD_FACTOR) /
354:            uint128(Base.BASIS_POINT);

Thus any liquidation incentives are removed. Even if the position would immediately be liquidatable there would be no incentive to liquidate it. Other than possibly for the LP as they would get their liquidity back.

0 premium also robs the liquidity provider of any fees that would have been accrued for their borrowed liquidity: ParticlePositionManager::_closePosition (same for the other branch of zeroForOne):

File: contracts/protocol/ParticlePositionManager.sol

461:            cache.token0Owed = cache.token0Owed < cache.tokenToPremium ? cache.token0Owed : cache.tokenToPremium;
462:            cache.token1Owed = cache.token1Owed < cache.tokenFromPremium ? cache.token1Owed : cache.tokenFromPremium;

Here, min(tokenOwed,tokenPremium) is taken, since tokenPremium would be 0 no fees would be owed.

Impact

A borrower can open a position without any premium. This removes any incentive to liquidate the position and also robs the liquidity provider of fees that would have been accrued.

In the extreme, if you for example open a long position above the price (thus with no leverage), a borrower will not need to provide any funds at all. Thus can spam positions like this locking liquidity providers liquidity and denying them fees (if the trading moves in their direction).

Proof of Concept

Actually, the basic test testBaseOpenLongPosition in OpenPosition.t.sol already reproduces this:

    function testOpenPositionWithoutPremium() public {
        testBaseOpenLongPosition();

        (,, uint128 token0Premium, uint128 token1Premium,,) = particleInfoReader.getOwedInfo(SWAPPER, 0);
        assertEq(0,token0Premium);
        assertEq(0,token1Premium);
    }

Tools Used

Manual audit

Consider implementing a minimum enforced premium portion. Any premium portion would guarantee a reward to the liquidator.

Assessed type

Invalid Validation

#0 - c4-judge

2023-12-21T21:42:07Z

0xleastwood marked the issue as primary issue

#1 - wukong-particle

2023-12-23T06:02:27Z

Good suggestion, will add a minimum premium parameter. Not agreeing with the severity though. In the worst case, the LP is still willing to close the position to reclaim liquidity. But will let the judge to ultimately decide. Thanks!

#2 - 0xleastwood

2023-12-23T19:16:38Z

This seems like a DoS on the LP's liquidity. It will still be possible for them to reclaim if they liquidate a position and subsequently withdraw their liquidity but this is still not ideal.

I guess my final question would be do trader's lose any funds when creating positions with no premium? Is there a financial disincentive to do this? This would be the difference between QA and medium severity imo. For the time-being I will downgrade to medium.

#3 - c4-judge

2023-12-23T19:16:48Z

0xleastwood changed the severity to 2 (Med Risk)

#4 - 0xleastwood

2023-12-23T19:18:24Z

@wukong-particle I forgot to tag you in the above comment.

#5 - wukong-particle

2023-12-24T09:34:51Z

Well, trader will lose their initial margin if the premium is 0 and got immediately liquidated. If the trader believes immediate liquidation won't happen (because there is no reward from premium), logically the trader will go with 0 premium because this would be the least amount of cost. We will add a minimum premium parameter here so we confirm the issue.

#6 - c4-sponsor

2023-12-24T09:34:56Z

wukong-particle (sponsor) confirmed

#7 - c4-judge

2023-12-24T12:34:24Z

0xleastwood marked the issue as selected for report

#8 - romeroadrian

2023-12-30T14:34:37Z

@0xleastwood Isn't this a duplicate of #27? I think both describe the same core issue with different examples/scenarios

#9 - c4-judge

2023-12-31T12:52:08Z

0xleastwood marked the issue as not selected for report

#10 - c4-judge

2023-12-31T12:52:18Z

0xleastwood marked the issue as duplicate of #27

#11 - 0xleastwood

2023-12-31T12:52:47Z

@0xleastwood Isn't this a duplicate of #27? I think both describe the same core issue with different examples/scenarios

correct, it should be a duplicate of #27. Thanks

#12 - c4-judge

2023-12-31T13:09:30Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: ladboy233

Also found by: adriro, bin2chen, immeas, said

Labels

bug
2 (Med Risk)
satisfactory
duplicate-2

Awards

Data not available

External Links

Lines of code

https://github.com/code-423n4/2023-12-particle/blob/main/contracts/libraries/LiquidityPosition.sol#L190-L199 https://github.com/code-423n4/2023-12-particle/blob/main/contracts/libraries/LiquidityPosition.sol#L255-L261

Vulnerability details

Impact

Lack of slippage protection for increasing and decreasing liquidity can cause the liquidity provider to provide liquidity at an unfavorable price. Or the borrower to borrow/repay in a manipulated pool.

Proof of Concept

When adding liquidity eventually calls comes down to LiquidityPosition::increaseLiquidity and decreaseLiquidity which interact with the Uniswap position manager:

LiquidityPosition::increaseLiquidity and decreaseLiquidity:

File: contracts/libraries/LiquidityPosition.sol

178:    function increaseLiquidity(
            // ... params
184:    ) internal returns (uint128 liquidity, uint256 amount0Added, uint256 amount1Added) {
            // ... set approvals

189:        // increase liquidity via position manager
190:        (liquidity, amount0Added, amount1Added) = Base.UNI_POSITION_MANAGER.increaseLiquidity(
191:            INonfungiblePositionManager.IncreaseLiquidityParams({
192:                tokenId: tokenId,
193:                amount0Desired: amount0,
194:                amount1Desired: amount1,
                    // @audit no slippage for which price to add liquidity to
195:                amount0Min: 0,
196:                amount1Min: 0,
197:                deadline: block.timestamp
198:            })
199:        );
        
            // ... reset approvals
204:    }

...

253:    function decreaseLiquidity(uint256 tokenId, uint128 liquidity) internal returns (uint256 amount0, uint256 amount1) {
254:        (amount0, amount1) = Base.UNI_POSITION_MANAGER.decreaseLiquidity(
255:            INonfungiblePositionManager.DecreaseLiquidityParams({
256:                tokenId: tokenId,
257:                liquidity: liquidity,
                    // @audit no slippage or how much tokens to get when decreasing liquidity
258:                amount0Min: 0,
259:                amount1Min: 0,
260:                deadline: block.timestamp
261:            })
262:        );
263:    }

These are called directly by the liquidity provider through: ParticlePositionManager::increaseLiquidity and ParticlePositionManager::decreaseLiquidity.

As well as indirectly when opening, closing or liquidating a position.

Tools Used

Manual audit

Consider adding amount0/1Min parameters to ParticlePositionManager::increaseLiquidity and decreaseLiquidity and also through the calls openPosition, closePosition and liquidatePosition.

This would also cover the usage of slot0 in Base::getRequiredRepay as it would enforce a certain amount of token0/1 to be returned when repaying the liquidity.

Assessed type

MEV

#0 - c4-judge

2023-12-21T21:37:17Z

0xleastwood marked the issue as duplicate of #2

#1 - c4-judge

2023-12-23T23:01:16Z

0xleastwood marked the issue as satisfactory

Findings Information

🌟 Selected for report: immeas

Also found by: adriro, bin2chen, ladboy233, said

Labels

bug
grade-a
QA (Quality Assurance)
selected for report
sponsor confirmed
Q-02

Awards

Data not available

External Links

QA Report

Summary

idtitle
L-01ParticlePositionManager::onERC721Received allows all ERC721 tokens
L-02ParticlePositionManager can be initialized with bad params
L-03Fee tier 100 is also supported by Uniswap
L-04Use of non-upgradable ReentrancyGuard
L-05Some tokens revert on 0 amount transfer
L-06Uniswap positions minted to ParticlePositionManager are stuck
NC-01Liquidation criteria very complicated
NC-02Confusing parameter naming in ParticlePositionManager::swap
NC-03Erroneous comment

Low

L-01 ParticlePositionManager::onERC721Received allows all ERC721 tokens

ParticlePositionManager::onERC721Received:

File: contracts/protocol/ParticlePositionManager.sol

 98:    function onERC721Received(
...
103:    ) external override returns (bytes4) {
104:        if (msg.sender == Base.UNI_POSITION_MANAGER_ADDR) {
			// ... add liq position
109:        }
			// all ERC721s are accepted
110:        return this.onERC721Received.selector;
111:    }

As you see above all ERC721 tokens are accepted. This can cause ERC721 tokens accidentally sent to the contract to be stuck as there is no way to transfer them back (apart from code upgrade).

Recommendation

Consider only allowing UNI_POSITION_MANAGER to send ERC721 tokens to the contract and reverting otherwise.

L-02 ParticlePositionManager can be initialized with bad params

ParticlePositionManager has some parameters that are used to control fees, liquidation rewards and loan terms. If these params are incorrect they can cause severe problems with the contract. Therefore, when updating them, they are verified to be within certain limits. This is however not done, when initilizing the contract:

ParticlePositionManager::initialize:

File: contracts/protocol/ParticlePositionManager.sol

60:    function initialize(
61:        address dexAggregator,
62:        uint256 feeFactor,
63:        uint128 liquidationRewardFactor,
64:        uint256 loanTerm,
65:        uint256 treasuryRate
66:    ) external initializer {
67:        __UUPSUpgradeable_init();
68:        __Ownable_init();
69:        DEX_AGGREGATOR = dexAggregator;
70:        FEE_FACTOR = feeFactor;
71:        LIQUIDATION_REWARD_FACTOR = liquidationRewardFactor;
72:        LOAN_TERM = loanTerm;
73:        _treasuryRate = treasuryRate;
74:    }

Hence the contract can be setup with any values:

PoC
    function testSetupWithBadParams() public {
        address impl = address(new ParticlePositionManager());

        bytes memory init = abi.encodeWithSelector(ParticlePositionManager.initialize.selector,
          address(0), type(uint256).max, type(uint128).max, type(uint256).max, type(uint256).max);
          
        ParticlePositionManager ppm = ParticlePositionManager(address(new ERC1967Proxy(address(impl),init)));

        assertEq(address(0),ppm.DEX_AGGREGATOR());
        assertEq(type(uint256).max,ppm.FEE_FACTOR());
        assertEq(type(uint128).max,ppm.LIQUIDATION_REWARD_FACTOR());
        assertEq(type(uint256).max,ppm.LOAN_TERM());
        // treasury rate cannot be verified as it isn't public and there's no getter for it
        // assertEq(type(uint256).max,ppm._treasuryRate());
    }
Recommendation

Consider using the setter functions already declared, update*(...), to initialize the contract parameters. That would guarantee that the initialization is done using the same validation as changing the parameters.

L-03 Fee tier 100 is also supported by Uniswap

The helper contract ParticleInfoReader has a function to view which uniswap pool has the deepest liquidity:

ParticleInfoReader::getDeepPool:

File: contracts/protocol/ParticleInfoReader.sol

102:    function getDeepPool(address token0, address token1) external view returns (address deepPool) {
103:        uint24[3] memory feeTiers = [uint24(500), uint24(3000), uint24(10000)];
104:        uint128 maxLiquidity = 0;
105:
106:        for (uint256 i = 0; i < feeTiers.length; i++) {
107:            address poolAddress = Base.UNI_FACTORY.getPool(token0, token1, feeTiers[i]);
108:            if (poolAddress != address(0)) {
109:                IUniswapV3Pool pool = IUniswapV3Pool(poolAddress);
110:                uint128 liquidity = pool.liquidity();
111:                if (liquidity > maxLiquidity) {
112:                    maxLiquidity = liquidity;
113:                    deepPool = poolAddress;
114:                }
115:            }
116:        }
117:    }

The issue is that uniswap supports more feeTiers:

https://docs.uniswap.org/concepts/protocol/fees#pool-fees-tiers:

More fee levels may be added by UNI governance, e.g. the 0.01% fee level added by this governance proposal in November 2021, as executed here.

If you look at the contract: https://etherscan.io/address/0x1f98431c8ad98523631ae4a59f267346ea31f984#readContract it returns tick spacing 1 for fee tier 100

Hence this might not return the pool with the deepest liquidity.

Recommendation

Consider adding 100 to the list of fee tiers

L-04 Use of non-upgradable ReentrancyGuard

ParticlePositionManager is an upgradeable contract. Therefore it uses both Ownable2StepUpgradeable and UUPSUpgradeable from OZ. It does not however use ReentrancyGuardUpgradeable but instead ReentrancyGuard:

ParticlePositionManager.sol#L19-L26:

File: contracts/protocol/ParticlePositionManager.sol

19: contract ParticlePositionManager is
20:    IParticlePositionManager,
21:    Ownable2StepUpgradeable,
22:    UUPSUpgradeable,
23:    IERC721Receiver,
24:    ReentrancyGuard,
25:    Multicall
26: {

If ReentrancyGuard was upgraded in the future with new state this could cause issues for ParticlePositionManager.

Recommendation

Consider using ReentrancyGuardUpgradeable from OZ.

L-05 Some tokens revert on 0 amount transfer

ParticlePositionManager::liquidatePosition:

File: protocol/ParticlePositionManager.sol

376:        // reward liquidator
377:        TransferHelper.safeTransfer(closeCache.tokenFrom, msg.sender, liquidateCache.liquidationRewardFrom);
378:        TransferHelper.safeTransfer(closeCache.tokenTo, msg.sender, liquidateCache.liquidationRewardTo);

Some tokens, like LEND revert on 0 amount transfer. If the liquidation amount is 0 for this token this would make liquidation impossible for this position.

Recommendation

Consider checking if the liquidationAmount > 0 before transferring liquidation reward.

L-06 Uniswap positions minted to ParticlePositionManager are stuck

Using the NonfungiblePositionManager directly a user could potentially mint a position directly to ParticlePositionManager by having ParticlePositionManager as the receiver when calling NonfungiblePositionManager::mint.

Since NonfungiblePositionManager doesn't use safeMint this would just transfer the token and liquidity to ParticlePositionManager without calling ParticlePositionManager::onERC721Received, thus the liquidity would be lost.

Recommendation

Consider adding a rescue function callable by owner that can transfer any accidentally minted tokens out of the contract. This could check that ParticlePositionManager is the owner of the token but has lps.owner == address(0) to prevent misuse.

Informational / Non crit

NC-01 Liquidation criteria very complicated

ParticlePositionManager::liquidatePosition:

File: contracts/protocol/ParticlePositionManager.sol

358:        // check for liquidation condition
359:        ///@dev the liquidation condition is that
360:        ///     (EITHER premium is not enough) OR (cutOffTime > startTime AND currentTime > startTime + LOAN_TERM)
361:        if (
362:            !((closeCache.tokenFromPremium < liquidateCache.tokenFromOwed ||
363:                closeCache.tokenToPremium < liquidateCache.tokenToOwed) ||
364:                (lien.startTime < lps.getRenewalCutoffTime(lien.tokenId) &&
365:                    lien.startTime + LOAN_TERM < block.timestamp))
366:        ) {
367:            revert Errors.LiquidationNotMet();
368:        }

Even with the comment it is very hard to understand this criteria.

I suggest to move it to a function canBeLiquidated or similar where the logic can be broken up into multiple blocks and made easier to comprehend.

NC-02 Confusing parameter naming in ParticlePositionManager::swap

It's called token0/1 but later in the swap library its tokenFrom/To.

The parameters would be easier to understand if they were from/to in ParticlePositionManager::swap as well.

NC-03 Erroneous comment

ParticlePositionManager::_closePosition:

File: contracts/protocol/ParticlePositionManager.sol

422:        // add liquidity back to borrower

borrower is not correct here, liquidity is added back to the lender/liquidity provider.

#0 - c4-judge

2023-12-26T11:23:43Z

0xleastwood marked the issue as selected for report

#1 - c4-judge

2023-12-26T11:23:46Z

0xleastwood marked the issue as grade-a

#2 - c4-sponsor

2023-12-29T04:12:21Z

wukong-particle (sponsor) confirmed

#3 - 0xleastwood

2023-12-30T12:09:09Z

I consider all these issues to be valid.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter