SKIP TO CONTENT
ETHEREUM STANDARDS6 minStandards That Ship / Ep. 9 · Stage 9-3

Batch Transfer Bugs: Two Rules That Catch Them

A batch went through that the token’s own check refused. Which two rules would have caught it before anyone spotted the bug?

XIN
Pixel-art night scene of a vault checkpoint. A red lamp at the gate refuses one large crate, while a conveyor belt carries two identical smaller crates through the same gate under a green lamp, and a padlocked shelf in the vault behind stands empty.

IN 30 SECONDS

An invariant is a rule that must still hold after every call. Two catch this bug: execution never allows what canTransfer refused, and a holder’s own transfers never dig into frozen tokens.

KEY TAKEAWAYS · 3

  • Rule one: if a transfer executes, canTransfer said yes to each token id’s total, not to each entry.
  • Rule two: a holder’s own transfers never push a balance below its frozen amount, if it started at or above it.
  • Point both rules at every path that moves several amounts: batches, operators, self-transfers, routers and permissionless burns.
On this page

ERCs PR #2045, open as of 7 Oct 2026: check its current state.

Which invariant proves the check matches the transfer?

IN SHORT Two rules, checked after every call: execution never allows what canTransfer refused, and a holder’s own transfers never dig into frozen tokens.

WORDS TO KNOW

Invariant
A rule that must stay true after every call to a contract, whatever order the calls come in.
canTransfer
A read-only check, or view, that answers yes or no: would this transfer be allowed?
Operator
An account the holder has allowed to move its tokens on its behalf.
Forced transfer
A transfer made by an authorized role without the holder’s approval.

IN PLAIN WORDS

A unit test for this exact case is easy once you know the bug. I want a rule that would have caught it before anyone knew: an Invariant A rule that must stay true after every call to a contract, whatever order the calls come in. See the glossary, true after every call.
THE TWO RULES
The check matches the transfer
If a batch executes, canTransfer, asked by the same caller before the call, said yes to each id’s total, every entry for that id added up
Execution may be stricter than the view (it can still fail on balance), never looser
Frozen tokens stay put
After every holder transfer, balance ≥ frozen for each id, if that held at the start
A freeze above the balance is allowed, so the general form is: holder transfers never widen the gap
For holder transfers. Forced transfers and privileged burns are out of scope: the standard lets them reduce the frozen amount first, so they need their own rule.

The first rule is general. ERC-7943 states it outright for public transfers, naming the canTransfer A read-only check, or view, that answers yes or no: would this transfer be allowed? See the glossary view:

MUST NOT succeed in cases where canTransfer would return false.

ERC-7943

ITEM GET

The view is a promise

If canTransfer says no, the transfer must fail too. It may refuse more than the view, never allow more.

The second rule is the one the freeze exists for: a holder can only send what sits above the frozen amount. The first one depends on the total per id: ask the view about each entry on its own, and that rule passes on the buggy code.

Where else the view and the execution can drift

IN SHORT Any token with a check-first view and a separate transfer path can drift, so ask one question of every function that moves several amounts.

STEP BY STEP

Decide: Does each entry see the debit of the previous one?Decision tree: Does each entry see the debit of the previous one? If Yes: it loops over a full single transfer, then Frozen tokens protected by construction. If No: check every entry, then debit, then Add up per id; a check after the debit can back it up. If Not sure from reading the code, then Write the test handler first.DECIDEDoes each entry see the debitof the previous one?If yes: it loops over afull single transferFrozen tokensprotected byconstructionStill run the test:self-transfers can disagreewith the viewIf no: check every entry,then debitAdd up per id; acheck after the debitcan back it upAlso check burns andfrozen-amount updates inthe loopIf not sure from readingthe codeWrite the testhandler firstFew ids, a flag checkedagainst the view

Step 1. The question. Read the batch path and ask: does each entry see the debit of the previous one?

Pick a step to see it move
  1. The question Read the batch path and ask: does each entry see the debit of the previous one?
  2. Yes Protected by construction, but still run the test: a self-transfer can disagree with the view.
  3. No Add up per id. A check after the debit, run before the receiver hook, can back that up, but it misses self-transfers. Check burns in the same loop too.
  4. Not sure Write the test handler first (stage 4) and let it find out.
The one question I’d ask of every such function.

The pattern isn’t specific to ERC-1155. For comparison, the T-REX implementation of ERC-3643 has batchTransfer loop over transfer(). So each entry is checked against a balance the previous one already debited.

WATCH OUT

ERC-3643’s canTransfer lives on its compliance contract. That separate contract checks the rules, directly or through modules. It isn’t the same function as ERC-7943’s.

Beyond batches, I’d point the same two rules at Operator An account the holder has allowed to move its tokens on its behalf. See the glossary transfers (allowed through setApprovalForAll). Also at self-transfers, and at any wrapper or router that calls the token in a loop. Add permissionless burns: the standard says they MUST NOT burn more than the unfrozen amount.

WATCH OUT

Privileged paths get a different rule, because they may dip into frozen tokens. A Forced transfer A transfer made by an authorized role without the holder’s approval. See the glossary or privileged burn that does so must update the frozen amount to match (the reference contract lowers it by the excess), and emit Frozen before the transfer event.

The issue’s extra findings, like a forced transfer where from equals to, belong here. Hiring someone for this kind of contract? Ask for this test in the audit-ready brief.

Sources: ERC-7943 (Final), ERCs issue #1814, T-REX Token.sol, ERC-3643.

FAQ

Two rules: if a batch goes through, canTransfer said yes to each token id’s total before the call. And a holder’s own transfers never push a balance below its frozen amount, if it started at or above it. Forced transfers and privileged burns need their own rule.

No. The bug is in the reference implementation under assets/erc-7943 in the ERCs repository. The normative text already says frozen tokens cannot be transferred by the account, and that canTransfer must check the amount against the unfrozen amount.

STAGE CLEAR

What you now know

  • The two rules: nothing executes that the view refused, and no holder transfer digs into frozen tokens.
  • Why the first rule must ask about each id’s total, not each entry.
  • Which other paths need the rules, and why privileged paths get their own.

Check yourself

Tap or click a question to see the answer.

A router moves a holder’s tokens by calling the token in a loop. Do the two rules still apply to it?

Yes. Any path that moves the holder’s tokens should pass both, and a loop is exactly where the view and the execution can drift.

Next stage:How do you write a Foundry test that checks both rules on random batches?6 min
Adam Boudjemaa
Adam Boudjemaa

Former CTO of Integra. Named author (1 of 5) of ERC-3643, first author of ERC-6960, co-author of ERC-7410, and co-author of ERC-8203, which is still a draft. Building production AI and regulated Web3 systems.

Have a project to ship or a role to fill?

Discuss your project or role
Follow the engineering notes

Get new articles on production AI and smart contracts by email.