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 →
L-10·

The filler of an open quote cannot set a minimum, so a schedule or price move between its simulation and settlement is charged to it in full

Fixedrwarfqoracles
TL;DR

fillRFQOrder bounds each side only by terms that side signed.

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

Affected files

src/octarine-rfq/OctarineRFQ.sol#L336-L382 src/octarine-rfq/OctarineRFQ.sol#L404-L405

03Section · Description

Description

fillRFQOrder bounds each side only by terms that side signed. Its natspec says minReceivedAmount "floors the taker's proceeds. Neither is supplied at call time". The README adds that minReceivedAmount "is what bounds the damage when a price source or a schedule moves against the taker between signing and settlement, whether by market drift, a manual price, or a compromised keeper widening the per-asset discount ceiling".

That holds for a named taker, who signs its own request. It does not hold on an open quote. With taker == address(0), _validateCommonTerms returns before any taker-side term (L404-L405):

solidity
taker = r.taker == address(0) ? msg.sender : r.taker;
if (r.taker == address(0)) return taker;

The only floor then checked is order.minReceivedAmount (L377-L382), and on an open quote the maker signs it and may set it to 1. fillRFQOrder takes no call-time minimum. So the filler can simulate the fill but cannot bound what it receives. Any horizon or price move between its simulation and its inclusion is charged to it in full, and the maker gains the difference.

README assumption 4 treats an open quote as the maker's firm offer and covers the maker's exposure. It does not mention the filler's.

04Section · Impact

Impact

In the test, an RWA holder fills an open quote for 10,000 RWA at NAV 1.00, priced at 10 bps/day. On a 30-day horizon it simulates 9,700.00 USDC and would accept 1% less.

  • No attacker. On a cyclical asset 12 seconds before its redemption date, the filler simulates 9,999.998611. Its transaction lands one block later, after the date, and is charged a full 30-day cycle. It receives 9,700.00: 299.998611 less than simulated, and 199.998624 below its own 1% tolerance.
  • A compromised keeper key. Sixty setSchedule calls of +2 days each land in the filler's block, ahead of its transaction. The filler receives 8,500.00 instead of 9,700.00 (−12.4%), and the maker pays 1,200.00 less for the same RWA.
  • A compromised setter-signer key, used by the maker. The maker posts the open quote, then pushes the price down 5% ahead of the fill, within the oracle's step bound and after the update interval. The filler receives 9,215.00 instead of 9,700.00, and the maker holds RWA bought 5% under NAV. With minUpdateInterval = 0 ([L-04]), the key is not limited to one step before the fill.

The two key-compromise cases need to see the filler's transaction before it lands, which a public mempool such as Ethereum's allows. The no-attacker case needs nothing.

The control is a named taker that signs a floor of its quote minus 1%. The same push makes its fill revert ReceivedBelowMinimum.

Your UI flow uses named takers, which are protected. This affects anyone who fills open quotes directly, such as an integration or a bot.

05Section · Recommendations

Recommendations

  1. Let the filler of an open quote state its own minimum. The change below reads it from takerSignature, which an open quote does not otherwise use, so the interface is unchanged:
diff
diff --git a/src/octarine-rfq/OctarineRFQ.sol b/src/octarine-rfq/OctarineRFQ.sol
index 0026a90..9c3cbc2 100644
--- a/src/octarine-rfq/OctarineRFQ.sol
+++ b/src/octarine-rfq/OctarineRFQ.sol
@@ -374,12 +374,15 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,
uint32 horizonSeconds;
(makerAmount, fee, horizonSeconds) = quoteRFQOrder(order, delivered);
_checkMakerCap(makerAmount, order.maxMakerAmount, takerAmountIn, order.takerAmount);
- _checkTakerFloor(
- _payMakerLegs(order.makerToken, order.maker, taker, order.feeRecipient, makerAmount - fee, fee),
- order.minReceivedAmount,
- takerAmountIn,
- order.takerAmount
- );
+ uint256 received =
+ _payMakerLegs(order.makerToken, order.maker, taker, order.feeRecipient, makerAmount - fee, fee);
+ _checkTakerFloor(received, order.minReceivedAmount, takerAmountIn, order.takerAmount);
+ // On an open quote the signed floor is the maker's. The filler states its own minimum for this fill,
+ // in `takerSignature`, which an open quote does not otherwise use.
+ if (order.taker == address(0) && takerSignature.length == 32) {
+ uint256 fillerMin = abi.decode(takerSignature, (uint256));
+ if (received < fillerMin) revert ReceivedBelowMinimum(received, fillerMin);
+ }
emit RFQOrderFilled(
orderHash, order.maker, taker, takerAmountIn, makerAmount, fee, horizonSeconds, order.makerBpsPerDay

With this change the three harm tests fail with ReceivedBelowMinimum, and the named-taker control still passes. Your full test suite passes with this change (706 passed, 1 skipped).

A dedicated minReceived argument, or a separate function for open quotes, is cleaner if you are willing to change the interface. Either way, every integration that fills open quotes should pass its simulation minus its tolerance. Consider requiring the minimum on open quotes, so an integration cannot omit it. Your tests that fill open quotes would then pass one.

  1. Until then, state in README assumption 4 that the filler of an open quote has no bound of its own. A filler that needs one should fill through a named-taker request.
06Section · Remediation

Remediation

Fixed in 0c68070 and 786427a. On an open quote, the filler passes its own minimum as abi.encode(uint256) in takerSignature, on both fillRFQOrder and fillRFQOrderEscrowed. Any other non-empty encoding reverts InvalidSignature. The PoC's three harm tests now revert ReceivedBelowMinimum. The natspec and README tell every integration that fills open quotes to pass its simulated proceeds minus its tolerance.

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
L-10