Введение

Ссылка на видео-туториал и подробное объяснение

GitHub

В этом материале речь пойдет про стандарт EIP-2535, также широко известен как Diamond или Multi-Facet Proxy. Стандарт дает возможность создавать модульные, обновляемые смарт контракты, которые обладают рядом преимуществ перед такими стандартами обновляемых контрактов как Transparent и UUPS.

Рассмотрим причины, по которым вы можете использовать стандарт Diamond для своего проекта:

  1. Diamond имеет единый адрес proxy который используется как точка входа для обращения ко всем имплементациям, такой подход упрощает развертывание, тестирование и интеграцию с другими смарт контрактами.

  2. Diamond не имеет ограничений по размеру контракта, то есть суммарный размер всех ваших имплементаций может выходить далеко за 24 килобайта.

  3. Стандарт позволяет реализовать гибкую систему взаимодействия между гранями(facets), стораджами и библиотеками.

  4. С течением времени и по мере необходимости функциональность контракта может быть добавлена, заменена или вовсе удалена. Также по причине описанной в п. 2 контракт не имеет ограничений по количестве функций, которые в нем используются.

  5. Контракт Diamond может быть неизменяемым, либо сразу, либо по прошевствию какого-то времени, когда будет принято решение сделать Diamond неизменяемым.

  6. Diamond может использовать уже развернутые имплементации, что может положительно сказаться на тратах на газ. Такой подход делает этот стандарт открытым для всех участников сети.

  7. Имплементации или грани(facets) независимы друг от друга, но могут совместно использовать внутренние функции, библиотеки и переменные состояния.

  8. Добавление/замена/удаление сразу нескольких функций может осуществляться в одной транзакции.

  9. Для изменения имплементаций можно использовать DAO и прочих инициализаторов обновления контракта.

Теория

Diamond вызывает функции своих граней(facet), используя delegatecall, если кому-то нужно вспомнить или узнать как работает delegatecall, милости прошу: solidity-by-example/delegatecall.

Когда к Diamond обращается какой-то адрес и вызывает функцию из имплементации, срабатывает fallback. Как работает функция fallback: solidity-by-example/fallback.

Внутри функции fallback определяется на какой адрес нужно делегировать вызов на основе первых четырех байтов из msg.data, или же эти 4 байта можно получить сразу обратившись к глобальной переменной msg.sig, таким образом мы получим селектор функции. Подробнее о том что такое селектор функции и как его можно получить: solidity-by-example/function-selector.

Благодаря свойствам fallback и delegatecall, Diamond может выполнять функцию грани(facet), как если бы она была определена в самом Diamond. При вызове функции из имплементации(читай грани) благодаря delegatecall значения msg.sender и msg.value остаются неизменными, а также считывается и записывается только хранилище diamond, то есть, можно сказать, что грань(facet), к которой мы обращаемся дает только инструкцию, в которой сказано как обращаться с хранилищем diamond.

Рассмотрим концептуальную реализацию функции fallback() в основном контракте diamond:

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())}
  }
}

Подведем итоги по строению Diamond:

  1. В контракте есть только одна функция fallback.

  2. В зависимости от селектора вызов делегируется на нужную имплементацию.

  3. Каждому селектору соответствует свой адрес грани.

  4. Все состояния хранятся на контракте Diamond, на гранях(facets) только логика.

Строение Diamond
Строение Diamond

Организация хранилища

Для облуживания стораджа контракта Diamond используются библиотеки. Количество стораджей неограниченно, они могут появляться по мере необходимости и появления нового функционала и переменных. Чтобы значения в сторадже не перепутались и не перезаписывались хаотичным образом, каждый сторадж организуется в структуру, в свою очередь структура, помещается в определенную ячейку, которая является точкой входа, для обращения к конкретной переменной структуры.

Рассмотрим следующий пример: мы имеем две грани, которые обращаются к одному и тому же стораджу используя библиотеку. Эти грани будут представлять из себя частичную реализацию стандарта ERC-721.

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

Как правильно обновлять сторадж:

  1. Чтобы добавить новые переменные состояния в структуру хранилища, добавьте их в самый конец структуры.

  2. Если вам нужно добавить mapping, помещайте его также в конец структуры.

  3. Имена переменных состояния могут быть изменены, но это может привести к путанице, если разные грани используют разные имена для одних и тех же мест хранения. Поэтому для всех граней рекомендуется использовать библиотеки где сторадж определен одинаково, как с точки зрения нейминга, так и с точки зрения порядка следования переменных в структуре.

