Multi-proxy smart contract EIP-2535 “Diamond”


Introduction

Link to video tutorial and detailed explanation: https://www.youtube.com/watch?v=42TUqDW74v8

GitHub: https://github.com/davydovMikhail/multi-proxy-contract

This article will focus on the EIP-2535 standard, also commonly known as Diamond or Multi-Facet Proxy. The standard makes it possible to create modular, upgradable smart contracts that have a number of advantages over such updatable contract standards as Transparent and UUPS.

Consider the reasons why you can use the Diamond standard for your project:

  1. Diamond has a single proxy address that is used as an entry point to access all implementations, this approach simplifies deployment, testing and integration with other smart contracts.

  2. Diamond has no restrictions on the size of the contract, that is, the total size of all your implementations can go well beyond 24 kilobytes.

  3. The standard allows you to implement a flexible system of interaction between faces (facets), storages and libraries.

  4. Over time, and as needed, contract functionality can be added, replaced, or removed altogether. Also, for the reason described in paragraph 2, the contract has no restrictions on the number of functions that are used in it.

  5. The Diamond contract may be immutable, either immediately or at a later date, when a decision is made to make Diamond immutable.

  6. Diamond can use already deployed implementations, which can positively affect gas spending. This approach makes this standard open to all network participants.

  7. Implementations or faces are independent of each other, but can share internal functions, libraries, and state variables.

  8. Adding/replacing/removing several functions at once can be carried out in one transaction.

  9. You can use DAO and other contract update initializers to change implementations.

Theory

Diamond calls the functions of its faces (facet) using delegatecall, if someone needs to remember or learn how delegatecall works, you are welcome: solidity-by-example/delegatecall.

When Diamond is accessed by some address and calls a function from the implementation, a fallback is triggered. How the fallback function works: solidity-by-example/fallback.

Inside the fallback function, it is determined to which address the call should be delegated based on the first four bytes from msg.data, or these 4 bytes can be obtained immediately by accessing the global variable msg.sig, so we get the function selector. More about what a function selector is and how you can get it: solidity-by-example/function-selector.

Thanks to the fallback and delegatecall properties, Diamond can perform the facet function as if it were defined in Diamond itself. When calling a function from the implementation (read faces), thanks to the delegatecall, the values ​​of msg.sender and msg.value remain unchanged, and only the diamond storage is read and written, that is, we can say that the facet (facet) to which we refer only gives an instruction , which tells how to deal with diamond storage.

Consider the conceptual implementation of the fallback() function in the main diamond contract:

fallback() external payable {
  // получаем адрес грани, на которую будет делегирован вызов
  address facet = selectorTofacet[msg.sig];
	// убеждаемся в том, что такая грань была добавлена в mapping selectorTofacet
  require(facet != address(0));
	// выполяняем вызов external функции из грани через delegatecall и возвращаем
	// какое-то значение
  assembly {
    // копируем селектор функции и аргументы
    calldatacopy(0, 0, calldatasize())
		// вызываем функцию из грани, указывая адрес этой грани    
    let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
    // получаем какое-то возвращаемое значение
    returndatacopy(0, 0, returndatasize())
    // возвращаем либо ошибку либо ответ в зависимости от значения result
    switch result
      case 0 {revert(0, returndatasize())}
      default {return (0, returndatasize())}
  }
}

Let’s sum up the structure of Diamond:

  1. There is only one fallback function in the contract.

  2. Depending on the selector, the call is delegated to the desired implementation.

  3. Each selector has its own face address.

  4. All states are stored on the Diamond contract, only logic is on the faces.

Building Diamond
Building Diamond

Storage organization

Libraries are used to service the Diamond contract storage. The number of stacks is unlimited, they can appear as needed and as new functionality and variables appear. So that the values ​​in the storage are not confused and not overwritten in a chaotic way, each storage is organized into a structure, in turn, the structure is placed in a specific cell, which is the entry point, to access a specific structure variable.

Consider the following example: we have two faces that access the same stack using a library. These faces will be a partial implementation of the ERC-721 standard.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// библиотека для обслуживания стораджа
library LibERC721 {
		// получаем идентификатор стораджа
    bytes32 constant ERC721_POSITION = keccak256("erc721.storage");
 
		// перечисляем в структуре стораджа состояния, к которым будем обращаться 
    struct ERC721Storage {
        // tokenId => owner
        mapping (uint256 => address) tokenIdToOwner;
        // owner => count of tokens owned
        mapping (address => uint256) ownerToNFTokenCount;
        string name;
        string symbol;   
    }

		// функция, возвращающая структуру стораджа из слота ERC721_POSITION
    function getStorage() internal pure returns (ERC721Storage storage storageStruct) {
        bytes32 position = ERC721_POSITION;
        assembly {
            storageStruct.slot := position
        }
    }
    event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);

		// все функции, которые есть в библиотеке должны быть internal
    function transferFrom(address _from, address _to, uint256 _tokenId) internal {
				// обращаемся к getStorage, чтобы получить структуру стораджа
				// указываем ключевое слово storage, это говорит компилятору о том,
				// что мы читаем и вносим изменения именно в хранилище контракта,
				// а не в memory или calldata
        ERC721Storage storage erc721Storage = LibERC721.getStorage();
				// изменяем переменные так как нам нужно
        address tokenOwner = erc721Storage.tokenIdToOwner[_tokenId];
        require(tokenOwner == _from);
        erc721Storage.tokenIdToOwner[_tokenId] = _to;
        erc721Storage.ownerToNFTokenCount[_from]--;
        erc721Storage.ownerToNFTokenCount[_to]++;
        emit Transfer(_from, _to, _tokenId);
    }
}
// грань, которая реализует три метода, при этом во всех методах, получение 
// и запись значений происходит из библиотеки, в свою очередь библиотека изменяет 
// хранилище diamond, используя ERC721_POSITION как точку входа для обращения к стораджу,
// а struct ERC721Storage как темплейт, который показывает как правильно обратиться
// к той или иной переменной внутри контракта Diamond
contract ERC721Facet {
    function name() external view returns (string memory name_) {
        name_ = LibERC721.getStorage().name;
    }
    
    function symbol() external view returns (string memory symbol_) {
        symbol_ = LibERC721.getStorage().symbol;
    }

    function transferFrom(address _from, address _to, uint256 _tokenId) external {
        LibERC721.transferFrom(_from, _to, _tokenId);
    }
}
// ещё одна грань, которая использует библиотеку
contract ERC721BatchTransferFacet {
    function batchTransferFrom(address _from, address _to, uint256[] calldata _tokenIds) external {
        for(uint256 i; i < _tokenIds.length; i++) {
          LibERC721.transferFrom(_from, _to, _tokenIds[i]);
        }
    }
}

