Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
M-06·

OctarineCallOracle cannot refuse a price its own source flags as invalid: a defaulted Pareto tranche keeps settling at its pre-default price, and ULTRA's x100 unit change re-prices the route

Fixedrwarfqoracles
TL;DR

OctarineCallOracle prices a pair off one view call: word 0 of the return data, scaled by an immutable sourceDecimals (L54-L61).

Severity
MEDIUM
Method
MManual review
CAT.
02Section · Affected files

Affected files

src/shared/oracles/OctarineCallOracle.sol#L54-L61 src/shared/oracles/OctarineCallOracle.sol#L13

03Section · Description

Description

OctarineCallOracle prices a pair off one view call: word 0 of the return data, scaled by an immutable sourceDecimals (L54-L61). Its only notion of health is the optional timestamp call. Nothing else about the source can stop a read.

Two of the sources this adapter names for launch publish exactly the signals a consumer needs before trusting the number. The adapter cannot read either.

Pareto (priceAA()): the tranche keeps its pre-default price after a default.

  • When the borrower fails to pay at an epoch's end, the Pareto CDO records a hard default: defaulted() turns true, and the vault pauses and stops the epoch.
  • It does not touch priceAA(); the loss is only booked later, by finalizeDefault.
  • The AA token stays transferable.
  • So after a default the adapter keeps returning the pre-default price as fresh. A timestamp call would not help: Pareto exposes no update time, and nothing newer exists to be stale against.

ULTRA (lastSetMintExchangeRate()): the unit is an issuer parameter, and it has already changed ×100.

  • ULTRA's rate means lastSetMintExchangeRate / BPS_DENOMINATOR(), and BPS_DENOMINATOR is a storage variable the issuer's admin can set.
  • On Ethereum, after an upgrade at block 22437985 (8 May 2025), BPS_DENOMINATOR read 0 until block 22438191 and 1e6 from then on (it had been 1e4). The rate was republished at the new scale at block 22444277: 10071 became 1007100, both $1.0071.
  • An adapter deployed with sourceDecimals = 4 priced $1.0071 before the change and $100.71 after it, with no signal on-chain.

The design treats a bad oracle price as a staleness problem ("a stale RWA price misprices the entire fill"). For these two sources the price is not stale but wrong, and the source says so on-chain.

04Section · Impact

Impact

  • Pareto. Once a default is recorded (and while it is not yet reflected in priceAA()), any taker holding a live maker bid can sell AA at the pre-default price.
    • The maker's signed cap does not bind, because the price never moved.
    • The maker's loss is the tranche's eventual impairment × the size filled, and it is unknown until the default is finalised.
    • The taker picks the moment: it fills itself, or anyone holding both signatures does when sender is unset. A taker can back-run the epoch-closing transaction before the maker sees the default and cancels.
  • ULTRA. After a unit change, the route misprices ×100 in one direction or the other.
    • Each counterparty's own signed bound limits the loss: maxMakerAmount or minReceivedAmount. With a realistic cap the fill reverts, so the route is dead until redeployed.
    • Any consumer of the same adapter without a per-order bound is exposed to the full ×100.
05Section · Recommendations

Recommendations

Let each adapter pin one guard call whose answer must match, and treat any other answer, or a failed call, as no price:

diff
@@ -14,9 +14,12 @@
bool public immutable inverse;
uint32 public immutable gasCap;
address public immutable timestampTarget;
+ address public immutable guardTarget;
+ uint256 public immutable expectedGuard;
bytes public callData;
bytes public timestampCallData;
+ bytes public guardCallData;
error SourceUnavailable();
error TimestampUnavailable();
@@ -32,8 +35,14 @@
bool inverse_,
uint32 gasCap_,
address timestampTarget_,
- bytes memory timestampCallData_
+ bytes memory timestampCallData_,
+ address guardTarget_,
+ bytes memory guardCallData_,
+ uint256 expectedGuard_
) OctarineOracleBase(base_, quote_) {
+ guardTarget = guardTarget_;
+ guardCallData = guardCallData_;
+ expectedGuard = expectedGuard_;
if (target_ == address(0) || callData_.length < 4 || gasCap_ == 0) {
revert InvalidGuards();
}
@@ -53,6 +62,10 @@
function getPrice(address _base, address _quote) public view returns (uint256 price, uint256 lastUpdated) {
_checkPair(_base, _quote);
+ if (guardTarget != address(0)) {
+ (bool ok, uint256 g,) = _read(guardTarget, guardCallData, gasCap, 1);
+ if (!ok || g != expectedGuard) return (0, 0);
+ }
if (timestampTarget == address(0)) return (_scale(_readSource(), sourceDecimals, inverse), block.timestamp);
lastUpdated = _readTimestamp();
  • Pareto: deploy with defaulted() as the guard, expected 0. Once a default is recorded, the route reads as down and fills revert NoPriceAvailable.
  • ULTRA: deploy with BPS_DENOMINATOR() as the guard, expected the value at deployment. After a unit change the route reads as down until a new adapter is deployed at the new scale.
  • Construction: if a guard target is set, also reject guard calldata shorter than 4 bytes in the constructor.

Both cases were fix-checked on the same forks with a guarded copy of the adapter:

  • Pareto: priced before the default, NoPriceAvailable after it.
  • ULTRA: no price after the switch, so the ×100 fill cannot settle.

Three conditions for the guard to hold:

  1. Guard every route that prices the asset. A tripped guard makes its route read as down, and the RFQ then uses the reference-asset route instead. If the asset also has an unguarded route, the fallback still prices it at the pre-default price. In our test, a guarded direct AA→USDT pair with an unguarded AA→USDC leg settled 1,111.79 USDT through the fallback after a recorded default.
  2. Guard the setter oracle too. [M-04] recommends moving Pareto to OctarineThirdPartySetterOracle, which has no guard. A keeper re-pushing priceAA() after a default keeps it priced; in our test a fill 3 h after the default still settled 1,111.79 USDC. Give the setter oracle the same optional guard. The diff applies on top of the publication-time change recommended in [M-04]:
diff
@@ -20,6 +20,13 @@
uint32 public immutable minUpdateInterval;
uint32 public immutable signerTimelock;
+ /// @notice Optional health call pinned at deployment (e.g. Pareto `defaulted()` expecting 0). Any other
+ /// answer, or a failed call, reads as no price, as in {OctarineCallOracle}.
+ address public immutable guardTarget;
+ uint256 public immutable expectedGuard;
+ bytes public guardCallData;
+ uint32 internal constant GUARD_GAS = 100_000;
+
uint256 public price;
/// @notice When the SOURCE published `price` (signer-attested). Staleness is measured from this, not from
/// the push, so re-pushing an unchanged rate cannot keep a frozen source fresh.
@@ -58,7 +65,10 @@
uint32 maxAge_,
uint16 maxChangeBps_,
uint32 minUpdateInterval_,
- uint32 signerTimelock_
+ uint32 signerTimelock_,
+ address guardTarget_,
+ bytes memory guardCallData_,
+ uint256 expectedGuard_
) OctarineOracleBase(base_, quote_) Ownable(owner_) {
if (
maxAge_ == 0 || maxChangeBps_ == 0 || maxChangeBps_ > MAX_CHANGE_BPS_CEILING || minUpdateInterval_ == 0
@@ -70,6 +80,10 @@
maxChangeBps = maxChangeBps_;
minUpdateInterval = minUpdateInterval_;
signerTimelock = signerTimelock_;
+ guardTarget = guardTarget_;
+ guardCallData = guardCallData_;
+ expectedGuard = expectedGuard_;
+ if (guardTarget_ != address(0) && (guardCallData_.length < 4 || !_guardOk())) revert InvalidGuards();
for (uint256 i; i < signers.length; ++i) {
isSigner[signers[i]] = true;
emit SignerSet(signers[i], true);
@@ -78,10 +92,16 @@
function getPrice(address _base, address _quote) external view returns (uint256, uint256) {
_checkPair(_base, _quote);
- if (price == 0 || block.timestamp - updatedAt > maxAge) return (0, updatedAt);
+ if (price == 0 || block.timestamp - updatedAt > maxAge || !_guardOk()) return (0, updatedAt);
return (_scale(price, 18, false), updatedAt);
}
+ function _guardOk() internal view returns (bool) {
+ if (guardTarget == address(0)) return true;
+ (bool ok, uint256 g,) = _read(guardTarget, guardCallData, GUARD_GAS, 1);
+ return ok && g == expectedGuard;
+ }
+
/// @notice The last pushed price however old, for marking positions only.
function latestPriceUnchecked(address _base, address _quote) external view returns (uint256, uint256) {
_checkPair(_base, _quote);
  1. Validate the guard at deployment. An adapter whose guard call fails, or returns anything but the expected value, should refuse to deploy; otherwise the route is dead from day one:
diff
@@ -65,6 +65,11 @@ contract OctarineCallOracle is OctarineOracleBase {
if (_readTimestamp() == 0) revert TimestampUnavailable();
}
if (_readSource() == 0) revert SourceUnavailable();
+ // A guard that does not read as healthy at deployment would leave the route dead from day one.
+ if (guardTarget_ != address(0)) {
+ (bool ok, uint256 g,) = _read(guardTarget_, guardCallData_, gasCap_, 1);
+ if (!ok || g != expectedGuard_) revert InvalidGuards();
+ }
}
function getPrice(address _base, address _quote) public view returns (uint256 price, uint256 lastUpdated) {

With all three applied, a recorded default leaves the asset unpriced on every route. We checked this with these changes and the other recommended oracle fixes applied together.

You told us you unroute an asset on an issuer event. The guard closes the window until the unroute lands: a taker can fill in the block after the default.

Two cautions about that runbook, which apply to any issuer event, not only a default:

  • Unroute every pair that involves the asset, in both directions. registerOracle(asset, USDC, address(0), 0) stops fills against USDC, but a registered direct pair, such as the asset against another RWA, keeps pricing on its own oracle. In our test, after the USDC leg was unrouted during a 20% mark-down walked in 5% steps, an open quote on the direct pair filled at the first step: the maker paid 464.08 B for RWA worth 390.80 B at the published NAV (18.75% more).
  • Invalidating a direct pair's oracle does not leave the pair unpriced. You described invalidate() followed by a new push as leaving the route "unpriced rather than mispriced in between". For a direct pair, the RFQ reads the invalidated oracle as down and prices the pair through the reference-asset route instead. In our test, ten minutes after invalidate() on a direct pair's setter oracle, and before the reference leg re-printed, a fill settled at twice the asset's new value (488.50 B for RWA worth 244.25 B).

Pausing the RFQ is the one control that stops every route at once. To stop a single asset, add a per-asset halt that priceOf checks before any route.

Costs and composition:

  • Constructor parameters. Each adapter's constructor gains three parameters. The six calls in test/unit/OctarineCallOracle.t.sol and the four setter-oracle constructions in the tests need them.
  • The change composes with the live flag recommended for [M-04]. Both add constructor parameters; applied together by hand, src compiles.

Separate from [M-04]. [M-04] is about sources with no clock. A clock would not catch either case here.

After an issuer unit change like ULTRA's, deploy the replacement adapter only once the issuer has republished the rate at the new scale. ULTRA's scale had changed for about 21 hours before its rate was republished.

06Section · Remediation

Remediation

Fixed in aace386, documented in b77231b. OctarineCallOracle now takes a guard call whose answer must match, and an unhealthy guard is refused at deployment. setHalted, held by PAUSER_ROLE, stops every RFQ route that prices an asset.

On mainnet forks:

  • Pareto, with defaulted() as the guard, reads as no price after the real default.
  • ULTRA, with BPS_DENOMINATOR() as the guard, reads as no price after the unit change.

The README states that the halt covers RFQ pricing only, and that every route pricing a guarded asset needs the guard.

Two notes remain open:

  • Halting the reference asset does not stop pairs that triangulate through it, although the halted natspec says no route may price a halted asset.
  • OctarineThirdPartySetterOracle has no guard, so for an asset priced through it the halt is the only stop.

A runnable Foundry proof of concept for this finding is in the corresponding issue on the project tracker.

07Section · Report provenance

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.

Status
Fixed
M-06