Smart Contracts Sharing Common Data
By: Jules Goddard
Tags: Solidity, Programming, Smart Contracts, Ethereum, Blockchain

Smart Contracts Sharing Common Data

This article discusses why and how some Solidity smart contracts share common data.

Photo by Kitty West

There are many possible models of interaction between multiple smart contracts. In the example for this article, we consider a simple Producer to Consumer model, so that we can focus on the theme of sharing common data:

Typical Producer to Consumer model

For example, the Producer could be a bank providing mortgage products. The Consumer could be the auditing department.

The Producer and Consumer model may be required because:

the Producer and Consumer work at different rates the Producer and Consumer work on different timelines the Producer and Consumer are different contracts, developed by different teams or at different times a host of other reasons

In this model, the Producer will produce a data object which is sent to the Consumer to consume when they are ready to do so. That is, the two smart contracts must handle the same data object, but at different times.

To enable that, we shall employ a queue.

Now that we have identified that common queue data must be shared between contracts, let’s move on to doing just that.

Common Queue Data Location

In all cases, the only way for smart contracts to share any common data in the Ethereum Virtual Machine (EVM), is if their functions are invoked in the context of the calling contract which provides the common data space. This is the same as calling library functions, facilitated by Solidity’s delegatecall feature.

Below, we consider each of these options in turn for the common queue data location:

1) Common Data within a Producer contract

2) Common Data within a Consumer contract

3) Common Data within a Router contract

4) Common Data within a Queue contract

1) Common Data within a Producer contract

This may be useful if the Consumer has not yet been agreed/implemented/finalised etcetera. It also facilitates multiple Consumers.

Locating the common queue data in the Producer

However, as you can see from the code and data location illustration above, in order for the Consumer code to be able to access the common queue data, it must be invoked in the context of the Producer contract.

That also means that any Consumer data, which will typically be required, will also have to be kept in that context, since it is inadvisable to allocate storage data in either the Producer or Consumer contract, as it may overwrite storage data in the other contract.

This may be suitable under some circumstances, but not in this example.

2) Common Data within a Consumer contract

This may be useful if the Producer wants to control which Consumer is consuming which object. It also facilitates multiple Producers.

However, in a similar manner to the illustration above, in order for the Producer code to be able to access the common queue data, it must be invoked in the context of the Consumer contract.

The caveat above also applies.

Again, this may be suitable under some circumstances, but not in this example.

3) Common Data within a Router contract

This requires a Router (or a Diamond, discussed later) contract. It facilitates multiple Producers and Consumers. It also facilitates changing (or upgrading) the Producer and Consumer smart contracts.

The Router contains the data for all smart contracts

The Producer and Consumer may be contracts or libraries, but their functions must always be executed in the context of the Router.

Any Producer, Consumer or Router data, which will typically be required, will also have to be kept in that context, since it is inadvisable to allocate storage data in either the Producer, Consumer or Router contract, as it may overwrite storage data in the other contracts.

This technique also requires discipline by the contracts using the common data, so that each contract only alters data pertaining to its operation, and does not alter anything else in the shared, common data.

4) Common Data within a Queue Contract

This also requires a Router contract. It is similar to the previous option, but enforces secure access to appending and removing items.

An additional Queue contract controls access to the queue data

This solution is a little more complex: a reference to the Queue contract must be provided to both the Producer and Consumer, and the Queue contract will also need to know their addresses in order to check whether access is permitted.

This is probably the ideal solution for most circumstances, but to keep the code simpler for this example we will create and measure option (3), Common Data within a Router contract.

Method for Sharing Common Data

The method for sharing common data in the Router between any number of Solidity smart contracts and libraries is exemplified by Diamond Storage described in “EIP-2535: Diamond Standard” and “New Storage Layout For Proxy Contracts and Diamonds” based on OpenZeppelin’s “Unstructured Storage Proxies” and other literature.

The code effectively selects random storage slots for each common data object. The slots are fixed for each object. Since the slots are randomly selected from a virtual address space of 2²⁵⁶, there is little chance of a location clash. We shan’t consider it further in this article.

Whenever any of the contracts or libraries need to access the common Queue data, the code shown in the example function action() is executed:

function action() public {

... // Code Fragment (1)

QueueData storage qds = queueData(); ...

}

This obtains a reference specifically to the common queue data which we are sharing in this example. The queue can then be used as required as shown below.

The queueData() function is:

// Code Fragment (2)

function queueData() internal pure returns (QueueData storage) {

return queueDataAt(QUEUE_DATA_LOCATION);

}

This obtains a reference to the common queue data at QUEUE_DATA_LOCATION using QueueDataAt(), which is deceptively simple code:

// Code Fragment (3)

function QueueDataAt(uint location) internal pure returns

