Building Ethereum’s public smart contract infrastructure (Part 2 of 2)
In order to introduce the concept of gateway contracts gradually, I will first introduce runtime utilities. The challenges involved with widespread use of runtime utilities directly relate to the concept of upgradability, which is introduced in Part 1.
Runtime utilities are public contracts; snippets of code housed outside of an application. They can be used to perform simple checks and interactions before the execution of an application, and operate as a shared resource between all of the applications which choose to utilize the utility. In effect, a runtime utility can be used to add predicates to the execution of an application, such that some initial series of checks and interactions must succeed before the execution of an application.
The benefit of using a runtime utility (over simply implementing the same code within your own app) is its shared state; using a runtime utility may have implications or effects that span any number of applications.
Example Use Case: Account Registration
Let’s say that some token contract wants to make certain that tokens are transferred to an address with a known private key, to avoid burning tokens. As a standalone contract, the implementation is trivial, and probably follows a pattern something like this:
- The token contract implements an additional function,
- Users that wish to receive tokens to their address must call
registerAccount(), which proves to the contract that the sender is some account (contract or otherwise) with the capability to interact with the token
- Once a user has registered, they can be transferred tokens normally. The
transferfunction is extended slightly to check destination addresses against this account registry
This pattern changes slightly when using a runtime utility. We start with the assumption that some account registry utility has been deployed, which simply maintains a registry of “verified” addresses (similar to
registerAccount()). Instead of implementing a function for
registerAccount(), the token contract is instead configured to accept transactions only through the runtime utility.
The effect is similar to before. Instead of sending transactions directly to the token contract, users send transactions to the registry utility (specifying that the transaction is meant for the token contract). The utility checks the user’s status, updates it if needed, and then forwards execution to the token contract. The registry utility knows the token contract’s interface, and requires any address to which tokens are being transferred to have first been registered. The following is a simple example, showing the implementation of a registry utility. Note that the token contract must adopt a non-standard interface, to be compatible.
While this example may seem like jumping through additional hoops for little additional benefit, consider that the utility being used maintains a state that can be utilized by any application. What this means is that any token contract that wants to use this account registry utility, will extend the state of the registry utility for other token contracts that use the same utility. This interaction has clear implications for efficiency, as well as UX; users do not need to register several times over several contracts — a single transaction through the utility will suffice.
We can imagine several use-cases for runtime utilities:
- As a meta-token, implementing multi-app DAO-esque voting features
- As a cross-application transaction scheduler, through which users can schedule transactions for many different applications
- As a cross-application registry and naming system, through which users can be sure they are accessing the canonical latest version of some application
The key benefit from which a utility derives its usefulness is that it can provide applications access to a network effect that spans several applications and user-bases. What’s more, utilities can be accessed dynamically post-launch: applications do not need to exist before the utility’s deployment to take advantage of its features. Once deployed, these contracts provide their utility for as long as the contract exists, to any applications that choose to use them.
Ultimately, standalone runtime utilities may not provide sufficient benefit to justify their use. While a few isolated use-cases may find some interest or traction among the community, the fact that they require altering standard, widely-used interfaces (like ERC20) may not be tempting enough to warrant action when considering their limitations. The problem lies in their inflexibility: once deployed, these utilities can never change — unlike standards. To keep their contracts compatible or using the latest utilities, developers will need to make use of several disparate utilities across several addresses. At some point, the added friction probably isn’t worth it, even with the benefits individual utilities offer.
What utilities need is to be upgradable, extendable, and sufficiently modular to avoid high-friction configuration to those seeking to integrate them into applications. However, as the previous article showed, the problem of upgradability is exceedingly difficult, and does not by any means have a widely-accepted solution. The next section proposes a solution, in the form of gateway contracts.
Gateway contracts are special applications with several properties:
- They expose a public interface to any number of runtime utilities, accessible to any application from a single address.
- They maintain a registry of runtime utilities, which can be extended by anyone, without limit. Adding runtime utilities to the gateway extends the functionality of the gateway for all its users, without incurring additional bloat.
- Adding a new runtime utility is guaranteed not to conflict with the state of previous utilities.
- Like the isolated runtime utilities described in the previous section, they act as a call forwarder for external applications.
The net effect of these properties is that a gateway contract becomes a kernel on which any number of public protocols can be defined and entered into by external applications. It is an extension of the concept of an on-chain standard library, as it is a repository of both code and state; runtime utilities operate on a shared state through the gateway contract. Anyone can extend the repository by adding new code (in the form of runtime utilities) which can be used by applications.
Running a runtime utility extends the shared state of more than just the utility in question. It extends the shared state of every utility registered in the gateway, as they all maintain their state in the single gateway contract.
Crucially, the rules of the gateway contract prevent newly added runtime utilities from overwriting state from previous additions. If this were not the case, the gateway contract would be susceptible to the same potential vulnerabilities and downsides of traditional delegate proxy contracts. However, because this is not the case, a gateway contract can be infinitely extended by anyone, and each subsequent utility provides some benefit to previously-added utilities.
The following describes one potential implementation by which a gateway contract evaluates a received transaction. In this proposed process, we assume the gateway contract is configurable by an application to automatically execute certain runtime utilities, dependent on the transaction’s function selector. Note, though, that the protocol used by the gateway contract is sufficiently modular as to allow several interfaces.
We first assume that some runtime utility has been deployed as a contract to some address, and that it has been registered in the gateway contract. The gateway has assigned the utility a unique identifier which acts as a key to the utility’s state.
- User sends a transaction meant for some application to the gateway contract. The data included in the transaction has two parts. The first 20 bytes are the address of the destination application. The remaining bytes contain the calldata that would normally be sent to the destination to execute some function.
- The gateway contract parses the transaction and reads the destination address, as well as the function selector contained in the calldata portion. It checks whether the destination application has designated a runtime utility for the transaction, based on the function selector. If not, it forwards the calldata to the destination via a
- If, instead, the destination has designated a runtime utility for the transaction, the gateway contract executes a
delegatecallto the utility, forwarding the calldata. The utility is able to read from the state of the gateway contract, evaluate the calldata, and compute some result. The utility must return the result to the gateway contract by way of
revert, or the gateway contract will perform its own
revert, ending execution safely. The gateway contract will evaluate the result returned by the utility, and can both update its state and/or forward the calldata to the destination via
- If calldata was forwarded to the destination, it ends its execution and returns data to the gateway contract. If execution was successful (that is, it did not revert), the gateway contract will
returnthe data returned, ending execution. If execution was not successful (that is, it reverted), the gateway contract will
revertthe data returned, ending execution.
Because the gateway contract requires that the utility end its execution via
revert, it is able to guarantee that no state changes occurred, while simultaneously allowing the utility to efficiently interact with the universal state of the gateway contract.
The utility is able to efficiently perform reads directly via
sload, as it is the target of a
delegatecall from the gateway contract. In order to write to the state of the gateway, the utility cannot use
sstore; enforcing a
revert ensures the utility can not directly alter the gateway contract’s state. Instead, the gateway contract interprets the data returned by the utility as a list of storage locations and values. When storing these values, each location is hashed with the utility’s unique identifier. The following provides a graphical representation of two separate transactions to two separate applications, using a “RegisterAccount” utility:
Although the gateway’s runtime utilities are invoked similarly to traditional delegate proxy targets, enforcing a
revert on a
delegatecall circumvents all the negative side-effects present in traditional delegate proxy implementations. This pattern is not susceptible to malicious runtime utilities, even ones that attempt to exploit re-entrancy, call
selfdestruct, or change state in manners not controlled by the gateway; the enforced revert completely mitigates all potential state changes. All added utilities can efficiently read from the state of the gateway contract, as the
delegatecall allows local storage reads via
sload. At the same time, the gateway contract’s state can only be changed by the gateway contract itself. Because the utility cannot affect state through its
delegatecall, it can only interact with the state in the gateway contract on the gateway contract’s terms.
For these reasons, the gateway contract can be safely extended and modified indefinitely, and by anyone. Each time a utility is executed, it can store values to its state mapping via its unique identifier (through the returned data parsed by the gateway). It can also read values from the state of any other utility added to the registry, as long as it is provided with the corresponding identifier.
The implication of a universal shared state between these public utilities is powerful: by accessing widely-used utilities, any end application can tap into the network effect of the cumulative user base of the gateway contract.
This scheme does not come without difficulties.
- As proposed, the end application receives a
callfrom the gateway contract, meaning it does not have the context of
msg.sender. However, this can be mitigated if the gateway contract requires that
tx.origin == msg.sender. The end application can then require that
msg.sender == gateway, and then reliably use
tx.originin place of
msg.sender. Alternatively, the gateway contract can append
msg.senderto the end of the calldata forwarded to the external application.
- Currently, the syntax of Solidity will make designing these utilities difficult. Returning data via
revertin the manner described is only possible through the liberal use of in-line assembly. To make this accessible to many developers, it is likely both Solidity, as well as many standard contract interfaces, will need to change.
- The use of the gateway contract and utility adds some overhead to execution. However, I believe that for certain applications this might be well worth the additional cost — especially since, ultimately, the shared state reduces redundancy across all utilizing applications.