Security Audit
September 02, 2024
Version 1.0.0
Presented by 0xMacro
This document includes the results of the security audit for Kwenta's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from August 12th to August 23rd, 2024.
The purpose of this audit is to review the source code of certain Kwenta Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.
Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.
The following is an aggregation of issues found by the Macro Audit team:
Severity | Count | Acknowledged | Won't Do | Addressed |
---|---|---|---|---|
Critical | 1 | - | - | 1 |
High | 2 | 1 | - | 1 |
Medium | 2 | - | - | 2 |
Low | 2 | - | 1 | 1 |
Code Quality | 6 | - | 2 | 4 |
Kwenta was quick to respond to these issues.
Our understanding of the specification was based on the following sources:
The following source code was reviewed during the audit:
0da22f4ccf3d1d4bc58c50ea62ccf68e5c906ffc
Specifically, we audited the following contracts within this repository:
Source Code | SHA256 |
---|---|
contract/contracts/FeeModule.sol |
|
contract/contracts/FeeReimbursementApp.sol |
|
contract/contracts/FeeReimbursementClaim.sol |
|
circuit/opv2_volume_fee_circuit.go |
|
common/common.go |
|
Note: This document contains an audit solely of the Solidity contracts listed above. Specifically, the audit pertains only to the contracts themselves, and does not pertain to any other programs or scripts, including deployment scripts.
Click on an issue to jump to it, or scroll down to see them all.
FeeReimbursementClaim.claim()
function leads to DOS
safeTransfer
instead of transfer
requestId
in event
We quantify issues in three parts:
This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:
Severity | Description |
---|---|
(C-x) Critical |
We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost. |
(H-x) High |
We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec. |
(M-x) Medium |
We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner. |
(L-x) Low |
The risk is small, unlikely, or may not relevant to the project in a meaningful way. Whether or not the project wants to develop a fix is up to the goals and needs of the project. |
(Q-x) Code Quality |
The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design. |
(I-x) Informational |
Warnings and things to keep in mind when operating the protocol. No immediate action required. |
(G-x) Gas Optimizations |
The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it. |
The whole purpose of the circuit is to proof that an account has generated a certain trading volume
and fee
over a specific period of time. The period is determined by the StartBlkNum
and EndBlkNum
given to the circuit as inputs.
The circuit calculates the associated volume
and fee
based on the following receipts:
It is important to check that the receipt’s blockNumber
is within the valid range of the circuit’s StartBlkNum
and EndBlkNum
inputs, otherwise, volume and fee might be incorrectly calculated.
For unclaimable receipts, the following range check is done:
uint248.IsZero(uint248.IsLessThan(r.BlockNum, startBlk30DAgo)), // r.BlockNum >= startBlkOneMonthAgo // @audit-info check 3: is in timerange
uint248.IsLessThan(r.BlockNum, c.StartBlkNum), // r.BlockNum < c.StartBlkNum
Reference: opv2_volume_fee_circuit.go#L82-L83
For claimableExecution receipts, the following range check is done:
uint248.AssertIsLessOrEqual(c.StartBlkNum, blockNumber)
uint248.AssertIsLessOrEqual(blockNumber, c.EndBlkNum)
Reference: opv2_volume_fee_circuit.go#L147-L148
However, for the claimableOrderFeeFlow receipts, being used to calculate the fee amount, a appropriate range check is missing. This can be exploited to receive a higher fee rebate for the account. Invalid proofs can be generated by inserting claimableExecution receipts that don’t fall under the valid blocknumber range (startBlkNum <= blocknumber <= endBlkNum
), allowing to add out-of-range claimableExecution receipts for the whole purpose of inflating the fee amount.
This kind of vulnerability can be often seen in ZK circuits and belongs to the category of “under-constrained” circuits. This essentially means that a malicious Prover can create an invalid proof which is subsequently successfully verified by the verifier. This happens as the circuit contains too few Constrains (aka checks).
Remediation to Consider
Add an appropriate blocknumber range check for the claimableOrderFeeFlow receipts, similar to what is done for the ClaimableExecution receipts: startBlkNum <= blocknumber <= endBlkNum
FeeReimbursementClaim.claim()
function leads to DOS
In FeeReimbursementClaim.claim()
function, the rebate fee will be transferred to account
, which is msg.sender
. The function attempts to authenticate msg.sender
by calling to smAccount.isAuth()
:
function claim(address _smartMarginAccount) external {
address account = msg.sender;
...
IAccount smAccount = IAccount(_smartMarginAccount);
if (!smAccount.isAuth()) {
revert Unauthorized();
}
...
rewardToken.transfer(account, feeRebateOP);
...
}
Reference: FeeReimbursementClaim.sol#L93-L121
However, the smAccount.isAuth()
function is called by the FeeReimbursementClaim
contract, which means msg.sender
in the context of the isAuth()
function is the FeeReimbursementClaim
contract itself. As a result, the actual caller of FeeReimbursementClaim.claim()
is not correctly authenticated, instead isAuth()
attempts to authenticate the FeeReimbursementClaim
contract address:
function isAuth() public view virtual returns (bool) {
return (msg.sender == owner || delegates[msg.sender]);
}
Reference: Auth.sol#L54-L56
This will cause isAuth()
reverting, so the owner of the smAccount
can’t claim their rebate fee
Remediations to Consider
Consider checking authentication for the actual caller in the FeeReimbursementClaim.claim()
function:
function claim(address _smartMarginAccount) external {
address account = msg.sender;
...
IAccount smAccount = IAccount(_smartMarginAccount);
- if (!smAccount.isAuth()) {
+ if (!(smAccount.owner() == account) && !(smAccount.delegates(account)) ) {
revert Unauthorized();
}
...
}
In the circuit implementation, a zero block number for claimable execution receipt is allowed. When its value is set to zero, it will be initialized to the value of StartBlkNum
.
claimableExecutionReceipts := sdk.RangeUnderlying(receipts, MaxReceipts-MaxClaimableTradesPerCircuit, MaxReceipts)
// Makse sure claimable execution tx's block numbers are in ascending order
sdk.AssertSorted(claimableExecutionReceipts, func(a, b sdk.Receipt) sdk.Uint248 {
return uint248.Or(uint248.IsLessThan(a.BlockNum, b.BlockNum), uint248.IsEqual(a.BlockNum, b.BlockNum))
})
sdk.AssertEach(claimableExecutionReceipts, func(r sdk.Receipt) sdk.Uint248 {
=> blockNumber := uint248.Select(uint248.IsZero(r.BlockNum), c.StartBlkNum, r.BlockNum)
uint248.AssertIsLessOrEqual(c.StartBlkNum, blockNumber)
uint248.AssertIsLessOrEqual(blockNumber, c.EndBlkNum)
...
}
However, allowing claimableExecutionReceipts
with a 0 block number may lead to unexpected results. For example, if one claimable execution receipt is provided with BlockNum
value 0 and another with BlockNum
equal to StartBlkNum
, both will be processed, and their corresponding fees and volume will be accumulated even though they have identical other properties. If two claimable execution receipts were provided with identical properties, including BlockNum
value, the circuit would revert due to the uniqueness assertion being invalidated.
As a result, allowing claimableExecutionReceipts
with a 0 value for block number may circumvent input uniqueness constraint.
Remediations to Consider
Update circuit implementation to add proper constraints for the r.BlockNum
.
In FeeReimbursementClaim’s _getChainlinkDataFeedLatestAnswer
, a Chainlink price feed is used to retrieve the OP price:
(
/*uint80 roundID*/
,
int256 data,
/*uint startedAt*/
,
/*uint timeStamp*/
,
/*uint80 answeredInRound*/
) = dataFeed.latestRoundData();
Reference: FeeReimbursementClaim.sol#L177-L188
However, the returned data
is used “as is” without any further sanity checks.
It needs to be noticed, that the call to Chainlink’s price feed using latestRoundData()
can return stale data in extreme situations, such as highly volatile market conditions and flash crashes. In such situations, the returned price can significantly deviate from the actual OP price.
Remediations to Consider
Follow best practices when consuming Chainlink price feeds. We have written a post about this.
In FeeReimbursementApp, the new claim period is calculated in _newClaimPeriod
. The goal of the function is to create or extend the claim period with the provided startBlockNumber
and endBlockNumber
of the new claim period. Additionally, it is designed to prevent double-counting, meaning that the new period must not have any overlapping block numbers with the previous period.
For this to work, the current implementation relies on calling the handleProofResult
function in the correct order. If this is not the case and the Brevis service is not calling the function in the correct order, claimPeriods might be left out and cannot be set at a later point in time anymore. Consider the following scenario:
claimPeriod
of claimPeriod
ofAs a result, the claimPeriod
is set to startBlockNumber: 1
and endBlockNumber: 15
, implicitly assuming that the users is not eligible for any rebates for block numbers between 5 and 8.
However, a proof for block number 5-8 were not generated due to some outages of the Brevis Service. With the current design, resubmitting a proof for block number 5-8 will revert, effectively preventing the user from claiming any deserved rebates during this period.
Remediations to Consider
Adapt the logic in _newClaimPeriod
accordingly so that it doesn’t need the Brevis service to generate the proofs in a consecutive order.
safeTransfer
instead of transfer
In FeeReimbursementClaim’s claim
function, transfer
is used to send the reward tokens to the user instead of using safeTransfer
.
Some ERC20
tokens return false
when the call fails instead of reverting. Depending on the rewardToken
being used, this could cause claim to successfully run without the user acutal claiming the tokens.
Note that this issue is considered as low severity, since it is expected to only use OP ERC20 token as reward token. However, with the current implementation, any other ERC20 tokens could be specified as reward token.
Remediations to Consider
Use OpenZeppelin’s safeTransfer
function.
In FeeReimbursementClaim’s _convertUSDtoOP
function, the price data returned by the Chainlink price feed is expected to be 8 decimals, which is the case for the OP price feed.
It further scales up opPriceInWei
to 18 decimals as follows:
uint256 opPriceInWei = uint256(price) * 1e10;
Reference: FeeReimbursementClaim.sol#L142
The price is incorrectly calculated when using a price feed with decimals other than 8.
Note that this issue is considered low severity since it is expected to only use the OP price feed. However, with the current implementation, any other price feed could be provided at deployment.
Remediations to Consider
It is recommended to use the value returned by AggregatorV2V3Interface.decimals()
to scale the price accordingly.
Won't do - Contract will be deployed with OP as rewardToken, decimals are assumed inside a function called convertUSDtoOP and it is documented
requestId
in event
Topic | Event | |
---|---|---|
Impact | Low |
In FeeReimbursementApp, handleProofResult
emits the following event:
emit FeeRebateAccumulated(
account, feeRebate, volume30D, feeRebateWithRate, startBlockNumber, endBlockNumber
);
Reference: FeeReimbursementApp.sol#L94
The function is called by the Brevis service which passes an associated requestId
. The requestId
might be useful for tracking down any potential issues.
Remediations to Consider
Consider adding the provided _requestId
to the FeeRebateAccumulated
event.
FeeReimbursementApp uses revert
to return custom errors as well as require
statements to handle error messages. Additionally, FeeReimbursementClaim only uses custom errors.
Remediations to Consider
For consistency, consider replacing require
statements with custom errors.
Topic | Nitpicks | |
---|---|---|
Impact | Low |
rewardToken
, rewardTokenDecimals
and associated function setRewardToken
is not used. Reference: FeeReimbursementApp.sol#L31-L32claimer
and associated function setClaimer
is not used. Reference: FeeReimbursementApp.sol#L34, FeeReimbursementApp.sol#L165MigrationDone
and MigrationFinishedForAccount
are not used. Reference: FeeReimbursementApp.sol#L55-L56dataFeed
and sequencerUptimeFeed
can both be declared as immutable
. Reference: FeeReimbursementClaim.sol#L23-L24_convertUSDtoOP
specifies opAmount
and price
as “named return variables”. When using “named return variables”, values need to be assigned to the variables but there is no need to explicitly return the variables themselves. Reference: FeeReimbursementClaim.sol#L132AlreadySet()
event is defined but never used. Reference: FeeReimbursementApp.sol#L26SafeERC20
import is never used. Consider removing unnecessary import and corresponding library usage using SafeERC20 for IERC20
. Reference: FeeReimbursementApp.sol#L29Follow-up fix: https://github.com/Kwenta/zkVIP/commit/d45bb73735d0e6387a16b6f1a1a7c640ca3c5f58
FeeModule contract inherits OZ’s Ownable functionality. However, none of that functionality is needed or used.
Consider removing unnecessary contract inheritance.
In FeeReimbursementApp, setClaimContract()
is owner accessible function that can update the claim contract.
However, this important state change does not have corresponding event emission, which would facilitate off-chain tracking and monitoring.
Consider emitting appropriate event on state change.
In FeeReimbursementApp, handleProofResult()
contains the calculation of feeRebate
with externally retrieved rebateRate
. The implementation assumes that the value of the percentage is within [0,100] and, as a result, uses a hardcoded 100 value as a denominator.
if (feeRebate > 0) {
percentage = feeRebateTierModule.getFeeRebatePercentage(volume30D);
feeRebateWithRate = feeRebate * percentage / 100;
}
However, if the percentage value was 1e18, as is commonly used to represent 100%, this hardcoded denominator would be incorrect, and consequently, the final result would be too.
Consider retrieving both percentage and precision (100) from the feeRebateTierModule
or providing both volume30D
and feeRebate
to the feeRebateTierModule
to calculate the final result.
Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.
Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.
The scope of this report and review is limited to a review of only the code presented by the Kwenta team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. Specifically, for the avoidance of doubt, this report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.