Platform: Code4rena
Start Date: 23/02/2024
Pot Size: $92,000 USDC
Total HM: 0
Participants: 47
Period: 10 days
Judge: 0xTheC0der
Id: 336
League: ETH
Rank: 2/47
Findings: 2
Award: $6,009.37
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: CodeWasp
Also found by: 0xdice91, 0xlemon, Aamir, Al-Qa-qa, AlexCzm, BAHOZ, Bauchibred, Breeje, DadeKuma, Fassi_Security, PetarTolev, Shield, SpicyMeatball, Trust, ZanyBonzy, cheatc0d3, gesha17, haxatron, imare, jesjupyter, kutugu, lsaudit, marchev, merlinboii, nnez, osmanozdemir1, peanuts, radev_sw, twicek, visualbits
5987.3522 USDC - $5,987.35
ID | Title |
---|---|
L-01 | UniStaker::notifyRewardAmount reward transferred check can get down permanently |
L-02 | MEV searcher will not claim the fees for the swaps that occurred after initializing claimFees function |
L-03 | Signatures do not have a deadline, even the significant ones |
L-04 | Stake More signature do not check beneficiary address changing |
NC‑01 | FactoryOwner cannot activate more than one pool at a time |
NC‑02 | Values are not checked that they differ from the old values when altering them |
NC‑03 | Unauthorized error has no parameters in V3FactoryOwner |
NC‑04 | Type definition should be at the top |
NC‑05 | Time-weighted contributions staking algorism is activated only after the first reward notified |
NC‑06 | Fixed payoutAmount may cause some pools unprofitable to claim their fees |
UniStaker::notifyRewardAmount
reward transferred check can get down permanentlyUnistaker::notifyRewardAmount
is designed to be called once the award is distributed, but this function design can not guarantee that the rewards are distributed.
Trail Of Bits has mentioned in their report that users' unclaimed tokens are not checked with it, so the notifyRewardAmount
can be fired without distributing the exact amount.
What we want to point out here is that this check can get permanently off (get passed all the time), if the contract receives donations.
The check is done to see that the amount the contract received + remaining rewards are not greater than the actual contract balance.
function notifyRewardAmount(uint256 _amount) external { ... if ( (scaledRewardRate * REWARD_DURATION) > (REWARD_TOKEN.balanceOf(address(this)) * SCALE_FACTOR) ) revert UniStaker__InsufficientRewardBalance(); ... }
If the contract receives a donation, let's say 1 WETH. An extra 1 WETH will be considered, whenever a reward is sent, and notifyRewardAmount
is fired.
So the _amount
value can be atmost 1 WETH less than the actual amount sent by the notifier (if there are no remaining rewards for example).
This issue differs from the case Trail Of Bits mentions, as the problem we mentioned is not because of unclaimed rewards by the users, but because of donations the contract received.
UniStaker devs mentioned that the notifier is required to send _amount
before calling notify, so we preferred to make it a LOW issue.
Mitigating this may be a little hard, as we will need to track the amount sent by notifiers, compare the real balance with the amount sent by notifiers, and take suitable actions. And tracking the number of tokens transferred is not an easy task, and will require some changes to UniStaker
.
As Trail Of Bits said in their report, mitigating the issue will be hard, and UniStaker devs decided to document the check. So I provide adding in the comment that donations can get the check permanently off.
claimFees
functionIn the implementation of V3FactoryOwner::claimFee
the caller must provide the amount of tokens (first and second pairs) he wants to withdraw, and there is a check that the amount taken is not less than the amount requested by the user
function claimFees(... , uint128 _amount0Requested, uint128 _amount1Requested) external returns (uint128, uint128) { ... (uint128 _amount0, uint128 _amount1) = _pool.collectProtocol(_recipient, _amount0Requested, _amount1Requested); // Protect the caller from receiving less than requested. See `collectProtocol` for context. // @audit check that the amount we received is not less than that we requested if (_amount0 < _amount0Requested || _amount1 < _amount1Requested) { revert V3FactoryOwner__InsufficientFeesCollected(); } ... }
So the MEV will not be able to extract the maximum profit from the pool (all fees), if there are some swaps or flash loans done at the time the MEV searcher function was in the mempool.
claimFee
with amounts requested (100,100)claimFee
and the total fees are (105,105) nowIf the amount requested by the called of the claimFee
is greater than the protocol fees, the amount gets downed to the maximum in uniswapv3-pools
function collectProtocol(address recipient, uint128 amount0Requested, uint128 amount1Requested) ... ( ... ) { // @audit make the amount equals the fees collected if the amount requested is greater than collected amount0 = amount0Requested > protocolFees.token0 ? protocolFees.token0 : amount0Requested; amount1 = amount1Requested > protocolFees.token1 ? protocolFees.token1 : amount1Requested; ... }
So the amount can be set to a big value by the MEV searcher to guarantee the maximum profit, but because of the V3OwnerFactory::claimFees
check, the amount received will be < requested and the function will get reverted.
We can make a mechanism similar to the minAmountOut
which prevents sandwich attacks.
The caller will provide an amount to request for claiming, and a minimum amount to receive parameters, and if the received amount is < min, the function gets reverted.
Here is a sample of how this can be implemented
V3FactoryOwner::claimFee()
function claimFees( IUniswapV3PoolOwnerActions _pool, address _recipient, uint128 _amount0Requested, uint128 _amount1Requested, + uint128 _amount0Min, + uint128 _amount1Min ) external returns (uint128, uint128) { PAYOUT_TOKEN.safeTransferFrom(msg.sender, address(REWARD_RECEIVER), payoutAmount); REWARD_RECEIVER.notifyRewardAmount(payoutAmount); (uint128 _amount0, uint128 _amount1) = _pool.collectProtocol(_recipient, _amount0Requested, _amount1Requested); // Protect the caller from receiving less than requested. See `collectProtocol` for context. - if (_amount0 < _amount0Requested || _amount1 < _amount1Requested) { + if (_amount0 < _amount0Min || _amount1 < _amount1Min) { revert V3FactoryOwner__InsufficientFeesCollected(); } emit FeesClaimed(address(_pool), msg.sender, _recipient, _amount0, _amount1); return (_amount0, _amount1); }
So the caller (MEV searcher) can call the function providing the requested amounts with type(uint128).max
for example, and set the minimum to the current fees the protocol collects, or the amount that makes him gain a profit.
In UniStaker.sol
, all functions like (stake and withdraw) can get fired on behalf of the user, where the user (deposited) signs the message and either a 3rth party fires it or he fires it himself.
Functions like (stake, stakeMore, and claim), which transfers funds, do not have a deadline
parameter, All functions do not have a deadline parameter, but we want to point to the critical ones.
We can see in permit
for example, that the function has a deadline
, as it makes the user allow another party to spend his token, and the case is the same for staking, withdrawing, etc..., So having a deadline
parameter in the signature, and a deadline
check is useful in this case.
Provide a deadline
parameter for signatures of the critical functions like stake*()
and stakeMore*()
, and we can check this deadline
in UniStaker::_revertIfSignatureIsNotValidNow()
function.
UniStaker::_revertIfSignatureIsNotValidNow
function _revertIfSignatureIsNotValidNow( address _signer, bytes32 _hash, bytes memory _signature, + uint256 deadline ) internal view { bool _isValid = SignatureChecker.isValidSignatureNow(_signer, _hash, _signature); if (!_isValid) revert UniStaker__InvalidSignature(); + // deadline will be `type(uint256).max` for the functions that do not have deadline, or want to allow signature forever + if (deadline != type(uint256).max) { + require(block.timestamp <= deadline, "UniStaker: Signature expired"); + } }
In UniStaker
contract, STAKE_MORE_TYPEHASH
does not contain the beneficiary address in consideration. So the beneficiary address can get changed after signing the message, and this will end up adding funds to the new beneficiary address, and not the one that existed when signing the message.
bytes32 public constant STAKE_MORE_TYPEHASH = // @audit beneficiary is not passed in the signature keccak256("StakeMore(uint256 depositId,uint256 amount,address depositor,uint256 nonce)");
0x01
0x01
the ability to stake0x02
, to let him earn yields too0x01
fired stakeMoreOnBehalf
with his signature signature0x02
instead of 0x01
Since the deposit owner is the one who can make the signatures, and the one who will alter the beneficiary, the problem is not critical, and the severity of it is low. But maybe for further protocol integrations, this can happen who knows?
Add beneficiary
parameter to STAKE_MORE_TYPEHASH
signature, and provide it with the signature, so that if the beneficiary changes, the message will be invalid
In V3FactoryOwner::setFeeProtocol
, the function accepts only one pool to activate the fees on it, and the function is restricted to admins.
Since it's planned to make Uni Governance contract the admin, adding a new pool, will need to make proposal, then vote, then agree, and execute in the last which is not a straight thing.
New ERC20 tokens will launch and pools for them created, so to accept these token pools for fees, you will have to make Governance proposals again and again for each pool separately.
XYZ
is a new and powerful token launchedI recommend making the function to set pool fees in batches, where pools are passed to the function as an array, and the function goes to each pool and activates it, this will allow the governance to accept more than one pool in a single proposal.
And instead of modifying the function itself, we can add another function for adding more than one pool for verbosity and flexibility.
In Unistaker::_alterDelegatee
and Unistaker::_alterBeneficiary
, the new value provided is not checked if it is the same as the old value or not, changing by providing the same value will cause emitting events with no meaning and may cause confusion.
Check that the new address provided either the new delegatee or the new beneficiary equals the old one or not.
V3FactoryOwner
In UniStaker
contract, the unauthorized error has two parameters as it is used to authorize both the notifier and the admin.
error UniStaker__Unauthorized(bytes32 reason, address caller);
But in the case of V3FactoryOwner
, the error has no parameters, and it is named unauthorized too.
error V3FactoryOwner__Unauthorized();
Either provide a message in the V3FactoryOwner__Unauthorized
, or we can change the error name to V3FactoryOwner__NotAdmin
, so that it will be easy for frontend devs to handle the error in the UI.
In UniStaker
contract, the type definition of DepositIdentifier
is presented inside the contract, this is not a good practice, and type definitions are preferred to be at the top of the file.
contract UniStaker is INotifiableRewardReceiver, Multicall, EIP712, Nonces { type DepositIdentifier is uint256; ... }
Put the type declaration in the top of the file
The staking algorism implemented by synthetix
activates only after the first reward notified, what I mean by this is that as long as the first reward has not been notfyied yet, the one who staked on the early will gain the same as the one who staked before norifying the first reward by some minutes.
Since this is how the algorism work, solving this is not ideal, and can cause more issues. But I wanted to point out this here so that devs notice about this thing.
Make the first notify
as early as you can.
payoutAmount
may cause some pools unprofitable to claim their feesIn the current implementation of V3FactoryOwner
, the payoutAmount
is fixed for all pools activated. So the one who is going to claim fees and send rewards will have to pay that amount to collect pool actions fees.
Since pools are not always active, and some pools may have fewer actions (swaps and flash loans), the fees collected by the protocol may not reach this limit forever (fees collected be smaller than the amount needed to be paid).
Since fixing payoutAmount
is desired by the design, making a variety of payAmount for pools is not intended by the Dev team. So I recommend not adding pools that have fewer active swaps or interactions
#0 - wildmolasses
2024-03-12T16:26:19Z
L-01 disputed; stray transfers are not in scope L-02 not useful; MEV searcher tweaking is out of scope; searcher can write a smart contract method claiming max allowed if this situation is considered important L-03 dupe of https://github.com/code-423n4/2024-02-uniswap-foundation-findings/issues/205 L-04 Not applicable or useful
NCs: nothing that we see as particularly useful (NC-02 will be addressed based on https://github.com/code-423n4/2024-02-uniswap-foundation-findings/issues/388)
Ultimately we are glad to see this report, but don't consider it particularly high quality (nor will we dispute it)
#1 - c4-sponsor
2024-03-12T16:26:47Z
wildmolasses (sponsor) acknowledged
#2 - c4-judge
2024-03-14T14:13:11Z
MarioPoneder marked the issue as grade-b
#3 - Al-Qa-qa
2024-03-15T08:41:43Z
Hi,
My 6th NC issue is the same as issue 37
, But it did not forward in issue #37.
In the 5th NC issue, I talked about issue 369
, but from another impact side, and provided a mitigation that fixes issue #369 and the state I mentioned in the report.
Both issues are invalid, but they got Escalations, so I wanted to comment here to consider these issues and judge them. if something changes for these two issues.
Thanks a lot.
🌟 Selected for report: roguereggiant
Also found by: 0xepley, Aamir, Al-Qa-qa, LinKenji, MSK, McToady, Myd, SAQ, Sathish9098, ZanyBonzy, aariiif, cudo, emerald7017, fouzantanveer, hassanshakeel13, hunter_w3b, ihtishamsudo, kaveyjoe, peanuts
22.023 USDC - $22.02
Title | Description | |
---|---|---|
1 | Overview of the UniStaker protocol | Services and Features provided by the protocol |
2 | System Overview | Illustrating the components of the system |
3 | System Architecture | Explaining the Architecture using workflow Diagram |
4 | Documentation | Overview of the documentation and its quality |
5 | Centralization Risks | Risks that should be taken into consideration |
6 | My view on the project | My opinion about the protocol's success when it launches |
7 | Time spent on analysis | The total time I spent analyzing and auditing the codebase |
UniStaker will be used as an infrastructure for all UniSwap foundation protocols and upcoming protocols. It will be the gate for all Stakeholders to earn yields in WETH
token by investing in this protocol and putting UNI
token in it.
Rewards will be distributed to the stakes using time-weighted contributions staking algorism.
Fees will be collected from different Uniswap Projects (V3 pools will be activated at this moment) and reward the stakers with that fees.
The protocol fees will be collected by anyone, but the caller should pay an amount of WETH
token to fire this function and claim protocol fees earned from Uniswap-V3 pools.
Once claiming the fees, WETH
will be sent to The Staker
contract, and stakers will earn yields (Amount of WETH
).
The admin is the one who can control the amount to be paid to claimFees, the pools that will get activated for protocol fees, and the contracts (notifiers) that can increase the staking amount in Staker
contract.
The admin is willing to be the Uniswap Governance contract, so the stakers who hold their staking tokens will not only earn yields by staking, but also will have the power to vote for the changes that can happen that will affect Staking
contract (adjusting the payoutAmount, or the pools to colect fees from).
Stakers (people that hold UNI
in the staker contract), can claim their rewards, withdraw and take their UNI
tokens if they have no plans to stake, and vote on proposals that can be made by the Governance contract to accept or deny a certain change.
The protocol consists of 2 main Components
The staking contract UniStaker.sol
has a lot of public functionalities that can be fired by anyone:
UNI
tokens.beneficiary
is the address that will receive rewards, the user can make this his address or another address.delegatee
is that address that will have the power of votes for UNI
token.IDs
, so the same user can stake more than one time, and he will be the owner of two different deposit IDs
. this will be helpful if he wants to separate his tokens for different beneficiaries
, and delegatees
.deposit ID
, can increase the value of his deposit ID
by staking more UNI
tokens to the same deposit ID
.withdraw
his staking tokens from a given deposit ID
anytime.
deposit ID
, he can not unstake all his tokens at once. He needs to call withdraw
two times to withdraw, providing deposit ID
for each, as the withdrawal happens by providing single deposit ID
.beneficiary
address can claim
his rewards.
beneficiary
can either claim all his rewards at the time of calling or leave them, he can not withdraw
part from his earnings.beneficiary
rewards, he can just unstake tokens, preventing the beneficiary
from earning.beneficiary
address, that will receive rewards.delegatee
address that has the power of voting.deposit ID
balance (staked token), by staking more tokens to the same deposit ID
.owner
. The owner
of the deposit ID can sign the message, and allow 3rd party to fire his transaction. Staking a new deposit ID
allows this functionality too.Other functions are only callable by the admin of the contract these include:
The system is designed to be able to have more than one notifier contract. In the context of this codebase, there is only one notifier contract (V3FactoryOwner
), which is responsible for claiming fees from Uniswap v3 DEX pools.
V3FactoryOwner.sol
characteristics:
fee/tick
support for the future pools that will be deployed (used to manage concentrated liquidity).WETH
first, to claim the pool fees.Once stakers stake their tokens, they provide the delegatee
address which will have the power of the tokens held by the stakers.
The tokens themselves will not be with the delegatee
address, just the voting power will be set to delegatee
(this is achievable as UNI
is a vote token). The tokens will be sent to a surrogate contract address, which will be like a safe place for Staked tokens. And these tokens can be withdrawn from this contract anytime from the UniStaker
contract When withdrawing,
The system works 100% on-chain, with no oracles, and no Web2 development integrations.
UniStaker
) uses time-weighted contributions staking algorism, which distributes rewards according to the amount staked and the duration these staked tokens were in the contract.notifier
contracts.notifier
contract can only distribute reward after transferring the amount of the reward to the UniStaker
.UNI
voting power which is set to delegatee
The documentation was impressive. UniSwap Devs provided detailed information that helps us in our auditing process, this includes:
The system will face centralization risks at the beginning of its deployment. But since it is planned to make the admin of the Staker contract and the notifier contracts the Governance contract of the Uniswap
, The centralization risk will not stay.
The project is impressive. I liked it. The project is insane in my opinion, because of the following features:
UNI
token for proposing and voting. and this token which will control the governance contract. So the stakers themselves will be able to control how the system they are investing in should work, and this is a good point.WETH
which is a well-known token, packed by native ETH.I spent 6 days auditing, reporting, and analyzing the codebase, with a total hours ~30 Hours.
30 hours
#0 - c4-judge
2024-03-14T18:29:05Z
MarioPoneder marked the issue as grade-b