November 15th, 2022
This security assessment was prepared by OpenZeppelin.
We audited the pods-finance/yield-contracts repository at the 9389ab46e9ecdd1ea1fd7228c9d9c6821c00f057 commit.
In scope were the following contracts:
contracts/
├── configuration
│ └── ConfigurationManager.sol
├── interfaces
│ ├── IConfigurationManager.sol
│ ├── ICurvePool.sol
│ ├── IERC4626.sol
│ └── IVault.sol
├── libs
│ ├── AuxMath.sol
│ ├── CastUint.sol
│ └── DepositQueueLib.sol
├── mixins
│ └── Capped.sol
├── proxy
│ ├── ETHAdapter.sol
│ └── Migration.sol
└── vaults
├── BaseVault.sol
└── STETHVault.sol
The Pods Yield Vault is a one-click deposit investment product that features an options strategy to generate principal-protected returns when the ETH market is volatile. The underlying technique uses a portion of the yield from stETH assets to invest in the same number of call and put ETH options in an off-chain market. Returns from these investments will be given to the participants who provided their yield to the investor.
The following diagram gives an overview of the system:
The Ethereum Volatility Vault, stETHvv
for short, is a tokenized vault on the Ethereum mainnet, conforming to the EIP-4626 standard. It integrates with the Lido stETH
token as the single underlying asset. Users deposit the underlying asset into the vault in return for the stETHvv
vault shares to participate in the investment strategy.
The investment strategy is executed in rounds. After a new round starts, users can join the deposit queue, where the users’ underlying assets will be transferred to the vault, thus starting to generate yield to the vault. However, stETHvv
vault shares will not be minted to the user until the queued deposit is processed after the round ends.
The user can withdraw their queued deposit before it is processed, but any stETH
yield generated by the queued deposit will not be accessible. Before the round ends, users with stETHvv
vault shares can withdraw their share of assets for a fee.
When the round ends, only a portion of the stETH
yield, not the principal, will be transferred to a centralized investor
account, where the yield is manually used to participate in the off-chain option strategy. In the same transaction, the strategy profit denominated in stETH
from the previous round will be transferred to the vault from the investor
account.
After the round ends, deposits to the queue and withdrawals from the vault are temporarily paused while processing of queued deposits is enabled. Anyone can process any queued deposit by specifying a valid start and end index of the queue. Each processed deposit will mint new stETHvv
vault shares, as computed based on the total assets in the vault. Any unprocessed deposit will remain in the queue, which can be either processed or withdrawn in the next round.
When the next round starts, new deposits and withdrawals to the queue may resume, while queued deposits can no longer be processed.
Parameters such as the vault controller, the percentage of commission for withdrawals, and the cap of shares that can be issued are established in this contract for the different vaults of the protocol. One particularity of this contract is that these values are set through a function that allows storing any name-value pair for a specific target on a nested mapping. This allows more values to be added in the future without the need to replace the contract but adds more complexity to managing contract storage.
It is worth mentioning that there is an exception where an exclusive function was defined to establish if a vault can be used as a migration destination. It has its own setter and getter.
In order to allow vault deposits to be made directly with ETH, this contract acts as an intermediary so that users can participate in the protocol without needing to perform the swap themselves. This contract swaps ETH for stETH through a Curve Finance pool. After the exchange, the ETH adapter deposits the resulting tokens to the vault on behalf of the user within the same transaction.
In the case of an upgrade, a stETHvv
vault shareholders can call migrate()
on the current vault contract to redeem their shares from this vault and join the deposit queue in the new vault. Also, users can leverage the migration contract, an implementation that has already set the origin and destination vaults, to join the deposit queue in a new vault. In addition to the migrate()
available on the current vault, the migration contract also has a permit feature that allows users to perform the migration in one transaction.
In order to fulfill its value proposition of being a one-click deposit investment product, Pods Finance team implements permit functionalities on deposits and other key vault functions. With this, users can sign a permit off-chain and then use the functions with permit integrations to perform the approval in the same transaction.
Some privileged roles exercise powers over the vault and periphery contracts:
Owner
of the ConfigurationManager
Controller
of the vault
controller
can start a new round anytime.controller
can end a round.Investor
of the vault
Here we present our findings.
When users attempt to join the STETHVault
, they first join a DepositQueue
. While being part of the DepositQueue
, users can elect to either refund their deposit or wait until the round ends for their deposit to be processed. Both of these actions will then call the underlying remove function in order to restructure the DepositQueue
.
The issue is that the remove
function performs gas-intensive operations that drastically limit the upper bound on the queue size, which we conservatively estimate to be in the magnitude of a few thousands. With a large enough queue, any transaction attempting to refund or process even a single deposit will exceed the block gas limit of 30,000,000 gas. This could occur due to organic user growth, or a malicious actor may choose to send dust amounts of stETH to the vault from different addresses to take up positions in the queue. Without the ability to process or refund users, all current and future deposits made to the vault would be locked in the DepositQueue
.
Consider accounting for the gas usage of all queue operations and ensuring they cannot reasonably exceed the block gas limit. Some recommendations to consider:
DepositQueue
so that operations such as remove
can be performed in significantly less (and preferably constant) gasDepositQueue
size to guarantee it can be processed each roundDepositQueue
, such as an EnumerableMap, to reduce code maintenance overhead and ensure necessary operations can be performed in constant gasUpdate: Fixed in commit e05f165
.
When processing queued deposits, the amount of assets that have been processed are accounted for in the processedDeposits
variable and used to calculate the amount of shares to be minted. This variable is reset to 0 at the beginning of each round.
However, each time the processQueuedDeposits
function concludes, the processed amount will be removed from totalIdleAssets
, hence increasing the totalAssets
. When the queue is processed the next time, the totalAssets
value already contains the amount of assets processed during the round. By adding the processedDeposits
to the totalAssets
again, the previously processed amount is double counted, which leads to an incorrect calculation for the amount of shares minted to the next depositor in the queue.
This penalizes depositors by minting fewer shares if they are not included in the first call of the processQueuedDeposits
function. This issue is particularly severe if each deposit is processed individually. Hence it breaks the system invariant that the number of splits should not modify the result when processing the deposits.
Additionally, this means users not processed in the first round may receive fewer assets than they deposited when they redeem their shares. This breaks another important system invariant that 100% withdrawal in stETH should always be equal to or higher than the initial deposit amount.
Consider correcting the total asset calculation to ensure the processed amount is accounted for accurately.
Update: Fixed in PR#54, with commit 3ad9b76
being the last one added.
During the withdrawal process, users can specify the amount of assets to withdraw, which is then rounded down to shares.
When the asset amount specified by the user is less than the minimum amount that can be converted to a unit share, the shares
argument is zero in the internal withdraw function but the assets
argument is not. Hence, with zero shares, all internal calls can succeed and a non-zero amount of asset token will be transferred out to the receiver without burning any shares. This process can be repeated many times to drain the entire vault. The attack can also be executed with any asset amount by burning a rounded-down amount of shares and extracting the excess assets.
Since the vault is expected to become more valuable over time due to its yield strategy, this could lead to a profitable attack when one share is worth more than the cost.
Consider rounding up the shares for a given amount of assets during withdrawal.
Update: Fixed in PR#46, with commit 5ac5e3c
being the last one added.
withdraw
can disable endRound
The controller should be able to call the endRound()
function any time after a round starts to remove a portion of the accrued interest from the vault to the investor
account for the execution of investment strategies. In the _afterRoundEnd
hook, the totalAssets()
is compared to the lastRoundAssets
to compute the accrued interest. When a user withdraws assets before the endRound
, the lastRoundAssets
is updated in the _beforeWithdraw()
hook, where the withdrawn shares are rounded down to the corresponding asset value, which is subsequently subtracted from the lastRoundAssets
.
This may cause the lastRoundAssets
to be more than the totalAssets()
when a user calls the withdraw
function. This can result in a revert due to underflow. In such a case, the controller cannot end a round and must wait for the accrued interest to accumulate enough value.
Consider keeping track of the withdrawn asset directly. Also, consider making sure the key system functionality is not impeded by users’ actions.
Update: Fixed in PR#73, with commit b258ca9
being the last one added.
The internal _deposit
function handles user deposits, transferring a specified amount of stETH
from msg.sender
to the vault. Before moving the funds, it adds the deposit to the queue, which is processed later by the processQueuedDeposits
function.
As the underlying token could have hooks that allow the token sender to execute code before the transfer (e.g., ERC777 standard), a malicious user could use those hooks to re-enter the deposit
function multiple times.
This re-entrancy will result in an increment in the receiver balance on the queue, even though this balance will not correspond to the actual amount deposited into the vault.
In the current implementation, the _deposit
function in the BaseVault
contract is overridden by the implementation in the STETHVault
, which has the correct order of operation. However, the BaseVault
is likely to be inherited by future vaults, so it is crucial to have the correct _deposit
implementation in this contract in case it is not overridden.
Consider reordering the calls, doing the transfer first, and then adding the receiver to the queue to prevent this reentrancy scenario. Also, consider using OpenZeppelin’s ERC4626 implementation, which already has this solution implemented.
Update: Fixed in PR#41, with commit 2ffcb1e
being the last one added.
When processing the queued deposits, shares are minted to the receiver in the queue according to the amount of assets deposited. However, the amount of shares minted is always rounded up. This means that one can always receive 1 vault share with a 1-wei deposit.
As the vault is expected to be increasing in value from yield rewards, 1 vault share will be worth more than 1 wei asset eventually. A malicious user can spam the deposit queue with 1-wei deposit from many accounts to get 1 share each and then redeem them for more assets when each share is worth more.
Consider rounding down when minting vault shares.
Update: Fixed in PR#46, with commit 5ac5e3c
being the last one added.
The vault implements a mintWithPermit
and depositWithPermit
function intended to allow users to transfer assets to the vault in a single transaction. However, the vault’s underlying asset is intended to be stETH which does not have a permit
function. Currently, any user who tries to perform a mintWithPermit
or depositWithPermit
will have their transaction reverted due to the stETH contract’s fallback function.
Consider removing the mintWithPermit
and depositWithPermit
functions. We note that wstETH does have a permit function for future considerations.
Update: Acknowledged, and will not fix. Pods Finance team’s statement for this issue:
We decided not to fix this issue because we may implement assets similar to LIDO as the yield source where they may have permit functionality (aTokens, for instance).
The ETHAdapter
contract is used as a proxy to allow users to interact with the vault through sending and receiving ETH instead of stETH. The adapter achieves this by converting ETH and stETH through a curve pool and then forwarding interactions to and from the vault. In the course of a normal withdrawal or redemption transaction, the ETHAdapter
will pull the funds out of the vault before passing them on to the designated receiver. During the moment the ETHAdapter
is holding the funds, it first converts all of its stETH to ETH, and then sends its entire ETH balance to the receiving address.
The issue is that the ETHAdapter
sends its full balance to the receiver each time, meaning any ETH or stETH that is mistakenly sent to it can be drained by any user who performs a withdrawal or redemption on the ETHAdapter
. This is exacerbated by the fact that the vault is passed in as a parameter, potentially allowing a user to perform withdrawals and redemptions without interacting with the actual stETH vault.
Consider transferring out the exchanged balance from the curve pool to the receiver instead of the entire balance of the ETHAdapter
. Also consider implementing a rescue or sweep function to allow the recovery of funds that are accidentally sent to the ETHAdapter
.
Update: Acknowledged, will not fix. Pods Finance team’s statement:
For now, we do not want to take action in case of funds sends by mistake to our contract. We see this as a low-priority issue.
We found the following instances of incorrect calculations in view
functions that are not currently called internally:
previewWithdraw
function, the DENOMINATOR
over invertedFee
is always bigger than 1 when the fee is non-zero. Hence, the final returned shares are always an overestimate. Further, the withdrawFeeRatio
is multiplied to shares
instead of assets
as in other instances such as _getFee
. Consider correcting the withdrawal fee calculation.assetsOf
function over-estimates the actual commitment by an additional committedAssets
. Consider removing the extra component.Update: Partially fixed in commit 6d37029
in PR#75. previewWithdraw
does not include fees as stated in the EIP.
The AuxMath
library is a custom auxiliary math library that performs multiplication and division with rounding specifications.
The implementation of mulDivUp
and mulDivDown
first compute the multiplication of a uint256
with a uint256
without taking into account the possibility of an overflow in the product. The product could overflow into a uint512
in the multiplication step even if the result were to fit into a uint256
after the subsequent division. Hence the current implementation will not give the right result in such a case.
Consider using OpenZeppelin’s Math library which implements the mulDiv
function that is developed especially considering these scenarios and is widely accepted in the ecosystem.
Update: Fixed in commit 2a8c58a.
The amount of vault shares available for minting is limited by the availableCap
. Similarly, the availableCap
also limits the amount of assets that may be deposited and then later converted into vault shares for the depositor. The maxMint
and maxDeposit
functions do not account for the availableCap
and instead return the max uint256
value when called. In order to conform to the EIP4626
standard, these functions must return the real amount that can be minted or deposited.
Consider changing the maxMint
and maxDeposit
functions to return a value that accounts for the availableCap
.
Update: Fixed in PR#42, with commit 3159de5
being the last one added.
When a user deposits into the vault by joining the deposit queue, the corresponding shares, yet to be minted, are deducted from the spending cap immediately. The spending cap is restored only at withdrawal when the shares are burned.
If one user decides to use the refund function to leave the queue before the round ends, the spending cap will not be restored. When the cap is not zero, a malicious user with a sufficiently large amount of assets could repeatedly deposit and refund
to reach the cap limit and stop other eligible users from joining the queue.
Note that the spendCap
variable cannot be manually restored, but the owner can reset the cap to a higher value to unlock the deposit.
Consider accounting for the available cap during refunds from the queue.
Update: Fixed in commit 4a2e475
.
Deposits added to the queue are point in time stETH balance amounts. The stETH token balance rebases regularly to account for yield, and in the event of slashing, may be subject to a negative yield. In the event that a stETH token rebase is negative between the time a user deposits and calls for a refund, the vault will over credit the user by the rebase difference.
Consider handling the deposits in the queue in shares instead of balances to account for rebase changes on refunds.
Update: Acknowledged, will not fix. Pods Finance team’s statement:
Although we agree with the issue, we won’t prioritize it right now because of timing. In case of a slashing event, we can refund the vault, transferring funds directly to the contract.
The configuration manager determines the maximum vault shares available for minting. When setting a new cap through the setCap
function, the target contract address is checked for the zero address, and a SetCap
event is emitted.
However, the underlying setParameter
function can be called directly to change the maximum vault shares available. This will bypass the zero address check and emit a ParameterSet
event instead of the expected SetCap
event.
Consider ensuring consistency between the two mechanisms in setting the share cap. Depending on the desired outcome, this could involve removing the setParameter
function and adding individual get and set functions for all necessary configuration parameters.
Update: Fixed in commit cbe61d0
.
There are many instances throughout the codebase where changes can be made to improve gas consumption. For example:
public
functions might consume more gas than external functions. This is because with public functions, the EVM copies inputs (especially dynamic-sized arrays) into memory
, while it reads from calldata
if the function is external
, which is cheaper. Throughout the codebase there are public
functions like mintWithPermit
, convertToSTETH
, convertToETH
, decimals
, symbol
, name and assetsOf
that might be declared as external
. Consider reviewing the entire codebase for more instances.toAddress
has a conditional statement that returns the zero address if the value passed is equal to zero. This additional check increases the gas consumption and does not add any benefit even if the provided value is equal to zero.processedDeposits
, isProcessingDeposits
, roundAccruedInterest
, endSharePrice
and the while loop counter inside the remove
function.for
loop inside the refund
function does not cache the result of depositQueue.size()
and perform unnecessary operations on each iteration. The same issue occurs in the last while loop of the remove
function.name
, symbol
and the operation 10**sharePriceDecimals
.When performing these changes, aim to reach an optimal tradeoff between gas optimization and readability. Having a codebase that is easy to understand reduces the chance of future errors and improves community transparency.
Update: Partially fixed in PR#81, with commit 6f1bb8e
being the last one added.
The name attribute in the constructor of the BaseVault
, passed to the draft-ERC20Permit
contract does not match the name returned from the STETHVault
implementation. All vaults that use the same underlying asset and inherit from BaseVault
will have the same hashedName
when calculating the domain separator by the EIP712 standard. Although this does not represent a risk, it is recommended that the name sent to the ERC20Permit
match the ERC20 token name.
Also, the name
and symbol
defined in the BaseVault
constructor are inconsistently overridden in the STETHVault
implementation.
To avoid complications and improve code clarity, consider passing both the name and symbol from the STETHVault
contract as parameters to maintain consistency. This also saves calculating the name and symbol at every call, thus saving more gas.
Update: Fixed in commit 61a9996
.
Comments are meant to be helpful and describe the intent of the code block. However, in multiple parts of the codebase, comments are not consistent with the code that is written or do not add any value to the reader. These are some examples:
_deposit
in both the STETHVault
and BaseVault
contracts does not create any shares even though the comments say so.setParameter
, getParameter
and getGlobalParameter
does not add any value to the reader.target
parameter on setParameter
has a wrong description._spendCap
has the same description as availableCap
.Consider correcting the comments to not mislead users or developers and write comments that add value to the reader. This will improve code clarity and consistency between docstrings and contract implementations.
Update: Fixed in commit 3ad59d0
.
Throughout the codebase, there are several files that do not have docstrings. For instance:
ConfigurationManager
contract has no docstrings for any of its state variables.IConfigurationManager
interface does not have any docstrings.ICurvePool
interface is for an external contract, docstrings need to be added to explain the interface purpose and why each function is used within the codebase.IERC4626
interface has no docstrings for the interface description and also for any of its events.IVault
interface has no docstrings for the interface description and also for any of its events and errors.AuxMath
library does not have any docstrings.CastUint
library has no docstrings for the library description.DepositQueueLib
library just has some inline comments inside the remove
function.Capped
contract has no docstrings for the contract description, state variables, constructor, and its custom error.ETHAdapter
contract just has some inline comments for the receive function.Migration
contract does not have any docstrings.BaseVault
contract has no docstrings for some of its state variables (configuration
, _asset
, currentRoundId
, isProcessingDeposits
, EMERGENCY_INTERVAL
, processedDeposits
, _lastEndRound
and depositQueue
), the constructor, and the functions depositWithPermit
and mintWithPermit
.STETHVault
contract has no docstrings for some of its state variables (sharePriceDecimals
, lastRoundAssets
, lastSharePrice
, and investor
) and the constructor. Adding comments in the internal function _afterRoundEnd
can be beneficial for easy understanding of its purpose.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 easy maintenance.
Consider thoroughly inserting documentation above each contract, interface, library, function, state variable, event, and custom errors. 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, will not fix. Pods Finance team’s statement:
We didn’t prioritize this because of time constraints.
Any user may process deposits in the queue after a round ends. After deposits are processed, the section of the queue they were held in is removed. This means that any user who processes deposits will be changing the size and indexes of the queue for all others. This may lead to reverted transactions if an index that was previously thought to be in range no longer exists in the queue. It may also lead to some processing of unintended indexes and others missing their targeted indexes in the queue.
Consider having additional validations on the queue size and indexes to ensure the correct transactions are executed without going out of bounds.
Update: Fixed in commit e05f165
.
Refunds are processed by searching the entire queue front to back for a user’s position. Currently, the gas price to refund increases linearly based on the user’s position in the queue. Users that have joined the queue later in the round will incur a more significant gas penalty and, in an extreme case, could be prevented from refunding.
Consider keeping track of a user’s queue position alongside other attributes for easier retrieval and update.
Update: Fixed in commit e05f165
.
startIndex
is greater than or equal to endIndex
The remove
function is responsible for removing records from the queue. When called, it checks if the endIndex
is greater than the startIndex
to execute the function’s implementation. The issue is that if the condition is not met, the transaction is not reverted, and the code execution continues. This could cause processQueuedDeposits
to fail silently when the indexes are not valid.
Although it does not currently cause any errors other than spending gas unnecessarily, consider rolling back the transaction if the condition is met to avoid unwanted behaviors.
Update: Fixed in commit e05f165
.
The system allows users to migrate their shares to a new vault with the same underlying asset. There are a few risks regarding the current migration mechanism design.
migrate
function in the old vault or through the exclusive Migration
contract. There is no clarity on which route is preferred as both essentially perform the same functionality. Consider removing duplicate actions that can lead to user confusion.Migration
contract sets both the old vault and the new vault addresses as immutable variables in the constructor. This limits its use case to one single migration. It would not be reusable across multiple vaults with the same underlying assets and different investment strategies.Migration
contract due to direct transfers or mistaken withdrawals, the entire balance of the contract will be deposited to the new vault by the next user. This design is not resistant to genuine user mistakes and prohibits recovering user funds. Consider using the returned value from the redeem
function to migrate the correct amount of assets and establishing a sweep or rescue function for funds locked in the contract.Update: Partially fixed in commit d97672d
.
The investmentYield
and the investmentAmount
are transferred back and forth from the vault to the same investor
account. Instead of making two separate transactions, one can compare the two values and reach the final balance in one transaction.
Consider simplifying the code for clarity and readability.
Update: Acknowledged, will not fix. Pods Finance team’s statement:
We actively preferred to leave the code in the back-and-forth way, so it’s easier to understand the flow of funds of the operations.
Although constants are generally used correctly throughout the codebase, there are a few occurrences of literal values being used with unexplained meaning. For example, the following blocks use hardcoded values:
ETHAdapter
contract, the pool’s coins
method is called with arguments 0
and 1
, with no explanation.convertToETH
and convertToSTETH
, the pool’s get_dy
method is called with arguments 0
and 1
with no explanation.deposit
function, the pool’s exchange
method is called with arguments 0
and 1
, with no explanation.To improve the code’s readability and facilitate refactoring, consider defining a constant for every magic number, giving it a clear and self-explanatory name. Consider adding an inline comment explaining how they are calculated or why they are chosen for complex values.
Update: Acknowledged, will not fix. Pods Finance team’s statement:
We didn’t prioritize this because of time constraints.
Throughout the codebase there are require
statements and custom error blocks that need multiple conditions to be satisfied. For instance:
ETHAdapter
contract there is a require
statement that checks if coin 0 from the pool is equal to the ETH
address and if coin 1 is equal to stETH
address. If one of the conditions is not met, there is no error message to indicate why the transaction was reverted.migrate
function in the BaseVault
contract has a conditional block that checks if the vault is not allowed and if the destination vault does not have the same underlying asset as the current vault. Although the custom error that is thrown is not wrong, it might be better if these two validations are separated to return more specific error messages.onlyRoundStarter
modifier has a custom error block that checks if less than a week has passed since the last end round and if the caller is different from the controller. If the conditions are met, the error returned is that the caller is not the controller. Consider separating both conditions in order to throw a more specific error message.To simplify the codebase and to raise the most helpful error messages for failing blocks, consider having a single require
statement with an appropriate error message or a single custom error block per condition.
Update: Acknowledged, will not fix. Pods Finance team’s statement:
We didn’t prioritize this because of time constraints.
To favor explicitness and readability, several parts of the contracts may benefit from better naming. Our suggestions are:
withdrawFeeRatio
can be changed to getWithdrawFeeRatio
. The current name suggests that the action is the withdrawal of fee ratio but what it actually does is to query the WITHDRAW_FEE_RATIO
value.ConfigurationManager__InvalidCapTarget
can be changed to ConfigurationManager__TargetCannotBeTheZeroAddress
or something similar, since the only invalid target is the zero address.Update: Fixed in commit 88ce562
.
Non-explicit imports are used throughout the codebase, which reduces code readability and could lead to conflicts between names defined locally and the ones imported. This is especially important if many contracts are defined within the same Solidity files or the inheritance chains are long. For instance, but not limited to:
BaseVault.sol
EthAdapter.sol
Following the principle that clearer code is better code, consider using named import syntax (import {A, B, C} from "X"
) to explicitly declare which contracts are being imported.
Update: Acknowledged, will not fix. Pods Finance team’s statement:
We didn’t prioritize this because of time constraints.
All contracts within the codebase use compiler version 0.8.9
, an old version with various bugs that have been fixed in later releases.
Consider taking advantage of the latest Solidity version to improve the overall quality and security of the codebase.
Update: Fixed in commit 93956a9
in PR#66.
Throughout the codebase, there are state variables that lack explicitly declared visibility. For instance:
CAP
in ConfigurationManager.sol
ETH_ADDRESS
in ETHAdapter.sol
STETH_ADDRESS
in ETHAdapter.sol
from
in Migration.sol
to
in Migration.sol
For clarity, consider always explicitly declaring the visibility of variables, even when the default visibility matches the intended visibility.
Update: Fixed in PR#64, with commit a3eb939
being the last one added.
Style inconsistencies can be seen throughout the codebase. For example:
require
statements for error handling and others use custom errors. In general custom errors are preferred over require
statements.StartRound
, the function’s name with the first letter capitalized, and others, like FeeCollected
, describe the action that was executed. Consider following only one convention on the entire codebase.investorRatio
does not use all capital letters with underscores separating words but all other constants in the codebase do. Consider changing it to INVESTOR_RATIO
.Update: Fixed in commit cf6ae4d
.
Across the codebase, there are some typographical errors in the comments. Some examples are:
ConfiguratorManager
should be ConfigurationManager
.MAX_WITDRAW_FEE
should be MAX_WITHDRAW_FEE
.split
.Consider correcting the above and any further typos in favor of correctness and readability.
Update: Fixed in PR#41
commit a515964
and PR#69
commit 4ec70cb
.
We found some internal functions with unused arguments in the implementation, hence are unnecessary to be defined in the base contract. For example:
_afterRoundStart(uint256)
function does not require an argument in the implementation but is defined with an argument in the base contract._beforeWithdraw(uint256 shares, uint256)
function does not require a second argument but is defined with a second argument in the base contract.Consider removing unnecessary arguments for clarity and readability.
Update: Fixed in commit f100925
.
The following instances of internal virtual functions are implemented in BaseVault.sol
, which are later overridden by a different implementation in STETHVault.sol
.
_withdraw
function is overridden by the same implementation._deposit
function is overridden by a different implementation.Consider removing unnecessary implementations for readability and maintenance.
Update: Acknowledged, will not fix. Pods Finance team’s statement:
We didn’t prioritize this because of time constraints.
The global address and the getGlobalParameter()
function of the ConfigurationManager
contract are supposed to manage global configurations that do not depend on a specific vault. However, the purpose of those entities is unclear, as they are not used anywhere in the codebase.
To avoid confusion and favor explicitness, consider documenting the purpose of these functions and variables. If they are not expected to be used, consider removing them from the codebase to reduce the code’s size and attack surface.
Update: Acknowledged, will not fix. Pods Finance team’s statement:
We didn’t prioritize this because of time constraints.
Throughout the codebase imports on the following lines are unused and could be removed:
Consider removing unused imports to avoid confusion and improve the overall readability of the codebase.
Update: Fixed in commits b702189
and 89869ad
.
Throughout the codebase, there are several occasions where named return variables are declared but not used. For instance:
newShares
on migrate
and migrateWithPermit
functionsstETHAmount
on convertToSTETH
ethAmount
on convertToETH
depositedAssets
on _deposit
on both STETHVault
and BaseVault
roundId
on startRound
shares
on maxMint
, maxRedeem
, deposit
, convertToShares
, and previewDeposit
assets
on maxWithdraw
, maxDeposit
, convertToAssets
, and previewMint
effectiveAmount
on _stETHTransferFrom
Consider adding those variables to function implementations or remove them.
Update: Fixed in commit 1228184
.
Three critical and three high-severity issues are found among various lower-severity issues. The system documentation and diagrams provided by the Pods Finance team made it easier for the auditors to assess and comprehend the code. Throughout the audit, the team has been responsive and active in responding to questions and suggestions from the auditors.
Given the number of important issues raised, we believe there could be more undiscovered issues, and we anticipate a significant refactor to the codebase. If this turns out to be the case, we highly recommend another round of audits on the new code.
Update as of November 15th:
The Pods Finance team considerably refactored the codebase to address many findings that were initially reported; however, another critical issue was discovered during the fix-review process. OpenZeppelin worked with the Pods Finance team to address this issue, though we noted that the fix-review process was limited in assessing security issues within the significant changes made to the codebase. OpenZeppelin strongly recommended the Pods Finance team obtain a full code re-audit and the Pods Finance team is proceeding with that.
With the goal of providing a complete security solution, we have identified several actions that might benefit from on-chain monitoring to provide useful alerts and insights about on-chain protocol activities. Here are some example scenarios that would benefit from monitoring:
Deposits
to the vault
with dust or large amounts. This could represent a potential threat to manipulate vault share calculations.Owner
of the ConfigurationManager
, the Controller
of the vault
, and the Investor
of the vault
. Consider monitoring events related, but not limited to, ownership changes, vault configuration modifications, and transfers initiated by the Investor
to somewhere other than the vault
as these may signal key compromise of a privileged role.Critical Issue: Malicious user can manipulate shares calculation and steal deposits
As part of the fixes developed and provided during the fix-review process, it was noted that the current queue setup could potentially be exploited by Maximum Extractable Value (MEV) searchers if the endRound
transaction is sent through the public mempool.
In the scenario when the MEV searcher has sufficient liquidity, the following strategy can be viable.
endRound
.endRound
transaction with a transaction that
In this way, the MEV searcher gets one share while everyone else gets zero shares due to the rounding in the division. Since no deposit or withdrawal is possible during the processing period, the MEV searcher waits until either the next startRound
or one week has passed. Then the searcher can redeem its shares and remove the entire liquidity from the vault. We note that the searcher may need to back-run the startRound
transaction to ensure that its one share is not diluted by further large sum deposits. Now, the vault is back to zero shares, zero liquidity and the same attack can be repeated.
In case the MEV searcher does not have enough liquidity, a milder version of the same strategy can be carried out by processing only part of the queue with the correct ordering.