Skip to content

Defensive Programming in Smart Contracts: Recommendations and Security

Defensive programming is a design philosophy that anticipates every way software is misused and plans for those scenarios. The approach matters most for smart contracts, where an error translates directly into financial loss.

Principle of Least Privilege

The Principle of Least Privilege (PoLP) is a core defensive programming and general cybersecurity concept. A system grants only the minimum access levels an entity needs to perform its tasks.

The term "entity" is intentionally ambiguous, because applying the principle depends on the abstraction layers of the software. It applies to an entire smart contract, to a specific function within one, or to an authorization role.

PoLP prevents an unauthorized party from performing destructive actions such as draining funds or altering critical variables. Access control modifiers are the most common way to apply it, and the onlyOwner modifier from the OpenZeppelin Ownable abstract contract limits function calls to an owner address.

More complex authorization requirements call for role-based access control (RBAC), which defines roles carrying their own permissions and assigns them to accounts. A MINTER role permits minting new tokens in a token contract, and a PAUSER role permits pausing contract operations, and neither grants anything beyond that.

Even an administrative role holds only the privileges it needs for significant system-state changes. Pausing the contract is a reasonable power to hold in case of an attack, while arbitrarily altering user balances is excessive and dangerous. Timing matters too, since privileged users sandwich other users' transactions with their administrative actions, which produces dangerous side effects.

Smart contracts also restrict the data each function accesses and manipulates, which minimizes the damage a compromised function causes. A function interacts only with the data it needs to fulfill its purpose and nothing more. That is a substantial argument against data separation patterns such as the Diamond or Eternal Storage patterns.

How to enforce the principle in a particular system depends on its abstraction layers and authorization mechanisms. When designing smart contract systems, minimizing privileges at every level complicates or thwarts exploit attempts and contributes to a defense-in-depth approach in which every entity performs adequate security checks.

The strongest application of the principle to authorization mechanisms and administrative actions is a genuinely autonomous system that needs no roles with centralized power in the first place.

Proactive Checks

Check for errors and unexpected values as early as possible, before they cause issues. Validating inputs and outputs is crucial, since invalid inputs make a contract behave unpredictably. The checks range from simple, such as confirming a transfer amount does not exceed the sender's balance, to complex business logic constraints. A simple check executed as early as possible:

1
2
3
4
function withdraw(uint256 amount) public {
    require(shares[msg.sender] >= amount, "Insufficient balance");
    // ...
}

The function is also structurable to reject invalid values implicitly, without reverting:

1
2
3
4
5
6
7
function withdraw(uint256 amount) public {
    uint256 claimable = shares[msg.sender];
    if (amount > claimable) {
        amount = claimable;
    }
    // ...
}

Checking the state of a contract before acting is equally essential. A global time lock gates withdrawals:

1
2
3
4
5
6
uint256 public unlockBlock;

function withdraw(uint256 amount) public {
    require(block.number >= unlockBlock, "Withdrawals are still locked");
    // ...
}

A common mistake places such checks only on public-facing methods, which produces a hard shell around a weak core. Internal functions and libraries then fail to validate their own inputs, so an attacker who finds one gap in the outer shell meets little resistance beyond it. Proactive validation inside internal functions closes that gap and, done correctly, introduces little gas overhead.

Well-known Libraries

Implementing common logic is a useful exercise, and production-grade applications call for well-established, reliable libraries instead. Tried and tested code saves development time and reduces the opportunity to introduce bugs or security vulnerabilities.

OpenZeppelin's contracts is the prime example and has become an industry standard for building secure smart contracts. It supplies implementations of token standards such as ERC20, ERC721, and ERC1155, alongside cryptographic utilities such as the ECDSA library and the ReentrancyGuard abstract contract carrying the popular nonReentrant modifier. Other options exist for other problems. PRBMath by Paul Razvan Berg is an optimized solution for fixed-point mathematical functions, the Gnosis Safe contracts handle digital assets for systems built on multisig wallets, and Uniswap's Merkle Distributor covers efficient and secure token distribution.

Externalizing functionality into dependencies leaves less code to maintain and stronger security guarantees behind. Dependency management carries its own risks, which the section on dependencies covers.

Purposeful Delays

Purposeful delays, sometimes called speed bumps, are deliberate delays in contract actions that buffer against unwanted actions and potential threats. The slowdown is crucial wherever immediate execution of an activity opens a vulnerability, permits malicious exploitation, or simply runs against the community's interests.

Consider a DeFi protocol granting administrative roles the power to change system settings. Instantaneous changes leave users in an unfavorable or risky position without notice or opportunity to react, particularly where the change takes effect unnoticed while their transaction is already in flight. A delay lets users pull their funds out of the system when they disagree with a change or have risk concerns.

The following example illustrates the principle with a small governance mechanism, in which proposed changes are locked in for a period before implementation. Token holders use the window to vote against a proposal or remove their funds from the system:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
contract ProposalManager {
    uint constant delay = 1 weeks;

    bytes32 public proposal;
    uint256 public proposalDeadline;

    event Proposal(address indexed _sender, bytes32 _data, uint256 _deadline);

    function propose(bytes32 _proposal) public {
        proposal = _proposal;
        proposalDeadline = block.timestamp + delay;
        emit Proposal(msg.sender, _proposal, proposalDeadline);
    }

    function execute() public {
        require(block.timestamp > proposalDeadline, "Proposal is still frozen");
        //Do something with the proposal data
    }
}

The propose function sets a proposed change and starts the countdown in proposalDeadline. The execute function runs only once that deadline has passed, which imposes a deliberate waiting period before any change occurs.

Delays are not a standalone solution and serve as a line of defense integrated with other security practices. They add a time-based protective layer applicable to asset management, to the impact of administrative actions, or as part of a larger state machine that has to guarantee users a reasonable window in which to act.

Concrete Types

Use the most specific type available when building out business logic, which lets the compiler check type safety and contract existence. Lower-level types such as address belong only where they are necessary.

Consider a contract, ExperiencedMembers, managing group membership. It stores user data in a mapping and consults a GroupManager contract to verify membership. Storing only the address of the GroupManager contract produces repetitive, unnecessary interface casts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
contract ExperiencedMembers {
    address manager;
    // ...

    function authorizedAction() public {
        require(
            GroupManager(manager).existsInGroup1(accounts[msg.sender].pubKey) ||
            GroupManager(manager).existsInGroup2(accounts[msg.sender].pubKey),
            "Unauthorized"
        );
        // ...
    }
}

Storing the GroupManager contract as a state variable of its own type is the more efficient approach:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
contract ExperiencedMembers {
    GroupManager manager;

    function authorizedAction() public {
        require(
            manager.existsInGroup1(accounts[msg.sender].pubKey) ||
            manager.existsInGroup2(accounts[msg.sender].pubKey),
            "Unauthorized"
        );
        // ...
    }
}

The reverse occurs as well, where business logic needs an address type and an interface type has been stored, which forces a continual cast through address(interfaceType). Picking the appropriate type from the beginning saves a significant amount of unnecessary typecasting and improves the efficiency of the contract.