How to update storage correctly:

  1. To add new state variables to the storage structure, add them to the very end of the structure.

  2. If you need to add mapping, put it at the end of the structure as well.

  3. State variable names can be changed, but this can be confusing if different faces use different names for the same storage locations. Therefore, for all faces, it is recommended to use libraries where storage is defined in the same way, both in terms of naming and in terms of the order of variables in the structure.

What should not be done when changing storage:

  1. Do not add new state variables to the beginning or middle of structures. Doing this causes the new state variable to overwrite the existing state variable data and all state variables after it, because the new state variable references the wrong storage location.

  2. Suppose you have a mapping in the storage structure that returns a different structure, this practice is acceptable if the returned structure will not be updated throughout the lifetime of the Diamond. If in the future there will be a need to change the structure returned by the mapping, then you will have to create a new mapping for this structure.

  3. Do not add new state variables to structures that are used in arrays.

  4. When using Diamond storage, do not use the same slot number (ERC721_POSITION in the example) for different storages. It is obvious. Two different repositories in the same location will overwrite each other.

  5. Don’t let any facet be able to call selfdestruct. Just don’t let the team selfdestruct exist in any facet source code, and do not allow this command to be called through a delegatecall. because selfdestruct can remove a facet that is used by a diamond, or selfdestruct can be used to remove Diamond’s main proxy contract.

Edges can share the same storages. Edges can use the same internal functions and libraries. Thus, the faces are independent units from each other, but at the same time they can access the same libraries to change the state of the store.

An example of the organization of the storage architecture and the logic of the faces
An example of the organization of the storage architecture and the logic of the faces

In the diagram above:

  • Only FacetA can access DataA

  • Only FacetB can access DataB

  • Only Diamond code can access DataD

  • Both FacetA and FacetB have access to DataAB

  • DataABD can be accessed from anywhere

Adding/replacing/removing features

Any Diamond must implement the IDiamond interface.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IDiamond {
		// действие, которое нам необходимо произвести по отношение к грани и ее функциям
    enum FacetCutAction {Add, Replace, Remove}
    // Add=0, Replace=1, Remove=2 (добавить, заменить, удалить)

		// структура, в которой описаны действия для редактирования грани
    struct FacetCut {
        address facetAddress; // адрес грани
        FacetCutAction action; // производимое действие
        bytes4[] functionSelectors; // массив с селекторами функций
    }

		// событие, которое вызывается каждый раз, 
		// когда грани добавляются, заменяются, удаляются
    event DiamondCut(FacetCut[] _diamondCut, address _init, bytes _calldata);
}

In turn, the IDiamond interface is inherited by another IDiamondCut interface, which contains a single diamondCut function.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { IDiamond } from "./IDiamond.sol";

interface IDiamondCut is IDiamond {    
		// _diamondCut - содержит адрес грани, селекторы и действие
		// _init - адрес контракта на который будет вызвана _calldata
		// в конце функции diamondCut
    function diamondCut(
        FacetCut[] calldata _diamondCut,
        address _init,
        bytes calldata _calldata
    ) external;    
}

The diamondCut function updates any number of features from any number of facets in a single transaction. Performing all changes within a single transaction prevents data corruption that can occur with updates performed across multiple transactions.

After adding/replacing/removing functions, the _calldata argument is executed to the _init address via delegatecall. This execution is performed to initialize data or set up or remove anything needed or no longer needed after functions have been added, replaced and/or removed. You can draw an analogy with a constructor function, which also initializes some initial values.

If the value of _init is zero address address(0) then execution of _calldata is skipped. In this case, _calldata may contain 0 bytes or user information, which will be sent to etherscan via the DiamondCut event.

Verification of faces and functions

Diamond must support aspect and function checking by implementing the IDiamondLoupe interface.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IDiamondLoupe {
    // структура содержащая информацию о грани
    struct Facet {
        address facetAddress; // адрес грани
        bytes4[] functionSelectors; // массив со всеми добавленными селекторами
    }

    // получение полной информации по всем граням, на которые может делегировать Diamond
    function facets() external view returns (Facet[] memory facets_);

    // получение селекторов всех функций грани, на которые делегирует Diamond
    function facetFunctionSelectors(address _facet) external view returns (bytes4[] memory facetFunctionSelectors_);

    // получение адресов всех граней
    function facetAddresses() external view returns (address[] memory facetAddresses_);

    // получение адреса грани по селектору, функция которого находится на этой грани
    function facetAddress(bytes4 _functionSelector) external view returns (address facetAddress_);
}

