Saltar al contenido

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

Guía: Crear un Nuevo Facet de Diamante

En este proyecto utilizamos el patrón "Diamond" (EIP-2535) para componer contratos modulares. Un Diamond es un proxy que delega llamadas a múltiples Facets, cada uno con lógica relacionada.

Visión General#

En este proyecto utilizamos el patrón "Diamond" (EIP-2535) para componer contratos modulares. Un Diamond es un proxy que delega llamadas a múltiples Facets, cada uno con lógica relacionada. Por ejemplo, "Un Facet puede compararse con un contrato de implementación... [éste] contiene la lógica de funciones externas que el proxy (Diamond) llamará". Los Facets nos permiten dividir funcionalidades en contratos separados que pueden ser actualizados independientemente. Cada Facet tiene su estado aislado (utilizando el patrón de Diamond Storage) para evitar conflictos de almacenamiento. Esta guía explica nuestras convenciones para añadir un nuevo facet, incluyendo estructura de carpetas, diseño del contrato y pruebas.

Estructura de Carpetas#

Los facets viven bajo src/garden/facets/, agrupados por lo que son:

src/garden/facets/
├── baseFacets/<name>/                 # el núcleo: ownership, cut, upgrade, loupe
├── indexFacets/                       # conexión a Index y rebalanceo
└── utilityFacets/<chain>/<protocol>/  # integraciones DeFi, una carpeta por cadena

Por tanto, una nueva integración DeFi va en utilityFacets/<chain>/<protocol>/, por ejemplo utilityFacets/arbitrumOne/uniswapV3/. Colócala bajo la cadena a la que apunta; el mismo protocolo en dos cadenas tiene una carpeta bajo cada una.

Cada carpeta de facet contiene los archivos que realmente necesita, no un conjunto fijo:

  • <Name>Facet.sol – el contrato facet externo. Siempre presente.
  • <Name>Base.sol – contrato abstracto con la lógica interna. Siempre presente.
  • <Name>Storage.sol – la librería de almacenamiento. Solo si el facet mantiene estado. Un facet sin estado (la mayoría de integraciones de swap) no la tiene.
  • I<Name>.sol – la interfaz externa. Los facets de utilidad definen la suya; los facets base normalmente implementan un estándar que ya vive en src/interfaces/ (por ejemplo IERC173 para ownership).

Las pruebas reflejan la ruta de src bajo test/, con una subcarpeta por tipo de prueba:

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

unit/ es la que tiene cada facet. fuzz/ e invariant/ existen cuando ese facet lo justifica. Un contrato de prueba por facet, nombrado <Facet>Test, en <Facet>.t.sol, lo que mantiene útil forge test --match-contract OwnershipFacetTest.

Diseño del Contrato Facet#

Dentro de cada carpeta de facet, seguimos una estructura estricta:

  • <FacetName>Storage.sol: Implementa el patrón Diamond Storage. Deriva el slot mediante LibStorageSlot.deriveStorageSlot(type(YourStorage).name), nunca codificando una cadena a mano ni escribiendo la fórmula del hash en línea. Declara un STORAGE_LAYOUT_VERSION y haz que _storageLayoutVersion sea el primer campo de la estructura: las actualizaciones lo validan, así que un layout que lo tenga en otra posición es incorrecto.
library TransferStorage {
    uint256 internal constant STORAGE_LAYOUT_VERSION = 1;
 
    struct Layout {
        /// @notice Storage layout version — MUST be first field. Validated during upgrades.
        uint256 _storageLayoutVersion;
        // Añade las variables necesarias después, solo por adición
        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
        }
    }
}

Esto aísla el estado del facet permitiendo entenderlo como una unidad independiente. Dos consecuencias que conviene saber antes de elegir un nombre: el nombre de la librería es la clave de almacenamiento, así que nunca puede renombrarse una vez desplegada, y los nuevos campos de la estructura solo pueden añadirse al final. Consulta Consideraciones de Seguridad para ver qué lo hace cumplir.

  • <FacetName>Base.sol: Contrato abstracto que define funciones internas. Este contrato importa la librería de almacenamiento y las interfaces necesarias, implementando funciones internal o internal view. También puede leer/escribir estado usando Layout storage s = <FacetName>Storage.layout(). Ejemplo:
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: Contrato público que hereda del Base, la interfaz externa y un marcador de Facet genérico. Define las funciones externas que pueden llamar usuarios o el dueño del Diamond. Aplica control de acceso y puede incluir inicializadores:
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: Interfaz que define solo las funciones externas del facet. Se usa para interactuar con el Diamond desde clientes externos.
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: Interfaz o contrato abstracto con errores, eventos, enums y structs usados internamente. Centraliza definiciones para uso entre <FacetName>Base y <FacetName>Facet. Los errores personalizados deben seguir el formato FacetName_Error:
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);
}

Pruebas de Facets#

Las pruebas de facets despliegan un Jardín real mediante el harness de pruebas y luego llaman al facet a través de la dirección del Jardín, que es la única forma en que se alcanza al facet en producción. El harness vive en test/base/:

  • BaseTest.sol – configuración compartida y actores comunes de prueba.
  • DiamondTestBase.sol – lo que extienden la mayoría de las pruebas de facets. Te da helpers para registrar un tipo de Jardín y desplegar un Jardín.
  • DexFacetTestBase.sol – para integraciones de swap, que necesitan fixtures de pools y routers además de lo anterior.

La forma de una prueba de facet#

Extiende la base adecuada, despliega un Jardín en setUp() y luego ejercita el facet mediante un cast de interfaz sobre la dirección del Jardín:

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

Fíjate en el cast: IERC173(garden), no el contrato del facet directamente. Llamar a la propia dirección del facet ejecutaría su código contra el almacenamiento del facet en lugar del Jardín, que no es cómo se comporta en producción y te dará resultados engañosos.

Nomenclatura y ubicación#

Un contrato de prueba por facet, nombrado <Facet>Test, en <Facet>.t.sol, bajo la ruta equivalente a src más unit/. Agrupa las pruebas dentro del contrato por la función que se prueba en lugar de repartirlas entre archivos. Añade hermanos fuzz/ o invariant/ solo cuando el facet tenga propiedades que merezca probar así.

Antes de abrir un PR#

forge test también ejecuta tres protecciones de almacenamiento heredadas por cada suite. Detectan dos librerías que comparten nombre, y fallan si una librería de almacenamiento registrada en storage-registry.json ha desaparecido del código. Si alguna falla después de añadir un facet, el nombre de librería de almacenamiento que elegiste colisiona con uno existente, así que elige otro en lugar de sortear la protección.