Our synthetic token transfer sends 95 units to a market and puts five units into a fee inventory. Processing that inventory is a separate operation that can call external code. Treating the transfer and the processing as one harmless balance update hides the point where execution leaves the token contract.
The independent JavaScript fixture models collection and processing as separate state transitions. It uses invented quantities and no AMM addresses. It does not implement ERC20 or reproduce an existing token's fee policy and its callback is supplied directly by the test.
Name the behavior added by the hook
An ordinary token transfer moves value between balances. A fee hook changes that behavior by redirecting part of the value. A processing hook adds an external interaction such as a swap or distribution. These are different effects even if they all begin from one transfer function.
OpenZeppelin Contracts 5 exposes the internal update path used for custom ERC20 behavior. An override must consider minting and burning as well as transfers. It also has to define zero value transfers and exemptions so a special balance change does not accidentally enter a trading path.
function process(executor) {
if (book.processing) throw new Error("processing in progress");
const reserved = book.pending;
book.pending = 0n;
book.processing = true;
try {
executor(reserved);
book.processed += reserved;
} catch (error) {
book.pending += reserved;
throw error;
} finally {
book.processing = false;
}
}The fixture collects fees without performing a swap. A separate process function reserves the pending quantity and blocks another concurrent processing invocation before invoking its executor. That makes the state visible during a callback explicit and keeps an ordinary transfer from depending on the processor's availability.
A separate contract does not erase the call graph
Moving fee logic into another contract changes where the code lives but an external call still transfers control. The processor may call a router that calls a pair that transfers the token again. The nested token invocation then runs while the original operation remains in progress.
A guard that blocks processing reentry should reset its state on both success and failure. It also needs a defined policy for other token entry points during processing. Preventing one recursive function call does not prove that every balance related invariant survives a call into another public function.
We reserve the pending quantity before passing control to the executor because the executor can invoke other reachable code and the next invocation must see an inventory that already reflects the amount committed to the current processing attempt instead of treating those same fee units as available for another independent operation.
The fixture models an atomic processor result by restoring its reserved pending quantity when the executor throws. That restoration covers the model's own variable and does not undo arbitrary external JavaScript effects. An EVM test must verify the transaction behavior of the actual token and router combination.
Failure policy is part of the product behavior
If processing happens during a user transfer then a failed swap can make that transfer fail too. Deferring processing allows transfers to continue while inventory accumulates but requires a separate execution policy. There is no universal choice because the acceptable coupling depends on the token's documented behavior.
Our model chooses deferred processing. A failed attempt preserves the pending inventory for a later retry. The ledger records processed inventory only after the executor returns successfully and the test checks that no fee units disappear across the rejection path.
100 transferred: 95 delivered and 5 collected; failed processing retains inventory
11 assertions passedFor an actual AMM swap the minimum received quantity and deadline need meaningful values under a declared pricing policy. A zero minimum removes a price bound. A reentrancy guard does not protect against an economically unfavorable swap because call ordering and execution price are separate concerns.
Conservation comes before distribution policy
The original 100 units equal 95 delivered units plus five collected units. After processing those five move from pending inventory into the processed ledger. We can check that conservation without choosing a revenue recipient or importing a production distribution configuration.
Contract tests should include buys and sells alongside wallet transfers and burns. An exempt sender and an exempt recipient can reach different branches. We also exercise an external executor that rejects and one that invokes another token operation before returning.
Partial processing adds a useful conservation test. Limit one attempt below the pending inventory and verify that it removes exactly the reserved quantity while preserving both the remainder and the accounting of the transfer that originally collected those fees.