Thus, using the IDiamondCut interface, we update the Diamond, and using the IDiamondLoupe, we can track the edges and their state for the presence of certain selectors.

Practice

Let’s write the usual erc20, all the functionality of which will be divided into 4 faces. This will be done for simplicity, because the internal mechanism of the erc20 standard is known to everyone or many.

But before that, we need to deal with service faces, which are responsible for changing functionality and monitoring existing faces and functionality.

The LibDiamond library and three faces will serve these purposes: DiamondCutFacet – for changing functionality, DiamondLoupeFacet – for monitoring the edges and functionality of the Diamond contract, OwnershipFacet – a face that helps administer access to the diamindCut function so that implementations are not changed by anyone.

Consider the LibDiamond library code, it will be given in an abbreviated form, the full code will be publicly available on github:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { IDiamond } from "../interfaces/IDiamond.sol";
import { IDiamondCut } from "../interfaces/IDiamondCut.sol";

library LibDiamond {
		// номер слота, который является точкой входа для обращения DiamondStorage
    bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("diamond.standard.diamond.storage");

    struct FacetAddressAndSelectorPosition {
        address facetAddress; // адрес грани
        uint16 selectorPosition; // индекс селектора в массиве bytes4[] selectors
    }

    struct DiamondStorage {
        // соответсвие селектор => информация по нему
        mapping(bytes4 => FacetAddressAndSelectorPosition) facetAddressAndSelectorPosition;
				// массив со всеми селекторами
        bytes4[] selectors;
        // владелец Diamond, который может вызывать diamondCut
        address contractOwner;
    }

		// функция для обращения к хранилищу
    function diamondStorage() internal pure returns (DiamondStorage storage ds) {
        bytes32 position = DIAMOND_STORAGE_POSITION;
        assembly {
            ds.slot := position
        }
    }

    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

		// установить нового владельца
    function setContractOwner(address _newOwner) internal {
        DiamondStorage storage ds = diamondStorage();
        address previousOwner = ds.contractOwner;
        ds.contractOwner = _newOwner;
        emit OwnershipTransferred(previousOwner, _newOwner);
    }

		// получить адрес владельца
    function contractOwner() internal view returns (address contractOwner_) {
        contractOwner_ = diamondStorage().contractOwner;
    }

		// проверка на то, что вызывающий является влдаельцем Diamond
    function enforceIsContractOwner() internal view {
        if(msg.sender != diamondStorage().contractOwner) {
            revert NotContractOwner(msg.sender, diamondStorage().contractOwner);
        }        
    }

    event DiamondCut(IDiamondCut.FacetCut[] _diamondCut, address _init, bytes _calldata);

    // основная функция с помощью которой изменяется весь функционал Diamond
    function diamondCut(
        IDiamondCut.FacetCut[] memory _diamondCut,
        address _init,
        bytes memory _calldata
    ) internal {
        for (uint256 facetIndex; facetIndex < _diamondCut.length; facetIndex++) {
            bytes4[] memory functionSelectors = _diamondCut[facetIndex].functionSelectors;
            address facetAddress = _diamondCut[facetIndex].facetAddress;
            if(functionSelectors.length == 0) {
                revert NoSelectorsProvidedForFacetForCut(facetAddress);
            }
            IDiamondCut.FacetCutAction action = _diamondCut[facetIndex].action;
            if (action == IDiamond.FacetCutAction.Add) {
                addFunctions(facetAddress, functionSelectors);
            } else if (action == IDiamond.FacetCutAction.Replace) {
                replaceFunctions(facetAddress, functionSelectors);
            } else if (action == IDiamond.FacetCutAction.Remove) {
                removeFunctions(facetAddress, functionSelectors);
            } else {
                revert IncorrectFacetCutAction(uint8(action));
            }
        }
        emit DiamondCut(_diamondCut, _init, _calldata);
        initializeDiamondCut(_init, _calldata);
    }

		// функции добавления/замены/удаления селекторов из граней
    function addFunctions(address _facetAddress, bytes4[] memory _functionSelectors) internal {        
        
    }
    function replaceFunctions(address _facetAddress, bytes4[] memory _functionSelectors) internal {        
        
    }
    function removeFunctions(address _facetAddress, bytes4[] memory _functionSelectors) internal {        
              
    }

		// функция вызываемая каждый раз в конце diamondCut, для инициализации каких-то переменных
    function initializeDiamondCut(address _init, bytes memory _calldata) internal {
        if (_init == address(0)) {
            return;
        }
        enforceHasContractCode(_init, "LibDiamondCut: _init address has no code");        
        (bool success, bytes memory error) = _init.delegatecall(_calldata);
        if (!success) {
            if (error.length > 0) {
                // bubble up error
                /// @solidity memory-safe-assembly
                assembly {
                    let returndata_size := mload(error)
                    revert(add(32, error), returndata_size)
                }
            } else {
                revert InitializationFunctionReverted(_init, _calldata);
            }
        }        
    }

		// проверка на то, что адрес является контрактом, а не адресом
    function enforceHasContractCode(address _contract, string memory _errorMessage) internal view {
        uint256 contractSize;
        assembly {
            contractSize := extcodesize(_contract)
        }
        if(contractSize == 0) {
            revert NoBytecodeAtAddress(_contract, _errorMessage);
        }        
    }
}

Consider the DiamondCutFacet face responsible for changing the Diamond functionality:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { IDiamondCut } from "../interfaces/IDiamondCut.sol";
// импортируем библиотеку, чтобы обращаться к ней и менять состояния хранилища
import { LibDiamond } from "../libraries/LibDiamond.sol";

