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.
| 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 |
|
Trading lock |
before |
Anti-bot |
an address can be flagged for the first 30 minutes only; unflagging works forever |
Two-step ownership |
|
Exclusions |
exchange, vesting and staking addresses can be exempted from tax and limits |
Burn |
|
Asset rescue |
a foreign ERC-20 can be recovered, the token’s own balance cannot |
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% |
|
|
No new tokens will be minted |
|
no |
The owner cannot take tokens from other wallets |
no function writes to a foreign balance |
— |
The owner cannot take the collected tax in tokens |
|
|
Removing the limits is irreversible |
|
|
Limits cannot be tightened |
|
|
The blacklist is not open-ended |
flagging works for 30 minutes after launch |
|
The pair and the router can never be blocked |
explicit check in |
|
The pair cannot be swapped out |
|
value never changes |
Tax cannot be raised after |
|
|
Tax sales are capped at 1% of supply per block |
hard cap in |
|
The cap survives recursion and looping |
|
— |
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 |
|
Ownership cannot be renounced before launch |
otherwise the trading lock would close forever |
|
Ownership cannot be renounced with limits on |
|
|
Ownership cannot be renounced with addresses flagged |
otherwise they would stay frozen forever |
|
Ownership never lands on an address that did not accept it |
|
|
The contract cannot be upgraded |
no proxy, no |
— |
npm install
cp .env.example .env # fill in the keys; .env is gitignored
npx hardhat compile
npx hardhat testDeploy 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 bscTestnetToken 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.tsThe full launch sequence, from a clean repository to renouncing ownership, is in docs/OPERATIONS.adoc.
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 |
|---|---|---|
|
supply, pair creation, parameters, constructor reverts |
10 |
|
buy and sell tax, exemptions, |
8 |
|
|
10 |
|
the trading lock before |
6 |
|
the 30-minute window, protected addresses, batch flagging |
10 |
|
owner rights, rescue, two-step ownership, renounce guards, absence of |
28 |
|
BSC fork: real swap back, |
7 |
|
same scenarios on a mock router, |
14 |
|
RPC responses returning |
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.
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.
| Constant | Value | Meaning |
|---|---|---|
|
10000 |
basis points denominator, 300 = 3% |
|
500 |
5% ceiling on each tax |
|
30 minutes |
how long an address can be flagged after launch |
|
20 |
one swap is at most 20 thresholds |
|
1000000 |
threshold floor, 0.0001% of supply |
|
100 |
threshold ceiling, 1% of supply |
|
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.
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.
Whether something is a buy, a sell or a transfer comes entirely from the
isAMMPair mapping:
from |
to |
Classified as | Tax |
|---|---|---|---|
pair |
wallet |
buy |
|
wallet |
pair |
sell |
|
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.
While limitsActive is true:
| Case | Checks |
|---|---|
buy |
|
sell |
|
transfer |
|
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.
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.
Trigger conditions, all required:
swapEnabled &&
!inSwap &&
!isAMMPair[from] && // never on a buy
tradingEnabled &&
!isExcludedFromFee[from] && // owner transfers do not trigger it
balanceOf(address(this)) >= swapThresholdHow 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.
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
Three things a home-grown tax token tends to get wrong, all covered by tests.
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.
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.
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.
| Action | Why it cannot be undone |
|---|---|
|
there is no |
|
reverts on a second call, and |
|
there is no unlock; the tax can only go down afterwards |
|
every admin function stops working |
deploying against the wrong router |
|
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.
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 tablesAt 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.
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
mintin 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.
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
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.
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.
MIT — see LICENSE.