Skip to content

Repository files navigation

Token Launch Kit

ERC-20 / BEP-20 launch token with separate buy and sell taxes, automatic tax swap into the native coin, launch limits and a time-boxed anti-sniper window.

Solidity 0.8.24 · OpenZeppelin 5.x · Hardhat 2.22 · TypeScript · 99 tests

Liquidity gets added with real money exactly once and cannot be undone. Most of what this repository does is make the irreversible steps hard to get wrong: renounceOwnership() before removeLimits() bricks the token, a wrong router address is unfixable because the address is immutable, and a swap without try/catch stops user transfers the moment the pool is empty. The contract refuses all three.

This file describes what the token is and how it works. Two companion documents cover the rest:

  • docs/OPERATIONS.adoc — launching it and driving every mechanic afterwards.

  • docs/SECURITY.adoc — what the owner can still do, what is not guaranteed, and what the security reviews found.

Features

Feature Details

Separate taxes

buy and sell configured independently, 5% ceiling on each

Automatic tax swap

collected tax is swapped to BNB/ETH and sent to the marketing wallet

Launch limits

maxTx 1% and maxWallet 2% of supply, removed by one irreversible call

Trading lock

before enableTrading() only fee-exempt addresses can move the token

Anti-bot

an address can be flagged for the first 30 minutes only; unflagging works forever

Two-step ownership

Ownable2Step — a typo in the address does not kill administration

Exclusions

exchange, vesting and staking addresses can be exempted from tax and limits

Burn

burn / burnFrom from OpenZeppelin, no emission

Asset rescue

a foreign ERC-20 can be recovered, the token’s own balance cannot

Guarantees

Every row is verifiable by reading the verified source, which is the point of the list.

Guarantee Mechanism Explorer check

Tax can never exceed 5%

uint256 public constant MAX_TAX = 500

MAX_TAX() = 500

No new tokens will be minted

_mint runs only in the constructor

no mint in the ABI

The owner cannot take tokens from other wallets

no function writes to a foreign balance

The owner cannot take the collected tax in tokens

rescueERC20 reverts on its own address

CannotRescueOwnToken

Removing the limits is irreversible

removeLimits() reverts the second time

LimitsAlreadyRemoved

Limits cannot be tightened

setLimits only accepts larger values

LimitsCannotBeTightened

The blacklist is not open-ended

flagging works for 30 minutes after launch

BotWindowClosed

The pair and the router can never be blocked

explicit check in setBot

ProtectedAddress

The pair cannot be swapped out

address public immutable primaryPair

value never changes

Tax cannot be raised after lockTaxes()

taxesLocked flag

TaxesAreLocked

Tax sales are capped at 1% of supply per block

hard cap in _swapBack plus one swap per block

lastSwapBlock()

The cap survives recursion and looping

lockTheSwap is a mutex, lastSwapBlock bounds consecutive calls

Tax collection cannot be switched off silently

the pair and the router cannot be fee-exempt, and a fee-exempt address cannot become a pair

ProtectedAddress

Ownership cannot be renounced before launch

otherwise the trading lock would close forever

TradingNotEnabled

Ownership cannot be renounced with limits on

renounceOwnership is overridden

LimitsStillActive

Ownership cannot be renounced with addresses flagged

otherwise they would stay frozen forever

BotsStillFlagged

Ownership never lands on an address that did not accept it

Ownable2Step

pendingOwner()

The contract cannot be upgraded

no proxy, no delegatecall, no selfdestruct

Quick start

npm install
cp .env.example .env      # fill in the keys; .env is gitignored
npx hardhat compile
npx hardhat test

Deploy and verify:

npx hardhat run scripts/deploy.ts --network bscTestnet
npx hardhat run scripts/verify.ts --network bscTestnet
npx hardhat run scripts/post-deploy-checklist.ts --network bscTestnet

Token parameters come from .env. The router is picked by chainId from scripts/routers.ts — check it against the official documentation before every mainnet deploy, because the router address in the contract is immutable.

deploy.ts writes deployments/<network>.json and verify.ts reads the constructor arguments back from it, which removes the most common cause of failed verification: arguments retyped by hand. That directory is gitignored, since the records carry the deployer and marketing wallet addresses.