contract DiamondCutFacet is IDiamondCut {
    
    function diamondCut(
        FacetCut[] calldata _diamondCut,
        address _init,
        bytes calldata _calldata
    ) external override {
				// проверяем, что отправитель являеися владельцем Diamind
        LibDiamond.enforceIsContractOwner();
				// непосредтвенно вызываем diamondCut из библиотеки
        LibDiamond.diamondCut(_diamondCut, _init, _calldata);
    }
}

Consider the DiamondLoupeFacet face, the code will be shown in short form, the full code can be viewed on github:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { LibDiamond } from  "../libraries/LibDiamond.sol";
import { IDiamondLoupe } from "../interfaces/IDiamondLoupe.sol";

contract DiamondLoupeFacet is IDiamondLoupe {
    
		// получение полной информации по всем граням, на которые может делегировать Diamond
    function facets() external override view returns (Facet[] memory facets_) {
        // код функции
    }

		// получение селекторов всех функций грани, на которые делегирует Diamond
    function facetFunctionSelectors(address _facet) external override view returns (bytes4[] memory _facetFunctionSelectors) {
        // код функции
    }

		// получение адресов всех граней
    function facetAddresses() external override view returns (address[] memory facetAddresses_) {
        // код функции
    }

		// получение адреса грани по селектору, функция которого находится на этой грани
    function facetAddress(bytes4 _functionSelector) external override view returns (address facetAddress_) {
        // код функции
    }
}

The last required OwnershipFacet:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { LibDiamond } from "../libraries/LibDiamond.sol";

contract OwnershipFacet {
		// передача права вледения на Diamond другому адресу
    function transferOwnership(address _newOwner) external {
				// проверка на то, что отправитель владелец Diamond
        LibDiamond.enforceIsContractOwner();
				// установка нового владельца
        LibDiamond.setContractOwner(_newOwner);
    }

		// view функция, которая возвращает адрес владельца Diamond
    function owner() external view returns (address owner_) {
        owner_ = LibDiamond.contractOwner();
    }
}

Finally, let’s look at the Diamond contract itself, and then we will understand how to correctly and in what order you need to deploy Diamond and edges.

Diamond Contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { LibDiamond } from "./libraries/LibDiamond.sol";
import { IDiamondCut } from "./interfaces/IDiamondCut.sol";

error FunctionNotFound(bytes4 _functionSelector);

struct DiamondArgs {
    address owner;
    address init;
    bytes initCalldata;
}

contract Diamond {    

    constructor(IDiamondCut.FacetCut[] memory _diamondCut, DiamondArgs memory _args) payable {
        LibDiamond.setContractOwner(_args.owner);
        LibDiamond.diamondCut(_diamondCut, _args.init, _args.initCalldata);
				// здесь может быть добавлен какой-то дополнительный код 
				// для инициализации каких-то переменных в сторадже
    }

    fallback() external payable {
				// объявляем переменную хранилища
        LibDiamond.DiamondStorage storage ds;
        bytes32 position = LibDiamond.DIAMOND_STORAGE_POSITION;
        // получаем хранилище, указав слот, через который к хранилищу можно обратиться
        assembly {
            ds.slot := position
        }
        // получаем адрес грани по селектору функции
        address facet = ds.facetAddressAndSelectorPosition[msg.sig].facetAddress;
				// если грань не была добавлена, возвращаем ошибку
        if(facet == address(0)) { 
            revert FunctionNotFound(msg.sig);
        }
        // вызываем функцию на грани и получаем назад какое-то значение
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
                case 0 {
                    revert(0, returndatasize())
                }
                default {
                    return(0, returndatasize())
                }
        }
    }

    receive() external payable {}
}

Let’s start testing, the full testing code can also be viewed on github.

let diamondCutFacet: DiamondCutFacet;
let diamondLoupeFacet: DiamondLoupeFacet;
let ownershipFacet: OwnershipFacet;
let constantsFacet: ConstantsFacet;
let balancesFacet: BalancesFacet;
let allowancesFacet: AllowancesFacet;
let supplyRegulatorFacet: SupplyRegulatorFacet;

interface FacetCut {
    facetAddress: string,
    action: FacetCutAction,
    functionSelectors: string[]
}

interface FacetToAddress {
    [key: string]: string
}

let diamondInit: DiamondInit;

let owner: SignerWithAddress, admin: SignerWithAddress, 
user1: SignerWithAddress, user2: SignerWithAddress, user3: SignerWithAddress;

const totalSupply = parseEther('100000');
const transferAmount = parseEther('1000');
const name = "Token Name";
const symbol = "SYMBOL";
const decimals = 18;

beforeEach(async () => {
    [owner, admin, user1, user2, user3] = await ethers.getSigners();
});

enum FacetCutAction {
    Add,
    Replace,
    Remove
}

let calldataAfterDeploy: string;
let addressDiamond: string;

let facetToAddressImplementation: FacetToAddress = {};

// массив с инструкциями по части добавления новых граней и селекторов
let facetCuts: FacetCut[] = [];

// обслуживающие грани и сам Diamond

const FacetNames = [
    'DiamondCutFacet',
    'DiamondLoupeFacet',
    'OwnershipFacet'
];
// сначала деплоим обслуживающие грани, без которых Diamond не сможет существовать как Diamond
// если эти грани уже задеплоены, они могут быть использованы повторно
mocha.step("Деплой обязательных граней для обслуживания Diamond", async function() {
    for (const FacetName of FacetNames) {
        const Facet = await ethers.getContractFactory(FacetName)
        const facet = await Facet.deploy()
        await facet.deployed();
				// наполняем массив, указывая адрес грани, действие(добавить) и
				// и массив с селекторами, которые были получены с помощью кастомного хелпера
				// код также можно найти в проекте на github
        facetCuts.push({
          facetAddress: facet.address,
          action: FacetCutAction.Add,
          functionSelectors: getSelectors(facet)
        });
				// записываем имя грани и адрес, куда грань быда задеплоена
        facetToAddressImplementation[FacetName] = facet.address;
    };
});

