Overview
This report covers the security review for GLIF Plus, an NFT that provides GLIF card holders with benefits, such as cashback on their FIL. This review included the new LpPlus and RWTFuture contracts, that allow holders to stake and get beneficial strike prices on their RWT to FIL conversions. Our security assessment was a full review of the code, spanning a total of 2 weeks. During our review, we did identified 2 High severity vulnerabilities, which could have resulted in incorrect distribution of FIL among the token holders. We also identified several minor severity vulnerabilities and code optimisations. All of our reported issues were fixed or acknowledged by the development team and consequently validated by us. We can confidently say that the overall security and code quality have increased af ter completion of our audit.
Scope
The analyzed resources are located on:
First week: https://github.com/glifio/plus/tree/9f7c95f3ab48c926a01087fecb9a3cf0505aa5c4
Second week: https://github.com/glifio/plus/tree/da0b62f611deed8ccf0f035761a3ad3344eae6b1
The issues described in this report were fixed in the following commit:
https://github.com/glifio/plus/tree/84d63653688d6d47102913d0d76de813f8ad516a
Summary
Weaknesses
This section contains the list of discovered weaknesses.
GLIF5-2 | INCORRECT CALCULATION OF STAKINGTOTALYBT WHEN SETTING WINDOW ROOT
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlusMerkleHelper.sol#L336-L388
Description:
The Merkle root and the staking snapshot for a specific window are set using the flow LpPlusMerkleHelper.setWindowRoot() -> LpPlus.commitWindow(). The function LpPlusMerkleHelper.setWindowRoot() is responsible for computing the necessary data, while LpPlus.commitWindow() only stores that data on-chain.
Inside LpPlusMerkleHelper.setWindowRoot() the value stakingTotalYbt is computed by the internal function _computeTwaStakingTotalYbt(). This stakingTotalYbt is then stored in the window as stakingSnapshot.stakingYbtTwab and used as the denominator when allocating FIL to users. In other words, for a given window, if a user has a time-weighted average YBT balance ybt, their FIL allocation is:
stakingSnapshot.totalFilToAllocate
------------------------------------------ * ybt
stakingSnapshot.stakingYbtTwab
FIL tokens.
There is a flaw in _computeTwaStakingTotalYbt() at line 317: totalYbt is initialized with _lastVars.lastYbt, which represents the closingTotalYbt of the previous staking snapshot. This is inconsistent with _computeTwaValues(), which computes each user's time-weighted average YBT balance and does not include the previous snapshot's closingTotalYbt. As a result, stakingYbtTwab can be overstated and an individual user's allocated FIL will be lower than expected.
Remediation:
The allocated FIL should be correctly distributed among the total balance.
GLIF5-3 | INCORRECT USE OF _WINDOWSTART AS _WINDOWID IN _BINARYSEARCHWINDOWID CALL
Severity:
Status:
Fixed
Path:
lp-plus/LpPlusMerkleHelper.sol#L336-L388
Description:
Inside LpPlusMerkleHelper._computeTwaStakingTotalYbt(), the function _binarySearchWindowId() is invoked with _windowStart passed as the _windowId argument:
uint256 low = _binarySearchWindowId(
_windowStart, /// <== @audit this is a timestamp, not a windowId
actions,
_compareExclusive
);
This is incorrect because _windowStart represents the timestamp of the last staking snapshot, whereas _binarySearchWindowId() expects a window ID, which should be a much smaller index value - not a timestamp.
Due to this mismatch, _binarySearchWindowId() returns an invalid result, causing _computeTwaStakingTotalYbt() to compute stakingTotalYbt incorrectly. As a consequence, setWindowRoot() will produce an incorrect staking total for the window, directly impacting the FIL allocation for users.
Remediation:
The function should use the _windowId instead of _windowStart.
GLIF5-1 | THE FIL RESERVES OF THE LPPLUS CONTRACT MAY BE INSUFFICIENT TO EXECUTE RWTFUTURE TOKENS
Severity:
Status:
Acknowledged
Path:
src/lp-plus/LpPlus.sol#L882-L884
Description:
In the _mintRWTFutures() function, there is a check to ensure that the FIL reserves are sufficient for the allocations in this mint.
// Check if FIL reserves are sufficient for this allocation
require(
totalAllocatedFil <= address(this).balance, FilReservesDepleted(totalAllocatedFil, address(this).balance)
);
However, totalAllocatedFil in the above code is only a memory variable used within the _mintRWTFutures() function, so it cannot ensure that the contract's FIL reserves are sufficient for all RWTFuture allocations that will be executed.
Therefore, after using claim() and receiving RWTFuture tokens, users may be unable to execute them before expiration due to insufficient FIL reserves, resulting in a loss of claims.
Additionally, claims made through claimAndExecute(), which does not trigger _mintRWTFutures(), are missing a FIL reserve. This can also cause unexecuted claims from the claim() function to be unable to execute due to insufficient FIL tokens.
Remediation:
The total allocation of unexecuted claims should be checked against the available FIL when creating new claims (for example, in the claimBatch() function) to ensure they are executable in time.
GLIF5-13 | FULL REMOVAL OF YBT OR RWT CAN BE DOSED
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlus.sol:removeYBT, removeRWT
Description:
The LpPlus NFT contains a balance of YBT and RWT and the contract has functions to respectively add and remove these.
The contract also implements a minimum balance for both YBT and RWT (initially set to 1e18), so if adding or removing would lead to a non-zero balance of less than the minimum, it would revert:
uint256 newBalance = vaultBalance - _RWTAmount;
uint256 minimumRWTBalance_ = $.minimumRWTBalance;
require(
newBalance >= minimumRWTBalance_ || newBalance == 0,
InvalidBalance(_tokenId, newBalance, minimumRWTBalance_)
);
Only if the remove function is called with the exact amount that is the full balance, the resulting newBalance would be 0 and it would be allowed.
However, due to this check, it becomes possible for an attacker to DoS full removal of YBT or RWT by front-running the transaction and donating 1 wei of the corresponding token using the permissionless add function.
For example:
- A user has an NFT with token ID 1 and 100 YBT and 100 RWT.
- The user calls
removeRWT(1, 100e18) - The attacker front-runs this call and calls
addRWT(1, 1), adding 1 wei to the NFT's YBT balance. - The user's function call will now revert, because the calculated
newBalancewill be equal to 1 wei.
Remediation:
Consider adding the leftover balance to the amount to send when removing YBT or RWT.
GLIF5-4 | INCONSISTENT DEFAULT_FUTURE_EXPIRATION_PERIOD
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlus.sol#L40-L41
Description:
The LpPlus contract has a constant DEFAULT_FUTURE_EXPIRATION_PERIOD which is set to 180 days. However, the natspec comment states that it is 6 months, which is not the same and would be more accurately equal to 26 weeks or 182 days.
/// @dev The default future expiration period in seconds (6 months). RWT Futures lifespan.
uint256 private constant DEFAULT_FUTURE_EXPIRATION_PERIOD = 6 * 30 days;
Remediation:
We recommend to consider correcting either the comment or the constant's value.
GLIF5-5 | MISSING ADDRESS NORMALISATION
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlus.sol:mint#L168
Description:
In the function mint of the LpPlus contract, the function parameter _to is normalised into the stack variable to on line 173.
However, the raw _to address is still used as input for the modifier validAddress(_to) on line 168.
The modifier does not perform normalisation.
function mint(address _to, uint256 _referrerTokenId, uint256 _ybtToStake, uint256 _rwtToStake)
external
virtual
whenNotPaused
validAddress(_to)
returns (uint256 tokenId)
{
[..]
}
modifier validAddress(address _address) {
require(_address != address(0), ZeroAddress());
_;
}
Remediation:
We recommend normalising the address in the modifier:
modifier validAddress(address _address) {
-- require(_address != address(0), ZeroAddress());
++ require(_address.normalize() != address(0), ZeroAddress());
_;
}
GLIF5-6 | MISSING EVENTS FOR LPPLUS CONFIGURATION STATE CHANGES
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlus.sol#L409-L492
Description:
In the contract LpPlus, there are numerous functions that allow the owner to change the configuration state variables. However, none of the functions emit events with the new values.
This makes off-chain tracking of the state cumbersome, as it is not always evident from the root transaction that a change was made (such as with Safe multi-sig wallets).
Remediation:
We recommend to add specific events for important state configuration changes that communicate the new value to off-chain indexers.
GLIF5-7 | REDUNDANT BALANCE CHECK IN WITHDRAWFILFUNDS
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlus.sol:withdrawFilFunds#L502-L512
Description:
In the function withdrawFilFunds, the owner can withdraw FIL from the contract. The function does some validation in the form of a balance check against the specified amount.
However, this check is redundant, as the same check is already performed in the sendValue function that is subsequently used to send the FIL:
function sendValue(address payable _recipient, uint _amount) internal {
if (address(this).balance < _amount) revert InsufficientFunds();
(bool success, ) = _recipient.call{value: _amount}("");
if (!success) revert CallFailed();
}
Remediation:
We recommend to remove the redundant check in favour of gas savings and redundancy.
GLIF5-8 | LPPLUS MINT EMITS UNNORMALISED ADDRESS PARAMETER
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlus.sol:mint#L164-L223
Description:
In the function mint of LpPlus, the _to parameter is normalised into the local to variable. The latter is then used in subsequent actions, function calls and events.
However, only on line 180 there is an event that still emits the unnormalised version using _to.
function mint(address _to, uint256 _referrerTokenId, uint256 _ybtToStake, uint256 _rwtToStake)
external
virtual
whenNotPaused
validAddress(_to)
returns (uint256 tokenId)
{
[..]
emit CardMinted(tokenId, msg.sender, _to, _referrerTokenId);
[..]
}
Remediation:
We recommend using to in the event instead.
GLIF5-9 | MISSING EVENTS FOR RWTFUTURE CONFIGURATION STATE CHANGES
Severity:
Status:
Fixed
Path:
src/lp-plus/RWTFuture.sol:setLpPlus#L151-L155
Description:
In the RWTFuture contact, there is an owner function setLpPlus to change the configuration state of the contract.
However, this function does not emit an event with the new value.
This makes off-chain tracking of the state cumbersome, as it is not always evident from the root transaction that a change was made (such as with Safe multi-sig wallets).
function setLpPlus(ILpPlus _lpPlus) external virtual onlyOwner {
require(address(_lpPlus) != address(0), ZeroAddress());
RWTFutureStorage storage $ = _getStorage();
$.lpPlus = ILpPlus(address(_lpPlus).normalize());
}
Remediation:
We recommend to add a corresponding event to the function.
GLIF5-10 | REDUNDANT BALANCE CHECK IN _EXECUTEBATCH
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlus.sol:_executeBatch#L828-L862
Description:
In the function _executeBatch, the RWTFuture holder can execute their the strike price and purchase FIL from the contract. The function does some validation in the form of a balance check against the specified amount.
However, this check is redundant, as the same check is already performed in the sendValue function that is subsequently used to send the FIL:
function _executeBatch(
uint256[] memory _rwtFutureTokenIds,
uint256[] memory _strikePrices,
uint256[] memory _allocatedFils,
address _receiver,
LpPlusStorage storage $
) internal {
[..]
require(address(this).balance >= allocatedFil, InsufficientFILBalance(allocatedFil, address(this).balance));
$.RWTToken.transferFrom(msg.sender, $.treasury, requiredRwt);
payable(_receiver).sendValue(allocatedFil);
}
Remediation:
We recommend to remove the redundant check in favour of gas savings and redundancy.
GLIF5-11 | REDUNDANT LENGTH CHECKS IN _CLAIMBATCH
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlus.sol:_claimBatch#L769-L824
Description:
In the function _claimBatch, a holder of the LpPlus NFT can claim an RWTFuture using Merkle proofs. The function takes 3 different arrays and checks the length of each:
require(length > 0, ZeroAmount());
require(_userSnapshots.length > 0, ZeroAmount());
require(_proofs.length > 0, ZeroAmount());
require(_userSnapshots.length == length, InvalidLengths(_userSnapshots.length, length));
require(_proofs.length == length, InvalidLengths(_proofs.length, length));
However, the non-zero checks on the _userSnapshots and _proofs is redundant, as they are required to be equal to length and length is greater than 0.
Remediation:
We recommend to remove the redundant check in favour of gas savings and redundancy.
GLIF5-12 | EXPIRED RWTFUTURE NFTS CAN NEVER BE BURNED
Severity:
Status:
Fixed
Path:
src/lp-plus/LpPlus.sol:execute#L690-L715
Description:
In the protocol, the LpPlus contract is the only contract that is allowed to burn RWTFuture NFTs and it is only done in the execute function.
One requirement in the function, is that the RWTFuture has not expired for it to be executed:
require(
block.timestamp <= position.expirationDate, RWTFutureExpired(rwtFutureTokenId, position.expirationDate)
);
Even though this is correct, it does make burning expired (and thus useless) RWTFuture NFTs impossible.
Remediation:
Consider allowing the burning of expired RWTFuture NFTs, which would not need a subsequent call to _executeBatch.