In the last two chapters, we learned how to read from and write to the Ethereum network. Reads can be made as regular calls to contracts, or by querying logged events, while writes are always performed in the context of a transaction. However, these operations only accommodate for basic use cases. If we want to display aggregate data from the blockchain, querying events client-side quickly falls short. Similarly, if we want to store large amounts of information in a contract, gas costs make it economically infeasible. In this chapter, we will work with off-chain components to solve both problems. We will first go through the process of indexing blockchain data in a server to query it from a client application and then go into off-chain storage solutions. We will also review techniques for handling reorganizations and testing along the way, as well as discussing the value of centralized vs. decentralized solutions.
Indexing Data
We will begin with the problem of indexing blockchain data. In this context, by indexing we refer to the action of collecting certain pieces of information from the network (such as token balances, sent transactions, or contracts created) and storing them in a queryable data store, such as a relational database or an analytics engine like ElasticSearch, in order to perform complex queries. Throughout this chapter, instead of attempting to index the entire chain, we will choose a specific dataset and build a solution tailored for it.
Indexing is necessary whenever you need to perform any kind of aggregation over logged data, such as a sum or an average, since the Ethereum events API is not fit for doing so. For instance, it is not possible to easily obtain the number of unique addresses that hold more than a certain balance of an ERC20 asset – a query that is trivial to run on a relational database.
Indexing can also be used to improve performance when querying large numbers of events, by acting just as a query cache. A dedicated database can answer event queries much faster than a regular Ethereum node.
Note
Certain public node providers, such as Infura, actually include an events query layer,1 separate from their nodes, in order to greatly reduce their infrastructure footprint for serving logs.
We will now focus on a specific indexing use case and design a solution for collecting the information to index.
Tracking ERC20 Token Holders
We will track the token holders of a specific ERC20 coin. Remember that ERC20 non-fungible tokens can act as a coin, where each address has a certain balance. However, the contract offers no methods to actually list them. Even if it did, some tokens have a user base that would vastly exceed the capabilities of a client-side-only application querying an Ethereum node. For example, at the time of this writing, the OmiseGO (OMG) token has over 650,000 unique holders.2
Revisiting the ERC20 Interface
As mentioned, unlike the extended ERC721 standard, ERC20 does not provide a method to list all token holders. The only way to build such a list is by going through every Transfer event and collect all recipient addresses.
Caution
Certain ERC20 contracts do not emit a Transfer event when minting new tokens, but rather emit a non-standard Mint event. In such cases, we would need to track both events in order to build the complete set of holders.
Querying Transfer Events
Constructor for the ERC20Indexer class we will be working on. We are once again depending on [email protected] to get the ERC20 contract ABI. We will store the list of all token holder addresses on the holders set, and the lastBlock field will keep track of the last block we have processed
Querying all transfer events from a range of blocks in batches. Here BATCH_SIZE should depend on the volume of transactions per block of the contract
Retrieving the recipient of a token transfer to add it to the list of token holders. Transfers to the zero address are usually tokens being burnt, so we will keep it out of our list
Note
In a production implementation, you will not want to store the list of holders in a javascript data structure in memory that is wiped out whenever the process is stopped. You should rather use a database for storing all data and serving your clients’ queries. The choice of the database engine is out of the scope of this book, and depends heavily on your use case.
We can rely on the ERC20 balanceOf method to check the balance of each address we add to our list. We can do this as soon as we find a new address to add to our set, and have a holder with its balance ready. However, this means that we are making an additional request for each token holder and that we also need to re-run this query whenever we see a new block with new transfers.
We can use the fact that the balance of any address in an ERC20 contract can be determined by just looking at the transfer events. Since we are already querying them, we can track all movements to and from each address and update them accordingly. This will require a bit more logic on our end, but does not need any extra requests to the Ethereum network. Its downside is that we cannot rely on the balance of an address until we have finished scanning until the latest block in the chain.
Reducing an event to update the balances of each holder. Here balances is an object that replaces the former set of holders. Note that we are excluding the zero address both as sender and recipient, since transfers from it represent minting events and transfers to it represent burns
Polling for new blocks and retrieving any new transfer events. The processNewBlocks function will query the latest block and call into processBlocks with the new blocks range, while the start function kicks off an infinite loop that constantly polls and then sleeps for 1 second
An important detail of our implementation is that we will not process up to the latest block, but only until a certain number of blocks ago. This ensures that any transfer events we have processed are confirmed and will not be rolled back as part of a reorganization. We will later look into strategies for querying up to the most recent block and handling reorgs as we detect them.
Sharing Our Data
Simple express server that exposes the balances from the indexer in an HTTP endpoint. The mapValues lodash helper is used to format the BigNumber values before serializing them in JSON. Make sure to get an Infura token to set the API_TOKEN variable
Querying the express endpoint to retrieve the token balances. We are using the jq3 utility just to pretty-print the JSON output
This naive implementation makes use of the fact that all indexed data is stored in the indexer instance. Keep in mind that for actual deployments you will want to store all data in a separate datastore, such as a relational database. This will allow you to run queries directly to the database and decouple the web server and indexer processes so they run separately. It will also allow you to add aggregations, filters, sorting, and paging from the client as needed: returning a list of half a million token holders in a single request may not fare well in some scenarios.
Handling Chain Reorganizations
Up to this point, we have avoided the issue of chain reorganizations by only processing transfers that can be considered to be finalized, in other words, transfers that occurred enough blocks ago that the chance of those blocks being removed from the chain is negligible. We will now remove this restriction and see how we can safely process the latest events by reacting properly to a reorg.
Using Subscriptions
Reverting a transfer event in our list of balances. The logic here is the reverse of that in the reduceEvent method
Using subscriptions for monitoring new events and tracking removed ones due to reorganizations. The data handler fires whenever there is a new event and the changed one when the event is removed from the chain due to a reorganization
However, this approach has a major downside. If the websocket connection to the node is lost when a reorg occurs, our script will never be notified of the removed events. This means that we will not roll back the reverted transfers and end up with an invalid state. Another issue is that subscriptions only return new events, so if any blocks are minted between the processBlocks call and the time the subscription is installed, we may miss some events. Let’s try a different, simpler approach then. We will first need to manually detect when a reorg has happened.
Detecting a Reorganization
A reorganization occurs when a chain fork gathers more accumulated hash power than the current head, and that fork becomes the official chain. This can happen if different sets of miners work on different forks.
This means that in a reorganization, one or more blocks (starting backward from the current head) will be replaced by others. These new blocks may or may not contain the same transactions as the previous ones and may also be ordered differently,4 yielding different results.
We can detect a reorganization by checking the block identifiers. Recall from Chapter 3 that each block is identified by its hash. This hash is calculated from the block’s data and the hash of the previous block. This is what constitutes a blockchain in its essence: the fact that each block is tied to the previous one. And this means that an old block cannot be changed without forcing a change in the identifiers of all subsequent blocks.
This is exactly what allows us to easily detect a reorganization. When the hash of a block at a given height changes, it means that that block and potentially other blocks before it have changed as well. We can then just monitor the latest block we have processed, and if its hash changes at any point, we then scan backward for other changed hashes, until we detect a common ancestor (Listing 6-10).
Updated processNewBlocks function that checks for reorgs on every iteration. The function undoBlocks (Listing 6-12) should undo all transfers related to removed blocks, returning the most recent block not affected by the reorganization
Note that we are now keeping track of not just the latest block number but also its hash. Whenever we start a new iteration, we check if the hash for the block at that same height changed. If it did, then we have stumbled upon a reorganization and must revert all changes from the removed blocks.
Reverting Changes
When the reorganization is detected, we need to revert any transfers we have processed from the blocks removed. To do that, we first need to keep track of which transfers we processed on each block (Listing 6-11).
Keeping track of transfer events per block. This function is called from reduceEvent. Note that this function must be invoked in order as new events are being processed to ensure the list of blocks remains sorted with the most recent block at its end
Walk backward the list of processed events and undo all transfers. This function must return the most recent block that was not removed in the reorganization, so the script can reprocess the chain from it. The function undoTransfer is analogous to the one presented in the subscriptions subsection earlier in this chapter
- 1.
When processing a transfer event, save its block number and hash on a list, appending the most recent ones at the end.
- 2.
When checking for new blocks, verify if the hash of the latest block we have processed has changed.
- 3.
If it did change, undo each transfer event that happened on each changed block, starting from the most recent one. When we reach an unchanged block, stop.
- 4.
Reset the latest block to the unchanged block, and resume processing from there.
While these changes have introduced much complexity to our solution, they ensure that its state does not fall out of sync because of reorganizations. It will depend on your use case how you choose to handle them: ignoring the most recent blocks until they become confirmed, using subscriptions to let the node track removed events assuming a stable connection, or implementing a client-based design similar to this one.
Unit Testing
Up until now, we have overlooked a critical aspect of software development: tests. While testing is not substantially different in Ethereum than in other applications, we will use our indexer example to introduce some useful techniques specific to blockchain testing.
Choosing a Node
The first decision lies in choosing which node to use for our tests. Recall from earlier chapters that we can work with ganache, a blockchain simulator, or on an actual node, such as Geth or Parity, running on development mode. The former is lighter and provides additional methods for manipulating the blockchain state which are useful in unit tests. On the other hand, using an actual node will be more representative of the actual production setup for your application.
A good compromise is to use ganache with instant seal for unit tests, while a Geth or Parity development node can be used for end-to-end integration tests, running with a fixed block time. This allows more fine-grained control in the unit tests and a more representative environment on integration.
Note
Whether unit tests should be allowed to call external services is typically a contentious issue in software development. In traditional applications, some developers prefer to set up a testing database to back their unit tests, while others stub all calls to it in order to test their code in isolation, arguing that a unit test must only exercise a single piece of code. Here, we will side with the former group and will connect to a ganache instance in our unit tests. Either way, this is only a matter of semantics on what we understand for a unit test.
Testing Our Indexer
We will now write some unit tests for our indexer. We will use ganache as a back end, mocha5 as a test runner, and chai6 with the bignumber plugin7 for writing assertions.
ERC20 token contract with a public minting method, which allows anyone to create new tokens. Do not use in production!
Boilerplate code for a test suite for the Indexer. It initializes a new web3 instance and deploys an instance of the ERC20 token contract. Note that some require statements were removed for brevity
Test for checking that transfers from minting are correctly processed. We initialize a new Indexer instance for the newly deployed token contract, mint some tokens for an address, and execute the indexer to check the result
Starting a ganache instance and running the test suite respectively. Each command should be run on a different terminal
However, if we run our test, it will fail – the indexer will not pick up any balance for the holder address. This is because we built our indexer to ignore any information from the latest blocks and only consider transfers after a certain number of confirmations.
Helper method to instruct ganache to mine a certain number of blocks. The code in mineBlocks fires a chosen number requests in parallel and returns when all of them have succeeded
We can now add a call to mineBlocks right before instructing our indexer to process new blocks in our test, run it again, and see it pass.
Using Snapshots
We can now write more tests that exercise other scenarios of our indexer. However, in any good test suite, all tests should be independent from each other, which is not the case here since we are deploying a single instance of our ERC20 contract. This means that any minting or transfers that occur in a test will be carried on to the following ones.
Helper functions for taking a new snapshot, which returns the snapshot id, and for reverting to a specific snapshot given its id
Taking a new snapshot before each test, and reverting back to it once the test has ended. Note that we cannot revert to the same snapshot more than once, so we need to create a new one for each test
Another interesting use case of snapshots is for testing reorganizations. Triggering a reorganization on a regular Geth or Parity node is complex, as it involves setting up our own private node network, and disconnecting and reconnecting nodes to simulate the chain split. On the other hand, testing a reorganization on ganache is much simpler: we can take a snapshot, mine a few blocks, and then roll back and mine another set of blocks with a different set of transactions.
Caution
This will not be equivalent to an actual chain reorganization, since subscriptions will not report any removed events. Nevertheless, since our indexer relies on plain polling for detecting any changes, using snapshots will do in this case.
Note
These testing techniques can be used to test not only components that interact with smart contracts but the smart contracts themselves. It is a good practice to have a good test coverage in any code you deploy to the network, especially considering you will not be able to modify it later (in most cases9) to fix any bugs.
A Note on Centralization
For the first time in this book, we have introduced a server-side component to our applications. Instead of just building a client-side-only app that connects directly to the blockchain, we now have a new centralized dependency that is required for our application to run. It can be argued that our application thus no longer qualifies as a decentralized app, as it blindly trusts data returned by a server run by a single team.
Depending on the kind of data being queried, this can be alleviated by having the client verify part of the data supplied by the server. Following with the ERC20 balance example, a client could request the top 10 holders of a token from the indexing server and then verify against the blockchain that their balances are correct. They may still not be the top 10 – but at least the client has a guarantee that the balances have not been tampered with.
However, this is not a solution to the problem, as not all data can be verified against the blockchain without having to re-query all events. Furthermore, our application now depends on a component that could go down at any time, rendering it unusable.
Let’s discuss two different approaches to this problem. The upcoming discussion applies not just to indexing but also to any other off-chain service, such as storage or intensive computation.
Decentralized Services
One approach to this problem is to look for decentralized off-chain solutions. For instance, at the time of this writing, a GraphQL interface to blockchain data named EthQL10 is under review. This could be added as part of the standard interface for all Ethereum nodes, allowing easier querying of events from any client. As another example, thegraph11 is a project that offers customized GraphQL schemas for different protocols. They rely on a token-incentivized decentralized network of nodes that keep such schemas up to date and answer any queries from users.
While elegant, these decentralized solutions may not yet be ready for all use cases. Decentralized indexing or computing solutions are still being designed. And even when ready, a generic decentralized solution may not always cater for the specific needs of your application. With this in mind, we will discuss a second approach.
Centralized Applications
An apparent non-solution to the problem is to just accept that applications can be centralized. This may come as a controversial statement, having focused strongly on decentralized applications throughout the book, but it does not need to be.
It can be argued that the strength of a blockchain-based system lies not in the application but in the protocol layer. By relying on the chain as the ultimate source of truth and building open protocols that run on it, any developer can freely build an application to interact with such protocols. This gives a user the flexibility to move between different apps that act as gateways to their data in a common decentralized protocol layer. Decentralization is then understood as the freedom of being able to pack up and leave at any time while preserving all data and network effects that arise from the shared decentralized layers.
This rationale gives us as developers the freedom to build solutions as powerful as we want in the application level by leveraging any number of centralized components for querying, storage, computing, or any other service we could need. These solutions have the potential to deliver a much richer user experience than fully decentralized ones.
As you can imagine, centralization is a contentious issue in the Ethereum development community. There is no right or wrong answer to this topic, and the discussion will probably keep evolving over time. Regardless of the approach you take, be sure to understand the pros and cons of it and weigh them against the requirements of the solution you are looking to build.
Storage
We will now focus on another problem in Ethereum: storage. Storing data on the Ethereum blockchain is very expensive, costing 625 gas per byte (rounded up to 32-byte slots), plus a base 68 per non-zero byte just for sending that data to the blockchain. At 1GWei gas price, this means that storing a 100kb PNG will cost about 0.07 ETH. On the other hand, saving data in logs for off-chain access is much cheaper, costing 8 gas units per byte, plus the base 68 for sending the data (this amounts to 1/10th of the cost for our sample image), but is still expensive as we start scaling up. This means we need to look into alternative solutions for storing large application data.
Off-chain Storage
Following a similar approach to the one we used for indexing, we can set up a separate centralized server that provides storage capabilities. Instead of storing the actual data on the blockchain, we can store the URL from which we can retrieve that data off-chain. Of course, this approach is only useful for data that is not needed for contract execution logic, but rather for off chain purposes - such as displaying an image associated to an asset.
Along with the URL, we should also store the hash of the data stored. This allows any client that retrieves that information to verify that the storage server has not tampered with it. Even though data may be rendered inaccessible due to the storage server going down, the hash guarantees that any client can check that provided the data is correct. In other words, we may be sacrificing the availability of our data by moving it off-chain, but not its integrity.
We will pick up our ERC721 minting application from the previous chapter as a sample use case to illustrate how this may be implemented and store metadata associated to each token (such as a name and description) in an off-chain site.
ERC721 Metadata Extension
Specification of the tokenURI method required by the ERC721 metadata extension
Updated ERC721 contract that accepts an associated tokenURI when minting a token. Recall that this contract required an amount of ETH proportional to the ID of the token for fun and profit
Let’s modify our application to save metadata in a storage server and add the URL with the data to the token contract, along with the content hash.
Saving Token Metadata
Updated mint method to handle token metadata. Note that this also requires modifying the Mint component by adding the title and description inputs, so the user can provide these values
Saving data to a local storage server, using the data hash as an identifier
In this example, the server that receives the POST request is a NodeJS process that will accept arbitrary JSON data at an URL path, save it locally in a file, and then serve it upon a GET request. In an actual application, you may want to rely on real storage services.
Loop through all current tokens and load their metadata by querying the contract to retrieve the URL where it is stored and then the URL to retrieve the actual metadata.
Note that the metadata integrity is verified by calculating its hash before accepting it. You can test it by modifying the saved metadata in your local filesystem (look in the server/data folder of the project) and checking that no metadata is displayed for the modified token.
This allows us to associate additional data to each non-fungible token when we mint it, which can be actually leveraged by any application that displays token information. In that regard, you can think of token metadata as the equivalent of opengraph metadata13 for a regular HTML page.
Interplanetary Storage
As an alternative to centralized storage solutions, we can store our data in the InterPlanetary File System (IPFS). IPFS is “a distributed system for storing and accessing files, web sites, applications, and data.”14 In other words, it acts as a decentralized storage system.
What is IPFS?
IPFS acts as a peer-to-peer content distribution system. Any node can join the network, from a dedicated IPFS server to a regular user in their home computer. Whenever a user requests a file from the network, that file is downloaded from the nearest node that has the file and is made available for other users to download from this new location. Availability of a piece of content depends on having enough users willing to store the relevant files.
Any data unit in IPFS is not identified by its location, as it is in most traditional file systems, but by its content. When requesting a file from the IPFS network, you address it by its identifier, which is nothing else than a hash of the content. This guarantees integrity of all content, since a client will validate all content received against its identifier. It also allows a client to request a file without needing to know where it is stored in the network.
This implies that content in IPFS is immutable. Any changes to it require storing a new copy entirely under a new identifier. The previous file will be retained by the network as long as someone keeps a copy of it.
All these properties make IPFS a good match for blockchain applications. The content hash verification we manually built in the previous section is already provided by the protocol itself. And by indexing the content identifier in the smart contract instead of its location, we can decouple the blockchain data from any centralized content provider.
Note
IPFS relies on users willing to save and share content for availability, which may make it look like a poor choice for building critical applications. Nevertheless, data availability for your application can be provided by relying on an IPFS pinning service. These are services that act as traditional storage servers, but they take part on the IPFS network by making your content available to all users – for a fee, that is.
Using IPFS in Our Application
To enable IPFS support in our application, we first need to connect to an IPFS node. This is similar to connecting to an Ethereum node in order to access the Ethereum network, with the difference that we do not need a private key or a currency to write any data to IPFS.
We can either host our own IPFS public node as part of our application or rely on a third-party provider. As an example, Infura provides not only Ethereum public nodes but also IPFS nodes, meaning we can use their IPFS gateway directly.
However, it is also possible that a user is running their own IPFS node in their computer. IPFS provides a browser extension, the IPFS companion,15 that connects to a local node and makes the browser IPFS-enabled. This includes adding support for ipfs links and injecting an ipfs object to the global window object in all sites – much like Metamask adds an ethereum provider object to all sites.
Note
There is also the option of running an IPFS node within your own web site. The js-ipfs library provides a browser-compatible implementation of the entire IPFS protocol, so you can start an IPFS daemon as a user accesses your app. However, this can make your application much heavier, and the in-app IPFS process is not as stable as a dedicated one. Because of this, the suggested method for interacting with the network is to use the IPFS HTTP API to connect to a separate node.
Creating a new ipfs client instance. We first check whether the global object, injected by the companion extension, is available. If not, we fall back to a connection to the Infura IPFS gateway
Hosting Our Application on IPFS
IPFS can be used not only to store our application data but also the application itself, for maximum decentralization. All our application’s client-side code can be uploaded to IPFS and served from there. But how can our users access it from a regular browser? Or address it without having to specify a hash?
You can access an older version of the ipfs.io web site at the following addresses directly on your browser via any of the public gateways listed. Since the ID of the content is its hash, you can be certain that all gateways will serve exactly the same object
The second problem, having user-friendly names for IPFS sites, can be solved using DNSLink. DNSLink is a process for mapping DNS names to IPFS content using DNS TXT records.
Let’s say we want to map our site in IPFS to the domain example.com. By adding a TXT record to _dnslink.example.com with the value dnslink=/ipfs/CID, any IPFS gateway will automatically map any requests to /ipfs/example.com to the specified content.
A user makes a request to example.com.
The DNS query answers with a CNAME to gateway.ipfs.io.
The user sends a request to the IP of gateway.ipfs.io using example.com as a Host header.
The gateway makes a DNS TXT query for both example.com and _dnslink.example.com and obtains an IPFS CID as response.
The gateway transparently serves the content from IPFS to the end user.
Another level of indirection can be introduced by relying on IPNS, the InterPlanetary Name System. This system allows you to have mutable links that refer to IPFS content, though IPNS links are also hashes. You can then have your DNSLink point to your IPNS name, instead of the IPFS ID, and update your site by just updating the IPNS link to a new version of your content. This saves you from having to modify your DNS TXT records whenever you deploy a new version of your site.
Summary
We have gone through two problems that arise when building non-trivial Ethereum applications: how to perform complex queries on chain data and how to store large amounts of data. Both of these problems require looking outside Ethereum itself and relying on other services – either centralized or decentralized. We also looked into how to write unit tests that interact with the Ethereum network, and some strategies for handling chain reorganizations.
Besides the specific problems or strategies detailed in this chapter, perhaps the most important takeaway is that of defining the decentralization demands of your application. While we are used to traditional non-functional requirements such as performance, security, or usability, blockchain apps need to take decentralization into account as well. Decentralization is the core reason of why a blockchain is used in the first place, so it makes sense to pay special attention to it.
Like other non-functional requirements, decentralization is not binary. Our application can have different degrees of decentralization, depending on which components of the stack are centralized, how much trust we place on commercial third parties as opposed to peer-to-peer networks, or how much control our users have over their own data.
For instance, a financial application can be purely centralized except for the underlying protocol that manages the users’ assets, allowing high performance and good user experience, and at the same time ensuring its users’ that they can part with their assets at any point in time. On the other hand, an application focused on bypassing censorship may need to be purely decentralized to not risk being shut down by its hosting provider.
Different applications will have different requirements. It is important that you define yours, so you know which solutions you have access to, and build the architecture of your application accordingly.