Verification needs one key from etherscan.io in ETHERSCAN_API_KEY. Etherscan V2 serves every chain with it, BSC included; the V1 endpoint and separate BscScan keys were retired on 31 May 2025.

To rehearse the whole chain locally, with no testnet and no test coins:

npx hardhat node                                              # terminal 1
npx hardhat run scripts/deploy-mocks.ts --network localhost   # terminal 2
# copy ROUTER_ADDRESS into .env, then run deploy.ts and post-deploy-checklist.ts

The full launch sequence, from a clean repository to renouncing ownership, is in docs/OPERATIONS.adoc.

Tests

99 tests, 7 of them on a BSC fork against the real PancakeSwap.

npx hardhat test            # 92 local tests, fork tests are skipped
npm run test:fork           # fork tests only, needs an archive BSC_RPC
File Covers Tests

01-deployment

supply, pair creation, parameters, constructor reverts

10

02-taxes

buy and sell tax, exemptions, MAX_TAX, lockTaxes

8

03-limits

maxTx, maxWallet, exemptions, irreversibility

10

04-trading-lock

the trading lock before enableTrading()

6

05-antibot

the 30-minute window, protected addresses, batch flagging

10

06-admin

owner rights, rescue, two-step ownership, renounce guards, absence of mint

28

07-swapback.fork

BSC fork: real swap back, lockTheSwap, limits on a live pool

7

08-swapback.unit

same scenarios on a mock router, try/catch, reentrancy, recursion, looping

14

09-rpc-compat

RPC responses returning to: "" instead of null

6

The fork tests need an archive BSC RPC and a pinned block:

FORK_BSC=true
BSC_RPC=https://bsc-mainnet.public.blastapi.io
BSC_FORK_BLOCK=45000000

Pinning the block is mandatory. Without it the tests pass or fail depending on the state of the chain.

Mechanics

How the token behaves internally. Everything here is checkable against contracts/LaunchToken.sol; this section explains the parts where the reasoning is not obvious from the code.

Constants and immutables

Constant Value Meaning

BPS

10000

basis points denominator, 300 = 3%

MAX_TAX

500

5% ceiling on each tax

BOT_WINDOW

30 minutes

how long an address can be flagged after launch

MAX_SWAP_MULTIPLIER

20

one swap is at most 20 thresholds

MIN_SWAP_THRESHOLD_DIVISOR

1000000

threshold floor, 0.0001% of supply

MAX_SWAP_THRESHOLD_DIVISOR

100

threshold ceiling, 1% of supply

MAX_SWAP_SHARE_DIVISOR

100

hard cap on one swap, 1% of supply

Set once in the constructor and never again: router, primaryPair, and initialSupply. The last one is the base for the threshold bounds because totalSupply() can shrink through burns.

The constructor also rejects a marketing wallet equal to the pair, the router or the token itself. The marketing wallet is fee-exempt, so any of those would silently stop tax collection.

Trading starts closed. Until enableTrading() only the deployer, the contract and the marketing wallet can move tokens, which is what makes adding liquidity safe — nobody can front-run the launch by buying from a pool that already exists.

_update is the only entry point

In OpenZeppelin 5.x every transfer goes through _update, including mint and burn. All of the logic hangs off it, in this order:

1. mint / burn / the contract's own transfer inside a swap  ->  pass straight through
2. blacklist check                                          ->  BotBlocked
3. trading lock                                             ->  TradingNotEnabled
4. compute the tax
5. limits                                                   ->  MaxTxExceeded / MaxWalletExceeded
6. swap back, if the conditions are met
7. move the tax to the contract, the remainder to the recipient

The order is load-bearing in three places.

Tax before limits, because maxWallet is checked against what the recipient actually receives. With a 2% maxWallet of 20,000,000 and a wallet holding 19,020,000:

gross: 19,020,000 + 1,000,000 = 20,020,000  > maxWallet  -> would revert
net:   19,020,000 +   970,000 = 19,990,000 <= maxWallet  -> goes through

The gross reading rejects a purchase that in fact fits, and the buyer sees a revert with no obvious cause.

The swap before step 7, before the current trade’s tax is credited. At that moment the seller’s tokens have not reached the pair, so the nested swap sees correct reserves. Reversing the order makes the router count part of the seller’s amount as its own input.

