# Introduction to modular smart contract architecture

By [V12 ](https://paragraph.com/@sprkfi) · 2024-09-03

---

This article presents a modular architecture for writing smart contracts which enables developers to upgrade their contracts while also protecting users from upgrades the developers introduce.

The idea presented here is not entirely novel and it can be seen as an extension of the [EIP-2535 Diamond Standard](https://eips.ethereum.org/EIPS/eip-2535).

This architecture is not the end game of what can be achieved however it acts as an introduction to the first stage. Subsequent stages require complexity beyond the scope of this presentation.

**Architecture**
================

The architecture consist of 4 components

*   **Core Contract** which is the API to its state
    
*   **Modules** which are logical components that perform work and settle their computation in the **Core Contract** if permitted
    
*   **Access Control List (ACL)** which determines the interactions between contracts
    
*   **Registry** which keeps track of user **ACLs**
    

**Core Contract**
=================

A smart contract usually contains all of its logic within itself and thus updating a contract means redeploying the entire source code.

To minimise the need for redeploying the entire contract the **Core Contract** acts as a minimal interface to its storage meaning that most of the code is abstracted away into **modules**.

In practice, this means the contract exists to validate the caller (**module**) and upon validation updates its state with the arguments from the calling **module**.

**Contract Extensions**
=======================

Extensions are additional functions that may be implemented directly on the **Core** **Contract** but are not required for minimal functionality.

For example, a contract may not expose functionality to check the internal balance (ex. rebasing assets) of a user however this may lead to a poor user experience since the user may have no means of querying their balance for a subsequent action.

**Modules**
===========

A **module** is a minimal contract that performs computation that would be carried out within the **Core Contract**.

Moving the functionality from **Core** into a **module** enables minimal changes to be implemented to the system without redeploying the entire contract.

Modules must make a call back to the **Core Contract** to settle. These calls can be specific to an implementation through the use of an **ABI** or generic by using the low-level call provided by the Sway standard library.

The low level call is more complex to implement however it enables the module to work with any number of compatible contracts which also unlocks the possibility of a module marketplace.

**Access Control List (ACL)**
=============================

The **ACL** is a contract that tracks which contracts can perform specific actions, namely, perform computation and settle in the **Core Contract**.

**Developers**
==============

From the developer’s perspective, the **ACL** is used by the **Core** **Contract** to check if a **module** is permitted to call its function.

**Users**
=========

From the user’s perspective, the **ACL** acts as a safety check to prevent a developer from switching a **module** when the user may not consent to that change.

It defines which **modules** the user allows to perform computation on their behalf within their workflow.

**Registry**
============

The registry is a contract that contains a mapping between each user and their **ACL** to query the modules.

### **Example System**

The example contains pseudo-code which uses each component within the system. For more information check the [repository](https://github.com/compolabs/Modular-Contracts).

![](https://storage.googleapis.com/papyrus_images/1dc384980fa66463262e9a0ff95f8a92f4a4aba8311b44287f1e5016da7e62b8.webp)

**Walk-through**

The following steps broadly describe the workflow presented in the image above.

*   A user interacts with a DApp to perform an action, ex. deposit into a vault
    
*   The DApp queries the **Registry** to find the user’s **ACL** in order to query for available actions
    
*   The DApp queries the **ACL** and discovers the preferences the user has set.
    
*   The DApp calls a **module** to perform computation on behalf of the user.
    
*   The **module** runs its code and proceeds to call the **Core** **Contract** to settle its results.
    
*   The **Core** **Contract** checks its **ACL** to validate whether the **module** is authorised to settle.
    
*   Upon validation the **Core** **Contract** settles by updating its state with the provided arguments by the module.
    
*   Optionally, the **Core** **Contract** may send a message upstream to the **module** to continue the workflow.
    

**Code**

The following example is a simple vault.

**Core**

The contract keeps track of its **ACL** in order to perform validation of the calling **modules** and the balance of each asset from each user.

The `deposit()` function starts by validating the caller in the `firewall()` function. It contains assertions and calls to the **ACL** to ensure that the module is permitted to change its state.

Upon validation the balance is updated with the data provided by the **module** and a log is emitted.

    contract;
    

    configurable {
        ACL: ContractId::from(/* some ID */)
    }
    storage {
        // Map(user => Map(asset => amount))
        balances: StorageMap<Identity, StorageMap<AssetId, u64>> = StorageMap {},
    }
    impl Vault for Contract {
        #[payable]
      #[storage(read, write)]
      fn deposit(user: Identity) {
          // Call the ACL contract and assert module is authorized to call this fn
          firewall(String::from_ascii_str("deposit(Identity)"), ACL);
            // Module forwards asset from the user
        let balance = storage.balances.get(user).get(msg_asset_id()).try_read().unwrap_or(0);
        storage
            .balances
            .get(user)
            .insert(msg_asset_id(), balance + msg_amount());
        log(DepositEvent {
            user,
            asset: msg_asset_id(),
            amount: msg_amount(),
        })
        }
    }
    

### **User Extension**

The extension is an information provider therefore it has not been restricted to **module** only calls.

    impl User for Contract {
        #[storage(read)]
      fn balance(user: Identity, asset: AssetId) -> u64 {
          storage.balances.get(user).get(asset).try_read().unwrap_or(0)
      }
    }
    

### **Deposit Module**

This module contains code that would be within the **Core Contract**.

It is restricted to a single contract through the implementation of the ABI over the low level call and after it finishes its computation it calls the target contract to settle.

In this example the additional complexity is omitted as that is up to the reader to implement to their requirements.

There may be multiple versions of the module for different benefits ex.

*   Module 1: 1% tax for usage
    
*   Module 2: 2% tax with 1% going to a treasury and the user gains additional benefits in return
    

As long as both modules are permitted to use the **Core Contract** then the user has the choice between modules.

A warning ought to be mentioned. When there are a lot of **modules** interacting with your settlement there may come a time when complexity arises from mixing different **modules**. This may lead to unintended consequences which may not have been foreseen when looking at each **module** individually.

    contract;
    

    use vault::Vault;
    impl Deposit for Contract {
        #[payable]
        fn deposit(vault: ContractId) {
        let target = abi(Vault, vault.into());
        // insert additional module logic before calling the target to deposit
        
        target.deposit {
            asset_id: msg_asset_id().into(),
            coins: msg_amount(),
        }(msg_sender().unwrap())
        }
    }
    

### **Access Control List (Developer)**

The **ACL** for the developer contains code to associate a **module** with a specific function on the **Core Contract**. This functionality may also be revoked.

Each **ACL** ought to be deployed for each new **Core Contract**. If there is ever a time when this contract is compromised it will limit the scope to a single application rather than all applications.

In this example we store the hash of the function signature rather than the function selector. The reader is free to make an adjustment here and in the **Core Contract** in order to enable low level calls.

    configurable {
        OWNER: Address = Address::from(ZERO_B256)
    }
    

    storage {
        // Map(fn => Map(module, bool))
        ACL: StorageMap<b256, StorageMap<ContractId, bool>> = StorageMap {}
    }
    impl VaultACL for Contract {
      #[storage(read, write)]
      fn add(function: b256, module: ContractId) {
        require(
          msg_sender().unwrap().as_address().unwrap() == OWNER, 
          AuthError::Unauthorized
        );
        storage.ACL.get(function).insert(module, true);
        log(AddModuleEvent { function, module })
      }
      #[storage(read, write)]
      fn remove(function: b256, module: ContractId) {
        require(
          msg_sender().unwrap().as_address().unwrap() == OWNER, 
          AuthError::Unauthorized
        );
        storage.ACL.get(function).insert(module, false);
        log(RemoveModuleEvent { function, module })
      }
    }
    impl Info for Contract {
      #[storage(read)]
      fn authorized(function: b256, module: ContractId) -> bool {
        storage.ACL.get(function).get(module).try_read().unwrap_or(false)
      }
    }
    

### **Access Control List (User)**

Similar to the developer **ACL** the user has a contract deployed for each application to reduce the risk upon a contract being compromised or access being lost.

    configurable {
      OWNER: Address = Address::from(ZERO_B256),
    }
    

    storage {
      // Map(module => bool)
      ACL: StorageMap<ContractId, bool> = StorageMap {},
    }
    impl UserACL for Contract {
      #[storage(read, write)]
      fn add(module: ContractId) {
          require(
              msg_sender().unwrap().as_address().unwrap() == OWNER,
          AuthError::Unauthorized,
        );
        storage.ACL.insert(module, true);
        log(AddModuleEvent { module })
      }
      #[storage(read, write)]
      fn remove(module: ContractId) {
        require(
            msg_sender().unwrap().as_address().unwrap() == OWNER,
            AuthError::Unauthorized,
        );
        storage.ACL.insert(module, false);
        log(RemoveModuleEvent { module })
      }
    }
    impl Info for Contract {
      #[storage(read)]
      fn authorized(module: ContractId) -> bool {
        storage.ACL.get(module).try_read().unwrap_or(false)
      }
    }
    

### **Registry**

The registry keeps track of users and their **ACL**.

It follows the same simple API for storing its data.

    contract;
    

    configurable {
        OWNER: Address = Address::from(ZERO_B256),
    }
    storage {
        // Map(user => acl)
        registry: StorageMap<Address, Option<ContractId>> = StorageMap {},
    }
    impl Registry for Contract {
        #[storage(read, write)]
        fn add(acl: ContractId, user: Address) {
            require(
                msg_sender().unwrap().as_address().unwrap() == OWNER,
                AuthError::Unauthorized,
            );
            storage.registry.insert(user, Some(acl));
            log(AddACLEvent { acl, user })
        }
        #[storage(read, write)]
        fn remove(user: Address) {
            require(
                msg_sender().unwrap().as_address().unwrap() == OWNER,
                AuthError::Unauthorized,
            );
            let acl = storage.registry.get(user).read();
            require(acl.is_some(), RemoveError::MissingACL);
            storage.registry.insert(user, None);
            log(RemoveACLEvent { acl: acl.unwrap(), user })
        }
    }
    impl Info for Contract {
        #[storage(read)]
        fn acl(user: Address) -> Option<ContractId> {
            storage.registry.get(user).try_read().unwrap_or(None)
        }
    }
    

### **Summary**

The modular architecture splits contracts into smaller components which makes it easier to maintain and implement new features while minimising the attack surface.

The modules may be open to use for the network reducing the need for everyone to redeploy the same code.

Users may configure custom workflow systems through the use of various modules which enables them to closer satisfy their preferences.

There is a trade-off between modularity and execution speed through Fuel’s parallelism. When a module changes the state of the core contract that code must be executed sequentially because of the state access list. If a contract cannot be reasonably designed to be modular and execution speed is paramount then the developer may opt to deploying multiple copies of the same monolithic contract with different configurable arguments in order to gain maximum execution speed.

If upgradability is a priority and parallelism may not be as important, or cannot be reasonably designed into the system, then the modular approach may be taken.

---

*Originally published on [V12 ](https://paragraph.com/@sprkfi/introduction-to-modular-smart-contract-architecture)*
