Aller au contenu

For AI agents: a documentation index is available at /llms.txt. Every page is also available as Markdown at the same URL with a .md suffix, or by requesting Accept: text/markdown.

BLOK Capital

Guide : Création d’un Nouveau Facet Diamond

Ce projet utilise le pattern “Diamond” (EIP-2535) pour composer des contrats modulaires. Un Diamond est un proxy qui délègue des appels à plusieurs Facets, chacun contenant une logique spécifique.

Vue d’ensemble#

Ce projet utilise le pattern “Diamond” (EIP-2535) pour composer des contrats modulaires. Un Diamond est un proxy qui délègue des appels à plusieurs Facets, chacun contenant une logique spécifique. Par exemple : « Un Facet peut être comparé à un contrat d’implémentation… [il] contient la logique des fonctions externes que le proxy (Diamond) va appeler ».

Les Facets permettent de séparer les fonctionnalités en contrats indépendants pouvant être mis à jour séparément. Chaque Facet a son propre état isolé (grâce au pattern Diamond Storage) pour éviter les conflits de stockage.

Ce guide décrit nos conventions pour ajouter un nouveau Facet, incluant la structure des dossiers, l’organisation du contrat et les tests associés.


Structure des Dossiers#

Les facets vivent sous src/garden/facets/, regroupés selon leur nature :

src/garden/facets/
├── baseFacets/<name>/                 # le noyau : ownership, cut, upgrade, loupe
├── indexFacets/                       # connexion à l'Index et rééquilibrage
└── utilityFacets/<chain>/<protocol>/  # intégrations DeFi, un dossier par chaîne

Une nouvelle intégration DeFi va donc dans utilityFacets/<chain>/<protocol>/, par exemple utilityFacets/arbitrumOne/uniswapV3/. Placez-la sous la chaîne qu'elle cible ; le même protocole sur deux chaînes obtient un dossier sous chacune.

Chaque dossier de facet contient les fichiers dont il a réellement besoin, pas un ensemble fixe :

  • <Name>Facet.sol – le contrat facet externe. Toujours présent.
  • <Name>Base.sol – contrat abstrait contenant la logique interne. Toujours présent.
  • <Name>Storage.sol – la librairie de stockage. Uniquement si le facet conserve un état. Un facet sans état (la plupart des intégrations de swap) n'en a pas.
  • I<Name>.sol – l'interface externe. Les facets utilitaires définissent la leur ; les facets de base implémentent généralement un standard qui vit déjà dans src/interfaces/ (par exemple IERC173 pour ownership).

Les tests reflètent le chemin de src sous test/, avec un sous-dossier par type de test :

test/garden/facets/baseFacets/ownership/unit/OwnershipFacet.t.sol

unit/ est celui que possède chaque facet. fuzz/ et invariant/ existent lorsque le facet le justifie. Un contrat de test par facet, nommé <Facet>Test, dans <Facet>.t.sol, ce qui garde forge test --match-contract OwnershipFacetTest utile.

Organisation du Contrat Facet#

<FacetName>Storage.sol#

Implémente le Diamond Storage pattern. Dérivez l'emplacement via LibStorageSlot.deriveStorageSlot(type(YourStorage).name), jamais en codant une chaîne en dur ni en inscrivant la formule de hash en ligne. Déclarez un STORAGE_LAYOUT_VERSION et faites de _storageLayoutVersion le premier champ de la structure : les mises à jour le valident, donc un layout qui le place ailleurs est incorrect.

library TransferStorage {
    uint256 internal constant STORAGE_LAYOUT_VERSION = 1;
 
    struct Layout {
        /// @notice Storage layout version — MUST be first field. Validated during upgrades.
        uint256 _storageLayoutVersion;
        // Ajoutez les variables nécessaires après, uniquement par ajout
        uint256 lastTransferBlock;
    }
 
    /// @dev Storage slot is derived from keccak256(bytes(type(TransferStorage).name))
    function layout() internal pure returns (Layout storage l) {
        bytes32 position = LibStorageSlot.deriveStorageSlot(type(TransferStorage).name);
        assembly {
            l.slot := position
        }
    }
}

Deux conséquences à connaître avant de choisir un nom : le nom de la librairie est la clé de stockage, il ne peut donc jamais être renommé une fois déployé, et les nouveaux champs de la structure ne peuvent qu'être ajoutés à la fin. Voir les Considérations de Sécurité pour ce qui l'applique.

<FacetName>Base.sol#

Un contrat abstrait définissant la logique interne. Il importe la bibliothèque de stockage et les interfaces requises. Les fonctions internes utilisent TransferStorage.layout() pour lire/écrire le stockage. Exemple :

