Understanding Fixed-Proxy and Dynamic-Proxy Smart Contracts in Solidity
Smart contracts on Ethereum are immutable once they are deployed. This immutability provides trust and reliability, ensuring that its logic…
Smart contracts on Ethereum are immutable once they are deployed. This immutability provides trust and reliability, ensuring that its logic cannot be tampered with once a contract is deployed. However, this also poses a problem for contract maintainability and upgradeability, as bugs cannot be fixed, and improvements can only be implemented by redeploying a whole new contract.
To circumvent this problem, the concept of Proxy patterns in smart contracts was introduced. This article explores the two types of Proxy patterns used in Solidity smart contracts — Fixed Proxy and Dynamic Proxy — provides implementation examples of both, along with important security considerations.
Fixed-Proxy Smart Contracts
The fixed-proxy pattern allows us to upgrade a smart contract's logic while maintaining the contract's state. In this pattern, the Proxy contract is fixed, and the forward function calls to the implementation contract, which contains the logic and can be upgraded.
The proxy contract stores the address of the implementation contract. To upgrade the contract, a new version of the implementation contract is deployed, and the reference in the proxy contract is updated to point to the new implementation contract's address.
Here is a simple example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract FixedProxy {
address internal _implementation;
function upgradeTo(address newImplementation) public {
_implementation = newImplementation;
}
fallback() external payable {
address implementation = _implementation;
require(implementation != address(0));
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(gas(), implementation, ptr, calldatasize(), 0, 0)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
The fallback
function redirects the call to the implementation contract in the fixed-proxy contract above. The delegatecall
function executes the code at the implementation contract's address within the context of the proxy contract, maintaining its storage.
Dynamic-Proxy Smart Contracts
Unlike the fixed-proxy pattern, the dynamic-proxy pattern permits changes to proxy and implementation contracts. In this pattern, the proxy contract can be upgraded, allowing changes in the storage structure and upgrade mechanisms over time.
A dynamic-proxy contract requires a registry contract to manage the versions of the proxy and logic contracts. Here is an example:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Registry {
address public logicContract;
address public proxyContract;
function upgradeLogic(address newLogicContract) public {
logicContract = newLogicContract;
}
function upgradeProxy(address newProxyContract) public {
proxyContract = newProxyContract;
}
}
contract DynamicProxy {
address public registry;
constructor(address _registry) {
registry = _registry;
}
fallback() external payable {
Registry r = Registry(registry);
address implementation = r.logicContract();
require(implementation != address(0));
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(gas(), implementation, ptr, calldatasize(), 0, 0)
let size := returndatasize()
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}
The Registry
contract stores the addresses of the current DynamicProxy
and logic contracts and allows them to be updated. The DynamicProxy
contract contains a fallback
function similar to the FixedProxy
contract, but instead of directly holding the logic contract's address, it references the Registry
contract to fetch the current logic contract's address.
Essential Security Considerations in Upgradeability
Proxy contracts and delegate calls are fundamental elements of upgradable contracts in Solidity. They can help you avoid the cost of deploying a new contract every time you want to add new features or fix bugs. However, using them also comes with significant security considerations. Here are a few you should always keep in mind:
- Re-Entrancy Attacks: If the proxy contract delegates call to an untrusted contract, re-entrancy attacks might occur. If the delegated contract’s function makes an external call to another contract, an attacker could make the called contract call back into the calling contract before the first call is finished. This can cause the state of the calling contract to change unexpectedly.
- Function Selector Collisions: A function selector is the first four bytes of the keccak256 hash of the function’s signature. In Solidity, function selectors are used to determine which function to call. If two functions from different contracts have the same function selector, unexpected behaviour could occur because the wrong function may get called.
- Storage Collisions: When a delegate call is used, the called contract has access to the calling contract’s storage. Therefore, if the layout of the storage variables in the delegate contract doesn’t match the layout in the proxy contract, unexpected behaviour or malicious attacks could occur.
- Immutable Variables: In Solidity, some variables are set as
immutable
, which means they can only be set during the creation of the contract and can't be changed later. However, when using delegate calls, the immutable variables of the called contract are not accessible, and the values will always be as if they were not initialized. - Constructor and Self-Destruct: A delegate call does not run the constructor of the delegated contract, and self-destruct does not work as you might expect. If a contract relies on some setup code in its constructor or uses self-destruct for security purposes, these features will not work correctly when using delegate calls.
- Access Control: Since the delegate call is executed in the context of the calling contract
msg.sender
andmsg.value
from the calling, contracts are preserved. This may lead to potential security issues if not correctly handled.
Advanced Considerations in Upgradeability
While the fixed-proxy and dynamic-proxy models provide a basis for upgradeable smart contracts, they also introduce new challenges and considerations. Some of these advanced considerations are:
- Governance: Who has the power to upgrade the contract? The simple examples we have seen give this power to the original deployer of the contract. Still, in a decentralized context, you might want this power to belong to a collective of token holders or some other governance mechanism.
- Atomic Upgrades: Depending on the nature of the contract, you can upgrade the contract and migrate the state in a single atomic transaction. This typically involves writing a migration function that can be called during the upgrade.
- Pause & Upgrade: If a critical issue is found, you may need to pause the contract operations before preparing the upgrade. Such capability needs to be built into the contract from the beginning.
- Emergency Downgrade: While upgrades are usually thought of as adding new features or fixing bugs, you may want to be able to downgrade a contract to a previous version if something goes wrong.
Proxy Libraries
Given these challenges' complexities and repetitive nature, it's no surprise that several Ethereum libraries aim to make it easier to deploy and manage upgradeable contracts. Two famous examples are OpenZeppelin's Upgrades plugins and the Truffle Upgrades plugin.
These tools provide a variety of features like automatically managing your contract versions, handling deployments, checking for storage collisions, and more.
Finally, remember that upgradeability is only sometimes desirable. The promise of immutability and predictability is one of the cornerstones of blockchain and smart contracts. The ability to upgrade contracts brings a lot of flexibility, but it also introduces a point of centralization and potential vulnerability.
As a developer, it's essential to strike a careful balance and consider whether upgradeability is needed for your contract and how it might affect its trust model. Sometimes, a series of immutable contracts with well-planned interaction mechanisms might serve better than a fully upgradeable contract.
Conclusion
In conclusion, fixed-proxy and dynamic-proxy contracts provide a means of developing upgradeable smart contracts in Solidity. While this adds complexity, it also provides valuable flexibility, particularly for long-lived contracts or those that need to adapt over time.
However, it's crucial to understand and carefully manage upgradeability's potential risks and challenges, including maintaining data layout compatibility, securing access control, and managing the complexities of contract governance.
The examples here offer a starting point, but real-world applications typically require a more sophisticated approach. Using existing libraries and tools is also recommended to help manage some of the complexities and potential pitfalls of upgradeable contracts.
Finally, always remember that one of the cornerstones of blockchain technology is its immutable nature. Upgradeable contracts are robust, but they should be used judiciously and appropriately.
Stay tuned, and happy coding!
Visit my Blog for more articles, news, and software engineering stuff!
Follow me on Medium, LinkedIn, and Twitter.
Check out my most recent book — Application Security: A Quick Reference to the Building Blocks of Secure Software.
All the best,
Luis Soares
CTO | Head of Engineering | Blockchain Engineer | Web3 | Cyber Security | Solidity | Smart Contracts
#blockchain #solidity #ethereum #smartcontracts #designpatterns #datastructures #communication #protocol #data #smartcontracts #web3 #security #privacy #confidentiality #cryptography #softwareengineering #softwaredevelopment #coding #software