Что не следует делать при изменении стораджа:

  1. Не добавляйте новые переменные состояния в начало или середину структур. Выполнение этого приводит к тому, что новая переменная состояния перезаписывает существующие данные переменной состояния и все переменные состояния после неё, так как новая переменная состояния ссылается на неправильное место хранения.

  2. Допустим у вас в структуре стораджа есть mapping, который возвращает другую структуру, такая практика допустима, если возвращаемая структура не будет обновляться на протяжении всего времени существования Diamond. Если же в будущем появится потребность изменить возвращаемую мапингом структуру, то вам придется создавать новый мапинг для этой структуры.

  3. Не добавляйте новые переменные состояния в структуры, которые используются в массивах.

  4. При использовании хранилища Diamond не используйте один и тот же номер слота(ERC721_POSITION в примере) для разных хранилищ. Это очевидно. Два разных хранилища в одном и том же месте будут перезаписывать друг друга.

  5. Не допускайте, чтобы какая-либо грань могла вызывать selfdestruct. Просто не разрешайте команде selfdestruct существовать в любом исходном коде грани и не разрешайте вызывать эту команду через вызов delegatecall. Потому что selfdestruct может удалить грань, которая используется алмазом, или selfdestruct может быть использован для удаления основного прокси-контракта Diamond.

Грани могут совместно использовать одни и те же стораджи. Грани могут использовать одни и те же внутренние функции и библиотеки. Таким образом, грани являются независимыми друг от друга единицами, но при этом они могут обращаться к одинаковым библиотекам для изменения состояния хранилища.

Пример организации архитектуры хранилища и логики граней
Пример организации архитектуры хранилища и логики граней

На диаграмме выше:

  • Только FacetA может получить доступ к DataA

  • Только FacetB может получить доступ к DataB

  • Только код Diamond может получить доступ к DataD

  • Доступ к DataAB имеют как FacetA так и FacetB

  • К DataABD можно обратиться откуда угодно

Добавление/замена/удаление функций

Любой Diamond должен реализовывать интерфейс IDiamond.

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

В свою очередь интерфейс IDiamond наследуется другим интерфейсом IDiamondCut, который содержит в себе одну функцию diamondCut.

// 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;    
}

Функция diamondCut обновляет любое количество функций из любого количества фасетов за одну транзакцию. Выполнение всех изменений в рамках одной транзакции предотвращает повреждение данных, которое может произойти при обновлениях, выполняемых в рамках нескольких транзакций.

После добавления/замены/удаления функций аргумент _calldata выполняется на адрес _init через delegatecall. Это выполнение выполняется для инициализации данных или настройки или удаления чего-либо необходимого или больше не нужного после добавления, замены и/или удаления функций. Можно провести аналогию с функцие конструктором, которая также инициализирует какие-то начальные значения.

Если значение _init равно нулевому адресу address(0) то выполнение _calldata пропускается. В этом случае _calldata может содержать 0 байт или пользовательскую информацию, которая отправится на etherscan через событие DiamondCut.

Проверка граней и функций

Diamond должен поддерживать проверку аспектов и функций путем реализации интерфейса IDiamondLoupe.

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

Таким образом с помощью интерфейса IDiamondCut мы обновляем Diamond, а с помощью IDiamondLoupe можем отслеживать грани и их состояние на наличие тех или иных селекторов.

Практика

Напишем обычный erc20 весь функционал которого будет разбит на 4 грани. Это будет сделано для наглядности, ведь внутренний механизм работы стандарта erc20 известен всем или многим.

Но перед этим нам нужно разобраться с обслуживающими гранями, которые отвечают за изменение функциональности и мониторинг уже имеющихся граней и функциональности.

Эти целям будет служить библиотека LibDiamond и три грани: DiamondCutFacet - для изменения функциональности, DiamondLoupeFacet - для мониторинга граней и функциональности контракта Diamond, OwnershipFacet - грань, которая помогает администрировать доступ к функции diamindCut, чтобы имплементации не менялись кем попало.

Рассмотрим код библиотеки LibDiamond, она будет дана в сокращенном виде, полный код будет в открытом доступе на 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);
        }        
    }
}

