Architecture

The primary goal of the current architecture is to give flexibility in order to accommodate the project's potential for development over time. Having stated that, the EIP-2535: Diamonds, Multi-Facet Proxy served as an inspiration for the system architecture. Although the Diamond standard achieves the primary objective, it is complicated and has features that we will not use. However, we have implemented a storage-module design with the modifications and advantages listed below:

  • Simplified version of the Diamond standard.

  • Instead of facets, we have modules. It is pretty much the same functionality, but with a more familiar terminology.

  • It allows the admins to easily upgrade the protocol and add/update/remove functionality.

  • Decreases the chance of reaching the 24kb contract limit size.

  • Mitigates storage collision during upgrades.

Any call or transaction is forwarded to the appropriate module, which contains the implementation code, by the entry point contract DIMORegistry acting as a proxy. Despite the fact that state variables can be defined in modules, they are not really preserved in the module storage. The DIMORegistry contract contains all of the state, and the modules contain all of the logic.

The DIMORegistry contract manages the system's modules, including their additions, deletions, updates, and tracking. To add, remove, or update a module, you'll need the implementation address as well as all of the function signatures. The main contract associates each signature ever registered in the system with its corresponding implementation, mapping the implementation address to a hash made up of all function signatures of that module.

function addModule(address implementation, bytes4[] calldata selectors);
// selector => implementation
mapping(bytes4 => address) implementations;

// implementation => keccak256(abi.encode(selectors))
mapping(address => bytes32) selectorsHash;

In order to forward the calls to the correct module, the fallback function gets a corresponding implementation based on the msg.sig of the call.

fallback() external {
    address implementation = DIMOStorage.getStorage().implementations[
        msg.sig
    ];
    assembly {
        // Forwarding call
    }
}

Storage

Storage collision is one of the key problems with upgradable proxy arrangements. You must be cautious when upgrading after specifying the storage design in the proxy to prevent overwriting current state variables and losing data.

By using an unstructured storage proxy, the Diamond Standard and the DIMO identity architecture both avoid the storage collision. Each module that uses state variables has separate storage of its own. This can be achieved by creating a struct containing its corresponding state variables, that is kept in a slot defined by a hash based on the name of the module. The code sample below demonstrates how the storage of the modules is defined as libraries in order to minimise the contract size.

library ModuleStorage {
    // 0x0c1251637bebf82c52b8c29b02dcea90fc9d91eeb0ced0c3380faa7c04899173
    bytes32 private constant MODULE_STORAGE_SLOT =
        keccak256("DIMORegistry.module.storage");

    struct Storage {
        // State variables
        uint256 id;
    }

    function getStorage() internal pure returns (Storage storage s) {
        bytes32 slot = MODULE_STORAGE_SLOT;
        assembly {
            s.slot := slot
        }
    }
}

contract Module {
    function example() external {
        ModuleStorage.Storage storage s = ModuleStorage.getStorage();
        s.id = 1;
    } 
}

The variables inside the struct adhere to the conventional proxy's collision rule. Instead of the entire system, it just applies to the variables within the struct. Nevertheless, by constructing tiny, independent archives that are randomly assigned to certain locations in the storage, we are able to reduce the likelihood of collisions.

Node Structure

DIMO's primary functionality is organised as a tree with parents and children. Each node is a representation of a real-world object, such as a manufacturer, a vehicle, or an aftermarket device. Every time a new entity is introduced to the DIMO system, they are minted as NFTs in tree structure to ensure their uniqueness. A new node can be added to the tree structure in one of two ways:

  1. Root

    • The new node does not have a parent and it is a starting point which all other nodes will be derived from. Ex: Manufacturer

  2. Child

    • The node must be associated with a parent node that also represents a real world relationship. Ex: A Vehicle and an Aftermarket Device must be under their respective Manufacturer

The struct that represents each node is in the following format:

struct Node {
    uint256 parentNode;
    mapping(string => string) info;
}
  • parentNode: this is the link to the immediate parent node that builds the tree structure.

  • info: a mapping to store any attribute-value associate to the node. It is worth reminding that only whitelisted attributes are allowed to be in the mapping.

Sharing between modules

This article shows different ways to share functionality between facets in the Diamond Standard. Since DIMO is inspired by the same standard, we also use similar approaches to sharing functionality.

  1. Storing shared state inside an existing storage struct.

  2. Organizing and refactoring the modules so they don't need cross-module calls.

  3. Writing libraries/contracts with internal function calls.

Last updated