Table of Contents
- Table of Contents
- Summary
- Scope
- System Overview
- Security Model and Trust Assumptions
- Critical Severity
- High Severity
- Medium Severity
- Low Severity
- It Is Possible to Call poolCollateral for a Disabled Token
- Unused Code
- Allowance Changes Are Allowed to Non-Whitelisted Collateralizable Contracts
- Inconsistent Starting and Ending Epoch Timepoints
- Lack of Input Sanity Checks
- Ownership Can Be Renounced
- Users Will Lose Tokens if They Attempt to Transfer Them to Themselves
- depositAndStake Could Fail Due to Front-Running
- Floating Pragma
- Notes & Additional Information
- Unused Named Return Variables
- Unnecessary Cast
- Lack of Security Contact
- Hardhat console Import Present
- Duplicated Code
- Misleading Documentation
- Code Clarity Suggestions
- Multiple Initializations of Claim Are Possible
- Using uint Instead of uint256
- Unused Errors
- Typographical Errors
- Todo Comments in the Code
- Inconsistent Use of Named Returns
- Lack of Indexed Event Parameters
- Duplicate Imports
- Unused Imports
- Negative Allowance in CollateralVault Can Be Counterintuitive
- Add Functionality to Cancel Signed Messages
- Counterintuitive depositFromAccount Functionality
- Recommendations
- Conclusion
Summary
- Type
- DeFi
- Timeline
- From 2024-04-08
- To 2024-05-03
- Languages
- Solidity
- Total Issues
- 32 (32 resolved)
- Critical Severity Issues
- 1 (1 resolved)
- High Severity Issues
- 2 (2 resolved)
- Medium Severity Issues
- 1 (1 resolved)
- Low Severity Issues
- 9 (9 resolved)
- Notes & Additional Information
- 19 (19 resolved)
Scope
We audited the AmperaFoundation/sol-contracts repository at commit c6e940c.
In scope were the following files:
contracts
├── CollateralVault.sol
├── Pricing.sol
├── TimeBasedCollateralPool.sol
├── governance
│ ├── Anvil.sol
│ ├── AnvilERC20Votes.sol
│ ├── AnvilGovernorDelegate.sol
│ ├── AnvilGovernorDelegator.sol
│ ├── AnvilTimelock.sol
│ ├── AnvilVotes.sol
│ ├── Claim.sol
│ └── IClaimable.sol
└── interfaces
├── ICollateral.sol
├── ICollateralDepositTarget.sol
├── ICollateralPool.sol
└── ITimeBasedCollateralPool.sol
Update: The Acronym Foundation team has moved the Anvil contracts to a new repository which can be found at https://github.com/AcronymFoundation/anvil-contracts. We have validated that the in-scope contracts as of commit c6e940c from the AmperaFoundation/sol-contracts repository match exactly with the in-scope contracts as of commit be6bd02 from the AcronymFoundation/anvil-contracts repository. Further, we can confirm that both repositories use the same version of the OpenZeppelin contracts and contracts-upgradeable libraries. It should be noted that the AcronymFoundation/anvil-contracts repository uses solc 0.8.25 whereas the AmperaFoundation/sol-contracts codebase used 0.8.20, however this difference is in-line with the change that was reviewed in issue L-09.
System Overview
The Anvil protocol is an Ethereum-based collateral protocol providing transparent and efficient collateral management to the smart contracts within its ecosystem.
CollateralVault
and TimeBasedCollateralPool
The CollateralVault
contract is the backbone of the system and acts as the central point of collateral management, interacting with the approved collateralizable contracts of the ecosystem. The users can deposit collateral in the CollateralVault
contract and approve a specific allowance amount to each collateralizable contract of their preference. Then, any collateral management action needed for their account in the collateralizable contract is settled transparently in cooperation with the CollateralVault
contract.
More specifically, users interact with CollateralVault
to deposit or withdraw collateral funds and to set or adjust the allowances to the collateralizable contracts. The collateralizable contracts mainly interact with CollateralVault
on behalf of their users to reserve or release collateral and to claim reserved collateral (i.e., to actually withdraw collateral to the collateralizable contract). A collateralizable contract may also have its own account within the CollateralVault
and manage the funds that are accounted as its own. A system fee is charged upon transferring funds out of the CollateralVault
contract, essentially upon users' withdrawals and collateralizable contracts' claims.
The TimeBasedCollateralPool
contract is a collateralizable contract within the Anvil ecosystem which interacts with CollateralVault
. It functions as a staking pool imposing a delay upon exit. Liquidity providers are issued shares of the pool proportional to their staked amount. Staked funds may be withdrawn from the pool at any time by an account with the "claimant" role. The exit delay imposed by the pool ensures a minimum amount of time when the staked funds are available for the claimant. The claim reason is not stated nor verified on the pool contract and is considered to be handled separately, either off-chain or by an external contract. In addition, the incentives for the liquidity providers to stake funds are not part of the pool's contract and are expected to be managed outside of the Anvil protocol.
Any token that is supported by the CollateralVault
contract may be staked in pools. The pool's shares are not tokens and are non-fungible. For each token, they only represent a balance of the user's account proportional to the account's participation in the pool. When funds are claimed by the claimant, the value represented by the shares decreases along with the pool's balance. This can reach a point where the shares' value is highly diluted so that any new staking action fails due to an integer overflow during the calculation of the amount of shares to be issued. For this reason, it is possible that the pool is "reset", meaning that all tokens are unstaked and the users are required to either stake them again or withdraw them. Immediately after a reset, shares are again issued in 1:1 proportion to the staked amount. A reset can happen either when the claimable pool balance is zero or a dust amount, or when the resetter role explicitly calls the resetPool
function.
Anvil Governance
The protocol's governance utilizes the Anvil token as the voting token. The total supply of the Anvil token is planned to be 100 billion tokens, a portion of which will be minted to the token contract deployer while the rest will be distributed via an airdrop procedure handled by the Claim
contract. The airdropped amounts will be subject to a vesting period, after which the beneficiaries can claim them. However, during the vesting period, the beneficiaries will be able to prove their balance with a Merkle proof and delegate the corresponding voting power.
In order to implement this feature of delegating voting power of tokens that are not yet in the beneficiaries' possession, the governance token has been implemented as a modified version of the OpenZeppelin Votes
contract. Most importantly, the functionality associated with transferring the voting power has been changed so that the proven yet unclaimed token balance can also be accounted for votes delegation. After a certain point in time, the Claim
contract's owner is able to rescue the unclaimed tokens that remain in the contract but cannot rescue the tokens that have been proven yet are unclaimed.
The governance proposals are considered to have reached quorum if at least 4% of the Anvil token's total supply has participated in the voting process. Successful proposals are subject to a timelock delay before their execution in order to guarantee a minimum amount of time for users to react in case of unfavorable proposal actions. The governance contract is upgradeable but is owned by the governance's timelock itself. As such, a voting procedure and a delay are also ensured in case of important governance changes.
Security Model and Trust Assumptions
The protocol defines a number of privileged entities which are able to make crucial decisions or modify sensitive parameters of the system that affect its overall security. During the audit, we assumed that all of these entities would behave honestly and in the best interests of the users and the overall ecosystem. Wherever possible, some specific on-chain restrictions have been suggested which we included as lower-severity issues in this report.
The privileged entities or roles and their corresponding sensitive operations are described below.
- The
owner
of theCollateralVault
contract is able to set the withdrawal fee ratio (up to 10%), grant or revoke approval for a collateralizable contract, enable or disable a collateral token, and withdraw the fees accrued by transferring the collateral tokens to an arbitrary address of their preference. - The
CLAIMANT_ROLE
in theTimeBasedCollateralPool
contract is able to withdraw an arbitrarily large portion of the pool's balance. It has been assumed that the claim reason is well-defined and understood by all participating parties. It has also been assumed that the verification of the claim reason is going to be managed by a separate contract or system. - The
CLAIM_ROUTER_ROLE
in theTimeBasedCollateralPool
contract is able to set the destination account for claims. It has been assumed that this role behaves in the claimant's interest. - The
RESETTER_ROLE
in theTimeBasedCollateralPool
contract is able to reset the pool at will and under no restriction. It has been assumed that the pool is going to be reset only when necessary (i.e., when the pool's units have been highly diluted due to a large claim). Otherwise, it is possible that the vesting period is not respected as the minimum funds availability period for the claimant, possibly resulting in a DoS for the claim actions. - The deployer of the Anvil governance token is immediately minted a portion of the token's total supply. It is assumed that this portion is reasonable and will be handled with respect to the ecosystem's needs.
- The owner of the
Claim
contract is able to call theinitialize
function more than once and (re)set sensitive parameters of the airdrop procedure such as the vesting period and the rescue delay. In addition, the assigned value of the rescue delay is important to guarantee that users are given enough time to claim their ownership over their airdropped balance. It has been assumed that the owner will act in accordance with the users' interests and that the rescue delay will be set to a reasonable point in time. - The owner of the
AnvilGovernorDelegator
contract, which is the proxy contract for governance, is able to upgrade the governance implementation contract. The Ampera team is planning to assign the governance's timelock contract as the owner, essentially having the governance being the owner of itself. In this way, any implementation upgrade is going to undergo the governance proposal procedure.
Critical Severity
Proposals Can Reach Quorum Without Any Votes
The minting of Anvil tokens does not update the total voting power recorded in the _totalCheckpoints
struct, hence _totalCheckpoints
is always empty. This empty value is used to calculate the past total supply of tokens in the getPastTotalSupply
function of the AnvilVotes
contract, incorrectly indicating the total supply as zero. The getPastTotalSupply
function is called in the GovernorVotesQuorumFractionUpgradeable
contract to calculate the quorum
, which makes the returned quorum always be equal to zero. If the quorum is zero, proposals would reach quorum even without anyone voting on them. Once the proposal deadline has passed, the proposal can be executed. The only way to prevent a malicious proposal from getting executed is to have enough votes against it.
Since the minting of Anvil tokens is restricted to the constructor and there is no possibility of burning Anvil tokens, consider modifying the getPastTotalSupply
function so that it returns the total supply of Anvil tokens for the correct calculation of quorum.
Update: Resolved in pull request #281 at commit e1352ca.
High Severity
Funds May Be Locked Inside TimeBasedCollateralPool
Users' deposits are represented by units instead of tokens in the TimeBasedCollateralPool
contract. This is because any amount of staked tokens may be claimed at any time by an account with the claimant role and the total amount of tokens inside the pool will decrease in such a case. As such, the users will not be able to receive their full deposit when they unstake their tokens. Instead, they will receive the amount of tokens proportional to their unit's balance. All the unit-related calculations are performed by the calculateProportionOfTotal
function of the Pricing
library.
Every time users stake their tokens, they receive the amount of units proportional to their contribution to the entire token pool balance. This, combined with the claims that may happen over time, may drastically increase the amount of units needed to represent a single token. For this reason, the reset functionality has been introduced in order to manually release all tokens staked in the pool. This is so that the balance between tokens and units is brought back to a 1:1 ratio when users stake their tokens again. However, there are possible scenarios where users are allowed to deposit their tokens but cannot unstake these tokens later on due to the Integer Overflow exception. One such scenario is described below.
Assume that there is some amount of tokens deposited into the pool, but the units are diluted. For example, let's say that for a token with 18
decimals, the pool holds a total of 1e26
units, but only a dust amount of tokens due to claims. It can either naturally happen over time, or by way of an attack where there is a transaction issued by an account with the claimant role in order to claim the entire collateral, but an attacker front-runs it and deposits 1 wei
of the token into the pool. This will avoid resetting the pool and will quickly dilute the units.
Afterward, another user deposits some 1e8
tokens to the pool and receives a very large amount of units ~1e52
. After some time, they want to withdraw their assets so they schedule a withdrawal. When the withdrawal is finally processed after two epochs, the user will not be able to claim it as this line will revert. This is because an attempt will be made to multiply two very large numbers (1e26
and 1e52
) and an integer overflow will occur. It will also revert for every other user, as the _unlockEligibleTokenContractPendingUnstakes
function is being called in order to process any user's withdrawals.
In particular, users will not be able to call any function with the withEligibleAccountTokensReleased
modifier. The only way to recover from such a situation would be to reset the pool. However, the user who deposited 1e8
tokens after the units became diluted will still not be able to withdraw their assets, as this time, this calculation will cause an overflow. This will lock the user's funds in the protocol.
Consider verifying that integer overflow will not happen for future withdrawals each time users stake their tokens.
Update: Resolved in pull request #275 at commit 523f534.
Users May Lose The Ability to Transfer Their Tokens
Whenever a change is made to the voting power of any user, the _moveVotingPower
function is called. It handles several different scenarios, such as a voting power change during token transfer or during delegations. Another scenario involves claiming the tokens from the Claim
contract. Before doing that, users have to prove their balance inside the Claim
contract. Although the tokens are vested over time, whenever a user proves their balance, their delegatee immediately receives the voting power from the tokens to be claimed. Because of that, when they finally receive the vested tokens, the voting power of their delegatee does not change as it has already been accounted for in the past.
However, it is possible to execute the code responsible for handling claims during normal token transfer. It can happen when a user transfers tokens to any account while having a non-zero claimable balance and the Claim
contract as their current delegatee. In such a case, during the tokens transfer, the _moveVotingPower
function will be executed with claimContractAddress
as the second argument, and since the user has a non-zero amount of claimable tokens, this check will pass. In order to illustrate the possible consequences of this behavior, consider the following attack scenario where there are two users: Alice and Bob.
Alice has two accounts: one having a non-zero balance and some proven unclaimed tokens to claim from the Claim
contract, and a second account with a zero token balance that has Bob as its delegatee. Bob has a non-zero balance in his account. Now, Alice can:
- Call
proveAndDelegate
from her first account and specify theClaim
contract as the delegatee. - Transfer her balance to her second account. Since Alice's first account delegates to the
Claim
contract and its claimable balance is non-zero, no change will be made to the voting power of her second account's delegatee. - Change the delegatee of her first account to itself and transfer the tokens back from her second account to her first account. As a result, the second account's delegatee, Bob, will lose a corresponding amount of his voting power.
Consequently, Bob will not be able to transfer his tokens in the future as an integer underflow will occur during the attempt to decrease his voting power.
Consider not allowing users to delegate to the Claim
contract and handling the accounting of the accountProvenUnclaimedAmount
outside the _moveVotingPower
function.
Update: Resolved in pull request #279 at commit a6cac6a.
Medium Severity
Risk of Storage Collision in Proxy Contract
The AnvilGovernorDelegator.sol proxy contract does not follow the ERC-1967 standard for proxy storage slots. The contract stores the implementation address in a non-random slot, which can lead to a storage collision with the logic contract if some state variables are in use.
The AnvilGovernorDelegate.sol logic contract does not have any state variables using the storage slots. However, if the logic contract upgrades in the future and introduces a state variable, the proxy contract storage will collide, leading to a contract takeover due to the collision of the _owner
variable or making the contract entirely inoperable due to the collision of the _implementation
variable.
Consider following the ERC-1967 standard and utilize unstructured storage approaches for proxy implementations.
Update: Resolved in pull request #298 at commit 0a5cb12.
Low Severity
It Is Possible to Call poolCollateral
for a Disabled Token
The users of the CollateralVault
contract can only interact with certain whitelisted tokens. These tokens may be enabled or disabled by the owner of the CollateralVault
contract through a call to the upsertCollateralTokens
function. For a disabled token, it is not possible to create reservations or to deposit more funds, and only operations related to withdrawals are supported.
However, the poolCollateral
function does not check if the collateralToken
is enabled. Hence, a collateralizable contract can pool the existing collateral of a disabled token, thereby transferring funds between the user's and the collateralizable contract's accounts within CollateralVault
.
Consider verifying that the poolCollateral
function can only interact with enabled tokens.
Update: Resolved in pull request #303 at commit b9f99cf.
Unused Code
In the _moveVotingPower
function of the Anvil
contract, the integer sign of the fromVotingUnitDelta
variable makes a distinction between the cases where voting units should be subtracted from, or added to, the from
address.
However, this distinction is redundant since there is no case where fromVotingUnitDelta
is a positive value. For this reason, there is also no need to cast fromVotingUnitDelta
to a negative integer value. This variable could be declared as an unsigned integer and subtraction could always be performed when it is assigned a non-zero value.
Consider declaring the fromVotingUnitDelta
variable of the _moveVotingPower
function as an unsigned integer in order to considerably simplify the code.
Update: Resolved in pull request #304 at commit d8a885c.
Allowance Changes Are Allowed to Non-Whitelisted Collateralizable Contracts
The depositAndApprove
, modifyCollateralizableTokenAllowance
, and modifyCollateralizableTokenAllowanceWithSignature
functions of the CollateralVault
contract do not validate whether the collateralizable contract with the given address is approved. Instead, the internal
functions are immediately called, assuming that any necessary input validation has been already performed on the caller's side. Although no specific way to exploit this lack of validation was identified, it is possible that invalid data is inserted into the accountCollateralizableTokenAllowances
mapping.
Consider always validating that _collateralizableContractAddress
belongs to an approved collateralizable contract or only permitting decreasing the allowance of a formerly enabled collateralizable contract for consistency and clarity.
Update: Resolved in pull request #305 at commit 6ca3a49.
Inconsistent Starting and Ending Epoch Timepoints
In the TimeBasedCollateralPool
contract, the getCurrentEpoch
function is exclusive of the next epoch's starting second, whereas according to the getEpochEndTimestamp
function, the ending timestamp for epoch i
is equal to the starting timestamp for epoch i+1
.
For consistency and clarity, consider excluding the next epoch's starting timestamp in the getEpochEndTimestamp
function.
Update: Resolved in pull request #306 at commit 5b58bbb.
Lack of Input Sanity Checks
Throughout the codebase, there are several instances of missing or insufficient function argument validation. Some instances are listed below:
-
Since the users are allowed to withdraw tokens from a disabled token address, the withdrawal of tokens from
CollateralVault
does not verify whether the_tokenAddress
is approved by governance or not. However, if the input_amount
is zero, the contract can make a safe transfer to any arbitrary token address and emit a misleadingFundsWithdrawn
event. -
While the docstring within the
_moveVotingPower
function states that the_destination
address provided for rescuing tokens in theClaim
contract should not have a claimable balance, this condition is not checked in the codebase. If the_destination
address has an initial proven balance, rescue of tokens may revert in case the_tokenAmount
is greater than theirdelegatorProvenUnclaimedUnits
. -
There is no check ensuring that the
_tokens
and_amounts
arrays have the same length in theclaim
function of theTimeBasedCollateralPool
contract.
In order to reduce the attack surface of the codebase, consider implementing all the missing checks.
Update: Resolved in pull request #307 at commit 1c3402a.
Ownership Can Be Renounced
The owner of the Claim
contract can renounce ownership by calling the renounceOwnership
function. If the ownership is lost, the ownerRescueTokens
function cannot be called, prohibiting the recovery of tokens locked in the contract.
Consider overriding the renounceOwnership
function to revert if an attempt to renounce ownership is made when there are tokens left in the contract.
Update: Resolved in pull request #308 at commit df852c8.
Users Will Lose Tokens if They Attempt to Transfer Them to Themselves
The transferCollateral
function may be used in order to transfer between accounts those tokens which have already been deposited into the vault. This transfer does not cost any fees and it is performed by adding the target amount of tokens to the recipient's account and by subtracting them from the sender's account. However, the value used for calculating the new sender's balance is cached before the recipient's balance is calculated, which means that if sender and recipient are the same, their accountBalances
entry will decrease by the token amount instead of staying the same.
Consider not modifying a user's balance upon a transfer when the sender is the same as the recipient.
Update: Resolved in pull request #309 at commit ca00376.
depositAndStake
Could Fail Due to Front-Running
By calling the depositAndStake
function of the TimeBasedCollateralPool
contract, a user can deposit funds into the CollateralVault
contract and also stake them in a single transaction. To trigger the deposit action, the collateralizable contract must have been granted a non-zero allowance by the user. As an alternative, the user can provide a signed message to increase the corresponding allowance and thus make the whole transaction possible. However, an attacker may front-run a depositAndStake
transaction containing a signed message and make it fail as follows:
- A user submits a
depositAndStake
transaction for which they have a signed a message. - The attacker front-runs the user's transaction and uses it to call
modifyCollateralizableTokenAllowanceWithSignature
successfully. - The user's
depositAndStake
call fails because the signed message has already been used, thereby increasing the nonce.
The attacker has no immediate profit but can ruin the user's experience.
Consider requiring a separate message for allowing a collateralizable contract to trigger a deposit action on behalf of a user.
Update: Resolved in pull request #266 at commit 615c922.
Floating Pragma
Pragma directives should be fixed to clearly identify the Solidity version with which the contracts will be compiled. Throughout the codebase, there are numerous contracts that use the solidity ^0.8.20
floating pragma directive.
Consider using fixed pragma versions.
Update: Resolved in pull request #310 at commit 682ae20.
Notes & Additional Information
Unused Named Return Variables
Named return variables are a way to declare variables that are meant to be used within a function's body for the purpose of being returned as that function's output. They are an alternative to explicit in-line return
statements. Throughout the codebase, there are unused named return variables:
- The
_collateralFactorBasisPoints
return variable in thecollateralFactorInBasisPoints
function inPricing.sol
- The
_creditedTokenAmount
return variable in thecollateralAmountInCreditedToken
function inPricing.sol
Consider either using or removing any unused named return variables.
Update: Resolved in pull request #311 at commit 5c3c9b1.
Unnecessary Cast
Within the Claim
contract, the uint256(amount)
cast is unnecessary.
To improve the overall clarity, intent, and readability of the codebase, consider removing unnecessary casts.
Update: Resolved in pull request #312 at commit af85da8.
Lack of Security Contact
Providing a specific security contact (such as an email or ENS name) within a smart contract significantly simplifies the process for individuals to communicate if they identify a vulnerability in the code. This practice proves beneficial as it permits the code owners to dictate the communication channel for vulnerability disclosure, eliminating the risk of miscommunication or failure to report due to a lack of knowledge on how to do so. In addition, if the contract incorporates third-party libraries and a bug surfaces in these, it becomes easier for the maintainers of those libraries to contact the appropriate person about the problem and provide mitigation instructions. The contracts of the audited codebase do not have a security contact.
Consider adding a NatSpec comment containing a security contact above the contract definitions. Using the @custom:security-contact
convention is recommended as it has been adopted by the OpenZeppelin Wizard and the ethereum-lists.
Update: Resolved in pull request #313 at commit 04a6952.
Hardhat console
Import Present
There is a Hardhat console
import in line 15 of AnvilGovernorDelegate.sol
.
To improve the overall readability of the codebase, consider removing Hardhat debugging imports.
Update: Resolved in pull request #314 at commit ed1926b.
Duplicated Code
Throughout the codebase, there are several instances of duplicated code:
- In the
_authorizedModifyCollateralizableTokenAllowance
function of theCollateralVault
contract, the mappingaccountCollateralizableTokenAllowances
could equivalently be refactored out of theif-else
statement to avoid duplicating the code in the_addAccountCollateralizableTokenAllowance
function. - Inside the
getClaimableCollateral
function, the code calculating claimable collateral for the current epoch works exactly the same as the code which calculates claimable collateral for the next epoch.
Consider refactoring the code so that the instances of duplicated code are removed. This will help make the codebase more concise and less error-prone.
Update: Resolved in pull request #315 at commit a453a51.
Misleading Documentation
Throughout the codebase, there are several instances of misleading or incorrect documentation. Some examples are listed below:
- The documentation of the
_unlockEligibleTokenContractPendingUnstakes
function suggests that the function iterates through thetokenContractPoolBalances
array. However, such an array does not exist and the function does not iterate through any data. - The documentation inside the
_authorizedModifyCollateralizableTokenAllowance
function suggests that_byAmount
will always be negative at that point, but it can also be equal to0
. - The documentation of the
getClaimableCollateral
function suggests that the function is not aview
function, but it is declared as such. - The inherited documentation for the
getAccountPoolBalance
function of theTimeBasedCollateralPool
contract is not clear and makes no specific comment regarding the notion of an account's "balance" in association with vested or unvested stakes.
Consider fixing the documentation in order to improve the overall clarity and readability of the codebase.
Update: Resolved in pull request #316 at commit 9b15022.
Code Clarity Suggestions
Throughout the codebase, a number of instances were identified where code clarity could be improved:
- Since the
_proof
argument of theproveInitialBalance
function should not be edited in any way, consider passing it to the_verifyInitialBalanceOrRevert
function ascalldata
instead ofmemory
in order to avoid copying it to memory. In addition, consider using theverifyCalldata
function of theMerkleProof
library. - Throughout the
TimeBasedCollateralPool
contract, the conditionepoch > 0 && epoch < currentEpoch
is checked multiple times. Consider putting it into a helper function in order to reduce code duplication and make the code more readable. - Several functions throughout the
CollateralVault
contract require that the passedtoken
or collateralizable contract address are enabled by the owner. Consider using modifiers for these checks to make the requirements clearer to the reader.
Update: Resolved in pull request #317 at commit 6965e70.
Multiple Initializations of Claim
Are Possible
The Claim
contract can be initialized multiple times by the owner, allowing them to modify sensitive parameters (which should not be changed) such as balanceRoot
, vesting duration, and ownerRescueTimestamp
.
Consider allowing the initialize
function to be called only once in order to guarantee that the parameters will not be changed after they are initially set.
Update: Resolved in pull request #318 at commit 9e1bcec.
Using uint
Instead of uint256
Throughout the codebase, there are uses of uint
as opposed to uint256
:
- In line 40 of
AnvilGovernorDelegator.sol
- In line 41 of
AnvilGovernorDelegator.sol
- In line 42 of
AnvilGovernorDelegator.sol
- In line 310 of
TimeBasedCollateralPool.sol
In favor of explicitness and style consistency, consider replacing all instances of uint
with uint256
.
Update: Resolved in pull request #319 at commit 246426d.
Unused Errors
Throughout the codebase, there are several instances of errors which are declared, but not used anywhere. Some examples include:
- The
InvalidCollateralPool
error defined in theICollateral
interface - The
InsufficientReleasable
error defined in theITimeBasedCollateralPool
interface
In order to improve the readability of the codebase, consider removing all unused errors.
Update: Resolved in pull request #320 at commit 4a6f0bc.
Typographical Errors
Throughout the codebase, there are several instances of typographical errors present. Some examples are listed below.
In the Pricing
contract:
- In line 22, "credited token token" should be "credited token".
- In line 40, "give" should be "given".
- In line 80, "
amountBeforeFee
function" should be "amountWithFee
function".
In the TimeBasedCollateralPool
contract:
- In line 119, the word "for" is redundant and should be removed.
- In line 517, the word "grated" should be "granted".
- In line 881, the second "the" should be removed.
In the AnvilVotes
contract:
- In line 117, "it no longer used" should be changed to "it is no longer used".
In the AnvilGovernorDelegate
contract:
- In line 18, "
AnvilGovernorDelegate
" should be changed to "AnvilGovernorDelegator
".
In order to improve the readability of the codebase, consider fixing all instances of typographical errors.
Update: Resolved in pull request #321 at commit 1218798.
Todo Comments in the Code
During development, having well-described TODO/Fixme comments will make the process of tracking and solving them easier. Without this information, these comments might age and important information for the security of the system might be forgotten by the time it is released to production. These comments should be tracked in the project's issue backlog and resolved before the system is deployed. The Following TODO/Fixme comment was found:
TODO
comment in line 42 ofAnvil.sol
Consider removing all instances of TODO/Fixme comments and instead tracking them in the issues backlog. Alternatively, consider linking each inline TODO/Fixme to the corresponding issues backlog entry.
Update: Resolved in pull request #322 at commit 8816b61.
Inconsistent Use of Named Returns
Throughout the codebase, there are multiple instances where contracts have inconsistent usage of named returns in their functions. A non-exhaustive list of such instances is given below:
- In
CollateralVault
contract, theclaimCollateral
function has named return variables whereas themodifyCollateralReservation
function does not. - In
TimeBasedCollateralPool
contract, thedepositAndStake
function has named return variables whereas the_unlockEligibleTokenContractPendingUnstakes
function does not.
To improve readability, consider being consistent with the use of named returns throughout the codebase.
Update: Resolved in pull request #311 at commit e204a18.
Lack of Indexed Event Parameters
Throughout the codebase, several events do not have indexed parameters:
- The
TokensClaimed
event inClaim.sol
- The
InitialBalanceProven
event inClaim.sol
- The
FundsRescued
event inClaim.sol
- The
AccountCollateralizableContractAllowanceUpdated
event inICollateral.sol
- The
AccountInitiatedUpgrade
event inICollateral.sol
- The
CollateralTransferred
event inICollateral.sol
- The
ProtocolBalanceWithdrawn
event inICollateral.sol
To improve the ability of off-chain services to search and filter for specific events, consider indexing event parameters.
Update: Resolved in pull request #323 at commit 8ea9d51.
Duplicate Imports
Throughout the codebase, there are several instances of duplicated imports:
import {Checkpoints} from "@openzeppelin/contracts/utils/structs/Checkpoints.sol";
imports duplicated aliasCheckpoints
inAnvil.sol
. It is already imported through the chain of inheritance viaAnvilVotes.sol
.import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
imports duplicated aliasSafeCast
inAnvil.sol
. It is already imported through the chain of inheritance viaAnvilVotes.sol
.import "./interfaces/ICollateralDepositTarget.sol";
inCollateralVault.sol
. It is already imported through the chain of inheritance viaICollateral.sol
.import "@openzeppelin/contracts-upgradeable/governance/extensions/GovernorSettingsUpgradeable.sol";
is imported twice inAnvilGovernorDelegate.sol
.
Consider removing duplicate imports to improve the overall clarity and readability of the codebase.
Update: Resolved in pull request #324 at commit 628feb1.
Unused Imports
Throughout the codebase, there are several instances of unused imports:
- The
CollateralVault
contract imports theIERC20Metadata
contract but never uses it. - The
Anvil
contract imports theERC20Votes
contract but never uses it. - The
AnvilGovernorDelegate
contract imports theInitializable
contract but never uses it - The
Claim
contract importsSafeERC20
contract but never uses it.
In order to improve the readability of the codebase, consider removing all unused imports.
Update: Resolved in pull request #324 at commit 628feb1.
Negative Allowance in CollateralVault
Can Be Counterintuitive
The CollateralVault
contract allows users to set negative allowances for collateralizable contracts. While it provides flexibility to maintain the allowance, there are certain scenarios where this feature can be counterintuitive and create confusion for the user, thereby adversely affecting the user experience. For instance:
- Consider a scenario where a user initially sets an allowance of amount
A
to a collateralizable contract and this amount gets reserved. Then, the user decides to zero out the overall allowance by setting the allowance to-A
. Later, the user wishes to grant allowanceA
to the same collateralizable contract for another reservation, but due to confusion, only adds amount+A
, resulting in a zero allowance and causing the reservation to fail. - Negative allowances remain if the reserved amounts are claimed, even when there is no chance for the allowance to increase automatically by a release action. In this scenario, the user who previously set the allowance to a negative value could fail to increase the allowance to a positive value due to confusion.
In addition to the instances above, this feature makes the code more complicated and negatively impacts the readability of the codebase.
Consider using a single-use, non-negative allowance mapping to track allowances.
Update: Resolved in pull request #267 at commit b25ec58.
Add Functionality to Cancel Signed Messages
By calling the depositFromAccount
function of the CollateralVault
contract, a collateralizable contract that has zero or negative allowance can use a message signed by a user to increase its allowance for the user's account and deposit funds directly from the user's wallet.
Given that this functionality is highly sensitive, consider adding a cancelModifyCollateralizableTokenAllowanceWithSignature
function which the user could call to cancel the signed message (e.g., by increasing their nonce).
Update: Resolved in pull request #325 at commit 343b09f. The Nonces
contract has been replaced by the custom SignaturesNonces
contract which enables separate nonces per message typehash and supports cancelling batches of signed messages that have not been verified yet.
Counterintuitive depositFromAccount
Functionality
The users of the CollateralVault
contract can deposit funds into their account and set a specific allowance amount for one or more collateralizable contracts. In this way, each collateralizable contract is able to handle at most the allowed amount on the user's behalf, whereas the total deposits may be less than the total allowance made to the collateralizable contacts.
The depositFromAccount
function of the CollateralVault
contract can be called from a collateralizable contract in order to increase an account's deposited funds by transferring funds directly from the user's wallet. Thus, if a user has given an unlimited or large enough allowance to CollateralVault
, the collateralizable contract is able to successfully perform a deposit on the user's behalf, unless the collateralizable's allowance is zero or negative.
In essence, the depositFromAccount
function equates the user's allowance made to the collateralizable contracts with the allowance made towards the CollateralVault
contract. However, the fact that one is related to the user's wallet and the other to the user's account in CollateralVault
creates a qualitative difference between these two kinds of allowances in the protocol. The allowance towards CollateralVault
seems to simply offer flexibility and gas savings so that the user can allow a very large amount only once and then decide to what extent it will be used by triggering deposit actions in the future.
In addition, the fact that a user may set a larger sum of allowances to the collateralizable contracts compared to the total funds deposited in CollateralVault
seems to be a feature which allows the user to control the total risk taken. The depositFromAccount
function does not respect this feature and seems to be counterintuitive for the user. It is also possible that a collateralizable contract transfers a significant amount from a user's wallet to the CollateralVault
even if it has insufficient allowance to use it. However, the user would still be obliged to pay the system's fee to withdraw the funds back to their wallet.
Consider defining a specialized message typehash for permitting deposit actions and always require a signed message in order to execute depositFromAccount
.
Update: Resolved in pull request #266 at commit be2f3b8.
Recommendations
Compatibility With Tokens That Extend ERC-20
The owner of the CollateralVault
contract is responsible for enabling or disabling the token contracts that can act as collateral. There are no specific guidelines or requirements for the enabled tokens other than they are expected to follow the ERC-20 standard. However, there are some extensions of the ERC-20 standard that should not be enabled for specific reasons:
- Fee-on-transfer tokens. These tokens will result in erroneous accounting upon deposit actions as the amount received by the vault will be lower than the provided deposit amount.
- Rebasing tokens. If the contract's balance is increasing after a rebase then the extra amount will be eventually held by the
CollateralVault
contract as fee which is unfair to the depositors. On the other hand, if after a token's rebase the contract's balance is decreasing, then the whole accounting is against the protocol and any depositor can benefit until all the contract's funds are drained. - Upgradeable token contracts. It is generally a risk to whitelist upgradeable contracts since their implementation might be altered.
We have also considered the case of ERC-777 tokens but could not identify a specific scenario where the transfer hooks would introduce an attack vector, at least under the current implementation of the CollateralVault
and TimeBasedCollateralPool
contracts.
Consider explicitly documenting that instances of the tokens listed above should not be enabled inside CollateralVault
.
Factory Contract for ITimeBasedCollateralPool
Contracts
In the stakeReleasableTokensFrom
function of the TimeBasedCollateralPool
contract, if the ITimeBasedCollateralPool _pool
argument is not equal to the contract's address then an external call to _pool
is made. Essentially, it is expected that the _pool
contract implements similar functionality so that the releaseEligibleTokens
function releases the vested unstaked tokens to the user's account in CollateralVault
. In this way, the released funds can be reserved again in order to be staked in Anvil's TimeBasedCollateralPool
.
However, there is no guarantee regarding the actual implemented functionality of the called contract. While no specific vulnerabilities have been identified, calling arbitrary code increases the attack surface.
Consider adding a factory contract which deploys pool contracts as per the TimeBasedCollateralPool
implementation. In this way, it is possible to only allow external calls to pool contracts that have been deployed by the factory contract, which is a guarantee about the code executed.
Conclusion
The Ethereum-based Anvil Protocol aims to manage collateral transparently via the CollateralVault
contract. Using this contract, users can deposit and withdraw collateral and set allowances for collateralizable contracts. Anvil's governance uses the Anvil token for voting, with a total planned supply of 100 billion tokens distributed mainly through airdrops subject to vesting periods. Voting delegation for these tokens is enabled using a modified OpenZeppelin Votes
contract.
One critical-severity and two high-severity issues have been discovered, mostly due to important modifications of external library contracts for the governance token. Despite the fact that a high-severity issue was discovered inside the TimeBasedCollateralPool
contract, it nonetheless handles considerably subtle accounting properly.
The Ampera team has sufficiently tested certain parts of the audited codebase, including fuzzing for the TimeBasedCollateralPool
and the governance's Claim
contract. However, we recommend enhancing the test suite for the governance token contracts which contain the modified library code. There are some instances where the code can be simplified or the functionality could be made more intuitive for the common user. This has led us to including a number of recommendations in this report.
The Ampera team provided us with extensive documentation for the protocol and has been responsive and engaging in discussions with the auditors during the audit period.