Exploring Ethereum Storage Costs with the FOAM Developer Tools

A good craftsman never has to blame their tools.

Ilya Ostrovskiy
FOAM

--

Over the past few months we’ve had the pleasure of introducing some of the tooling we’ve created to simplify our workflow for developing Ethereum applications. Our developer blog posts about purescript-web3, Chanterelle, and Cliquebait were met with enthusiasm both online and in person, and we are excited to see that people are experimenting with and taking advantage of the elements of our stack.

However, we realize it may not be immediately apparent to newcomers how all these components tie together and thought it would be worthwhile to showcase one of the ways we used our stack to help drive the design and engineering process behind the FOAM TCR. If you haven’t seen our previous post introducing the FOAM Token Curated Registry and Static Proof-of-Location yet, you should take this opportunity to check it out, as it provides vital background and motivates much of the details we’ll be exploring in this post.

The FOAM TCR aims to build a decentralized map of the world, which ultimately means we need to store our geospatial information in an immutable, verifiable manner. The most obvious approach is to simply store this data within some form of storage contract on that blockchain. While this approach has many drawbacks, such as contributing to the accelerating growth of the Ethereum state trie which would make us something of a bad neighbor; it also opens up the possibility of issues in the future storing and accessing this data. The concept of rent for on-chain storage has been discussed and may potentially be implemented at some point, which could mean that additional costs that are currently difficult to reliably account for (as they’re still fairly theoretical at this point) may come into play. If and when sharding comes along, it may become prohibitively expensive for end users to store useful quantities of data (for our application) on the Mainnet that we plan on deploying to (in addition to Mainnet rent to incentivize deploying applications on shards).

Finally, there are still issues in the present that affect the viability of storing such data on the chain itself, the primary one being that the Cartographers will end up paying gas costs to store Point of Interest data, which may end up being challenged or overwritten in the future with someone else paying a similar cost — leading to “wasted gas”. As a consequence, you’d have both the FOAM Token and the ETH paid for the gas to transact against the TCR (which would include the cost of the data storage) as a factor to consider in the economic stake behind a PoI. This throws a significant metaphorical wrench in the economic machinery behind a TCR and complicates the design process — and increasing complication is generally a good indicator that a different approach should be considered.

It should now be readily apparent that most of the signs point to on-chain storage of PoI data to be a Bad Idea™. Instead, we have the good fortune to be part of an ecosystem with insanely talented people working on some incredibly forward-thinking projects such as IPFS. IPFS is a content-addressed, peer-to-peer file sharing system — meaning that it has good decentralization properties due to its P2P nature, and because the unique identifier of a datum stored in the system is derived from the hash of its contents and metadata such as creation time, it’s possible to assert that the correct data is received when pulled from a peer. These hashes are fairly compact and consistently sized, so regardless of how rich and detailed the PoI data is, the on-chain storage burden remains constant. If there’s interest, we can delve into how we use IPFS and challenges faced for storing PoI data in a future blog post.

While we’ve presented a great theoretical case for why to avoid on-chain storage which should be enough to kill that approach and explore other avenues, no good theory is complete without some concrete numbers to act as nails in the coffin. To this end, we used all the components of our Web3 stack to rapidly iterate on some visualizations of how gas costs scale with the size of the data being stored on the chain. Thanks to our tooling, we were able to simply empirically test the gas usage of transactions storing data on chain in four different manners by running actual transactions against a real Ethereum blockchain.

The Experiment

The test setup in this experiment was a set of contracts which encapsulate access to a mapping(string => string). The transactions involved would simply set a key-value pair of varying length in the storage of the contract, in four different manners. Our control was a “no-op” contract, which did absolutely no storage. It is expected that the cost of transactions against this no-op contract should simply scale in an almost perfectly linear manner based on the size of the key and value, reflecting the intrinsic gas cost of the transaction.

Of the tested techniques, the first (RawStrAS) would use a library contract to store the original input string as the key completely unmodified, with the value unmodified as well. The second (KecAS) would use a library contract to perform a similar storage behavior, except it performs a keccak256() operation on the key. The final two (emRSAS and emKecAS) would forgo a library contract, and directly implement the logic inside the contract code (hence em, for embedded).

