Reports

Smart Contract Security Assessment

HAI CurveStableSwapNG Oracle Review

HAI CurveStableSwapNG Oracle provides oracle infrastructure for price feeds. The reviewed component is a Curve StableSwap-NG relayer that combines Curve's EMA price oracle with external rate providers to deliver WAD-scaled price feeds via the IBaseOracle interface.

5
Issues
0
C/H/M
Period
Feb 12, 2026 - Feb 13, 2026
Auditors
Adriro, HHK

Review Summary

Protocol Overview

HAI CurveStableSwapNG Oracle provides oracle infrastructure for price feeds. The reviewed component is a Curve StableSwap-NG relayer that combines Curve's EMA price oracle with external rate providers to deliver WAD-scaled price feeds via the IBaseOracle interface.

Protocol
HAI
Timeline
Feb 12, 2026 - Feb 13, 2026
Audit Team
Adriro, HHK

Audit Overview

Scope and Resources

Scope

This audit covers 2 smart contracts totaling approximately 180 lines of code across 1 day of review.

Overall Assessment

The codebase is well-structured and minimal in scope. No critical or high severity issues were found. Findings are limited to a low severity precision loss, a gas optimization, and two informational issues around oracle reliability and factory ergonomics.

Evaluation Matrix

access control

mathematics

complexity

libraries

decentralization

code stability

documentation

monitoring

testing

Key Findings

Findings Summary

0
Critical
0
High
0
Medium
1
Low
3
Informational
1
Gas
L-1 Finding

L-1: Unnecessary precision loss in `_adjustForOracleRates()` due to intermediate WAD truncation

Low

Summary:

_adjustForOracleRates() computes _price.wmul(_baseOracleRate).wdiv(_quoteOracleRate), which introduces an avoidable rounding step.

Description:

The expression _price.wmul(_baseOracleRate).wdiv(_quoteOracleRate) expands to:

((_price * _baseOracleRate / WAD) * WAD) / _quoteOracleRate

The intermediate division by WAD in wmul() truncates before the subsequent multiplication in wdiv(). This can be simplified to _price * _baseOracleRate / _quoteOracleRate, which avoids the intermediate truncation and preserves more precision. Since all three values are in WAD scale, the single division by _quoteOracleRate (1e18-denominated) produces the correct WAD-scaled result.

Impact:

Low.

Recommendation:

Replace the chained wmul/wdiv with a single multiplication and division:

return _price * _baseOracleRate / _quoteOracleRate;

Developer Response:

Acknowledged. This is the same pattern used throughout the codebase.

I-1 Finding

I-1: Missing membership getter for `_curveStableSwapNGRelayers` set

Informational

Summary:

CurveStableSwapNGRelayerFactory stores deployed relayers in an EnumerableSet.AddressSet and exposes curveStableSwapNGRelayersList() to retrieve all entries, but provides no way to check whether a specific address belongs to the set.

Description:

The factory uses OpenZeppelin's EnumerableSet which natively supports contains(), but this function is never exposed. Consumers that need to verify whether a given oracle was deployed by the factory must iterate over the full list returned by curveStableSwapNGRelayersList(), which is gas-inefficient and impractical for on-chain callers.

Impact:

Informational.

Recommendation:

Add a public view that wraps _curveStableSwapNGRelayers.contains():

function isCurveStableSwapNGRelayer(address _relayer) external view returns (bool) {
  return _curveStableSwapNGRelayers.contains(_relayer);
}

Developer Response:

I-2 Finding

I-2: `getResultWithValidity()` may revert due to external dependency in `stored_rates()`

Informational

Summary:

CurveStableSwapNGRelayer.getResultWithValidity() calls pool.stored_rates(), which depending on the pool's asset types may query an external oracle or invoke an ERC4626 convertToAssets() call. Either of these can revert, violating the IBaseOracle requirement that getResultWithValidity() should never revert.

Description:

Curve StableSwap-NG pools support multiple asset types. For type 1 (oracle) assets, stored_rates() queries an external price oracle; for type 3 (ERC4626) assets, it calls convertToAssets() on the vault. If the external oracle is paused, deprecated, or the ERC4626 vault reverts (e.g., due to a paused state), the entire stored_rates() call will revert. This causes getResultWithValidity() to revert instead of returning (0, false), breaking the contract specified by IBaseOracle which states that this method should never revert.

Impact:

Informational.

Recommendation:

Consider returning (0, false) instead of bubbling the error if an external call reverts.

Developer Response:

I-3 Finding

I-3: EMA price adjusted with current external rates can enable sudden price changes

Informational

Summary:

The relayer uses Curve's manipulation-resistant EMA but adjusts it with current rates from external oracles, which can change suddenly.

Description:

The relayer combines two data sources (CurveStableSwapNGRelayer.sol#L82-94):

  • price_oracle(): EMA of historical prices (slow-moving, manipulation-resistant)
  • stored_rates(): Current rates via external calls (can change instantly)

For pools on Optimism with asset_type 1 (custom rate providers) or asset_type 3 (ERC4626 vaults), stored_rates() makes external calls to fetch current rates. The relayer multiplies the EMA by the current rate ratio.

Example: wstETH / ETH pool

  • EMA shows 1.0 (balanced pool in xp space)
  • Historical wstETH rate: 1.15
  • Current wstETH rate: 1.20 (sudden increase from staking rewards or oracle update)
  • Relayer immediately reports adjusted price using rate 1.20

While this gives the correct current execution price, the EMA's manipulation resistance is partially bypassed since rates can spike instantly (e.g., ERC4626 donation inflation, or sudden oracle updates).

Impact:

Informational. Low risk in most situation with battle-tested rate providers / high TVL ERC4626 vaults.

Recommendation:

No ideal fix (can't dynamically bound rates without storing history). Consider:

  • Setting hardcoded boundaries, to restrict how high/low a rate can go
  • Only deploying relayers on pools with well-established ERC4626 vaults and robust rates manipulation protection

Developer Response:

Acknowledged. This is mitigated through the selection of assets.

G-1 Finding

G-1: State variables can be made immutable to save gas

Gas

Summary:

All state variables in CurveStableSwapNGRelayer are set once in the constructor and never modified, but are not marked as immutable.

Description:

The following variables at CurveStableSwapNGRelayer.sol#L21-40 can be immutable:

  • pool
  • baseToken
  • quoteToken
  • symbol
  • baseIndex
  • quoteIndex
  • baseRateMultiplier
  • quoteRateMultiplier

Impact:

Gas.

Recommendation:

Consider making all variables immutable.

Developer Response:

Acknowledged.

Originally I had these marked as immutable but if they are immutable we cannot use .runtimeCode in our unit tests.

Final Remarks

The CurveStableSwapNG relayer is a compact and focused oracle integration. The main risk surface is the reliance on external rate providers via stored_rates(), which can revert or introduce sudden price changes. These risks are low in practice when restricted to battle-tested pools, as acknowledged by the team. The identified issues were promptly addressed. Overall the codebase is solid.

Methodology

Severity Classification

Critical

Immediate threat to user funds or protocol integrity

Direct loss of funds, protocol compromise

High

Significant security risk requiring urgent attention

Potential fund loss, major functionality disruption

Medium

Important issue that should be addressed

Limited fund risk, functionality concerns

Low

Minor issue with minimal impact

Best practice violations, minor inefficiencies

Gas

Findings that improve gas efficiency

Increased transaction costs

Informational

Code quality and best practice recommendations

Reduced maintainability and readability