moveLiquidity() set new destination index LP entry deposit time to be equal to the source index deposit time, while destination bucket might have defaulted after that time.
This is generally not correct as source bucket bankruptcy is controlled (i.e. LP shares that are moved are healthy), while the destination bucket's bankruptcy time, being arbitrary, can be higher than source index deposit time, and in this case the funds will become inaccessible after such a move (i.e. healthy shares will be marked as defaulted due to incorrect deposit time used).
In other words the funds are moved from healthy non-default zone to an arbitrary point, which can be either healthy or not. In the latter case this constitutes a loss for an owner as toIndex bucket bankruptcy time exceeding deposit time means that all other retrieval operations will be blocked.
Impact
Owner will permanently lose access to the LP shares whenever positions[params_.tokenId][params_.toIndex] bucket bankruptcy time is greater than positions[params_.tokenId][params_.fromIndex].depositTime.
moveLiquidity() is a common operation, while source and destination bucket bankruptcy times can be related in an arbitrary manner, and the net impact is permanent fund freeze, so this is a fund loss without material prerequisites, setting the severity to be high.
Proof of Concept
moveLiquidity() sets toPosition deposit time to be fromPosition.depositTime:
function moveLiquidity(
MoveLiquidityParams calldata params_
) external override mayInteract(params_.pool, params_.tokenId) nonReentrant {
Position storage fromPosition = positions[params_.tokenId][params_.fromIndex];
MoveLiquidityLocalVars memory vars;
>> vars.depositTime = fromPosition.depositTime;
// handle the case where owner attempts to move liquidity after they've already done so
if (vars.depositTime == 0) revert RemovePositionFailed();
// ensure bucketDeposit accounts for accrued interest
IPool(params_.pool).updateInterest();
// retrieve info of bucket from which liquidity is moved
(
vars.bucketLP,
vars.bucketCollateral,
vars.bankruptcyTime,
vars.bucketDeposit,
) = IPool(params_.pool).bucketInfo(params_.fromIndex);
// check that bucket hasn't gone bankrupt since memorialization
if (vars.depositTime <= vars.bankruptcyTime) revert BucketBankrupt();
// calculate the max amount of quote tokens that can be moved, given the tracked LP
vars.maxQuote = _lpToQuoteToken(
vars.bucketLP,
vars.bucketCollateral,
vars.bucketDeposit,
fromPosition.lps,
vars.bucketDeposit,
_priceAt(params_.fromIndex)
);
EnumerableSet.UintSet storage positionIndex = positionIndexes[params_.tokenId];
// remove bucket index from which liquidity is moved from tracked positions
if (!positionIndex.remove(params_.fromIndex)) revert RemovePositionFailed();
// update bucket set at which a position has liquidity
// slither-disable-next-line unused-return
positionIndex.add(params_.toIndex);
// move quote tokens in pool
(
vars.lpbAmountFrom,
vars.lpbAmountTo,
) = IPool(params_.pool).moveQuoteToken(
vars.maxQuote,
params_.fromIndex,
params_.toIndex,
params_.expiry
);
Position storage toPosition = positions[params_.tokenId][params_.toIndex];
// update position LP state
fromPosition.lps -= vars.lpbAmountFrom;
toPosition.lps += vars.lpbAmountTo;
// update position deposit time to the from bucket deposit time
>> toPosition.depositTime = vars.depositTime;
I.e. there is no check for params_.toIndex bucket situation, the time is just copied.
While there is checking logic in LenderActions, which checks for toBucket bankruptcy and sets the time accordingly:
vars.toBucketDepositTime = toBucketLender.depositTime;
if (vars.toBucketBankruptcyTime >= vars.toBucketDepositTime) {
// bucket is bankrupt and deposit was done before bankruptcy time, reset lender lp amount
toBucketLender.lps = toBucketLP_;
// set deposit time of the lender's to bucket as bucket's last bankruptcy timestamp + 1 so deposit won't get invalidated
vars.toBucketDepositTime = vars.toBucketBankruptcyTime + 1;
} else {
toBucketLender.lps += toBucketLP_;
}
// set deposit time to the greater of the lender's from bucket and the target bucket
toBucketLender.depositTime = Maths.max(vars.fromBucketDepositTime, vars.toBucketDepositTime);
This way, while bucket structure deposit time will be controlled and updated, PositionManager's structure will have the deposit time copied over.
In the case when positions[params_.tokenId][params_.fromIndex].depositTime was less than params_.toIndexbankruptcyTime, this will freeze these LP funds as further attempts to use them will be blocked:
function moveLiquidity(
MoveLiquidityParams calldata params_
) external override mayInteract(params_.pool, params_.tokenId) nonReentrant {
Position storage fromPosition = positions[params_.tokenId][params_.fromIndex];
MoveLiquidityLocalVars memory vars;
>> vars.depositTime = fromPosition.depositTime;
// handle the case where owner attempts to move liquidity after they've already done so
if (vars.depositTime == 0) revert RemovePositionFailed();
// ensure bucketDeposit accounts for accrued interest
IPool(params_.pool).updateInterest();
// retrieve info of bucket from which liquidity is moved
(
vars.bucketLP,
vars.bucketCollateral,
>> vars.bankruptcyTime,
vars.bucketDeposit,
) = IPool(params_.pool).bucketInfo();
// check that bucket hasn't gone bankrupt since memorialization
>> if (vars.depositTime <= vars.bankruptcyTime) revert BucketBankrupt();
function moveLiquidity(
MoveLiquidityParams calldata params_
) external override mayInteract(params_.pool, params_.tokenId) nonReentrant {
Position storage fromPosition = positions[params_.tokenId][params_.fromIndex];
MoveLiquidityLocalVars memory vars;
vars.depositTime = fromPosition.depositTime;
...
Position storage toPosition = positions[params_.tokenId][params_.toIndex];
// update position LP state
fromPosition.lps -= vars.lpbAmountFrom;
toPosition.lps += vars.lpbAmountTo;
- // update position deposit time to the from bucket deposit time
+ // update position deposit time with the renewed to bucket deposit time
+ (, vars.depositTime) = pool.lenderInfo(params_.toIndex, address(this));
toPosition.depositTime = vars.depositTime;
Notice, that this time value will be influenced by the other PositionManager positions in the params_.toIndex bucket, but the surface described will be closed as it will be controlled against params_.toIndex bucket bankruptcy time.
Lines of code
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L262-L323
Vulnerability details
moveLiquidity() set new destination index LP entry deposit time to be equal to the source index deposit time, while destination bucket might have defaulted after that time.
This is generally not correct as source bucket bankruptcy is controlled (i.e. LP shares that are moved are healthy), while the destination bucket's bankruptcy time, being arbitrary, can be higher than source index deposit time, and in this case the funds will become inaccessible after such a move (i.e. healthy shares will be marked as defaulted due to incorrect deposit time used).
In other words the funds are moved from healthy non-default zone to an arbitrary point, which can be either healthy or not. In the latter case this constitutes a loss for an owner as
toIndex
bucket bankruptcy time exceeding deposit time means that all other retrieval operations will be blocked.Impact
Owner will permanently lose access to the LP shares whenever
positions[params_.tokenId][params_.toIndex]
bucket bankruptcy time is greater thanpositions[params_.tokenId][params_.fromIndex].depositTime
.moveLiquidity() is a common operation, while source and destination bucket bankruptcy times can be related in an arbitrary manner, and the net impact is permanent fund freeze, so this is a fund loss without material prerequisites, setting the severity to be high.
Proof of Concept
moveLiquidity() sets
toPosition
deposit time to befromPosition.depositTime
:https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L262-L323
I.e. there is no check for
params_.toIndex
bucket situation, the time is just copied.While there is checking logic in LenderActions, which checks for
toBucket
bankruptcy and sets the time accordingly:https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/libraries/external/LenderActions.sol#L315-L327
This way, while bucket structure deposit time will be controlled and updated, PositionManager's structure will have the deposit time copied over.
In the case when
positions[params_.tokenId][params_.fromIndex].depositTime
was less thanparams_.toIndex
bankruptcyTime
, this will freeze these LP funds as further attempts to use them will be blocked:https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L262-L285
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L352-L372
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L436-L443
Recommended Mitigation Steps
Consider using the resulting time of the destination position, for example:
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L262-L323
Notice, that this time value will be influenced by the other PositionManager positions in the
params_.toIndex
bucket, but the surface described will be closed as it will be controlled againstparams_.toIndex
bucket bankruptcy time.Assessed type
Error