Is this token safe? A contract checklist for ERC-20 and SPL tokens
A reproducible method for checking whether an ERC-20 or SPL token can dilute or trap holders: mint, freeze, fee, sell, upgrade and liquidity checks, a worked 99% sell-tax example, and what scanners miss.
- Written by
- Quantix Research, Threat Intelligence
- Published
- Reading time
- 12 minutes
- Category
- DeFi Security
Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Contents
- Six questions, three kinds of evidence
- A model token
- Who can mint
- Who can stop a transfer
- Who can change the fees
- Can holders sell?
- Renounced is a claim, not a property
- Can the code change?
- Effects on integrators
- SPL and Token-2022 mints
- Who owns the liquidity
- Worked example: from 3% to a 99% sell tax
- Risk table
- What the Token Scanner checks, and what it cannot
- Conclusions
- Recommendations
- Bibliography
Whether a token is safe to hold depends on its code and configuration: who can create supply, stop a transfer or change what a sale pays, whether the code can be replaced, and who controls the liquidity. Each has an answer on the chain.
This primer sets out a method for ERC-20 and Solana SPL tokens: each risk pattern with an illustrative contract and how to detect it, a worked example, a risk table, and the limits of automated scanning. It assesses no real token.
Six questions, three kinds of evidence
Six questions cover most of what can go wrong. Who can mint? Who can pause or freeze? Who can change fees, and up to what limit? Can a holder sell? Can the code change? Who owns the liquidity?
Verified source says what the code can do; state, read from storage and view functions, says how it is configured and who holds each power; a simulation says what one trade does, from one address, at one block. Each misses what the others catch, so use all three, in order:
- Identify the object: contract or mint, proxy or not, and the implementation behind a proxy.
- Read the implementation’s verified source; without it, treat every unexplained function as a privilege.
- List every function that changes balances, supply, fees, limits, lists or roles, and its guard.
- Read who passes each guard today, and whether each is a contract or a single key.
- Simulate a buy, then a sell, from a fresh address on a fork.
- Find the pools and who controls their liquidity.
Steps 1, 3 and 4 need only a JSON-RPC endpoint, for example with Foundry’s cast:
# Read-only. $TOKEN, $IMPL, $ADDR and $RPC_URL are placeholders.export ETH_RPC_URL=$RPC_URL # Step 1: a non-zero word in an EIP-1967 slot means the token is a proxy.cast storage $TOKEN 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc # implementationcast storage $TOKEN 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 # admin # Step 3 without source: selectors in the implementation's runtime code.cast selectors $(cast code $IMPL) --resolve # Step 4: who holds power? Code "0x", or an EIP-7702 delegation starting 0xef0100,# means a single externally owned key.cast code $(cast call $TOKEN "owner()(address)")cast call $TOKEN "hasRole(bytes32,address)(bool)" \ 0x0000000000000000000000000000000000000000000000000000000000000000 $ADDR # DEFAULT_ADMIN_ROLEA model token
The contract below is a model written for this article, on OpenZeppelin Contracts 5.x, where every transfer, mint and burn goes through _update. Several levers have legitimate uses; what matters is who controls them, within what limits.
// Simplified, illustrative. Not production code. OpenZeppelin Contracts 5.x.contract ModelTaxToken is ERC20, Ownable { uint256 public constant BUY_TAX_BPS = 300; // fixed 3 % uint256 public constant MAX_SELL_TAX_BPS = 9_900; // the only promise: 99 % uint256 public sellTaxBps = 300; uint256 public maxTxAmount = type(uint256).max; bool public tradingEnabled; address public pair; address public immutable taxWallet; mapping(address => bool) public isBlocked; mapping(address => bool) public isExempt; constructor(address taxWallet_) ERC20("Model token", "MODEL") Ownable(msg.sender) { taxWallet = taxWallet_; isExempt[msg.sender] = true; isExempt[taxWallet_] = true; } function mint(address to, uint256 amount) external onlyOwner { _mint(to, amount); } // no cap function setPair(address p) external onlyOwner { pair = p; } function setSellTax(uint256 bps) external onlyOwner { require(bps <= MAX_SELL_TAX_BPS, "above cap"); sellTaxBps = bps; } function setBlocked(address a, bool b) external onlyOwner { isBlocked[a] = b; } function setMaxTx(uint256 amount) external onlyOwner { maxTxAmount = amount; } // no floor function setTrading(bool on) external onlyOwner { tradingEnabled = on; } // reversible function _update(address from, address to, uint256 value) internal override { if (from != address(0) && to != address(0) && !isExempt[from] && !isExempt[to]) { require(tradingEnabled, "trading off"); require(!isBlocked[from] && !isBlocked[to], "blocked"); require(value <= maxTxAmount, "max tx"); uint256 bps = from == pair ? BUY_TAX_BPS : to == pair ? sellTaxBps : 0; uint256 fee = (value * bps) / 10_000; if (fee > 0) { super._update(from, taxWallet, fee); value -= fee; } } super._update(from, to, value); }}Who can mint
mint gives the owner unlimited supply, and new tokens sold into the pool take value from every holder. In source, find every path to _mint and its guard. In bytecode, a mint selector is a lead, not proof: minting can hide behind any name. In history, EIP-20 says a contract that creates tokens should emit Transfer from 0x0, so such events after launch show minting has happened.
Who can stop a transfer
A blocklist (setBlocked) stops one holder while others trade. A trading switch or pause (setTrading) stops everyone, and here it can be turned off again after launch. A maximum transaction size (setMaxTx) without a floor can be set to one wei, blocking every sale.
In source, find every condition in _update that can revert and trace its variable to the setter and its guard. Note the exemptions: the owner skips every check, so it can always sell. GoPlus reports is_blacklisted, transfer_pausable and anti_whale_modifiable, and warns that a very small anti-whale value can make all trading fail.
Who can change the fees
EIP-20 neither mentions nor prevents transfer fees. The model takes a fixed 3% on buys (tokens leaving the pair) and an owner-set rate on sells (tokens arriving at it). A buy tax is paid at a visible rate; a sell tax is paid at whatever rate is in force at exit. The holder’s only promise is the cap in setSellTax: here, 99%.
Read the cap and confirm it is a constant, then the current rate and any per-address rates. GoPlus reports slippage_modifiable, personal_slippage_modifiable, and measured buy_tax and sell_tax on a 0–1 scale. A measured rate describes today; the cap, the worst case. The fix is to put the bounds in code:
- uint256 public constant MAX_SELL_TAX_BPS = 9_900; // 99 %: the owner sets your exit price+ uint256 public constant MAX_SELL_TAX_BPS = 500; // 5 %: a bound holders can rely on- function setMaxTx(uint256 amount) external onlyOwner { maxTxAmount = amount; }+ function setMaxTx(uint256 amount) external onlyOwner {+ require(amount >= totalSupply() / 200, "below floor"); // never below 0.5 % of supply+ maxTxAmount = amount;+ }Can holders sell?
A honeypot accepts buys and refuses sells, usually through a branch that fires only when tokens move to the pair:
// Simplified, illustrative. Not production code.// Inside _update, for non-exempt addresses only.if (to == pair) { // a sell require(sellsOpen, "sells closed"); // buys never check it require(block.timestamp >= lastBuy[from] + cooldown, "cooldown"); // no maximum length require(value <= balanceOf(from) * maxSellPct / 100, "max sell"); // cannot sell it all}if (from == pair) lastBuy[to] = block.timestamp; // a buy starts the clockEach condition reads state the owner can change after buyers arrive; the last is what GoPlus calls cannot_sell_all. In source, search for logic that differs between from == pair and to == pair. Then simulate: buy and sell from a fresh address at a realistic size, and again after advancing time past any cooldown. A soft honeypot passes this test: the sale succeeds and pays almost nothing.
Renounced is a claim, not a property
renounceOwnership() sets the owner to zero, after which, in OpenZeppelin’s words, tasks protected by onlyOwner “will no longer be callable”. Nothing else changes.
// Simplified, illustrative. Not production code. Constructor and transfer logic omitted.contract RenouncedButNot is ERC20, Ownable { uint256 public sellTaxBps; address private immutable _ops; // never returned by owner() address private _previousOwner; uint256 private _unlockTime; modifier onlyOps() { require(msg.sender == _ops, "not ops"); _; } // Survives renounceOwnership(): guarded by a different check. function setSellTax(uint256 bps) external onlyOps { sellTaxBps = bps; } // "Lock" pattern: owner() reads as zero, then control comes back. function lock(uint256 duration) external onlyOwner { _previousOwner = owner(); _unlockTime = block.timestamp + duration; _transferOwnership(address(0)); } function unlock() external { require(msg.sender == _previousOwner && block.timestamp > _unlockTime, "locked"); _transferOwnership(_previousOwner); }}Check three variants. A second address checked with msg.sender == keeps control after the owner is gone. AccessControl roles are separate from Ownable: DEFAULT_ADMIN_ROLE manages every other role unless the role admin was changed. A lock-and-unlock pattern zeroes the owner and later restores it. In source, list every msg.sender comparison, modifier and role check; in state, call hasRole and read RoleGranted events. GoPlus flags hidden_owner and can_take_back_ownership.
Can the code change?
Behind a proxy, everything above describes the current implementation only. EIP-1967 fixes the slots holding the implementation, admin and beacon addresses, so three storage reads show whether a token is upgradeable (the mechanics are in our primer on upgradeable proxy security). The holder’s question is who can upgrade. In a transparent proxy, the admin slot usually holds an admin contract whose owner can be a single key while the token’s owner() is zero. In UUPS, _authorizeUpgrade decides, usually with onlyOwner. A beacon’s owner upgrades every proxy behind it.
An upgrade can install code that mints, blocks or restores an owner, so behind a proxy “renounced” means “renounced in this version”. Follow the admin chain to its final owner, and watch Upgraded, AdminChanged, role and fee changes with on-chain monitoring.
Effects on integrators
An external call in the transfer path moves behaviour into another contract. If its address is settable, the token is mutable without a proxy, and every protocol moving it inherits a reentrancy and gas risk.
// Simplified, illustrative. Not production code.ITransferGuard public guard; // settable by the ownerfunction setGuard(ITransferGuard g) external onlyOwner { guard = g; } function _update(address from, address to, uint256 value) internal override { super._update(from, to, value); if (address(guard) != address(0)) guard.check(from, to, value); // may revert or re-enter}A fee-on-transfer token delivers less than the amount sent, so crediting amount mis-accounts; Uniswap V2’s router has separate …SupportingFeeOnTransferTokens functions that measure the input as the pair’s balance minus its reserve. A rebasing token changes balanceOf without transfers, so cached balances go stale.
SPL and Token-2022 mints
Solana tokens share one token program, so the risk sits in the mint’s authorities and, for Token-2022 mints, its extensions:
- Mint authority. If none is present, supply is fixed; if one is set, it can mint at will.
- Freeze authority. It can freeze any token account of the mint, preventing transfers and burns.
- Transfer fee. Withheld in the recipient’s account as
min(ceil(amount × bps / 10,000), maximum_fee). The fee authority can change it, but a new fee takes effect two epochs later. - Transfer hook. A program called during transfers that can make them fail.
- Permanent delegate. It can transfer or burn any amount from any account.
- Default account state, pausable, non-transferable. New accounts start frozen; transfers, mints and burns can be stopped; the token cannot be transferred.
For a 6-decimal mint, a 50 bps fee on 1,000 tokens is ceil(1,000,000,000 × 50 / 10,000) = 5,000,000 base units, 5 tokens; with a maximum fee of 2,000,000 base units it is 2 tokens. Rates go up to 10,000 bps, so a 99% fee is possible here too; the difference is the notice.
# Read-only. $RPC_URL and $MINT are placeholders. A null authority means none is set.curl -s $RPC_URL -H 'content-type: application/json' -d '{ "jsonrpc": "2.0", "id": 1, "method": "getAccountInfo", "params": ["'"$MINT"'", { "encoding": "jsonParsed" }]}' | jq '.result.value | { program: .owner, mintAuthority: .data.parsed.info.mintAuthority, freezeAuthority: .data.parsed.info.freezeAuthority, extensions: [.data.parsed.info.extensions[]? | {extension, state}]}'Who owns the liquidity
In a Uniswap V2-style pool, whoever holds the LP tokens can redeem them for the reserves with removeLiquidity, the simplest form of rug pull. Find every pool and who holds its LP supply: a key, a burn address or a lock. A lock is a contract with the token’s questions: a proxy admin can upgrade it, an owner with an emergency withdrawal can release it, and it may cover part of the supply or expire soon. GoPlus marks LP as locked only for lockers and burn addresses it recognises, which says nothing about a locker’s own controls.
Worked example: from 3% to a 99% sell tax
The model token trades in a model constant-product pool of 1,000,000 tokens and 100 ETH with a 0.3% fee (Uniswap V2 arithmetic). A holder sells 1,000 tokens; the tax stays in tokens and is not sold in the same transaction. For a sell tax s:
tokens reaching the pool d = 1,000 × (1 − s)ETH out = d × 997 × 100 / (1,000,000 × 1,000 + d × 997)
With no tax, ETH out = 99,700,000 / 1,000,997,000 = 0.099601. Values are exact 18-decimal integer results, rounded for display. Round trip is (1 − 0.03) × (1 − s), before pool fees and price impact; the last column is the Token Scanner’s severity for a measured sell tax of that size.
| Sell tax | To tax wallet | Reaches pool | ETH received | Round trip | Scanner severity |
|---|---|---|---|---|---|
| 0% | 0 | 1,000 | 0.099601 | 97.00% | info |
| 3% | 30 | 970 | 0.096616 | 94.09% | low |
| 10% | 100 | 900 | 0.089650 | 87.30% | medium |
| 25% | 250 | 750 | 0.074719 | 72.75% | high |
| 50% | 500 | 500 | 0.049825 | 48.50% | critical |
| 99% | 990 | 10 | 0.000997 | 0.97% | critical |
| 100% | 1,000 | 0 | reverts | 0% | critical |
At 99% the sale succeeds, so a check that asks only “can I sell?” passes, and the holder receives 0.000997 ETH. The exempt tax wallet can then sell its 990 tokens into the same pool for about 0.098604 ETH, which matches, to six decimals, what the holder lost against a no-tax sale. At 100% nothing reaches the pool and the router reverts on a zero input: a hard honeypot, which simulation catches. A sale whose minimum output comes from a no-tax quote reverts at 99% too; the loss falls on sellers who raise slippage until the sale goes through. A test pins the property down:
// Simplified, illustrative. Not production code. Runs against the model contract only.// setUp(): deploy ModelTaxToken(taxWallet), setPair(pair), setTrading(true),// and mint 1,000 tokens to holder.function test_softHoneypotAt99Percent() public { token.setSellTax(9_900); // raised after the holder bought vm.prank(holder); token.transfer(pair, 1_000e18); // a sell is a transfer to the pair: it succeeds assertEq(token.balanceOf(pair), 10e18); // 10 tokens reach the pool assertEq(token.balanceOf(taxWallet), 990e18); // 990 go to the tax wallet}Risk table
Severity is for holders and assumes one key with no delay; a timelocked multisig lowers it, a proxy raises it.
| Signal | Why it matters | How to verify | Severity |
|---|---|---|---|
| Uncapped mint | Dilutes holders | Paths to _mint; Transfer from 0x0; mint authority | Critical |
| Blocklist or freeze | One holder stopped | Reverts in _update; is_blacklisted; freeze authority | High |
| Pause or trading switch | Every exit stops | Pause checks; transfer_pausable | High |
| Limit without a floor | Blocks every sale | Setter’s lower bound; anti_whale_modifiable | High |
| High or no tax cap | Exit value taken | Cap constant; slippage_modifiable, sell_tax | Critical if cap ≥ 50% |
| Per-address tax | Holders targeted | personal_slippage_modifiable | Critical |
| Sell-only branches | Sells revert | Branches on to == pair; simulation; is_honeypot, cannot_sell_all | Critical |
| Privilege after renouncing | Renouncing is cosmetic | msg.sender checks; hasRole; hidden_owner, can_take_back_ownership | Critical |
| Balance control | Holdings taken | owner_change_balance; permanent delegate | Critical |
| Upgradeable proxy | Any answer can change | EIP-1967 slots; admin chain; Upgraded | Critical |
| External call or hook | Logic lives elsewhere | Calls in _update; external_call; transfer hook | Medium |
| Fee-on-transfer or rebasing | Integrators mis-account | Balance change versus amount sent | Low; high for integrators |
| Controllable LP or lock | Liquidity withdrawn | LP holders; locker admin; lp_holders | High |
What the Token Scanner checks, and what it cannot
The Quantix Token Scanner automates part of this method. It is an automated check of public data, not a review.
EVM, JSON-RPC eth_getCode; eth_call name(), symbol(), decimals(), totalSupply(), owner(); eth_getStorageAt on the EIP-1967 implementation, admin and beacon slots; EIP-1167 minimal proxies; PUSH4 selectors in the logic contract's bytecodeEVM, Etherscan source verification of the token and of its implementationEVM, GoPlus is_honeypot buy_tax sell_tax transfer_tax cannot_buy cannot_sell_all transfer_pausable trading_cooldown is_mintable hidden_owner can_take_back_ownership owner_change_balance is_blacklisted slippage_modifiable personal_slippage_modifiable holders owner_percent creator_percent holder_count dex is_in_dex lp_holders selfdestruct external_call is_proxy is_open_source is_airdrop_scam fake_token trust_list (overview: token_name token_symbol total_supply owner_address)Solana, RPC getAccountInfo (program, mint and freeze authorities, Token-2022 extensions); getTokenLargestAccountsSolana, GoPlus mintable freezable balance_mutable_authority transfer_fee transfer_fee_upgradable transfer_hook holders dex trusted_token (overview: metadata total_supply)The selector catalogue covers mint, pause, blacklist, fee setters, trading switches and limits, upgrade and role functions, and owner levers such as setBots and manualSwap. The owner is classified as renounced, absent, role-based, a contract or a single key. A field a provider does not return is shown as not available, never as safe. One critical signal, or three high ones, make the level critical; with neither an on-chain read nor a GoPlus record, no level is given. It cannot see:
- Behaviour behind names. A branch inside
_updatehas no selector, an uncatalogued privileged function is missed, and matched functions are attributed to the owner, not traced to their guard. - Limits. Fee caps are not read from bytecode, transaction limits are found only by setter name, and an owner with contract code is not resolved into a multisig or a timelock. An owner whose code is only an EIP-7702 delegation is reported as a single key, because its private key still controls it.
- Selective behaviour. GoPlus’s honeypot and tax values come from its sandbox, which its documentation says anti-bot and cooldown mechanisms can affect; a token that treats addresses, amounts or times differently can pass.
- Lockers and pools outside GoPlus’s coverage, and a locker’s own controls.
- The future and intent. A scan is a snapshot at one block or slot.
For verified source, the Contract Analyzer runs Slither’s static analysis; the admin chain, simulation and liquidity checks stay manual. Listing teams that must trace where privileged addresses were funded and where proceeds go need AML and transaction risk analytics.
Conclusions
A token is as safe as the powers its code grants and the people who hold them. Six questions cover those powers, and each has an answer in source, state or simulation.
Limits matter more than current values. A 3% and a 99% sell tax are one call apart; only the cap, who can call the setter, and the absence of an upgrade path say which one waits at exit.
Recommendations
Traders and holders
- Read the cap, not the current rate: without a low constant cap, the owner sets your exit price.
- Simulate a sell from a fresh address before committing size; needing slippage far above the stated tax is a warning.
- Treat “renounced” as unverified until the proxy admin, roles and other
msg.senderchecks are accounted for.
Listing, exchange and wallet risk teams
- Record each token’s admin chain, privilege holders, fee caps and LP ownership (for Solana, both authorities and every extension), and re-check on
Upgraded,AdminChanged,RoleGrantedandOwnershipTransferred. - Credit deposits from measured balance changes, not Transfer event values.
Token developers
- Make bounds constants: a low maximum tax, a floor under transaction limits, no per-address rates.
- Hold remaining privileges in a timelocked multisig, revoke the rest, and verify every implementation’s source.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1ERC-20: Token Standard(opens in a new tab)Ethereum Improvement Proposals
- 2ERC-1967: Proxy Storage Slots(opens in a new tab)Ethereum Improvement Proposals
- 3Access Control (Ownable, AccessControl, renouncing ownership)(opens in a new tab)OpenZeppelin Contracts 5.x documentation
- 4Router02 (fee-on-transfer swaps, removeLiquidity)(opens in a new tab)Uniswap V2 documentation
- 5Assets on Solana (mint and freeze authorities)(opens in a new tab)Solana documentation
- 6Transfer Fees(opens in a new tab)Solana documentation, Token Extensions
- 7Token-2022: Extension Guide(opens in a new tab)Solana Program documentation
- 8Response Details (token security API fields)(opens in a new tab)GoPlus Security documentation
Applied on this site
Assessments, case studies and tools that apply this publication.