(QueueData storage qds) {

assembly { qds.slot := location }

}

This function returns a QueueData storage variable. Since QueueData is a struct, this function returns a reference (a pointer to the struct). The assembly language statement simply sets the storage slot of the returned reference, to the given location. This has the effect of converting (effectively casting) the location parameter, which is a uint, into the QueueData storage. The assembly syntax to access the slot varies slightly between compiler version 0.7.0 and previous versions.

QueueDataAt() must be supplied with a fixed random location, in this case QUEUE_DATA_LOCATION, which is encoded in this manner:

// Code Fragment (4)

uint constant QUEUE_DATA_LOCATION =

uint(keccak256("queue.data.location"));

The keccak256 function is used to effectively generate a random location for the data.

If another queue was required elsewhere in the solution, that would need to be placed at a different location, requiring a different argument to the keccak256 function.

If the four code fragments above are present in any functions in the Router or subordinate contracts or libraries, then access to the same Router data will be available.

We can pull these code fragments together, to produce:

function action() public {

... // Code Fragment (1)

QueueData storage qds = queueData(); ...

} // Code Fragments (2)(3)(4) combined

function queueData() internal pure returns

(QueueData storage qds) {

uint location = uint(keccak256("queue.data.location"));

assembly { qds.slot := location }

}

Or even:

function action() public {

... // Code Fragments (1)(2)(3)(4) combine

QueueData storage qds;

uint location = uint(keccak256("queue.data.location"));

assembly { qds.slot := location } ...

}

We will use these combined code fragments in the example code below.

Method for Storing Private Contract Data

As mentioned above, since it is inadvisable to allocate traditional storage data in any contract, as it may overwrite traditional storage data in any of the other contracts, any private contract data will also have to be kept in the Router context by employing the method described above.

If the code fragments are private to a contract or library, then no other contract (such as the Router contract) will be able to access the data.

Sydney by Freya Gabbutt

Example Implementation

To illustrate the method, we will implement the simple Producer and Consumer model described at the top of this article in option (3), Common Data within a Router contract, and focus on the mechanism to access private contract data and share the common queue data.

Example Implementation

The contracts are:

The Producer contract

The Consumer contract

The Router contract

The QueueData library

The QueueDataLocation contract

The private contract data is:

The Producer Data - within the Producer contract

The Consumer Data - within the Consumer contract

The Router Data - within the Router contract

The shared common data is:

The QueueData - within the QueueData library

Producer Contract

The Producer contract contains the code to manipulate its own, private contract data, and also to append items to the common queue data, both in the context of the Router contract.

contract Producer is QueueDataLocation {

struct ProducerData {

uint count;

} function produce() public {

// Code Fragment (1) for private Producer data

ProducerData storage pds = producerData();

pds.count++; // Code Fragment (1) for common queue data

QueueData storage qds = QueueDataLocation.queueData(); QueueDataLib.append(qds, pds.count);

}



// Code Fragments (2)(3)(4) combined for private Producer data

function producerData() internal pure returns

(ProducerData storage pds) {

uint location = uint(keccak256("produce.data.location"));

assembly { pds.slot := location }

}

}

The QueueDataLocation contract will be shown later. Code Fragment (1) is shown accessing the two different data structs, the private contract data and the shared common data. Code Fragments (2), (3) and (4) are as per the method for private contract data.

Consumer Contract

The Consumer contract is very similar to the Producer contract, containing the code to remove items from the common queue data, and also to manipulate its own, private contract data, both in the context of the Router contract.

contract Consumer is QueueDataLocation {

struct ConsumerData {

uint total;

}



function consume() public returns (uint count) {

// Code Fragment (1) for common queue data

(bool success, uint item) =

QueueDataLib.remove(QueueDataLocation.queueData()); if (success) {

// Code Fragment (1) for private Consumer data

ConsumerData storage cds = consumerData(); cds.total += item;

return cds.total;

}

} // Code Fragments (2)(3)(4) combined for private Consumer data

function consumerData() internal pure returns

(ConsumerData storage cds) {

uint location = uint(keccak256("consumer.data.location"));

assembly { cds.slot := location }

}

}

Again, Code Fragment (1) is shown accessing the two different data structs, the shared common data and the private contract data.

Router Contract

The Router contract contains the code to routes calls to the Producer and Consumer contracts, and it also manipulate its own private contract data.

