Author: Wesley Santos
Project: SimpleToken - ERC20 with Transfer Fees
Purpose: Document design rationale, trade-offs, and alternatives considered
Last Updated: February 2026
- Design Philosophy
- Architecture Overview
- Key Design Decisions
- Trade-Offs Analysis
- Alternative Approaches
- Scalability Considerations
- Integration Patterns
- Future Extensions
-
Simplicity Over Complexity
- Minimize contract complexity to reduce attack surface
- Use established patterns (OpenZeppelin) over custom implementations
- Clear, readable code over clever optimizations
-
Security First
- Fail-safe defaults (pausable, max limits)
- Input validation on all external functions
- No experimental features or unaudited code
-
Gas Efficiency
- Custom errors instead of string messages
- Efficient storage layout
- Minimal state changes per transaction
-
Extensibility
- Modular design allows future enhancements
- Standard interfaces (ERC20) enable ecosystem integration
- Events for off-chain indexing and monitoring
┌─────────────────────────────────────────────────────────┐
│ SimpleToken │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ ERC20 │ │ Ownable │ │ Pausable │ │
│ │ (OpenZep) │ │ (OpenZep) │ │ (OpenZep) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └─────────────────┴─────────────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ │ SimpleToken Contract │ │
│ └────────────┬────────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ │ │ │ │
│ ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ Core │ │ Admin │ │ View/Info │ │
│ │Transfer │ │ Functions │ │ Functions │ │
│ │ Logic │ │ │ │ │ │
│ └─────────┘ └───────────┘ └───────────┘ │
│ │ │ │ │
│ │ │ │ │
│ ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ Fee │ │ Whitelist │ │ Events │ │
│ │Calculation│ │ Management│ │ & Errors │ │
│ └─────────┘ └───────────┘ └───────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Contract State Transitions │
├──────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ pause() ┌──────────┐ │
│ │ Active │ ──────────────────>│ Paused │ │
│ │ State │ │ State │ │
│ │ │ <──────────────────│ │ │
│ └────┬─────┘ unpause() └──────────┘ │
│ │ │
│ │ Transactions allowed │
│ │ - transfer() │
│ │ - transferFrom() │
│ │ - approve() │
│ │ │
│ │ Admin functions (always available): │
│ │ - setFeePercent() │
│ │ - addToWhitelist() │
│ │ - removeFromWhitelist() │
│ │ - setMaxTransactionAmount() │
│ │ - pause() / unpause() │
│ │ │
└──────────────────────────────────────────────────────┘
Rationale:
// CHOSEN APPROACH: Burn tokens
function _transferWithFee(address from, address to, uint256 amount) {
uint256 fee = (amount * feePercent) / 10000;
_burn(from, fee); // Removes from supply
super._transfer(from, to, amount - fee);
}
// ALTERNATIVE: Redistribute to holders (RFI model)
// NOT CHOSEN due to complexity and gas costs
function _transferWithFee(address from, address to, uint256 amount) {
uint256 fee = (amount * feePercent) / 10000;
uint256 feePerHolder = fee / holderCount; // GAS INTENSIVE
// Loop through all holders - EXPENSIVE!
for (uint i = 0; i < holders.length; i++) {
balances[holders[i]] += feePerHolder;
}
}Trade-offs:
| Aspect | Burn (Chosen) | Redistribute (RFI) |
|---|---|---|
| Gas Cost | ✅ Low (~65k gas) | ❌ Very High (>500k gas) |
| Complexity | ✅ Simple | ❌ Complex (holder tracking) |
| Holder Benefit | 🔶 Indirect (scarcity) | ✅ Direct (rewards) |
| Total Supply | 📉 Decreasing | 📊 Constant |
| MEV Risk | 🟢 Low | 🔴 High (reflection gaming) |
Why This Matters:
- Burning is deterministic and predictable
- No state bloat from tracking all holders
- Simpler for DEX integration
- Lower attack surface
Rationale:
// CHOSEN: Whitelist (opt-in exemption)
mapping(address => bool) public isWhitelisted;
function _transferWithFee(address from, address to, uint256 amount) {
if (isWhitelisted[from] || isWhitelisted[to]) {
super._transfer(from, to, amount); // No fee
return;
}
// Apply fee...
}
// ALTERNATIVE: Blacklist (opt-in restriction)
// NOT CHOSEN - more risky for legitimate users
mapping(address => bool) public isBlacklisted;
function _transferWithFee(address from, address to, uint256 amount) {
require(!isBlacklisted[from] && !isBlacklisted[to], "Blacklisted");
// Apply fee...
}Comparison:
| Criterion | Whitelist (Chosen) | Blacklist (Alternative) |
|---|---|---|
| Default Behavior | Fee applies to all | No restrictions |
| Use Case | DEX, Treasury exemptions | Sanction compliance |
| Admin Burden | 🔶 Medium (add trusted) | 🔴 High (track bad actors) |
| User Risk | 🟢 Low (explicit exemption) | 🔴 High (can be blocked) |
| Regulatory | 🟢 Neutral |
Why Whitelist Won:
- Clearer intent (exemption is privilege)
- Better for DEX/liquidity pool integration
- Lower risk of censorship accusations
- Easier to audit and understand
Rationale:
// CHOSEN: Single owner (educational simplicity)
import "@openzeppelin/contracts/access/Ownable.sol";
contract SimpleToken is ERC20, Ownable {
function setFeePercent(uint256 fee) external onlyOwner { }
function addToWhitelist(address user) external onlyOwner { }
}
// ALTERNATIVE: Role-based access control
// BETTER FOR PRODUCTION - but more complex
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SimpleToken is ERC20, AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE");
function setFeePercent(uint256 fee) external onlyRole(FEE_MANAGER_ROLE) { }
function pause() external onlyRole(PAUSER_ROLE) { }
}Decision Matrix:
| Feature | Ownable | AccessControl |
|---|---|---|
| Simplicity | ✅ Very simple | 🔶 More complex |
| Granularity | ❌ All-or-nothing | ✅ Fine-grained roles |
| Gas Cost | ✅ Lower | 🔶 Slightly higher |
| Security | 🔶 Single point of failure | ✅ Separation of duties |
| Learning Curve | ✅ Easy | 🔶 Moderate |
| Production Ready | ✅ Recommended |
For This Project:
- ✅ Ownable chosen for educational clarity
⚠️ Production version should use AccessControl- 📝 Documented in PRODUCTION_READINESS.md
Current Implementation:
import "@openzeppelin/contracts/utils/Pausable.sol";
function transfer(address to, uint256 amount)
public
override
whenNotPaused // Global pause
returns (bool)
{
_transferWithFee(msg.sender, to, amount);
return true;
}
// ALTERNATIVE: Selective pause (partial lockdown)
// NOT IMPLEMENTED - more complexity
struct PauseConfig {
bool transfersPaused;
bool adminFunctionsPaused;
bool approvalsAllowed;
}
PauseConfig public pauseStatus;
modifier whenTransfersNotPaused() {
require(!pauseStatus.transfersPaused, "Transfers paused");
_;
}Trade-off Analysis:
| Aspect | Global Pause | Selective Pause |
|---|---|---|
| Emergency Response | ✅ Fast, decisive | 🔶 Requires more decisions |
| User Impact | 🔴 All functions blocked | 🟢 Some functions available |
| Complexity | ✅ Simple boolean | 🔴 Multiple flags to manage |
| Testing | ✅ Easy to test | 🔶 More test cases needed |
| Attack Surface | 🟢 Smaller | 🔴 Larger (more logic) |
Why Global Pause:
- Emergency situations require immediate, total shutdown
- Simpler to reason about security
- Matches OpenZeppelin standard pattern
- Easier for auditors to verify
When Selective Would Be Better:
- Complex DeFi protocols with multiple modules
- Want to allow withdrawals during investigation
- Different trust levels for different functions
Implementation:
// CHOSEN: Basis points (10000 = 100%)
uint256 public feePercent = 200; // 2.00%
uint256 private constant MAX_FEE_PERCENT = 1000; // 10.00%
function calculateFee(uint256 amount) internal view returns (uint256) {
return (amount * feePercent) / 10000;
}
// ALTERNATIVE 1: Percentage (100 = 100%)
// NOT CHOSEN - less precision
uint256 public feePercent = 2; // 2% (no decimals possible)
return (amount * feePercent) / 100;
// ALTERNATIVE 2: Permyriad (1000000 = 100%)
// NOT CHOSEN - overkill precision
uint256 public feePermyriad = 20000; // 2%
return (amount * feePermyriad) / 1000000;Precision Comparison:
| Method | Smallest Unit | Example (2.5%) | Pros | Cons |
|---|---|---|---|---|
| Basis Points | 0.01% | 250 | ✅ Industry standard | 🔶 Max 100% |
| Percentage | 1% | 2 or 3 | ✅ Simple | ❌ No decimals |
| Permyriad | 0.0001% | 25000 | ✅ High precision | ❌ Overkill, confusing |
Why Basis Points:
- Standard in finance (TradFi and DeFi)
- Sufficient precision (0.01% increments)
- Easy to understand (200 = 2%)
- Used by Uniswap, Curve, other protocols
Code:
uint256 public maxTransactionAmount = 0; // 0 = unlimited
function _transferWithFee(address from, address to, uint256 amount) {
// Only check if limit is set (> 0)
if (maxTransactionAmount > 0 && amount > maxTransactionAmount) {
revert MaxTransactionExceeded(amount, maxTransactionAmount);
}
// Continue...
}
// ALTERNATIVE: Always enforce a limit
// NOT CHOSEN - too restrictive
uint256 public maxTransactionAmount = 10000 * 10**18; // Always set
function _transferWithFee(...) {
// Always check (no way to disable)
require(amount <= maxTransactionAmount, "Exceeds max");
}Flexibility vs Security:
| Approach | Flexibility | Anti-Whale Protection | Gas Cost |
|---|---|---|---|
| Optional (Chosen) | ✅ High | 🔶 Owner controlled | ✅ Slightly lower (skip check if 0) |
| Required | ❌ Limited | ✅ Always enforced | 🔶 Always checks |
Reasoning:
- Different use cases need different limits
- DEX liquidity pools need unlimited transfers
- Can be enabled/disabled by owner as needed
- More flexible for testing and integration
Gas Costs (approximate):
Operation | Gas Used | Industry Average | Verdict
---------------------|----------|------------------|----------
transfer() | ~65,000 | ~50,000-80,000 | ✅ Acceptable
transferFrom() | ~75,000 | ~60,000-90,000 | ✅ Acceptable
approve() | ~46,000 | ~45,000-50,000 | ✅ Good
setFeePercent() | ~45,000 | N/A | ✅ Good
addToWhitelist() | ~48,000 | N/A | ✅ Good
// For comparison:
USDT transfer() | ~60,000 | (No fee mechanism)
Uniswap swap() | ~120,000 | (Complex AMM logic)
SafeMoon transfer() | ~180,000 | (Reflection to all holders)What We Sacrificed for Efficiency:
❌ Holder reflection (would cost 500k+ gas)
❌ Complex fee distribution logic
❌ Automatic liquidity provision
❌ Dynamic fee adjustments
What We Kept:
✅ Burn mechanism (simple, effective)
✅ Whitelist (essential for DEX)
✅ Pausable (security requirement)
✅ Max transaction limit (anti-whale)
Power Distribution:
Owner (100% control):
├── Change fee (0-10%)
├── Pause/unpause contract
├── Manage whitelist
├── Set transaction limits
└── Transfer ownership
Users (No control):
├── Transfer tokens (when not paused)
├── Approve spenders
└── View balances
// FUTURE: Progressive decentralization
Stage 1 (Current): Owner → All power
Stage 2 (Testnet): Owner → Multi-sig (3/5)
Stage 3 (Production): Multi-sig → DAO governance
Stage 4 (Mature): DAO → Immutable (renounce ownership)
Justification:
- Educational project: Owner control is acceptable
- Allows easy demonstration of admin functions
- Can be upgraded to multi-sig before production
- Progressive decentralization is industry best practice
Production Path:
// Phase 1: Current (Single owner)
contract SimpleToken is ERC20, Ownable { }
// Phase 2: Multi-sig (Before mainnet)
// Deploy → transferOwnership(gnosisSafeAddress)
// Phase 3: DAO Governance (Mature project)
import "@openzeppelin/contracts/governance/Governor.sol";
contract TokenGovernor is Governor { }
// SimpleToken.transferOwnership(address(governor))
// Phase 4: Immutable (If appropriate)
// SimpleToken.renounceOwnership()
// WARNING: No more admin functions possible!Current Architecture:
// CHOSEN: Immutable contract (no proxy)
contract SimpleToken is ERC20, Ownable, Pausable {
// Code is final after deployment
// No upgradeability
}
// ALTERNATIVE: Upgradeable proxy (UUPS or Transparent)
// NOT CHOSEN for educational version
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract SimpleToken is Initializable, UUPSUpgradeable {
function initialize() public initializer { }
function _authorizeUpgrade(address newImplementation)
internal
onlyOwner
override
{ }
}Comparison:
| Aspect | Immutable (Chosen) | Upgradeable (Alternative) |
|---|---|---|
| Security | ✅ No upgrade risk | |
| Simplicity | ✅ Straightforward | 🔴 Complex (proxy + impl) |
| Gas Cost | ✅ Lower | 🔶 Higher (delegatecall) |
| Flexibility | ❌ No bug fixes | ✅ Can patch issues |
| Trust | ✅ Code is final | 🔴 Owner can change logic |
| Audit Cost | ✅ One-time | 🔴 Per upgrade |
Why Immutable for This Project:
- Educational Focus: Easier to understand without proxy complexity
- Security: No upgrade attack vector
- Trust: Users know code won't change
- Best Practice: Start simple, add complexity if needed
When Upgradeable Makes Sense:
- Complex DeFi protocols with evolving logic
- Projects expecting to add features over time
- Well-funded teams with regular audits
- Established governance for upgrade decisions
Approach: Fee only on DEX swaps, not peer-to-peer transfers
// NOT IMPLEMENTED - Alternative design
interface IUniswapV2Pair {
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}
// Would require:
// - Detecting DEX contracts (whitelist/blacklist)
// - Different fee structure for swaps vs transfers
// - Integration with Uniswap router logic
// Why not chosen:
// - More complex to implement
// - Requires maintaining list of all DEXs
// - Educational project should be simplerApproach: Token doubles as voting power
// NOT IMPLEMENTED - Future enhancement
import "@openzeppelin/contracts/governance/extensions/ERC20Votes.sol";
contract SimpleToken is ERC20, ERC20Votes, Ownable {
function _afterTokenTransfer(address from, address to, uint256 amount)
internal
override(ERC20, ERC20Votes)
{
super._afterTokenTransfer(from, to, amount);
}
// Delegation and voting functions
}
// Why not chosen for v1:
// - Adds significant complexity
// - Not needed for basic fee token
// - Better as separate project or future extensionApproach: Transfer fees go to staking rewards
// NOT IMPLEMENTED - Alternative fee destination
contract SimpleToken is ERC20, Ownable {
address public stakingContract;
function _transferWithFee(address from, address to, uint256 amount) {
uint256 fee = (amount * feePercent) / 10000;
// Instead of burning:
_transfer(from, stakingContract, fee); // Send to staking
IStaking(stakingContract).notifyReward(fee);
_transfer(from, to, amount - fee);
}
}
// Why not chosen:
// - Requires separate staking contract
// - More complex architecture
// - Gas costs increase
// - Better as Project 2 or 3Current Status: ✅ Fully compatible with L2s
// SimpleToken uses standard ERC20 interface
// No L1-specific features (e.g., no block.difficulty)
// Works on:
// ✅ Ethereum Mainnet
// ✅ Arbitrum
// ✅ Optimism
// ✅ Polygon
// ✅ Base
// ✅ zkSync Era
// ✅ Starknet (via Cairo wrapper)Gas Savings on L2:
// Ethereum Mainnet:
transfer() = ~65,000 gas × $30/M gas = $1.95
// Arbitrum:
transfer() = ~65,000 gas × $0.10/M gas = $0.0065
// Optimism:
transfer() = ~65,000 gas × $0.15/M gas = $0.01
// Polygon:
transfer() = ~65,000 gas × $50/M gas = $3.25 (in MATIC)Stress Test Results (simulated):
// Test: 1000 transfers in rapid succession
Scenario: DEX trading bot making many small trades
Results:
├── Block gas limit: 30M gas
├── Gas per transfer: 65k
├── Max transfers per block: ~460
├── Realistic throughput: ~200 tx/block (accounting for other transactions)
└── Daily volume: ~28,800 transactions (15s blocks)
Bottlenecks:
❌ Network throughput (blockchain limitation)
❌ Not contract-specific
✅ Contract handles high volume wellRecommended Setup:
// 1. Add liquidity pool to whitelist
SimpleToken.addToWhitelist(uniswapV2PairAddress);
SimpleToken.addToWhitelist(uniswapV2RouterAddress);
// 2. Create pair
IUniswapV2Factory.createPair(address(SimpleToken), address(WETH));
// 3. Add initial liquidity
SimpleToken.approve(routerAddress, liquidityAmount);
IUniswapV2Router.addLiquidityETH{value: ethAmount}(
address(SimpleToken),
tokenAmount,
...
);
// Result:
// ✅ Pool → User: No fee (whitelisted)
// ✅ User → Pool: 2% fee applied
// ✅ User → User: 2% fee appliedWhy Whitelist the Pool:
- Pool shouldn't pay fees when sending tokens (users buying)
- Users should pay fees when sending to pool (users selling)
- Creates buy pressure (buy cheaper than sell)
Production Deployment Flow:
// 1. Deploy SimpleToken
const token = await SimpleToken.deploy(initialSupply);
// 2. Deploy Gnosis Safe (or use existing)
const safe = await Safe.deploy([owner1, owner2, owner3], 2);
// 3. Add Safe to whitelist (for admin operations)
await token.addToWhitelist(safe.address);
// 4. Transfer ownership to Safe
await token.transferOwnership(safe.address);
// 5. All future admin actions require 2/3 signatures
// Example: Change fee
const tx = await safe.proposeTransaction({
to: token.address,
data: token.interface.encodeFunctionData("setFeePercent", [300])
});
await safe.executeTransaction(tx, [sig1, sig2]);Subgraph Schema (example):
# NOT IMPLEMENTED - Future enhancement
type Token @entity {
id: ID!
name: String!
symbol: String!
totalSupply: BigInt!
totalBurned: BigInt!
feePercent: Int!
}
type Transfer @entity {
id: ID!
from: Bytes!
to: Bytes!
value: BigInt!
fee: BigInt!
timestamp: BigInt!
}
type FeeChange @entity {
id: ID!
oldFee: Int!
newFee: Int!
timestamp: BigInt!
}Why This Matters:
- Off-chain indexing for fast queries
- Historical data analysis
- Dashboard creation (burn rate, volume, etc.)
// Fee adjusts based on transaction size
function calculateDynamicFee(uint256 amount) internal view returns (uint256) {
if (amount < 1000 * 10**18) return 200; // 2% for small
if (amount < 10000 * 10**18) return 300; // 3% for medium
return 500; // 5% for large (whale)
}// Token holders vote on where fees go
enum FeeDestination { BURN, STAKING, TREASURY, LIQUIDITY }
mapping(address => FeeDestination) public voteFor;
function _transferWithFee(address from, address to, uint256 amount) {
uint256 fee = calculateFee(amount);
FeeDestination dest = getWinningDestination();
if (dest == FeeDestination.BURN) _burn(from, fee);
else if (dest == FeeDestination.STAKING) _transfer(from, stakingPool, fee);
// ...
}// Holding reward: fees decrease over time
mapping(address => uint256) public lastTransferTime;
function calculateFee(address from, uint256 amount) internal view returns (uint256) {
uint256 holdTime = block.timestamp - lastTransferTime[from];
uint256 discount = holdTime / 30 days * 50; // 0.5% discount per month
uint256 effectiveFee = feePercent > discount ? feePercent - discount : 0;
return (amount * effectiveFee) / 10000;
}import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
contract SimpleToken is ERC20, ERC20Snapshot, Ownable {
function snapshot() external onlyOwner returns (uint256) {
return _snapshot();
}
// Enables off-chain voting (Snapshot.org)
// No gas costs for voting
}A production-quality ERC20 token with transfer fees, demonstrating:
- ✅ Solid understanding of Solidity fundamentals
- ✅ Security-conscious design decisions
- ✅ Trade-off analysis and documentation
- ✅ Integration planning
- ✅ Scalability awareness
- Design is about trade-offs, not perfect solutions
- Simplicity is a feature, not a limitation
- Documentation of decisions is as important as code
- Security must be considered from day one
- Production readiness requires more than just working code
- For Learning: Implement challenges from CHALLENGES.md
- For Portfolio: Deploy to testnet, add frontend
- For Production: Follow PRODUCTION_READINESS.md checklist
Document Status: Complete v1.0
Maintenance: Update when significant changes occur
Audience: Developers, auditors, employers, fellow learners