Skip to content
Quantix Security Labs
Primer

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.

Illustration: a token under a lens, its privileges read out as a checklist of mint authority, pause, transfer tax, owner and proxy, and liquidity lock; the transfer-tax check fails.
Contents
  1. Six questions, three kinds of evidence
  2. A model token
  3. Who can mint
  4. Who can stop a transfer
  5. Who can change the fees
  6. Can holders sell?
  7. Renounced is a claim, not a property
  8. Can the code change?
  9. Effects on integrators
  10. SPL and Token-2022 mints
  11. Who owns the liquidity
  12. Worked example: from 3% to a 99% sell tax
  13. Risk table
  14. What the Token Scanner checks, and what it cannot
  15. Conclusions
  16. Recommendations
  17. 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:

  1. Identify the object: contract or mint, proxy or not, and the implementation behind a proxy.
  2. Read the implementation’s verified source; without it, treat every unexplained function as a privilege.
  3. List every function that changes balances, supply, fees, limits, lists or roles, and its guard.
  4. Read who passes each guard today, and whether each is a contract or a single key.
  5. Simulate a buy, then a sell, from a fresh address on a fork.
  6. 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:

EVM state reads (illustrative)bash
# 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_ROLE

A 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.

ModelTaxToken.sol (illustrative)solidity
// 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:

Bounded tax and limit (illustrative)diff
-    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:

Sell-only restrictions (illustrative)solidity
// 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 clock
Where a sale can fail inside a token’s transfer functionA holder sells through a router, which moves the tokens to the pool with transferFrom and then calls swap. Before the tokens reach the pool, the token’s internal _update function runs six owner-controlled checks, drawn from the article’s illustrative contracts: a trading switch, a per-address block list, a maximum transaction size, a cooldown since the last buy, a sell tax of up to 99 percent sent to a tax wallet, and an external call to a guard contract. The pool pays out ether only on the tokens that actually arrive. A revert at checks one to four or six blocks the sale outright; a tax close to 100 percent at check five lets the sale succeed while paying almost nothing.Holdersells 1,000 tokensRoutermoves tokens to the pool,then calls swap()Token: _update(from, to, value)Pool (pair)pays out ETHwhat is left1require(tradingEnabled)a switch the owner can turn off again2require(!isBlocked[from])per-address block list3require(value <= maxTxAmount)max transaction or max wallet, no floor4require(elapsed >= cooldown)time since the last buy, no maximum5fee = value × sellTaxBps / 10,000owner-set sell tax, capped at 99%6guard.check(from, to, value)external call: logic in another contractETH paid only on the tokens that reach the poolA revert at 1–4 or 6 blocks the sale outright. A tax near 100% at 5 lets it succeed and pay almost nothing.
Checks from the model token and this article’s snippets, in the order a sale meets them. The pool pays only for the tokens that arrive.

Each 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.

RenouncedButNot.sol (illustrative)solidity
// 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.

External call in a transfer hook (illustrative)solidity
// 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.

Solana mint read (illustrative)bash
# 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 taxTo tax walletReaches poolETH receivedRound tripScanner severity
0%01,0000.09960197.00%info
3%309700.09661694.09%low
10%1009000.08965087.30%medium
25%2507500.07471972.75%high
50%5005000.04982548.50%critical
99%990100.0009970.97%critical
100%1,0000reverts0%critical
Selling 1,000 model tokens into a 1,000,000-token, 100-ETH pool (ETH rounded to six decimals)

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:

ModelTaxToken.t.sol (Foundry, illustrative)solidity
// 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.

SignalWhy it mattersHow to verifySeverity
Uncapped mintDilutes holdersPaths to _mint; Transfer from 0x0; mint authorityCritical
Blocklist or freezeOne holder stoppedReverts in _update; is_blacklisted; freeze authorityHigh
Pause or trading switchEvery exit stopsPause checks; transfer_pausableHigh
Limit without a floorBlocks every saleSetter’s lower bound; anti_whale_modifiableHigh
High or no tax capExit value takenCap constant; slippage_modifiable, sell_taxCritical if cap ≥ 50%
Per-address taxHolders targetedpersonal_slippage_modifiableCritical
Sell-only branchesSells revertBranches on to == pair; simulation; is_honeypot, cannot_sell_allCritical
Privilege after renouncingRenouncing is cosmeticmsg.sender checks; hasRole; hidden_owner, can_take_back_ownershipCritical
Balance controlHoldings takenowner_change_balance; permanent delegateCritical
Upgradeable proxyAny answer can changeEIP-1967 slots; admin chain; UpgradedCritical
External call or hookLogic lives elsewhereCalls in _update; external_call; transfer hookMedium
Fee-on-transfer or rebasingIntegrators mis-accountBalance change versus amount sentLow; high for integrators
Controllable LP or lockLiquidity withdrawnLP holders; locker admin; lp_holdersHigh
Token risk signals, how to verify them, and severity for holders

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.

Token Scanner inputstext
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 _update has 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

  1. Read the cap, not the current rate: without a low constant cap, the owner sets your exit price.
  2. Simulate a sell from a fresh address before committing size; needing slippage far above the stated tax is a warning.
  3. Treat “renounced” as unverified until the proxy admin, roles and other msg.sender checks are accounted for.

Listing, exchange and wallet risk teams

  1. 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, RoleGranted and OwnershipTransferred.
  2. Credit deposits from measured balance changes, not Transfer event values.

Token developers

  1. Make bounds constants: a low maximum tax, a floor under transaction limits, no per-address rates.
  2. 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.

  1. 1ERC-20: Token Standard(opens in a new tab)Ethereum Improvement Proposals
  2. 2ERC-1967: Proxy Storage Slots(opens in a new tab)Ethereum Improvement Proposals
  3. 3Access Control (Ownable, AccessControl, renouncing ownership)(opens in a new tab)OpenZeppelin Contracts 5.x documentation
  4. 4Router02 (fee-on-transfer swaps, removeLiquidity)(opens in a new tab)Uniswap V2 documentation
  5. 5Assets on Solana (mint and freeze authorities)(opens in a new tab)Solana documentation
  6. 6Transfer Fees(opens in a new tab)Solana documentation, Token Extensions
  7. 7Token-2022: Extension Guide(opens in a new tab)Solana Program documentation
  8. 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.

Want this question asked of your code?

Quantix reviewers apply the same reasoning to production systems. Share what you are building and when it ships.