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?

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
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.
ITEM GET
The view is a promise
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
Step 1. The question. Read the batch path and ask: does each entry see the debit of the previous one?
- The question Read the batch path and ask: does each entry see the debit of the previous one?
- Yes Protected by construction, but still run the test: a self-transfer can disagree with the view.
- 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.
- Not sure Write the test handler first (stage 4) and let it find out.
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
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.
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 roleFollow the engineering notes
Get new articles on production AI and smart contracts by email.