// деплой Diamond, в качестве аргумента передаем массив с инструкциями facetCuts
// и прочие аргументы в diamondArgs
mocha.step("Деплой контракта Diamond", async function () {
    const diamondArgs = {
        owner: owner.address, // адрес владельца, который может менять имплементации
        init: ethers.constants.AddressZero, // нулевой адрес, так как нам ничего не нужно инициализировать
        initCalldata: '0x00' // пустая коллдата, так как нам ничего не нужно вызывать для инициализации
    };
    const Diamond = await ethers.getContractFactory('Diamond')
    const diamond = await Diamond.deploy(facetCuts, diamondArgs)
    await diamond.deployed();
    addressDiamond = diamond.address;
});

// созадем инстансы контрактов, но в качестве адреса указывваем адрем Diamond,
// так как для всех операция он является единственной точкой входа
mocha.step("Инициализация обслуживающих контрактов", async function () {
    diamondCutFacet = await ethers.getContractAt('DiamondCutFacet', addressDiamond);
    diamondLoupeFacet = await ethers.getContractAt('DiamondLoupeFacet', addressDiamond);
    ownershipFacet = await ethers.getContractAt('OwnershipFacet', addressDiamond);
});

// в последующих проверках обращаемся к грани DiamondLoupeFacet, 
// чтобы убедиться, что обслуживающие грани были добавлены и что сама грань DiamondLoupeFacet
// работает корректно
mocha.step("Убеждаемся в том, что адреса граней на контракте совпадают с теми, которые были получены при деплое имплементаций", async function () {
    const addresses = [];
    for (const address of await diamondLoupeFacet.facetAddresses()) {
        addresses.push(address)
    }
    assert.sameMembers(Object.values(facetToAddressImplementation), addresses)
});

mocha.step("Получим селекторы функций по адресам их граней", async function () {
    let selectors = getSelectors(diamondCutFacet)
    let result = await diamondLoupeFacet.facetFunctionSelectors(facetToAddressImplementation['DiamondCutFacet'])
    assert.sameMembers(result, selectors)
    selectors = getSelectors(diamondLoupeFacet)
    result = await diamondLoupeFacet.facetFunctionSelectors(facetToAddressImplementation['DiamondLoupeFacet'])
    assert.sameMembers(result, selectors)
    selectors = getSelectors(ownershipFacet)
    result = await diamondLoupeFacet.facetFunctionSelectors(facetToAddressImplementation['OwnershipFacet'])
    assert.sameMembers(result, selectors)
});

mocha.step("Получим адреса граней по селекторам, кторые относятся к этим граням", async function () {
    assert.equal(
        facetToAddressImplementation['DiamondCutFacet'],
        await diamondLoupeFacet.facetAddress('0x1f931c1c') //diamondCut(FacetCut[] calldata _diamondCut, address _init, bytes calldata _calldata)
    )
    assert.equal(
        facetToAddressImplementation['DiamondLoupeFacet'],
        await diamondLoupeFacet.facetAddress('0x7a0ed627') // facets()
    )
    assert.equal(
        facetToAddressImplementation['DiamondLoupeFacet'],
        await diamondLoupeFacet.facetAddress('0xadfca15e') // facetFunctionSelectors(address _facet)
    )
    assert.equal(
        facetToAddressImplementation['OwnershipFacet'],
        await diamondLoupeFacet.facetAddress('0xf2fde38b') // transferOwnership(address _newOwner)
    )
});

mocha.step("Трансфер права менять имплементации и обратно", async function () {
    await ownershipFacet.connect(owner).transferOwnership(admin.address);
    assert.equal(await ownershipFacet.owner(), admin.address);
    await ownershipFacet.connect(admin).transferOwnership(owner.address);
    assert.equal(await ownershipFacet.owner(), owner.address);
});

We have deployed the Diamond base part, which is necessary for managing implementations. Now we are implementing the functionality of the ERC20 token on Diamond, with all the functions that are inherent in this standard.

The entire storage of the token will be divided into three stacks, which will be accessed by 4 edges, let’s look at the architecture of edges and stacks in the diagram:

ERC20 architecture in the context of our Diamond
ERC20 architecture in the context of our Diamond

The LibConstants library will be responsible for storing token constants such as name, symbol, decimals and the address of the admin, who will have access to the mint(), burn() functions from the SupplyRegulatorFacet facet.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

error NotTokenAdmin();

library LibConstants {
		// слот, через который можно обратиться к ConstantsStates
    bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("erc20.constants");

    event AdminshipTransferred(address indexed previousAdmin, address indexed newAdmin);

		// переменные в хранилище "erc20.constants"
    struct ConstantsStates {
        string name;
        string symbol;
        uint8 decimals;
        address admin;
    }

    function diamondStorage() internal pure returns (ConstantsStates storage ds) {
        bytes32 position = DIAMOND_STORAGE_POSITION;
        assembly {
            ds.slot := position
        }
    }

		// проверка на то, что отправитель является админом
    function enforceIsTokenAdmin() internal view {
        if(msg.sender != diamondStorage().admin) {
            revert NotTokenAdmin();
        }        
    }

		// функция установки нового админа
    function setTokenAdmin(address _newAdmin) internal {
        ConstantsStates storage ds = diamondStorage();
        address previousAdmin = ds.admin;
        ds.admin = _newAdmin;
        emit AdminshipTransferred(previousAdmin, _newAdmin);
    }
}

