Platform: Code4rena
Start Date: 26/09/2022
Pot Size: $50,000 USDC
Total HM: 13
Participants: 113
Period: 5 days
Judge: 0xean
Total Solo HM: 6
Id: 166
League: ETH
Rank: 2/113
Findings: 6
Award: $8,310.10
π Selected for report: 2
π Solo Findings: 2
π Selected for report: Jeiwan
3255.2724 USDC - $3,255.27
https://github.com/code-423n4/2022-09-quickswap/blob/main/src/core/contracts/AlgebraPool.sol#L880
Undercounted liquidity leads to incorrect trading volume reporting and affects the adaptive fee calculation. In most cases (when the majority of liquidity in a pool is concentrated around the current price) trading volume is overstated, which results in lowered swap fees.
According to the Tech Paper, trading volume is one of the three indicators that are used to compute the adaptive swap fee:
To find the optimal commission amount, depending on the nature of the asset's behavior, the following indicators are monitored:
- Volatility
- Liquidity
- Trading Volume
Further in the paper, it's shown how trading volume per liquidity is calculated:
Where $\widetilde{L}_t = \sqrt{V_1 * V_2}$ is the average trading volume of assets 1 and 2; $L_t$ is the liquidity that was used during a trade.
In Uniswap V3 (on which Algebra Finance is based), liquidity is concentrated in discrete price rangesβthis can clearly be seen on the official analytics dashboard. During swapping, the current price moves within a price range and, when there's not enough liquidity in the price range to satisfy the swap, jumps to adjacent liquidity positions. In the case of big swaps and narrow liquidity positions, the current price can traverse over multiple intermediary liquidity positions before the swap is fulfilled and end up in a different price range. In such situation, the liquidity required to fulfil the swap is made of:
According to the Tech Paper, it's expected that $L_t$ takes into account the liquidity in the initial and final price
ranges, as well as liquidity of the intermediary price ranges (since this is the liquidity required to make the swap).
However, in the code, it only counts the liquidity of the final price range (calculateVolumePerLiquidity
is called after the main swap loop):
https://github.com/code-423n4/2022-09-quickswap/blob/main/src/core/contracts/AlgebraPool.sol#L880
By the time the calculateVolumePerLiquidity
function is called, currentLiquidity
is the liquidity that's
available at the new price (the price after a swap), which includes only the price ranges that include the new price. During a swap, currentLiquidity
is updated multiple times
whenever new liquidity is added or removed while the price is traversing over liquidity positions. The liquidity of the
initial price range and all the intermediary price ranges is not counted by calculateVolumePerLiquidity
.
The idea of volume per liquidity calculation is somewhat similar to swap fees calculation: swap fees are also calculated on the liquidity engaged in a swap. In the code, swap fees are calculated in the movePriceTowardsTarget function, which is called inside the main swap loop and which receives currentLiquidity multiple times as the price moves from one price range to another and liquidity gets added and removed. Thus, swap fees are always calculated on the entire liquidity required for a swap while the trading volume computation counts only the price ranges that include the new price after a swap is done.
Calculated volume per liquidity on the entire liquidity required for a swap. Use the swap fee amount calculation as a reference.
#0 - 0xean
2022-10-04T13:44:18Z
Would like the sponsor to weigh in on this one prior to judging.
#1 - vladyan18
2022-10-04T16:08:03Z
Thank you!
Indeed, the calculation taking into account intermediate liquidity would be more correct according to the technical paper, although more expensive in terms of gas. The main purpose of this metric when calculating the adaptive commission is to reduce the commission when there is low activity in the pool. In case of sufficient activity, i.e. the ratio of volumes to liquidity, this metric does not affect the fee.
In the current implementation, large volumes will have a stronger impact on fees after swaps ending in "thin" liquidity. And less strongly when getting into more "thick" liquidity. Imo, this behavior is consistent with the purpose of the metric.
So, in my opinion, this is an issue because it does not exactly match the technical paper, but rather a QA or a medium.
#2 - 0xean
2022-10-06T17:58:20Z
Downgrading to M, as this is more a "leak of value" type issue.
2 β Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.
π Selected for report: Jeiwan
3255.2724 USDC - $3,255.27
https://github.com/code-423n4/2022-09-quickswap/blob/main/src/core/contracts/AlgebraPool.sol#L753 https://github.com/code-423n4/2022-09-quickswap/blob/main/src/core/contracts/AlgebraPool.sol#L833
In situations when calls to AlgebraVirtualPool
fail, swapping would fail as well until the issues with AlgebraVirtualPool
are resolved or activeIncentive
is unset.
During swapping, external calls are made to a AlgebraVirtualPool
contract when an activeIncentive
address is set:
Either of these calls can fail, which will result in a failed swap. In the case when AlgebraVirtualPool
fails consistently
(due to a misconfiguration or a bug in the contract), swapping would be not possible until the issues in the AlgebraVirtualPool
contract are resolved or until activeIncentive
is unset.
Short term, handle reverts in the external calls to AlgebraVirtualPool
. Long term, consider using the pull pattern to
synchronize changes: instead of pushing changes to AlgebraVirtualPool
from AlgebraPool
, pull
necessary data from AlgebraPool
by calling it from AlgebraVirtualPool
when needed.
π Selected for report: 0xSmartContract
Also found by: 0xDecorativePineapple, Jeiwan, berndartmueller, brgltd, kaden, rbserver
https://github.com/code-423n4/2022-09-quickswap/blob/main/src/core/contracts/AlgebraPool.sol#L604 https://github.com/code-423n4/2022-09-quickswap/blob/main/src/core/contracts/AlgebraPool.sol#L610
Pool users can be tricked into buying tokens from a destroyed token contract. Due to a missing contract existence check when pool transfers tokens, traders would send their tokens to a pool and get no tokens in exchange because the other token contract was destroyed by a malicious actor.
During swapping, the Pool contract exchanges tokens: it takes some amount of one pool token from a user and sends some amount of the other pool token in exchange. Depending on the swap direction, it sends either token0 or token1:
token0
, token1
is sent from the pool to the user;token1
, token0
is sent from the pool to the user.However, the TransferHelper.safeTransfer function doesn't check if a token contract exists. As per Solidity documentation:
The low-level functions call, delegatecall and staticcall return true as their first return value if the account called is non-existent, as part of the design of the EVM. Account existence must be checked prior to calling if needed.
Thus, when the token contract a pool sends tokens from was destroyed (assuming it's a malicious token contract), transferring tokens from the contract to a user would always succeed. No actual token transfer would be made because there would be no code at the address where the malicious token contract was deployed.
selfdestruct
;selfdestruct
on the malicious token contract;In the TransferHelper.safeTransfer
function, consider adding a contract existence check. This will guarantee that,
when a token contract doesn't exist, no swapping is made and users' funds don't get stuck in a pool contract without
users receiving no tokens in exchange.
#0 - 0xean
2022-10-02T23:56:55Z
duplicate of #86
π Selected for report: 0xSmartContract
Also found by: 0xDecorativePineapple, 0xNazgul, 0xmatt, Jeiwan, Trust, berndartmueller, brgltd, catchup, ch13fd357r0y3r, cryptonue, ladboy233, minhtrng, neko_nyaa, rbserver, rvierdiiev, s3cunda
35.4829 USDC - $35.48
https://github.com/code-423n4/2022-09-quickswap/blob/main/src/core/contracts/AlgebraPool.sol#L193 https://github.com/code-423n4/2022-09-quickswap/blob/main/src/core/contracts/AlgebraPoolDeployer.sol#L51
A malicious actor can initialize a newly deployed pool and set an incorrect price, without providing liquidity. A depositor of initial liquidity could mistakenly provide liquidity at the price set by the malicious actor, allowing the latter to swap tokens at the incorrect price and extract profit.
Pool contracts are initialized via the initialize function, which is not called during deployment:
initialize
function, the initial price is set.However, the caller is not required to deposit initial liquidity. Thus, the initial price can be set arbitrarily.
In a case when a malicious actor sets an incorrect initial price, an initial liquidity depositor will be forced to deposit liquidity at the incorrect price:
initialize
and sets the current price to 1 X = 100 Y;mint
to deposit liquidity; the amounts she has to deposit are calculated from the price set by Bob;Consider following solutions:
initialize
during pool deployment and let deployer choose the price;#0 - 0xean
2022-10-02T21:45:27Z
dupe of #84
1464.8726 USDC - $1,464.87
Pool price can be manipulated and set to an arbitrary value after pool was initialized and before initial liquidity was provided. A manipulation is also possible when the liquidity was removed from a tick.
AlgebraPool p = AlgebraPool(f.createPool(address(token0), address(token1))); // Initialize to some price. p.initialize(TickMath.MIN_SQRT_RATIO+1); // Swap when there's zero liquidity. The swap amount is 1 wei, the target // price is different from the current one, and it's much bigger. p.swap( address(this), false, // zeroToOne 1, TickMath.MAX_SQRT_RATIO-1, // limit price "" ); (uint160 price, int24 tick, , , , ,) = p.globalState(); // The swap allowed to manipulate the current price of the pool and set // it to an arbitrary value. assertEq(uint256(price), TickMath.MAX_SQRT_RATIO-1); assertEq(tick, TickMath.MAX_TICK-1);
In the above PoC, the swap amount is 1 wei and the limit price is arbitrary. When a swap amount is 1 wei:
amountAvailableAfterFee
is 0 on this line
((1 wei * (1e6 - fee)) // 1e6 == 0
);resultPrice
equals targetPrice
here;
targetPrice
is the limit price provided by user in the swap
call;resultPrice
is returned as currentPrice
here;globalState
here.initialize
and sets the current price to 1 X = 10 Y;swap
with 1 wei and sets the limit price to 1 X = 100 Y;mint
to deposit liquidity; the amounts she has to deposit are calculated from the price set by Bob;Consider disallowing swaps on ticks that don't have liquidity. This seems reasonable because it's liquidity that makes swaps possible, so, when there's no liquidity, swapping shouldn't be possible.
#0 - 0xean
2022-10-06T17:52:28Z
This is also possible when liquidity is really thin. In these situations, on-chain price cannot be trusted and isn't accurate. Given that, this becomes more a question of slippage controls so the user doesn't get front ran when attempting to provide liquidity at a "reasonable" price. Going to mark as a duplicate of #284
π Selected for report: 0xNazgul
Also found by: 0x1f8b, 0x52, 0xDecorativePineapple, 0xSmartContract, 0xmatt, Aeros, Aymen0909, Bnke0x0, Chom, CodingNameKiki, Deivitto, DimitarDimitrov, IllIllI, JC, Jeiwan, Lambda, Matin, Migue, Mukund, Ocean_Sky, Olivierdem, RaymondFam, RockingMiles, Rolezn, Ruhum, Satyam_Sharma, Shinchan, Tomo, Trabajo_de_mates, V_B, Waze, __141345__, a12jmx, ajtra, asutorufos, aysha, brgltd, bulej93, carrotsmuggler, catchup, cccz, chrisdior4, cryptonue, cryptphi, d3e4, defsec, delfin454000, durianSausage, erictee, fatherOfBlocks, gogo, kaden, karanctf, ladboy233, lukris02, mahdikarimi, martin, mics, natzuu, oyc_109, p_crypt0, pedr02b2, rbserver, reassor, rotcivegaf, rvierdiiev, sikorico, slowmoses, sorrynotsorry, tnevler, trustindistrust
52.0694 USDC - $52.07
dt
, however it's not there.onlyValidTicks
modifier. The function takes bottomTick
and topTick
in arguments.