A relayed fill publishes the taker's signed request, so a competing maker can front-run the winning bid and fill at the taker's floor
When someone other than the taker submits a fill, the taker's request signature is required (L409), so it travels in the transaction's calldata:
Affected files
src/octarine-rfq/OctarineRFQ.sol#L398 src/octarine-rfq/OctarineRFQ.sol#L404-L413
Description
When someone other than the taker submits a fill, the taker's request signature is required (L409), so it travels in the transaction's calldata:
taker = r.taker == address(0) ? msg.sender : r.taker;...bytes32 requestHash = hashRequest(r);if (taker != msg.sender) _verifySignature(requestHash, taker, takerSignature, false);
The request names no maker (README, Assumption 5), and sender binds the submitter only when it is set (L398). You told us the UI leaves sender unset and that fills are submitted by the user or a relayer. Anyone who sees a relayed fill can therefore pair the taker's request with their own bid. requestFilled is shared by every bid paired with the request (L411-L413), so whichever fill lands first consumes it, and the winning maker's fill reverts OrderOverfilled.
Assumption 5 accepts that "anyone holding it can pair their own bid with it", and treats that as the cost of a leak: the signature "is not broadcast to makers until after the auction winner is chosen", and "a leaked request costs price improvement, never principal". The relayed path makes it public every time a winner is picked, and nothing then makes the auction's result binding. Any maker that sees it can still replace the winning bid with a worse one, up to the taker's floor:
- On a chain with a public mempool (Ethereum), a losing maker sees the relayer's pending transaction, copies the request, signs its own bid at the highest rate the taker's floor still admits, and lands first.
- On any chain, including Plume's sequencer, a relayed fill that reverts for an ordinary reason (the winner's cap trips on a small NAV tick, a balance or allowance is short) leaves the signature on-chain. The request stays fillable by anyone until its
expiry, which the UI lets users set from 24 hours to a month.
The bidder needs no special position, only the ability to receive the RWA, which every maker bidding on it already has.
Impact
The taker is paid near its floor instead of the winning bid. This is the "price improvement" cost Assumption 5 accepts for a leak, but here it applies to every relayed fill, not just a leaked one. The maker that won the auction loses the trade. The loss is bounded by the taker's own signed slack, up to minReceivedAmount and takerMaxBpsPerDay. At your default 1% slippage, that slack is about 1% of every relayed sale:
- Front-run: a sale of 1,000,000 RWA at NAV 1.00 over a 30-day horizon. The winning bid of 10 bps/day pays 970,000 USDC. A competing maker at 13.23 bps/day lands first and pays 960,310 USDC: −9,690 USDC (0.97%) for the taker, all within its signed floor.
- After a failed relayed fill: the same request, two days later, pays 966,071.86 USDC instead of 975,820: −9,748.14 USDC. In that state the taker's terms would admit up to about 15,500 USDC of loss.
A relayer that is also a market maker holds the taker's signature before it broadcasts anything, so it can do the same without racing anyone.
Recommendations
- Set
senderwhenever a fill is relayed. In the request it asks the user to sign, have the UI or backend name assenderan address only your relayer can submit from: the relayer's own key, or a relay contract that admits only that key. The existing check then rejects the copy (test_control_namedSenderBlocksTheSnipe). This needs no contract change. A shared forwarder, multicall or ERC-2771 forwarder does not bind, because anyone can submit through it: in our test, a snipe routed through a public forwarder named assenderstill paid the taker 960,310 instead of 970,000. Other signed-order venues bind the submitter the same way: 0x v4 RFQ orders carry atxOrigin, and 1inch's limit orders anallowedSender. - Optionally enforce it on-chain, so a request without a named submitter can only be filled by the taker itself:
diff --git a/src/octarine-rfq/OctarineRFQ.sol b/src/octarine-rfq/OctarineRFQ.sol--- a/src/octarine-rfq/OctarineRFQ.sol+++ b/src/octarine-rfq/OctarineRFQ.sol@@ -406,7 +406,11 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,if (saltCancelled[taker][r.salt]) revert SaltIsCancelled(taker, r.salt);bytes32 requestHash = hashRequest(r);- if (taker != msg.sender) _verifySignature(requestHash, taker, takerSignature, false);+ if (taker != msg.sender) {+ // A relayed fill publishes the request, so it must name its relayer.+ if (r.sender == address(0)) revert WrongSender(address(0), msg.sender);+ _verifySignature(requestHash, taker, takerSignature, false);+ }uint128 filled = requestFilled[requestHash];if (filled + takerAmountIn > r.takerAmount) revert OrderOverfilled(takerAmountIn, r.takerAmount - filled);
With this change both harm tests fail with WrongSender, and the named-sender control still passes. A relayed fill that does not name its relayer is then rejected, so the UI must set sender before this ships. With the change, 704 of your 707 tests pass and 1 is skipped. The 2 that fail, test_revert_requestSignedForImmediateCannotSettleEscrowed and test_revert_delegateCannotSignRequest, relay a fill without naming sender. They now revert WrongSender before reaching the signature check they exist to test, and in the escrow test the fill that should settle reverts too. Name the relayer as sender in each (one line, order.sender = outsider;): both then reach the signature check again, and the suite passes (706 passed, 1 skipped). Any other test or tool that relays a fill without sender needs the same change, including the tests in [M-03], [L-05] and [L-07].
- After a relayed fill fails, have the backend cancel the request (
cancelSalt(taker, salt)from the user's wallet) or re-quote with a shortexpiry, rather than leaving the signature live.
Remediation
Acknowledged, with no contract change. The client will set sender in the UI on every relayed request. In our tests that blocks the snipe when sender is the taker, or a relayer key only the client controls. A shared forwarder named as sender does not bind. The protection now lives in the code that builds the request for the user to sign, which was outside this review.
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.