The following LibBalances library is responsible for storing balances and related functions:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

library LibBalances {
		// слот, через который можно обратиться к BalancesStates
    bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("erc20.balances");

    event Transfer(address indexed from, address indexed to, uint256 value);
		
		// переменные в хранилище "erc20.balances"
    struct BalancesStates {
        mapping(address => uint256) balances;
        uint256 totalSupply;
    }

    function diamondStorage() internal pure returns (BalancesStates storage ds) {
        bytes32 position = DIAMOND_STORAGE_POSITION;
        assembly {
            ds.slot := position
        } 
    }

		// внутренние функции transfer, mint, burn, взятые прямиком из стандарта erc20 от openzeppelin
    function transfer(
        address from,
        address to,
        uint256 amount
    ) internal {
        BalancesStates storage ds = diamondStorage();
        require(from != address(0), "ERC20: transfer from the zero address");
        require(to != address(0), "ERC20: transfer to the zero address");

        uint256 fromBalance = ds.balances[from];
        require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
        unchecked {
            ds.balances[from] = fromBalance - amount;
            ds.balances[to] += amount;
        }
        emit Transfer(from, to, amount);
    }

    function mint(address account, uint256 amount) internal {
        BalancesStates storage ds = diamondStorage();
        require(account != address(0), "ERC20: mint to the zero address");
        ds.totalSupply += amount;
        unchecked {
            ds.balances[account] += amount;
        }
        emit Transfer(address(0), account, amount);
    }

    function burn(address account, uint256 amount) internal {
        BalancesStates storage ds = diamondStorage();
        require(account != address(0), "ERC20: burn from the zero address");
        uint256 accountBalance = ds.balances[account];
        require(accountBalance >= amount, "ERC20: burn amount exceeds balance");
        unchecked {
            ds.balances[account] = accountBalance - amount;
            ds.totalSupply -= amount;
        }
        emit Transfer(account, address(0), amount);
    }
}

To initialize constants from the LibConstants library, you need a separate contract with an initialization function that will be called immediately after adding a face to Diamond.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// импорт библиотек, хранилище которых нужно проинициализировать
import { LibConstants } from "../libraries/LibConstants.sol";
import { LibBalances } from "../libraries/LibBalances.sol";

contract DiamondInit {    
		// фунция инициализации переменных
    function initERC20(string calldata _name, string calldata _symbol, uint8 _decimals, address _admin, uint256 _totalSupply) external {
        LibConstants.ConstantsStates storage constantsStorage = LibConstants.diamondStorage();
				// инициализируем переменные:
        constantsStorage.name = _name;
        constantsStorage.symbol = _symbol;
        constantsStorage.decimals = _decimals;
        constantsStorage.admin = _admin;
				// формируем первоначальное предложение, обращаясь к соотвествующей библиотеке
        LibBalances.mint(_admin, _totalSupply);
    }
}

Finally, let’s write a face contract that will return some constants by referring to the LibConstants library:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { LibConstants } from "../libraries/LibConstants.sol";

contract ConstantsFacet {

		// обычные view функции присущие стандарту ERC20: name, symbol, decimals
    function name() external view returns (string memory) {
        LibConstants.ConstantsStates storage ds = LibConstants.diamondStorage();
        return ds.name;
    }

    function symbol() external view returns (string memory) {
        LibConstants.ConstantsStates storage ds = LibConstants.diamondStorage();
        return ds.symbol;
    }

    function decimals() external view returns (uint8) {
        LibConstants.ConstantsStates storage ds = LibConstants.diamondStorage();
        return ds.decimals;
    }

		// посмотреть текущего админа
    function admin() external view returns (address) {
        LibConstants.ConstantsStates storage ds = LibConstants.diamondStorage();
        return ds.admin;
    }
		
		// функция передачи админских прав на токен
    function transferAdminship(address _newAdmin) external {
				// проверяем на то, что отправитель это текущий админ
        LibConstants.enforceIsTokenAdmin();
				//устанавливаем нового админа
        LibConstants.setTokenAdmin(_newAdmin);
    } 
}

Let’s continue testing, during which we will initialize the storage variables using the initERC20 function from the DiamondInit contract, and also add a new ConstantsFacet face:

mocha.step("Деплой контракта который инициализирует значения переменных для функций name(), symbol() и т. д. во время вызова функции diamondCut", async function() {
    const DiamondInit = await ethers.getContractFactory('DiamondInit');
    diamondInit = await DiamondInit.deploy();
    await diamondInit.deployed();
});

mocha.step("Формирование calldata, которая будет вызвана из Diamond через delegatecall для инициализации переменных, во время вызова функции diamondCut", async function () {
		// указываем значения, которые будут проинициализированы во время вызова функции initERC20
    calldataAfterDeploy = diamondInit.interface.encodeFunctionData('initERC20', [
        name,
        symbol,
        decimals,
        admin.address,
        totalSupply
    ]);
});

mocha.step("Деплой имплементации(грани) с константами", async function () {
    const ConstantsFacet = await ethers.getContractFactory("ConstantsFacet");
    const constantsFacet = await ConstantsFacet.deploy();
    constantsFacet.deployed();
    const facetCuts = [{
        facetAddress: constantsFacet.address,
        action: FacetCutAction.Add,
        functionSelectors: getSelectors(constantsFacet)
    }];
		// два последних аргумента: адрес контракта с функцией для инициализации, колдата для вызова этой функцией, которой есть эти значения
    await diamondCutFacet.connect(owner).diamondCut(facetCuts, diamondInit.address, calldataAfterDeploy);
    facetToAddressImplementation['ConstantsFacet'] = constantsFacet.address;
});

