eOracle EOFactory
Smart Contract Security Assessment
August 28, 2025

SUMMARY
ABSTRACT
Dedaub was commissioned to perform a security audit of the eOracle EOFactory system, a comprehensive smart contract factory system for creating various types of price feeds and oracles for DeFi protocols. The audit identified several medium and low-severity findings. All identified issues have been addressed and resolved by the protocol team.
BACKGROUND
The audited system is a smart contract factory framework for creating and managing price feeds and oracles for DeFi protocols. It provides a modular and standardized approach to feed deployment, supporting protocols such as Pendle, Spectra, and custom adapters.
The EOFactory follows the factory pattern to deploy and manage different types of price feeds. Each feed type has its own implementation contract, deployed through the factory.
The system supports two proxy mechanisms:
- Clone (EIP-1167 Minimal Proxy): Lightweight, gas-efficient deployments.
- OpenZeppelin TransparentUpgradeableProxy: Admin-controlled upgradeable deployments.
SETTING & CAVEATS
This audit report mainly covers the contracts of the at-the-time private repository eodata/eofactory of the eOracle EOFactory protocol at commit 46e704d34ae94f2c641471c6e0d32078c964a96e.
Audit Start Date: August 19, 2025
Report Submission Date: August 28, 2025
Two auditors worked on the following contracts:
As part of the audit we also reviewed the fixes for the issues included in the report up to commit 0f89ae7fe3d9bdd08b0767662118994b2324058d and we have found that they have been implemented correctly.
The audit’s main target is security threats, i.e., what the community understanding would likely call "hacking", rather than the regular use of the protocol. Functional correctness (i.e. issues in "regular use") is a secondary consideration. Typically it can only be covered if we are provided with unambiguous (i.e. full-detail) specifications of what is the expected, correct behavior. In terms of functional correctness, we often trusted the code’s calculations and interactions, in the absence of any other specification. Functional correctness relative to low-level calculations (including units, scaling and quantities returned from external protocols) is generally most effectively done through thorough testing rather than human auditing.
VULNERABILITIES & FUNCTIONAL ISSUES
This section details issues affecting the functionality of the contract. Dedaub generally categorizes issues according to the following severities, but may also take other considerations into account such as impact or difficulty in exploitation:
- User or system funds can be lost when third-party systems misbehave
- DoS, under specific conditions
- Part of the functionality becomes unusable due to a programming error
- Breaking important system invariants but without apparent consequences
- Buggy functionality for trusted users where a workaround exists
- Security issues which may manifest when the system evolves
Issue resolution includes “dismissed” or “acknowledged” but no action taken, by the client, or “resolved”, per the auditors.
CRITICAL SEVERITY
[No critical severity issues]
HIGH SEVERITY
[No high severity issues]
MEDIUM SEVERITY
The ERC4626CappedFeedAdapter contract extends Ownable. However, during deployment and initialization, the initialization function is invoked by the EOFactory contract. As a result, the factory becomes the owner of the adapter rather than the deployer. This ownership discrepancy prevents the deployer from directly controlling the adapter, meaning they cannot invoke the owner-restricted function setMaxYearlyGrowthPercent.
Resolved
Since current sanity checks introduced vulnerabilities and did not protect against manipulation the team decided to remove them and rely on the manipulation-resistant TWAP from Pendle only for the time being until a better solution is found.
The rate computed by EOPendleLPFeed::_getLPSanityRate can be manipulated. An attacker can temporarily skew the pool composition within a single transaction or block, altering totalPt and totalSy while totalLP remains unchanged. Since the PtToSy rate returned by the TWAP does not immediately reflect such intra-transaction changes, the attacker can artificially inflate or deflate the LPSanityRate depending on the pool tilt direction and the relationship between the actual average execution price (SY per PT) and the TWAP ptToSYRate.
EOPendleLPFeed::latestRoundData relies on the potentially manipulated LPSanityRate to bound the lpToSYRate TWAP. As a result, an attacker could:
-
Force a revert by shifting the bounds so that a valid
lpToSYRateTWAP falls outside of the accepted range. This could disrupt the oracle and cause denial of service for protocols depending on it. -
Validate a manipulated rate by adjusting the bounds to make an otherwise invalid or manipulated
lpToSYRateTWAP appear valid. This undermines the integrity of the oracle, potentially enabling incorrect pricing or misinformed downstream decisions.
Resolved
Before returning the rate, the following read-only sanity guard was added:
The pool’s spot tick is compared to the pool’s TWAP tick in the native fractional-tick domain (Maverick returns TWAP as tick * 1e8). If the absolute deviation exceeds a configured threshold of N ticks (expressed as maxTickDeviation with 8 decimals), latestRoundData() reverts. TWAP is slow-moving and cannot be moved materially in one block. Therefore, a large spot-vs-TWAP tick gap is a reliable red flag. By reverting during such dislocations, trust in the instantaneous inventory is withheld, preventing publication of a manipulable quote. The feed effectively “pauses” until spot and TWAP realign (TWAP catches up or spot mean reverts), then resumes normally. This maximizes transparency and avoids masking anomalies with heuristics.
In EOLPYAPFeed, the computation of the lpYAPRate for the boosted position relies on the NAV formula (adjustedA * rateFeedA + adjustedB * rateFeedB). While the use of external price feeds protects against direct spot-price manipulation, an attacker can still manipulate the NAV through inventory manipulation. By shifting the pool across multiple bins, the attacker can alter the balances of tokens A and B, thereby skewing the adjustedA and adjustedB values used in the calculation.
This design leaves the rate calculation vulnerable to NAV manipulation despite using external feeds. The practical impact depends on how the oracle is integrated:
-
If downstream consumers rely on
lpYAPRatefor critical pricing or collateralization logic, an attacker could artificially inflate or deflate values, enabling mispricing or exploitation. -
If usage is limited or auxiliary, the manipulation is expected to have minimal impact.
LOW SEVERITY
- In
EOLPYAPFeed, thelatestRoundDatafunction queries two Chainlink feeds to get the prices of the two tokens of the Maverick pool. However, these values and theirupdatedAttimestamps are not checked for their validity to verify that the prices are positive values or fresh. For example, should any of the feeds return a0price, while the other returns a valid price, the oracle will end up calculating its final price based on partial and invalid data.
EOLPYAPFeed.sol::latestRoundData:107-108function latestRoundData() external view returns (...) {
(int256 rateFeedA, uint8 decimalsFeedA) =
_getFeedData(tokenAFeed);
(int256 rateFeedB, uint8 decimalsFeedB) =
_getFeedData(tokenBFeed);
...
uint256 totalSupply = IERC20(lpYAPToken).totalSupply();
...
int256 lpYAPRate = (
int256(adjustedA) * rateFeedA / int256(10 ** decimalsFeedA)
+ int256(adjustedB) * rateFeedB / int256(10 ** decimalsFeedB)
) * int256(10 ** lpYAPTokenDecimals) / int256(totalSupply);
return (0, lpYAPRate, 0, block.timestamp, 0);
}
- The same applies to both
EOSingleFeedAdapter::latestRoundDataandEOMultiFeedAdapter::latestRoundDatathat fetch prices directly from external price feeds without validating the returned values. One possible feed is Chainlink, which under certain conditions may return a price of zero. Without validation, these zero values are used in internal multiplications and divisions.
EOMultiFeedAdapter.sol::latestRoundData:142function latestRoundData() external view returns (...) {
int256 rate = numeratorMultiplier;
...
uint256 latestUpdatedAt;
for (uint256 i = 0; i < baseFeedsLength; i++) {
(int256 feedRate, uint256 updatedAt) =
_baseFeeds[i].getRate();
rate = rate * feedRate;
...
}
uint256 quoteFeedsLength = _quoteFeeds.length;
for (uint256 i = 0; i < quoteFeedsLength; i++) {
(int256 feedRate, uint256 updatedAt) =
_quoteFeeds[i].getRate();
rate = rate / feedRate;
...
}
rate = rate / denominatorMultiplier;
return (0, rate, 0, latestUpdatedAt, 0);
}
Resolved
Using min(updatedAt_i) could result in situations where the price has changed but the returned timestamp remains stale, which might also be misleading because some protocols interpret updatedAt as “when the price was last updated”. Since the currently returned updatedAt reflects the time when the price was actually calculated, and not the freshness of all inputs, the team has chosen to use max(updatedAt_i) in latestRoundData.
To address the need for assessing feed freshness, the team also introduced a new method, minUpdatedAt, which explicitly reflects the earliest update among all external feeds contributing to the final rate calculation.
- In
EOMultiFeedAdapter::latestRoundData, multiple different feeds are queried for prices, each returning possibly differentupdatedAttimestamps. To account for this, the contract returns the most recent timestamp as its own price timestamp. However, it would be more accurate for the oracle’s external consumers if the oldest of the timestamps was returned instead to allow them to decide if the returned result can be considered fresh or not.
EOMultiFeedAdapter.sol::latestRoundData:145function latestRoundData() external view returns (...) {
uint256 latestUpdatedAt;
for (uint256 i = 0; i < baseFeedsLength; i++) {
(int256 feedRate, uint256 updatedAt) =
_baseFeeds[i].getRate();
...
if (updatedAt > latestUpdatedAt) {
latestUpdatedAt = updatedAt;
}
}
...
for (uint256 i = 0; i < quoteFeedsLength; i++) {
(int256 feedRate, uint256 updatedAt) =
_quoteFeeds[i].getRate();
...
if (updatedAt > latestUpdatedAt) {
latestUpdatedAt = updatedAt;
}
}
...
return (0, rate, 0, latestUpdatedAt, 0);
}
- In
FeedUtils, thegetRatefunction is used to wrap around the different feed types and fetch prices. One of the options is Chainlink feeds. However, if they returned a 0-valueupdatedAttimestamp, which would possibly indicate an issue with the feed, the value gets replaced with the currentblock.timestamphiding any issues from the external consumer. The retrieved timestamp could be forwarded instead, even if0, to the external receiver or handle it internally as an error.
FeedUtils.sol::getRate:61function getRate(
IEOFeedAdapterBase.Feed memory feed
) internal view returns (int256 rate, uint256 updatedAt) {
if (feed.feedInterface == IEOFeedAdapterBase.FeedInterface.
MINIMAL_AGGREGATOR_V3_INTERFACE) {
(, rate,, updatedAt,) =
MinimalAggregatorV3Interface(
feed.feedAddress).latestRoundData();
} ...
if (updatedAt == 0) {
updatedAt = block.timestamp;
}
}
In EOMultiFeedAdapter, the maximum value for an int256 is approximately . Allowing up to 64 decimals for intermediate values sets an upper bound on the product of actual prices: .
With 4 base feeds totaling 64 decimals, the geometric mean of the raw prices must be for the multiplication loop to be provably safe. This means deployers need to be very cautious when selecting feed combinations to avoid accidental overflows if the decimals for intermediate values approach 64.
OTHER / ADVISORY ISSUES
This section details issues that are not thought to directly affect the functionality of the project, but we recommend considering them.
In EOFactory, the _setImplementations function does not verify that the provided array matches the length of the ArtifactType enum. If it has fewer elements, it will revert the operation.
- In
EOLPYAPFeed, the comment “The number of bits in a uint256” is incorrect. It could be changed to “Index of the most significant bit (MSB) in a uint256 (0-based)”. - In
EOSpectraPTFeedHybrid, the comment “The address of the Spectra pool” is incorrect. It should be changed to “The address of the Curve pool”
In EOMultiFeedAdapter::initialize, the baseFeeds_ and quoteFeeds_ arrays can both be empty.
In EOFactory, the _setImplementations function checks if the given implementation address is 0x00, which is nevertheless checked by _setImplementation, which is called afterwards.
In EOMultiFeedAdapter, the initialize function is declared as public when all other similar functions in the other implementations have been made external, which we only mention as a minor code consistency comment.
In EOPendlePTFeedLinearDiscount::initialize, you can use the constant ONE instead of the 1e18 number directly in the if (baseDiscountPerYear_ > 1e18) check.
The execution of EOLPYAPFeed::latestRoundData can revert unexpectedly due to a division by zero. The function performs calculations involving totalSupply without verifying that totalSupply != 0.
The EOPendlePTFeedTWAP::initialize code distinguishes only between TWAPType.PT_TO_SY and an implicit else branch, which assumes TWAPType.PT_TO_ASSET. Because twapType is an enum, it cannot presently take an arbitrary value. However, if the enum is extended in a future upgrade (e.g., with additional TWAP types), the else branch would unintentionally handle new values as PT_TO_ASSET, leading to incorrect logic.
There are several enums (e.g., TWAPType, DiscountType, FeedInterface) that are used by the upgradeable EOFactory and the various different adapters in their initializers, and thus one should keep in mind that any change in these enums would require an upgrade to all the contracts that use them in order to make sure that the new values are visible by all implementations. For example, updating EOFactory, but not the underlying implementations would not allow the feed deployments to go through. We only mention this for visibility and awareness.
EOPendlePTFeedTWAP, EOPendleLPFeed and EOPendlePTFeedHybrid configure the TWAP duration of the underlying TWAP oracles. It is understood that the TWAP durations are configurable only by the EOFactory deployer, but it might still be worth enforcing reasonable lower bounds within the various oracles to prevent potential misconfigurations.
In README, it is mentioned that Beacon proxies are also supported by the protocol which does not seem to be the case any more.
The code is compiled with Solidity 0.8.25. Version 0.8.25, in particular, has no known bugs.
DISCLAIMER
The audited contracts have been analyzed using automated techniques and extensive human inspection in accordance with state-of-the-art practices as of the date of this report. The audit makes no statements or warranties on the security of the code. On its own, it cannot be considered a sufficient assessment of the correctness of the contract. While we have conducted an analysis to the best of our ability, it is our recommendation for high-value contracts to commission several independent audits, a public bug bounty program, as well as continuous security auditing and monitoring through Dedaub Security Suite.
ABOUT DEDAUB
Dedaub offers significant security expertise combined with cutting-edge program analysis technology to secure some of the most prominent protocols in DeFi. The founders, as well as many of Dedaub's auditors, have a strong academic research background together with a real-world hacker mentality to secure code. Protocol blockchain developers hire us for our foundational analysis tools and deep expertise in program analysis, reverse engineering, DeFi exploits, cryptography and financial mathematics.