🛡️ Mastering Ethereum Security: A Step-by-Step Guide to Documenting the SecureBank Contract for Production-Ready Solidity Code

Magda JankowskaMagda Jankowska
22 min read

📚 Introduction

In the world of Ethereum smart contracts, security is paramount. Even a minor vulnerability can lead to catastrophic losses. In this guide, we’ll walk you through the process of building a secure smart contract by starting with a simple and potentially insecure contract. We’ll then progressively enhance its security by incorporating industry best practices.

🔧 Step 1: The Basic Contract

We begin with a simple Bank contract that allows users to deposit and withdraw Ether. It also emits events whenever a deposit or withdrawal occurs. This contract, while functional, lacks several key security features.

📝 Create a Base Contract: Bank

Objective: Develop a smart contract with basic functions for managing Ether.

  • 💵 Deposit Function:

  • Function to deposit Ether into the contract.

  • Emits an event Deposit(address indexed user, uint256 amount) whenever Ether is deposited.

  • 🔄 Withdraw Function:

  • Function to withdraw Ether from the contract.

  • Emits an event Withdraw(address indexed user, uint256 amount) whenever Ether is withdrawn.

🔗 Inherit and Extend: AdvancedBank

Objective: Build upon the base Bank contract to add additional functionality.

  • 📈 Track Total Balance:

  • Inherit from the Bank contract to create the AdvancedBank contract.

  • Implement functionality to track the total balance of all users.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

contract Bank {
    // Mapping to keep track of each user's balance
    // `address` is the user's address, and `uint` is their balance
    mapping(address => uint) private balances;

    // Event emitted when a user deposits Ether into the contract
    event Deposit(address indexed user, uint amount);

    // Event emitted when a user withdraws Ether from the contract
    event Withdraw(address indexed user, uint amount);

    // Function to deposit Ether into the contract
    // `payable` modifier allows this function to accept Ether
    function deposit() public payable {
        // Ensure the deposit amount is greater than 0
        require(msg.value > 0, "Deposit amount must be greater than 0");

        // Update the user's balance with the deposited amount
        balances[msg.sender] += msg.value;

        // Emit the Deposit event to log the deposit action
        emit Deposit(msg.sender, msg.value);
    }

    // Function to withdraw Ether from the contract
    function withdraw(uint _amount) public {
        // Ensure the user has sufficient balance for the withdrawal
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        // Deduct the withdrawal amount from the user's balance
        balances[msg.sender] -= _amount;

        // Transfer the specified amount to the user's address
        payable(msg.sender).transfer(_amount);

        // Emit the Withdraw event to log the withdrawal action
        emit Withdraw(msg.sender, _amount);
    }

    // Function to get the balance of the caller
    // `view` modifier indicates this function does not modify state
    function getBalance() public view returns (uint) {
        Return the balance of the caller's address
        return balances[msg.sender];
    }
}

🔑 The Key Components

Mapping (balances):

  • This is a key-value store that maps user addresses to their respective balances.

  • The private visibility means this mapping cannot be accessed directly from outside the contract.

Events (Deposit and Withdraw):

  • Events are used for logging important actions (deposits and withdrawals) on the blockchain. They help track the contract’s state changes and provide transparency.

Deposit Function:

  • Allows users to send Ether to the contract. The payable modifier is necessary for functions that accept Ether.

  • The require statement ensures that the deposit amount is positive.

  • The user’s balance is updated, and an event is emitted.

Withdraw Function:

  • Allows users to withdraw Ether from the contract.

  • The require statement checks if the user has enough balance for the requested withdrawal amount.

  • The user’s balance is reduced, and the Ether is transferred to the user’s address.

  • An event is emitted to log the withdrawal.

Get Balance Function:

  • Provides the current balance of the caller’s address.

  • The view modifier indicates that this function does not change the contract's state and only reads from it.

🔍 Step 2: Identifying Vulnerabilities