Рассмотрим грань DiamondCutFacet, отвечающая за изменение функционала Diamond:

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

Рассмотрим грань DiamondLoupeFacet, код будет показан в сокращенном виде, полный код можно посмотреть на 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_) {
        // код функции
    }
}

Последняя обязательная грань 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();
    }
}

Наконец рассмотрим сам контракт Diamond, а затем поймем как правильно и в каком порядке нужно деплоить Diamond и грани.

Контракт Diamond:

// 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 {}
}

Приступим к тестированию, полный код тестирования можно посмотреть также на 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);
});

Мы развернули базовую часть Diamond, которая необходима для управления имплементациями. Теперь реализуем на Diamond функционал токена ERC20, со всеми функциями, которые присущи этому стандарту.

Все хранилище токена будет разбито на три стораджа, к которым будут обращаться 4 грани, рассмотрим архитектуру граней и стораджей на диаграмме:

Архитектура ERC20 в контексте нашего Diamond
Архитектура ERC20 в контексте нашего Diamond

Библиотека LibConstants будет отвечать за хранение таких констант токена, как name, symbol, decimals и адрес админа, которому будут доступны функции mint(), burn() из грани SupplyRegulatorFacet.

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

Следующая библиотека LibBalances отвечает за хранение балансов и сопутствующих функций:

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

Для инициализации констант из библиотеки LibConstants нужен отдельный контракт с функцией инициализации, которая будет вызвана сразу после добавления грани в 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);
    }
}

Наконец напишем контракт грани, которая будет возвращать некоторые константы, обращаясь к библиотеке LibConstants:

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

Продолжим тестирование, в ходе которого мы проинициализируем переменные хранилища, с помощью функции initERC20 из контракта DiamondInit, а также добавим новую грань ConstantsFacet:

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);
});

Напишем ещё одну грань для нашего контракта, благодаря которой мы сможем вызывать функцию transfer и передавать наши токены другим адресам.

// 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;
    }
}

Продолжим тестирование в ходе которого в контракт будет добавлена грань BalancesFacet:

// деплой грани 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);
});

Наконец напишем грань, после добавления которой у нас будет полноценный erc20, а именно возможность апрувить токены и переводить их с другого адреса используя transferFrom, помимо самой грани нам ещё нужна библиотека для обслуживания хранилища, в котором будет записано, кто сколько кому заапрувил.

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

Контракт грани:

// 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;
    }
}

Вернемся к тестированию, добавим грань AllowancesFacet и проверим корректно ли она работает, порядок деплоя и добавления ничем не отличается от предыдущих граней:

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));
});

Напишем ещё одну грань, которая будет регулировать эмиссию токена, в ней будет две функции: mint и burn, их сможет вызывать только админ, который был проинициализирован в функции initERC20.

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

Продолжим тестирование:

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));
});

На этом тестирование функционала ERC20 закончено и мы можем отметить что работает оно корректно. Но, что если мы хоти сделать Diamond не обновляемым, например, в процессе разработке мы пришли к какой-то стабильной версии контракт и нас больше нет необходимости вызывать функцию diamondCut? Для это мы просто удалим селектор функции diamondCut:

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

После выполнения этого действия контракт станет не обновляемым и статичным, так что будьте осторожны, если решитесь удалить функцию diamondCut из Diamond.

Послесловие

Ссылка на видео-туториал и подробное объяснение: https://www.youtube.com/watch?v=42TUqDW74v8

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

Остались вопросы? С чем-то не согласны? Пишите комментарии

Поддержать автора криптовалютой: 0x021Db128ceab47C66419990ad95b3b180dF3f91F

Комментарии (2)


  1. svosin
    09.01.2023 18:30

    Думаю, стоило бы упомянуть гитхаб автора стандарта с референс-реализациями.

    https://github.com/mudgen/diamond-1-hardhat/

    Там же есть скрипт для деплоя и пример тестов с апгрейдами и инициализацией. Понимаю, что для того, чтобы вникнуть в стандарт, написать свой велосипед бывает полезно, но в проде лучше все же пользоваться чем-то проверенным.


    1. justDeveloper23 Автор
      10.01.2023 03:39

      Согласен, мой недочет, я бы ещё добавил ссылку на первоисточник самого стандарта: https://eips.ethereum.org/EIPS/eip-2535