EVM patterns
ERC1155 Receiver Security: Batch Accounting and Deposit Evidence
A receive hook can run without adding new custody. ERC1155 receiver security requires trusted-token checks, per-ID batch accounting and an explicit rule for when a transfer creates deposit credit.

Key facts
- Original bounded experiment
- 9324 Python ledger cases from 259 ordered vectors, 9 balance pairs and two binary axes. No EVM execution
- Self-transfer boundary
- 786 successful modeled hooks carry a positive payload with zero net custody change
- Repeated-ID comparison
- 222 of 627 successful distinct-account hooks differ under lossy last-write accounting
- Dispatch limit
- Pinned OpenZeppelin v5.4.0 chooses the single hook for singleton arrays and the batch hook for other lengths
Reproduce a receipt without a new deposit
Start with a receiver that already holds a token. Transfer that token back to the same receiver through an authorized route. The callback can report a positive amount even though custody has not increased.
This is a constructed accounting check, not a reported exploit. It requires an authorized path that can move the receiver's existing holdings back to itself. It does not show that an arbitrary external account can move those holdings. The property to inspect is narrower: does the application create a new deposit entitlement solely because its receive hook was called?
ERC1155 receiver security includes the application policy that turns a multi-token transfer into an accepted deposit. The token contract updates balances and requests interface acceptance. Your receiver decides which token, token ID and account may receive application credit. A correct acceptance response cannot make those decisions for it.
Use the ERC1155 standard as the interface baseline, then inspect the actual implementation. In the pinned OpenZeppelin Contracts v5.4.0 source, a batch call containing one item reaches the single receiver hook. A batch with equal empty arrays can reach the batch hook without moving tokens. Method names alone are an inadequate dispatch oracle.
- Minimal self-transfer control
- Start with balance
1for token ID1. An authorized self-transfer of1leaves that balance at1after subtraction and addition. - Deposit requirement
- If credit represents newly received custody, its evidence must exclude a mere circulation of the receiver's existing inventory.
- Different application policy
- An internal reassignment can intentionally change ownership records without adding custody. Name that operation and authorize it separately.
Record the expected state before testing the callback. The test is useful only when a successful ordinary deposit remains possible beside the rejected or noncrediting self-transfer. Disabling all receive hooks can prevent the symptom while breaking the product.
The neighboring ERC1363 callback guide separates receipt from spending permission. Here the distinction is between a transfer payload and a per-ID custody change. Neither a callback count nor the sum of array values establishes that change by itself.
An unsolicited balance increase also needs a rule. A generic holder can accept a transfer without creating a customer deposit, whereas a custodial product may require a pending request that identifies the beneficiary. Choose whether the application supports direct pushes, initiated collection or both. A hook should not silently switch between those workflows because one happens to deliver valid token arguments.
Keep the token address in the asset key. An ID supplied by an unrelated contract does not become the accepted asset merely because its integer value matches an allowed ID. The configured token and its reviewed behavior remain part of every deposit decision.
Original bounded ledger experiment
Method
We executed a Python model of the pinned balance-update loop and receiver-dispatch conditions. The experiment enumerates every combination inside a declared finite domain. It does not compile Solidity or execute an EVM. A successful receiver response and an authorized caller are assumptions, so the experiment cannot establish that either defense works.
- Primary evidence
- OpenZeppelin Contracts
v5.4.0implementation and receiver utilities, fetched on . Complete responses and source hashes retained. - Vector population
- Lengths
0through3. Each position chooses token ID1or2and amount0,1or2. This gives259ordered vectors. - State and routing axes
- Each source balance independently ranges from
0through2, giving9initial pairs. Distinct recipients start empty. Cross distinct versus identical endpoints with absent versus present recipient code. - Denominator
259vectors multiplied by9balance pairs and both binary axes gives9324cases. No cases inside this grid were sampled or excluded.- Comparison rule
- Compare per-ID accumulated amounts with a deliberately lossy last-write map. Separately count successful modeled hooks with a positive payload and zero net custody change.
- Reproduction
seo/research/erc1155-receiver-security-2026-10-30/measurement.py,measurement.jsonandcases.csvretain the model and complete ledger. These repository artifacts are not public downloads.
Results
| Endpoint relationship | Cases | Successful paths | Modeled hooks | Positive payload, zero net | Last-write differences |
|---|---|---|---|---|---|
| Distinct accounts | 4662 | 1254 | 627 | 0 | 222 |
| Self-transfer | 4662 | 1842 | 921 | 786 | Not compared |
The 786 positive-payload, zero-net cases are self-transfer hooks in this model. They are not observed false credits in a deployed application. Among the 627 successful distinct-account hooks, 222 produce a different per-ID amount under last-write storage than under accumulation. The comparison identifies a lossy accounting policy, not a defect in the token library.
Limits
- The domain is deliberately small. Its counts change when the input ranges change and must never be presented as deployment frequencies.
- Only equal-length arrays, nonzero endpoints and the pinned update rules are modeled. Authorization checks, minting and burning remain outside the grid.
- Callbacks do not execute application code. Reentry, custom token overrides, overflow and transaction gas were not tested.
- Failed-path rollback is represented by restoring the model's starting state. That is an arithmetic boundary, not an executed EVM rollback result.
Use the ledger to choose cases for the real integration suite. Preserve the exact inputs and expected per-ID balances when translating a model row into a contract test.
Account for repeated IDs in input order
An ordered list is not a unique-key map. The pinned update loop processes each position against the balance left by earlier positions. It contains no duplicate-ID rejection. The standard requires balance changes and events to follow input-array order. A receiver must not silently discard earlier entries when building its own accounting record.
Take a distinct-account transfer with IDs [1,1] and values [1,1], starting from source balance 2 for that ID. The model subtracts and adds each entry, leaving the receiver with 2. A map assignment that retains only the final value records 1. This exact case is present in the retained ledger.
A receiver that permits duplicates can aggregate amounts by ID before making an application credit decision. Retain the original arrays for interpreting the token operation, however. Normalization for accounting is not permission to rewrite the transaction's observed order or to claim that the source rejected inputs it actually accepts.
Self-transfers make that distinction visible. With existing balance 1, the repeated values [1,1] can both pass because each subtraction is followed by addition to the same balance slot. Requiring an initial balance equal to their sum would describe a different eligibility rule. For a new-custody deposit, the relevant result remains zero net change.
- Identify the asset by token address and ID.
- Build a per-ID accounting view without overwriting earlier amounts. Decide explicitly whether repeated IDs are supported or rejected by the application.
- Compare that view with the intended custody and entitlement transition. A self-transfer or internal reassignment needs its own policy rather than the ordinary deposit shortcut.
- Test the repeated-ID case beside a distinct-ID control and an insufficient-balance case. Verify the expected reason for rejection, not merely that some call reverted.
Amounts for different token IDs need not represent interchangeable assets. Adding the integers can help classify whether every net change is zero in this nonnegative model. It supplies no exchange rate or economic valuation. A release test should retain the per-ID vector rather than reduce collateral accounting to one unpriced total.
Balance snapshots also need a defined observation boundary. If the receiver forwards tokens during its hook, a later balance can reflect both incoming and outgoing transfers. Compare state at the point your invariant actually covers, and keep any outgoing liability separate. The finite experiment deliberately excludes forwarding, so it cannot certify a snapshot strategy for that case.
Authenticate both receiver hooks
Implement the acceptance policy for both hooks. In the pinned source, array length determines their selection after the update. A test suite that exercises only onERC1155BatchReceived because its caller uses safeBatchTransferFrom can miss the singleton path entirely.
| Array length | Cases | Successful paths | Single hooks | Batch hooks |
|---|---|---|---|---|
| 0 | 36 | 36 | 0 | 18 |
| 1 | 216 | 144 | 72 | 0 |
| 2 | 1296 | 576 | 0 | 288 |
| 3 | 7776 | 2340 | 0 | 1170 |
Equal empty arrays pass this implementation's length check. The loop performs no balance updates, then the batch acceptance route is selected for a code-bearing destination. The receiver may reject empty application deposits or accept them without creating credit. Either decision should be deliberate. Do not infer positive custody from successful hook delivery.
At the receiving contract, authenticate the immediate caller as the configured token. Treat operator as the initiating actor and from as the source account under the reviewed token's rules. Another contract can call a public hook with invented arguments. Matching their shape does not establish a real transfer.
The expected single response is 0xf23a6e61. Batch acceptance instead expects 0xbc197c81. The pinned utilities compare these selectors and reject a mismatching response. They invoke the receiver only when the destination currently has code. That code-presence observation is not a permanent classification of an address or a proof of its application policy.
ERC1155Holder supplies permissive receiver responses. Its base callbacks do not implement a deposit allowlist, beneficiary binding or accounting rules. Inheriting it solves an acceptance-interface task. The source separately warns that an inheriting application needs a way to use its received tokens or they can become stuck.
Do not confuse approval of an operator with approval of a deposit beneficiary. The public transfer path checks whether the initiating sender is the source or an approved operator. That permits a token movement under the token's rules. The receiver still needs its own decision about whose application position changes. Exercise a control in which operator and source differ, with a deliberately different proposed beneficiary.
Keep custom data handling consistent across the hooks. Its format is application-defined, so validate the fields that select a beneficiary or order before using them. Empty data can be supported for a generic holder while being unsuitable for a deposit system that requires an identified account.
Finish local state before external execution
The external receiver call occurs after token balances and transfer events have been updated in the pinned path. A token-side extension must account for that interaction boundary. OpenZeppelin discourages overriding _updateWithAcceptanceCheck for state changes after the check and points extensions toward _update instead.
The receiving application has its own boundary. Once it invokes another contract, unfinished application credit can become observable or usable through a neighboring function. Our reentrancy analysis explains that broader problem. Review the functions sharing the same entitlement state, not only whether one receive hook has a guard.
- Token phase
- Trace the ordered balance updates and the hook chosen for the actual array length.
- Receiver policy
- Verify the trusted asset and authorized beneficiary, preserving repeated-ID accounting and the intended custody condition.
- Application interaction
- Inspect what another entrypoint can do while a credit, withdrawal or forwarded transfer remains incomplete.
The standard allows a receiver to forward accepted tokens into a new transfer context. That behavior is useful, but it adds another execution path. Include the final destination and the originating receiver's obligations in the test. A forwarding design must not be assessed as if every accepted token remained in place.
Minting needs a separate acceptance case. The pinned mint helpers use the zero address as the source and can invoke the receiver through the same acceptance wrapper. A rule that attributes every receipt to a previous holder cannot be copied unchanged onto a mint. Define who may cause the application to recognize issued assets and who receives any resulting entitlement. Minting is outside the experiment, so this is a proposed integration check.
A rejecting callback should unwind the enclosing transfer under the reviewed implementation's revert behavior. The utility maps an empty revert reason to an invalid-receiver error and propagates nonempty revert data. Assert the resulting application and token state in real contract tests. A selector comparison in source is insufficient evidence that your complete rollback scenario was exercised.
Build the release acceptance ledger
Make the acceptance record specific enough that another engineer can replay it. Pair each input with the expected token balances, application credit and hook. A transaction success flag alone leaves the deposit property unresolved.
| Case | Required assertion | What the control rules out |
|---|---|---|
| Ordinary distinct-account deposit | Per-ID custody and authorized credit follow the configured policy | A receiver that simply rejects every transfer |
| Repeated ID | Earlier amounts survive normalization or the explicit duplicate policy rejects the input | Last-write undercount |
| Authorized self-transfer | No new-custody credit from existing inventory alone | Payload-driven entitlement without an incoming balance change |
| Empty and singleton batch | Expected hook and empty-deposit policy are both enforced | Dispatch inferred solely from the public method name |
| Wrong token or wrong response | Correct authorization or acceptance failure and unchanged final state | A revert caused by an unrelated insufficient balance |
| Forwarding or nested application call | Intermediate entitlement cannot fund an unauthorized additional effect | A green isolated hook test mistaken for complete integration coverage |
Put malformed arrays outside the successful-deposit controls. The pinned token rejects unequal ID and value lengths before processing balances. A receiver test intended to demonstrate token authentication can accidentally stop at that earlier condition, never reaching the hook. Use a well-formed transfer for that negative control and retain a separately named malformed-array test.
Test partial batch failure as well. Give an early entry sufficient balance and a later entry insufficient balance, then inspect the final state after rejection. Your assertion must cover the earlier token and application effects. The model restores its initial state on failure, but a real integration test is needed to validate the actual contract path and receiver behavior.
Version the policy along with the code. Changing an allowlist, enabling a new mint route or accepting previously rejected empty data can change deposit semantics without changing the receiver selectors. Review those configuration changes against the same acceptance ledger rather than treating interface compatibility as a complete release check.
Preserve the token revision, application revision and configured token addresses beside that record. If an upgrade changes the update path or hook policy, replay the cases against the new combination. The pinned dispatch behavior should never be assumed for every ERC1155 implementation.
Include withdrawal and recovery authority in the review scope. Accepting a token and being able to release it under the intended conditions are separate capabilities. The security provider directory and Pharos Production member profile provide starting points for an engagement that names the receiver, its credit ledger and its exit paths.
The release criterion is observable: accepted deposits satisfy the declared custody-to-credit rule, while the selected empty, repeated-ID and self-transfer controls behave as specified. Keep unknown results visible. A Python ledger row helps select the test. The deployed implementation and its retained execution evidence decide whether it passes.
Frequently asked questions
Does balanceOfBatch execute a deposit or call a receiver hook?
It is a view query over account and token-ID pairs. In the pinned implementation it reads balanceOf for each pair after checking equal array lengths. Query success does not execute a transfer or create an application deposit.
Does ERC1155Holder also implement the ERC721 receiver interface?
The pinned holder implements the ERC1155 receiver hooks and interface discovery. It does not supply onERC721Received. A multi-standard receiving application needs the appropriate separate interfaces and policies.
Does revoking operator approval remove tokens already held by a receiver?
No. The pinned approval setter updates the operator-permission mapping and emits ApprovalForAll. It does not move balances. Releasing tokens already held by a receiver requires the application to expose an authorized transfer path.