While the above contract works, it has several security vulnerabilities:

  1. Reentrancy Attack: The withdraw function is vulnerable to reentrancy attacks, where a malicious contract can repeatedly call the withdraw function before the first call completes.

  2. Lack of Access Control: There is no restriction on who can call the withdraw function, which could allow unauthorized access in certain contexts.

  3. Potential Overflow Issues: Although Solidity 0.8+ includes built-in overflow checks, older versions are susceptible to overflows.

  4. No Emergency Withdraw: In case of emergency, there’s no way for the owner to withdraw all funds.

🔒 Step 3: Implementing Reentrancy Protection

To protect against reentrancy attacks, we introduce the ReentrancyGuard from OpenZeppelin, which adds the nonReentrant modifier. This ensures that no function marked with this modifier can be called while another nonReentrant function is in execution.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

// Import the ReentrancyGuard contract from OpenZeppelin
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

// The Bank contract extends ReentrancyGuard to prevent reentrancy attacks
contract Bank is ReentrancyGuard {
    // Mapping to track the balance of each address
    mapping(address => uint) private balances;

    // Event emitted when a user deposits Ether into the contract
    event Deposit(address indexed user, uint amount);

    // Event emitted when a user withdraws Ether from the contract
    event Withdraw(address indexed user, uint amount);

    // Deposit function allows users to deposit Ether into the contract
    function deposit() public payable {
        // Ensure that the deposit amount is greater than 0
        require(msg.value > 0, "Deposit amount must be greater than 0");

        // Update the balance of the sender
        balances[msg.sender] += msg.value;

        // Emit the Deposit event
        emit Deposit(msg.sender, msg.value);
    }

    // Withdraw function allows users to withdraw Ether from the contract
    function withdraw(uint _amount) public nonReentrant {
        // Ensure that the caller has enough balance to withdraw
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        // Update the balance of the sender
        balances[msg.sender] -= _amount;

        // Transfer the requested amount to the caller
        payable(msg.sender).transfer(_amount);

        // Emit the Withdraw event
        emit Withdraw(msg.sender, _amount);
    }

    // Function to get the balance of the caller
    function getBalance() public view returns (uint) {
        // Return the balance of the caller
        return balances[msg.sender];
    }
}

🔍 The Components

Imports:

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
  • Import ReentrancyGuard from OpenZeppelin to use its functionality for preventing reentrancy attacks.

Contract Declaration:

