review commit hash - 667a41b577647f0d95591a5f9928a43b976b8e25
Scope: Zerem.sol
The documentation and the chart in README.md
shows that it is expected that when unlockExponent == 0
then immediately after funds unlockDelaySec
the user can claim his whole locked amount. This is actually not working as intended, let’s look at the _getWithdrawableAmount
function:
- To calculate the amount to unlock, we have the following:
uint256 totalUnlockedAmount = (record.totalAmount * factor) / precision;
record.totalAmount
is the total locked amount,precision
is a constant with a value of1e8
andfactor
is calculated by this:
uint256 factor = deltaTimeNormalized ** unlockExponent;
if (factor > precision) {
factor = precision;
}
- If we have
unlockExponent == 0
thenfactor
is always equal to 1, which is less than1e8
sofactor == 1
- Now if we go back to the total amount to unlock math, we will get
uint256 totalUnlockedAmount = (record.totalAmount * factor) / precision;
souint256 totalUnlockedAmount = record.totalAmount / precision
The expected unlocked amount was equal to record.totalAmount
but instead we got record.totalAmount / precision
which is incorrect. Now every subsequent time the _getWithdrawableAmount
function is called, the math will be the same and the code will basically think there is no newly unlocked amount. This means that no user that has locked funds in Zerem will be able to withdraw more than totalLockedAmount / 1e8
ever, all of the other tokens will be stuck.
There is also a problem when unlockExponent > 1
, because the computed factor
can easily be >= precision
which will result in 100% of funds being unlocked too early.
Here is the important math:
uint256 deltaTimeNormalized = (deltaTimeDelayed * precision) / unlockPeriodSec;
uint256 factor = deltaTimeNormalized ** unlockExponent;
if (factor > precision) {
factor = precision;
}
uint256 totalUnlockedAmount = (record.totalAmount * factor) / precision;
and let’s look at example scenario:
unlockPeriodSec == 100 000
,deltaTimeDelayed == 100
andprecision == 1e8
sodeltaTimeNormalized == 1e5
- if
unlockExponent > 1
for example when equal to 2, we will getfactor = 1e5 ** 2
, so1e10
which is> precision
that is1e8
so nowfactor = precision
- Now
totalUnlockedAmount = record.totalAmount * factor / factor
which isrecord.totalAmount
- even though only 1/1000th of the unlock period has passed and the
unlockExponent
was just2
, the user can already claim all of their locked tokens.
The protocol does not work as expected in its core functionality and can also result in stuck tokens (value loss) for users or tokens unlocked too early, so it is High severity.
Redesign the unlockExponent
logic or just hardcode it to always be linear (a value of 1)
Fixed by removing the unlockExponent
logic
In the _sendFunds
method we have the following code for transferring ERC20 tokens
IERC20(underlyingToken).transfer(receiver, amount);
The problem is that the transfer
function from ERC20 returns a bool to indicate if the transfer was a success or not. As there are some tokens that do not revert on failure but instead return false
(one such example is ZRX) and also Zerem should work with all types of ERC20 tokens since it might be integrated with a protocol that does that, not checking the return value can result in tokens getting stuck. Let’s look at the following scenario:
- Alice is trying to claim some tokens from a protocol that has integrated with Zerem, so her transaction makes a call to
Zerem::transferTo
- The amount to claim is less than the
lockThreshold
so the code goes to the_sendFunds
functionality directly - The transfer fails and since the token does not revert but returns
false
this is not accounted for by the Zerem protocol and transaction completes successfully - The tokens are now stuck and are not claimable by Alice anymore
If an ERC20::transfer
call fails it will lead to stuck funds for a user. This only happens with a special class of ERC20 tokens though, so it is Medium severity.
Use OpenZeppelin’s SafeERC20
library and change transfer
to safeTransfer
Fixed by adding SafeERC20
The way the Zerem protocol transfers out ETH looks like this
payable(receiver).call{gas: 3000, value: amount}(hex"")
As you see, there is a gas stipend of 3000, but this might not be enough in some cases as some smart contract recipients need more than 3000 gas to receive ETH.
Examples of problematic recipients:
- Recipient is a smart contract that has a payable fallback method which uses more than 3000 gas
- Recipient is a smart contract that has a payable fallback function that needs less than 3000 gas but is called through a proxy, raising the call's gas usage above 3000.
Additionally, using higher than 3000 gas might be mandatory for some multi-sig wallets.
Some recipients will lose access to all of their claimable ETH from protocols that are integrated with Zerem. This requires a special type of recipient, so it is Medium severity.
At least doubling down the gas stipend should help in most scenarios, but maybe think about dynamic configuration options for it as well
Fixed by doubling the gas stipend
This comment // TODO: send relayer fees here
in the unlockFor
method and its design show that it is possible that unlockFor
is usually called by relayers. This opens up a new attack-vector in the contract and it is gas griefing on the ETH transfer
(bool success,) = payable(receiver).call{gas: 3000, value: amount}(hex"");
Now (bool success, )
is actually the same as writing (bool success, bytes memory data)
which basically means that even though the data
is omitted it doesn’t mean that the contract does not handle it. Actually, the way it works is the bytes data
that was returned from the receiver
will be copied to memory. Memory allocation becomes very costly if the payload is big, so this means that if a receiver
implements a fallback function that returns a huge payload, then the msg.sender
of the transaction, in our case the relayer, will have to pay a huge amount of gas for copying this payload to memory.
Malicious actor can launch a gas griefing attack on a relayer. Since griefing attacks have no economic incentive for the attacker and it also requires relayers it should be Medium severity.
Use a low-level assembly call
since it does not automatically copy return data to memory
bool success;
assembly {
success := call(3000, receiver, amount, 0, 0, 0, 0)
}
Fixed by using a low-level assembly call
Currently the liquidationResolver
has the power to steal 100% of locked funds in the following way:
- Call
freezeFunds
for every user that has a locked balance - Wait some time until the liquidation delay has passed so the
require
statement inliquidateFunds
succeeds - Call
liquidateFunds
and receive all of the users’ balances
This can happen if the liquidationResolver
becomes malicious or is compromised.
Centralisation vulnerabilities usually require a malicious or a compromised account and are of Medium severity
Reconsider if the freeze/liquidate funds is a mandatory mechanism for the protocol
Acknowledged
If a protocol integrates with Zerem it needs to deploy different instances of Zerem.sol
for each underlyingToken
. In the constructor there are some configurations being set but the inputs are not validated at all. Now if the deployer did not configure them correctly, or fat-fingered the deployment or if the deployment scripts were incorrect, it is possible to misconfigure the protocol in such a way that it is not obvious but leads to all locked funds getting stuck forever.
Let’s look at the following scenario:
- Mistake in the deployment script sets the
unlockDelaySec
or theunlockPeriodSec
to be huge, or to be a concrete timestamp - Now in
_getWithdrawableAmount
ifunlockDelaySec
is too big we will always get 0 withdrawable amount because of
if (deltaTime < unlockDelaySec) {
return 0;
}
- Also in
_getWithdrawableAmount
ifunlockPeriodSec
is too big we will always get 0 because thisuint256 deltaTimeNormalized = (deltaTimeDelayed * precision) / unlockPeriodSec;
will be zero
This means the user will need to wait a huge amount (might be infinite) of time to be able to unlock his funds, and they won’t be unlockable even with the liquidateFunds
functionality
This can possibly lead to user funds being stuck in Zerem, but this requires misconfiguration in deployment, so it is Medium severity
Add sensible constraints for the valid values of unlockDelaySec
and unlockPeriodSec
in the constructor of Zerem.sol
Fixed by adding constraints in the constructor
Some tokens take a transfer fee (STA
, PAXG
) and there are some that currently do not but might do so in the future (USDT
, USDC
). Since Zerem might be integrated with a protocol that works with all types of ERC20 tokens, and Zerem should too, this can lead to problems.
Let’s look at the following scenario:
- Alice tries to claim tokens that have a fee-on-transfer mechanism from a protocol that is integrated with Zerem
- The integrated protocol calls
Zerem::transferTo
method but theamount
argument passed does not take the fee into consideration - The
require(transferredAmount >= amount, "not enough tokens");
check will always fail, since thetransferredAmount
will be less thanamount
due to the fee
If this happens this means that all of users balances of such tokens won’t be claimable and stuck forever.
If a token with a fee-on-transfer mechanism is used and not properly handled on both the integration protocol and Zerem’s side, it can result in 100% stuck balances of this token of users. Since this happens only with a special type of ERC20 it is Medium severity.
Integration of such tokens will require special handling on the integrating protocol side (pre-calculating the fee, so the amount
argument passed has the correct value) and possibly on Zerem’s side. Consider either better documentation for those or advise integrating protocols to not transfer such tokens through Zerem.
Added a warning comment in the code
[M-07] Protocol does not work with ERC20 tokens that have a mechanism for balance modifications outside of transfers
Some tokens may make arbitrary balance modifications outside of transfers. One example are Ampleforth-style rebasing tokens and there are other tokens with airdrop or mint/burn mechanisms. The Zerem system caches the locked balances for users and if such an arbitrary modification has happened this can mean that the protocol is operating with outdated information. Let’s look at the following scenario:
- Alice claims some tokens with such a mechanism from a protocol that is integrated with Zerem
- The protocol calls
Zerem::transferTo
method, but the amount sent is ≥lockThreshold
so the funds are locked - In
_lockFunds
the amount sent is cached inrecord.totalAmount
,record.remainingAmount
andpendingTotalBalances[user]
. - Some time after this, let’s say a rebase of the token balances has happened and now actually Zerem holds less tokens
- Now when the
unlockPeriodSec
passes and Alice wants to claim her tokens she is unable to because the cached amount is more than the actual amount that is held in the Zerem protocol, so the transaction always reverts leading to all of the locked funds getting stuck
Also if the rebasing of the tokens actually increased the protocol balance, then those excess tokens will be stuck in it.
Funds can be stuck in Zerem, but it requires a special type of ERC20 token, so it is Medium severity.
Allow partial unlock of funds or document that the protocol does not support such tokens, so integrating protocols do not transfer them through Zerem. Also you can add functionality to rescue excess funds out of the Zerem protocol.
Acknowledged
Using a floating pragma ^0.8.13
statement is discouraged as code can compile to different bytecodes with different compiler versions. Use a stable pragma statement to get a deterministic bytecode. Also use latest Solidity version to get all compiler features, bugfixes and optimizations
NatSpec documentation to all public methods and variables is essential for better understanding of the code by developers and auditors and is strongly recommended.
Use a code formatter to keep code clean & tidy, I’d suggest adding the forge fmt
command to your pre-commit hook
Move TransferFulfilled
event emission to _sendFunds()
and TransferLocked
event emission to _lockFunds()
Change recive
to receive
and timeframe
to time frame
Add non-zero address checks for all address
type arguments in Zerem.sol
's constructor
Casting address
to address payable
is only needed when using send
or transfer
, so it is not needed here
Merge freezeFunds
and unfreezeFunds
into updateFundsFreezeStatus
- same for their events. There is no need for two separate methods and events
There is an open TODO
in unlockFor
- this implies changes that might not be audited. Resolve it or remove it
Reuse code, make _getRecord
call _getTransferId
because the bytes32 transferId = keccak256(abi.encode(user, lockTimestamp));
logic is duplicated, same for _unlockFor
The OpenZeppelin library dependency is using a stale version - upgrade to latest one to get all security patches, features and gas optimisations
Currently the key in the pendingTransfers
mapping is calculated by this
bytes32 transferId = keccak256(abi.encode(user, lockTimestamp));
I’d say that just using a simple uint256 nonce for each TransferRecord
would work just fine. It will also be simpler and more gas efficient.
Remove the mapping as pendingTransfers[transferId].remainingAmount
has pretty much the same functionality
Both precision
and NATIVE
storage variables have predetermined values - change them into constant
s not immutable
s. Also make sure they are private, to save gas.
Unless you are doing variable-packing in storage it is best to use always use uint256
because all other uint
types get automatically padded to uint256
in memory anyway. Same applies for function parameters
Solidity 0.8.4 added the custom errors functionality, which can be use instead of revert strings, resulting in big gas savings on errors. Replace all revert statements with custom error ones
Since a function is not called from within a contract, using external
instead of public
will save gas as all the function arguments will be in calldata
instead of copied to memory
The !=
operator costs less gas than >
and for uint types you can use it to check for non-zero values to save gas
You can replace all -=
and +=
occurrences to save gas
If in the _getWithdrawableAmount
method you change if (totalUnlockedAmount < withdrawnAmount)
to if (totalUnlockedAmount <= withdrawnAmount)
you will save gas by skipping a few computations
- With the current design, a project will have to deploy a separate
Zerem
contract for each ERC20 token it supports. This might not be very efficient and to have much bigger gas costs for either the integrated protocol or its users - As pointed out in the
README
file, the Zerem integration has an invasive approach - additional code logic should be added to the outbound methods of the integrating protocol so that all transfers go through Zerem. This will not work with already existing and non-upgradeable protocols and also might not be very well accepted, since it more code means more maintenance and a bigger attack vector for protocols. - An attacker can always send
lockThreshold - 1 wei
amount, and it will always be directly transferred. He can just spray multiple transactions in a single block so in a few blocks he can steal very large amount of tokens