// указываем в инстансе адрес Diamond, как точку входа
mocha.step("Инициализация имплементации c константами", async function () {
    constantsFacet = await ethers.getContractAt('ConstantsFacet', addressDiamond);
});

mocha.step("Проверка констант на наличие", async function () {
    assert.equal(await constantsFacet.name(), "Token Name");
    assert.equal(await constantsFacet.symbol(), symbol);
    assert.equal(await constantsFacet.decimals(), decimals);
    assert.equal(await constantsFacet.admin(), admin.address);
});

Let’s write another face for our contract, thanks to which we can call the transfer function and transfer our tokens to other addresses.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import { LibBalances } from "../libraries/LibBalances.sol";

contract BalancesFacet {
		// реализация функций из стандарта erc20:
    function totalSupply() external view returns (uint256) {
        LibBalances.BalancesStates storage ds = LibBalances.diamondStorage();
        return ds.totalSupply;
    }

    function balanceOf(address _account) external view returns (uint256) {
        LibBalances.BalancesStates storage ds = LibBalances.diamondStorage();
        return ds.balances[_account];
    }

    function transfer(address _to, uint256 _amount) external returns (bool) {
        address owner = msg.sender;
        LibBalances.transfer(owner, _to, _amount);
        return true;
    }
}

Let’s continue testing during which the BalancesFacet will be added to the contract:

// деплой грани BalancesFacet
mocha.step("Деплой имплементации с функцией трансфера", async function () {
    const BalancesFacet = await ethers.getContractFactory("BalancesFacet");
    const balancesFacet = await BalancesFacet.deploy();
    balancesFacet.deployed();
    const facetCuts = [{
        facetAddress: balancesFacet.address,
        action: FacetCutAction.Add,
        functionSelectors: getSelectors(balancesFacet)
    }];
		// добавление грани в контракт
    await diamondCutFacet.connect(owner).diamondCut(facetCuts, ethers.constants.AddressZero, "0x00");
    facetToAddressImplementation['BalancesFacet'] = balancesFacet.address;
});

// указываем инстансу адрес Diamond как точку входа
mocha.step("Инициализация имплементации c балансами и трансфером", async function () {
    balancesFacet = await ethers.getContractAt('BalancesFacet', addressDiamond);
});

// убеждаемся, что функции из грани работают
mocha.step("Проверка view функции имплементации с балансами и трансфером", async function () {
    expect(await balancesFacet.totalSupply()).to.be.equal(totalSupply);
    expect(await balancesFacet.balanceOf(admin.address)).to.be.equal(totalSupply);
});

mocha.step("Проверка трансфера", async function () {
    await balancesFacet.connect(admin).transfer(user1.address, transferAmount);
    expect(await balancesFacet.balanceOf(admin.address)).to.be.equal(totalSupply.sub(transferAmount));
    expect(await balancesFacet.balanceOf(user1.address)).to.be.equal(transferAmount);
    await balancesFacet.connect(user1).transfer(admin.address, transferAmount);
});

Finally, we will write a face, after adding which we will have a full-fledged erc20, namely the ability to approve tokens and transfer them from another address using transferFrom, in addition to the face itself, we also need a library to maintain the storage, which will record who has approved how much to whom.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

library LibAllowances {
		// слот, через который можно обратиться к AllowancesStates
    bytes32 constant DIAMOND_STORAGE_POSITION = keccak256("erc20.allowances");

    event Approval(address indexed owner, address indexed spender, uint256 value);

		// переменные в хранилище "erc20.allowances"
    struct AllowancesStates {
        mapping(address => mapping(address => uint256)) allowances;
    }

    function diamondStorage() internal pure returns (AllowancesStates storage ds) {
        bytes32 position = DIAMOND_STORAGE_POSITION;
        assembly {
            ds.slot := position
        } 
    }

		// функция апрува
    function approve(
        address _owner,
        address _spender,
        uint256 _amount
    ) internal {
        AllowancesStates storage ds = diamondStorage();
        require(_owner != address(0), "ERC20: approve from the zero address");
        require(_spender != address(0), "ERC20: approve to the zero address");

        ds.allowances[_owner][_spender] = _amount;
        emit Approval(_owner, _spender, _amount);
    }

		// функция, вызываемая после траты заапрувленной суммы
    function spendAllowance(
        address _owner,
        address _spender,
        uint256 _amount
    ) internal {
        AllowancesStates storage ds = diamondStorage();
        uint256 currentAllowance = ds.allowances[_owner][_spender];
        if (currentAllowance != type(uint256).max) {
            require(currentAllowance >= _amount, "ERC20: insufficient allowance");
            unchecked {
                approve(_owner, _spender, currentAllowance - _amount);
            }
        }
    }
}

Edge contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// импортируем библиотеки, к хранилищам которых нам нужно обратиться
import { LibBalances } from "../libraries/LibBalances.sol";
import { LibAllowances } from "../libraries/LibAllowances.sol";

contract AllowancesFacet {
		// оставшиеся функции необходимые для реализации ERC20
		// view функция для получения значения заапрувленной суммы
    function allowance(address _owner, address _spender) external view returns (uint256) {
        LibAllowances.AllowancesStates storage ds = LibAllowances.diamondStorage();
        return ds.allowances[_owner][_spender];
    }

		// функция одобрения какой-то суммы на адрес
    function approve(address _spender, uint256 _amount) external returns (bool) {
        address owner = msg.sender;
        LibAllowances.approve(owner, _spender, _amount);
        return true;
    }

		// функция трансфера одобренной суммы
    function transferFrom(
        address _from,
        address _to,
        uint256 _amount
    ) external returns (bool) {
        address spender = msg.sender;
        LibAllowances.spendAllowance(_from, spender, _amount);
        LibBalances.transfer(_from, _to, _amount);
        return true;
    }
}