The free pass in step 1 is inSwap && from == address(this), not a bare inSwap. Mid-swap the router pays native coin to marketingWallet, and a contract wallet gets control inside our _update. With a bare inSwap it could move tokens past tax, limits and the blacklist. The only transfer that genuinely needs the pass is the contract’s own leg to the pair.

Classification and tax

Whether something is a buy, a sell or a transfer comes entirely from the isAMMPair mapping:

from to Classified as Tax

pair

wallet

buy

buyTaxBps

wallet

pair

sell

sellTaxBps

wallet

wallet

transfer

none

uint256 taxBps = isBuy ? buyTaxBps : (isSell ? sellTaxBps : 0);
fee = (value * taxBps) / BPS;
amountAfterFee = value - fee;

Nobody pays if either side is fee-exempt. The primary pair is registered in the constructor; a second DEX or a V3 pool has to be registered with setAMMPair, or trading through it goes untaxed.

The collected tax sits on the contract as tokens until the swap back converts it. pendingTax() reports it.

Limits

While limitsActive is true:

Case Checks

buy

maxTx on the amount, maxWallet on the buyer, unless the buyer is exempt

sell

maxTx on the amount unless the seller is exempt; maxWallet never applies to the pair

transfer

maxTx unless both sides are exempt, maxWallet on the recipient unless exempt

setLimits only accepts values greater than or equal to the current ones. Tightening would let maxTx = 1 wei build a honeypot without touching the tax rates.

removeLimits() clears the flag and raises both stored values to type(uint256).max, so getters read by wallets and front ends stop advertising a limit that no longer exists.

Anti-bot

setBot(address, true) works only while block.timestamp < tradingStartTime + BOT_WINDOW. After that it reverts forever. setBot(address, false) always works.

That asymmetry is the whole design: flagging is an emergency tool with an expiry date, unflagging is a correction that must stay available. The pair, the router, the token, the owner and the marketing wallet can never be flagged — doing so would break the token rather than a sniper.

flaggedBots counts currently flagged addresses and only moves on a real state change, so a repeated setBot(x, true) cannot inflate it. renounceOwnership() reverts while it is non-zero, because renouncing with a non-empty blacklist would freeze those addresses permanently.

botWindowRemaining() uses the same >= boundary as setBot, so the view and the behaviour never disagree by a second.

Swap back

Trigger conditions, all required:

swapEnabled &&
!inSwap &&
!isAMMPair[from] &&               // never on a buy
tradingEnabled &&
!isExcludedFromFee[from] &&       // owner transfers do not trigger it
balanceOf(address(this)) >= swapThreshold

How much is sold:

maxSwap = swapThreshold * MAX_SWAP_MULTIPLIER;    // 20 thresholds
hardCap = initialSupply / MAX_SWAP_SHARE_DIVISOR; // 1% of supply
if (maxSwap > hardCap) maxSwap = hardCap;
amountToSwap = min(contractBalance, maxSwap);

Both bounds are needed. With the threshold at its 1% maximum, twenty thresholds alone would authorise selling 20% of supply in a single call.

How much is sold per block: swapBack returns immediately if block.number ⇐ lastSwapBlock, and claims the block _before the external call.

uint64 previousSwapBlock = lastSwapBlock;
lastSwapBlock = uint64(block.number);
try router.swapExactTokensForETHSupportingFeeOnTransferTokens(...) {
    emit SwapBackExecuted(amountToSwap);
} catch {
    lastSwapBlock = previousSwapBlock;   // nothing sold, give the budget back
    _approve(address(this), address(router), 0);
    emit SwapBackFailed(amountToSwap);
}

Claiming it up front makes the block check a second, independent lock: while the router runs, lastSwapBlock already equals the current block, so anything re-entering _swapBack stops there without reaching the mutex. Restoring it on failure matters too, or one failed attempt would postpone collection and a permanent failure would burn the first attempt in every block.

Net guarantee: no more than 1% of supply per block.

manualSwapBack() is an owner-only trigger that ignores the threshold and respects everything else — the mutex, the per-swap cap and the block rule.

A real PancakeSwap sell, traced

