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-08·

A delegated signer's cancelSalt under its own address succeeds and emits SaltCancelled, but the orders it signed stay fillable

Acknowledgedrwarfqoracles
TL;DR

cancelSalt is documented as:

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

Affected files

src/octarine-rfq/OctarineRFQ.sol#L285-L289 src/octarine-rfq/OctarineRFQ.sol#L402 src/octarine-rfq/interfaces/IOctarineRFQ.sol#L259-L264

03Section · Description

Description

cancelSalt is documented as:

solidity
/// @notice Invalidate every unfilled order or request `signer` signed carrying `salt`.
/// @dev The one retraction besides `expiry`. Callable by `signer` itself or by any of its
/// registered order signers, so a maker in cold custody can pull a quote with its hot key.

A maker in cold custody quotes through a registered hot key, so the hot key is the address that signs the order. If that key retracts its quote the way the @notice reads, with cancelSalt(hotKey, salt), the call succeeds: signer == msg.sender (L286). It records saltCancelled[hotKey][salt] and emits SaltCancelled(hotKey, salt).

Fills never read that entry. They check the cancellation under the order's maker only (L402):

solidity
if (saltCancelled[maker][r.salt]) revert SaltIsCancelled(maker, r.salt);

So the quote stays fillable. The maker, its tooling, and anyone indexing SaltCancelled all see a successful cancellation.

The working call is cancelSalt(maker, salt) sent from the hot key. The @dev's second sentence implies it, but nothing rejects the wrong form.

04Section · Impact

Impact

A maker that retracts a quote this way stays exposed to it until expiry, which can be up to a month. Retracting quotes matters most around a NAV mark-down, when a live quote is worth exercising.

In the test, a cold maker's hot key signs a month-long open quote at NAV 1.00 (cap 1,000 USDC for 1,000 RWA). On a 20% mark-down announcement, the hot key calls cancelSalt(hotKey, salt): the call succeeds and the event is emitted. A day later the first 5% step of the walk is published, and a taker fills the quote. The maker pays 921.50 USDC for RWA worth 776.00 at the announced NAV: an overpay of 145.50 USDC (18.75%).

05Section · Recommendations

Recommendations

Make a cancellation by the key that signed the order effective on every path that accepts a delegated maker signature: fillRFQOrder, and also fillRFQOrderEscrowed and fillTicketOrder in OctarineEscrowRFQ. That contract was outside this review's scope, but your test base deploys it, and a check placed only in fillRFQOrder leaves it open. In our test, with the check only in fillRFQOrder, the cancelled quote was refused there but filled through fillRFQOrderEscrowed, which pulled the maker's 97 USDC into escrow.

One way is a helper that also checks the recovering key's cancellations, used on all three paths:

diff
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
@@ -288,6 +288,19 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,
emit SaltCancelled(signer, salt);
}
+ /// @dev The maker's signature, plus: a cancellation recorded under the key that signed the order also voids it.
+ /// Every path that accepts a delegated maker signature goes through here.
+ function _verifyMakerSignature(bytes32 orderHash, address maker, uint256 salt, bytes calldata makerSignature)
+ internal
+ view
+ {
+ _verifySignature(orderHash, maker, makerSignature, true);
+ (address signedBy, ECDSA.RecoverError rerr,) = ECDSA.tryRecover(orderHash, makerSignature);
+ if (rerr == ECDSA.RecoverError.NoError && signedBy != maker && saltCancelled[signedBy][salt]) {
+ revert SaltIsCancelled(signedBy, salt);
+ }
+ }
+
/// @dev ECDSA from the signer, EIP-1271 from a contract signer, or ECDSA from a registered signer.
function _verifySignature(bytes32 digest, address signer, bytes calldata signature, bool allowDelegate)
internal
@@ -366,7 +379,7 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,
revert OrderOverfilled(takerAmountIn, order.takerAmount - filled);
}
- _verifySignature(orderHash, order.maker, makerSignature, true);
+ _verifyMakerSignature(orderHash, order.maker, order.salt, makerSignature);
rfqFilled[orderHash] += takerAmountIn;
uint128 delivered = _deliverTakerLeg(order.takerToken, taker, order.maker, takerAmountIn);
diff --git a/src/octarine-rfq/OctarineEscrowRFQ.sol b/src/octarine-rfq/OctarineEscrowRFQ.sol
--- a/src/octarine-rfq/OctarineEscrowRFQ.sol
+++ b/src/octarine-rfq/OctarineEscrowRFQ.sol
@@ -93,7 +93,7 @@ contract OctarineEscrowRFQ is OctarineRFQ, IOctarineEscrowRFQ {
if (filled + takerAmountIn > order.takerAmount) {
revert OrderOverfilled(takerAmountIn, order.takerAmount - filled);
}
- _verifySignature(orderHash, order.maker, makerSignature, true);
+ _verifyMakerSignature(orderHash, order.maker, order.salt, makerSignature);
rfqFilled[orderHash] = filled + takerAmountIn;
(uint128 escrowed, uint256 shares) = _escrowTakerLeg(order.takerToken, taker, takerAmountIn, escrow);
@@ -277,7 +277,7 @@ contract OctarineEscrowRFQ is OctarineRFQ, IOctarineEscrowRFQ {
bytes32 orderHash = hashTicketOrder(o);
if (ticketOrderFilled[orderHash]) revert TicketOrderAlreadyFilled(orderHash);
- _verifySignature(orderHash, o.maker, makerSignature, true);
+ _verifyMakerSignature(orderHash, o.maker, o.salt, makerSignature);
if (msg.sender != o.taker) _verifySignature(orderHash, o.taker, takerSignature, false);
ticketOrderFilled[orderHash] = true;

With this change the test fails with SaltIsCancelled(hotKey, salt), and so does the same fill through fillRFQOrderEscrowed. Your full test suite passes with this change (706 passed, 1 skipped).

An alternative is to make cancelSalt revert when a registered order signer calls it under its own address; that needs a mapping from each signer back to its maker. Either way, reword the natspec to say that a delegate cancels with cancelSalt(maker, salt).

06Section · Remediation

Remediation

Acknowledged. cancelSalt(hotKey, salt) still succeeds and cancels nothing, by design. In 9b3cdcd and 6ef4d9b, the parameter and the event and error fields were renamed account. The natspec, README and DESIGN.md now say that a delegated signer cancels with cancelSalt(maker, salt), and that the app must always pass the maker's address.

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.

L-08