DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Incorrect Price Scaling in KeeperProxy Validation

Summary

The KeeperProxy contract incorrectly scales protocol prices when comparing against Chainlink feeds. This mismatch in decimal representation can lead to invalid price checks that either block legitimate transactions or allow manipulated prices to pass through.

Vulnerability Details

The KeeperProxy is designed to validate prices between the protocol’s internal 30-decimal format and Chainlink’s 8-decimal format. However, it incorrectly scales protocol prices by 10^(30 - tokenDecimals - 8) instead of scaling Chainlink’s price to 30 decimals. This creates a mismatch in price magnitudes, for USDC token, where a $1.00 price is represented as 1e30 in the protocol but scales to 1e14 during validation.

Meanwhile, Chainlink reports the same price as 1e8 (8 decimals). The resulting comparison falsely flags a 99,999,900% deviation, allowing invalid trades or blocking legitimate ones.

Proof of Concept (PoC):

  • Token: USDC (6 decimals).

  • Protocol Price: $1.00 (1e30 in 30-decimal terms).

  • Chainlink Price: $1.00 (1e8 in 8-decimal terms).

  • Threshold: 1% (100 basis points).

In the _check() function below, the internal price is scaled using the following logic:-

/**
* @notice Checks the price difference between the given price and the Chainlink price.
* @dev Internal function to ensure the price difference is within the threshold.
* @param token The address of the token.
* @param price The price to be checked.
*/
function _check(address token, uint256 price) internal view {
// https://github.com/code-423n4/2021-06-tracer-findings/issues/145
(, int chainLinkPrice, , uint256 updatedAt, ) = AggregatorV2V3Interface(dataFeed[token]).latestRoundData();
require(updatedAt > block.timestamp - maxTimeWindow[token], "stale price feed");
uint256 decimals = 30 - IERC20Meta(token).decimals();
price = price / 10 ** (decimals - 8); // Chainlink price decimals is always 8.
require(
_absDiff(price, chainLinkPrice.toUint256()) * BPS / chainLinkPrice.toUint256() < priceDiffThreshold[token],
"price offset too big"
);
}

Meaning, according to the scaling logic used in the code, for a token with 6 decimals (e.g., USDC):

  • decimals = 30 - 6 = 24

  • price = price / 10^(24 - 8) = price / 10^16

If price = 1e30 (protocol’s $1.00):
--> price = 1e30 / 1e16 = 1e14 (scaled to 14 decimals).

Comparison with Chainlink:

  • Chainlink’s price = 1e8 (8 decimals), which means, the code compares 1e14 (scaled protocol price) to 1e8 (Chainlink price).

Deviation calculation: |1e14 - 1e8| / 1e8 * 10,000 = 99,990,000%

This exceeds any reasonable threshold (e.g., 1% = 100 BPS), causing false failures. Therefore, a keeper opens a position using a stale or manipulated price, bypassing safety checks. Positions are liquidated unfairly or trades execute at incorrect prices, draining user funds.

In hindsight, the protocol scales down its 30-decimal price to a lower decimal format, while Chainlink’s price remains at 8 decimals. This creates an apples-to-oranges comparison. However, both prices should have been normalized to the same decimal precision (e.g., 30 decimals).

Intended Behavior --> Both prices should be normalized to the same decimal precision (e.g., 30 decimals).

Actual Behavior -->

  • Protocol price: 1e30 → 1e14 (incorrectly scaled down).

  • Chainlink price: 1e8 (unchanged).

  • A valid price (both $1.00) appears as a 99,990,000% deviation, causing the check to fail.

To compare prices accurately, scale Chainlink’s price to 30 decimals instead of scaling the protocol’s price down:

uint256 tokenDecimals = IERC20Meta(token).decimals();
uint256 chainlinkScaled = chainLinkPrice.toUint256() * 10 ** (30 - 8 - tokenDecimals);
require(_absDiff(price, chainlinkScaled) * BPS / chainlinkScaled < priceDiffThreshold[token], "...");

After this changes :-

  • Chainlink price = 1e8 (8 decimals).

  • Scaling factor = 10^(30 - 8 - 6) = 10^16.

  • chainlinkScaled = 1e8 * 1e16 = 1e24 (30 decimals).

  • Protocol price = 1e30 (30 decimals).

  • Deviation: |1e30 - 1e24| / 1e24 * 10,000 = 0.99% (99 BPS) → Valid for a 1% threshold.

Impact

Price validation is critical for keeper-triggered operations (like opening or closing positions). If the scaling is off, orders might be executed at highly inaccurate prices or legitimate orders could be blocked. This flaw could allow an attacker to force a keeper to execute orders at manipulated prices or, conversely, cause the protocol to revert valid transactions under normal market conditions—both scenarios posing systemic risk.

Tools Used

Manual Review

Recommendations

Properly scale Chainlink’s 8‑decimal price up to the 30‑decimal standard.

uint256 tokenDecimals = IERC20Meta(token).decimals();
uint256 chainlinkScaled = chainLinkPrice.toUint256() * 10 ** (30 - 8 - tokenDecimals);
require(_absDiff(price, chainlinkScaled) * BPS / chainlinkScaled < priceDiffThreshold[token], "...");
Updates

Lead Judging Commences

n0kto Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

invalid_prices_decimals

GMX github documentation: “Prices stored within the Oracle contract represent the price of one unit of the token using a value with 30 decimals of precision. Representing the prices in this way allows for conversions between token amounts and fiat values to be simplified, e.g. to calculate the fiat value of a given number of tokens the calculation would just be: token amount * oracle price, to calculate the token amount for a fiat value it would be: fiat value / oracle price.” Sponsor confirmed the keeper does the same, so price decimals change in function of the token, to be sure the above rule is true. Example for USDC (6 decimals): Prices will have 24 decimals → 1e6 * 1e24 = 1e30. Just a reminder for some submissions: shortToken == collateralTokens, so the decimals is 1e24 for shortToken prices.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.