Reach out for an audit or to learn more about Macro
or Message on Telegram

Savea A-1

Security Audit

Nov 18th, 2024

Version 1.0.0

Presented by 0xMacro

Table of Contents

Introduction

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.

Overall Assessment

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.

Specification

Our understanding of the specification was based on the following sources:

Source Code

The following source code was reviewed during the audit:

Specifically, we audited the following contracts within this repository:

Source Code SHA256
contracts/Management.sol

7d7ae9e03c4f49c6edd93705fad54405cec6f2cc4226d91cc35f7746abc31538

contracts/SaveaToken.sol

573da29ba97a4725db4236fa0161ac0cb3e2fc28d51c8bea8edf42438d966689

contracts/libraries/DataTypes.sol

be2019dd06da5b755e162383a302a0c4fa31634623650214b330692d58930130

contracts/libraries/Errors.sol

0ba297d9062b39fbac782f7f6c98448993760e3bfd94af653e160cc64717b376

contracts/libraries/IterableMapping.sol

99c15f50f7661c655b4a95e004815f6cd03d473b9fc5fddbb69784d4595813fb

contracts/libraries/Settings.sol

52a2ffee76b7e8af79d8490b253afc3f656fed77a4ddb2d8dfe6d7989b72a2df

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.

Issue Descriptions and Recommendations

Click on an issue to jump to it, or scroll down to see them all.

Security Level Reference

We quantify issues in three parts:

  1. The high/medium/low/spec-breaking impact of the issue:
    • How bad things can get (for a vulnerability)
    • The significance of an improvement (for a code quality issue)
    • The amount of gas saved (for a gas optimization)
  2. The high/medium/low likelihood of the issue:
    • How likely is the issue to occur (for a vulnerability)
  3. The overall critical/high/medium/low severity of the issue.

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.

Issue Details

H-1

Redemption allowance restriction can be circumvented

Topic
Spec
Status
Acknowledged
Impact
High
Likelihood
High

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

  • Limit token transfers for a particular token holder and the current period if the redemption request has already been processed.
Response by Savea

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.

H-2

Token holder fee charging can be circumvented

Topic
Spec
Status
Acknowledged
Impact
High
Likelihood
High

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

  • Redesign the fee collection process. For example, in one approach, the system could mint a token equal to the fee percentage of all tokens in circulation and assign this newly minted asset balance to the Management contract.
  • An alternative approach would be to use a rebase token system similar to stETH, where the token tracks the individual tokenholder's share. Then, based on changes in total supply (due to fee collection) meant for token holders, individual holder balance changes.
Response by Savea

At this phase this is not a threat for us but we aim to fix it in the next implementation.

H-3

For new token holders, a fee is incorrectly applied

Topic
Spec
Status
Impact
High
Likelihood
High

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

  • Redesign fee collection system as described in [H-2].
Response by Savea
  • 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.
M-1

Hardcoded Savea token <> Listing currency exchange rate enables arbitrage opportunity

Topic
TODO
Status
Acknowledged
Impact
High
Likelihood
Medium

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

  • Update implementation to rely on external oracles provided by 3rd parties (e.g. zk oracles, chainlink, etc.)
Response by Savea

We’re ok with this at the moment and we’ll rely on external oracles in the next version.

M-2

Incorrect token balance check in buyTokensWithCurrency()

Topic
Input validation
Status
Impact
Low
Likelihood
High

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

  • Update check implementation
// check if the contract has enough tokens
require(
    token.balanceOf(address(this)) >= tokenAmount,
    Errors.MANAGEMENT_AVAILABLE_TOKENS
);
M-3

Incorrect implementation of checkRedemptionAllowance()

Topic
Spec
Status
Impact
Medium
Likelihood
High

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

  • Update implementation to reference to token.balanceOf(wallet) instead of token.balanceOf(msg.sender), or
  • Remove wallet parameter and checkRedemptionAllowance() only for msg.sender
L-1

Inappropriate fee values may affect user assets

Topic
TODO
Status
Acknowledged
Impact
Medium
Likelihood
Low

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

  • Add limits (min, max) for the most important system configuration variables to indicate expected system conditions and reduce risks for managed assets.
Response by Savea

We’re ok with this for now. Fees are going to be subject to T&Cs.

L-2

Incorrect NFTWithdraw event definition and emission

Topic
Events
Status
Impact
Low
Likelihood
High

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

  • Update the implementation of 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);
Q-1

Public view functions are unnecessarily access protected

Topic
Best practices
Status
Quality Impact
Low

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.

Q-2

Unused code present in the implementation

Topic
Best practices
Status
Quality Impact
Low

In the Management contract, the following code is present but unused:

  • commented out code
    • checkBuyMinimum
    • checkBuyMaximum
  • unused code
    • validateAddress() modifer
    • checkAvailableFunds() modifier
    • Errors.MANAGEMENT_FEES_INVALID_ADDRESS

Consider removing this code unless it is needed.

Q-3

Consider using Ownable2Step instead of Ownable

Topic
Best practices
Status
Acknowledged
Quality Impact
Low

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.

Response by Savea

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.

Q-4

Disable unnecessary Ownable functionality

Topic
Best practices
Status
Quality Impact
Low

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.

Q-5

Inadequate parameter naming for TokensPurchased event

Topic
Events
Status
Quality Impact
Low

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.

Q-6

Use OpenZeppelin’s SafeERC20 library

Topic
Token standards
Status
Quality Impact
Low

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.

Q-7

Define constants for various numerical values

Topic
Best practices
Status
Acknowledged
Quality Impact
Medium

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();
    
Response by Savea

We’ll be fixing this in the next implementation.

G-1

Inefficient fee collection implementation

Topic
Gas optimization
Status
Acknowledged
Gas Savings
High

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).

Response by Savea

We’ll be fixing this in the next implementation.

I-1

Centralization risks

Topic
Trust model
Status
Acknowledged
Impact
Informational

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:

  1. use a multi-sig account for an owner account instead of EOA, and enhance with governance features if possible
  2. consider introducing a timelock mechanism for important system settings updates, so that end users may have the opportunity to react to undesirable system changes
  3. introduce features that allow the system to be more decentralized, such as oracle for tracking exchange rate.
  4. remove or set limits on owner operations and handling of user assets such as transfer of tokens, transfer of currency assets, and transfer of NFT tokens.
I-2

Settings.CURRENT_MONTH should never repeat

Topic
Use case
Status
Acknowledged
Impact
Informational

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.

Response by Savea

We’ll be fixing this in the next implementation.

Disclaimer

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.