ERC-6224 Contract dependency registry (Contracts Dependencies Registry)
Last Call · Other · created 2022-12-27
A management standard that registers the addresses of all smart contracts used by a protocol under names in a single registry, so each contract can fetch the dependency contracts it needs from there. Registered contracts can also be wrapped in a proxy for upgrades.
Why it is needed
As the Ethereum ecosystem grows, projects become increasingly complex, and continuing to ship new features requires the flexibility to change things. However, blockchains and smart contracts are immutable once deployed, which makes this requirement hard to meet. In a complex system, when contracts reference each other in tangled relationships, bugs become easy to introduce and maintenance becomes difficult. If which contract uses which contract is scattered throughout the code, replacing one contract means hunting down and fixing every contract that holds its address. This standard solves the problem with two contracts: a registry that collects addresses in one place (ContractsRegistry) and a mixin that receives injected dependencies (Dependant). The stated advantages are a clear structure, the ability to add, remove, or replace contracts at runtime, upgrades that can be enabled when needed, and transparent dependency relationships.
What to implement
event ContractAdded(string name, address contractAddress);: An event that announces a name and address when a contract is added to the registry.event ProxyContractAdded(string name, address contractAddress, address implementation);: An event that announces a name, proxy address, and implementation contract address when a proxy contract is added to the registry.event ProxyContractUpgraded(string name, address newImplementation);: An event emitted when a proxy is upgraded to a new implementation through the registry.event ContractRemoved(string name);: An event emitted when a contract is removed from the registry.function getContract(string memory name) external view returns (address);: Returns the address of a contract registered under a name. Must revert if the address is address(0).function hasContract(string memory name) external view returns (bool);: Returns whether a contract with the given name is registered in the registry.function injectDependencies(string memory name) external;: Injects dependencies into the specified contract. Must call the target contract's setDependencies() with address(this) and bytes("") as arguments.function injectDependenciesWithData(string memory name, bytes memory data) external;: Injects dependencies along with additional data. Must call setDependencies() with address(this) and data as arguments.function upgradeContract(string memory name, address newImplementation) external;: Upgrades a registered proxy contract to a new implementation. Must emit the ProxyContractUpgraded event, and verifying compatibility between implementations is the owner's responsibility.function addContract(string memory name, address contractAddress) external;: Registers a plain, non-proxy contract. Must emit the ContractAdded event and must revert if the address is address(0).function addProxyContract(string memory name, address contractAddress) external;: Deploys and registers a proxy on top of the given implementation. Used for contracts that the registry must have upgrade authority over, and must emit the ProxyContractAdded event.function justAddProxyContract(string memory name, address contractAddress) external;: Registers an already-deployed proxy as is. Can be used when migrating to a new registry, and the new registry must have upgrade authority over that proxy.function removeContract(string memory name) external;: Removes a contract from the registry. Must emit the ContractRemoved event and must revert if it has already been removed.function setDependencies(address contractsRegistry, bytes memory data) external;: The function that ContractsRegistry calls to inject dependencies. Must perform an msg.sender access check and must only be callable from the registry.function setInjector(address injector) external;: Sets a new dependency injector. Must perform an msg.sender access check.function getInjector() external view returns (address);: Returns the address of the current dependency injector.
In depth
The role of ContractsRegistry and its required rules
ContractsRegistry is a singleton registry that must store references to every standalone contract used within a protocol. On registration it MAY be configured to deploy a chosen proxy contract on top. It must reject registering the zero address, and getContract must revert if the requested contract is address(0). This is meant to reduce the risk of protocol initialization mistakes, since the correct contract must be registered before dependency injection takes place. addContract, addProxyContract, addProxyContractAndCall, and justAddProxyContract likewise revert on the zero address for the same reason.
Three branches of registration functions
addContract is for contracts used without a proxy, registering contracts over which the system does not have upgrade authority or that are not upgradeable by design. addProxyContract and addProxyContractAndCall newly deploy a proxy over the given implementation and register it, and are used for contracts the registry must be able to upgrade. The AndCall variant takes call data to invoke on the proxy immediately after deployment (for example, an ABI-encoded initializer call). justAddProxyContract registers an already-deployed proxy as is, and can be used when a system migrates to a new ContractsRegistry, in which case the new registry must have upgrade authority over that proxy.
The dependency injection mechanism
injectDependencies(name) must call the registered contract's setDependencies() with address(this) and bytes("") as arguments, and injectDependenciesWithData passes the supplied data instead of bytes(""). The data parameter is meant to carry application-specific additional context and can be used to extend the method's behavior. Every standalone contract must inherit Dependant, and required dependencies must be set in an overridden setDependencies method rather than in a constructor or initializer. This way, references can be refreshed by simply re-running injection whenever a dependency changes.
Injector access control and the storage slot
setDependencies and setInjector must only be callable by the injector. The initial injector is the zero address, in which case the access control check must not revert. In the reference implementation, _checkInjector passes only when the injector is address(0) or equal to msg.sender. The injector address (typically ContractsRegistry) MAY be stored in the special slot 0x3d1f25f1ac447e55e7fec744471c4dab1c6a2b6ffb897825f9ea3d2e8c9be583, which is derived as bytes32(uint256(keccak256("eip6224.dependant.slot")) - 1). setInjector is external so that dependency injection can also be supported for contracts minted by a factory, and it is explicitly noted that this must be used with particular care.
Upgrade path and reference implementation
upgradeContract and upgradeContractAndCall upgrade a registered proxy to a new implementation and must emit the ProxyContractUpgraded event. Verifying compatibility between implementations is the Owner's responsibility. In the reference implementation (based on OpenZeppelin 4.9.2), the registry deploys a ProxyUpgrader during initialization and designates this ProxyUpgrader as the admin of the TransparentUpgradeableProxy. Upgrade requests go through an onlyOwner registry function and are executed via ProxyUpgrader.upgrade, which reverts with "not a proxy contract" for an address that is not registered as a proxy.
Why names use string
Contract identifiers use string rather than uint256 or bytes32. Because the registry is the top-level contract of the protocol, users commonly interact with it directly through block explorers or a DAO, so code readability and reducing human error were prioritized over gas cost. Because identifiers are strings, event parameters are not made indexed. A string indexed parameter longer than 32 bytes is converted into the keccak256 hash of the name, which makes it hard to read. This proposal is currently in Last Call status (deadline 2025-07-31), a specification in its final comment period before finalization.
Things to watch out for
- Owner key management is everything: The ContractsRegistry owner's key must be kept somewhere secure. If access to the registry is lost or leaked, the application reaches an irreversible point. Since the registry is the cornerstone of the protocol, access must be granted only to a trusted party.
- There is no upgrade compatibility check: ContractsRegistry performs no upgrade compatibility checks between proxy upgrades. Verifying that a new implementation is compatible with the previous one is the user's responsibility.
- Risk of front-running the initial injection: A Dependant contract must set its dependency injector no later than before its first setDependencies call. Otherwise, someone else could front-run and hijack the first dependency injection.