Handling ERC-20 tokens means writing and interacting with smart contracts that move, approve, and account for fungible assets safely and predictably. In plain terms, you need secure patterns to transfer tokens, set allowances, read balances, and integrate with other protocols without breaking user expectations. This guide distills the most reliable ways to work with ERC-20 tokens in production-grade code. Brief disclaimer: token decisions can affect money; treat this as general engineering guidance—consult qualified professionals for legal, tax, and financial matters. In one sentence: ERC-20 defines a consistent interface for fungible tokens—transfer, approve, transferFrom, events—that other contracts can rely on.
At a glance, here’s the sequence most teams follow: (1) call tokens safely, (2) manage allowances correctly, (3) use pull patterns where appropriate, (4) handle decimals, (5) guard against reentrancy, (6) support permit signatures, (7) account for fee-on-transfer tokens, (8) handle rebasing assets, (9) implement robust math and accounting, (10) test thoroughly, (11) optimize gas sensibly, and (12) integrate responsibly with routers and bridges.
1. Call ERC-20s Safely with SafeERC20
Calling third-party token contracts directly can fail in subtle ways: some tokens return false instead of reverting, others omit return values entirely, and a few have non-standard behaviors on transfer. The safest baseline is to wrap interactions with OpenZeppelin’s SafeERC20 helpers, which standardize call results and bubble up failures. In practice, you import SafeERC20, use safeTransfer/safeTransferFrom/safeApprove, and let the library handle missing return values or boolean returns. This improves reliability when your contract handles multiple, arbitrary tokens, and it reduces the chance of silent failures. Remember: SafeERC20 protects your interactions with someone else’s token; it doesn’t magically “fix” a broken token. When in doubt, add explicit balance checks before and after transfers for extra assurance, especially with exotic tokens.
How to do it
- Import using SafeERC20 for IERC20; and replace raw calls with safe* equivalents.
- Prefer safeIncreaseAllowance/safeDecreaseAllowance over setting allowances directly (see Item 2).
- Log critical steps with events so you can debug integration issues quickly.
Common mistakes
- Assuming every ERC-20 returns true.
- Ignoring return data entirely—then shipping code that never reverts on failed transfers.
- Forgetting to handle tokens that take fees (see Item 7).
Tie-back: by standardizing calls with SafeERC20, you reduce variance across tokens and prevent a class of “it worked on test token X” surprises in production.
2. Manage Approvals without the Race Condition
The basic allowance flow (approve → transferFrom) has a well-known race: changing a non-zero allowance to another non-zero value can be front-run so that both the old and new allowances are used. The standard’s own notes and major libraries recommend either resetting to 0 before setting the new amount or using increaseAllowance/decreaseAllowance helpers that adjust deltas safely. In user-facing flows, pair this with UI prompts that discourage unlimited approvals unless truly necessary. For better UX and fewer transactions, consider signature-based approvals via permit (Item 6).
Numbers & guardrails
- Allowance change: If Alice has allowance = 100 and wants to change to 50 via approve(spender, 50), a malicious spender can front-run with transferFrom(100) and then still end up with 50 once the new approval lands—total 150.
- Safer pattern: approve(spender, 0) → wait mined → approve(spender, 50); or call increaseAllowance(spender, delta)/decreaseAllowance.
Mini-checklist
- Use increaseAllowance/decreaseAllowance where possible.
- If setting explicit values, reset to 0 first and wait for confirmation.
- Add expiry or permit-based flows to tighten approval scope (see Item 6).
Tie-back: correct allowance flows prevent accidental over-spends and user loss while keeping your dApp compatible with the ERC-20 standard.
3. Prefer “Pull” over “Push” When the Caller Supplies Tokens
When your contract needs user tokens, it’s safer to design around pulling funds—letting your contract call transferFrom after the user has given permission—rather than pushing tokens directly into a contract. Pull patterns let your contract control ordering, write state before calling out, and revert cleanly if balances or slippage checks fail. They also minimize “stuck token” situations where a user accidentally transfers tokens to a contract without a recovery function. Combine pull patterns with the Checks-Effects-Interactions (CEI) structure to update state before interacting with external contracts and to reduce reentrancy risk (see Item 5). OpenZeppelin Docs
How to do it
- Ask users to approve your contract (or use permit) for the exact required amount.
- In your function: check preconditions → update internal accounting → interact via safeTransferFrom.
- Emit clear events: Deposit(user, token, amount).
Common mistakes
- Assuming users will “send first, then call” a function.
- Writing logic that requires funds to arrive before state is set, creating inconsistent states if transfers are partial or fee-adjusted.
- Not providing a token recovery method for accidental transfers (with appropriate access controls).
Tie-back: pull patterns give your contract control over funds flow and sequencing, which simplifies reasoning about success/failure and improves safety.
4. Handle decimals and Unit Conversions Explicitly
Most ERC-20s use 18 decimal places, but not all do. Treat displayed “human units” as UI concerns and do all math in base units (the smallest indivisible unit). Query decimals() where available (via IERC20Metadata) and avoid baking in 18 as an invariant across your codebase. For cross-token math (e.g., fees, percentage splits), scale consistently and document assumptions. When a token lacks decimals(), treat it as an optional extension and let the UI fall back to 18 by convention—not the contract logic. OpenZeppelin’s docs note the 18-decimals default in their implementation, but your integration should still read the value when possible.
Quick table (units at a glance)
| Concept | Meaning | Example |
|---|---|---|
| Base units | Smallest unit stored on-chain | 1.5 GLD with decimals=18 → 1_500_000_000_000_000_000 |
| Human units | UI display amount | Show 1.5 to users |
| Scaling | Multiply/divide by 10**decimals | human * 10**d = base and base / 10**d = human |
Numbers & guardrails
- Display: For a token with decimals=6, 0.5 human unit equals 500_000 base units; with decimals=18, it’s 500_000_000_000_000_000.
- Rule: Do arithmetic in base units; convert at boundaries (input/output).
- Note: IERC20Metadata defines decimals() but it’s optional at the standard level; don’t hard-require it on unknown tokens. OpenZeppelin Docs
Tie-back: careful unit handling prevents overflow/underflow surprises and user-visible rounding errors, keeping balances precise across diverse tokens.
5. Structure Functions with CEI and Use Reentrancy Guards
Any ERC-20 transfer call is an external interaction that might trigger unexpected code paths (even if uncommon). Arrange functions as Checks → Effects → Interactions: validate inputs and state first, update your own storage second, and only then call out to token contracts or other protocols. For functions that receive tokens and then perform additional calls (e.g., deposit-and-stake), add nonReentrant to block call-stack tricks. Use this defensively around stateful flows, not everywhere by default. CEI plus ReentrancyGuard remains the most widely taught baseline for EVM safety.
Numbers & guardrails
- One SSTORE saved: Writing your state before interacting avoids writing it twice on failure paths.
- Failure behavior: If the external call reverts, your earlier storage writes are also reverted, restoring pre-call state atomically.
- Pattern: require(…) → state = new → token.safeTransferFrom(…). fravoll.github.io
Common mistakes
- External calls hidden inside modifiers (harder to audit).
- Updating state after a token transfer, which makes partial effects possible on malicious tokens.
- Forgetting to emit events on state changes.
Tie-back: CEI makes your intent auditable and predictable; ReentrancyGuard adds a belt to CEI’s suspenders for token-heavy flows.
6. Support permit (EIP-2612) and Typed Data (EIP-712)
permit lets users grant allowances by signing a message, so your contract can transferFrom without a separate on-chain approve. That means fewer transactions and better UX, especially when users don’t hold native gas. To implement it correctly, verify the domain separator, nonces, and deadlines; reject expired signatures; and ensure you’re hashing structures per EIP-712. Libraries like OpenZeppelin’s ERC20Permit encapsulate these details, and developer tools include helpers to test typed data signatures end-to-end.
Numbers & guardrails
- Deadlines: Require a timestamp deadline and revert if block.timestamp > deadline to prevent replay.
- Nonces: Every signature should consume exactly one nonce; always compare and increment.
- Typed data digest: keccak256(“\x19\x01” || domainSeparator || structHash)—test with your framework’s EIP-712 utilities.
Tools/Examples
- OpenZeppelin ERC20Permit for tokens you control.
- Foundry cheatcodes and guides for generating/verifying EIP-712 signatures in tests. getfoundry.sh
Tie-back: permit shrinks user friction and gas costs while keeping allowances explicit and bounded by signatures.
7. Handle Fee-on-Transfer and Deflationary Tokens with Balance Deltas
Some tokens deduct a fee during transfer—your contract requests amount, but receives a bit less. If you assume full receipt, accounting breaks and users can get unfair outcomes. The safest pattern is to measure before and after balances and compute the delta as the actual received amount. Then, run logic (e.g., minting shares, fulfilling purchases) using that delta. If a minimum is required, compare the delta to the minimum and revert on shortfall. This pattern also helps with fee-sharing or burn mechanisms embedded in a token’s _transfer.
Mini case
- User intends to deposit 1_000 base units.
- Token takes a 2% fee; contract balance increases by 980.
- Your logic should use 980 as the received amount; revert if user required ≥ 990 (slippage/min-amount).
How to do it
- uint256 pre = token.balanceOf(address(this));
- token.safeTransferFrom(msg.sender, address(this), amount);
- uint256 received = token.balanceOf(address(this)) – pre;
- Use received for subsequent calculations.
Tie-back: balance-delta accounting keeps your protocol accurate across deflationary and fee-bearing tokens you don’t control.
8. Treat Rebasing and Elastic-Supply Tokens as Shares, Not Fixed Units
Rebasing tokens adjust balances algorithmically; an account’s balance can change without transfers. If you mint “1:1” claims based on a snapshot number, a later rebase can make your accounting wrong. The robust approach is to track shares (your internal accounting unit) and map shares↔balance using the token’s current scaling factor at the moment of interaction. If you can’t support elastic balances safely, consider whitelisting only non-rebasing assets. Explain this to users up front to avoid confusion.
Numbers & guardrails
- If the token supply expands by 10%, a holder with 1,000 base units becomes 1,100 without any transfer; if your vault minted shares for 1,000, you must decide whether shares represent proportional ownership, not fixed units.
- For deposits, compute shares = received * 1e18 / exchangeRate, where exchangeRate reflects the current rebase factor.
Common mistakes
- Storing raw balances as immutable truths.
- Assuming balanceOf deltas match transfers for rebasing assets.
- Mixing rebasing and non-rebasing logic in the same pool without clear normalization.
Tie-back: share-based accounting preserves proportional fairness when token balances move under your feet.
9. Get Math and Accounting Right (Checked Math, Casting, Rounding)
Solidity since 0.8 reverts on over/underflow by default, but you still need to guard casts and rounding. Use SafeCast for down-casts, avoid accumulating large sums in smaller types, and round in a documented direction (e.g., “round down on division”). For percentage fees, prefer multiply-then-divide with checked ordering to avoid truncation surprises. When looping over user positions, avoid unbounded iteration on chain; use mappings or batched reads. Where performance is critical, use unchecked blocks only when you can prove bounds. OpenZeppelin utilities cover most everyday arithmetic and reentrancy concerns.
Mini-checklist
- Use uint256 for balances and totals.
- Prefer a * b / DENOM with overflow checks; document rounding.
- SafeCast.toUint128(…) only after asserting value ≤ type max.
- Keep per-user accounting isolated (no unbounded loops).
Numbers & guardrails
- A 0.3% fee is amount * 3 / 1000.
- Casting to uint128 fails above ~3.4e38; assert bounds before casting.
Tie-back: disciplined math prevents subtle loss of funds and keeps your code friendly to audits and formal checks.
10. Test with Mainnet Forks, Fuzzing, and Invariants
Token integrations fail in the wild when you only test “happy paths.” Combine unit tests with mainnet forking (to interact with real token bytecode), fuzzing (randomized inputs) to find edge cases, and invariant tests to enforce global properties (e.g., “total shares equals underlying minus fees”). Hardhat and Foundry are the dominant toolchains: Hardhat for flexible JS/TS-driven workflows and Foundry for ultra-fast Solidity tests, fuzzing, and invariant frameworks. For permit, add typed-data signature tests to verify your domain separator and hashing.
How to do it
- Fork tests: simulate real ERC-20 behavior with mainnet state.
- Fuzz tests: write properties (e.g., “redeeming after depositing never gives more than deposited minus fees”) and let the fuzzer search inputs.
- Invariants: assert rules like conservation of value across sequences of deposits/withdrawals.
Mini case
- Property: For any amount ∈ [1, 1e24], after deposit(amount) then withdrawAll(), the user’s net tokens ≤ amount (fees excluded) and ≥ amount – maxFee.
- Fuzzer finds an edge input where rounding up mints too many shares → fix by rounding down and documenting precision.
Tie-back: stronger tests surface token-specific quirks before users hit them—and before auditors do.
11. Optimize Gas Without Sacrificing Safety
Token operations are storage-heavy; help users by trimming needless writes and calls. Cache IERC20 addresses and decimals() in memory within a function; avoid reading the same storage repeatedly; minimize event payload size; and group state changes so you do fewer SSTOREs. Use custom errors over require(“strings”) to cut bytecode. Pack small fields into a single storage slot when appropriate, and prefer immutable for constructor-set addresses. Only use unchecked if you’ve proven bounds and documented them. Measure with your toolchain’s gas reporters rather than guessing. (Foundry and Hardhat both make this straightforward.) GitHub
Numbers & guardrails
- SSTORE cost dominates: reducing just one redundant SSTORE per hot path can save users meaningful gas over time.
- Event bytes: trimming unused fields reduces log costs across millions of emits.
- Loop bounds: avoid unbounded iteration over holders; constant-time functions are kinder to gas and DoS-resistant.
Mini-checklist
- Cache decimals() locally (trust, but verify on first call) rather than calling per loop.
- Prefer mapping lookups to arrays for membership checks.
- Use constant/immutable for addresses like routers to save on repeated loads.
Tie-back: small, safe optimizations at scale become real user savings—without eroding correctness.
12. Integrate with Routers and Bridges Carefully (Permit2, Minimal Approvals)
When approving routers (DEXs, spenders, bridges), grant only what you need and prefer time-limited or signature-scoped approvals. Uniswap’s Permit2 abstracts approvals and signature transfers across tokens with consistent semantics and shorter approval lifetimes. If you cannot adopt Permit2, still adopt least-privilege: set tight allowances, clear them after use, and consider batching flow so approvals are consumed immediately. When you must hold a standing approval (e.g., for a vault), monitor and let users revoke from the UI.
How to do it
- Use Permit2’s SignatureTransfer for one-time, signature-scoped transfers; or AllowanceTransfer for expiring allowances.
- On classic routers, approve exactly the amount you’ll spend, then spend it in the same call sequence.
- After bridging, verify token addresses on the target chain—wrapped tokens often differ from canonical ones.
Common mistakes
- Granting infinite approvals forever to third-party routers.
- Not clearing allowances on error paths.
- Ignoring chain-specific wrapped token differences and decimals.
Tie-back: integration hygiene limits blast radius if a downstream protocol is compromised and keeps user risk contained.
Conclusion
ERC-20 handling is about robust interfaces and careful assumptions. You can’t control the behavior of every token your users bring, but you can control how your contracts talk to them. Wrap calls with SafeERC20 and structure flows with CEI; fix allowances with safer patterns or permit; measure actual deltas for fee-on-transfer tokens; treat rebasing assets as shares; test with forks, fuzzing, and invariants; and give external approvals sparingly—ideally scoped by signatures or time. If you apply these 12 practices consistently, you’ll ship token integrations that are safer, clearer to audit, and kinder to your users’ gas and risk budgets. Start small, add guardrails, and iterate behind thorough tests—then ship.
Copy-ready CTA: Ready to harden your token flows? Implement Items 1–3 today, add Item 6 next, and schedule a fuzzing session for Item 10.
FAQs
1) What is the ERC-20 “race condition” with approvals?
It’s the risk that changing an existing non-zero allowance to another non-zero value lets a spender front-run and consume the old allowance before the new one takes effect. Mitigations include resetting to 0 before setting the new value, using increaseAllowance/decreaseAllowance, or switching to permit so approvals are tightly scoped and time-boxed. OpenZeppelin Docs
2) Should my contract assume tokens have 18 decimals?
No. While many implementations default to 18, decimals() is an optional extension. Do math in base units and read decimals() when available. Let the UI handle human-unit conversions. If a token lacks decimals(), choose a sensible display default outside the contract.
3) Why does SafeERC20 matter if my tokens are standard?
Because you’ll eventually meet tokens that are almost standard—returning false instead of reverting or omitting return values. SafeERC20 smooths those differences so failures revert consistently and your logic doesn’t proceed on false assumptions.
4) How do I support gasless approvals?
Implement permit (EIP-2612) or integrate with a token that already supports it. Verify domain separator, nonce, and deadline, then transferFrom using the freshly granted allowance. Test typed-data hashing thoroughly with your framework’s EIP-712 utilities.
5) How do I handle tokens that take a fee on transfer?
Measure the contract’s token balance before and after the transfer and use the delta as the canonical received amount. If your logic requires an exact minimum, revert when the delta is below that threshold. This pattern handles deflationary or fee-sharing tokens safely. Medium
6) What changes with rebasing tokens?
Balances can change without transfers. Don’t promise fixed units; track shares (proportional ownership) and convert at interaction time. If your protocol can’t support elastic balances safely, whitelist non-rebasing assets only. Chiliz
7) Do I need both CEI and ReentrancyGuard?
CEI is the baseline structure; ReentrancyGuard is a pragmatic extra layer on complex stateful flows. Use both for deposit/withdraw or multi-call paths that touch external contracts.
8) How much should I approve for DEX or bridge integrations?
Grant only what you need and consider approvals that expire automatically. Uniswap’s Permit2 supports one-time signature transfers and time-bounded allowances, reducing risk compared to unlimited approvals.
9) What tests catch the most token integration bugs?
Mainnet-fork tests (real token bytecode), fuzz tests (randomized inputs across ranges), and invariants (global properties that must always hold). These detect rounding mistakes, unexpected token behaviors, and broken assumptions.
10) Is it okay to cast balances to smaller integer types for storage?
Only after explicit bounds checks. Use SafeCast and document the maximum supported value. Casting incorrectly can revert or, worse, truncate silently if you later change compiler settings. OpenZeppelin Docs
References
- ERC-20: Token Standard, Ethereum Improvement Proposals, 2015 — https://eips.ethereum.org/EIPS/eip-20
- ERC-2612: Permit Extension for EIP-20, Ethereum Improvement Proposals, 2020 — https://eips.ethereum.org/EIPS/eip-2612
- EIP-712: Typed Structured Data Hashing and Signing, Ethereum Improvement Proposals, 2017 — https://eips.ethereum.org/EIPS/eip-712
- ERC-20 (Guide & API), OpenZeppelin Contracts, n.d. — https://docs.openzeppelin.com/contracts/5.x/erc20
- SafeERC20 and ERC-20 APIs, OpenZeppelin Contracts (API Reference), n.d. — https://docs.openzeppelin.com/contracts/5.x/api/token/ERC20
- Security Considerations (CEI, Reentrancy), Solidity Documentation, n.d. — https://docs.soliditylang.org/en/latest/security-considerations.html
- Reentrancy — Smart Contract Best Practices, ConsenSys Diligence, n.d. — https://consensysdiligence.github.io/smart-contract-best-practices/attacks/reentrancy/
- Permit2 Overview & References, Uniswap Docs, n.d. — https://docs.uniswap.org/contracts/permit2/overview
- Foundry: Fuzz Testing (Forge), Foundry Docs, n.d. — https://getfoundry.sh/forge/fuzz-testing
- Hardhat: Getting Started & Testing, Hardhat Docs, n.d. — https://hardhat.org/docs/getting-started