abstract contract TransferBase is ITransferBase {
    using SafeERC20 for IERC20;
 
    function _transferEth(address payable to, uint256 amount) internal {
        if (amount > address(this).balance) revert Transfer_Failed();
        (bool success,) = to.call{ value: amount }("");
        if (!success) revert Transfer_Failed();
        TransferStorage.layout().lastTransferBlock = block.timestamp;
        emit ETHTransferExecuted(msg.sender, to, amount);
    }
 
    function _erc20Transfer(address token, address to, uint256 amount) internal {
        IERC20(token).safeTransfer(to, amount);
        TransferStorage.layout().lastTransferBlock = block.timestamp;
        emit ERC20TransferExecuted(token, msg.sender, to, amount);
    }
 
    function _erc20TransferFrom(address token, address from, address to, uint256 amount) internal {
        IERC20(token).safeTransferFrom(from, to, amount);
        TransferStorage.layout().lastTransferBlock = block.timestamp;
        emit ERC20TransferExecuted(token, from, to, amount);
    }
}

<FacetName>Facet.sol#

Le contrat Facet public. Il hérite de Base, de l’interface externe, et d’un marqueur Facet générique. Il expose les fonctions accessibles depuis l’extérieur et applique les contrôles d’accès. Exemple :

contract TransferFacet is ITransfer, TransferBase {
    function transferEth(address payable to, uint256 amount) external override {
        _transferEth(to, amount);
    }
 
    function erc20Transfer(address token, address to, uint256 amount) external override {
        _erc20Transfer(token, to, amount);
    }
 
    function erc20TransferFrom(address token, address from, address to, uint256 amount) external override {
        _erc20TransferFrom(token, from, to, amount);
    }
}

I<FacetName>.sol#

Interface Solidity définissant uniquement les fonctions publiques/externes. Les clients du Diamond interagiront avec cette interface.

interface ITransfer {
    function transferEth(address payable to, uint256 amount) external;
    function erc20Transfer(address token, address to, uint256 amount) external;
    function erc20TransferFrom(address token, address from, address to, uint256 amount) external;
}

I<FacetName>Base.sol#

Interface contenant les erreurs personnalisées, événements, structures et énumérations internes. Elle est utilisée par <FacetName>Base et <FacetName>Facet.

interface ITransferBase {
    error Transfer_Failed();
 
    event ETHTransferExecuted(address indexed from, address indexed to, uint256 amount);
    event ERC20TransferExecuted(address indexed token, address indexed from, address indexed to, uint256 amount);
}

Tests des Facets#

Les tests de facet déploient un vrai Jardin via le harness de test, puis appellent le facet à travers l'adresse du Jardin, qui est la seule façon dont le facet est atteint en production. Le harness vit dans test/base/ :

  • BaseTest.sol – configuration partagée et acteurs de test communs.
  • DiamondTestBase.sol – ce que la plupart des tests de facet étendent. Fournit des helpers pour enregistrer un type de Jardin et déployer un Jardin.
  • DexFacetTestBase.sol – pour les intégrations de swap, qui ont besoin de fixtures de pools et de routers en plus de ce qui précède.

La forme d'un test de facet#

Étendez la bonne base, déployez un Jardin dans setUp(), puis exercez le facet via un cast d'interface sur l'adresse du Jardin :

contract OwnershipFacetTest is DiamondTestBase {
    address public garden;
 
    function setUp() public override {
        super.setUp();
        _addGardenTypeEmpty(GARDEN_TYPE_1);
        garden = _deployGarden(GARDEN_TYPE_1, gardenOwner);
    }
 
    function test_owner_returnsGardenOwner() public view {
        assertEq(IERC173(garden).owner(), gardenOwner);
    }
 
    function test_transferOwnership_updatesOwner() public {
        vm.prank(gardenOwner);
        IERC173(garden).transferOwnership(alice);
        assertEq(IERC173(garden).owner(), alice);
    }
}

Notez le cast : IERC173(garden), et non le contrat du facet directement. Appeler l'adresse propre du facet exécuterait son code contre le stockage du facet plutôt que celui du Jardin, ce qui n'est pas son comportement en production et vous donnera des résultats trompeurs.

Nommage et emplacement#

Un contrat de test par facet, nommé <Facet>Test, dans <Facet>.t.sol, sous le chemin miroir de src plus unit/. Regroupez les tests à l'intérieur du contrat par fonction testée plutôt que de les répartir entre plusieurs fichiers. N'ajoutez des voisins fuzz/ ou invariant/ que lorsque le facet possède des propriétés qui méritent d'être testées ainsi.

Avant d'ouvrir une PR#

forge test exécute aussi trois protections de stockage héritées par chaque suite. Elles détectent deux librairies partageant un nom, et échouent si une librairie de stockage enregistrée dans storage-registry.json a disparu du code. Si l'une d'elles échoue après l'ajout d'un facet, le nom de librairie de stockage que vous avez choisi entre en collision avec un nom existant : choisissez-en un autre plutôt que de contourner la protection.