Detail code in this branch: https://github.com/leonleerl/bank/tree/1-add-bank-internal-transfer
At present, the funds can only be in or out. You can implement a function that allows users to directly transfer their balance within the bank to another address without having to withdraw money first and then deposit it.
- Challenge: Learn how to securely handle balance increases and decreases in two accounts within a contract.
// successful scenario
function test_InternalTransfer_Success() public {
// user1 deposits 5 ether, transfers 2 ether to user2
}
// failed scenario
function test_InternalTransfer_InsufficientBalance() public {
// insufficient funds
}
function test_InternalTransfer_ZeroAddress() public {
// transfer to zero address
}
function test_InternalTransfer_SelfTransfer() public {
// transfer to self
}
function test_InternalTransfer_ZeroAmount() public {
// zero amount
}
// boundry scenario
function test_InternalTransfer_AllBalance() public {
// transfer all
}
function test_InternalTransfer_BankBalanceUnchanged() public {
// check whether the bank balance is unchanged
}Detail code in this branch: https://github.com/leonleerl/bank/tree/3-time-locked-deposit
Introducing time-based logic. Users can deposit money and set a withdrawal timeframe (e.g., 7 days later).
- Challenge: Learning to use
block.timestampand handling time-basedrequirelogic.
Implemented a time-locked deposit mechanism that allows users to lock their funds for a specified duration and withdraw them only after the lock period expires.
- Create Fixed Deposits: Users can create fixed-term deposits with customizable lock periods (minimum 2 days)
- Time-based Withdrawal Control: Funds can only be withdrawn after the lock period expires using
block.timestamp - Multiple Deposits Support: Each user can maintain multiple fixed deposits simultaneously
- Withdrawal Protection: Prevents duplicate withdrawals and early withdrawals before maturity
- Event Tracking: Comprehensive event logging for deposit creation and withdrawals
struct FixedDeposit {
uint256 amount; // Deposit amount
uint256 depositTime; // Deposit creation timestamp
uint256 unlockTime; // Unlock timestamp (depositTime + duration)
bool isWithdrawn; // Withdrawal status flag
}createFixedDeposit(uint256 _amount, uint256 _duration)- Create a new time-locked depositwithdrawFixedDeposit(uint256 _depositIndex)- Withdraw a matured depositgetFixedDepositDetails(uint256 _depositIndex)- Query deposit information
- ✅ Reentrancy protection using OpenZeppelin's
ReentrancyGuard - ✅ Custom errors for gas-efficient error handling
- ✅ Comprehensive input validation
- ✅ Duplicate withdrawal prevention
- ✅ Time-based access control using
block.timestamp
Comprehensive test suite covering:
- ✅ Successful deposit creation and withdrawal
- ✅ Early withdrawal rejection (before maturity)
- ✅ Duplicate withdrawal prevention
- ✅ Invalid deposit index handling
- ✅ Multiple deposits management
- ✅ Edge cases and boundary conditions
Detail code in this branch: https://github.com/leonleerl/bank/tree/5-erc20-support
Extended the bank contract to support ERC20 tokens in addition to ETH, enabling users to deposit, withdraw, and transfer multiple types of tokens within the bank system.
- Dual Balance System: Separate tracking for ETH (
ethBalances) and ERC20 tokens (tokenBalances) - Multi-Token Support: Users can hold balances in multiple different ERC20 tokens simultaneously
- Unified Interface: Consistent function patterns for both ETH and token operations
Token Deposits
depositToken(address _token, uint256 _amount)- Deposit ERC20 tokens into the bank- Utilises the Approve-TransferFrom pattern for secure token transfers
- Validates token address and amount before processing
Token Withdrawals
withdrawToken(address _token, uint256 _amount)- Withdraw ERC20 tokens from the bank- Checks both user balance and contract's token holdings
- Implements CEI (Checks-Effects-Interactions) pattern for security
Internal Token Transfers
internalTransferToken(address _token, address _to, uint256 _amount)- Transfer tokens between users within the bank- No actual token movement on blockchain (gas-efficient)
- Only updates internal balance ledger
getBankTotalTokenBalance(address _token)- View total token balance held by the bankgetUserTokenBalance(address _user, address _token)- View user's wallet token balancetokenBalances(address user, address token)- View user's token balance in the bank (public mapping)
// Nested mapping for multi-token balance tracking
mapping(address => mapping(address => uint256)) public tokenBalances;
// tokenBalances[user][tokenAddress] = balance
// Enhanced FixedDeposit structure with token support
struct FixedDeposit {
uint256 amount;
address token; // Token address (address(0) for ETH)
uint256 depositTime;
uint256 unlockTime;
bool isWithdrawn;
}event LogDepositToken(address indexed sender, address indexed token, uint256 amount);
event LogWithdrawToken(address indexed user, address indexed token, uint256 amount);
event LogInternalTransferToken(address indexed from, address indexed to, address indexed token, uint256 amount);- ✅ Reentrancy protection on all token deposit/withdrawal functions
- ✅ Comprehensive input validation (zero address, zero amount, self-transfer checks)
- ✅ Balance verification before transfers
- ✅ CEI pattern implementation for state changes
- ✅ Custom errors for gas-efficient error handling
Understanding the Two-Step Process:
- User Approval (executed on the token contract):
// User approves the bank to spend their tokens
token.approve(bankAddress, amount);- Bank Transfer (executed on the bank contract):
// Bank transfers tokens from user to itself
IERC20(token).transferFrom(msg.sender, address(this), amount);Why This Pattern?
- Security: Users explicitly authorise how much the contract can spend
- Flexibility: One approval can cover multiple deposits
- Standard: Universal pattern across all ERC20 DeFi protocols
Key Differences:
| Function | Use Case | Direction |
|---|---|---|
transferFrom(from, to, amount) |
Deposit tokens | User → Bank |
transfer(to, amount) |
Withdraw tokens | Bank → User |
Mock Token Setup:
// Created MockToken contract for testing
contract MockToken is ERC20 {
constructor() ERC20("MockToken", "MTK") {
_mint(msg.sender, 1000000 * 10**18);
}
function mint(address _to, uint256 _amount) external {
_mint(_to, _amount);
}
}Test Scenarios:
- ✅ Successful token deposits with proper approval
- ✅ Successful token withdrawals
- ✅ Internal token transfers between users
- ✅ Insufficient balance errors
- ✅ Zero amount validation
- ✅ Zero address validation
- ✅ Self-transfer prevention
- ✅ Bank balance verification after operations
// 1. User approves bank to spend tokens (on token contract)
USDT.approve(bankAddress, 1000 * 10**6);
// 2. User deposits tokens (on bank contract)
bank.depositToken(USDTAddress, 500 * 10**6);
// 3. User transfers tokens internally (on bank contract)
bank.internalTransferToken(USDTAddress, recipientAddress, 200 * 10**6);
// 4. User withdraws tokens (on bank contract)
bank.withdrawToken(USDTAddress, 100 * 10**6);- ERC20 support for fixed deposits (time-locked token deposits)
- Token whitelist mechanism for supported assets
- Batch operations for multiple tokens
- SafeERC20 wrapper for additional safety with non-standard tokens
- Interest-bearing token deposits
Detail code in this branch: https://github.com/leonleerl/bank/tree/7-interest-distribution
Implemented a pull-based interest distribution mechanism for both ETH and ERC20 tokens. Users earn interest on their deposits based on an annual interest rate set by the contract owner. The system uses simple interest (not compound interest), where earned interest is accumulated separately and does not automatically reinvest into the principal.
- Pull-based Distribution: Users actively claim their interest rather than automatic distribution (gas-efficient and scalable)
- Simple Interest Model: Interest = Principal × Rate × Time
- Multi-Asset Support: Separate interest tracking for ETH and each ERC20 token
- Dynamic Interest Calculation: Interest accumulates per second based on current balance and time elapsed
Push Model (Not Implemented)
// Iterates through all users - NOT SCALABLE
function distributeInterest() external onlyOwner {
for (uint i = 0; i < allUsers.length; i++) {
balances[allUsers[i]] += calculateInterest(allUsers[i]);
}
}Problems:
- Gas cost grows linearly with user count
- May exceed block gas limit with 1000+ users
- Not viable in production DeFi
Pull Model (Implemented) ⭐
// User claims their own interest - SCALABLE
function claimInterest() external {
uint256 interest = calculateAccruedInterest(msg.sender);
// Transfer interest to user
}Advantages:
- Constant gas cost regardless of user count
- Industry standard (used by Compound, Aave, Uniswap)
- Infinitely scalable
ETH Interest Tracking
uint256 public annualInterestRate; // Global interest rate (e.g., 5 = 5%)
// Last time interest was calculated for each user
mapping(address => uint256) public lastInterestUpdate;
// Accumulated but unclaimed interest for each user
mapping(address => uint256) public pendingInterest;Token Interest Tracking
// Last update time: user → token → timestamp
mapping(address => mapping(address => uint256)) public tokenLastInterestUpdate;
// Pending interest: user → token → amount
mapping(address => mapping(address => uint256)) public tokenPendingInterest;The _updateInterest function is the heart of the interest system. It's called before every balance change to ensure accurate interest calculation.
Problem: Interest depends on three variables:
- Principal (changes with deposits/withdrawals)
- Interest Rate (fixed unless owner updates)
- Time (continuously increasing)
If we don't settle interest before balance changes, calculations become incorrect.
Day 0: User deposits 100 ETH
Day 182.5: User deposits another 100 ETH
Day 365: How much interest should they have?
❌ WRONG (without _updateInterest):
Interest = 200 ETH × 10% × 365 days = 20 ETH
✅ CORRECT (with _updateInterest):
First half: 100 ETH × 10% × 182.5 days = 5 ETH
Second half: 200 ETH × 10% × 182.5 days = 10 ETH
Total: 15 ETH
function _updateInterest(address _user) internal {
// Step 1: First-time initialization
if (lastInterestUpdate[_user] == 0) {
lastInterestUpdate[_user] = block.timestamp;
return; // No interest to calculate yet
}
// Step 2: Calculate time elapsed since last update
uint256 timePassed = block.timestamp - lastInterestUpdate[_user];
// Step 3: Get CURRENT principal (before it changes)
uint256 principal = ethBalances[_user];
// Step 4: Calculate NEW interest for this period
// Formula: Interest = Principal × Rate × Time / Year
uint256 newInterest = (principal * annualInterestRate * timePassed) / (365 days * 100);
// Step 5: Add to pending interest "savings jar"
pendingInterest[_user] += newInterest;
// Step 6: Reset the clock
lastInterestUpdate[_user] = block.timestamp;
// Step 7: Emit event
emit LogUpdateInterest(_user, newInterest);
}Timeline Example:
═══════════════════════════════════════════════════════════════
TIME: Day 0 Day 182.5 Day 365
ACTION: Deposit 100 Deposit 100 Query Interest
═══════════════════════════════════════════════════════════════
DAY 0 - First Deposit (100 ETH)
───────────────────────────────────────────────────────────────
deposit(100 ETH) called
↓
_updateInterest(user)
├─ lastInterestUpdate[user] == 0 ✓ (first time)
├─ lastInterestUpdate[user] = block.timestamp (Day 0)
└─ return (no interest yet)
↓
ethBalances[user] += 100 ETH
STATE AFTER:
ethBalances[user] = 100 ETH
lastInterestUpdate[user] = Day 0
pendingInterest[user] = 0
DAY 182.5 - Second Deposit (100 ETH)
───────────────────────────────────────────────────────────────
deposit(100 ETH) called
↓
_updateInterest(user) 👈 CRITICAL: Called BEFORE balance changes
├─ timePassed = Day 182.5 - Day 0 = 182.5 days
├─ principal = 100 ETH 👈 Uses OLD balance
├─ newInterest = 100 × 10% × 182.5/365 = 5 ETH
├─ pendingInterest[user] += 5 ETH 👈 Save to "jar"
└─ lastInterestUpdate[user] = Day 182.5 👈 Reset clock
↓
ethBalances[user] += 100 ETH = 200 ETH 👈 NOW balance changes
STATE AFTER:
ethBalances[user] = 200 ETH (balance changed)
lastInterestUpdate[user] = Day 182.5 (clock reset)
pendingInterest[user] = 5 ETH (saved first period interest)
DAY 365 - Query Interest
───────────────────────────────────────────────────────────────
getAccruedInterest(user) called
↓
calculateAccruedInterest(user)
├─ timePassed = Day 365 - Day 182.5 = 182.5 days
├─ principal = 200 ETH 👈 Uses NEW balance
├─ newInterest = 200 × 10% × 182.5/365 = 10 ETH
└─ return pendingInterest + newInterest
= 5 ETH + 10 ETH = 15 ETH ✓
TOTAL INTEREST: 15 ETH
= First period (100 ETH for 182.5 days) = 5 ETH
+ Second period (200 ETH for 182.5 days) = 10 ETH
This implementation follows the Checkpoint Pattern used in major DeFi protocols:
User Account Structure:
┌──────────────────────────┐
│ ethBalances │ → Current principal (for calculating NEW interest)
├──────────────────────────┤
│ pendingInterest │ → Interest "savings jar" (already earned)
├──────────────────────────┤
│ lastInterestUpdate │ → Clock (when was interest last settled)
└──────────────────────────┘
Every time balance changes:
- Calculate interest with OLD principal
- Put interest in the "savings jar" (
pendingInterest) - Reset the clock (
lastInterestUpdate) - Then change the balance
When querying total interest:
Total Interest = Savings Jar + Recently Earned
= pendingInterest + (currentBalance × rate × timeSinceLastUpdate)ETH Operations:
function deposit() external payable {
_updateInterest(msg.sender); // Settle interest BEFORE deposit
ethBalances[msg.sender] += msg.value;
}
function withdraw(uint256 _amount) external {
_updateInterest(msg.sender); // Settle interest BEFORE withdrawal
ethBalances[msg.sender] -= _amount;
// Transfer ETH
}
function internalTransfer(address _to, uint256 _amount) external {
_updateInterest(msg.sender); // Settle sender's interest
_updateInterest(_to); // Settle receiver's interest
// Transfer balances
}Token Operations:
function depositToken(address _token, uint256 _amount) external {
_updateTokenInterest(msg.sender, _token); // Settle before deposit
// Transfer and update balance
}
function withdrawToken(address _token, uint256 _amount) external {
_updateTokenInterest(msg.sender, _token); // Settle before withdrawal
// Transfer and update balance
}
function internalTransferToken(address _token, address _to, uint256 _amount) external {
_updateTokenInterest(msg.sender, _token); // Settle sender
_updateTokenInterest(_to, _token); // Settle receiver
// Transfer balances
}Interest Calculation (View Functions)
// Calculate total accrued interest for ETH
function calculateAccruedInterest(address _user) public view returns (uint256) {
if (lastInterestUpdate[_user] == 0) return 0;
uint256 timePassed = block.timestamp - lastInterestUpdate[_user];
uint256 principal = ethBalances[_user];
uint256 newInterest = (principal * annualInterestRate * timePassed) / (365 days * 100);
return pendingInterest[_user] + newInterest;
}
// Calculate total accrued interest for a specific token
function calculateTokenAccruedInterest(address _user, address _token)
public view returns (uint256)
{
if (tokenLastInterestUpdate[_user][_token] == 0) return 0;
uint256 timePassed = block.timestamp - tokenLastInterestUpdate[_user][_token];
uint256 principal = tokenBalances[_user][_token];
uint256 newInterest = (principal * annualInterestRate * timePassed) / (365 days * 100);
return tokenPendingInterest[_user][_token] + newInterest;
}Claiming Interest
// Claim ETH interest
function claimInterest() external nonReentrant {
uint256 interest = calculateAccruedInterest(msg.sender);
if (interest == 0) revert NoInterestToClaim();
// Verify bank has sufficient balance
if (address(this).balance < interest) {
revert InsufficientFunds(address(this).balance, interest);
}
// Transfer interest to user
(bool success,) = payable(msg.sender).call{value: interest}("");
require(success, "Transfer failed");
// Reset state
lastInterestUpdate[msg.sender] = block.timestamp;
pendingInterest[msg.sender] = 0;
emit LogClaimInterest(msg.sender, interest);
}
// Claim token interest
function claimTokenInterest(address _token) external nonReentrant {
uint256 interest = calculateTokenAccruedInterest(msg.sender, _token);
if (interest == 0) revert NoInterestToClaim();
// Verify bank has sufficient token balance
uint256 bankBalance = IERC20(_token).balanceOf(address(this));
if (bankBalance < interest) {
revert InsufficientFunds(bankBalance, interest);
}
// Transfer interest to user
bool success = IERC20(_token).transfer(msg.sender, interest);
require(success, "Transfer failed");
// Reset state
tokenLastInterestUpdate[msg.sender][_token] = block.timestamp;
tokenPendingInterest[msg.sender][_token] = 0;
emit LogClaimTokenInterest(msg.sender, _token, interest);
}Owner Functions
// Set annual interest rate (e.g., 5 = 5%)
function setInterestRate(uint256 _rate) external onlyOwner {
annualInterestRate = _rate;
emit LogSetInterestRate(msg.sender, _rate);
}event LogClaimInterest(address indexed user, uint256 amount);
event LogClaimTokenInterest(address indexed user, address indexed token, uint256 amount);
event LogSetInterestRate(address indexed owner, uint256 rate);
event LogUpdateInterest(address indexed user, uint256 interest);
event LogUpdateTokenInterest(address indexed user, address indexed token, uint256 interest);Current Implementation: Simple Interest
Year 1: Principal 100 ETH → Interest 10 ETH (goes to pendingInterest)
Year 2: Principal 100 ETH → Interest 10 ETH (goes to pendingInterest)
Year 3: Principal 100 ETH → Interest 10 ETH (goes to pendingInterest)
Total Interest = 30 ETH
Total Assets = 100 ETH (principal) + 30 ETH (interest) = 130 ETH
Key Points:
- Interest is stored in
pendingInterest - Interest does NOT automatically add to principal
- Next period calculates based on original principal
- User must manually claim interest (pulled to wallet)
Compound Interest (Not Implemented):
Year 1: Principal 100 ETH → Interest 10 ETH → Principal becomes 110 ETH
Year 2: Principal 110 ETH → Interest 11 ETH → Principal becomes 121 ETH
Year 3: Principal 121 ETH → Interest 12.1 ETH → Principal becomes 133.1 ETH
Total Assets = 133.1 ETH (all in principal)
Interest is paid from the bank's surplus (excess balance):
Bank Surplus = Total Bank Balance - Total User Deposits
Example:
- Total ETH in contract: 1000 ETH
- Total user deposits: 900 ETH
- Surplus available for interest: 100 ETHImportant: The contract verifies sufficient balance before paying interest to prevent insolvency.
// Owner sets 5% annual interest rate
bank.setInterestRate(5);
// User deposits 100 ETH
bank.deposit{value: 100 ether}();
// Wait 1 year (simulated)
vm.warp(block.timestamp + 365 days);
// Check accrued interest
uint256 interest = bank.getAccruedInterest(user); // Returns 5 ETH
// Claim interest
bank.claimInterest(); // User receives 5 ETH to their wallet
// For tokens
token.approve(address(bank), 100 ether);
bank.depositToken(address(token), 100 ether);
vm.warp(block.timestamp + 365 days);
uint256 tokenInterest = bank.getTokenAccruedInterest(user, address(token));
bank.claimTokenInterest(address(token));- Reentrancy Protection: All claim functions use
nonReentrantmodifier - Balance Verification: Checks bank has sufficient funds before paying interest
- CEI Pattern: Updates state before external calls
- Overflow Protection: Solidity 0.8+ has built-in overflow checks
- Access Control: Only owner can set interest rate
Compound Protocol:
- Uses a global exchange rate that increases over time
- Automatically compounds interest
- More gas-efficient for high-frequency operations
Aave Protocol:
- Uses a similar checkpoint pattern
- Supports variable interest rates
- More complex with liquidity provider mechanics
This Implementation:
- Educational focus on core concepts
- Simple interest for clarity
- Direct mapping to traditional banking
Detail code see this branch: https://github.com/leonleerl/bank/tree/9-pausable-and-blacklist
Implemented comprehensive security controls for emergency response and compliance. The contract owner can pause all operations globally or blacklist specific addresses to prevent malicious activity, respond to security incidents, or meet regulatory requirements.
Security Layers:
┌─────────────────────────────────────┐
│ Layer 1: Global Pause (Emergency) │ → All users affected
├─────────────────────────────────────┤
│ Layer 2: Blacklist (Targeted) │ → Specific addresses
└─────────────────────────────────────┘
Design Philosophy:
- Defense in Depth: Multiple security layers for comprehensive protection
- Granular Control: Choose between global shutdown vs targeted blocking
- Reversible Actions: Both mechanisms can be undone when issues are resolved
- Minimal Impact: View functions remain accessible during security events
Global emergency brake for the entire contract using OpenZeppelin's Pausable library.
Functionality:
- Pause: Immediately freeze all state-changing operations
- Unpause: Resume normal operations after issue resolution
- Scope: Affects all users equally
Protected Operations:
- ✅ Deposits (ETH & tokens)
- ✅ Withdrawals (ETH & tokens)
- ✅ Internal transfers
- ✅ Fixed deposit creation/withdrawal
- ✅ Interest claiming
- ❌ View functions (remain accessible)
Access Control:
- Only contract owner can pause/unpause
- Implemented using OpenZeppelin's
Ownable
Targeted blocking of specific addresses for compliance or security.
Functionality:
- Add to Blacklist: Block specific address from all operations
- Remove from Blacklist: Restore address access
- Query: Check if address is blacklisted
Protected Operations: Same as pausable, plus:
- Transfer recipients are also checked (prevents sending to blacklisted addresses)
Access Control:
- Only contract owner can manage blacklist
- Zero address cannot be blacklisted (validation check)
// Blacklist storage
mapping(address => bool) public blacklist;function criticalOperation()
external
payable // 1. Payment capability
nonReentrant // 2. Reentrancy protection
whenNotPaused // 3. Global pause check (higher priority)
notBlacklisted // 4. User-level check (lower priority)
{
// Function logic
}Modifier Order Rationale:
payable- Language feature, must be firstnonReentrant- Fundamental security, prevents reentrancy attackswhenNotPaused- Global switch, affects all users (check first for efficiency)notBlacklisted- User-specific, only relevant if contract is active
modifier notBlacklisted() {
if (blacklist[msg.sender]) revert Blacklisted();
_;
}function internalTransfer(address _to, uint256 _amount)
external
whenNotPaused
notBlacklisted // Checks sender
{
// Explicit check for recipient
if (blacklist[_to]) revert Blacklisted();
// Transfer logic
}Why check both sender and recipient?
- Prevents blacklisted users from receiving funds
- Prevents users from transferring to blacklisted addresses
- Comprehensive compliance enforcement
Owner Functions:
// Pause contract (emergency)
function pause() external onlyOwner {
_pause();
}
// Resume contract operations
function unpause() external onlyOwner {
_unpause();
}
// Add address to blacklist
function addToBlacklist(address _user) external onlyOwner {
if (_user == address(0)) revert ZeroAddress();
blacklist[_user] = true;
emit LogAddToBlacklist(_user);
}
// Remove address from blacklist
function removeFromBlacklist(address _user) external onlyOwner {
if (_user == address(0)) revert ZeroAddress();
blacklist[_user] = false;
emit LogRemoveFromBlacklist(_user);
}
// Check blacklist status
function isBlacklisted(address _user) external view returns (bool) {
return blacklist[_user];
}event LogAddToBlacklist(address indexed user);
event LogRemoveFromBlacklist(address indexed user);error Blacklisted();Timeline:
─────────────────────────────────────────────────────────
09:00 Security researcher reports critical reentrancy bug
09:05 Owner calls pause()
→ All deposits/withdrawals frozen
→ Attack vector neutralised
09:30 Development team analyses vulnerability
12:00 Fix deployed to new contract version
12:30 Migration plan announced to users
14:00 Owner calls unpause() on old contract for withdrawals only
16:00 All users migrated to new secure contract
Why Pause?
- Immediate protection for all users
- Prevents exploitation while fix is developed
- Allows controlled migration process
Timeline:
─────────────────────────────────────────────────────────
10:00 Alert: Address 0x123... withdrawing unusual amounts
10:05 Owner calls addToBlacklist(0x123...)
→ Specific address blocked
→ Other users unaffected
10:30 Fraud investigation begins
14:00 Investigation reveals legitimate large withdrawal
14:15 Owner calls removeFromBlacklist(0x123...)
→ User access restored
→ No harm, no foul
Why Blacklist?
- Surgical precision (only affects suspected address)
- Minimal disruption to legitimate users
- Reversible if investigation clears user
Timeline:
─────────────────────────────────────────────────────────
11:00 Government order: Freeze assets of address 0xABC...
11:10 Owner calls addToBlacklist(0xABC...)
→ Compliance with legal requirement
→ Audit trail via blockchain events
→ Funds remain in contract (not confiscated)
30d Legal case resolved
Owner calls removeFromBlacklist(0xABC...)
→ User regains access to funds
Why This Approach?
- Meets regulatory obligations
- Transparent and auditable
- Non-custodial (funds stay in contract)
- Reversible when legally permitted
Timeline:
─────────────────────────────────────────────────────────
15:00 Multiple addresses exploit price oracle manipulation
15:03 Owner calls pause()
→ Global freeze
15:05 Owner identifies attacker addresses
15:10 Owner calls addToBlacklist() for each attacker
15:30 Oracle issue fixed
15:45 Owner calls unpause()
→ Legitimate users resume operations
→ Attackers remain permanently blocked
Why Both Mechanisms?
- Pause: Immediate blanket protection
- Blacklist: Targeted long-term blocking
- Layered defense strategy
1. Centralization Risk
- Owner has significant power (pause/blacklist)
- Mitigation: Implement multi-sig ownership
- Best Practice: Establish clear governance procedures
2. Emergency Response Time
- Critical: Owner must respond quickly to threats
- Mitigation: 24/7 monitoring systems
- Best Practice: Automated alerts for suspicious activity
3. False Positives
- Risk: Legitimate users wrongly blacklisted
- Mitigation: Clear appeal process
- Best Practice: Investigate before blacklisting
4. View Function Accessibility
// ✅ CORRECT: View functions always accessible
function getUserBalance(address _user) external view returns (uint256) {
return ethBalances[_user];
}
// ❌ WRONG: Don't restrict view functions
function getUserBalance(address _user)
external
view
whenNotPaused // Don't do this!
returns (uint256)
{
return ethBalances[_user];
}Rationale:
- Users should always see their balances
- Transparency during security events
- No security risk (read-only)
- Maintains trust
Pause Mechanism Tests:
- ✅ Prevents deposits when paused
- ✅ Prevents withdrawals when paused
- ✅ Allows operations after unpause
- ✅ Only owner can pause/unpause
- ✅ View functions work during pause
Blacklist Mechanism Tests:
- ✅ Prevents blacklisted deposits
- ✅ Prevents blacklisted withdrawals
- ✅ Prevents transfers to blacklisted addresses
- ✅ Allows operations after removal from blacklist
- ✅ Only owner can manage blacklist
- ✅ View functions work for blacklisted users
Combined Tests:
- ✅ Pause takes precedence over blacklist
- ✅ Blacklist remains active after unpause
- ✅ Independent operation of both mechanisms
OpenZeppelin Pausable:
- Industry-standard implementation
- Battle-tested security
- Gas-efficient
- Widely audited
Similar Implementations:
- Tether (USDT): Blacklist for regulatory compliance
- Circle (USDC): Combined pause and blacklist
- MakerDAO: Emergency shutdown mechanism
Our Implementation:
- Follows best practices
- Comprehensive protection
- Educational clarity
- Production-ready patterns
// Emergency: Vulnerability discovered
bank.pause();
// Specific threat: Malicious address identified
bank.addToBlacklist(0x1234...);
// Investigation: Address cleared
bank.removeFromBlacklist(0x1234...);
// Recovery: Issue resolved
bank.unpause();
// Compliance: Check status
bool isPaused = bank.paused(); // OpenZeppelin function
bool isBlocked = bank.isBlacklisted(userAddress);Modifier Order Impact:
// Efficient: Checks in order of likelihood to fail
whenNotPaused // Global state, cheap to check
notBlacklisted // Mapping lookup, also cheap
// Both are O(1) operations
// Order matters for failed transactions (saves gas on revert)- Security Patterns: Multi-layered defense strategies
- Access Control: Owner privileges and responsibility
- OpenZeppelin Integration: Using audited libraries
- Emergency Response: Incident handling procedures
- Compliance: Meeting regulatory requirements
- UX Considerations: Balancing security with accessibility
- Testing Strategies: Comprehensive security test coverage
- Owner Security: Private key management is critical
- Response Time: Minutes matter in security incidents
- Communication: Inform users about security events
- Documentation: Maintain incident logs
- Testing: Regularly test pause/unpause procedures
- Transparency: Public communication about blacklist reasons
- Reversibility: Always plan for issue resolution
- Governance: Establish clear decision-making processes