A1 - Solidity Interfaces - Facilitating interaction, modularity and Dapp upgradability

Research and write an essay on the importance of interfaces in facilitating smart contract interactions, including how they contribute to the modularity and upgradability of dApps.

Introduction

Bitcoin was the first blockchain to be introduced to the world back in 2009. It has proven through the years its resilience as a store of value. As of 2024, the largest money managers, hedge funds and even countries count it as a valuable part of their portfolios and balance sheets.

Some years later Vitalik Buterin released the Ethereum blockchain. But why did we need another blockchain anyway?

What sets Ethereum apart from the Bitcoin, Is its execution layer. Also known as the Ethereum virtual machine (EVM). The EVM allows for code to be written and stored on the blockchain. One 'code file' on the EVM is commonly known as a 'smart contract'. This functionality essentially makes Ethereum a worldwide decentralized computer.

Smart contracts are the building blocks for all decentralized apps that are widely used today. Apps like Uniswap, Aave, Curve were some of the first examples, from the world of decentralized finance (Defi). But these projects paved the way for all the other dApps that exist today.

Here I will explore the role of 'interfaces' in Ethereum's Solidity programming language. They are the glue that holds all the other separate smart contract pieces of decentralized dApps together. Interfaces are crucial in allowing modularity and upgradability for decentralized applications.

Understanding Interfaces in Solidity

Interfaces are used to define how a smart contract can work with another smart contract. Although they look like smart contracts, there is one glaring difference. They cannot have any functions implemented, meaning they don't show how the function code works. Have a look at the simple example below:

// Define the interface
interface IGreeter {
    function greet() external view returns (string memory);
}

// External contract implementing the interface
contract Greeter is IGreeter {
    function greet() external pure override returns (string memory) {
        return "Hello, Solidity!";
    }
}

// Contract using the interface to interact with Greeter
contract GreeterClient {
    function fetchGreeting(address _greeterContract) external view returns (string memory) {
        IGreeter greeter = IGreeter(_greeterContract);
        return greeter.greet();
    }
}

The interface provides the necessary details needed to call the function and nothing else. This abstraction makes it far simpler for devs and users to interact with the smart contract because they don't need to see all the extra lines of code that make the function work. They just need to know what goes in and what comes out. Think about it in terms of driving a car. Millions of people drive cars every day. Is it necessary for them to know how to build one? Of course not, they simply need to know how to drive it.

Facilitating Smart Contract Interaction

Aside from only displaying the parts of the function neccesary to call the function. They also act as a place to store all of the external functions in a particular smart contract. You can see this keyword in the example above. Known as a 'function visibility specifier', these allow the programmer to specify, how and if ,other people or contracts can interact with a specific solidity function. You can find a list of all the different visibility specifiers here In the official Solidity documentation. The external specifier makes the function externally visible. Put simply, this allows the function to be called by other smart contracts.

Lets look at a well known example: The IERC20 interface. I have highlighted the transfer function. This function simply moves the balance of a given ERC20 token from one address to another. This is thankfully presented in a very straight-forward way, compared to the full function code below. As you will notice it actually uses three functions together. But as users, do we need to know this information? Of course not. We just need to know what information the function needs and what will happen.

pragma solidity ^0.8.20;

interface IERC20 {

    function transfer(address to, uint256 value) external returns (bool)
}

pragma solidity ^0.8.20;

contract ERC20 is IERC20 {
    
    function transfer(address to, uint256 value) public virtual returns (bool) {
        address owner = _msgSender();
        _transfer(owner, to, value);
        return true;
    }

   
    function _transfer(address from, address to, uint256 value) internal {
        if (from == address(0)) {
            revert ERC20InvalidSender(address(0));
        }
        if (to == address(0)) {
            revert ERC20InvalidReceiver(address(0));
        }
        _update(from, to, value);
    }

    
    function _update(address from, address to, uint256 value) internal virtual {
        if (from == address(0)) {
            _totalSupply += value;
        } else {
            uint256 fromBalance = _balances[from];
            if (fromBalance < value) {
                revert ERC20InsufficientBalance(from, fromBalance, value);
            }
            unchecked {
                _balances[from] = fromBalance - value;
            }
        }

        if (to == address(0)) {
            unchecked {
                _totalSupply -= value;
            }
        } else {
            unchecked {
                _balances[to] += value;
            }
        }

        emit Transfer(from, to, value);
    }
}

Enhancing Modularity with Interfaces

