fix: Security Audit Fixes for Dex223Oracle.sol and OracleLibrary.sol#35
Open
rroland10 wants to merge 4 commits intoEthereumCommonwealth:mainfrom
Open
fix: Security Audit Fixes for Dex223Oracle.sol and OracleLibrary.sol#35rroland10 wants to merge 4 commits intoEthereumCommonwealth:mainfrom
rroland10 wants to merge 4 commits intoEthereumCommonwealth:mainfrom
Conversation
Address multiple vulnerabilities found during security audit: - Add zero-address validation to set(), setOwner(), and createPool() - Prevent pool creation before factory is configured (pool_lib/quote_lib) - Harden tokenReceived() against external state manipulation - Add ERC-223 token address cross-pair collision checks - Re-enable standard() return value validation for ERC-223 detection - Change identifyTokens() from call to staticcall (view safety) - Fix variable shadowing in identifyTokens() - Add descriptive revert messages to all require statements Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses 10 vulnerabilities found during security audit: - V1: Reentrancy protection via nonReentrant modifier on all state-changing external functions - V2: Unchecked ERC-20 transfer return values in _sendAsset - V3: Locked funds after liquidation - remaining balance now returned to position owner - V4: autoWithdraw iteration bug - array snapshot prevents skipped elements during swap-and-pop - V5: Internal swap refactor - _swapToBaseAsset uses internal functions to avoid reentrancy/ownership conflicts - V6: Frozen time comparison strengthened to prevent same-block liquidation - V7: orderWithdraw blocked while positions are active to prevent fund draining - V8: Debt calculation reordered to reduce integer overflow risk - V9: Safe decrement of order_status.positions counter to prevent underflow - V10: Added receive() function for ETH-based collateral/order support Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses 9 vulnerability classes found during audit: V1 - Mutable pricePrecisionDecimals: made immutable V2 - Mutable factory/feeTiers: made immutable/constant V3 - Missing pool address validation in getSqrtPriceX96 V4 - Uninitialized pool (sqrtPriceX96 == 0) not rejected V5 - Zero amountToSell causes division-by-zero V6 - Intermediate overflow in sqrtPriceX96^2 calculation V7 - Division-by-zero in inverse-price branch V8 - Missing tokenB != address(0) validation V9 - Missing revert reason strings for debugging Co-authored-by: Cursor <cursoragent@cursor.com>
Address 8 vulnerabilities found during comprehensive security audit: - V1 (High): Safe int56-to-int24 narrowing with tick range validation in consult() - V2 (High): Division-by-zero guard for zero liquidity periods in consult() - V3 (Medium): Division-by-zero guard for same-block observations in getBlockStartingTickAndLiquidity() - V4 (Medium): Safe int56-to-int24 narrowing with tick range validation in getBlockStartingTickAndLiquidity() - V5 (Medium): Zero denominator guard in getWeightedArithmeticMeanTick() - V6 (Medium): Safe int256-to-int24 narrowing with tick range validation in getWeightedArithmeticMeanTick() - V7 (Low): Underflow guard for empty tokens array in getChainedPrice() - V8 (Low): Pool address zero-check validation across all pool-consuming functions Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security Audit Report: Dex223Oracle.sol and OracleLibrary.sol
This PR contains comprehensive security audits for both oracle-related contracts in the Dex223 system:
Part 1: Dex223Oracle.sol Audit
Summary
Full security audit of
contracts/dex-core/Dex223Oracle.sol-- the price oracle contract used by the Dex223 Margin Module for position valuation, liquidation decisions, and collateral estimation. Nine vulnerability classes were identified and fixed.Vulnerability Findings
V1 -- Mutable
pricePrecisionDecimals(Medium)Severity: Medium | Category: Access Control / Configuration Integrity
Description:
pricePrecisionDecimalswas declared as a regular mutable state variable (uint256 public pricePrecisionDecimals = 5). Since theOraclecontract has no access control, any external interaction pattern that upgrades or redeploys could inadvertently change this value. More critically, there is no setter function, but the variable being mutable means a storage collision in a proxy/delegatecall pattern or an unintended inheritance override could silently change it. Additionally, mutable storage costs more gas on every read.Fix: Changed to
immutable, set in constructor. This guarantees the value is embedded in the deployed bytecode and cannot be altered.V2 -- Mutable
factoryandfeeTiers(Medium)Severity: Medium | Category: Access Control / State Integrity
Description:
factorywas a regular public state variable andfeeTierswas a mutable dynamic array. Since there are no access controls or setter restrictions:factoryin a proxy/delegatecall context could be pointed to a malicious factory returning attacker-controlled pool addresses, enabling price manipulation.feeTiersarray could be altered to skip valid fee tiers or add invalid ones, causing the oracle to select suboptimal or malicious pools.Fix:
factorymadeimmutablewith a zero-address check in constructor.feeTiersreplaced with threeprivate constantvalues and a pure helper function_feeTier(uint256 idx).feeTiers(uint256 idx)view function preserves backward compatibility for external consumers.V3 -- Missing Pool Address Validation in
getSqrtPriceX96(High)Severity: High | Category: Input Validation
Description:
getSqrtPriceX96(address poolAddress)acceptedaddress(0)as a pool address without validation. In Solidity 0.7.6, calling.slot0()onaddress(0)(an EOA with no code) would silently return all-zeros instead of reverting. This means the function would returnsqrtPriceX96 = 0, causing all subsequent price calculations to return 0. In the margin module context, this would make positions appear to have zero value, potentially triggering incorrect liquidations or, worse, allowing undercollateralized positions to avoid liquidation if the zero propagates in unexpected ways.Fix: Added
require(poolAddress != address(0), "Oracle: zero pool")at the start of bothgetSqrtPriceX96andgetSpotPriceTick.V4 -- Uninitialized Pool Price Not Rejected (High)
Severity: High | Category: Data Validation / Economic Safety
Description: If a Uniswap V3 pool exists but has not been initialized (no
initialize()call),slot0().sqrtPriceX96returns 0. The Oracle would pass this zero value into price calculations, producing zero-valued outputs. When used in the Margin Module, this would value all non-base assets at 0, causing:getAmountOutinverse-price branch to divide by zero (revert without helpful error).Fix: Added
require(sqrtPriceX96 > 0, "Oracle: pool not initialized")after theslot0()call.V5 -- Zero
amountToSellCauses Division-by-Zero (Medium)Severity: Medium | Category: Input Validation / Arithmetic Safety
Description: Both
getAmountOutandgetAmountOutIntrospectionacceptedamountToSell = 0without validation. Whensell > buy(inverse price branch), the code computesamountToSell * amountToSell / amountBought. WithamountToSell = 0,amountBoughtalso becomes 0, causing a division-by-zero revert with no helpful error message.Fix: Added
require(amountToSell > 0, "Oracle: zero amount")at the start of both functions.V6 -- Intermediate Overflow in
sqrtPriceX96Squared Computation (Critical)Severity: Critical | Category: Arithmetic Overflow
Description: The original code computed
uint256(getSqrtPriceX96(_pool))**2 * sum / 2**192. SincesqrtPriceX96is auint160, squaring it produces a value up to2^320, which overflowsuint256(max2^256 - 1). In Solidity 0.7.6 (no built-in overflow checks), this silently wraps around, producing a completely incorrect price.Fix: Introduced
_safePriceCalc(uint256 sqrtPrice, uint256 amount)which splits the computation into two steps that keep all intermediates below2^256.V7 -- Division-by-Zero in Inverse-Price Branch (Medium)
Severity: Medium | Category: Arithmetic Safety
Description: When
sell > buy, bothgetAmountOutfunctions computeamountToSell * amountToSell / amountBought. IfamountBoughtevaluates to 0, this causes an unguarded division-by-zero.Fix: Added
require(amountBought > 0, "Oracle: zero price result")before the division in both functions.V8 -- Missing
tokenBZero-Address Validation (Low)Severity: Low | Category: Input Validation
Description:
findPoolWithHighestLiquidityvalidatedtokenA != address(0)but nottokenB.Fix: Added
require(tokenB != address(0), "Oracle: zero address tokenB").V9 -- Missing Revert Reason Strings (Informational)
Severity: Informational | Category: Debugging / Operability
Description: All
requirestatements in the original code lacked error message strings.Fix: Added descriptive revert reason strings to all
requirestatements throughout the contract.Dex223Oracle.sol Additional Improvements
Dex223Oracle.sol Test Plan
Oraclewith valid factory address -- verify constructor succeedsOraclewithaddress(0)factory -- verify constructor revertsgetSqrtPriceX96(address(0))-- verify revertsgetSqrtPriceX96on an uninitialized pool -- verify revertsgetAmountOut(buy, sell, 0)-- verify revertsgetAmountOutwith tokens wheresqrtPriceX96 > 2^128-- verify no overflowgetAmountOutwhere result would be 0 before inverse -- verify revertsfindPoolWithHighestLiquiditywithtokenB == address(0)-- verify revertsfeeTiers(0),feeTiers(1),feeTiers(2)-- verify returns 500, 3000, 10000feeTiers(3)-- verify revertsgetPositionStatus()with patched OraclePart 2: OracleLibrary.sol Audit
Summary
Comprehensive security audit of the
OracleLibrarylibrary incontracts/dex-periphery/base/OracleLibrary.solidentifying 8 vulnerabilities ranging from High to Low severity. All vulnerabilities have been fixed and verified to compile successfully.V1 - Unsafe int56-to-int24 Truncation in consult() (High)
Severity: High
Location:
consult()-- line 34 (original)Description: The arithmetic mean tick is computed as
int24(tickCumulativesDelta / secondsAgo). The divisiontickCumulativesDelta / secondsAgois performed on anint56value divided by auint32, yielding anint56result. Thisint56is then silently narrowed toint24via a direct cast. If the true mean tick exceeds theint24range (-8,388,608 to 8,388,607) -- which can happen with extreme tick cumulative values over short windows -- the high bits are silently discarded, producing a completely wrong tick value. While the Uniswap V3 valid tick range (-887,272 to 887,272) fits withinint24, a corrupted intermediate value outside this range would not revert but instead produce a nonsensical price, potentially enabling oracle manipulation or causing downstream financial losses in contracts relying on TWAP prices.Fix: The division result is first stored in a properly-typed
int56 meanTickvariable. Before narrowing toint24, arequirecheck validates thatmeanTickfalls within the valid tick range (TickMath.MIN_TICKtoTickMath.MAX_TICK). Only after validation does the safe downcast occur.V2 - Division by Zero in consult() for Zero-Liquidity Periods (High)
Severity: High
Location:
consult()-- line 40 (original)Description: The harmonic mean liquidity calculation divides by
uint192(secondsPerLiquidityCumulativesDelta) << 32. If the pool had zero in-range liquidity for the entire observation window,secondsPerLiquidityCumulativesDeltawill be zero (the cumulative seconds-per-liquidity counter does not advance when liquidity is zero). This produces a division by zero, causing the entire transaction to revert. Any contract that callsconsult()on a pool with no in-range liquidity (e.g., a newly created pool, or one where all positions have been removed) will be permanently DOSed. This is particularly dangerous for protocols using TWAP oracles for price feeds -- they could be bricked by an attacker who removes all liquidity from a pool.Fix: Added an explicit check: if
secondsPerLiquidityCumulativesDelta == 0, returnharmonicMeanLiquidity = 0directly (correctly reflecting zero effective liquidity). The division path is only taken when the delta is non-zero.V3 - Division by Zero in getBlockStartingTickAndLiquidity() (Medium)
Severity: Medium
Location:
getBlockStartingTickAndLiquidity()-- line 118 (original)Description: The function computes
delta = observationTimestamp - prevObservationTimestampand then divides bydeltaon line 119. If two consecutive observations share the same timestamp (which can happen whenobservationCardinalitywas just increased and the pool writes a new observation in the same block as the previous one),deltawill be zero, causing a division-by-zero revert. This would brick any function that depends on retrieving the block-starting tick for that specific block, including MEV-protection logic and TWAP-based pricing.Fix: Added
require(delta > 0, 'DELTA')to explicitly check that the time delta between observations is non-zero, providing a clear revert reason instead of an opaque EVM panic.V4 - Unsafe int56-to-int24 Truncation in getBlockStartingTickAndLiquidity() (Medium)
Severity: Medium
Location:
getBlockStartingTickAndLiquidity()-- line 119 (original)Description: Same class of vulnerability as V1. The expression
int24((tickCumulative - prevTickCumulative) / delta)performs anint56division and silently truncates toint24. In adversarial conditions (e.g., a manipulated tick cumulative via flash-loan-driven extreme price movement within a single block), the result could exceedint24range, producing a silently corrupted tick value. This is especially dangerous becausegetBlockStartingTickAndLiquidity()is typically used in MEV-protection contexts where accuracy is security-critical.Fix: The division result is stored in
int56 meanTickand validated againstTickMath.MIN_TICK/TickMath.MAX_TICKbefore downcasting toint24.V5 - Division by Zero in getWeightedArithmeticMeanTick() (Medium)
Severity: Medium
Location:
getWeightedArithmeticMeanTick()-- line 157 (original)Description: The function divides
numeratorbyint256(denominator)wheredenominatoraccumulates all weights. If the input array is empty (length 0) or if all entries haveweight == 0, the denominator will be zero, causing a division-by-zero revert with no descriptive error message. While an empty array is an obvious caller bug, the zero-weight scenario is more subtle -- a caller could construct weighted tick data from pool liquidity values, where some or all pools have zero liquidity.Fix: Added two guards: (1)
require(weightedTickData.length > 0, 'EMPTY')at function entry, and (2)require(denominator > 0, 'WGHT')after the accumulation loop to catch zero-weight inputs.V6 - Unsafe int256-to-int24 Truncation in getWeightedArithmeticMeanTick() (Medium)
Severity: Medium
Location:
getWeightedArithmeticMeanTick()-- line 157 (original)Description: The result of
numerator / int256(denominator)is anint256that is directly cast toint24. If the weighted mean tick computation produces a value outsideint24range (e.g., due to adversarial weight manipulation or extreme tick values from misconfigured pool comparisons), the truncation silently discards high bits. The result would be a completely wrong weighted mean price, which could mislead any protocol using this as a multi-pool oracle aggregator.Fix: The division result is stored in
int256 meanTick, then validated withrequire(meanTick >= int256(TickMath.MIN_TICK) && meanTick <= int256(TickMath.MAX_TICK), 'TICK')before safe downcasting.V7 - Unsigned Integer Underflow in getChainedPrice() (Low)
Severity: Low
Location:
getChainedPrice()-- line 173 (original)Description: The expression
tokens.length - 1is evaluated as an unsigned integer subtraction. Iftokensis an empty array (length 0), this underflows totype(uint256).max, which then fails the equality check againstticks.lengthand reverts with the generic'DL'error. While the function does eventually revert, the error message is misleading -- it suggests a length mismatch rather than the actual problem (empty input). More critically, iftokens.length == 1, thentokens.length - 1 == 0, and ifticks.lengthalso happens to be 0, the function silently returnssyntheticTick = 0(the loop body never executes), which is semantically incorrect -- a single token cannot have a "chained price".Fix: Added
require(tokens.length >= 2, 'TL')before the existing length check, ensuring the function receives at least one token pair (2 tokens). This makes the underflow impossible and provides a clear error message.V8 - Missing Pool Address Validation (Low)
Severity: Low
Location:
consult(),getOldestObservationSecondsAgo(),getBlockStartingTickAndLiquidity()Description: All three pool-consuming functions accept an
address poolparameter but never validate it is non-zero. Calling any of these withaddress(0)would result in low-level call failures to the zero address, producing opaque EVM revert errors with no useful diagnostic information. While unlikely in well-tested integrations, this makes debugging integration mistakes significantly harder and could mask root causes in complex multi-contract interactions.Fix: Added
require(pool != address(0), 'POOL')at the entry of all three functions:consult(),getOldestObservationSecondsAgo(), andgetBlockStartingTickAndLiquidity().Additional Hardening: getOldestObservationSecondsAgo() Timestamp Safety
Location:
getOldestObservationSecondsAgo()-- line 87 (original)Description: The subtraction
uint32(block.timestamp) - observationTimestampcould theoretically underflow ifobservationTimestampis somehow ahead ofblock.timestamp(e.g., due touint32timestamp wrapping after year 2106, or a chain with non-standard timestamp behavior). Added a defensiverequire(currentTimestamp >= observationTimestamp, 'FBO')check.Files Changed
contracts/dex-core/Dex223Oracle.solcontracts/dex-periphery/base/OracleLibrary.solCompilation Verification
MickTimeDex223PoolLib.sol,MockTimeDex223Pool.sol) are unrelated to these changesOracleLibrary.sol Test Plan
consult()with valid pool and secondsAgo: should return correct TWAP tick and harmonic mean liquidityconsult()withaddress(0)pool: should revert withPOOLconsult()withsecondsAgo = 0: should revert withBPconsult()with a pool that had zero in-range liquidity: should returnharmonicMeanLiquidity = 0without revertingconsult()with extreme tick cumulative values that would exceed int24 range: should revert withTICKgetQuoteAtTick()with boundary ticks (MIN_TICK, MAX_TICK): should return correct quote amountsgetOldestObservationSecondsAgo()with valid pool: should return correct seconds agogetOldestObservationSecondsAgo()withaddress(0): should revert withPOOLgetOldestObservationSecondsAgo()with uninitialized pool (cardinality 0): should revert withNIgetBlockStartingTickAndLiquidity()with valid pool (no current-block observation): should return slot0 tick and current liquiditygetBlockStartingTickAndLiquidity()with valid pool (current-block observation exists): should compute block-starting tick correctlygetBlockStartingTickAndLiquidity()withaddress(0): should revert withPOOLgetBlockStartingTickAndLiquidity()with same-timestamp observations (delta=0): should revert withDELTAgetBlockStartingTickAndLiquidity()with zero liquidity delta: should returnliquidity = 0getWeightedArithmeticMeanTick()with valid weighted tick data: should return correct weighted meangetWeightedArithmeticMeanTick()with empty array: should revert withEMPTYgetWeightedArithmeticMeanTick()with all-zero weights: should revert withWGHTgetWeightedArithmeticMeanTick()with extreme tick values that exceed int24 range when averaged: should revert withTICKgetWeightedArithmeticMeanTick()with negative numerator and non-zero remainder: should round toward negative infinitygetChainedPrice()with valid multi-hop route: should return correct synthetic tickgetChainedPrice()with empty tokens array (length 0): should revert withTLgetChainedPrice()with single token (length 1): should revert withTLgetChainedPrice()with mismatched tokens/ticks lengths: should revert withDLgetChainedPrice()with tokens in both sort orders: should correctly add/subtract ticksconsult(), verify price accuracy