Most token buyers check the price chart, the liquidity pool, and maybe the holder count. Developers building on top of a token contract — integrations, DEX aggregators, wallets — need to go deeper. A token that looks fine on the surface can hide transfer restrictions, infinite mint authority, or upgradeable logic that silently changes all the rules after you’ve integrated.
This guide breaks down the ERC-20 standard from a security auditor’s perspective: what each function does, how it can be weaponized, and what to look for when reading verified source code.
BLUF: Six areas to audit in any ERC-20 contract — (1) transfer/transferFrom logic for hidden restrictions, (2) approve/allowance for phising-style approval drains, (3) mint/burn authority and who holds it, (4) proxy upgradeability that can replace all logic, (5) event emission consistency, and (6) interface compliance. Run automated checks with the token risk API alongside manual review.
1. Transfer and TransferFrom: Where Honeypots Hide
The core of any ERC-20 token is the _transfer internal function. In a standard implementation, it moves tokens between addresses with no conditions beyond balance checks. Any deviation from this pattern is where scams live.
Safe Pattern
function _transfer(address from, address to, uint256 amount) internal {
require(balanceOf[from] >= amount, "Insufficient balance");
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
}
Dangerous Patterns
Honeypot — sells blocked:
function _transfer(address from, address to, uint256 amount) internal {
require(balanceOf[from] >= amount);
if (to == PAIR_ADDRESS && from != OWNER) {
revert(); // Sells to DEX blocked for everyone except owner
}
balanceOf[from] -= amount;
balanceOf[to] += amount;
}
Selective blocking — blacklist trap:
function _transfer(address from, address to, uint256 amount) internal {
require(!isBlacklisted[from], "Blocked");
// Owner can add anyone to blacklist at any time
balanceOf[from] -= amount;
balanceOf[to] += amount;
}
Cooldown or max-sell restrictions:
function _transfer(address from, address to, uint256 amount) internal {
require(block.timestamp >= lastSell[from] + 1 days, "Cooldown");
require(amount <= maxSellAmount, "Exceeds max sell");
// Legitimate in some community tokens, weaponizable by adjusting parameters
}
What to Look For
| Pattern | Risk | Notes |
|---|---|---|
| Clean transfer, no conditions | Safe | Standard ERC-20 |
Condition checking to == DEX_PAIR | Honeypot risk | Sell blocking |
isBlacklisted or isExcluded mapping | Medium-high | Can be weaponized retroactively |
maxSellAmount or cooldown | Medium | Check if adjustable by owner |
if (from != owner) conditions | High | Owner-only exemptions = privileged escape hatch |
2. Approve and Allowance: The Approval Drain
The approve/allowance mechanism lets a spender move tokens on your behalf. This is how DEX routers, staking contracts, and DEX aggregators work. It’s also one of the most exploited mechanisms in crypto — phishing sites trick users into approving unlimited spending caps on malicious contracts.
Contract-Side Risk
A token contract itself rarely has approval bugs if it follows the standard. The risk is in contracts that the token interacts with. But check for:
approvewith non-zero value when already non-zero: The ERC-20 standard requires setting to zero first, then to the new value. Some contracts skip this (race condition risk, though rarely exploited in practice).increaseAllowance/decreaseAllowance: These are OpenZeppelin extensions, not part of the original ERC-20 spec. They’re safe but indicate the contract uses extensions — check which library version.permit(EIP-2612): Gasless approvals via signature. Safe in standard implementations, but verify the signature verification logic — a flawedecrecovercan let anyone spend anyone’s tokens.
User-Side Risk
The bigger danger is at the user level. When a user approves a malicious smart contract with unlimited allowance, the contract can drain the entire balance. Tools like Revoke.cash and Etherscan’s token approval checker help users monitor active approvals.
3. Mint and Burn: Supply Control
Who can create or destroy tokens? This is the difference between a fixed-supply store of value and a printable inflation machine.
Safe Configuration
// No mint function at all — fixed supply forever
uint256 public constant MAX_SUPPLY = 1_000_000 * 10**18;
constructor() {
_mint(msg.sender, MAX_SUPPLY);
}
Dangerous Configuration
// Owner can mint unlimited tokens at any time
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
Even if the owner promises “we will never mint,” the capability existing in code means it can be exercised at any moment. Projects that later rugs often have a mint function that was “never going to be used.”
What to Check
| Configuration | Risk |
|---|---|
| No mint function, supply set in constructor | Safest |
| Mint capped at a hard maximum | Low-medium |
| Mint with timelock (governance vote required) | Medium — check timelock delay |
Mint with onlyOwner, no cap | High — unlimited inflation risk |
| Mint callable by anyone | Critical — token is worthless |
Also check for indirect minting: some contracts implement “rebasing” or “reflection” mechanisms that effectively change supply without a explicit mint call. Token rebalances can dilute holders invisibly.
4. Proxy and Upgradeability: The Logic Swap
A proxy pattern lets the contract’s logic be upgraded after deployment. This isn’t inherently malicious — it’s used by major protocols like Uniswap V3 and Compound. But in the hands of an anonymous token team, an upgradeable contract means they can replace the transfer logic with a honeypot after building trust.
How to Detect
- Look at the contract on the block explorer. If the “Contract” tab shows an “Implementation” address, it’s a proxy.
- Read the proxy admin contract. Who can trigger upgrades? Is there a timelock?
- Check if previous implementations have been swapped. How many times? Each upgrade is a point where logic changed.
// TransparentUpgradeableProxy pattern — standard, used by OpenZeppelin
// The admin can call upgradeTo(newImplementation)
// Dangerous: no timelock on upgrade
function upgradeTo(address newImpl) external onlyAdmin {
// Can be called instantly — no warning, no delay
}
Risk Matrix
| Proxy Setup | Risk |
|---|---|
| No proxy (immutable logic) | None — what you read is what runs |
| Proxy with multi-sig + long timelock | Low |
| Proxy with single-owner admin, short timelock | Medium-high |
| Proxy with single-owner admin, no timelock | High — logic can change instantly |
| Hidden proxy (EIP-1967 with misleading naming) | Critical |
5. Event Emission: Audit Trail Integrity
ERC-20 tokens must emit Transfer and Approval events. These events are how block explorers, indexers, and analytics tools track token movement. Missing or inconsistent events create blind spots.
What to Check
Every _transfer call should emit a Transfer event. Every _approve should emit an Approval event. Missing events on some code paths can indicate:
- Hidden transfers: Tokens moved internally without events won’t show up on explorers, making holder distribution analysis unreliable.
- Tax skimming: Some tokens deduct a fee on transfer but emit a
Transferevent for the full amount, hiding where the fee goes. - Double events: Emitting duplicate events to confuse indexers.
This is a subtle check that automated scanners often miss. If you’re building infrastructure on top of a token, verify event consistency by replaying a few transfers against the emitted logs.
6. Interface Compliance: Does It Actually Behave Like ERC-20?
A contract can claim to be ERC-20 but fail to implement the standard correctly. Common deviations:
decimals()returns a non-standard value (not 18). Exchanges and integrations that assume 18 decimals will calculate wrong amounts.totalSupply()is dynamic (changes with rebasing or minting). Integration code that cachestotalSupplywill display incorrect percentages.- Transfer returns
falseinstead of reverting on failure. The ERC-20 standard allows both behaviors, but most integrations expect reverts. Afalsereturn can silently fail. name()andsymbol()are empty or misleading. Not a security issue, but a sign of low-quality contracts.
Run the token address through our token risk API to automatically verify interface compliance alongside all the checks above — honeypot detection, tax analysis, holder concentration, and liquidity status. One call covers the full audit surface.
Putting It All Together
For a quick manual review, focus on three things: read _transfer for any conditions beyond balance checks, check if supply is truly fixed, and verify there’s no proxy. If all three are clean, the contract clears the basic security bar for integration. For a deeper audit, walk through each section above and cross-reference with automated on-chain analysis tools.