Skip to content

Defensive Programming in Smart Contracts: Recommendations and Security

Defensive programming is a design philosophy in software development that encourages developers to anticipate all possible ways the software could be misused and plan for those scenarios. This approach can be particularly crucial for smart contracts, where errors can lead to significant financial loss.

Principle of Least Privilege

The Principle of Least Privilege (PoLP) is a core defensive programming and general cybersecurity concept. It states that a system should grant only the minimum access levels needed for an entity to perform its tasks.

The term "entity" is intentionally ambiguous since applying the principle hinges on the software's abstraction layers. For example, the principle can apply to an entire smart contract, a specific function therein, or an authorization role.

The PoLP can help prevent an unauthorized party from performing destructive actions, such as draining funds from the contract or altering critical variables. The most common way to apply this principle in smart contracts is through access control modifiers. For example, the onlyOwner modifier from the OpenZeppelin Ownable abstract contract can limit function calls to an owner address.

Implementing role-based access control (RBAC) for more complex authorization requirements is expected. This involves defining different roles, each with its own permissions, and assigning these roles to other accounts. For example, one might have a MINTER role that only allows one to mint new tokens in a token contract or a PAUSER role that only allows one to pause the smart contract operations.

Even an administrative role should have only the privileges necessary to perform significant system-state changes. The ability to pause the contract might be required in case of an attack. Yet, the power to arbitrarily alter user balances would likely be excessive and potentially dangerous. Timing also plays a role, as privileged users could use their ability to perform specific administrative actions to sandwich other users' transactions, which can lead to dangerous side effects.

Moreover, smart contracts can and should restrict the data each function can access and manipulate, minimizing the potential damage a compromised function could cause. Functions should be designed to interact only with the data they need to fulfill their purpose, nothing more. This is a big reason against using data separation patterns like the Diamond or Eternal Storage patterns.

All in all, how to enforce the Principle of Least Privilege in a particular system depends on its concrete abstraction layers and authorization mechanisms. When designing smart contract systems, minimizing privileges on various levels can significantly complicate or thwart exploit attempts and contribute to a defense-in-depth approach where every entity performs adequate security checks.

It is worth noting that the best way to apply the Principle of Least Privilege regarding authorization mechanisms and administrative actions is to design a genuinely autonomous system that does not require roles with centralized power in the first place.

Proactive Checks

Errors and unexpected values should be checked for and handled as soon as possible, preventing them from causing issues. Validating inputs and outputs is crucial. Invalid inputs can make a contract behave unpredictably. Simple checks, such as ensuring a transfer amount doesn't exceed a sender's balance, to more complex business logic constraints can be applied. Here's an example of a simple check being executed as early as possible:

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

Alternatively, the function can be structured to implicitly reject invalid values without actually 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 performing actions is also essential. For example, the contract could enforce a global time lock for withdrawals to happen:

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 when such checks are implemented is that they are only localized on public-facing methods. This leads to a model with a hard shell and a weak core. It introduces additional risk since inputs on internal functions and libraries do not validate their inputs properly. Once attackers find a gap in the outer shell's validation, they meet little resistance beyond that point. Proactive validation in internal functions can mitigate this risk and, if done correctly, will introduce little gas overhead.

Well-known Libraries

Implementing common logic can be a helpful practice. Still, a more prudent approach for production-grade applications is to utilize well-established, reliable libraries. This allows developers to leverage tried and tested code, thus saving development time and reducing the potential for introducing bugs or security vulnerabilities.

OpenZeppelin's contracts is a prime example of such a collection. This dependency package has become an industry standard for building secure smart contracts. It provides implementations of token standards like ERC20, ERC721, and ERC1155, but also cryptographic utilities such as the ECDSA library or the ReentrancyGuard abstract contract containing the popular nonReentrant modifier. OpenZeppelin is not the only company open-sourcing smart contracts that developers can use to save time. For tasks involving fixed-point mathematical functions, PRBMath by Paul Razvan Berg presents an optimized solution. For systems building up on multisig wallets, the Gnosis Safe contracts offer an effective way to handle digital assets. Additionally, for dApps that need efficient and secure token distribution, Uniswap's Merkle Distributor is an excellent choice.

As an additional benefit, externalizing functionality into dependencies results in less code to maintain and stronger security guarantees. As dependency management bears its own set of risks, an extra section in the Smart Contract Security Field Guide elaborates on the security aspects of dependencies.

Purposeful Delays

The concept of purposeful delays, sometimes called speed bumps, in the context of smart contracts refers to implementing a deliberate delay in contract actions, creating a buffer against unwanted actions and potential threats. This mechanism of purposeful slowdown can be crucial in scenarios where the immediate execution of specific activities could lead to system vulnerabilities, allow for malicious exploitation, or simply go against the community's interests.

Consider the implications in a DeFi protocol, where administrative roles might be granted the power to change system settings. If such changes were to occur instantaneously, users could find themselves in an unfavorable or risky situation without prior notice or opportunity to react, especially when the change takes effect unnoticed when their transaction is already in-flight. Implementing delays allows users to pull their funds out of the system if they disagree with the changes or have risk concerns.

This example illustrates the principle with a small governance mechanism, where proposed changes must be locked in for a certain period before implementation. This allows token holders to vote against it or remove their funds from the system if the proposal is not to their liking.

 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 new(bytes32 calldata _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
    }
}

In this example, the new function sets a proposed change and initiates a countdown (proposalDeadline). The execute function can only be executed after the proposalDeadline has ended, introducing a deliberate waiting period before any change can occur.

Such delays are not a standalone solution for smart contract security but serve as a crucial line of defense when integrated with other security practices. They provide a time-based protective layer, which can be employed in many facets, whether regarding the management of assets, the impact of administrative actions, or part of a larger state machine that needs to assure users have a reasonable time window to become active.

Concrete Types

When building out the business logic of a smart contract system, using the most secure type available by default is recommended. This allows the compiler to perform checks for type safety and contract existence. Lower-level, less secure types, like address, should only be used when necessary.

Consider a contract, ExperiencedMembers, that manages group membership. This contract might use a mapping to store user data and then refer to a GroupManager contract to verify membership. In such a scenario, developers often store just the address of the GroupManager contract, which results in repetitive, unnecessary interface casts.

Here's an example of code where an address is stored, leading to repeated 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"
        );
        // ...
    }
}

A more efficient approach is to store the GroupManager contract as a state variable:

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

However, the reverse scenario can also occur, where an address type is needed for the business logic, but an interface type has been stored. This situation requires developers to continually cast the interface type to an address using address(interfaceType). Being mindful of the appropriate type to use from the beginning can save a significant amount of unnecessary typecasting and improve the efficiency of the smart contract.