Decoupling Solidity Contract Storage and Logic for Multi-Instance Management

PeterZ
4 min readSep 27, 2020

This article discusses the design of Solidity contracts that implement the structure as illustrated in the above figure. The requirements are:

  • To describe and operate an object (e.g., an on-chain vote or token-swapping pool) on blockchain, which can normally be achieved by defining storage variables and functions that implement the operations;
  • To create multiple instances of the object and manage these instances such that we have a single-point entrance (e.g., a single contract address) for users/dapps to interact with the instances.

Typically, the operations are complex. For instance, for a privacy-preserved voting system, you would need operations to generate and verify encrypted ballots, which require to implement complex cryptographic algorithms. As a result, the overall bytecode size of the operations would be large and economically, it makes sense to only deploy the code only once on the blockchain. Another important factor we need to consider is the need of upgrading operations to fix existing bugs or implementing more efficient algorithms.

The key to tackle the problem is to decouple the storage and operational logic for the object. The decoupling mechanism has already been extensively discussed and explored, e.g., in [1, 2]. Following the ideas, the delegatecall mechanism is used to decouple the storage and operational logic for the object. This work focuses more on the management of multiple instances of the object. I will present two different contract design patterns in the rest of this article.

Pattern 1 — Deploying Contracts

As illustrated in the above figure, we can decouple the storage and operations of the object into contracts Object and Operation. Contract Management is responsible for managing and serving as a single entry point to interact with users/dapps. A simple example that implements the design pattern is shown below.

An object instance is created by Management via deploying contract Object on-chain. The contract address is then saved by Management and referred by a unique ID given to the new object instance. In the sample code, contract addresses are put in array instances and objects referred by array indices.

To operate a particular object, users pass the object ID when calling functions (e.g., set(id, value)) provided by Management. These functions then extract the corresponding address and make a forwarding call. Following [1], contact Object delegatecalls functions of contract Operation within its fallback function.

Pattern 2 — Storage Manipulation

The second pattern is inspired by [2]. Instead of using a separate contract representing the object storage, we pack storage variables into a struct and manage object instances inside the storage of Management. A simple example that implements the design pattern is shown below.

As illustrated by newObject, when creating a new object instance, we calculate a unique hash for the instance and use the hash as the slot position of the struct instance (e.g., struct Object) that represents the storage of the object. To operate the object, we define a storage variable of the struct type, set its slot position as the corresponding hash and operate on the struct element(s). Management can then directly delegatecall functions of Operation by passing the slot position for the corresponding object instance in its storage.

There certainly would be some concerns about the situation when the two object instances share the same address in the storage space of Management. However, as pointed in [2], the way we manipulate storage is exactly the way Solidity manages mappings and dynamic arrays. The possibility can be considered negligible.

References

[1] https://github.com/fravoll/solidity-patterns/tree/master/ProxyDelegate

[2] https://github.com/mudgen/diamond-3

--

--