Back to Blog 
Why
An ERC-20 payment can fail even when the sending contract has enough tokens. It can also appear to succeed without moving tokens. In a third variant, tokens move successfully but the protocol records the wrong amount.
These are not one bug with three names. They are three separate proof obligations:
- Authority: did the contract choose the transfer operation that matches who owns the tokens and who is spending them?
- Call success: did the token call complete according to the token's actual return behavior?
- Accounting: did the balance change match the amount the protocol credits, mints, settles, or releases?
The distinction matters because a fix for one layer does not prove the other two. Replacing
transferFrom with transfer can correct an authority mismatch while leaving unchecked return values untouched. Adding SafeERC20 can normalize return handling while preserving a wrong owner-spender model. Checking both can still over-credit a fee-on-transfer deposit if the protocol trusts the requested amount rather than the received balance delta.This article builds a narrow comparative model around those three layers. The anchor is the public Bastion Wallet subscription finding described in the Bastion case study. The comparison then uses the ERC-20 specification, OpenZeppelin's implementation guidance, and public audit records for PoolTogether, ENS, and Tigris.[1][2][4][5][6]
Methodology and claim boundaries
A record entered the comparison if its public source described a mismatch at one of three boundaries:
- token custody versus the selected transfer primitive;
- EVM call completion versus ERC-20 success semantics; or
- the requested transfer amount versus the protocol's resulting accounting.
This is a mechanism-led, purposive sample. It is not a prevalence study. The records use different judging systems, token assumptions, and protocol contexts. No frequency, industry loss, or relative severity claim follows from the sample.
Four labels are used throughout:
- Source fact: a behavior, label, status, or recommendation stated in the linked source.
- Zealynx classification: our grouping of a record by which proof obligation failed.
- Inference: a reusable engineering conclusion derived across records.
- Limitation: something the public material does not establish, such as production exposure, deployment of a fix, exploitation, or monetary impact.
The model: prove authority, success, and accounting
A protocol-level token movement is correct only when all three statements hold:
1correct transfer2 = authorized movement3 AND confirmed token-call success4 AND protocol accounting matched to actual movement
The layers are sequential, but not interchangeable.
Proof 1: authority matches custody
ERC-20 defines
transfer(to, value) as moving tokens from the message caller's balance. It defines transferFrom(from, to, value) as a delegated withdrawal workflow and says the from account should have deliberately authorized the caller.[1]That gives a simple decision rule:
1If address(this) owns the tokens:2 token.transfer(recipient, amount)34If another address owns the tokens and this contract is the approved spender:5 token.transferFrom(owner, recipient, amount)
OpenZeppelin's implementation makes the distinction explicit.
transfer moves value from the caller. transferFrom checks and spends the caller's allowance over from before moving the tokens.[2]The common abstraction mistake is to read both functions as synonyms for "send ERC-20." They are not. The function selects an authorization model.
Proof 2: call completion means token success
The ERC-20 specification warns callers to handle
false and not assume it is never returned.[1] A low-level call can therefore complete without a revert while the ERC-20 operation reports failure.OpenZeppelin's
SafeERC20 utility addresses two compatibility cases: operations that return false, and tokens that return no value but do not revert. Its wrappers revert when an operation returns a value other than true, while treating a non-reverting call with no return data as successful.[2]This is a call-semantics proof. It does not decide whether
transfer or transferFrom was the correct authority model.Proof 3: accounting follows observed movement
Even a correctly authorized call that reports success does not prove an exact balance delta. Token implementations can impose transfer fees or other balance behavior. If a protocol issues shares, credit, debt repayment, or a receipt based on the input parameter, it needs an explicit assumption that the received amount equals that parameter.
The stronger invariant is:
1credited amount <= recipient balance after - recipient balance before
Whether equality is required depends on the product. A vault may reject tokens with non-exact transfer semantics. Another protocol may accept them and credit only what arrived. The dangerous state is accidental support, where the interface appears generic but the accounting silently assumes exact movement.
Bastion: enough balance, wrong authority model
The Bastion Wallet review covered subscription contracts in an ERC-4337-oriented account abstraction system. The public report records that
SubExecutor held the subscriber's ERC-20 funds and checked its own balance before settlement.[3]The reviewed path then called:
1token.transferFrom(address(this), sub.initiator, sub.amount);
The source address and the message caller were both
SubExecutor. Yet transferFrom introduced an allowance proof between those roles. The report's fuzz test reached ERC20: insufficient allowance, despite the contract holding enough tokens.[3]Zealynx classifies this as an authority-custody mismatch:
1actual owner: SubExecutor2actual caller: SubExecutor3selected model: delegated spender4missing proof: allowance from SubExecutor to SubExecutor
The report recommended
transfer(sub.initiator, sub.amount), and records the team response as acknowledged and fixed.[3] The case study traces the complete custody and execution path.The source does not establish that the affected code was deployed, that a user encountered the failure, or that the recorded fix reached production. The currently visible public repositories also do not expose the historical reviewed subscription code for independent fix verification.[3]
PoolTogether: call returned, transfer did not succeed
A public Code4rena finding for PoolTogether described unchecked return values from
transfer and transferFrom in two yield-source contracts. The finding used BADGER as an example of a token that could return false when a transfer failed rather than reverting.[4]The discussion attached to the issue disputed impact and likelihood, and a judge described the issue as Medium Risk after considering those constraints.[4] That judging context should not be flattened into a universal severity claim.
Zealynx classifies the mechanism as a call-success mismatch. The selected primitive may be appropriate, but the calling contract treats non-reversion as proof of transfer:
1// Unsafe conclusion: the external call did not revert,2// therefore the token moved.3token.transfer(recipient, amount);4_accountAsPaid(amount);
ERC-20 does not support that conclusion. The standard explicitly requires handling
false.[1]This record differs from Bastion in an important way. Bastion selected the wrong authorization primitive and reverted under the tested token. PoolTogether's reported issue concerned a token call that could return normally with a failure value. One fails before settlement because an unnecessary permission is required. The other can let local execution continue after settlement failed.
ENS: false-returning transfer and synthetic credit diverge
A public Code4rena ENS finding reported that unchecked
transferFrom results could let an ERC-1155 representation update even when the underlying ERC-20 transfer returned false. Its proof of concept used a mock token whose first transferFrom returned false, then showed the synthetic balance updating without the corresponding ERC-20 movement.[5]The issue carries a Medium Risk label, a judge-downgrade label, and duplicate-related metadata in the public record.[5] Those labels are source facts, not a Zealynx reassessment.
Zealynx classifies this as a call-success mismatch that becomes an accounting divergence:
1underlying movement reported failure2+ caller ignored boolean result3+ representation accounting continued4= claim can diverge from backing
This is useful because it connects the second and third proofs. The direct defect is unchecked success semantics. The protocol consequence arises only because a later accounting effect assumes that the transfer happened.
The public finding describes a proof of concept and a potential path. This article does not claim a realized exploit, production loss, or unresolved deployment.
Tigris: one interface, two compatibility failures
A public Tigris contest submission described raw
IERC20.transfer and transferFrom calls in a stablecoin vault. The submitter identified two behaviors: a token can return false, which the caller might ignore, or omit the expected return value, which can make an ABI-decoded interface call revert.[6]The issue history matters. The judge ultimately marked that submission out of scope.[6] It is therefore not evidence of an accepted in-scope vulnerability. It remains a public technical example of the two compatibility assumptions that
SafeERC20 is designed to normalize.Zealynx classifies it as support-policy ambiguity rather than as evidence of protocol loss. If a protocol claims to support arbitrary ERC-20 assets, raw interface calls can make that support depend on return-data conventions. If the protocol supports only a tightly controlled token list, the relevant task is to document and test those token-specific assumptions.
The reusable lesson is not "every token must be supported." It is:
Generic interface claims require generic behavior tests. Restricted support requires an explicit allowlist and explicit assumptions.
Comparison matrix
| Record | Source-reported behavior | Zealynx classification | What the record does not prove |
|---|---|---|---|
| Bastion H-02 | Holding contract used transferFrom on its own balance; report test reverted for insufficient allowance; recorded fixed [3] | Authority-custody mismatch | Deployment, user impact, or independently verified production fix |
| PoolTogether #112 | Return values from transfer and transferFrom were unchecked; judging discussion addressed severity [4] | Call-success mismatch | A universal outcome for every supported token |
| ENS #313 | A false-returning transferFrom could be followed by ERC-1155 accounting updates [5] | Success mismatch causing accounting divergence | Realized exploitation or production loss |
| Tigris #6 | Submission described false-returning and no-return token compatibility; judged out of scope [6] | Support-policy ambiguity | An accepted contest finding or confirmed protocol impact |
| ERC-20 specification | Callers must handle false; transferFrom is a delegated withdrawal workflow [1] | Normative baseline | Exact behavior of every deployed token |
| OpenZeppelin ERC20 and SafeERC20 | transferFrom spends allowance; SafeERC20 normalizes optional returns [2] | Reference implementation pattern | Correct protocol accounting after the call |
The records do not have equal severity or evidentiary weight. The table compares mechanisms, not outcomes.
Why SafeERC20 is necessary but not sufficient
Using
safeTransfer and safeTransferFrom is a strong default because it removes a class of return-value mistakes. It does not solve the full transfer problem.It cannot choose the owner-spender model
This remains wrong if the contract owns the tokens:
1asset.safeTransferFrom(address(this), recipient, amount);
The wrapper can faithfully execute the wrong primitive. If the token enforces allowance from the holding contract to itself, the call can revert just as Bastion's reviewed path did.
It cannot define the credited amount
This can remain wrong for a token whose recipient receives less than
amount:Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →1asset.safeTransferFrom(msg.sender, address(this), amount);2shares[msg.sender] += amount;
SafeERC20 proves that the token call met its accepted return convention. It does not prove that address(this) received exactly amount.It cannot define token support
A protocol still needs a policy for rebasing tokens, fee-on-transfer tokens, tokens with hooks, paused tokens, blocklists, upgradeable token logic, and other behaviors. No wrapper can infer the economic assumptions of the surrounding system.
A transfer proof table for design reviews
Before reviewing code, write the expected movement as a table:
| Question | Deposit or pull | Withdrawal or payout |
|---|---|---|
| Who owns tokens before the call? | usually the user | usually the protocol contract |
| Who calls the token? | protocol contract | protocol contract |
| Which primitive matches? | safeTransferFrom(user, protocol, amount) | safeTransfer(recipient, amount) |
| What proves call success? | SafeERC20 or explicit checked return | SafeERC20 or explicit checked return |
| What amount is credited? | observed receipt or explicitly supported exact amount | observed sender and recipient deltas where required |
| What token behavior is allowed? | documented allowlist and assumptions | same policy, including hooks and fees |
"Usually" is deliberate. Routers, smart accounts, vaults, callbacks, and permit systems can change the caller and owner. The table forces those roles to be named instead of inferred from a function called
deposit or pay.Testing each proof independently
A single happy-path test with a conventional mock token is weak evidence. Build adversarial tokens or mocks that isolate each assumption.
Authority test
- Fund the protocol contract directly.
- Leave self-allowance at zero.
- Execute the payout.
- Assert that the protocol balance falls and the recipient balance rises.
This catches Bastion's reviewed pattern because a direct owner payout should not need self-approval.
False-return test
Use a token that returns
false without reverting. Assert that the complete protocol operation reverts and that no shares, claims, receipts, or payment markers change.1function transferFrom(address, address, uint256)2 external3 returns (bool)4{5 return false;6}
No-return test
Use a token whose transfer function returns no data. Decide whether it is supported. If yes, verify the wrapper accepts a non-reverting call and the expected balance movement occurs. If no, verify admission controls reject it before accounting begins.
Balance-delta test
Use a token that deducts
amount from the sender but credits less to the recipient. Assert either:- the protocol rejects the token or transfer; or
- protocol credit equals the observed receipt.
This is where a balance delta becomes a security assertion rather than a bookkeeping convenience.
Atomicity test
For every failure mode, verify all adjacent state rolls back. A failed token movement must not leave payment timestamps, minted shares, delegated balances, debt reductions, or completed-order flags behind.
Property testing can express the core rule:
1If protocol credit increases by C,2then authenticated token assets received must increase by at least C,3unless a documented conversion rule explains the difference.
The fuzzing glossary entry provides the testing context. The property should vary callers, balances, allowances, return behavior, transfer fees, and ordering.
Review checklist
- Name the owner, caller, spender, and recipient. Do this at the exact token-call boundary.
- Select the primitive from those roles. Use direct transfer for the caller's tokens and delegated transfer for another owner's tokens.
- Use a return-safe wrapper. Do not treat non-reversion alone as ERC-20 success.
- Define supported token behavior. Generic ERC-20 support is a test matrix, not an interface import.
- Account from observed movement when exact transfer is not guaranteed. Measure before and after balances.
- Keep token movement and protocol accounting atomic. Do not update claims after an unverified call.
- Test self-custodied payouts with zero self-allowance. This isolates authority-custody confusion.
- Test
false, no return data, and a revert. They are separate call outcomes. - Test fee-bearing movement. Confirm whether the protocol rejects or correctly accounts for it.
- Retain remediation tests. A one-line primitive change deserves a permanent regression test.
Limitations
This comparison is intentionally narrow. It does not cover allowance front-running, permit signature validation, ERC-777 hooks, callback reentrancy, token blocklists, rebasing supply, or upgrade risk in depth.
The public audit records also differ in authority. Bastion's canonical report records a reviewed finding and team response.[3] Code4rena issues preserve submissions and judging metadata, including disputes, downgrades, duplicates, and out-of-scope decisions.[4][5][6] Their inclusion here establishes a documented mechanism, not equal validation or severity.
No source in this corpus establishes prevalence. The article does not claim that every raw ERC-20 call is exploitable, every non-standard token should be supported, or every accounting mismatch caused financial loss.
Conclusion: a token call is not a settlement proof
The lasting lesson from Bastion is not merely "use
transfer instead of transferFrom." It is that token settlement has several independent boundaries.Bastion shows a custody-authority mismatch: the contract owned the tokens but selected a delegated transfer.[3] PoolTogether's public finding shows why non-reversion is not enough when a token can return
false.[4] ENS connects an ignored failure result to synthetic accounting that can diverge from underlying movement.[5] Tigris illustrates why claimed token compatibility needs a deliberate return-data policy, even though that submission was judged out of scope.[6]The compact rule is:
Prove who may move the tokens, prove the token reported success, then prove protocol accounting matches what actually moved.
Anything less is a token call. It is not yet evidence of settlement.
Sources
[1] https://eips.ethereum.org/EIPS/eip-20 - ERC-20 Token Standard
[2] https://docs.openzeppelin.com/contracts/5.x/api/token/ERC20 - OpenZeppelin Contracts 5.x ERC20 and SafeERC20 documentation
[3] https://raw.githubusercontent.com/ZealynxSecurity/audits/main/web3/2024-03-zealynx-shieldify-bastion-wallet.pdf - Bastion Wallet Security Review
[4] https://github.com/code-423n4/2021-06-pooltogether-findings/issues/112 - PoolTogether finding #112
[5] https://github.com/code-423n4/2023-10-ens-findings/issues/313 - ENS finding #313
[6] https://github.com/code-423n4/2022-12-tigris-findings/issues/6 - Tigris submission #6
FAQ
1. When should a contract use transfer instead of transferFrom?
Use
transfer when the calling contract owns the tokens being sent. Use transferFrom when the calling contract is spending tokens owned by another address under an allowance. Confirm the roles at execution time rather than choosing from the business label of the operation.2. Does SafeERC20 fix every ERC-20 transfer issue?
No. SafeERC20 normalizes return-value behavior, including tokens that return no value. It does not choose the correct owner-spender model, define supported token economics, or prove that the received amount equals the requested amount.
3. Why check balance deltas after a successful transfer?
A successful call proves accepted execution semantics, not necessarily exact movement. A balance delta can show what the recipient actually received, which matters when the protocol creates shares or credit from that receipt.
4. Is self-approval required before a contract transfers its own tokens?
Not for a standard direct
transfer. Self-approval appears only if the contract unnecessarily selects the delegated transferFrom path for tokens it already owns.5. What is the minimum useful ERC-20 integration test matrix?
Test a standard successful token, a false-returning token, a no-return token if supported, a reverting token, and a fee-bearing token. For every case, assert token balance deltas and all protocol-side accounting.
Glossary
| Term | Definition |
|---|---|
| Allowance | ERC-20 authorization that permits a spender to move an owner's tokens through transferFrom. |
| Balance Delta | The measured difference between an account's balance before and after an operation. |
| Fuzzing | Automated testing across generated inputs and state combinations to find violated properties. |
| Pull Payment | A payment design in which an authorized party claims funds rather than receiving an unsolicited push. |
| Trust Boundary | A boundary where data or control crosses between components with different assumptions. |
Working auditors in your corner, all year
Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.
No spam. Unsubscribe anytime.
Building a protocol? Start with a three-day pre-audit, credited toward your audit →