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
OctarineCallOracle prices a pair off one view call: word 0 of the return data, scaled by an immutable sourceDecimals (L54-L61).
Affected files
src/shared/oracles/OctarineCallOracle.sol#L54-L61 src/shared/oracles/OctarineCallOracle.sol#L13
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, byfinalizeDefault. - 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(), andBPS_DENOMINATORis a storage variable the issuer's admin can set. - On Ethereum, after an upgrade at block 22437985 (8 May 2025),
BPS_DENOMINATORread 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 = 4priced $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.
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
senderis 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:
maxMakerAmountorminReceivedAmount. 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.
- Each counterparty's own signed bound limits the loss:
Recommendations
Let each adapter pin one guard call whose answer must match, and treat any other answer, or a failed call, as no price:
@@ -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, expected0. Once a default is recorded, the route reads as down and fills revertNoPriceAvailable. - 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,
NoPriceAvailableafter it. - ULTRA: no price after the switch, so the ×100 fill cannot settle.
Three conditions for the guard to hold:
- 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.
- Guard the setter oracle too. [M-04] recommends moving Pareto to
OctarineThirdPartySetterOracle, which has no guard. A keeper re-pushingpriceAA()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]:
@@ -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);
- 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:
@@ -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 afterinvalidate()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.soland the four setter-oracle constructions in the tests need them. - The change composes with the
liveflag recommended for [M-04]. Both add constructor parameters; applied together by hand,srccompiles.
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.
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
haltednatspec says no route may price a halted asset. OctarineThirdPartySetterOraclehas 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.
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.