Let’s return to testing, add the AllowancesFacet face and check whether it works correctly, the order of deployment and addition is no different from the previous faces:

mocha.step("Деплой имплементации с allowances", async function () {
    const AllowancesFacet = await ethers.getContractFactory("AllowancesFacet");
    const allowancesFacet = await AllowancesFacet.deploy();
    allowancesFacet.deployed();
    const facetCuts = [{
        facetAddress: allowancesFacet.address,
        action: FacetCutAction.Add,
        functionSelectors: getSelectors(allowancesFacet)
    }];
    await diamondCutFacet.connect(owner).diamondCut(facetCuts, ethers.constants.AddressZero, "0x00");
    facetToAddressImplementation['ConstantsFacet'] = allowancesFacet.address;
});

mocha.step("Инициализация имплементации c балансами и трансфером allowance, approve, transferFrom и т. д.", async function () {
    allowancesFacet = await ethers.getContractAt('AllowancesFacet', addressDiamond);
});

mocha.step("Тестрирование функций allowance, approve, transferFrom", async function () {
    expect(await allowancesFacet.allowance(admin.address, user1.address)).to.equal(0);
    const valueForApprove = parseEther("100");
    const valueForTransfer = parseEther("30");
    await allowancesFacet.connect(admin).approve(user1.address, valueForApprove);
    expect(await allowancesFacet.allowance(admin.address, user1.address)).to.equal(valueForApprove);
    await allowancesFacet.connect(user1).transferFrom(admin.address, user2.address, valueForTransfer);
    expect(await balancesFacet.balanceOf(user2.address)).to.equal(valueForTransfer);
    expect(await balancesFacet.balanceOf(admin.address)).to.equal(totalSupply.sub(valueForTransfer));
    expect(await allowancesFacet.allowance(admin.address, user1.address)).to.equal(valueForApprove.sub(valueForTransfer));
});

Let’s write another edge that will regulate the emission of the token, it will have two functions: mint and burn, they can only be called by the admin, who was initialized in the initERC20 function.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// импорт нужных библиотек
import { LibBalances } from "../libraries/LibBalances.sol";
import { LibConstants } from "../libraries/LibConstants.sol";

contract SupplyRegulatorFacet {
    
    function mint(address _account, uint256 _amount) external {
        LibConstants.enforceIsTokenAdmin(); // проверка на то, что функцию вызывает админ
        LibBalances.mint(_account, _amount);
    }

    function burn(address _account, uint256 _amount) external {
        LibConstants.enforceIsTokenAdmin(); // проверка на то, что функцию вызывает админ
        LibBalances.burn(_account, _amount); 
    }
}

Let’s continue testing:

mocha.step("Деплой имплементации с mint и burn", async function () {
    const SupplyRegulatorFacet = await ethers.getContractFactory("SupplyRegulatorFacet");
    supplyRegulatorFacet = await SupplyRegulatorFacet.deploy();
    supplyRegulatorFacet.deployed();
    const facetCuts = [{
        facetAddress: supplyRegulatorFacet.address,
        action: FacetCutAction.Add,
        functionSelectors: getSelectors(supplyRegulatorFacet)
    }];
    await diamondCutFacet.connect(owner).diamondCut(facetCuts, ethers.constants.AddressZero, "0x00");
    facetToAddressImplementation['SupplyRegulatorFacet'] = supplyRegulatorFacet.address;
});

mocha.step("Инициализация имплементации c функциями mint и burn", async function () {
    supplyRegulatorFacet = await ethers.getContractAt('SupplyRegulatorFacet', addressDiamond);
});

mocha.step("Проверка функций mint и burn", async function () {
    const mintAmount = parseEther('1000');
    const burnAmount = parseEther('500');
    await supplyRegulatorFacet.connect(admin).mint(user3.address, mintAmount);
    expect(await balancesFacet.balanceOf(user3.address)).to.equal(mintAmount);
    expect(await balancesFacet.totalSupply()).to.be.equal(totalSupply.add(mintAmount));
    await supplyRegulatorFacet.connect(admin).burn(user3.address, burnAmount);
    expect(await balancesFacet.balanceOf(user3.address)).to.equal(mintAmount.sub(burnAmount));
    expect(await balancesFacet.totalSupply()).to.be.equal(totalSupply.add(mintAmount).sub(burnAmount));
});

This completes the testing of the ERC20 functionality and we can note that it works correctly. But, what if we want to make Diamond not updatable, for example, during the development process we came to some stable version of the contract and we no longer need to call the diamondCut function? To do this, we simply remove the diamondCut function selector:

mocha.step("Удаление функции diamondCut для дальнейшей неизменяемости", async function () {
    const facetCuts = [{
        facetAddress: ethers.constants.AddressZero,
        action: FacetCutAction.Remove,
        functionSelectors: ['0x1f931c1c'] //diamondCut(FacetCut[] calldata _diamondCut, address _init, bytes calldata _calldata)
    }];
    await diamondCutFacet.connect(owner).diamondCut(facetCuts, ethers.constants.AddressZero, "0x00");
});

After doing this, the contract will become non-updatable and static, so be careful if you decide to remove the diamondCut function from Diamond.

Afterword

Link to video tutorial and detailed explanation: https://www.youtube.com/watch?v=42TUqDW74v8

GitHub: https://github.com/davydovMikhail/multi-proxy-contract

Do you have any questions? Do you disagree with something? Write comments

Support the author with cryptocurrency: 0x021Db128ceab47C66419990ad95b3b180dF3f91F

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *