Strategy Pattern for Smart Contracts
Software developers hate duplication of effort. Generally, it's better to use a battle-tested library than rolling your own for complex functionality, unless you really need to squeeze out extra performance (learning or non-commercial reasons are also acceptable). Examples include sorting algorithms, data structures, crytographic functions, etc. In addition to reusing specific code, best practices have been developed over time on how to design software at a higher-level to solve many common problems (design patterns).
Smart contract developers (EVM-focused specifically) have taken wholeheartedly to the first category. Several reputable libraries are used for a variety of code, such as tokens, governance contracts, type utilities, access control, and EIP compatibility. However, they have largely ignored the second category.
Smart contracts are often created with bespoke contract architectures and patterns to suit the specific use case for the contract(s). A primary reason for this is efficiency, since each operation in a smart contract increases the cost of execution. The major exceptions are Proxies and Factories, but there are a few other more niche examples such as Clones (a type of Proxy), Diamonds, and Singletons (e.g. Safe wallets). The Default Framework is an attempt to build a more general pattern to build many applications from, but it has certain limitations. Outside of that, there is a bit of "tribal knowledge" on the best way to build certain popular use cases, like lending protocols and automated market makers, but these are largely just based on the most popular protocol(s) in those use cases.
In this post, I attempt to describe the landscape of smart contract patterns with a focus on immutability, upgradability, and extensibility. Then, I introduce a pattern that we developed at Axis, which I believe has some attractive properties who want to build decentralized, extendable protocols: the Strategy pattern.
Immutable vs. Upgradable Contracts
By default, smart contracts deployed to an EVM blockchain are immutable. The code cannot be changed after the fact. This differs greatly from the iterative development and deployment methodologies that dominate most of the software industry these days. In this way, it's more similar to other "high assurance software" domains, such as aerospace and healthcare, than it is to your typical web applications. This is both a boon and a curse.
A key goal for many smart contract protocols is to maintain the native immutability and make the system rely on centralized actors as little as possible. Immutability allows users to trust the code and not the administrator of the contract (assuming the user reads and understands the code well enough to be confident there are no security issues). This aligns with the decentralization ethos of the industry and is generally scene as a positive by "natives". Additionally, it allows other projects to more confidently integrate with the contract(s) and be sure there will be no breaking changes.
However, immutability has a downside: bugs cannot be patched. If a bug is discovered and puts user funds at risk, the team has to hope they can beat any malicious actors (or even MEV bots) to beating them to make a recovery of the funds, if this is even possible. For this reason, many organizations, specifically corporate ones, use upgradeable proxies when they deploy their systems, which breaks the immutability, but allows them to patch it.
Upgradable proxies allow administrators to completely replace the code that is run at a contract address without changing the internal state or having to move any token balances. They can (and do) use this power for good to batch bugs without any impact to the user, but you have to trust them not to take all the funds stored in the contract. Sometimes proxies are used during an intial period after deployment and then "frozen" so they cannot be upgraded anymore, which is a decent compromise. However, any use of proxies means there is some centralization in the smart contract system, and, therefore trust is required in the administrators.
Middle Ground
While immutable and upgradable proxies represent two opposing extremes, in reality there is a spectrum of options between them. In order to compare these options with the extremes, we need to have some properties of these patterns to compare:
- Components that are immutable
- Components that are upgradable
- Extensible
- Transitionable (typically upgradable -> immutable)
- Stoppable
TODO explain these more
TODO stopped here
Extensibility
A lesser version of upgradability is extensibility, where an administrator can add additional functionality to a contract without altering existing features. This is an interesting
The Strategy Pattern
A common object-oriented software design is the Strategy pattern.
Thought: Strategy pattern is only one of the potential ways to use this general design
Implementation in Axis
TODO: remove redundant descriptions here
At Axis, we place a high priority on immutability and decentralization of the system. To start with, the AuctionHouse contracts themselves are immutable and require deployment of a new system to make updates. Additionally, we have designed Modules to allow extending and upgrading the functionaltiy of the system without affecting existing users.
Upgradable you say? That doesn't sound immutable! It is where it counts: once an auction is created, the logic for that auction and its derivative cannot be changed by any Module upgrades.
In the Axis system, each Module has a 5-byte Keycode
that tells an AuctionHouse what family of Modules it belongs to. When a Module is installed on an AuctionHouse, the AuctionHouse creates a pointer from the Module's Veecode
to its deployed address. A Veecode
is a 7-byte, versioned keycode, which is the combination of a 2 digit version and the Module's Keycode
. An example is the EncryptedMarginalPrice AuctionModule which has Keycode: "EMPA"
(Keycodes are 3-5 uppercase letters). The first version being deployed has Veecode: 01EMPA
, where the version number is 01
.
If a bug or improvements are made to a module, it can be "upgraded". However, what this really means is that a new version of that Module is installed on the AuctionHouse. The AuctionHouse keeps track of the latest version of a each Module family, but the previous ones are still referencable by their Veecode
.
Whenever an auction is created, the seller selects what kind of auction they want to run and, optionally, what kind of derivative to sell by specifying a Keycode
for the AuctionModule and DerivativeModule. The AuctionHouse takes these Keycode
s and looks up the most recently installed version of each, and then stores those modules Veecode
s in the auction data. In this way, that specific auction is locked into using the logic in those specific version of the Modules, regardless of if a newer version is installed after the auction is created. Therefore, users have confidence that the code that is present will not change on them in the future.
Additionally, Modules cannot be uninstalled. In this way, auctions cannot be stopped either. However, what if there is a Module family that cannot be fixed or is no longer desired? We can then "sunset" the Module. A sunset Module has no active versions and cannot be used to create new auctions/derivatives. Existing auctions/derivatives will continue to work until they are complete.
We think this design provides a good compromise to the two extremes of a completely immutable and a completely upgradable system.
Here's a table to summarize the main differences between Modules and Proxies:
Feature | Axis Modules | Proxies |
---|---|---|
Immutable | Yes (for a particular auction) | No |
Upgradable | Yes (only applies to new users) | Yes |
Brickable | No (but can prevent new usage) | Yes |
Freeze Version | Yes (for a particular auction) | Yes |
Module Management
Each AuctionHouse has an owner
who has the sole ability to install
, upgrade
, and sunset
Modules on the contract. Thus, the Modules that are installed on the AuctionHouse are permissioned and verified by the owner.
Axis offers a separate framework to permissionlessly extend auction functionality: Callbacks.
Permissioned Functions on Modules
When Modules are deployed, their parent
address is immutably set. This must be the AuctionHouse that the Module is being installed on.
In order for the auction logic to be executed correctly, state-changing functions on Auction Modules are restricted to only being called by the AuctionHouse, using the onlyParent
modifier. As such, any administrative setter functions must be called by the AuctionHouse. To facilitate this in a general way, the AuctionHouse has an execOnSubmodule
function that accepts arbitrary calldata to pass along that is callable by its owner
.
However, it is not intended that any restricted function on the AuctionHouse be callable by the owner
at any time. This could potentially cause issues with auctions if the sequenced logic in the AuctionHouse is bypassed. Therefore, Modules have another modifier onlyInternal
which restricts the state-changing functions that are called during the auction process to only calls that originate from outside of execOnSubmodule
.