Security Audit
Nov 18th, 2024
Version 1.0.0
Presented by 0xMacro
This document includes the results of the security audit for Savea's smart contract code as found in the section titled ‘Source Code’. The security audit was performed by the Macro security team from Nov 7th to Nov 11th, 2024.
The purpose of this audit is to review the source code of certain Savea 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 |
---|---|---|---|---|
High | 3 | 2 | - | 1 |
Medium | 3 | 1 | - | 2 |
Low | 2 | 1 | - | 1 |
Code Quality | 7 | 2 | - | 5 |
Informational | 2 | 2 | - | - |
Gas Optimization | 1 | 1 | - | - |
Savea 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:
22365145d9445882a4e4fa6cdd098ae90e94ca0a
91c7d11be24fd4c2109c0cfb31505cf771fbac1f
Specifically, we audited the following contracts within this repository:
Source Code | SHA256 |
---|---|
contracts/Management.sol |
|
contracts/SaveaToken.sol |
|
contracts/libraries/DataTypes.sol |
|
contracts/libraries/Errors.sol |
|
contracts/libraries/IterableMapping.sol |
|
contracts/libraries/Settings.sol |
|
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.
buyTokensWithCurrency()
checkRedemptionAllowance()
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. |
In the Management
contract, the sellTokensForCurrency()
allows redemption of Savea tokens up to the Settings.REDEEM_PERCENTAGE
per month. See isReedeemable()
, _checkandUpdateRedemption()
and checkRedemptionAllowance()
. When redemption is enabled, and the user tries to redeem for the first time within a particular month, the redemption allowance for that period is calculated and recorded.
// checkRedemptionAllowance()
if (redemption.amountRedeemed == 0) {
// wallet didn't reedem this month so they can request the full redemption amount
return ((token.balanceOf(msg.sender) *
(settings[Settings.REDEEM_PERCENTAGE])) / 10 ** 20); // 20 is required to get to the correct decimals
}
In the _checkAndUpdateRedemption()
, this allowance is decremented for the redeemed amount while the redeemed amount is tracked separately. On subsequent redemption attempts during the same month, the user’s amountAllowed
is checked, and if it is greater or equal to the requested amount, the request is processed; otherwise, it is denied.
However, the current implementation approach's issue implicitly assumes that users cannot transfer their tokens and that their token balance is fixed.
Malicious users may redeem up to REDEEM_PERCENTAGE
of tokens from one address, transfer the remaining balance to a different address, and repeat the process in the loop until the whole balance is redeemed. An alternative approach for them is to move the aggregated balance iteratively to multiple addresses and perform redemption with one wei to obtain max REDEEM_PERCENTAGE
at each address. Afterward, they can split their token balance across those addresses and redeem it in one step.
Remediations to Consider
Currently, all our investors are KYCed. However, we understand there’s no enforcement on this at the smart contract level. This is something we’re aiming to fix in the next version.
In the Management
contract, chargeFeesAllBatch()
efficiently enables iterative processing and fee collection from all token holders. Related chargeFeesAll()
function performs the identical operation. However, it is unsuitable when there are many token holders; therefore, chargeFeesAllBatch()
is expected to be used more commonly.
However, in the case of chargeFeesAllBatch()
, users may exploit the fact that fee collection is performed in multiple transactions across different groups of token holders. Users may observe that the owner has a pending transaction chargeFeesAllBatch()
, and they may front-run it with a transfer of their balance to the account address under their control but not specified as part of the batch to be processed. The token balance fee will not be applied as their initial address will now have 0 balance. Immediately after chargeFeesAllBatch()
is complete, they transfer their tokens back to the initial address, which usually would not be processed again during the same time period.
This issue impacts the core invariant of charging fees on all token balances not stored with the Management
contract.
Remediations to consider
Management
contract.At this phase this is not a threat for us but we aim to fix it in the next implementation.
In the Management
contract, _chargeFee()
internal function is responsible for processing and recording fee collection for token holders. Implementation check if time delta between current block.timestamp
and previous fee collection timestamp is greater than the defined feeStructure.period
for fee collection.
/**
* @notice Internal functon to charge a fee
@param wallet Address of the wallet to charge
**/
function _chargeFee(address wallet) internal {
// this contract will hold the tokens but we don't want to charge fees on them
if (wallet != address(this)) {
// check the last payment date to see if the fees are due
if (block.timestamp - payments[wallet] > feeStructure.period) {
uint256 feeAmount = ((token.balanceOf(wallet) / 100) *
feeStructure.fee) / 10 ** token.decimals();
if (feeAmount > feeStructure.minimumTokens) {
// charge the wallet
token.update(wallet, address(this), feeAmount);
// update the last payment date
payments[wallet] = block.timestamp;
// emit event
emit FeeCharged(wallet, feeAmount);
}
}
}
}
However, when the holder is processed for the first time, their payments[wallet]
record will be 0 as it is not initialized. As a result, they would be eligible for fee collection since the time delta in this scenario would certainly be greater than the value of feeStructure.period
. Consequently, when transferred between multiple token holders, the same token balance would be accounted for fee collection, resulting in excess fee charges per period.
Remediations to Consider
- This can happen only if the _chargeFee() method is called multiple times in a feeStructure.period period of time. If this is ran once a month then we should not worry about it as even though the user transferred his tokens to multiple addresses, each address will be charged independently.
- On another note, we’re now initializing the payments mapping with the block.timestamp for new buyers.
In the Management
contract, buyTokensWithCurrency()
and sellTokensForCurrency()
functions enable users to buy and sell Savea tokens in exchange for listing currency. Both of these functions rely on a fixed exchange rate defined by Settings.EXCHANGE_RATE_BUY
and Settings.EXCHANGE_RATE_SELL
to perform conversion. The contract owner controls these two settings and updates when necessary.
However, when the exchange rate deviates from the configured value, external actors may exploit arbitrage opportunities and buy or sell tokens at favorable value. This opportunity exists even if the exchange rate value is updated frequently since malicious actors may front-run price update transactions.
Remediations to Consider
We’re ok with this at the moment and we’ll rely on external oracles in the next version.
buyTokensWithCurrency()
In the Management
contract, the buyTokensWithCurrency()
function enables users to buy Savea tokens. This function contains several input validations, including a check, which validates if the contract’s token balance is enough to service the pending buy request.
// calculate the token amount into the correct token decimals
uint256 tokenAmount = (currencyAmount * 10 ** token.decimals()) /
settings[Settings.EXCHANGE_RATE_BUY];
// check if the contract has enough tokens
require(
token.balanceOf(address(this)) >= currencyAmount,
Errors.MANAGEMENT_AVAILABLE_TOKENS
);
However, the check implementation references the wrong variable, currencyAmount
, instead of tokenAmount
. If the exchange rate is such that one token is worth much more than currency, it may prevent processing purchase requests even if the contract has enough token balance.
Remediations to Consider
// check if the contract has enough tokens
require(
token.balanceOf(address(this)) >= tokenAmount,
Errors.MANAGEMENT_AVAILABLE_TOKENS
);
checkRedemptionAllowance()
In the Management
contract, checkRedemptionAllowance()
determines the amount of tokens a specific wallet can sell within a particular redemption period.
/**
* @notice Check the redemption allowance for the wallet
* @param wallet Wallet address to check
*/
function checkRedemptionAllowance(
address wallet
) public view returns (uint256 amountAllowed) {
// get redemption for address in the current month
DataTypes.Redemption storage redemption = redemptions[wallet][
settings[Settings.CURRENT_MONTH]
];
if (redemption.amountRedeemed == 0) {
// wallet didn't reedem this month so they can request the full redemption amount
return ((token.balanceOf(msg.sender) *
(settings[Settings.REDEEM_PERCENTAGE])) / 10 ** 20); // 20 is required to get to the correct decimals
} else {
// wallet already reedemed this month so they can request up to the amount allowed already set
return (redemption.amountAllowed);
}
}
However, while the function accepts the wallet
as a parameter, it performs calculations based on the token balance of msg.sender
instead of the wallet
address. As a result, the returned value for the provided wallet
will differ from the correct one if the wallet
and msg.sender
are unequal.
Remediations to Consider
token.balanceOf(wallet)
instead of token.balanceOf(msg.sender)
, orwallet
parameter and checkRedemptionAllowance()
only for msg.sender
In the Management
contract, the owner may update important system configurations using the updateFeeStructure()
function. When the fee is set, it affects which percentage of assets is charged per period from each token holder balance.
However, if this fee is by accident or due to a compromise set to an unexpectedly high value (e.g., 100%), it may result in a loss of assets for the end users.
Remediations to Consider
We’re ok with this for now. Fees are going to be subject to T&Cs.
In the Management
contract, the withdrawNFTOnly()
and withdrawAndBurnNFT()
functions enable users to withdraw their NFT token. As part of the implementation, NFTWithdraw()
event is emitted.
/** Emitted when nfts are withdrawn */
event NFTWithdraw(address to, address token, uint256 id);
/* Event emission in withdrawNFTOnly() and withdrawAndBurnNFT() */
emit NFTWithdraw(to, address(token), tokenId);
However, both functions do not use a token
(the address of the Savea token) but a nftContract
associated with a specific token. As a result, second emitted parameter is incorrect and misleading for off-chain tracking and monitoring tools relying on event data.
Remediations to Consider
withdrawNFTOnly()
and withdrawAndBurnNFT()
to emit proper event data and event definition to more clearly indicate what is being emitted./** Emitted when nfts are withdrawn */
event NFTWithdraw(
address indexed to,
address indexed nftContract,
uint256 indexed tokenId
);
/* Event emission in withdrawNFTOnly() and withdrawAndBurnNFT() */
emit NFTWithdraw(to, nftContract, tokenId);
The following public view functions are access controlled in SaveaToken.sol
getTokenHolderSize()
getTokenHolderAtIndex()
getTokenHolderState()
Consider removing the onlyRole(MANAGEMENT_ROLE)
modifier since it is unnecessary.
In the Management
contract, the following code is present but unused:
Consider removing this code unless it is needed.
The Management
contract, which relies heavily on a centralized owner, should support a safe owner transition to a more appropriate privileged operation handling account, such as one using a multi-sig. Ownable2Step
can enable safer owner transition when it is necessary.
We’re ok with this for now. Planning to use a multi-sig wallet as the owner of the Management contract so the transfer ownership process will require a quorum approval anyway.
The Management
contract, which inherits from OpenZeppelin’s Ownable contract, has renounceOwnership()
functionality. When this function is called, it permanently removes ownership of the contract.
Since Savea’s Management
contract is highly centralized and cannot operate without an owner, consider disabling the renounceOwnership()
functionality by overriding it and reverting whenever it is called.
In the Management
contract, the buyTokensWithCurrency()
emits TokensPurchased
event:
emit TokensPurchased(msg.sender, currencyAmount, tokenAmount);
When TokensPurchased
event is defined as following with parameter names which are misleading or inadequately named.
event TokensPurchased(
address indexed wallet,
uint256 price,
uint256 tokens
);
For comparison, TokenSold
event has more appropriate parameter naming.
event TokensSold(
address indexed seller,
uint256 tokenAmount,
uint256 currencyAmount
);
Consider updating TokensPurchased
event emission.
In the Management
contract, for general interaction with token
and currency
addresses, consider using SafeERC20
to help deal with various ERC20 implementations unless they are under system control.
In the Management
contract, different numerical values are used as literals in several function implementations. While there is no currently negative impact on the system operation, consider replacing these literal values with well-defined constants. In the constructor, set immutable variable TOKEN_PRECISION
.
checkRedemptionAllowance()
- replace 1020 with TOKEN_PRECISION * PERCENTAGE_PRECISION (1018 * 100)
return ((token.balanceOf(msg.sender) *
(settings[Settings.REDEEM_PERCENTAGE])) / 10 ** 20);
buyTokensWithCurrency()
- replace 10 ** token.decimals() with TOKEN_PRECISION
uint256 tokenAmount = (currencyAmount * 10 ** token.decimals()) /
settings[Settings.EXCHANGE_RATE_BUY];
sellTokensForCurrency()
- replace 10 ** token.decimals() with TOKEN_PRECISION
uint256 currencyAmount = (tokenAmount *
settings[Settings.EXCHANGE_RATE_SELL]) / 10 ** token.decimals();
We’ll be fixing this in the next implementation.
In the Management
contract, fee collection is performed using chargeFeesAllBatch()
and chargeFeesAll()
, triggered by the owner for each period. The cost of operating, charging, and collecting fees is manageable for a few token holders.
However, the current implementation would not be scalable for any larger number of token holders, especially in the scenario of high gas fees on the Ethereum mainnet, where the project is currently deployed.
Consider redesigning the fee collection system according to the design suggested in [H-2], as in that case, the complexity of operation would be O(1) instead of O(n).
We’ll be fixing this in the next implementation.
The contracts in scope, SaveaToken.sol
and Management.sol
, feature extensive functionality under the system owner's direct control. In case of owner account compromise or incidental and/or malicious owner actions, user assets could be affected.
Consider implementing various mechanisms for a more decentralized and less trust-demanding system:
In the Management
contract, the Settings.CURRENT_MONTH
parameter is used to access various configuration parameters for each month. If CURRENT_MONTH
always increases and never loops, there is no danger that some previously used and updated config variables will be reused in the new period. However, if that is not the case, it may negatively affect system behavior.
Consider treating CURRENT_MONTH as a monotonically increasing value and do not reset it.
We’ll be fixing this in the next implementation.
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 Savea 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.