Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0007·design-flaw

Cancellation has no on-chain expression, so an order cancelled in the app stays fillable at its stale price until expiry

Fixednfterc-1155marketplace
TL;DR

Cancelling an order in the app has no on-chain effect, so the order remains fillable at its stale signed price until it expires.

Severity
MEDIUM
Impact
MEDIUM
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
MEDIUM
02Section · Description

Description

_validateOrder gates an order on its quote, expiry, nonce epoch, replay flag and signature:

solidity
if (order.quote != currentQuote) revert QuoteCurrencyMismatch(order.quote, currentQuote);
digest = orderDigest(order);
if (block.timestamp > order.expiry) revert OrderExpired(digest, order.expiry);
if (order.nonce != nonces[order.maker]) revert StaleNonce(digest, order.nonce, nonces[order.maker]);
if (settledOrders[digest]) revert OrderAlreadySettled(digest);

settledOrders[digest] is written only by a completed fill in _finalize, and the only maker-callable revocation is incrementNonce, which is all-or-nothing by construction — its own comment describes individual cancellation as off-chain and itself as the backstop.

There is therefore no on-chain expression of a single cancelled order. A maker who cancels one order in the app has performed no state change; their signature remains fully valid until expiry. They will not use the backstop, because it destroys every other order they hold.

This sits against the stated model of the off-chain layer, which is that admission is a convenience and not a boundary because the contract re-checks everything. Cancellation is the one thing the contract does not re-check, so for this property the off-chain book is the only enforcement.

Submission requires RELAYER_ROLE, so an honest relayer following its own records will not present a cancelled order. What the contract does not have is any way to refuse one if the relayer's records are wrong, its key is compromised, or a stale cache is replayed — and the relayer can also supply the counterparty, since SelfTrade only compares the two makers' addresses.

Vulnerable Scenario:

  1. A maker signs an ask at 10.00 USDC with a 30-day expiry. The relayer's infrastructure stores it.
  2. The maker cancels it in the app. nonces[maker] is unchanged; settledOrders[digest] is false; the expiry is weeks away.
  3. The badge appreciates. The signature is still on-chain-valid at the stale price.
  4. The order is submitted against any bid at 10.00 and settles. Every check in _validateOrder passes.
03Section · Impact

Impact

Every order a maker believes they have cancelled remains fillable at its signed price until it expires. The maker loses the difference between that price and the badge's current value, and their only defence is to void their entire open book — which requires them to be online, and which the design assumes they are not. The counterparty and the treasury receive what the maker lost.

04Section · Recommendation

Recommendation

Add a per-order revocation that reuses the existing single-fill mapping, so _validateOrder needs no new check and the epoch bump becomes the rare escape hatch it is documented as:

diff
+error OrderCancelled(bytes32 orderHash);
+
+/// @notice Voids a single order. The on-chain counterpart of an app-side cancel.
+function cancelOrder(PlakxioOrderTypes.Order calldata order) external {
+ if (msg.sender != order.maker) revert InvalidSignature(orderDigest(order), order.maker);
+ settledOrders[orderDigest(order)] = true;
+ emit OrderCancelledEvent(orderDigest(order), msg.sender);
+}

Reusing settledOrders means a cancelled order surfaces as OrderAlreadySettled to any existing consumer, which is already a terminal state they handle. If distinguishing the two matters off-chain, use a separate cancelledOrders mapping and its own error instead — the extra SLOAD in _validateOrder is the only cost.

Users pay no gas in this system, so route the call the same way incrementNonce is routed.

05Section · Resolution

Resolution

On-chain cancellation now exists: cancelOrder and a batch cancelOrders, both permissionless to submit with the maker's signature as the authority, alongside a maximum order lifetime. Keeping submission permissionless is the right trade — role-gating it would let a compromised relayer key destroy every open order, and gasless makers cannot send transactions at all. Verified at audit-v2.

06Section · Affected files

Affected files

  • src/PlakxioSettlement.sol#L605-L618 and src/PlakxioSettlement.sol#L284-L305 at commit 5c38893
Status
Fixed
F-2026-0007