Building a Reflection Token on BSC with OpenZeppelin v5 — Architecture & Security Patterns
When we set out to build NSNAS Token 2.0, our goal wasn't just to create another BSC token. We wanted to build a token that was provably safe — where every security claim could be verified by reading the source code.
This article walks through the architecture decisions, security patterns, and lessons learned building a production reflection token on BNB Smart Chain.
The Architecture
Contract Inheritance
NSNAS Token 2.0
├── ERC20 (OpenZeppelin)
├── ERC20Pausable (OpenZeppelin)
├── Ownable (OpenZeppelin)
├── ReentrancyGuard (OpenZeppelin)
└── Custom Logic
├── Reflection System
├── Auto-Liquidity
├── Auto-Burn
├── Anti-Whale
└── Timelock
We chose OpenZeppelin v5.0.0 as our foundation. Every security-critical function — token transfers, access control, reentrancy protection — comes from battle-tested code that manages billions in value across the ecosystem.
Why v5 Over v4?
blockchain,
OpenZeppelin v5 introduced several improvements we leveraged:
- Namespaced storage — Better upgrade patterns (though our contract is not upgradeable by design)
- Custom errors — Gas savings over string-based reverts
-
Stricter access control —
Ownablenow requires explicit initial owner in constructor - Modernized patterns — Better alignment with latest Solidity features
Security Pattern 1: Immutable Constants
The most important security decision was making critical parameters immutable:
uint256 public constant MAX_SUPPLY = 10_000_000 * 10**18;
uint256 public constant MIN_SUPPLY = 1_000 * 10**18;
uint256 public constant MAX_TAX = 500; // 5% in basis points
uint256 public constant TIMELOCK_DELAY = 48 hours;
Why Constants Matter
In Solidity, constant values are embedded directly in the bytecode at compile time. They don't occupy storage slots. They can't be modified by any function, any role, or any governance mechanism.
When we say "tax can never exceed 5%," it's not a governance promise — it's a mathematical certainty enforced by the EVM.
function setTaxRates(uint256 _buyTax, uint256 _sellTax) external onlyOwner {
require(_buyTax <= MAX_TAX, "Buy tax exceeds 5%");
require(_sellTax <= MAX_TAX, "Sell tax exceeds 5%");
// ... timelock logic
}
Even if the private key is compromised, even if governance is attacked — the tax cannot exceed MAX_TAX.
Security Pattern 2: Timelock Mechanism
For parameters that need operational flexibility (like adjusting tax split between reflection, liquidity, and burn), we implemented a timelock:
uint256 public constant TIMELOCK_DELAY = 48 hours;
struct PendingTaxChange {
uint256 newBuyTax;
uint256 newSellTax;
uint256 executeAfter;
bool pending;
}
How It Works
- Owner proposes a change → recorded on-chain with
block.timestamp + TIMELOCK_DELAY - 48 hours must pass — visible to everyone on BscScan
- Owner executes the change after the delay
- Anyone monitoring the blockchain can see the pending change and react
This pattern is used by major DeFi protocols (Compound, Uniswap governance) and provides a critical trust guarantee: no surprises.
Security Pattern 3: Anti-Whale Protection
Large holders can manipulate markets. We prevent this at the contract level:
uint256 public maxTransactionAmount = 5_000 * 10**18;
uint256 public maxWalletAmount = 10_000 * 10**18;
uint256 public constant COOLDOWN_TIME = 30;
Implementation Details
The _update function (OpenZeppelin v5's replacement for _transfer) checks these limits on every transfer:
function _update(address from, address to, uint256 amount) internal override {
// Anti-whale checks
if (!isExcludedFromLimits[to]) {
require(amount <= maxTransactionAmount, "Exceeds max tx");
require(balanceOf(to) + amount <= maxWalletAmount, "Exceeds max wallet");
}
// Cooldown check
if (!isExcludedFromLimits[from]) {
require(block.timestamp >= lastTransferTimestamp[from] + COOLDOWN_TIME, "Cooldown active");
lastTransferTimestamp[from] = block.timestamp;
}
super._update(from, to, amount);
}
Key design decision: The contract owner and PancakeSwap pair are excluded from these limits. Without this, adding liquidity or collecting taxes would fail.
The Reflection System
Reflection tokens distribute a portion of every transaction to all holders proportionally. Here's the high-level flow:
User trades NSNAS on PancakeSwap
│
├── 1-2% → Distributed to ALL holders (reflection)
├── 1-2% → Added to PancakeSwap LP (auto-liquidity)
└── 1% → Permanently burned (deflation)
The Challenge: Gas Efficiency
Distributing tokens to every holder on every transaction would cost millions in gas. The standard approach — iterating through all holders — doesn't scale.
Instead, we use a virtual balance system where reflection rewards are calculated lazily. Each holder's "real" balance includes their base tokens plus accumulated reflections, calculated on-demand rather than distributed per-transaction.
Auto-Liquidity Mechanism
A percentage of every transaction is converted to liquidity:
- Half the liquidity tax is swapped to BNB via PancakeSwap
- The other half remains as NSNAS
- Both are added to the NSNAS/BNB liquidity pool
This creates a continuously growing liquidity floor, making the token more stable over time.
Reentrancy Protection
The swap-and-liquify function interacts with PancakeSwap (an external contract), creating a reentrancy vector. We protect against this with OpenZeppelin's ReentrancyGuard:
function swapAndLiquify() private nonReentrant {
// Safe from reentrancy
}
Additionally, we use a boolean flag to prevent recursive calls during the swap:
bool private inSwap;
modifier lockTheSwap {
inSwap = true;
_;
inSwap = false;
}
Burn Floor: Preventing Token Death
Deflationary tokens face an existential risk: what if they burn to zero? We implemented a floor:
uint256 public constant MIN_SUPPLY = 1_000 * 10**18;
function _burn(address account, uint256 amount) internal override {
if (totalSupply() - amount < MIN_SUPPLY) {
// Don't burn, skip the burn portion
return;
}
super._burn(account, amount);
}
This ensures the token can never be burned below 1,000 NSNAS, regardless of how many transactions occur.
Deployment & Verification
Constructor Design
constructor(
address _taxWallet,
address initialOwner,
address _routerAddress
) ERC20("NSNAS Token", "NSNAS") Ownable(initialOwner) {
// Setup PancakeSwap pair
IUniswapV2Router02 router = IUniswapV2Router02(_routerAddress);
uniswapV2Pair = IUniswapV2Factory(router.factory())
.createPair(address(this), router.WETH());
// Mint total supply to deployer
_mint(msg.sender, MAX_SUPPLY);
}
Three parameters are passed at deployment:
- Tax wallet address
- Initial owner address
- PancakeSwap V2 Router address
The constructor is the only place _mint is called. After deployment, no new tokens can ever be created.
Verification on BscScan
We verified the contract using BscScan's compiler verification:
- Compiler: Solidity v0.8.20
- Optimization: Yes (200 runs)
- EVM Version: Paris
The verified source code matches the deployed bytecode exactly — anyone can read and audit it.
Lessons Learned
Use constants aggressively. Any parameter that should never change after deployment should be a
constant. Don't rely on governance promises.Timelock everything else. Parameters that need flexibility should have a mandatory delay. 48 hours is a good minimum.
Don't write security code yourself. OpenZeppelin exists for a reason. Use
ReentrancyGuard, not custom mutex patterns.Test with anti-whale limits. Our first testnet deployment failed because the anti-whale limits blocked the initial liquidity add. Exclude necessary addresses from the start.
Verify your source code immediately. An unverified contract is a red flag. Verify on BscScan the same day you deploy.
Resources
- Contract: View on BscScan
- OpenZeppelin v5 Docs: docs.openzeppelin.com
- Website: nsnas.net
Questions about the implementation? Drop a comment below or reach out on Twitter.
Top comments (0)