Cancel-all is scoped per settlement contract while the badge is shared, and the deploy script arms both markets by default
The cancel-all epoch is stored per settlement contract while the badge is shared, and the deploy script enables both markets, so cancelling everything through one market leaves orders live on the other.
Description
nonces is per-settlement-contract storage:
/// @notice Per-user order-cancellation epoch; bumping voids all open orders.mapping(address maker => uint256) public nonces;
while the badge, and a maker's understanding of "cancel everything I have open", are shared
across every settlement contract whitelisted for that badge. incrementNonce() on one market
performs no write the other market's _validateOrder can observe, so a maker who cancels
through the app — which pins to the dollar market — has cancelled nothing on the euro book,
after being told the cancellation succeeded.
Signature replay across the two markets is genuinely closed: the EIP-712 domain binds
verifyingContract, and _validateOrder independently requires order.quote to equal the
market's immutable quoteCurrency. Cancellation is the property that does not carry across.
This is not reachable on the frozen deployment, where the euro market is de-authorized — its
badge settlementClassMask is 0 and its RELAYER_ROLE is revoked. What is reported is that
the deployment script arms both whenever the euro market is configured. EURC_ADDRESS is
optional and the script says so, but when it is set — as it was for the frozen deployment —
the script whitelists the second market with CLASS_MASK_ORDINARY and grants it
RELAYER_ROLE in the same run. The de-authorized state is therefore a manual post-deploy
step that nothing in the code reproduces, and a fresh deployment carrying an EURC_ADDRESS
has two live books and one cancel-all that covers one of them.
Impact
A maker who cancels everything through the application has cancelled nothing on the sibling market, after being told the cancellation succeeded. Their orders there remain fillable at their signed prices until expiry. Signature replay across the two markets is separately closed; it is cancellation that does not carry across.
Recommendation
Two independent changes; the second is worth doing whether or not the first is.
Share the epoch through the contract both markets already trust. The badge is the common dependency:
+mapping(address maker => uint256) public orderEpoch;+event OrderEpochIncremented(address indexed maker, uint256 newEpoch);++function incrementOrderEpoch(uint256 newEpoch) external {+ if (newEpoch <= orderEpoch[msg.sender]) revert EpochNotAdvancing(newEpoch);+ orderEpoch[msg.sender] = newEpoch;+ emit OrderEpochIncremented(msg.sender, newEpoch);+}
on PlakxioBadge, with PlakxioSettlement._validateOrder comparing order.nonce against
badge.orderEpoch(order.maker) instead of local storage, and incrementNonce computing the
jump as it does today and forwarding it. One bump then voids a maker's orders on every market
settling that badge.
Deploy the second market de-authorized. Have the script skip the whitelist and the
RELAYER_ROLE grant for any market beyond the primary, so arming it is an explicit later
action rather than the default that must be manually undone.
Resolution
The second market is removed from the deployment scripts rather than defaulted off, so a fresh
deployment can no longer bring up a book that cancel-all does not cover. Verified at
audit-v2.
Affected files
src/PlakxioSettlement.sol#L180-L181andscript/DeployBase.s.solat commit5c38893