DEV Community

NSNAS Pay
NSNAS Pay

Posted on

Building a Reflection Token on BSC with OpenZeppelin v5 — Architecture & Security Patterns

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
Enter fullscreen mode Exit fullscreen mode

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 controlOwnable now 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;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

How It Works

  1. Owner proposes a change → recorded on-chain with block.timestamp + TIMELOCK_DELAY
  2. 48 hours must pass — visible to everyone on BscScan
  3. Owner executes the change after the delay
  4. 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;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Half the liquidity tax is swapped to BNB via PancakeSwap
  2. The other half remains as NSNAS
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

Additionally, we use a boolean flag to prevent recursive calls during the swap:

bool private inSwap;
modifier lockTheSwap {
    inSwap = true;
    _;
    inSwap = false;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Use constants aggressively. Any parameter that should never change after deployment should be a constant. Don't rely on governance promises.

  2. Timelock everything else. Parameters that need flexibility should have a mandatory delay. 48 hours is a good minimum.

  3. Don't write security code yourself. OpenZeppelin exists for a reason. Use ReentrancyGuard, not custom mutex patterns.

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

  5. Verify your source code immediately. An unverified contract is a red flag. Verify on BscScan the same day you deploy.

Resources


Questions about the implementation? Drop a comment below or reach out on Twitter.

Top comments (0)