Poolshark engaged Guardian to review the security of their Directional AMM Cover Pool. From the 17th of March to the 14th of April, a team of 3 auditors reviewed the source code in scope. The auditing approach championed manual analysis to uncover novel exploits and verify intended behavior with ancillary verification from formal methods such as fuzzing and symbolic execution. All findings and remediations have been recorded in the following report.
- Published
- Review window
- March 17 to April 14, 2023
- Language
- Solidity
- Chains
- Arbitrum, Scroll
- Sector
- DEXs and AMMs
- 10 Critical
- 6 High
- 7 Medium
- 12 Low
- 0 Informational
Scope
Overview
Poolshark engaged Guardian to review the security of their Directional AMM Cover Pool. From the 17th of March to the 14th of April, a team of 3 auditors reviewed the source code in scope. The auditing approach championed manual analysis to uncover novel exploits and verify intended behavior with ancillary verification from formal methods such as fuzzing and symbolic execution. All findings and remediations have been recorded in the following report.
Issues Detected Throughout the course of the audit numerous high impact issues were uncovered and promptly remediated by the Poolshark team. Several issues impacted the fundamental behavior of the protocol, following their remediation Guardian believes the protocol to uphold the standards set forth in the Poolshark whitepaper.
Code Quality From the 17th of March to the 14th of April, the codebase quality improved considerably. However, it is recommended to improve in-code documentation supporting NatSpec standards and to address all outstanding comments. Additionally, given the scope of changes made to the codebase, Guardian supports an independent security audit of the protocol at a finalized frozen commit.
Findings 35
-
CP-1 Critical Outdated Swap Pricing Logical Error Resolved
Description
Proof of concept: PoC
The
PoolState memory poolvariable is loaded into memory before thepool0orpool1storage variables are updated bysyncLatest. This can yield an outdatedpool.pricewhen thesyncLatestcall would have updated the relevant pool price.When this outdated
pool.priceis used to compute themaxDxormaxDy, it can result in an unchecked underflow allowing virtually any amount of tokens to be swapped in a single auction. Therefore the entirety of the pool’s liquidity may be used at the current tick, regardless of the ranges each LPer wanted their position to be active over.Recommendation
Load the memory pool variable into memory after the
syncLatestfunction is called.Resolution
Poolshark Team: The recommendation was implemented in commit 8414f63.
-
CL-1 Critical Users Cannot Burn/Claim Due To Underflow Underflow Resolved
Description
Proof of concept: PoC
When users attempt to burn their position at a valid
claimTick, the transaction reverts due to an underflow as thecache.finalDeltas.amountOutDeltaMaxis greater than theamountOutDeltaMaxstored on the position’s end tick.When burning the
cache.finalDeltasfrom theupdateTick, thecache.finalDeltas.amountOutDeltaMaxshould never be greater than theupdateTick.deltas.amountOutDeltaMax.Recommendation
Ensure that the
cache.finalDeltas.amountOutDeltaMaxis never greater than theupdateTick.deltas.amountOutDeltaMaxwhen burning thecache.finalDeltasfrom theupdateTick.deltas.Additionally, add logic in
Deltas.burnto protect against underflow in the event that rounding would result in an underflow revert and prevent users from burning.Resolution
-
DT-1 Critical Errant Deltas.to Calculation Typo Resolved
Description
Proof of concept: PoC
In
Deltas.tothefromDeltas.amountOutDeltaMaxis added to thetoTick.deltas.amountOutDelta. Since anamountOutDeltaMaxvalue is treated as anamountOutDeltavalue, moreamountOutis errantly attributed to thetoTick.This can prevent users from burning as more funds may be attempted to be transferred than are in the contract. Furthermore, in many cases, assets that belong to other users’ positions will be transferred out, potentially causing catastrophic loss.
Recommendation
Replace the
toTick.amountOutDelta += fromDeltas.amountOutDeltaMaxwithtoTick.deltas.amountOutDelta += fromDeltas.amountOutDelta.Resolution
Poolshark Team: The recommendation was implemented in commit b924d61.
-
EP-1 Critical Pool Bricked Due To Cleared liquidityDelta Logical Error Resolved
Description
Proof of concept: PoC
When the price from the TWAP reverses directions, the active liquidity is not “stashed” on the
stopTick.This can lead to the
CoverPoolbeing bricked in the following scenario:- The TWAP price begins at tick 20.
- Alice creates a position from tick 0 to tick -60 in
pool0. - The TWAP price goes down to tick -20, entering Alice’s position.
- Because tick 0 is a cross tick while syncing, the
liquidityDeltaon it will be cleared. - Now the TWAP price goes back up to tick 0, and the liquidity for
pool0is zeroed out without being stashed. - As the TWAP price goes down and crosses tick -60, in the
_crossfunction thecurrentLiquiditywill be 0 but theliquidityDeltaon the lower tick of her position will still exist. As a result, the end tickliquidityDeltasubtracted from zero will underflow foruint.
The end result is that the
CoverPoolis bricked once the TWAP tick goes below Alice’s lower tick.Recommendation
When reversing directions and zeroing out the
pool.liquidity, “stash” this remaining liquidity onto thestopTickso that it may be reactivated when the TWAP price continues in that direction.Resolution
Poolshark Team: The recommended fix was implemented in 79e2bb6.
-
PS-1 Critical Fully Filled Auction Double Counted Double Counting Resolved
Description
Proof of concept: PoC1
In cases where an auction is fully filled at the time of claiming, the position ought to be shrunk so that the user is not able to claim again for this auction as a past auction.
However, since the position continues to include the previously filled auction tick, users who claim from a currently filled auction are credited with more tokens then they should be, effectively stealing from others in the pool.
Recommendation
When a user is claiming from a fully filled auction, shrink the position so that the user is not errantly credited with more tokens than they should be.
Resolution
Poolshark Team: The recommendation was implemented in commit f4c6cf1.
-
EP-2 Critical Incomplete amountOutDelta Rollover Logical Error Resolved
Description
Proof of concept: PoC
When syncs jump multiple ticks at a time and the direct
nextTickToAccumdoes not exist in theTickMap, it is possible for users to experience significant loss of assets when theamountOutDeltacalculations in_rolloverare restricted to the range between thepool.priceand thecrossPrice.This is because the range restriction in
_rolloverleaves out potentially several ticks that should be accounted for in theamountDeltacalculations.Recommendation
Consider initializing the
nextTickToAccum0andnextTickToAccum1ticks if they don’t exist in theTickMapwhen creating the cache insyncLatest.Alternatively, when TWAP updates span more than the
tickSpread, include anamountOutDeltacalculation from the auction starting price to theaccumPricein_rollover.Resolution
Poolshark Team: The recommendation was implemented in commit b87f161.
-
EP-3 Critical Invalid Epoch Stamping on Pool 1 Typo Resolved
Description
During
syncLatest,pool1is utilizingpool0ticks when updating theEpochMap. In many cases this results in users being locked into their positions and unable to exit due to their end tick being unclaimable and a previousclaimTickyielding an underflow.if (cache.nextTickToAccum0 > cache.stopTick0 && ticks0[cache.nextTickToAccum0].liquidityDeltaMinus > 0) { EpochMap.set(tickMap, cache.nextTickToAccum0, state.accumEpoch); }Recommendation
Use
pool1ticks to update the ticks forpool1:if (cache.nextTickToAccum1 < cache.stopTick1 && ticks1[cache.nextTickToAccum1].liquidityDeltaMinus > 0) { EpochMap.set(tickMap, cache.nextTickToAccum1, state.accumEpoch); }Resolution
Poolshark Team: The recommendation was implemented in commit a77bd18.
-
EP-4 Critical Invalid Tick Resulting From TWAP Ratelimiting Logical Error Resolved
Description
Proof of concept: PoC
When the
state.lastBlock - state.auctionStartis not an exact multiple of theauctionLength, the resultingmaxLatestTickMoveis not a multiple of thetickSpread.This results in positions being created on invalid ticks and swaps receiving more than the available liquidity for the active auction.
Recommendation
Adjust the
maxLatestTickMoveso that it is a valid multiple of thetickSpread.Resolution
Poolshark Team: The recommendation was implemented in commit f0d52ad.
-
CL-2 Critical amountOutDeltaMax Double Counted In Section2 Double Counting Resolved
Description
Proof of concept: PoC
When instantiating the cache using
Claims.getDeltasinPositions.updatetheamountOutDeltaMaxStashedis unstashed onto thecache.deltas.amountOutDeltaMax.The
cache.deltas.amountOutDeltaMaxis ultimately removed from the position’s end tick.However, in
section2this same value is removed from the position’s end tick a second time, therefore perturbing the position’s accounting and locking the position in the pool.Recommendation
Do not remove the same
amountOutDeltaMaxvalue from the position’s end tick insection2.E.g. remove the following line from
section2:params.zeroForOne ? ticks[params.lower].deltas.amountOutDeltaMax -= amountOutUnfilledMax : ticks[params.upper].deltas.amountOutDeltaMax -= amountOutUnfilledMax;Resolution
Poolshark Team: The recommendation was implemented in commit c9a3a42.
-
EP-5 Critical Liquidity Double Counted At Position End Double Counting Resolved
Description
Proof of concept: PoC
It is possible for the pool to have active liquidity after the end of a position because the
liquidityDeltaMinusdouble counts a portion of the current active liquidity stashed on thestopTick.Consider the following scenario in pool0:
- Alice creates a position from tick -20 to tick -60.
- The TWAP goes down to tick -40, entering Alice’s position.
- The TWAP goes up to tick -20. During the
stash, the pool’s active liquidity is added to theliquidityDeltaon thestopTick, -60. Additionally, Alice’sliquidityDeltaMinusis added to thestopTick, double counting her active liquidity. - The TWAP goes down to tick -60. Although this is the end of Alice’s position,
pool0.liquidityis now non-zero as her liquidity was stashed onto this tick twice, negating the negativeliquidityDeltaat the end of her position.
Recommendation
When crossing the end of a position, account for the extra
liquidityDeltaby subtractingstashTick.liquidityDeltaMinus. Otherwise, remove theliquidityDeltaMinusaltogether.Resolution
Poolshark Team: The recommendation was implemented in commit b8d5c44.
-
CL-3 High Stolen Deltas Logical Error Resolved
Description
Proof of concept: PoC
A user is able to steal a portion of another user’s tokens due to their
amountOutDeltaMaxbeing considered during a claim even if they did not contribute to the currentamountOutDeltaon theclaimTick. As a result, thepercentOutDeltacalculation will attribute tokens for a user when they should not be.Consider the following scenario:
- Price is at tick 0
- Bob mints a position for 100 tokens from 20 to 60
- Price goes past Bob’s claim tick and then back down to tick 20
- Alice mints a position for 100 tokens from 40 to 60
amountOutDeltaMaxon tick 60 is 200 andamountOutDeltais 100- Bob burns his entire liquidity with claim tick 60 but only receives 50% of his tokens
- Alice burns her entire liquidity afterwards and receives 150 tokens, stealing 50 tokens from Bob.
Recommendation
Create another parameter to ignore some
amountOutDeltaMaxif a user has not contributed to a tick’samountOutDelta, similar to the paradigm betweenliquidityDeltaandliquidityDeltaMinus.Resolution
Poolshark Team: The recommendation was implemented in commit 72f87d3.
-
CL-4 High Locked Liquidity Due To Rounding Underflow Resolved
Description
Proof of concept: PoC
Due to rounding in
section3, in some cases users will not be able to burn all of their liquidity sinceamountOutRemovedis 1 wei greater than theamountOutDeltaMaxstored on the position’s end tick. This will lead to the user's tx reverting with underflow if the user burns for most of their liquidity.Recommendation
Consider performing an explicit check to see whether
amountOutRemovedis greater than the tick’samountOutDeltaMax. If so, set theamountOutDeltaMaxto 0.Resolution
Poolshark Team: The recommendation was implemented in commit ba281fa.
-
PS-2 High The SafetyWindow Can Be Circumvented Logical Error Resolved
Description
Users may still create positions that begin inside of the
safetyWindowdue to the following check:if (params.zeroForOne) { if (params.lower > cache.requiredStart) revert PositionInsideSafetyWindow(); } else { if (params.upper < cache.requiredStart) revert PositionInsideSafetyWindow(); }The validation occurs on the position’s end tick rather than the start tick. Therefore users can create positions that begin before the
cache.requiredStart. In the event that the position’s start tick is at or before thestate.latestTick, it will be adjusted to thestate.latestTick +/- state.tickSpread. This adjustment will still lie within thesafetyWindow. However, if thesafetyWindowvalidation above is corrected, this adjustment logic can be removed as users should never be able to create positions where the beginning of the position is before or equal to thestate.latestTick.Recommendation
Correct the above validation to compare against the
params.upperin thepool0case and theparams.lowerin thepool1case.Alternatively correct the shrinking logic to appropriately shrink the beginning of the position to outside of the
safetyWindowby adjusting it to therequiredStartif it is before therequiredStart.Resolution
-
PS-3 High Min Auction Amount Adjusted Twice Logical Error Resolved
Description
In the
Positions.validatefunction,minAmountPerAuctionhas already been adjusted totoken1precision when line 146 is reached, yetminAmountPerAuctionis adjusted once again totoken1precision. As a result,minAmountPerAuctionwill be significantly smaller than intended (often 0), and the validation will be rendered useless.Consider the following scenario:
minAmountPerAuction= 1e18token1Decimals= 6minAmountPerAuction= 1e18 / 1e12 / 1e12 = 0
Recommendation
Remove the second adjustment.
Resolution
Poolshark Team: The recommendation was implemented in commit 113a0e0.
-
DT-3 High Incorrect Output Amount On Overlapping Positions Logical Error Resolved
Description
Proof of concept: PoC
It is possible for a user to be credited with filled amounts that do not belong to them from a previous auction. This is because a stashed tick contains the
deltaMaxvalues for all positions — regardless of if they had already claimed from the stashed auction result.Consider the following scenario:
- Alice mints a position from tick 20 to tick 80 for 100 tokens.
- Bob mints a position from tick 20 to tick 60 for 100 tokens.
- The TWAP moves up to tick 20, 83 tokens are swapped.
- The TWAP moves to tick 40, Bob burns half his liquidity receiving 50
tokenInand 25tokenOut. - The TWAP moves to tick 60, Bob burns his remaining liquidity. Bob receives 9 more
tokenInalthough his entire share of the auction was already claimed. - The TWAP moves to tick 80, Alice closes her position. Alice receives 24
tokenInalthough her share should have been 33tokenIn. Bob took 9 of Alice’stokenIn.
Recommendation
Account for users having already claimed from past auctions or creating positions and being errantly credited with filled amounts from past auctions. Otherwise document this behavior and implement a “safety window” so that this mechanism cannot be harnessed to vamp filled amounts from LPers.
Resolution
Poolshark Team: The suggested “safety window” was implemented in commit 36cb7a4.
-
EP-6 High Uncrossed Ticks Are Set In The EpochMap Logical Error Resolved
Description
Proof of concept: PoC
The
cache.nextTickToAccum0is set in theEpochMapeven when thecache.nextTickToAccum0is not crossed e.g. it is past thestopTick0.Therefore users are unable to claim at the right tick and are able to claim at a tick that has not yet been accumulated to, perturbing the pool accounting.
Recommendation
Only set the
cache.nextTickToAccum0in theEpochMapif it is being crossed into.Resolution
Poolshark Team: The recommendation was implemented in commit 74e646b.
-
CL-5 Medium Rounding Up In Section5 Logical Error Resolved
Description
Proof of concept: PoC
In
section5, an extra wei may be added to the position’samountOutdue to rounding up. Therefore, in some cases more funds are attempted to be transferred than are in the contract. Otherwise, 1 wei is taken from another user’s position.Recommendation
Consider switching to
Deltas.maxor perform explicit handling.Resolution
Poolshark Team: The recommendation was implemented in commit e308d31.
-
TK-1 Medium Unused State Variable For Safety Check Validation Resolved
Description
state.liquidityGlobalis never set, so the liquidity overflow validation only catches amounts greater thantype(int128).max.Recommendation
Set the
state.liquidityGlobal.Resolution
Poolshark Team:
state.liquidityGlobalis now updated in commit d15bb47. -
EP-7 Medium Reference Pool Tick Always Rounded Down Logical Error Resolved
Description
The TWAP tick of the reference pool is always rounded down. It may be beneficial to round up in certain cases to achieve a more accurate price point, and increase the speed of liquidity unlocking.
Recommendation
Consider rounding the TWAP tick of the reference pool to the nearest valid tick rather than always rounding down to the lower valid tick.
Resolution
Poolshark Team: The TWAP tick now shifts by quartiles in commit db9e57e.
-
GLOBAL-1 Medium Use Of block.number On Arbitrum Compatibility Resolved
Description
Throughout the codebase,
block.numberis used to perform syncs and determineauctionDepth.However,
block.numberis synced with the mainnet block number every minute. Therefore less syncs will occur and theauctionDepthcan be out of date on the order of ~4 blocks at the maximum.Recommendation
Consider using
ArbSys(100).arbBlockNumber()to rely on Arbitrum block numbers that are available in real-time.Resolution
Poolshark Team:
block.timestampwas adopted to replaceblock.numberin commit 116830b. -
CP-2 Medium Read-Only Reentrancy Reentrancy Resolved
Description
In the
mint,burnandswapfunctions, theglobalStatestorage variable is only updated after a token has been transferred to the recipient.If the token is an ERC777 token and the receiver implements the
tokensReceivedhook, a potential read-only reentrancy arises in thequotefunction because protocol parameters such aslatestPrice,auctionStart, and others are out of date.Recommendation
Utilize the Check-Effects-Interactions pattern
Resolution
Poolshark Team: The recommendation was implemented in commit 8b7eda9.
-
CPF-1 Medium No Minimum TWAP Length Validation Resolved
Description
Currently, there is no minimum bound on the
twapLength, however, an insufficiently largetwapLengthwill lead to viable oracle manipulation attacks.Additionally, a
twapLengthof 0 will break the protocol, causing a panic revert when computing theaverageTickin_calculateAverageTick.Recommendation
Implement a minimum
twapLength.Resolution
Poolshark Team: The recommendation was implemented in commit 2e9d570.
-
CP-3 Medium Users Can Update Other Positions Validation Resolved
Description
Users are able to update arbitrary positions as the
UpdateParamsin the add function utilizeparams.torather than themsg.senderas the owner.This way users can manipulate the fill percentage of others by forcing them to update their position when the pool fill percentage is unfavorable.
Recommendation
Use the
msg.senderas the owner for thePositions.updatecall.Resolution
Poolshark Team: The recommendation was implemented in commit c703e95.
-
CP-3 Low Unexpected Behavior When Minting Unexpected Behavior Resolved
Description
When a user mints to add to their existing position that was already crossed into, they end up with two positions: the previous position that was shrunk upon the
Positions.update, and a newly minted positions that spans the original range.This may lead to confusion as users may have expected to end up with a single position with added liquidity, rather than two separate positions.
Recommendation
Consider if the
mintfunction should add liquidity to the newly shrunk position, otherwise ensure the existing behavior is well documented.Resolution
Poolshark Team: This is the expected behavior and it will be well documented.
-
CP-4 Low Outdated Docs Documentation Resolved
Description
In the documentation for the
swapfunction, it is stated that “The router must prefund this contract…”, however theswapfunction transfers in theamountInwith a call to_transferIn.Recommendation
Update the docs for the
swapfunction.Resolution
Poolshark Team: The outdated docs were removed in commit 1ef04c0.
-
ST-1 Low Use Of Transfer To Send Ether Best Practices Acknowledged
Description
Although currently only ERC20 tokens are supported, the protocol would be incompatible with other contracts, arbitrageurs, and multisig functions if it sent Ether due to the use of
transferinSafeTransfers._transferOut.transferforwards only 2300 gas to protect from reentrancy, however, this hard-coded gas limit should be avoided as other protocols and contracts building on top of Poolshark may consume more than 2300 gas in theirfallback/receivefunction.Note that there are some multi-sig wallets that use more than 2300 gas in the
fallbackfunction.Additionally, gas prices for certain opcodes may change in the future which would force
fallback/receivefunctions that currently consume <2300 gas to consume >2300 gas and therefore become incompatible.In the event that a contract cannot receive Ether due to this gas limitation, it may result in loss of funds.
Recommendation
Consider using
callwith a configurable gas limit that can be set sufficiently high and adding alockmodifier everywhere these transfer functions are used and can potentially reenter.Resolution
Poolshark Team: Native token transfers are not used in the system at the moment, so no code change will be made at this time.
-
TK-2 Low Unnecessary storage manipulation Optimization Resolved
Description
In the
Ticks.removefunction, whenremoveUpperandremoveLowerarefalse, there are unnecessary storage manipulations that result in no net changes to theticksmapping.Recommendation
Only do these storage reads and writes inside of the conditionals where the
tickLowerandtickUpperare modified.Resolution
Poolshark Team: The recommendation was implemented in commit 9de5d31.
-
CP-5 Low Unused CollectParams.to Unused Feature Resolved
Description
In the
_collectfunction theCollectParams.tovalue is not used, and rather the claimed amount is always sent to themsg.sender.Recommendation
Use the
CollectParams.toaddress when transferring claimed amounts to the user.Resolution
Poolshark Team: The recommendation was implemented in commit e18ee82.
-
EP-8 Low Lack Of Underflow Protection Underflow Resolved
Description
In the
_rolloverfunction, there is underflow protection for the maxes forpool0, but not forpool1.Recommendation
Add underflow protection for
pool1.Resolution
Poolshark Team: The underflow protection for
pool1was implemented in commit 6d12f2d. -
TK-3 Low Revert Rather Than No-op On priceLimit Optimization Resolved
Description
Rather than returning a default state and allowing execution to continue, the tx should revert in the case that the
priceLimitis unsatisfiable.Recommendation
Consider reverting rather than returning default values, or choose to revert in the
swapfunction when theamountOutis 0.Resolution
Poolshark Team: This behavior allows users to still trigger a
syncLatestin the event that theirpriceLimitis unsatisfied. -
TK-4 Low Superfluous nextTickPrice Variable Optimization Resolved
Description
The
nextTickPricevariable is assigned to thestate.latestPriceand then immediately thenextPriceis assigned to thenextTickPrice. ThenextTickPriceis then never referenced again.Recommendation
Remove the
nextTickPricevariable and assign thenextPriceto thestate.latestPricedirectly.Resolution
Poolshark Team: The recommendation was implemented in commit 3a0d67a.
-
GLOBAL-2 Low Unused Q128 Variable Optimization Resolved
Description
The
Q128constant is never referenced after assignment in theTicks EpochsandPositionslibraries.Recommendation
Remove the
Q128constant.Resolution
Poolshark Team: The recommendation was implemented in commit b97c088.
-
GLOBAL-3 Low SafeCast Overflow Pending
Description
Throughout the codebase, casting operations are performed. Downcasting does not revert on overflow, therefore it would be prudent to use OpenZeppelin’s
SafeCastto revert in these cases.Recommendation
Consider using OpenZeppelin’s
SafeCastto protect against undetected overflow.Resolution
Pending Fix
-
GLOBAL-4 Low Variables Could Be Made Immutable Optimization Resolved
Description
Variables such as
tickSpreadandauctionLengthin the global state can be madeimmutable.Recommendation
Declare these variables
immutable.Resolution
Poolshark Team: The recommendation was implemented in commit 4346660.
-
EP-9 Low syncLatest Simplifications Optimization Resolved
Description
Proof of concept: PoC
The
syncLatestfunction can be simplified in the following ways:- The additional
_crossfunction call for thestopTickcan be deduplicated if the check inside thewhileloop is modified fromcache.nextTickToAccum0 > cache.stopTick0tocache.nextTickToAccum0 >=cache.stopTick0. - The two cases where the
stopTickis set in theTickMapif newLatestTick > state.latestTickornewLatestTick < state.latestTickcan be consolidated into onenewLatestTick != state.latestTickcondition. - The increment and decrement of
stopTick.liquidityDeltabyliquidityDeltaMinuscan be removed as there is no net effect.
Recommendation
Implement the suggested simplifications.
Resolution
Poolshark Team: The suggested simplifications were implemented in 8edd1ce.
- The additional
No findings match.
More from Poolshark
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
