Prepared by:
HALBORN
Last Updated 10/30/2024
Date of Engagement by: October 10th, 2024 - October 14th, 2024
100% of all REPORTED Findings have been addressed
All findings
11
Critical
0
High
1
Medium
4
Low
4
Informational
2
Prodigy
engaged Halborn to conduct a security assessment on their smart contracts revisions beginning on 10/10/2024 and ending on 10/14/2024. The security assessment was scoped to the smart contracts provided to the Halborn team.
The team at Halborn was provided 3 days for the engagement and assigned a full-time security engineer to evaluate the security of the smart contract.
The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.
The purpose of this assessment is to:
Ensure that smart contract functions operate as intended.
Identify potential security issues with the smart contracts.
In summary, Halborn identified some security risks that were addressed by the Prodigy team
.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance code coverage and quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
Research into architecture and purpose.
Smart contract manual code review and walkthrough.
Graphing out functionality and contract logic/connectivity/functions. (solgraph,draw.io
)
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.
Manual testing by custom scripts.
Static Analysis of security for scoped contract, and imported functions. (Slither
)
Testnet deployment. ( Hardhat
,Foundry
)
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
0
High
1
Medium
4
Low
4
Informational
2
Security analysis | Risk level | Remediation Date |
---|---|---|
Price Manipulation Vulnerability in Vault Execution Due to Unchecked Pyth Oracle Updates | High | Solved - 10/29/2024 |
Chainlink Oracle Price Feed Used Without Staleness Check | Medium | Solved - 10/29/2024 |
Excess ETH Not Refunded in Price Update Transactions | Medium | Solved - 10/29/2024 |
Zero Amount Transfer Vulnerability in Token Transfers | Medium | Solved - 10/29/2024 |
Pyth oracle price is not validated properly | Medium | Solved - 10/29/2024 |
Unrestricted Vault Creation in Factory Contract | Low | Solved - 10/29/2024 |
Unsafe Casting Operations | Low | Solved - 10/29/2024 |
Incorrect Fee Calculation Due to Delayed Initialization in Vault Contract | Low | Solved - 10/29/2024 |
Incorrect State Modification Order in lpWithdraw Function | Low | Solved - 10/29/2024 |
Missing Visibility Attribute | Informational | Solved - 10/29/2024 |
Consider Using Named Mappings | Informational | Solved - 10/29/2024 |
// High
The execute
function in Vault.sol allows arbitrary priceUpdateData
to be passed, which is used to fetch the oracle price:
function execute(bytes[] calldata priceUpdateData) public payable initialized returns (uint8) {
// ...
int256 oraclePriceAtExpiry = aggregator.getPrice(priceFeed, priceUpdateData);
// ...
}
This priceUpdateData
is passed through AggregatorHelper.sol
and then to PythAggregator.sol
:
function getLatestEmaPrice(bytes32 _pythPriceFeed, bytes[] calldata _priceUpdateData) external payable returns (PythStructs.Price memory) {
updatePrice(_priceUpdateData);
return getEmaPrice(_pythPriceFeed);
}
function updatePrice(bytes[] calldata _priceUpdateData) public payable {
uint256 fee = pyth.getUpdateFee(_priceUpdateData);
pyth.updatePriceFeeds{value: fee}(_priceUpdateData);
}
The issue is that there's no validation of _priceUpdateData
. A malicious user can provide empty price data, causing the updatePrice
function to skip the update entirely.
A vault executor could then skip or avoid the updating of the Pyth price by passing in an empty priceUpdateData
array.
Link to Base Pyth UpdatePriceFeeds
function updatePriceFeeds(
bytes[] calldata updateData
) public payable override {
uint totalNumUpdates = 0;
for (uint i = 0; i < updateData.length; ) {
if (
updateData[i].length > 4 &&
UnsafeCalldataBytesLib.toUint32(updateData[i], 0) ==
ACCUMULATOR_MAGIC
) {
totalNumUpdates += updatePriceInfosFromAccumulatorUpdate(
updateData[i]
);
} else {
updatePriceBatchFromVm(updateData[i]);
totalNumUpdates += 1;
}
unchecked {
i++;
}
}
uint requiredFee = getTotalFee(totalNumUpdates);
if (msg.value < requiredFee) revert PythErrors.InsufficientFee();
}
This leads to the getEmaPrice
function potentially returning an outdated price, which is then used to determine the vault's state.
Actors:
Alice: An honest user who created a vault
Bob: A malicious user looking to exploit the system
Initial Setup:
Alice creates a "Buy Low" vault with the following parameters:
Linked Price: 1000 USDC per ETH
Expiry: 7 days from now
The current market price of ETH is 1100 USDC.
Exploit Scenario:
7 days pass, and the vault is ready for execution.
The current market price of ETH has dropped to 990 USDC.
Bob, being a malicious actor, notices this price drop and sees an opportunity to exploit the system.
Bob calls the execute
function on Alice's vault, but instead of providing the current price data, he provides an empty priceUpdateData
array.
The execute
function passes this empty array through the system:
Vault.sol calls AggregatorHelper.sol
AggregatorHelper.sol calls PythAggregator.sol
PythAggregator.sol's updatePrice
function doesn't perform any update due to the empty array
PythAggregator.sol's getEmaPrice
function returns the last stored price, which is 1010 USDC
The execute
function uses this outdated price of 1010 USDC to determine the vault's state.
Since 1010 USDC is greater than the Linked Price of 1000 USDC, the vault's state is set to "unswapped" (state = 2).
As a result, Alice's "Buy Low" strategy fails to execute, even though the actual market price (990 USDC) is below her Linked Price.
Outcome:
Alice loses the opportunity to buy ETH at her desired price, even though market conditions were favorable.
Bob potentially benefits if he's an LP or has opposing positions.
It is recommended to implement a strict validation of the priceUpdateData
in the execute
function of Vault.sol, mostly when execute()
is called with Pyth Oracle:
require(priceUpdateData.length > 0, "Price update data cannot be empty");
A modifier can also be implemented to always verify this.
SOLVED: A modifier has been implemented to handle 0 length priceUpdateData
array :
modifier checkPriceUpdateData(bytes[] calldata _priceUpdateData) {
require(_priceUpdateData.length > 0, "PythAggregator: priceUpdateData is empty");
_;
}
// Medium
The getPrice
function in AggregatorHelper.sol fetches price data from a Chainlink oracle without validating the freshness of the returned data:
function getPrice(address _aggregator, bytes32 _pythPriceFeed, bytes[] calldata _priceUpdateData)
internal
returns (int256 price)
{
if (_pythPriceFeed == bytes32(0)) {
// latestRoundData() returns int256 answer
(, price,,,) = IChainlinkOracle(_aggregator).latestRoundData();
} else {
// PythStructs.Price.price is int64
price =
IPythAggregator(_aggregator).getLatestEmaPrice{value: msg.value}(_pythPriceFeed, _priceUpdateData).price;
}
}
The function calls latestRoundData()
on the Chainlink oracle but does not check the updatedAt
timestamp to ensure the price data is current. This allows stale or outdated price information to be used in critical calculations.
It is recommended to implement a staleness check using the updatedAt
value returned by latestRoundData()
. Compare it against the current block timestamp and a predefined threshold:
if (_pythPriceFeed == bytes32(0)) {
(, price,,uint256 updatedAt,) = IChainlinkOracle(_aggregator).latestRoundData();
require(block.timestamp - updatedAt <= PRICE_FRESHNESS_THRESHOLD, "Stale price data");
}
SOLVED: Checks have been implemented to ensure price returned by chainlink is correct and not stale.
// Medium
The PythAggregator
contract's updatePrice
function, which is called indirectly through getPrice()
, does not refund excess ETH sent by users. This function is called in multiple payable functions across Router.sol
, Factory.sol
, and Vault.sol
, allowing users to provide msg.value
. The relevant code in PythAggregator.sol
is:
function updatePrice(bytes[] calldata _priceUpdateData) public payable {
uint256 fee = pyth.getUpdateFee(_priceUpdateData);
pyth.updatePriceFeeds{value: fee}(_priceUpdateData);
}
This implementation transfers the exact fee to the Pyth
contract but does not refund any excess ETH sent by the user. The excess ETH remains trapped in the PythAggregator
contract.
Users who inadvertently send more ETH than required for price updates will lose their excess funds. This results in a direct financial loss for users and accumulation of unusable ETH in the PythAggregator
contract. The severity of this issue is heightened by the fact that it affects multiple core functions of the protocol in Router.sol,Factory.sol and of course Vault.sol
It is recommended to implement a refund mechanism in the updatePrice
function to return any excess ETH to the user. Here's an example of how to modify the function:
function updatePrice(bytes[] calldata _priceUpdateData) public payable {
uint256 fee = pyth.getUpdateFee(_priceUpdateData);
pyth.updatePriceFeeds{value: fee}(_priceUpdateData);
uint256 excess = msg.value - fee;
if (excess > 0) {
(bool success, ) = msg.sender.call{value: excess}("");
require(success, "ETH refund failed");
}
}
SOLVED: Excess fees are now refunded to msg.sender.
// Medium
The contracts contain multiple instances call of safeTransfer()
and safeTransferFrom()
calls that do not check if the transfer amount is greater than zero. This issue is present in the following locations:
In the init() function:
linkedToken.safeTransferFrom(owner(), address(this), linkedTokenAmount);
investmentToken.safeTransferFrom(owner(), address(this), investmentTokenAmount + fees);
In the deposit() function:
investmentToken.safeTransferFrom(msg.sender, address(this), depositAmount);
In the withdraw() function:
linkedToken.safeTransfer(msg.sender, linkedTokenAmount);
investmentToken.safeTransfer(msg.sender, investmentTokenAmount);
In the _lpWithdrawInvestmentToken() function:
investmentToken.safeTransfer(msg.sender, investmentTokenAmount);
In the _lpWithdrawLinkedToken() function:
linkedToken.safeTransfer(msg.sender, linkedTokenAmount);
In the lpWithdraw() function:
investmentToken.safeTransfer(feeReceiver, fees);
linkedToken.safeTransfer(feeReceiver, fees);
investmentToken.safeTransfer(msg.sender, investmentTokenAmount);
linkedToken.safeTransfer(msg.sender, linkedTokenAmount);
In the lpCancel() function:
investmentToken.safeTransferFrom(msg.sender, feeReceiver, cancellationFee);
linkedToken.safeTransferFrom(msg.sender, feeReceiver, cancellationFee);
These transfers are executed without verifying if the amount is greater than zero. Some ERC20 tokens, such as older implementations or non-standard tokens, revert when a transfer amount of zero is attempted. The absence of a zero amount check can cause these transfers to fail and revert the entire transaction.
Refs :
To address this, it is recommended to implement zero-amount checks before all token transfers. Add a condition to skip the transfer if the amount is zero. Here's an example of how to modify the code:
if (amount > 0) {
token.safeTransfer(receiver, amount);
}
SOLVED: Checks for non 0 amounts have been added to the needed functions.
// Medium
The PythAggregator
contract does not perform input validation on the price
, conf
, and expo
values returned from the called price feed, which can lead to the contract accepting invalid or untrusted prices. Those values should be checked as clearly stated in the official documentation.
It is recommended that the contract revert the transaction here if one of the following conditions is triggered:
price <= 0
expo < -18
conf > 0 && (price / int64(conf) < MIN_CONF_RATIO
for a given MIN_CONF_RATIO
SOLVED: Pyth price now have checks about price
,expo
and conf.
// Low
The Factory.sol contract allows unrestricted vault creation, contradicting the documented requirement for Liquidity Providers (LPs). The createVault() function lacks access control:
function createVault(DCIStructs.CreateVaultParams calldata _createVaultParams, bytes[] calldata _priceUpdateData)
external
payable
whenNotPaused
returns (address newVault)
{
// Function implementation
}
This implementation permits any address to create vaults without authorization checks.
It is recommended to either modify the documentation or implement strict access control for vault creation.
SOLVED: Documentation has been updated.
// Low
In the AggregatorHelper.sol contract, several unsafe casting operations are performed without using SafeCast
library. This could potentially lead to unexpected overflows or underflows in certain edge cases.
The following unsafe casting operations were identified:
int32(-1 * int8(IChainlinkOracle(_aggregator).decimals()))
uint256(uint64(price)) * 10 ** uint32(targetDecimals - priceDecimals)
uint256(uint64(price)) / 10 ** uint32(priceDecimals - targetDecimals)
These operations involve casting between different integer types (int8
, int32
, uint64
, uint256
) without using SafeCast library to prevent potential overflows or underflows.
Unsafe casting could potentially lead to unexpected behavior or vulnerabilities in edge cases or future modifications of the contract.
It is recommended to consider using OpenZeppelin's SafeCast
library for all casting operations to prevent potential overflows or underflows. This is a best practice that enhances the overall robustness and security of the contract.
SOLVED: SafeCast is now used everywhere.
// Low
The Vault contract calculates fees during the init()
function call instead of at contract creation. This delay allows for a discrepancy between the price at contract creation and the price at initialization.
Relevant code snippets:
// In constructor
constructor(DCIStructs.VaultParams memory _vaultParams, DCIStructs.FeeParams memory _feeParams)
Ownable(_vaultParams.owner)
{
// ... other initializations ...
oraclePriceAtCreation = _feeParams.oraclePriceAtCreation;
}
// In init() function
function init() external onlyOwner {
require(!isInitialized, "Vault: contract instance has already been initialized");
require(block.timestamp <= depositDeadline, "Vault: deposit period has ended");
// ... other logic ...
uint256 fees = _calculateFees(quantity);
// ... fee transfer logic ...
isInitialized = true;
emit Initialize();
}
// Fee calculation function
function _calculateFees(uint256 amount) internal view returns (uint256 fees) {
if (isBuyLow) {
fees = amount * listingFeeRate / 1e18;
} else {
fees = amount * listingFeeRate * SafeCast.toUint256(oraclePriceAtCreation) / 1e36;
}
}
This leads to incorrect fee calculations. A malicious vault creator could exploit this by:
Deploying the contract when prices are high.
Waiting for prices to drop.
Calling init()
when prices are low, resulting in lower fees.
It is recommended to calculate and store the fees in the constructor to ensure they are based on the price at contract creation.
SOLVED: Fees are still computed in constructor but added to linkedTokenAmount
or investmentTokenAmount
so the problem is solved.
// Low
The lpWithdraw
function in the Vault contract violates the Checks-Effects-Interactions (CEI) pattern. The lpWithdrawn
state variable is set to true after external interactions are performed, allowing for potential reentrancy attacks. The problematic code is as follows:
function lpWithdraw(bytes[] calldata priceUpdateData) external payable onlyOwner initialized {
if (state == 0) execute(priceUpdateData);
require(block.timestamp >= withdrawDate, "Vault: withdraw date has not passed yet");
require(!lpWithdrawn, "Vault: LP has withdrawn already");
uint256 linkedTokenAmount;
uint256 investmentTokenAmount;
if (depositTotal != 0) {
uint256 fees = _calculateFees(depositTotal);
(isBuyLow ? investmentToken : linkedToken).safeTransfer(feeReceiver, fees);
}
if (state == 1) {
investmentTokenAmount = IERC20(investmentToken).balanceOf(address(this));
investmentToken.safeTransfer(msg.sender, investmentTokenAmount);
_lpWithdrawLinkedToken(false);
} else {
linkedTokenAmount = IERC20(linkedToken).balanceOf(address(this));
linkedToken.safeTransfer(msg.sender, linkedTokenAmount);
_lpWithdrawInvestmentToken(false);
}
//E @audit CEI NOT RESPECTED
lpWithdrawn = true;
emit LpWithdraw(linkedTokenAmount, investmentTokenAmount);
}
To mitigate this, it is recommended to implement the Checks-Effects-Interactions pattern by updating the lpWithdrawn
state variable before performing any external calls. Restructure the function as follows:
function lpWithdraw(bytes[] calldata priceUpdateData) external payable onlyOwner initialized {
if (state == 0) execute(priceUpdateData);
require(block.timestamp >= withdrawDate, "Vault: withdraw date has not passed yet");
require(!lpWithdrawn, "Vault: LP has withdrawn already");
lpWithdrawn = true;
// ... //
emit LpWithdraw(linkedTokenAmount, investmentTokenAmount);
}
SOLVED: lpWithdrawn = true;
is now set right after the require statement.
// Informational
It is best practice to set the visibility of state variables and constants explicitly.
bool hasEarlyWithdrawn = false;
bool lpWithdrawn = false;
Consider explicitly setting the visibility of all state variables and constants.
SOLVED: Each variable has now a visibility set.
// Informational
Using named mappings can improve the readability and maintainability of the code by making the purpose of each mapping clearer. This practice helps developers and auditors understand the mappings' intent more easily.
mapping(bytes32 => uint8) public tokenPairDecimals;
mapping(address => uint256) public balances;
Consider refactoring the mappings to use named arguments, which will enhance code readability and make the purpose of each mapping more explicit.
mapping(bytes32 tokenPair => uint8 decimals) public tokenPairDecimals;
mapping(address user => uint256 balance) public balances;
SOLVED: Mappings are now using named arguments.
Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither
, a Solidity static analysis framework.
After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.
All issues identified by Slither
were proved to be false positives or have been added to the issue list in this report.
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed