# Les Patterns Proxy Expliqués — Transparent, UUPS et Beacon
Les smart contracts sont immuables. Mais les bugs, eux, ne le sont pas. D'où les proxies.
Le Problème : Immuabilité
// V1 : Déployé sur mainnet
contract Token {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount;
balances[to] += amount; // ❌ Bug : pas de check overflow
}
}
// Impossible de fix sans redéployer
// → Tous les utilisateurs doivent migrer
// → Perte de liquidity/composability
La Solution : Pattern Proxy
Un proxy = contrat qui délègue les appels à une implementation.
// Proxy (immuable)
contract Proxy {
address public implementation;
fallback() external payable {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
// Implementation V1
contract TokenV1 {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
// Implementation V2 (fix du bug)
contract TokenV2 {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
Upgrade :
proxy.setImplementation(address(tokenV2));
// Même adresse proxy, nouvelle logique
Pattern 1 : Transparent Proxy
Principe : le proxy gère l'upgrade logic.
contract TransparentProxy {
address private immutable ADMIN;
address private implementation;
constructor(address _admin, address _implementation) {
ADMIN = _admin;
implementation = _implementation;
}
modifier ifAdmin() {
if (msg.sender == ADMIN) {
_;
} else {
_fallback();
}
}
function upgradeTo(address newImplementation) external ifAdmin {
implementation = newImplementation;
}
function _fallback() private {
// delegatecall vers implementation
}
fallback() external payable {
_fallback();
}
}
Avantages
✅ Simple : admin upgrade, users utilisent
✅ Sécurisé : séparation admin/user claire
Inconvénients
❌ Gas : check admin à chaque call (SLOAD = 2100 gas)
❌ Fonction collision : si implementation a une fonction upgradeTo(), conflit
Quand l'Utiliser
- Protocoles avec gouvernance centralisée
- NFT projects (admin = multisig)
Exemple : OpenZeppelin TransparentUpgradeableProxy
Pattern 2 : UUPS (Universal Upgradeable Proxy Standard)
Principe : l'implementation gère son propre upgrade logic.
// Proxy (minimal)
contract UUPSProxy {
address private implementation;
constructor(address _implementation) {
implementation = _implementation;
}
fallback() external payable {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
// Implementation (contient upgrade logic)
contract TokenUUPS is Initializable, UUPSUpgradeable, OwnableUpgradeable {
mapping(address => uint256) public balances;
function initialize() public initializer {
__Ownable_init();
__UUPSUpgradeable_init();
}
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount;
balances[to] += amount;
}
// ✅ Upgrade logic dans l'implementation
function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}
Avantages
✅ Gas : pas de check admin dans le proxy (économie ~2K gas/call)
✅ Flexibilité : upgrade logic customisable
✅ Pas de collision : upgrade functions dans l'implementation
Inconvénients
❌ Complexité : upgrade logic doit être correcte dans chaque version
❌ Risque : si une version oublie la fonction upgrade, le contrat devient immuable
Quand l'Utiliser
- Protocoles à haut volume (DEX, lending)
- DeFi avec gouvernance on-chain
Exemple : Compound V3
Pattern 3 : Beacon Proxy
Principe : plusieurs proxies pointent vers un même Beacon qui stocke l'implementation.
// Beacon
contract UpgradeableBeacon is Ownable {
address public implementation;
event Upgraded(address indexed implementation);
constructor(address _implementation) {
implementation = _implementation;
}
function upgradeTo(address newImplementation) external onlyOwner {
implementation = newImplementation;
emit Upgraded(newImplementation);
}
}
// Beacon Proxy
contract BeaconProxy {
address private immutable BEACON;
constructor(address beacon) {
BEACON = beacon;
}
function _implementation() internal view returns (address) {
return IBeacon(BEACON).implementation();
}
fallback() external payable {
address impl = _implementation();
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
Usage
// Deploy
UpgradeableBeacon beacon = new UpgradeableBeacon(tokenV1);
// Deploy 1000 proxies
for (uint i = 0; i < 1000; i++) {
BeaconProxy proxy = new BeaconProxy(address(beacon));
// Chaque proxy pointe vers le beacon
}
// Upgrade ALL proxies en une transaction
beacon.upgradeTo(address(tokenV2));
// → Les 1000 proxies utilisent maintenant tokenV2
Avantages
✅ Batch upgrade : un seul upgrade pour N proxies
✅ Gas deployment : proxy minimal = moins cher
✅ Coordination : garantit que tous les proxies sont sur la même version
Inconvénients
❌ Single point of failure : si beacon compromis, tous les proxies impactés
❌ Rigidité : tous les proxies doivent upgrader en même temps
Quand l'Utiliser
- Factory patterns (ex : créer 1000 vaults identiques)
- Multi-chain deployments (même logic sur 10 chains)
Exemple : Dharma, OpenZeppelin Clones
Comparaison
| Feature | Transparent | UUPS | Beacon |
|---------|-------------|------|--------|
| Gas (deploy) | 🟡 Moyen | 🟢 Bas | 🟢 Très bas |
| Gas (call) | 🔴 Élevé | 🟢 Bas | 🟡 Moyen |
| Upgrade logic | Dans proxy | Dans impl | Dans beacon |
| Batch upgrade | ❌ | ❌ | ✅ |
| Risk si bug | 🟢 Faible | 🔴 Élevé | 🟡 Moyen |
| Adoption | 🔴 Déclin | 🟢 Standard | 🟡 Niche |
Storage Collisions
Danger : le proxy et l'implementation partagent le même storage.
// ❌ MAUVAIS
contract Proxy {
address public implementation; // slot 0
}
contract Implementation {
address public owner; // slot 0 aussi !
// → Collision : owner écrase implementation
}
// ✅ BON : EIP-1967 Storage Slots
contract Proxy {
// slot = keccak256("eip1967.proxy.implementation") - 1
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
function _implementation() internal view returns (address impl) {
assembly {
impl := sload(IMPLEMENTATION_SLOT)
}
}
}
Initializers vs Constructors
Les proxies ne peuvent pas utiliser de constructors (car le code n'est pas dans le proxy).
// ❌ Ne fonctionne pas avec un proxy
contract Token {
address public owner;
constructor() {
owner = msg.sender; // S'exécute dans l'implementation, pas le proxy
}
}
// ✅ Utilisez un initializer
contract Token is Initializable {
address public owner;
function initialize() public initializer {
owner = msg.sender; // S'exécute via delegatecall depuis le proxy
}
}
Sécurité : Checklist
- [ ] Initializers protected :
initializermodifier ou check manuel
- [ ] Selfdestruct forbidden : une implementation ne doit JAMAIS avoir
selfdestruct
- [ ] Delegatecall safe : attention aux delegatecalls dans l'implementation
- [ ] Storage layout preserved : ne jamais réordonner les variables en V2
- [ ] Upgrade governance : multisig ou timelock sur upgrade functions
Tools
# Vérifier les storage collisions
forge inspect MyContract storage-layout
# Tester un upgrade
forge test --match-test testUpgrade
# Valider la compatibilité V1 → V2
npx @openzeppelin/upgrades-core validate
Ressources
- EIP-1967 : Standard Storage Slots
- EIP-1822 : UUPS Spec
Les proxies sont puissants mais dangereux. Auditez toujours votre upgrade logic.