October 19, 2026
A Nonce Is Not a Boundary: Separating Replay, Cancellation and Domain Scope
An unused nonce can belong to a permission that was cancelled yesterday — or to a permission issued for a different contract. “Not used…

By Mohammad Khezer
4 min read
An unused nonce can belong to a permission that was cancelled yesterday — or to a permission issued for a different contract. "Not used before" answers only one part of the authorization question.
Consider a permit that authorizes an operation once. A reviewer sees a nonce check and concludes that replay is handled. But replay is not a single global property. The same message may reach a second consumer with its own empty nonce store. A wallet may cancel all previously issued permits. An application may change the meaning of an operation while retaining the same identifier. A local consumed-nonce set cannot describe all those boundaries.
The useful specification has three questions: where does the authority apply, which generation of authority remains valid, and has this particular authorization already been consumed?
Start with the policy, not the data structure
EIP-712 provides structured signing and domain context; it explicitly does not supply application replay protection. Domain fields can distinguish a chain, verifying contract and version. Separately, ERC-1271 allows a contract wallet's validation to depend on context, including state. Neither standard decides what cancellation should mean for every application.
That decision is consequential. Some systems intentionally create a durable commitment when they accept an order. Others merely queue an instruction whose authority must still exist at execution. A generation check is appropriate only if bulk cancellation is part of the intended policy. Adding it to an irrevocable commitment system could violate the promise that acceptance made.
The examples here choose a narrow policy: a trusted owner can cancel all outstanding permits in the current generation. Future permits use a new generation. No old generation can become current again.
A generation is a cancellation boundary
In Lab 09, a permit contains an owner, epoch and nonce. Grants are trusted fixture entries, not signatures. An epoch is simply a monotonically increasing integer associated with the owner.
The negative control advances that integer when cancellation is requested, but execution only checks whether the permit was once granted. Updating cancellation state is ineffective when the settlement path never consults it.
The corrected rule is compact:
if permit not in grants or permit in used:
reject()
if permit.epoch != current_epoch[permit.owner]:
reject()
used.add(permit)if permit not in grants or permit in used:
reject()
if permit.epoch != current_epoch[permit.owner]:
reject()
used.add(permit)This is an explanatory sketch; the executable model and its input assumptions are linked below. Crucially, rejection happens before consumption. An obsolete permit should not burn a newly issued permit that happens to reuse the same nonce number.
Take Alice's nonce 7 in epoch 0. Alice cancels all outstanding permits, moving to epoch 1. She then grants nonce 7 again. The new permit can execute once. The old one remains invalid. The replay key includes the generation, so the two permits are different authorizations even though their displayed nonce is identical.
That choice has a trade-off: nonce reuse is allowed across generations. An application that needs globally unique operation identifiers should retain a separate global identifier rule. An epoch is not a substitute for deciding what an operation's identity means.
Cancellation must have a scope
The lab cancels Alice's permits without cancelling Bob's. This is easy to miss when a first implementation stores one global epoch. A global value may be intentional for emergency shutdown, but it is a very different authority from per-owner cancellation.
The trusted cancellation method also matters. A public method that lets anyone advance Alice's epoch would turn cancellation into denial of service. The model assumes authenticated governance of grants and epochs; it does not implement access control. This is a boundary to test in the real adapter, not a claim that the local fixture proves it.
Monotonicity is another assumption. Python integers do not wrap. A bounded on-chain counter needs explicit behavior at its maximum. Resetting an epoch to a prior value is especially dangerous if old signed permits remain available: the system can accidentally make a historical generation current again.
A domain is an execution-context boundary
Lab 10 asks a different question. Its authority fixture grants an exact pair: a domain and an order. The domain has a chain identifier, verifier identifier and schema identifier. The order has recipient, amount and nonce.
The vulnerable consumer searches for any grant matching the order, regardless of domain. A permit intended for chain 1, vault A, payment schema v1 is then accepted by a separate consumer for chain 2, vault B, schema v2. Its nonce store is empty, so local replay prevention offers no resistance.
The corrected consumer requires the entire domain and order to match. The test changes one domain dimension at a time. Testing only a case where all three fields change would establish rejection, but would not show that each boundary is independently enforced.
The positive counterpart matters too. If an owner deliberately grants the same payload in two domains, both domains may execute it. Rejecting that outcome would impose a global single-use rule that the policy never promised. Security testing must preserve intended behavior as well as reject unintended behavior.
Rejection state is part of the result
An exception is not the complete assertion. After a wrong-domain or old-generation attempt, the test must inspect consumed identifiers and recipient credits. Otherwise, a rejecting request might still prevent a later legitimate operation from succeeding.
Likewise, a valid domain does not permit a second execution in the same consumer. Lab 10 grants a second, different order using an already consumed nonce and checks that it cannot settle. Domain binding and nonce consumption work together; neither replaces the other.
Likewise, a valid domain does not permit a second execution in the same consumer. Lab 10 grants a second, different order using an already consumed nonce and checks that it cannot settle. Domain binding and nonce consumption work together; neither replaces the other.
What was actually executed
On October 15, 2026, Lab 09 passed 7 tests, and Lab 10 passed 7 tests under pinned CPython 3.12.14. Every suite ran in a separate local process with standard-library dependencies only. The complete collection passed 137 tests locally, and hosted CI succeeded for commit 7aec1fad, run 38708110272.
Read Lab 09 and Lab 10. Each includes an exercise and a separate solution.
No cryptographic signature, EIP-712 encoding, ERC-1271 call, chain fork, transaction rollback, or concurrent executor was tested. These models use equality and trusted grants to isolate policy errors. They are not a deployed patch or proof of an integration's security. No incident date, affected contract, transaction reference, or loss is asserted because this is an evergreen lesson rather than an incident report.
For a real review, write down the intended answers to all three questions — context, cancellation generation and consumption — then construct a failing test for each boundary. A nonce can enforce single use within its scope. The specification must define that scope first.