Originally published on Fone.
A canceled authorization review should never open a wallet prompt a moment later. That can happen when a page waits for simulation or an account check, the user cancels, and the old asynchronous function resumes with values it captured before cancellation.
This lab builds a small asynchronous controller that reproduces that race and prevents the stale prompt. The result is a Python program with deterministic tests, including cancellation during the final wallet read. It uses fake callbacks and contains no signing keys, RPC calls or transactions. Download the complete runnable lab.
The engineering question comes from Agent Wallet Guard's Owner authorization flow. A source review on 29 October 2026 found a generation check after simulation and another after the final wallet checks, immediately before the transaction path. Its historical local acceptance report also records a cancellation fix. Here we isolate that narrow behavior so you can inspect the timing without running the application stack.
Run the race locally
Use Python 3.13.3, the version used for this lab's verification. There are no packages to install. Extract the ZIP, open its directory and run the following commands.
python3 preflight.py python3 -m unittest -v The demo pauses simulation using an event, cancels the review, then releases simulation. Its output reports stale, zero wallet prompts and the idle phase. No sleep duration determines the result. The test controls exactly when the delayed work is allowed to finish.
That detail matters during debugging. A test that waits ten milliseconds and hopes cancellation wins may pass on one machine and fail on another. An event gives the test a specific observation point. The simulated preflight has started, its result has not returned, and cancellation happens inside that interval.
Capture what the Owner actually reviewed
The controller uses a frozen context containing an Owner label, chain ID, deployment label and Session label. These are synthetic identifiers. An integration would use the actual account and contract context required by its authorization protocol.
@dataclass(frozen=True) class Context: owner: str chain: int deployment: str session: str A chain ID alone cannot describe the entire review. Two deployments can use the same local chain ID, while a page can switch to another Session without changing the connected account. Comparing all four fields lets the exercise detect those separate changes.
Each review also receives an increasing generation number. Canceling a review advances that number. Old work can still finish, but it no longer owns the right to open a prompt or reset the current interface. This distinction lets the page begin a new review while an older request is still returning.
The number is local control state. It does not travel to a contract and does not grant spending permission. Agent Wallet Guard's actual Session validation remains a separate part of execution. The existing spending limit article explains that other boundary.
Check again after the last wait
The following method is the complete prompt gate from the downloadable controller. simulate, read_wallet and prompt are injected callbacks, which lets the test stop each stage independently.
async def confirm(self, ticket, simulate, read_wallet, prompt): generation, reviewed = ticket if generation != self.generation or self.phase != "review": return "stale" self.phase = "preflight" try: await simulate(reviewed) if generation != self.generation: return "stale" current = await read_wallet() if generation != self.generation: return "stale" if current != reviewed or self.context != reviewed: self.phase = "idle" return "context_changed" # There is no await between the final guard and entering wallet phase. self.phase = "wallet" await prompt(reviewed) self.phase = "submitted" return "submitted" except Exception: if generation == self.generation: # Once invoked, a failed provider response may hide a broadcast. self.phase = "uncertain" if self.phase == "wallet" else "idle" raise The first check rejects a duplicate confirm call as soon as another call enters preflight. The next check runs after simulation. A further check runs after reading the wallet, because cancellation could have happened during that read too. Passing the earlier check gives no guarantee about later suspended work.
Immediately before invoking the prompt, the code compares the returned context with the reviewed context and the controller's current context. It then moves into the wallet phase without another wait between the final guard and that transition. Within this single asyncio event loop, another task cannot slip a cancellation into that synchronous segment.
This is a control-flow guarantee inside the teaching model. A browser wallet and a chain remain separate systems. A real integration must also handle provider account changes and validate the actual transaction. The small controller helps reviewers locate one race; it cannot prove every race in those other systems is absent.
Test the latest possible cancellation
The most revealing test pauses the final wallet read. Simulation has already passed, which catches an implementation that checks cancellation only after simulation.
async def test_cancel_during_last_wallet_read_blocks_prompt(self): entered, release = asyncio.Event(), asyncio.Event() async def slow(): entered.set() await release.wait() return self.context task = asyncio.create_task(self.run_ticket(self.flow.review(), wallet=slow)) await entered.wait() self.flow.cancel() release.set() self.assertEqual(await task, "stale") self.assertEqual(self.prompts, []) Run this test alone while changing the controller. If you remove the generation check after read_wallet, the old continuation can incorrectly open a prompt when the returned context still matches. The test expects zero prompts and therefore fails on the unsafe behavior.
Another test delays an old simulation until a fresh review exists, then makes the old simulation fail. The exception handler checks the generation before changing the phase. Without that condition, a late error from the abandoned attempt could reset the new review to idle. This case often gets missed when cancellation is tested only on successful requests.
The suite also changes each context field independently, submits the same ticket twice, and checks a preflight exception. Ten named tests passed from a fresh extraction of the archive. These are tests of the teaching controller; the product's application and contract suites were not rerun for this article.
Decide where cancellation stops
Once the prompt callback starts, cancel returns false. That is a deliberate boundary in this exercise. Dismissing a page-level review cannot undo an already invoked external operation.
The same distinction controls error handling. A simulation error returns the controller to idle. An error from the prompt leaves it in uncertain, where a new review is blocked. A provider response could have been lost after a transaction was submitted, so this model requires a separate recovery decision before another attempt. It deliberately has no button that silently retries an uncertain send.
Agent Wallet Guard has a broader recovery flow that distinguishes chain confirmation from API synchronization. The authorization recovery article covers those records. Keep that recovery concern separate when porting this lab. A generation counter belongs to the currently mounted interaction; it cannot replace a durable transaction record across a refresh.
Turn the race into a handover demonstration
For a team adding paid actions to an Agent product, this gives a concrete review script. Start an authorization, delay preflight, cancel it, and prove that the wallet stays closed after the delayed response arrives. Repeat during the last account read, then switch the account or Session and check that a new review is required.
Agent Wallet Guard provides the Owner workspace, independent signing components and Session authorization path that motivate this exercise. Its recorded demonstration uses local chain 1337 and MockUSDC. Actual Agent services, target networks and wallet combinations require their own integration and acceptance work. Bring the cancellation test to that walkthrough and ask to see the exact point where an abandoned review loses permission to prompt.
Prepared with AI assistance. The source wording and code are preserved.


