We built a small callback model around a withdrawal worth 100 units and gave the simulated pool a balance of 1000 units. In this fixture, the remaining 900 units belong to other depositors. Moving one state update changes the recipient payment from 100 units to 200 units while the original claim stays exactly the same.
This is an independent JavaScript accounting example using integer arithmetic. Its job is to make one ordering failure visible, with every intermediate value available for inspection. The model represents a recipient callback directly so we can inspect the intermediate state without a chain deployment or any client configuration.
The recipient sees the intermediate state
We start by reading the claim and sending the payment before clearing that claim. During the simulated transfer, the recipient calls the withdrawal function again. The second invocation reads the same 100 unit claim because the first invocation has yet to clear it.
const amount = claim;
if (amount === 0n) return;
balance -= amount;
paid += amount;
recipientCallback();
claim = 0n;Eventually, the first invocation returns and clears the claim as expected. A test that checks only the final claim sees zero and passes (a very polite accounting error). A test that checks the total payment sees 200 units leaving against a single 100 unit entitlement and catches the accounting error.
We can picture a coat check where the attendant returns a coat before collecting the ticket. The ticket remains available for another request during that gap. In the model, the claim plays that role and the recipient callback supplies the second request before the original invocation completes.
Move the update across the boundary
We run the same callback after clearing the claim before the simulated transfer. The recipient still calls back, but the second invocation reads zero and returns without a payment. The pool retains 900 units and the recipient receives the intended 100 units.
const amount = claim;
if (amount === 0n) return;
claim = 0n;
balance -= amount;
paid += amount;
recipientCallback();We assert the payment and the remaining pool balance, and we also compare the pool assets against the claims that remain outstanding because checking a single caller balance after a successful return misses the moment when another caller can observe state that still authorizes a payment.
The late update leaves 800 units backing 900 units of remaining claims. The early update leaves 900 units backing those same 900 units. Those are six assertions across the two executions and they expose the deficit without needing a price feed or a token valuation.
Keep the model boundary visible
Solidity documents the same control transfer issue in its security guidance and describes the checks and effects and interactions pattern. The example here isolates callback ordering. It does not simulate gas or transaction rollback or token behavior and it makes no claim about the security of a deployed contract.
For a contract review, we carry the accounting assertion into the actual contract tests and exercise the relevant external calls. We include a reverting recipient and multiple depositors and token implementations whose transfer semantics affect balances. Each of those cases tests a different assumption that this small model deliberately leaves outside its boundary.
The next extension is to make the recipient choose among several entry points during its callback. A withdrawal function can clear its own claim correctly while another function still observes shared state halfway through an update. We want the accounting rule to hold across the whole reachable state transition.