contract Bank is ReentrancyGuard {
  • The Bank contract inherits from ReentrancyGuard to use its nonReentrant modifier, which helps prevent reentrancy attacks in functions that modify the contract’s state.

State Variables:

mapping(address => uint) private balances;
  • balances is a mapping that associates each address with its balance in the contract.

Events:

event Deposit(address indexed user, uint amount);
event Withdraw(address indexed user, uint amount);

Deposit Function:

function deposit() public payable {
    require(msg.value > 0, "Deposit amount must be greater than 0");
    balances[msg.sender] += msg.value;
    emit Deposit(msg.sender, msg.value);
}
  • Allows users to deposit Ether into the contract.

  • Uses require to ensure the deposit amount is greater than 0.

  • Updates the sender’s balance and emits a Deposit event.

Withdraw Function:

function withdraw(uint _amount) public nonReentrant {
    require(balances[msg.sender] >= _amount, "Insufficient balance");
    balances[msg.sender] -= _amount;
    payable(msg.sender).transfer(_amount);
    emit Withdraw(msg.sender, _amount);
}
  • Allows users to withdraw Ether from the contract.

  • Uses the nonReentrant modifier to prevent reentrancy attacks.

  • Ensures the caller has sufficient balance, updates the balance, and transfers the amount.

  • Emits a Withdraw event.

Get Balance Function:

function getBalance() public view returns (uint) {
    return balances[msg.sender];
}

Provides a way to check the balance of the caller.

🔐 Step 4: Adding Access Control

To limit access to certain functions, we incorporate the Ownable contract from OpenZeppelin, which introduces the onlyOwner modifier. This ensures that only the contract owner (usually the deployer) can execute specific functions.

import "@openzeppelin/contracts/access/Ownable.sol";

contract Bank is Ownable, ReentrancyGuard {
    mapping(address => uint) private balances;

    event Deposit(address indexed user, uint amount);
    event Withdraw(address indexed user, uint amount);

    constructor() {
        // The Ownable constructor automatically sets the deployer as the owner
    }

    function deposit() public payable {
        require(msg.value > 0, "Deposit amount must be greater than 0");
        balances[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw(uint _amount) public nonReentrant {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        balances[msg.sender] -= _amount;
        payable(msg.sender).transfer(_amount);
        emit Withdraw(msg.sender, _amount);
    }

    function getBalance() public view returns (uint) {
        return balances[msg.sender];
    }

    // Emergency function to withdraw all funds, accessible only by the owner
    function withdrawAll() public onlyOwner {
        uint contractBalance = address(this).balance;
        require(contractBalance > 0, "No funds to withdraw");
        payable(msg.sender).transfer(contractBalance);
    }
}

🔢 Step 5: Handling Overflows Safely

Even though Solidity 0.8+ has built-in overflow checks, ensuring safe arithmetic is crucial for older versions.

In Solidity ^0.8.0 and later, overflow and underflow checks are built into the language itself. Therefore, the use of SafeMath is no longer necessary.

🔄 Step 6: Expanding Functionality with Inheritance

To build upon the secure Bank contract, we can create an AdvancedBank contract that inherits from Bank and adds additional functionality, such as tracking the total balance of all users.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

// Import the Bank contract to extend its functionality
import "./Bank.sol";

// The AdvancedBank contract inherits from the Bank contract
contract AdvancedBank is Bank {
    // State variable to keep track of the total balance of all users
    uint private totalBalance;

    // Override the deposit function from the Bank contract
    function deposit() public payable override {
        // Call the deposit function from the parent Bank contract
        super.deposit();
        // Update the total balance to include the deposited amount
        totalBalance += msg.value;
    }

    // Override the withdraw function from the Bank contract
    function withdraw(uint _amount) public override {
        // Call the withdraw function from the parent Bank contract
        super.withdraw(_amount);
        // Update the total balance to reflect the withdrawn amount
        totalBalance -= _amount;
    }

    // Function to get the total balance of all users
    function getTotalBalance() public view returns (uint) {
        // Return the total balance
        return totalBalance;
    }

    // Function to manually update the total balance, restricted to the owner
    function updateTotalBalance(uint _newBalance) public onlyOwner {
        // Allow only the contract owner to set the total balance manually
        totalBalance = _newBalance;
    }
}

🔍 The Components

Contract Declaration:

contract AdvancedBank is Bank {
  • The AdvancedBank contract inherits from the Bank contract. This means it has access to all functions and state variables of the Bank contract and can override them if needed.

State Variable:

uint private totalBalance;
  • totalBalance is a private state variable that keeps track of the total balance held by the contract, aggregated from all user deposits and withdrawals.

Deposit Function:

function deposit() public payable override {
    super.deposit();
    totalBalance += msg.value;
}
  • The deposit function is overridden to add extra functionality.

  • It first calls the deposit function of the parent Bank contract using super.deposit().

  • After updating the user’s balance, it increments totalBalance by the deposited amount (msg.value).

Withdraw Function:

function withdraw(uint _amount) public override {
    super.withdraw(_amount);
    totalBalance -= _amount;
}
  • The withdraw function is overridden to add extra functionality.

  • It first calls the withdraw function of the parent Bank contract using super.withdraw(_amount).

  • After updating the user’s balance, it decrements totalBalance by the withdrawn amount (_amount).

Get Total Balance Function:

function getTotalBalance() public view returns (uint) {
    return totalBalance;
}
  • Provides a public view function to retrieve the total balance of all users combined.

Update Total Balance Function:

function updateTotalBalance(uint _newBalance) public onlyOwner {
    totalBalance = _newBalance;
}
  • Allows the contract owner to manually update the totalBalance.

  • This function is restricted to the owner using the onlyOwner modifier from the Ownable contract, which is inherited from the Bank contract.

This detailed comment should help in understanding the purpose and functionality of each part of the AdvancedBank contract. It builds upon the baseBank contract by adding functionality to track and manage the total balance of all users.

🔐 Step 7: Advanced Security Enhancements

To further strengthen the contract, we introduce several advanced security features:

Upgradeability

  • Implementing upgradeable contracts using proxy patterns (like UUPS or Transparent Proxy) allows the contract to be updated without losing the stored data.

Multisignature Wallet for Owner Functions

  • Replace the single-owner model with a multisignature wallet to reduce the risk of a single point of failure.

Rate-Limiting Withdrawals

  • Introduce withdrawal limits and time windows to prevent large, rapid withdrawals, which can mitigate damage in case of a compromised account.
mapping(address = uint) private lastWithdrawalTime;
uint private constant WITHDRAWAL_LIMIT = 10 ether; // Example limit
uint private constant TIME_WINDOW = 1 day;

function withdraw(uint _amount) public override {
    require(_amount <= WITHDRAWAL_LIMIT, "Exceeds withdrawal limit");
    require(block.timestamp >= lastWithdrawalTime[msg.sender] + TIME_WINDOW, "Withdrawal time window not met");

    super.withdraw(_amount);
    lastWithdrawalTime[msg.sender] = block.timestamp;
}

🔍 The Components

Mapping for Withdrawal Tracking:

mapping(address = uint) private lastWithdrawalTime;
  • lastWithdrawalTimeis a mapping that records the timestamp of the last withdrawal for each user. This helps in enforcing time-based constraints on withdrawals.

Withdrawal Limit Constant:

uint private constant WITHDRAWAL_LIMIT = 10 ether; // Example limit
  • WITHDRAWAL_LIMITis a constant that defines the maximum amount of Ether a user can withdraw within a specified time window. In this example, it's set to 10 Ether.

Time Window Constant:

uint private constant TIME_WINDOW = 1 day;
  • TIME_WINDOWis a constant that specifies the time period during which the withdrawal limit applies. In this example, it's set to 1 day.

Overridden Withdraw Function:

function withdraw(uint _amount) public override {
    require(_amount <= WITHDRAWAL_LIMIT, "Exceeds withdrawal limit");
    require(block.timestamp >= lastWithdrawalTime[msg.sender] + TIME_WINDOW, "Withdrawal time window not met");

    super.withdraw(_amount);
    lastWithdrawalTime[msg.sender] = block.timestamp;
    totalBalance -= _amount;
}
  • Check Withdrawal Limit: Ensures that the requested withdrawal amount does not exceed theWITHDRAWAL_LIMIT.

  • Check Time Window: Ensures that enough time has passed since the last withdrawal. Usesblock.timestamp to get the current time and compare it with the storedlastWithdrawalTime.

  • Call Parent Withdraw Function: Calls the withdraw function from the parent Bank contract using super.withdraw(_amount).

  • Update Withdrawal Time: Updates the lastWithdrawalTime for the user to the current timestamp.

  • Update Total Balance: Decreases totalBalance by the withdrawn amount.

Other Functions:

  • Deposit Function: Overrides the parent deposit function to update totalBalance when Ether is deposited.

  • Get Total Balance Function: Returns the total balance of all users.

  • Update Total Balance Function: Allows the owner to manually update the total balance.

Pausable Contract

  • Implement the Pausable contract, which allows the owner to pause the contract in case of emergency, preventing further deposits or withdrawals.
import "@openzeppelin/contracts/security/Pausable.sol";

contract Bank is Ownable, ReentrancyGuard, Pausable {
    function deposit() public payable whenNotPaused {
        // Deposit logic
    }

    function withdraw(uint _amount) public nonReentrant whenNotPaused {
        // Withdraw logic
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }
}

Handling Self-Destruct Scenarios

  • Consider adding a self-destruct mechanism with a timelock to safely terminate the contract if necessary.
// Function to initiate the self-destruct process, restricted to the contract owner
function initiateSelfDestruct() public onlyOwner {
    Create a unique identifier for the self-destruct action using a keccak256 hash
    bytes32 actionId = keccak256("selfDestruct");

    // Set a timelock for the self-destruct action; it can only be executed after this period
    timelock[actionId] = block.timestamp + TIMELOCK;
}

// Function to execute the self-destruct process, restricted to the contract owner
function executeSelfDestruct() public onlyOwner {
    Create a unique identifier for the self-destruct action using a keccak256 hash
    bytes32 actionId = keccak256("selfDestruct");

    // Ensure that the current time is greater than or equal to the timelock for self-destruct
    require(block.timestamp >= timelock[actionId], "Timelock not expired");

    Destroy the contract and send any remaining Ether to the owner
    selfdestruct(payable(owner()));
}

🔍 The Components

Function: initiateSelfDestruct

function initiateSelfDestruct() public onlyOwner {
    bytes32 actionId = keccak256("selfDestruct");
    timelock[actionId] = block.timestamp + TIMELOCK;
}
  • Restricted to Owner: The function is protected by theonlyOwner modifier, meaning only the contract owner can call this function.

  • Create Action ID: Uses thekeccak256 hashing function to generate a unique identifier (actionId) for the self-destruct action. This ensures that the timelock is specific to this action.

  • Set Timelock: Thetimelock mapping stores the timestamp when the self-destruct action can be executed. The execution is delayed by theTIMELOCK period, which is added to the current timestamp (block.timestamp).

Function: executeSelfDestruct

function executeSelfDestruct() public onlyOwner {
    bytes32 actionId = keccak256("selfDestruct");
    require(block.timestamp >= timelock[actionId], "Timelock not expired");

    selfdestruct(payable(owner()));
}
  • Restricted to Owner: The function is also protected by theonlyOwner modifier, meaning only the contract owner can call this function.

  • Verify Timelock: Generates the same unique identifier (actionId) for the self-destruct action and checks if the current time (block.timestamp) has passed the time specified in the timelock mapping. If not, the function reverts with the error message "Timelock has not expired.".

  • Self-Destruct: If the timelock has expired, theselfdestruct function is called, which destroys the contract and sends any remaining Ether in the contract to the owner's address (payable(owner())).

  • initiateSelfDestructThis function allows the owner to initiate the process of contract destruction but delays the actual destruction to prevent hasty or accidental actions. The delay is enforced by setting a timelock.

  • executeSelfDestructAfter the timelock has expired, the owner can call this function to permanently destroy the contract. This two-step process adds an extra layer of security, giving the owner time to reconsider or cancel the action if necessary.

In order to minimize the possibility of accidental or malicious destruction, this structure makes sure that the contract can only be destroyed by the owner, and even then, only after a predetermined waiting period.

Derived Contract: AdvancedBank

Now, let’s create a contract AdvancedBank that inherits from Bank and adds functionality to track the total balance of all users combined.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

import "./Bank.sol";

contract AdvancedBank is Bank {
    // Variable to keep track of the total balance of all users
    uint public totalBalance;

    // Override the deposit function to update totalBalance
    function deposit() public payable override {
        // Call the base contract's deposit function
        super.deposit();

        // Update the total balance of all users
        totalBalance += msg.value;
    }

    // Override the withdraw function to update totalBalance
    function withdraw(uint _amount) public override {
        // Call the base contract's withdraw function
        super.withdraw(_amount);

        // Update the total balance of all users
        totalBalance -= _amount;
    }
}
  • Bank Contract: Handles individual user deposits and withdrawals, emitting events for each action.

  • AdvancedBank Contract: It inherits from Bank, and additionally tracks the total balance of all users combined by overriding the deposit and withdraw functions.

With these contracts, you can maintain a simple banking system on the Ethereum blockchain, complete with event logging and aggregate balance tracking.

📝 Step 8: The Final Contract

This is the final, comprehensive contract after all the improvements have been made.

Here’s the SecureBank contract with detailed comments, structured as a professional Solidity developer would write it for production use:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;

// Importing necessary OpenZeppelin contracts and utilities
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/proxy/utils/Initializable.sol";

// SecureBank contract inherits from Ownable, ReentrancyGuard, Pausable, and Initializable
contract SecureBank is Ownable, ReentrancyGuard, Pausable, Initializable {

    // Private state variables
    mapping(address => uint256) private balances; // Tracks the balance of each user
    mapping(address => uint256) private lastWithdrawalTime; // Tracks the last withdrawal time of each user

    uint256 private totalBalance; // Tracks the total balance held in the contract
    uint256 private constant WITHDRAWAL_LIMIT = 10 ether; // Maximum withdrawal limit per transaction
    uint256 private constant TIME_WINDOW = 1 days; // Time window between successive withdrawals
    uint256 private constant TIMELOCK = 2 days; // Timelock duration for sensitive operations

    mapping(bytes32 => uint256) public timelock; // Mapping to store the timelocks for various actions

    // Event declarations for logging important contract actions
    event Deposit(address indexed user, uint256 amount);
    event Withdraw(address indexed user, uint256 amount);
    event EmergencyWithdraw(address indexed owner, uint256 amount);
    event ContractPaused(address indexed owner);
    event ContractUnpaused(address indexed owner);

    // Initialize function for upgradeable contracts, setting the initial owner
    function initialize(address owner) public initializer {
        transferOwnership(owner); // Transfer ownership to the provided owner address
    }

    // Deposit function allowing users to deposit Ether into the contract
    // The function is pausable to allow the owner to stop deposits in case of emergency
    function deposit() public payable whenNotPaused {
        require(msg.value > 0, "Deposit amount must be greater than 0");
        balances[msg.sender] += msg.value; // Safely add the deposited amount to the user's balance
        totalBalance += msg.value; // Safely add the deposited amount to the total balance
        emit Deposit(msg.sender, msg.value); // Emit a deposit event
    }

    // Withdraw function allowing users to withdraw Ether from the contract
    // Includes reentrancy protection, withdrawal limit, and pausable functionality
    function withdraw(uint256 _amount) public nonReentrant whenNotPaused {
        require(_amount <= WITHDRAWAL_LIMIT, "Exceeds withdrawal limit"); // Check if the amount exceeds the withdrawal limit
        require(block.timestamp >= lastWithdrawalTime[msg.sender] + TIME_WINDOW, "Withdrawal time window not met"); // Check if the time window condition is met
        require(balances[msg.sender] >= _amount, "Insufficient balance"); // Check if the user has sufficient balance

        balances[msg.sender] -= _amount; // Safely subtract the withdrawn amount from the user's balance
        totalBalance -= _amount; // Safely subtract the withdrawn amount from the total balance
        lastWithdrawalTime[msg.sender] = block.timestamp; // Update the last withdrawal time for the user

        payable(msg.sender).transfer(_amount); // Transfer the amount to the user
        emit Withdraw(msg.sender, _amount); // Emit a withdrawal event
    }

    // Function to get the balance of the caller
    function getBalance() public view returns (uint256) {
        return balances[msg.sender]; // Return the balance of the caller
    }

    // Function to get the total balance of the contract
    function getTotalBalance() public view returns (uint256) {
        return totalBalance; // Return the total balance of the contract
    }

    // Emergency withdraw function, restricted to the contract owner
    function withdrawAll() public onlyOwner {
        uint256 contractBalance = address(this).balance; // Get the contract's balance
        require(contractBalance > 0, "No funds to withdraw"); // Check if there are any funds to withdraw

        (bool success, ) = msg.sender.call{value: contractBalance}(""); // Transfer the contract's balance to the owner
        require(success, "Transfer failed."); // Ensure the transfer was successful

        emit EmergencyWithdraw(msg.sender, contractBalance); // Emit an emergency withdrawal event
    }

    // Pauses the contract, preventing deposits and withdrawals; restricted to the owner
    function pause() public onlyOwner {
        _pause(); // Call the inherited pause function
        emit ContractPaused(msg.sender); // Emit a contract paused event
    }

    // Unpauses the contract, allowing deposits and withdrawals; restricted to the owner
    function unpause() public onlyOwner {
        _unpause(); // Call the inherited unpause function
        emit ContractUnpaused(msg.sender); // Emit a contract unpaused event
    }

    // Time-locked function to update the total balance, restricted to the owner
    function initiateUpdateTotalBalance(uint256 _newBalance) public onlyOwner {
        bytes32 actionId = keccak256(abi.encodePacked("updateTotalBalance", _newBalance)); // Generate a unique action ID using keccak256
        timelock[actionId] = block.timestamp + TIMELOCK; // Set the timelock for this action
    }

    // Executes the balance update after the timelock has expired
    function executeUpdateTotalBalance(uint256 _newBalance) public onlyOwner {
        bytes32 actionId = keccak256(abi.encodePacked("updateTotalBalance", _newBalance)); // Generate the same unique action ID
        require(block.timestamp >= timelock[actionId], "Timelock not expired"); // Ensure the timelock has expired

        totalBalance = _newBalance; // Update the total balance
        delete timelock[actionId]; // Clear the timelock entry for this action
    }

    // Function to initiate the self-destruct process with a time lock, restricted to the owner
    function initiateSelfDestruct() public onlyOwner {
        bytes32 actionId = keccak256("selfDestruct"); // Generate a unique action ID for self-destruct
        timelock[actionId] = block.timestamp + TIMELOCK; // Set the timelock for self-destruct
    }

    // Executes the self-destruct process after the timelock has expired
    function executeSelfDestruct() public onlyOwner {
        bytes32 actionId = keccak256("selfDestruct"); // Generate the same unique action ID for self-destruct
        require(block.timestamp >= timelock[actionId], "Timelock not expired"); // Ensure the timelock has expired

        selfdestruct(payable(owner())); // Destroy the contract and send remaining funds to the owner
    }

    // Fallback function to receive Ether; automatically calls the deposit function
    receive() external payable {
        deposit(); // Call the deposit function when Ether is received
    }

    // This function is included to comply with the upgradeable pattern. It's required by OpenZeppelin's Initializable contract.
    uint256[50] private __gap; // Reserved storage space to allow for future layout changes in the upgradeable contract
}

🔍 The Components

Contract Imports and Inheritance:

  • The contract imports various OpenZeppelin modules, including Ownable, ReentrancyGuard, Pausable, and Initializable. These modules provide security features like ownership control, reentrancy protection, pausability, and safe arithmetic operations.

State Variables and Constants:

  • The contract maintains mappings for user balances and last withdrawal times, as well as total balance and constants for withdrawal limits, time windows, and timelocks.

  • The timelock mapping tracks specific time-locked actions, ensuring they can only be executed after a predefined time.

Event Declarations:

  • Events are declared for deposits, withdrawals, emergency withdrawals, contract pauses, and unpauses, allowing for tracking and logging of these actions on the blockchain.

Functions:

  • initialize Function: Used to set the initial owner in upgradeable contracts.

  • deposit Function: Handles deposits and emits a Deposit event. The function is pausable to stop deposits during emergencies.

  • withdraw Function: Manages user withdrawals with checks for limits, reentrancy, and time windows. Emits a Withdraw event.

  • getBalance and getTotalBalance Functions: Return the user's balance and the total contract balance, respectively.

  • withdrawAll Function: Allows the owner to withdraw all contract funds in an emergency.

  • pause and unpause Functions: Enable and disable contract operations, controlled by the owner.

  • initiateUpdateTotalBalance and executeUpdateTotalBalance Functions: Implement a timelocked mechanism for updating the total balance, ensuring sensitive operations are delayed.

  • initiateSelfDestruct and executeSelfDestruct Functions: Provide a timelocked, controlled way to self-destruct the contract, ensuring the owner can only destroy the contract after a delay.

  • receive Function: Handles Ether sent to the contract, automatically calling the deposit function.

Storage Gap:

  • The __gap is a reserved storage space included for upgradeable contracts to ensure future-proofing by allowing additional variables to be added in future versions without affecting the storage layout.

This version of the contract is well-documented, incorporating best practices for security and upgradeability, making it suitable for production deployment.

🔒 Security Note for SecureBank Contract

The SecureBank contract has been carefully designed with a strong focus on security, incorporating industry best practices and utilizing trusted libraries such as OpenZeppelin. However, deploying smart contracts in a real-world production environment comes with inherent risks, and it’s crucial to understand the security measures implemented as well as areas where further precautions should be taken.

🔑 Key Security Features:

🛡️ Ownership Control:

  • The contract is governed by OpenZeppelin’s Ownable contract, which restricts critical functions to the contract owner. This ensures that only authorized individuals can perform actions such as pausing the contract, initiating emergency withdrawals, or executing a self-destruct.

🔒 Reentrancy Protection:

  • To protect against reentrancy attacks, which are a common vulnerability in Ethereum smart contracts, the withdraw function uses the nonReentrant modifier from OpenZeppelin’s ReentrancyGuard.

⏸️ Pausable Contract:

  • The Pausable feature allows the contract owner to temporarily disable deposits and withdrawals during emergencies, protecting user funds if a vulnerability or exploit is detected.

🧮 Safe Arithmetic Operations:

In the Solidity version ^0.8.0 and later, safe arithmetic operations are natively supported by the language itself. This eliminates the need for external libraries like SafeMath for overflow and underflow protection. The compiler automatically includes checks for arithmetic overflows and underflows, ensuring that arithmetic operations revert the transaction if they result in invalid values. This built-in feature enhances contract safety by preventing potential catastrophic errors or exploits related to arithmetic operations. As we have a Solidity version ^0.8.18 contract, you no longer need SafeMath. The language safely handles arithmetic operations.

🚫 Withdrawal Limits:

  • Daily withdrawal limits and enforced time windows prevent excessive or repeated withdrawals, reducing the risk of fund depletion if a user’s account is compromised.

⏳ Timelock for Critical Operations:

  • A timelock mechanism is in place for sensitive actions, such as updating the total balance or self-destructing the contract. This provides a buffer period during which unauthorized or suspicious actions can be identified and potentially stopped.

🚨 Emergency Withdrawals:

  • The contract owner has the ability to withdraw all funds in an emergency, acting as a failsafe if unforeseen issues arise.

📋 Recommendations for Production Deployment:

🔍 Formal Verification and Security Audits:

  • Before deploying the contract in a production environment, it’s highly recommended to subject it to formal verification and third-party security audits. These processes help identify and address potential vulnerabilities or logical errors that might not be apparent during development.

👥 Multi-Signature Ownership:

  • Consider implementing multi-signature (multi-sig) ownership for critical functions. This adds an extra layer of security by requiring multiple approvals before sensitive actions can be executed, reducing the risk of a single point of failure.

🖥️ User Interface Security:

  • Ensure that the user interface (e.g., wallets, dApps) interacting with the contract is secure and user-friendly. Educate users about the importance of security practices, such as verifying contract addresses and avoiding phishing scams.

📊 Continuous Monitoring and Updates:

  • Implement continuous monitoring for unusual activities, such as large or repeated withdrawals, and establish automatic alerts for such events. Regular updates and security patches should be applied as new threats emerge.

💰 Bug Bounty Program:

  • Launching a bug bounty program encourages the broader security community to review the contract, helping to discover and fix vulnerabilities before they can be exploited.

⛽ Gas Optimization:

  • Regularly assess and optimize the contract’s gas usage to ensure efficiency and prevent potential attacks related to high gas costs.

✅ Conclusion:

The SecureBank contract is built with robust security mechanisms suitable for production deployment. However, security is an ongoing process, and even well-designed contracts require continuous monitoring, auditing, and updates. By following the recommendations above and staying vigilant, you can significantly mitigate the risks associated with deploying smart contracts on the Ethereum blockchain.

0
Subscribe to my newsletter

Read articles from Magda Jankowska directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Magda Jankowska
Magda Jankowska

Security Researcher for Web3 and Dark Web Bug hunter Ethical Hacker