contract Router is CallLib, QueueDataLocation {

struct RouterData {

Producer producer;

Consumer consumer;

}



constructor(Producer producer, Consumer consumer, uint qSize) {

// Code Fragment (1) for private Router data

RouterData storage rds = routerData();

rds.producer = producer;

rds.consumer = consumer; // Code Fragment (1) for common queue data

QueueDataLib.create(queueData(), qSize);

}



function produce() public {

// Code Fragment (1) for private Router data

callLib(address(routerData().producer));

}



function consume() public returns (uint total) {

// Code Fragment (1) for private Router data

RouterData storage rds = routerData();

(bool ok, bytes memory bm) = callLib(address(rds.consumer));

if (ok) {

return abi.decode(bm, (uint256));

}

} // Code Fragment (2)(3)(4) combined for private Router data

function routerData() internal pure returns

(RouterData storage rds) {

uint location = uint(keccak256("router.data.location"));

assembly { rds.slot := location }

}

}

Code Fragment (1) is shown accessing the two different data structs, its private contract data and the shared common data.

The produce() and consume() public functions are provided to perform the actual operation of this solution. Note that produce() uses the return value of the routerData( ) function directly, whereas consume() assigns it to a variable.

There is also a trick going on in these two functions, which we shall reveal below.

Code Fragments (2), (3) and (4) are as per the method for private contract data.

Call Library

The callLib() function provided in the CallLib contract is similar to the fallback function provided in the article “Coding Upgradeable Smart Contracts” and OpenZeppelin’s “Proxy Forwarding”. Here is the code:

contract CallLib {

function callLib(address adrs) internal returns

(bool, bytes memory) {

assembly {

calldatacopy(0, 0, calldatasize())

let result := delegatecall(gas(), adrs, 0,

calldatasize(), 0, 0)

returndatacopy(0, 0, returndatasize())

switch result

case 0 {revert(0, returndatasize())}

default {return (0, returndatasize())}

}

}

}

QueueData and Library

The Solidity source code for the QueueData and associated library is provided in my article “Comparing queue routines in C and Solidity”.

This library is included into all contracts that use it. The Producer contract appends items, the Consumer contract removes items and the Router contract sets the queue size. The compiler will ensure that only those functions that are needed will be provided in the final bytecode.

QueueDataLocation Contract

This contract supplies the common queue data location.

contract QueueDataLocation { // Code Fragment (2)(3)(4) combined for common queue data

function queueData() internal pure returns

(QueueData storage qds) {

uint location = uint(keccak256("queue.data.location"));

assembly { qds.slot := location }

}

}

Code Fragments (2), (3) and (4) are as per the method for sharing common data.

Testing

This simple, interactive, sunny day contract enables the deployer to produce and consume items.

contract TestRouter { Router router;



constructor() {

Producer producer = new Producer();

Consumer consumer = new Consumer();

uint queueSize = 2;

router = new Router(producer, consumer, queueSize);

} function produce() public {

router.produce();

} function consume() public returns (uint) {

return router.consume();

}

}

Gas Consumption

A further contract called Combined comprising the inherited Producer and Consumer contracts was also constructed, to facilitate gas consumption comparisons. The subordinate contracts use the same method of allocating storage.

contract Combined is Consumer, Producer {

constructor(uint32 queueSize) {

QueueDataLib.create(queueData(), queueSize);

}

}

The gas consumption was measured as per our article “Gas Cost of Solidity Functions”.

Contract Construction

Individual contracts carry an overhead, so we can expect the construction gas consumption to be large than the combined contract.

Gas consumption of Router, Producer and Consumer contracts, versus Combined contract

And it is. As the gas consumption of construction is amortised over time, this may not be an issue at all.

Typical Contract Usage

What about the gas consumption of the Router contract’s produce() and consume() public functions?

Gas consumption of Router Produce() and Consume() functions

As expected, the cost of redirecting the function call using callLib() (shown earlier) is minimal. This is probably an acceptable amount to pay for the additional flexibility.

Further Possibilities

The Router contract’s produce() and consume() public functions use callLib() to call the subordinate Producer and Consumer contract functions. The Solidity function signature (called msg.sig) is used by both the Router and the subordinate contracts. That is the trick we referred to earlier. The destination functions must have the same name and parameters as the public functions in the Router contract:

Router redirecting function calls to subordinate contracts

In this example, it is not possible to use the fallback function in the Router contract because the destination functions are in different subordinate contracts. If there was only one subordinate contract, then the fallback function could have been used, as per the article “Coding Upgradeable Smart Contracts”.

If only there was an easy way for the Router to know which contracts supported which functions, then they code be invoked by the fallback function, like this:

Router using fallback function to make calls to subordinate contracts

Well there is. That’s what the EIP-2535: Diamond Standard is all about.

Conclusions

There are many reasons why smart contracts may be split into multi-contracts and will need to share common data and locate private contract data somewhere safe.

We have shown how easily that can be done using a simple router, with some caveats.

And we have introduced the idea of using a Diamond to encapsulate your multi-contract solution.

Bio

Jules Goddard is a founder of Datona Labs, who provide smart contracts to protect your digital information from abuse.

website — twitter — discord — GitHub

Also, Read