The submitter's gas limit can starve a healthy direct CallOracle read, so the fill is priced on the triangulated route
priceOf uses the direct base/quote feed "if one is registered and healthy" (IOctarineRFQ.sol L212-L222), and otherwise triangulates through the reference asset.
Affected files
src/octarine-rfq/OctarineRFQ.sol#L194-L203 src/shared/oracles/OctarineOracleBase.sol#L48-L62
Description
priceOf uses the direct base/quote feed "if one is registered and healthy" (IOctarineRFQ.sol L212-L222), and otherwise triangulates through the reference asset. Health is decided in _tryOracle, which maps every failure to 0 (L195-L203):
try oracle.getPrice(base, quote) returns (uint256 p, uint256 updatedAt) {if (p != 0 && block.timestamp <= updatedAt + oracleMaxStaleness[base][quote]) return p;} catch {}return 0;
OctarineCallOracle reads its source with staticcall(gasCap, …) (OctarineOracleBase.sol L55) and reports a failed read as price 0. It never checks that it actually had gasCap to forward. Under EIP-150, a caller that arrives with less gives the source only 63/64 of what is left. So the submitter of fillRFQOrder can choose a gas limit that is too small for the direct source but large enough for the rest of the fill. The direct read then fails, _tryOracle treats the healthy feed as down, and the fill settles on the triangulated price instead. Whoever sends the transaction picks the route; that is the taker on its own fill, or anyone when sender == address(0).
The RFQ keeps only a small fraction of its gas after a starved read, so the route can only be forced when the direct source's read costs far more than the rest of the fill. In a separate sweep of gas limits (not included here), a source costing about 1M gas was never forced. One costing about 3M gas was forced at 178 of the 234 gas limits that settled. No source the adapter lists (ULTRA, Pareto, BENJI, ERC-4626, Midas, Ondo) is anywhere near that. The issue applies to a direct pair whose source is that expensive, for example an ERC-4626 vault whose convertToAssets walks many markets. The loss per fill is the gap between the two routes' prices, up to the counterparty's signed bound.
Recommendations
Reserve the oracle's gas up front, instead of inferring starvation after the fact:
- give each direct read a fixed budget;
- refuse the fill if the caller cannot afford it;
- call with exactly that budget.
@@ -57,6 +57,11 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,mapping(address base => mapping(address quote => IOctarineOracle)) public oracles;mapping(address base => mapping(address quote => uint64)) public oracleMaxStaleness;+ /// @notice Gas a direct-feed read is handed. A read is never attempted with less: if the caller cannot+ /// afford the budget the call reverts, so a starved read can never be taken for a feed that is down.+ mapping(address base => mapping(address quote => uint32)) public oracleGas;++ uint32 public constant DEFAULT_ORACLE_GAS = 300_000;address public referenceAsset;@@ -196,12 +201,21 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,IOctarineOracle oracle = oracles[base][quote];if (address(oracle) == address(0)) return 0;- try oracle.getPrice(base, quote) returns (uint256 p, uint256 updatedAt) {+ uint256 budget = oracleGas[base][quote];+ if (budget == 0) budget = DEFAULT_ORACLE_GAS;+ // Refuse rather than starve: the oracle always receives its whole budget, so a failure inside+ // it is the feed's, and a down feed keeps its documented fallback however it fails.+ if (gasleft() < budget + budget / 63 + 10_000) revert NoPriceAvailable(base, quote);+ try oracle.getPrice{gas: budget}(base, quote) returns (uint256 p, uint256 updatedAt) {if (p != 0 && block.timestamp <= updatedAt + oracleMaxStaleness[base][quote]) return p;} catch {}return 0;}+ function setOracleGas(address base, address quote, uint32 gas_) external onlyRole(DEFAULT_ADMIN_ROLE) {+ oracleGas[base][quote] = gas_;+ }+function registerOracle(address base, address quote, address oracle, uint64 staleness)externalonlyRole(DEFAULT_ADMIN_ROLE)
What this changes:
- No starvation. The oracle always receives its whole budget, so a submitter can no longer starve a healthy read into the triangulated route.
- The fallback still works. A feed that is genuinely down fails inside its own budget, and still falls back to triangulation as documented, however it fails.
- Why not a simpler check. Comparing
gasleft()before and after the call cannot tell the two apart. Behind an adapter that forwards all its gas, asOctarineChainlinkOracledoes, a feed that fails by consuming everything it is given (for example a pre-0.8assert) always leaves less than 1/16 of the gas behind. Fills on such a down feed would then revert at every gas limit instead of falling back.
We checked this with a feed costing about 3M gas and a down feed that consumes all its gas:
- No gas limit forced the triangulated route for the healthy feed.
- The down feed fell back at every gas limit that could afford its budget.
With this change, the full test suite passes.
The costs:
- Budget sizing. The admin must size each pair's budget for its adapter's worst case: about
gasCapfor a CallOracle, up to three timesgasCapfor one with a timestamp call and a guard. An undersized budget makes a healthy route read as down. The default is 300,000 gas, which covers the adapters' usual sources (5–12k gas). - Gas limit. A fill's gas limit must cover each direct read's budget plus about 1/63 of it. Unused gas is refunded.
Remediation
Mitigated in 5896f96, with no code change. The gasCap natspec and the README now cap gasCap at 300,000 per call, with guidance for sizing it. In our sweep at that cap, no gas limit forced the reference route, even with a guard and a timestamp call on the same adapter. The cap is a deployment rule that is not enforced on-chain. An adapter deployed with a larger gasCap over a costly source is still exposed.
A runnable Foundry proof of concept for this finding is in the corresponding issue on the project tracker.
Report provenance
Source: the final report dated 28 October 2026. Original finding identifiers are retained, including M-03 and M-04, which were regraded Low. Final dispositions and residual conditions are preserved in this write-up. Read the full report.