Skip to content

Latest commit

 

History

History
852 lines (668 loc) · 27 KB

File metadata and controls

852 lines (668 loc) · 27 KB

Architecture & Design Decisions

Author: Wesley Santos
Project: SimpleToken - ERC20 with Transfer Fees
Purpose: Document design rationale, trade-offs, and alternatives considered
Last Updated: February 2026


Table of Contents

  1. Design Philosophy
  2. Architecture Overview
  3. Key Design Decisions
  4. Trade-Offs Analysis
  5. Alternative Approaches
  6. Scalability Considerations
  7. Integration Patterns
  8. Future Extensions

Design Philosophy

Core Principles

  1. Simplicity Over Complexity

    • Minimize contract complexity to reduce attack surface
    • Use established patterns (OpenZeppelin) over custom implementations
    • Clear, readable code over clever optimizations
  2. Security First

    • Fail-safe defaults (pausable, max limits)
    • Input validation on all external functions
    • No experimental features or unaudited code
  3. Gas Efficiency

    • Custom errors instead of string messages
    • Efficient storage layout
    • Minimal state changes per transaction
  4. Extensibility

    • Modular design allows future enhancements
    • Standard interfaces (ERC20) enable ecosystem integration
    • Events for off-chain indexing and monitoring

Architecture Overview

Component Diagram

┌─────────────────────────────────────────────────────────┐
│                     SimpleToken                         │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ┌─────────────┐   ┌─────────────┐   ┌─────────────┐    │
│  │    ERC20    │   │   Ownable   │   │   Pausable  │    │
│  │ (OpenZep)   │   │  (OpenZep)  │   │  (OpenZep)  │    │
│  └──────┬──────┘   └──────┬──────┘   └──────┬──────┘    │
│         │                 │                 │           │
│         └─────────────────┴─────────────────┘           │
│                           │                             │
│              ┌────────────┴────────────┐                │
│              │  SimpleToken Contract   │                │
│              └────────────┬────────────┘                │
│                           │                             │
│         ┌─────────────────┼─────────────────┐           │
│         │                 │                 │           │
│      ┌────▼────┐     ┌─────▼─────┐    ┌─────▼─────┐     │
│      │  Core   │     │   Admin   │    │ View/Info │     │
│      │Transfer │     │ Functions │    │ Functions │     │
│      │  Logic  │     │           │    │           │     │
│      └─────────┘     └───────────┘    └───────────┘     │
│         │                  │                │           │
│         │                  │                │           │
│    ┌────▼────┐       ┌─────▼─────┐    ┌─────▼─────┐     │
│    │  Fee    │       │ Whitelist │    │  Events   │     │
│    │Calculation│     │ Management│    │  & Errors │     │
│    └─────────┘       └───────────┘    └───────────┘     │
│                                                         │
└─────────────────────────────────────────────────────────┘

State Machine

┌──────────────────────────────────────────────────────┐
│              Contract State Transitions               │
├──────────────────────────────────────────────────────┤
│                                                       │
│   ┌──────────┐      pause()      ┌──────────┐       │
│   │  Active  │ ──────────────────>│  Paused  │       │
│   │  State   │                    │  State   │       │
│   │          │ <──────────────────│          │       │
│   └────┬─────┘      unpause()    └──────────┘       │
│        │                                              │
│        │ Transactions allowed                         │
│        │ - transfer()                                 │
│        │ - transferFrom()                             │
│        │ - approve()                                  │
│        │                                              │
│        │ Admin functions (always available):          │
│        │ - setFeePercent()                            │
│        │ - addToWhitelist()                           │
│        │ - removeFromWhitelist()                      │
│        │ - setMaxTransactionAmount()                  │
│        │ - pause() / unpause()                        │
│        │                                              │
└──────────────────────────────────────────────────────┘

Key Design Decisions

1. Fee Mechanism: Burn vs Redistribution

Decision: Automatic Burning

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

2. Whitelist vs Blacklist

Decision: Whitelist for Fee Exemption

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 ⚠️ Compliance complexity

Why Whitelist Won:

  • Clearer intent (exemption is privilege)
  • Better for DEX/liquidity pool integration
  • Lower risk of censorship accusations
  • Easier to audit and understand

3. Ownable vs AccessControl

Decision: Ownable (Single Owner)

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 ⚠️ Acceptable for small projects ✅ Recommended

For This Project:

  • ✅ Ownable chosen for educational clarity
  • ⚠️ Production version should use AccessControl
  • 📝 Documented in PRODUCTION_READINESS.md

4. Pausable: Global vs Selective

Decision: Global Pause (All or Nothing)

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

5. Fee Storage: Basis Points vs Percentage

Decision: Basis Points (1 bp = 0.01%)

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

6. Max Transaction Limit: Optional vs Required

Decision: Optional (Can be 0 = Disabled)

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

Trade-Offs Analysis

Gas Efficiency vs Feature Richness

Current Balance: Moderate Efficiency, Essential Features

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)


Centralization vs Decentralization

Current State: Centralized (Owner Control)

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!

Immutability vs Upgradability

Decision: Immutable (No Proxy)

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 ⚠️ Upgrade vulnerability
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:

  1. Educational Focus: Easier to understand without proxy complexity
  2. Security: No upgrade attack vector
  3. Trust: Users know code won't change
  4. 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

Alternative Approaches Considered

1. Uniswap V2 Fee Model (Swap Fees)

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 simpler

2. Compound-Style Governance Token

Approach: 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 extension

3. Staking Integration (Auto-Compound)

Approach: 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 3

Scalability Considerations

Layer 2 Compatibility

Current 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)

High-Volume Scenarios

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 well

Integration Patterns

DEX Integration (Uniswap, SushiSwap)

Recommended 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 applied

Why 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)

Multisig Integration (Gnosis Safe)

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]);

Monitoring & Analytics (The Graph)

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

Future Extensions

Potential Enhancements (Not in Scope for v1)

1. Dynamic Fee Model

// 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)
}

2. Fee Destination Voting

// 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);
    // ...
}

3. Time-Based Fee Reduction

// 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;
}

4. Snapshot Voting Integration

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
}

Conclusion

What We Built

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

What We Learned

  • 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

What's Next

  1. For Learning: Implement challenges from CHALLENGES.md
  2. For Portfolio: Deploy to testnet, add frontend
  3. For Production: Follow PRODUCTION_READINESS.md checklist

Document Status: Complete v1.0
Maintenance: Update when significant changes occur
Audience: Developers, auditors, employers, fellow learners