Bancor Compounding Rewards Audit
August 4, 2022
This security assessment was prepared by OpenZeppelin, protecting the open economy.
Table of Contents
Summary
The client asked us to review the smart contracts related to the auto-compounding rewards module, including the mathematical dependencies and how the pool contracts are used by the rewards module.
- Type
- DeFi
- Timeline
- From 2022-06-06
- To 2022-06-17
- Languages
- Solidity
- Total Issues
- 12 (2 resolved, 2 partially resolved)
- Critical Severity Issues
- 0 (0 resolved)
- High Severity Issues
- 1 (1 resolved)
- Medium Severity Issues
- 3 (0 resolved)
- Low Severity Issues
- 5 (1 partially resolved)
- Notes & Additional Information
- 3 (1 resolved, 1 partially resolved)
Scope
We audited commit b2224595afab14827ee065cb65a1444d61689fb3 of the bancorprotocol/contracts-v3 repository.
In scope were the following contracts:
contracts
├── pools
│ ├── BNTPool.sol
│ | └── poolTokenAmountToBurn()
│ └── PoolCollection.sol
│ └── poolTokenAmountToBurn()
├── rewards
│ ├── AutoCompoundingRewards.sol
│ ├── RewardsMath.sol
│ └── interfaces
│ └── IAutoCompoundingRewards.sol
└── utility
├── Constants.sol
├── Fraction.sol
├── FractionLibrary.sol
└── MathEx.sol
All contracts not explicitly mentioned above are out of scope for this audit. The pools
contracts were only audited to the extent that AutoCompoundingRewards.sol
uses poolTokenAmountToBurn
.
Rewards system overview
Token projects, including Bancor’s BNT
, can create a rewards schedule by providing liquidity to a pool and then burning the pool tokens it has received at a predetermined rate. Thus, the rewards budget is used as liquidity, and the ownership of the staked tokens is distributed to other stakers over time.
To initialize a rewards program, TKN
tokens are added to the pool, and bnTKN
pool tokens are issued as normal (or bnBNT
for the BNT
token). Then, the pool tokens are staked into a generic rewards contract, with customizable parameters, such as the number of tokens to distribute, the emission rate, and the emission profile (linear or exponential).
Interactions with the Vortex contract cause the pool tokens to be burned in accordance with the parameters set by the rewards contract; the Vortex trigger also triggers the rewards distribution. As pool tokens are burned, the token value they represent is effectively distributed to the remaining pool tokens. Thus, no contract interactions are required by stakers; so long as they hold the pool token, they are automatically participating in the rewards program.
The auto compounding system can be used in any instance where the reward token is the same as the staked token; it cannot be used to distribute BNT
to TKN
, or to distribute TKN
to any pool other than itself. For use cases where the reward token is different than the staked token, a standard rewards contract is available. The standard rewards contract is out of scope for this audit.
Privileged roles
The AutoCompoundingRewards
contract relies on the ROLE_ADMIN
role and onlyAdmin
modifier to protect the following functions:
setAutoProcessRewardsCount
– Sets the number of programs to auto-process the rewards for each timeautoProcessRewards
is calledterminateProgram
– Terminates a rewards programenableProgram
– Changes a rewards program status between enabled and disabledcreateProgram
– Creates a rewards program for a given pool
Security model and trust assumptions
The Bancor protocol is controlled by an active DAO.
In addition to updating the system via previously mentioned sensitive variables, the protocol is also arbitrarily upgradeable. The DAO has unrestricted control over the protocol, and we assume that the DAO is acting in the best interest of users and the system.
Findings
Here we present our findings.
Given the relatively small scope of this audit with respect to the size of the entire project, we assess the severity of findings based on our recommendations for best practices without consideration of whether there are mitigations in other out-of-scope parts of the codebase. For libraries, in particular, we consider how these may be improved upon for safe reuse in potential future iterations or new applications.
High Severity
reducedFraction
does not reduce in the mathematical sense
In the MathEx
library, the function reducedFraction
does not reduce the Fraction, instead it truncates the numerator and denominator in a way that ensures it is lower than a given maximum. So, rather than using the greatest common denominator between the numerator and denominator to reduce the fraction, this reducedFraction
function merely prunes the numerator and denominator to ensure that they are less than the provided max, and, in so doing, loses precision. In some instances the deviation between the fraction and the result of reducedFraction
can be over 10%.
Consider either revising the function to reduce the loss of precision or renaming the function to truncateFraction
and providing more inline documentation that makes the loss of precision clear.
Update: Fixed in commit 2cb941a636dd9b0c210dd52ee90907eeb02eea6a
and commit 56be8479ec633e0e59385dfcbe66de4759acb4bc
.
Medium Severity
weightedAverage
calculation might overflow
In the MathEx
library the function weightedAverage
performs a multiplication of up to three uint256
input variables without any prior validation. As the resulting number can have up to 768
bits, it is very likely to lead to an overflow during uint256
-multiplication.
Currently, the function weightedAverage
is only used in the _calcAverageRate
function in the PoolCollection
contract. Further, the _calcAverageRate
function is currently used in a way that ensures a maximum size of 128
, 112
and 3
bits for the three operands passed to weightedAverage
.
While the current usage of weightedAverage
within the codebase appears to be safe from uint256
overflows, consider adding input validation to weightedAverage
and including explicit preconditions in the function documentation to ensure that future versions of the codebase remain free from overflows.
Update: Acknowledged without code changes. In an effort to keep library functions lean, the client generally prefers panics over explicit error messages. Note that the function documentation continues to omit any description about preconditions.
calcExpDecayRewards
is at risk of overflow reversions
In the RewardsMath
contract the calcExpDecayRewards
function takes timeElapsed
and halfLife
values as input, puts them into a Fraction
struct, and then passes them along to the MathEx.exp2
function. There is a comment in the inline documentation that
because the exponentiation function is limited to an input of up to (and excluding) 16 / ln 2, the input value to this function is limited by
timeElapsed / halfLife < 16 / ln 2
This is problematic because, for any exponential decay distribution program, halfLife
is fixed while timeElapsed
will continue to grow after the program is created. For any such program then, it is only a matter of time before timeElapsed
exceeds halfLife
by more than the required ratio to overflow a call to exp2
.
If timeElapsed / halfLife
exceeds this limit, then the call will simply revert in the exp2
function with an Overflow
error. This will cause the entire transaction to revert. Since calcExpDecayRewards
is called by the _tokenAmountToDistribute
function, which is called by the _processRewards
function, no rewards for a program that reaches such a state will be possible.
In such a scenario where the timeElapsed
is too large, rather than making the call to exp2
and then reverting, consider having the calcExpDecayRewards
function return totalRewards
without any exponentiation calculation. This will result in minimal error (i.e., 1 * totalRewards
instead of 0.9999998874648253 * totalRewards
), but will prevent the case where a program’s rewards cannot be distributed because too much time elapses from the program’s start.
Update: Acknowledged without code changes. The client expressed that the function inputs are safe for timeframes considered practical. In the case of impractical timeframes the impact is acceptable.
Lack of validation
Throughout the codebase, there are places where a proper input/output validation is lacking. In particular:
- The
inverse
andfromFraction112
functions inFractionLibrary
do not check if they were provided an invalid fraction. - The
_mulMod
,mulDivC
,mulDivF
functions in theMathEx
library do not check thatz
in non-zero. In such cases they revert with a Panic, rather than a more helpful error message. - The
isInRange
andweightedAverage
functions in theMathEx
library do not check that the input fractions are valid. The latter also does not ensure it is returning a valid fraction.
A lack of validation on user-controlled parameters may result in erroneous or failing transactions that are difficult to debug. This is especially true in the case of libraries that are capable of being reused across different codebases. To avoid this, consider adding input and output validation to address the concerns raised above.
Update: Acknowledged without code changes. The client expressed that the lack of reversion on invalid inputs is intentional. In some cases it is desirable behavior to turn divide by zero inputs into zero outputs. In other cases, error messages will be raised somewhere else along the call chain in practice.
Low Severity
Unbounded number of programs could result in errors
The programs
function loops over a potentially unbounded list of programs/pools.
Relatedly, the pools
function requires that all stored values associated with stored pools are first loaded into memory for the call to complete.
In either case, if too many programs are added to the protocol, these functions will run out of gas.
Although neither of these functions are used internally, should the number of programs grow too large, they could be problematic for external contracts that might rely on them.
Consider either adding a pagination mechanism to limit the number of stored values that must be loaded or adding some warning to the documentation about the need to keep the number of programs bounded to some safe level to avoid issues with these functions.
Update: Acknowledged. The client conveyed that simplicity of the external interface was an intentional design choice. If future on-chain usage becomes relevant the respective contract will be upgraded.
Duplicated code
The internal logic of the poolTokenAmountToBurn
functions in the BNTPool
and PoolCollection
contracts are essentially duplicates.
Duplicating code can lead to issues later in the development lifecycle and leaves the project more prone to the introduction of errors. Such errors can inadvertently be introduced when functionality changes are not replicated across all instances of code that should be identical.
Rather than duplicating code, consider having just one contract or library containing the duplicated code and using it whenever the duplicated functionality is required.
Update: Acknowledged. The flexibility of independent upgrades is prioritized over re-use in this case. Client’s response:
If the contracts are used for a while and the shared logic is kept in place without frequent changes, a shared logic component will be created, but for now flexibility is more important than code reusability, especially since these two contracts are very different conceptually.
uint32
times can overflow in the future
Throughout the AutoCompoundingRewards
contract, all program start, end, and elapsed times are encoded as uint32
values. In the year 2106, this will be problematic in two ways.
- Near the time when unix timestamps will begin to overflow the
uint32
type, it will become difficult to create flat programs ending after the unix timestamp overflow event. Specifically, it will not be possible to callcreateFlatProgram
with anendTime
that overflows theuint32
type because it will appear to be less than thestartTime
. This will be a temporary inconvenience, because once the current time overflows theuint32
type it will be possible to create new flat programs as normal again. - The
createExpDecayProgram
does not expect anendTime
input. Instead, ahalfLife
input parameter is specified. This will make it possible to callcreateExpDecayProgram
even around the time that unix time will overflow theuint32
type. However, if a program is created before the overflow time, then after the overflow time, even if it should be a valid program, it would be treated as inactive and no rewards from the program could be processed. This, in turn, would prevent bothprocessRewards
andautoProcessRewards
from processing rewards for that program. This would be more problematic and tight packing of the stored programs could make an update to remedy the situation more difficult.
Consider either increasing the bits available to store and evaluating timestamps to something larger than uint32
or adding a warning to the protocol documentation to upgrade the contract well before the overflow time will be reached.
Update: Acknowledged. Client’s response:
The contracts are expected to change and evolve and new versions implemented way before the timestamps can overflow (at 2106).
Lack of event emissions after updating state
The following functions do not emit relevant events despite executing state-altering actions:
- No event is emitted when the
_autoProcessRewardsCount
variable is first set during contract initialization. This is inconsistent with the fact that an event is emitted when the same value is updated via thesetAutoProcessRewardsCount
function. - There is no event emitted when
_autoProcessRewardsIndex
is updated despite the fact that this value influences the behavior of theexternal
autoProcessRewards
function.
In order to facilitate tracking and to notify off-chain clients following the contracts’ activity, consider emitting events whenever state-changing operations take place.
Update: Partially addressed in commit 5f223b2ff0f1f9fcdd92d89bc2e420c9d984cb5c
. An event including the new value of _autoProcessRewardCount
is now emitted during contract construction. However, the client considers _autoProcessRewardsIndex
an non-public implementation detail which should not emit events upon alteration, despite its role in facilitating monitoring.
enableProgram
is overloaded
In the AutoCompoundingRewards
contract, the enableProgram
function is used to enable or disable a program for a specific pool, depending on the bool status
parameter. The function also emits a ProgramEnabled
event, even in the instance where the program is being disabled (although the event details contain the correct updated status).
In order to avoid unnecessary confusion and to increase the overall readability of the codebase, consider renaming the function and event to something such as updateProgramStatus
to better reflect the actual function operation.
Update: Acknowledged. Client’s response:
This was a design decision in order to prioritize ease of consumption (single event) and binary size over readability.
Notes & Additional Information
Confusing use of assert
statement
The assert
statement in the calcFlatRewards
function of the RewardsMath
library checks a condition that is impossible only because of how it is used within the _tokenAmountToDistribute
function. However, given the calcFlatRewards
function definition and use of the timeElapsed
and programDuration
parameters, the purpose of the assert
statement is not apparent within the RewardsMath
library.
To enforce input validation within the calcFlatRewards
function, consider changing the assert
statement to a require
, and providing an error message explaining the validation. Alternatively, consider moving the assert
statement into the _tokenAmountToDistribute
function to check that the invariant holds in that context.
Update: Remains unaddressed in commit b2224595afab14827ee065cb65a1444d61689fb3
. While the documentation of the function improved in commit ce5d2e73b348fcc368157edd70a5e59d043d939d
, the function still uses assert
to check a condition that is not an invariant.
Unused import
In the AutoCompoundingRewards
contract the AccessDenied
import is unused.
Consider removing unused import statements to simplify the codebase and increase overall readability.
Update: Fixed as of commit 107b3aa85bc306a0d94705a0bd816e57267bd9f7
in pull request #430.
Missing docstrings
Throughout the codebase there are numerous functions missing or lacking documentation. This hinders reviewers’ understanding of the code’s intention, which is fundamental to correctly assess not only security, but also correctness. Additionally, docstrings improve readability and ease maintenance. They should explicitly explain the purpose or intention of the functions, the scenarios under which they can fail, the roles allowed to call them, the values returned and the events emitted.
Consider thoroughly documenting all functions (and their parameters) that are part of the contracts’ public API. Functions implementing sensitive functionality, even if not public, should be clearly documented as well. When writing docstrings, consider following the Ethereum Natural Specification Format (NatSpec).
Update: Acknowledged. The comments will be added in future versions of the codebase.
Conclusions
1 high and 3 medium severity issues were found. Some changes were proposed to follow best practices and reduce the potential attack surface.