Modularity in computer programming is the concept of breaking something big down into smaller, more manageable chunks. Decentralized apps are usually split up into multiple different smart contracts, each with their own specific purpose.

Some benefits of modularity are:

  • The abstraction allows developers to focus on a high-level functionality, by not seeing all the code in one place.

  • Being broken down into little chunks allows for much easier maintenance and debugging, due to the code being well organized and not having to sift through thousands of lines of code, one by one.

  • A module can be modified without affecting the entire system. This is because if something did break when you are modifying the block, you know exactly where the issue is.

You can think of each Interface as a building block Interfaces allow developers to re-use existing smart contracts and create new ones. The interface being the standard that the new contract will adhere to. One example is factory Contracts. Factory contracts are the standardized name for a smart contract that creates instances of existing smart contracts. For example, IUniswapV2Factory below. This interface is for interacting with the UniswapV2Factory contract. We can see the createPair function, which creates a new pair that can be traded on Uniswap and returns the new smart contract address. Each pair added simply adds another working block to the Uniswap protocol.

pragma solidity >=0.5.0;

interface IUniswapV2Factory {
  event PairCreated(address indexed token0, address indexed token1, address pair, uint);

  function createPair(address tokenA, address tokenB) external returns (address pair);
}

Upgradability and Interfaces

Although blockchains immutability is one of their greatest strengths. It also poses as a restriction when talking about upgrading or handling bugs in dapp development. In traditional programming, if you found a bug, you would simply fix the bug and release a new version. In the blockchain world it is not quite that simple. Say we have a Defi app. All of the user information about their balances is kept on-chain in the form of state variables. Say then, that we found a critical bug, that would allow a hacker to drain the entire smart contract balance. How are the user balances protected?

The answer to creating truly upgradable smart contracts lies in separating a contracts logic from its storage. One way to do this is the proxy upgrade pattern. In its most basic form, it can be split into 3 contracts. I will be following the terminology from the openZeppelin documentation about this pattern for the titles of the different contracts.

  • Proxy Contract

    • immutable

    • User interaction done through the proxy contract address

    • Stores all the state variables

  • Implementation Contract

    • Handles any logic, rules or functions that govorn how the dapp works (for example: max withdraw, minimum colateral amount etc)

    • Functions should adhere to the interfaces specification

    • When upgrading, it is this file that will change.

  • Implementation Contract Interface

    • All external functions from the implementation contract specified

What is the difference between a normal smart contract and a proxy smart contract?

In a standard smart contract, state variables and business logic are all kept in the same file. To upgrade a standard single smart contract, you have to literally create a new contract. No state variables can be saved. A proxy contract, however acts as a gateway for user interaction, by delegating calls (asking another contract to make function calls on its behalf) to the implementation contract.

Below is a basic proxy upgrade pattern. The proxy contract, as you can see, has no specific function data specified at all. The magic is in the fallback function. Which throws the function call to the contract to which the proxy is pointing at. Essentially the call data is copied and sent to the implementation contract. Then the return data is retrieved and sent back to the caller.

This is based on the proxy forwarding section of the OpenZeppelin proxy upgrade pattern article, I highly recommend reading this for a deeper understanding of upgradable smart contracts.

// ILogicContract.sol
pragma solidity ^0.8.0;

interface ILogicContract {
    function getVersion() external view returns (string memory);
}

// --------------------------------------------------------------

// LogicContractV1.sol
pragma solidity ^0.8.0;

import "./ILogicContract.sol";

contract LogicContractV1 is ILogicContract {
    function getVersion() external pure override returns (string memory) {
        return "V1.0";
    }
}

// --------------------------------------------------------------

// ProxyContract.sol
pragma solidity ^0.8.0;

contract ProxyContract {
    address public implementation;

    // The constructor takes the implimentation contract address as input.
    constructor(address _initialImplementation) {
        implementation = _initialImplementation;
    }

    // Function to set the state variable to the new upgraded contract address.
    function upgradeImplementation(address _newImplementation) public {
        implementation = _newImplementation;
    }
    
    // 
    fallback() external payable {
        // Retrieves the address of the current implementation contract.
        address _impl = implementation;
        require(_impl != address(0));
        
        // Assembly block allows for low-level operations .
        assembly {
            // (1) copy incoming call data
            calldatacopy(0, 0, calldatasize())
            
            // // (2) forward call to logic contract
            let result := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
            
            // (3) retrieve return data
            returndatacopy(0, 0, returndatasize())
            
            // (4) forward return data back to caller
            switch result
            case 0 { revert(ptr, size) }
            default { return(ptr, size) }
        }
    }
}