600,000 tokens of tax on the contract, a user sells 1,000,000 tokens.

1. user -> router.swapExactTokensForETHSupportingFeeOnTransferTokens(
              1,000,000, 0, [TOKEN, WBNB], user, deadline)

2. router -> token.transferFrom(user, PAIR, 1,000,000)
   └─► _update(user, pair, 1,000,000)

   3. classification: isSell = true
      fee = 50,000, amountAfterFee = 950,000
      limits: 1,000,000 <= maxTx, ok

   4. swap conditions met -> _swapBack()
      │  inSwap = true
      │  block.number > lastSwapBlock, ok
      │  contractBalance = 600,000, cap 10,000,000 -> sell 600,000
      │  approve(router, 600,000)
      │  lastSwapBlock = block.number    <- block claimed BEFORE the call
      │
      ├─► router.swapExactTokensForETHSupportingFeeOnTransferTokens(
      │      600,000, 0, [TOKEN, WBNB], MARKETING, now)
      │   │
      │   ├─ router -> token.transferFrom(CONTRACT, PAIR, 600,000)
      │   │  └─► _update(contract, pair, 600,000)
      │   │      inSwap && from == address(this) -> FREE PASS
      │   │      the pair receives exactly 600,000, no second tax
      │   │
      │   ├─ router: amountInput = balanceOf(pair) - reserve0
      │   │  the user's tokens have NOT reached the pair yet (step 7 has not
      │   │  run), so amountInput = 600,000 — our tokens and nothing else
      │   │
      │   ├─ pair.swap(0, amountOut, router) -> pair pays WBNB, syncs reserves
      │   ├─ WBNB.withdraw(amountOut)
      │   └─ router sends BNB -> MARKETING WALLET
      │
      │  emit SwapBackExecuted(600,000)
      │  inSwap = false
      └─

   5. back in _update, step 7:
      super._update(user, contract, 50,000)   // tax on this trade
      super._update(user, pair, 950,000)      // the pair gets the remainder

6. the outer router call continues:
   amountInput = balanceOf(pair) - reserve0'  // reserves synced by the nested swap
              = 950,000                       // exactly what arrived
   pair.swap(...) -> WBNB -> withdraw -> BNB to the user

Net effect:

marketing wallet:  + BNB for 600,000 tokens
contract:          600,000 -> 0 -> 50,000   (sold the backlog, took new tax)
pair:              + 600,000 + 950,000 tokens, - BNB
user:              - 1,000,000 tokens, + BNB for 950,000

The three failure modes

Three things a home-grown tax token tends to get wrong, all covered by tests.

The swap must not run on a buy

During a buy the router is already inside pair.swap(). A Uniswap V2 pair has a lock modifier, so a nested swap on the same pair reverts with Pancake: LOCKED, taking the buyer’s transaction with it. Accumulated tax is therefore sold on sells and on ordinary transfers, never on buys.

lockTheSwap has to be a mutex

modifier lockTheSwap() {
    if (!inSwap) {
        inSwap = true;
        _;
        inSwap = false;
    }
}

The naive version, inSwap = true; _; inSwap = false;, is defeated by recursion. The router pays native coin to the marketing wallet in the middle of the swap, so a contract that is both the marketing wallet and the owner gets control there and can call manualSwapBack() again. The nested swap would sell another capful and then clear inSwap on its way out while the outer swap was still running. Recursion like that drains any amount. With the mutex, the nested call executes nothing.

A mutex alone is not enough

The mutex only sees nested entry. Consecutive calls in one transaction never nest: each transfer completes, inSwap drops back to false, and the next call looks like the first.

for (uint256 i; i < 20; ++i) {
    IERC20(token).transfer(sink, 1);   // 20 transfers, 20 swaps in a row
}

Twenty 1 wei transfers are twenty swaps, up to 20% of supply, and any address can do it. lastSwapBlock closes it.

Both attacks exist as contracts in contracts/mocks/ReentrantSwapper and SwapLooper — and are exercised in test/08-swapback.unit.test.ts.

try/catch around the router call

The swap can fail for reasons that have nothing to do with the user: an empty pool, slippage, a marketing wallet without receive(). Without try/catch any of those reverts an ordinary transfer, and the token looks exactly like a honeypot without being one.

