Platform: Code4rena
Start Date: 21/07/2023
Pot Size: $90,500 USDC
Total HM: 8
Participants: 60
Period: 7 days
Judge: 0xean
Total Solo HM: 2
Id: 264
League: ETH
Rank: 60/60
Findings: 1
Award: $21.60
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: LaScaloneta
Also found by: 0xComfyCat, 0xDING99YA, 0xnev, ABA, BugBusters, DadeKuma, MohammedRizwan, QiuhaoLi, Sathish9098, Udsen, ak1, bart1e, immeas, koxuan, ladboy233, matrix_0wl, oakcobalt, squeaky_cactus, zhaojie
21.5977 USDC - $21.60
During the audit, 9 low, 4 non-critical and 3 refactoring issues were found.
Total: 10 instances over 9 issues
# | Issue | Instances |
---|---|---|
[L-01] | NFTBoostVault registration token address and tokenId can each be set to arbitrary values (but not at the same time) | 1 |
[L-02] | Booster NFT multiplier can be set bellow 1e3 | 1 |
[L-03] | Spending amounts limits in ArcadeTreasury can be equal | 1 |
[L-04] | ARCDVestingVault::delegate allows delegation to 0 address | 1 |
[L-05] | ARCDVestingVault::delegate allows delegation without a grant existing | 1 |
[L-06] | NFTBoostVault::withdraw does not check for valid registration | 1 |
[L-07] | NFTBoostVault::withdraw does not properly clear registration if full staked tokens are withdrawn | 1 |
[L-08] | Voting power can delegated to multiple addresses in the same transaction | 2 |
[L-09] | Arcade voting power can be flash-loaned by both booster NFT and token logic | 1 |
Total: 5 instances over 3 issues
# | Issue | Instances |
---|---|---|
[R-01] | Do not revert when exceeding the maximum mint cap in ArcadeToken ; mint the maximum instead | 1 |
[R-02] | Redundant checks in ARCDVestingVault::claim can be eliminated | 1 |
[R-03] | grant.created is not used anywhere and can be removed from ARCDVestingVault logic | 3 |
Total: 3 instances over 4 issues
# | Issue | Instances |
---|---|---|
[NC-01] | Both GSC and normal governance transaction limits are using the same blockExpenditure limit check | 1 |
[NC-02] | All ADMIN_ROLE roles can remove themselves BadgeDescriptor leaving the contract without an admin | 1 |
[NC-02] | Incorrect, incomplete or misleading documentation | 2 |
When registring to the NFTBoostVault
via addNftAndDelegate
, among others, tokenId and tokenAddress are user provided.
If, however, both of these are not equal to 0 at the same time, then the non-zero one will be saved to the registration entry without any further check
if (_tokenAddress != address(0) && _tokenId != 0) { if (IERC1155(_tokenAddress).balanceOf(user, _tokenId) == 0) revert NBV_DoesNotOwn(); multiplier = getMultiplier(_tokenAddress, _tokenId); if (multiplier == 0) revert NBV_NoMultiplierSet(); } // ... registration.tokenId = _tokenId; registration.tokenAddress = _tokenAddress; // ...
Having a registration entry with a random tokenAddress
or tokenId
breaks protocol invariant and may lead to exploits in the future, but, as it is, it does not impact current operations since all code logic updates both at the same time
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L472-L478
Add an else clause that sets them both to 0 if not validated
diff --git a/contracts/NFTBoostVault.sol b/contracts/NFTBoostVault.sol index 5f907ee..701c425 100644 --- a/contracts/NFTBoostVault.sol +++ b/contracts/NFTBoostVault.sol @@ -475,6 +475,10 @@ contract NFTBoostVault is INFTBoostVault, BaseVotingVault { multiplier = getMultiplier(_tokenAddress, _tokenId); if (multiplier == 0) revert NBV_NoMultiplierSet(); + } else { + // cleans up if one of them was given with a set value + _tokenAddress = address(0); + _tokenId = 0; } // load this contract's balance storage
1e3 in the protocol logic represents a multiplier by 1. getMultiplier returns the default 1e3 or the set multiplier.
While there is a MAX_MULTIPLIER
value that is checked when setting the multiplier via setMultiplier
function setMultiplier(address tokenAddress, uint128 tokenId, uint128 multiplierValue) public override onlyManager { if (multiplierValue > MAX_MULTIPLIER) revert NBV_MultiplierLimit(); NFTBoostVaultStorage.AddressUintUint storage multiplierData = _getMultipliers()[tokenAddress][tokenId]; // set multiplier value multiplierData.multiplier = multiplierValue; emit MultiplierSet(tokenAddress, tokenId, multiplierValue); }
there is not minimum set. As such going bellow 1e3 will result in less voting power then not having an NFT locked.
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L493-L494
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L363-L371 https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L493-L494
Add a minimum multiplierValue
check in the setMultiplier
function for 1e3.
ArcadeTreasury
can be equalWhen setting these spending limits via ArcadeTreasury::setThreshold
they are incorrectly checked that are in ascending order:
// verify thresholds are ascending from small to large if (thresholds.large < thresholds.medium || thresholds.medium < thresholds.small) { revert T_ThresholdsNotAscending(); }
The corner case the check is allowing is where:
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/ArcadeTreasury.sol#L275-L278
Modify the if checks to be <=
instead of <
so that no equality is permitted
ARCDVestingVault::delegate
allows delegation to 0 addressThere is no check in ARCDVestingVault::delegate
for 0 address on the to
input. As such a user may mistakenly delegate his voting power to the null address. Although not in the current project form, this may lead to a sever issue in the future.
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/ARCDVestingVault.sol#L260C14-L262
Add a check for 0 address on the to
argument
ARCDVestingVault::delegate
allows delegation without a grant existingARCDVestingVault::delegate
's purpose is to delegate the grant voting power to an address. The function unfortunately does not check if there actually is a grant associated with the caller. As such, a grant
entry is created with only the delegate set to it.
This breaks protocol invariant and may lead to more critical issues in the future.
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/ARCDVestingVault.sol#L260-L262
Add a check that the grant is set, similar to the claim
function:
if (grant.allocation == 0) revert AVV_NoGrantSet();
NFTBoostVault::withdraw
does not check for valid registrationWhen withdrawing via NFTBoostVault::withdraw
the function does not check if there is a valid registration associated with the user.
Although function does revert, because of the if (withdrawable < amount) revert NBV_InsufficientWithdrawableBalance(withdrawable);
check,
it should be first checked that a registration does exist
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L223-L236
Add the following check to the withdraw function:
// If the registration does not have a delegatee, revert because the Registration // is not initialized if (registration.delegatee == address(0)) revert NBV_NoRegistration();
NFTBoostVault::withdraw
does not properly clear registration if full staked tokens are withdrawnNFTBoostVault::withdraw
deletes a registration if the full staked amount is withdrawn. Due to a fault check (that actually comes from a different issue) a registration may come with a token address but not a token ID (or vice-versa). As such, clearing the registration as the current code does leaves either the injected tokenAddress
or the tokenId
if (registration.tokenAddress != address(0) && registration.tokenId != 0) { _withdrawNft(); } // delete registration. tokenId and token address already set to 0 in _withdrawNft()
The comment is actually wrong and in the described case it does not clear.
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L247-L250
Clear tokenAddress and tokenId after the if regardless:
if (registration.withdrawn == registration.amount) { if (registration.tokenAddress != address(0) && registration.tokenId != 0) { _withdrawNft(); } - // delete registration. tokenId and token address already set to 0 in _withdrawNft() + // delete registration + registration.tokenId = 0; + registration.tokenAddress = 0; registration.amount = 0; registration.latestVotingPower = 0;
Both ARCDVestingVault::delegate
and NFTBoostVault::delegate
allow the delegation of voting power to happen multiple times in the same transactions.
Because of the way proposal are created and voted to by ArcadeGSCCoreVoting
(created at block.number - 1
), this issue cannot be exploited.
However it still portraits as an issue which may lead to future exploits.
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L182 https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/ARCDVestingVault.sol#L260
Modify the delegate function in both cases to not allow multiple delegations in the same transaction.
In the Arcade ecosystem, to vote on a proposal, the ArcadeGSCCoreVoting::vote
function is used. Function is inherited from CoreVoting parent contract. Function is protected against flash-loan because when voting on a proposal (or creating one), the voting power of the calling user is retrieved from each voting vault via IVotingVault::queryVotePower and, for voting/proposals, it is calculated at the block prior to proposal creation, as such although a flash loan can be created it does not help in this particular case.
BaseVotingVault::queryVotePower
calculates the voting power both by taking into consideration staked tokens and if the multiplier ERC1155 NFT (ReputationBadge
) if present. Depositing the ReputationBadge
NFT and withdrawing it can be done in the same transaction, making it susceptible to NFT flash-loans. Also, if withdrawals are enabled (BaseVotingVault::unlock
has been called) then the Arcade tokens themselves can also be flash-loaned.
Over the years a number of NFT flash-loan mechanism have appeared. For example, Blur's Blend protocol. It it safe to assume that an NFT flash lending mechanism, or something similar to it, such as buying and selling an NFT using Seaport within the same transaction to "mimic" a flash loan, exist.
Some observations:
addNftAndDelegate
or updateNft
but require an amount to be presentwithdrawNft
and when the vault is unlocked via withdraw
if all tokens are to be removedaddNftAndDelegate
or addTokens
withdraw
and only if the vault is unlockedThere are 2 possible scenarios:
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L114 https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L223 https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L266 https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L293 https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/NFTBoostVault.sol#L305
Modify code to:
addNftAndDelegate
or updateNft
) and withdraw (via withdrawNft
or withdraw
) in the same transactionaddNftAndDelegate
or addTokens
) and withdraw (if enabled) via withdraw
in the same transactionArcadeToken
; mint the maximum insteadIf by mistake when the cooldown period has passed and minter attempts to mint. If he introduces a value above the allowed minted cap, currently it reverts.
// inflation cap enforcement - 2% of total supply uint256 mintCapAmount = (totalSupply() * MINT_CAP) / PERCENT_DENOMINATOR; if (_amount > mintCapAmount) { revert AT_MintingCapExceeded(totalSupply(), mintCapAmount, _amount); }
Since this case may happen out of miscalculation and the intent of the user would still be to mint as much tokens as possible, a suggest here is to cap the amount
to mintCapAmount
if larger.
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/token/ArcadeToken.sol#L153-L157
Cap the amount
to mintCapAmount
if larger and not revert.
ARCDVestingVault::claim
can be eliminatedIn ARCDVestingVault::claim
there is a check done with regards to amount and withdrawable:
// update the grant's withdrawn amount if (amount == withdrawable) { grant.withdrawn += uint128(withdrawable); } else { grant.withdrawn += uint128(amount); withdrawable = amount; } // update the user's voting power _syncVotingPower(msg.sender, grant); // transfer the available amount token.safeTransfer(msg.sender, withdrawable); }
This check is redundant and the subsequently following and can be simply replaced with:
// update the grant's withdrawn amount grant.withdrawn += uint128(amount); // update the user's voting power _syncVotingPower(msg.sender, grant); // transfer the available amount token.safeTransfer(msg.sender, amount); }
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/ARCDVestingVault.sol#L240-L246
Replace the code with the above suggestion.
grant.created
is not used anywhere and can be removed from ARCDVestingVault
logicWhen creating a grant using ARCDVestingVault::addGrantAndDelegate
an optional argument can be passed startTime
which is:
Optionally set a start time in the future. If set to zero then the start time will be made the block tx is in.
As it indicates, this value is set to the current block number if 0 was provided:
// if no custom start time is needed we use this block. if (startTime == 0) { startTime = uint128(block.number); }
However the value is not used anywhere after this point and can be safely removed (sponsor confirmed no usage is currently intended for it).
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/ARCDVestingVault.sol#L39 https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/ARCDVestingVault.sol#L84-L107 https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/libraries/ARCDVestingVaultStorage.sol#L33
Remove the variable from the code as well as the documentation
blockExpenditure
limit checkBoth GSC and normal governance transaction limits are using the same blockExpenditure
limit check. As such, in the off change that both GSC and a normal governance transaction happens at the same time (block), and both reaching their amount limit, one will fail.
GSC uses the same functions as the regular spends/approvals
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/ArcadeTreasury.sol#L119 https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/ArcadeTreasury.sol#L200
ADMIN_ROLE
roles can remove themselves BadgeDescriptor leaving the contract without an adminAll ADMIN_ROLE
roles can remove themselves BadgeDescriptor leaving the contract without an admin
https://github.com/code-423n4/2023-07-arcade/blob/main/contracts/nft/ReputationBadge.sol
Override the revokeRole function to keep track of admins and not allow all to be removed
There are instances of incorrect, incomplete or misleading documentation in the project.
block.number
T_InvalidTarget
limitation.
Resolve the indicated issues
#0 - 141345
2023-07-31T14:27:11Z
L-3 duplicate of https://github.com/code-423n4/2023-07-arcade-findings/issues/340
#1 - c4-pre-sort
2023-08-01T14:34:38Z
141345 marked the issue as high quality report
#2 - c4-sponsor
2023-08-02T20:19:59Z
PowVT marked the issue as sponsor confirmed
#3 - PowVT
2023-08-02T20:20:07Z
A handful of these issues will require fixes.
#4 - c4-judge
2023-08-10T22:42:43Z
0xean marked the issue as grade-b