The idea with the proxy upgrade pattern is that the proxy contract simply points at the Implementation contract and directs function calls from the proxy contract to the contract is it pointing at. So, from the front end, the contract address does not change. Meaning you do not have to tinker with anything on the front end when you upgrade the implementation contract to the next version. You simply point the proxy contract at the new contract.

The interface is used to standardize the functions in the dapp, to ensure the user experience will not be affected when upgrading the implementation contract. This is because everything from the user's perspective is still the same. What goes in and what comes out is still the same.

Furthermore, because no state variables are stored on the implementation contract, the state of the dapp remains unchanged. Therefore, all user balances and anything like that are kept secure on the immutable blockchain.

Best Practices and Considerations for Solidity Interfaces

It is highly important to build effective and useful interfaces. Security considerations are also highly important. Here are some best practices to consider when developing your own Solidity interfaces. Along with some Security considerations and how to mitigate them.

Interface Best Practices

  1. Clear and consistent Naming: Use clear and descriptive function names. This makes it easy for developers to understand the purpose of each element.

  2. Documentation: Document interfaces thoroughly. Include descriptions of each function, its parameters and its return values.

  3. Interface Minimalism: Keep interfaces as minimal as possible. Don't use any functions that are not absolutely necessary for the contract to interact with other contracts. This reduced the chances of potential security exploits.

  4. Versioning: In the event that you do need to make changes to an interface, create a new version, to avoid breaking existing contracts that implement the interface.

  5. Security Considerations: Always consider security implications when designing interfaces. For example, avoid using interfaces to link to sensitive information.

Potential Security Implications of Improperly Implemented Interfaces

  1. Reentrancy Attacks: These occur when an external contract is called before status is updated. This can allow hackers to call a function, like a withdraw function, over and over again, in a single transaction.

  2. Upgradability Issues: If an interface is not designed with future upgrades in mind, It may not be possible to upgrade a contract without breaking its functionality.

Mitigating Security Risks

  1. Use Reentrancy Guards: Implement reentrancy guards in your contracts.

  2. Design For Upgradability: Use patterns like the proxy upgrade pattern to make your contracts more upgradable.

  3. Code Audits: Regularly audit your smart contracts and interfaces. This way you can catch and fix vulnerabilities before they can be exploited.

  4. Community Feedback: There are always blockchain developers happy to give feedback in the community. I would recommend the cyfrin updrift discord. They regularly share and discuss each others projects and ideas.

By following these best practices and security considerations, you can design more effective solidity interfaces and mitigate potential risks before it's too late.

Conclusion

So, to recap, Interfaces are a collection of external facing functions, from a particular smart contract. They define only what is necessary to interact with the smart contract and its functions. This abstraction turns each interface into a building block, which enhances modularity in programming dApps. Interfaces also boost upgradability of smart contracts, by separating logic and storage. This design style can be found in upgrade patterns like the proxy upgrade pattern.

Best practices for designing and implementing interfaces are all geared towards clarity, minimalism and consistency across the naming and documentation of the functions included. In addition, ensure that security and upgradability implications have been thoroughly considered. Always audit your smart contracts prior to release.

Future outlook

The blockchain space is constantly evolving. So too, are the role of interfaces. As decentralized apps continue to increase in size and complexity, effective interaction between different smart contract chunks will become ever more important.

Ai is at the forefront of innovation in 2024. I can see this lead to huge, decentralized Ai models, the likes of which will eventually compete with centralized models like ChatGPT, because decentralized compute power could potentially dwarf the amount of compute available to centralised Ai models, that are run in huge data centers or centralized cloud services. This in turn will foster a plethora of new decentralized applications, solving the problems of tomorrow. Interfaces will play a crucial role in how users and developers will interact with and build these systems, ensuring they are safe and fair for everyone.

Today it is more important than ever for smart contract developers to continue their learning. Keeping up with the latest trends and tools is crucial to stay competitive and relevant in the ever-evolving world of smart contract development.


Refrences

Solidity Interfaces: https://docs.soliditylang.org/en/v0.8.25/contracts.html#interfaces

Solidity function Visibility Specifiers and modifiers - https://docs.soliditylang.org/en/v0.8.25/cheatsheet.html#function-visibility-specifiers

OpenZeppelin Proxy Upgrade Pattern - https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies

OpenZeppelin Reentrancy Guard - https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard

Last updated