When working with a mapping type in Solidity, the bytecode that is generated effectively treats the data as a hash table. The key (plus a salt describing in what order the mapping is defined in the contract’s code) is hashed with Keccak256 to determine which address in the contract’s storage trie to store the value in. This is also why mapping()s in Solidity don’t let you iterate over all the keys and values natively unless you wrap them in a structure that tracks at least the keys. Therefore, we don’t expect the key size to have a significant impact on the gas used, besides the intrinsic cost to the transaction. In theory, RawStrAS and KecAS should have identical performance characteristics, as roughly the same behavior should be implemented by the compiler for each (Keccak256'ing the key + offset-salt to determine which storage slot to write to). Nonetheless, we wanted to see if theres any significant gas cost difference between manually specifying that action and having the compiler implement it for you. Finally, we tested both having the logic implemented in a library vs. directly in the contract bytecode to see if there are any significant differences relating to setting up a DELEGATECALL to a function that implements the storage logic. In theory, there should only be a negligibly higher cost as the functions in the library need an extra parameter to tell them what salt to use for the mapping operations which should be a very cheap operation.

We know that every operation in the EVM has a gas cost to it, and one of the most expensive of these is the SSTORE opcode, which sets a value in a contract’s storage trie. It costs a whopping 20,000 gas when it’s used to set the value of a storage slot to a non-zero value! Since each storage slot is one EVM word 256-bits (32 bytes), we can expect to see that the gas cost of the transaction should jump by at least 20,000 gas for each interval of 32 bytes on the value length. This would appear visually as a “staircase” with each step being created around every multiple of 32 bytes of value length.

Finally, because strings in Solidity don’t have a fixed size, there is some overhead involved in tracking the length, packing, and manipulating them. Furthermore, there may even be certain storage slots being set to zero on occasion as part of this management process, which would actually incur a gas refund. We expect to see this as slight noise on top of our staircase.

The results of our empirical testing were mostly in line with what was expected, but with a somewhat surprising twist! The control appeared exactly as expected — since no storage operations are being the done and the contract essentially immediately returns, its cost is almost perfectly linear with the size of the input data and reflects the intrinsic gas cost of putting a transaction of its size on the chain. The little regularly-spaced grooves are seemingly from how a string is packed in the ABI encoding when the function call transaction is created.

Control group gas usage, reflecting the intrinsic gas cost of the TX

The remainder of the tests all had very similar plots to each other. We can see the “staircase” mentioned earlier which jumps by at around 20k gas on every 32 bytes of input length in the value axis. The spikes and dips we see are likely due to the aforementioned string processing and refunds, which we expected to see as only mild noise but in reality they are much more extreme than we anticipated! And, as expected, there were no significant differences between the library-based and embedded versions.

3D Plot of the gas usage of emKecAS, similar to the rest of the experimental group

On higher data sizes we can see that the gas cost balloons into the order of hundreds of thousands of gas, and the string-processing noise grows much more extreme with the larger value sizes. Of course, this doesn’t even begin to take into account the cost of any other TCR-related logic. If it costs the end user on the order of a dollar (at ETH prices at the time of writing) for each interaction with the TCR, this could quickly discourage participation. Armed with this knowledge, we decided that PoI metadata would best be stored in IPFS, given that the identifier would be consistently sized regardless of the metadata content, and that it would significantly simplify much of the on-chain logic that would be necessary to handle and store the metadata.

With purescript-web3 and Chanterelle, we were able to quickly develop a set of minimal contracts and operate against them that would capture how gas costs scale with the size of the data we would potentially store, while being confident in the knowledge that our testing process is being performed in a type-safe manner and no inconsistencies in the data might be introduced due to sloppy code. And thanks to Cliquebait, we were able to completely saturate a “real” Geth-backed Ethereum chain with nearly 360,000 transactions in a little bit over an hour, due to Cliquebait’s default short block times and large block gas limits — all while being socially responsible and not affecting any of the testnets such as Ropsten, Rinkeby, and Kovan that most Ethereum developers rely on. Finally, we took the final gas used as recorded in the receipt for each transaction and used it to generate some interactive 3D plots with the Plotly JS API.

You can find the code we used for this project over at https://github.com/f-o-a-m/gaspasser/. Inside the collected-results/ directory, you can open results.html in your web browser of choice to play with the charts used to create the GIFs above. We hope you find this example to be a useful quickstart project to see how the components of the FOAM Web3 stack interact with one another and try to apply them to your projects!

--

--