Irreversible actions

Action Why it cannot be undone

enableTrading()

there is no disableTrading — the lock is a launch tool, not a switch

removeLimits()

reverts on a second call, and setLimits cannot bring the limits back

lockTaxes()

there is no unlock; the tax can only go down afterwards

renounceOwnership()

every admin function stops working

deploying against the wrong router

router and primaryPair are immutable

The renounce guards exist because the ordering mistakes here are common and final. renounceOwnership() reverts with TradingNotEnabled, LimitsStillActive or BotsStillFlagged rather than letting any of them happen.

Launch economics

tools/tax-calculator.js works out how much tax the owner collects at a given volume, what entering and exiting costs a buyer, and how long the limits hold a sniper up:

node tools/tax-calculator.js --supply 1000000000 --buy 3 --sell 5 --price 0.0001
node tools/tax-calculator.js --md      # markdown tables

At 3/5% a round trip costs a buyer 7.85%, so the price has to rise 8.52% for them to break even. At 5/5% that becomes 9.75% and 10.80%. The number matters because a high tax is taken out of the volume it depends on.

The tax swap is bounded three ways so collection never lands as one large sell: at most twenty thresholds, at most 1% of supply per swap, at most one swap per block.

Demo deployments

There is no public reference deployment. deployments/ is gitignored, because those records carry the deployer and marketing wallet addresses and publishing them ties a personal wallet to this repository — and the explorer will happily show everything else that wallet has ever done.

To put a demo on a testnet, deploy from a wallet used for nothing else, then point people at the explorer and let them check the guarantees themselves:

  • MAX_TAX() returns 500, and no setter can raise it.

  • There is no mint in the function list, so the supply is fixed.

  • primaryPair() matches the pair the token actually trades on.

  • owner() is the zero address once ownership has been renounced.

Anything you show has to be built from the current source. The guarantees above hold only if the verified code on the explorer matches this repository.

Layout

contracts/
  LaunchToken.sol              the token
  interfaces/IUniswapV2.sol    minimal router and factory interfaces
  mocks/                       mock DEX plus hostile contracts for the attack tests
test/
  01…06                        deploy, taxes, limits, launch, anti-bot, admin
  07-swapback.fork.test.ts     BSC fork, real PancakeSwap
  08-swapback.unit.test.ts     the same swap on a mock router, no RPC
  09-rpc-compat.test.ts        non-standard RPC responses
scripts/
  deploy.ts                    deploy and write deployments/<network>.json
  verify.ts                    verify with the recorded constructor arguments
  post-deploy-checklist.ts     launch checklist with live state
  deploy-mocks.ts              local mock DEX for a dry run
  rpc-compat.ts                fixes RPCs returning `to: ""` instead of null
  routers.ts                   router addresses per chain
tools/tax-calculator.js        launch economics
docs/OPERATIONS.adoc           launching and running the token
docs/SECURITY.adoc             threat model, review findings, accepted risks

Security

The contract has not been independently audited. It is covered by tests, including fork tests against the real PancakeSwap, and by two internal reviews in which every finding was reproduced with an exploit before the fix and covered by a test afterwards. Findings, accepted risks and the full threat model are in docs/SECURITY.adoc.

Static analysis: npm run slither (needs pip install slither-analyzer).

Keep .env out of git — it already is — and use a separate wallet for testnets.

This code is not meant for building honeypots. Removing MAX_TAX, adding a post-deploy mint(), making the blacklist permanent, or making sells revert for everyone but a chosen few all turn it into one.

Disclaimer

Provided as is, without warranty of any kind. Deploying a token puts real money at risk, and this contract has had no external audit — read the source, run the tests, and rehearse the whole sequence on a testnet before touching mainnet. You are responsible for what you deploy and for complying with the law where you operate. Nothing here is financial or legal advice.

License

MIT — see LICENSE.

About

ERC-20/BEP-20 launch token with separate buy/sell taxes (5% hard cap), automatic tax swap to BNB/ETH, launch limits, a 30-minute anti-sniper window, two-step ownership and guarded renounce. Solidity 0.8.24, OpenZeppelin 5, Hardhat, 99 tests including a BSC mainnet fork.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages