Building Block Explorers (Part 1) - Multi-chain Block Transaction Explorer

In this tutorial, you'll learn how to build a multi-chain block transactions explorer with the GoldRush API in less than 30 minutes.

Blocks lie at the heart of blockchains. And what are blocks? They’re simply a series of bundled transactions, nothing more, nothing less. It is therefore not surprising that a key on-chain data primitive is that of a transaction, grouped together into numbered blocks. In this series, we’ll dive straight into understanding the data that is produced by blocks, and try our hands at building a couple of views that we typically see in a block explorer: transactions by block, latest transactions, transactions of an address, and individual transactions.

Where Does GoldRush Come In?

GoldRush is a read-only API that has indexed every single block since genesis for 100+ blockchains, including Ethereum, Polygon, Avalanche, Arbitrum, Optimism, etc. This means that you can easily query for all the transactions in any block of your choosing. The transactions data response we return is super data rich - each item in the response can contain up to 48 unique fields, and we typically return as many items as there are transactions in 1 API call (very often going up to the 100s).

A Fun Exercise

Before we begin, let’s do a fun exercise. Let’s say I am an internet archeologist, and I want to know what the data from the first-ever block on Ethereum look like. How can we go about getting that?

For this, you can use GoldRush’s Get all Transactions in a block endpoint.

Let’s proceed to our API reference and input 1 as the blockHeight and click Run.

We see the following response returned on the right. And from this, we can see that the data from the first-ever block is…nothing!

Wait - what is going on here? Is there something wrong with the GoldRush API?

Let’s try to input the next few numbers and see what we get.

A blank wall of nothingness, all the way up to 100, and even 1000, and 10000.

What is going on?

Well, a quick googling will tell us that the first ever transaction that happened on the Ethereum chain was at block 46147. 😆

Fun fun fun facts.

Now, querying for block 46147 with the API reference…

Our persistence is finally rewarded. We see 1 transaction item indeed - the golden, first-ever transaction on the Ethereum chain. What data does it contain? Expanding it to a new window by clicking the top right arrow…

Lo and behold, the data from the sacred transaction. It has a transaction hash of 0x5c504…(tattoo idea, anyone?), made from the ‘0xa1e’ address, and has a value of 31337 wei, which is…0.000000000000031337 ETH. The transaction fee, on the other hand, was 1.05 ETH.

Talking about a ridiculously bad transaction.

In any case, the legend has been made, and we shall proceed with the real agenda of today: building a multi-chain block transactions explorer.

Notice that I said ‘block transactions’ explorer: this means that it’ll fetch all the transactions of blocks and display them. Notice I also mentioned ‘multi-chain’: luckily for you, GoldRush has done the hard work of indexing 100+ chains so that you don’t have to worry about any differences in chain architecture. What you build for Ethereum will work perfectly for Avalanche, Polygon, BSC, Oasis, Arbitrum, and so on. This is exactly what you’ll see in a bit.

By the end of this guide, you would have built the following component:

By clicking the chain selector dropdown on the left, you would be able to select and see the latest 5 blocks from up to 100 chains as well as the transactions contained within. Our table also boasts an ‘events’ column, which are the events that are produced from that transaction, similar to what you’ll see when you go to the logs page on Etherscan.

Knowing the events produced gives you great insight into what goes on beneath the hood of a transaction - it’s a measure of transaction complexity, something we can glean from our table much easier than with Etherscan.

Ready? Let’s begin.

(Estimated time to follow along: 20mins)

Prerequisites

  • Basic familiarity with React.

  • Some familiarity with the Antd UI library, which we’ll be using extensively.

  • Some HTML/CSS knowledge.

  • Fetching data using APIs.

Tutorial: Building a Block Explorer Component

1

Initialize the Project

Run the following commands to initialize your React starter kit:
git clone https://github.com/xiaogit00/building-block-explorers.git
cd building-block-explorers
git checkout part1-block-transactions
npm i
1.1
Then, be sure to add your API key in a .env file, like so:
REACT_APP_APIKEY='ckey_8bdxxxxxxxxxxxxxxxxxxxxxxxxxx99'
1.2
Finally, run:
npm start
1.3
You should be able to see the following on your localhost:
This is just a template to get you going. Everything displayed on the screen is hard-coded dummy data. As usual, styling for the entire project is done in App.css, and invoked in the JSX with classNames.
The first step for us is to get the data we want.
(If you just want to see the end state of this project, you can switch to the part1-block-transactions-end branch, run it, and examine the code yourself (be sure to supply your API key in the .env file). If not, follow along for a guided walkthrough.)
2

Fetching the Data for the Latest Blocks

First, I’ll make a call to the Get a block endpoint to get the latest block on the Ethereum chain. Then, using this block number, I’ll make 4 more calls to get the information of the previous 4 blocks. Once that is done, I’ll set the state of the blocks state variable by calling setBlocks, and populating it with the 5 block responses. This will allow me to render the 5 blocks seen above. The response for each call to Get a block item looks like this:
As you can see, it’s pretty lean, giving us the height and the signed_at variable.
{
  "updated_at": "2023-06-09T03:55:36.232778591Z",
  "chain_id": 1,
  "chain_name": "eth-mainnet",
  "items": [
    {
      "signed_at": "2023-06-09T03:54:35Z",
      "height": 17440146
    }
  ],
  "pagination": null
}
2.1
Here is the full implementation of the above:
function App() { const [selectedChainName, setSelectedChainName] = useState('eth-mainnet') const [loadingBlocks, setLoadingBlocks] = useState(false) const [blocks, setBlocks] = useState([...Array(5).keys()]) const [selectedBlock, setSelectedBlock] = useState(null) useEffect(() => { setLoadingBlocks(true) let latestBlock const blocksEndpoint = (blockNumber) => `https://api.covalenthq.com/v1/${selectedChainName}/block_v2/${blockNumber}/` const config = { auth: { username: process.env.REACT_APP_COVALENT_APIKEY } } axios.get(blocksEndpoint('latest'), config) .then(res => latestBlock = res.data.data.items[0]) .then(() => { const block2 = axios.get(blocksEndpoint(Number(latestBlock.height) - 1), config) const block3 = axios.get(blocksEndpoint(Number(latestBlock.height) - 2), config) const block4 = axios.get(blocksEndpoint(Number(latestBlock.height) - 3), config) const block5 = axios.get(blocksEndpoint(Number(latestBlock.height) - 4), config) axios.all([block2, block3, block4, block5]) .then(axios.spread((...res) => { setBlocks([latestBlock, res[0].data.data.items[0], res[1].data.data.items[0], res[2].data.data.items[0], res[3].data.data.items[0], ]) setLoadingBlocks(false) })) }) .catch(err => console.log(err)) }, [selectedChainName]) return ( ... ) } export default App;
Notice we’ve created 2 more state variables at the top. These will be referenced in the JSX below.
2.2
Once you’ve fetched the data, replace the hard coded values within the JSX (e.g. #1999308, 1 sec ago, and block #1990322) with the following:
<div className='blocks'> {blocks.map(block => ( <div className='block' key={block} onClick={() => setSelectedBlock(block.height)}> <div><img src='<https://res.cloudinary.com/dl4murstw/image/upload/v1686185288/virtual_awy3m3.png>' alt='block' height='126' width='126'/></div> <div className='blockNumber'>{loadingBlocks ? <Spin />: '#' + block.height }</div> <div className='time'>{loadingBlocks ? <Spin /> : ((Date.now() - new Date(block.signed_at))/1000).toFixed(0) + ' secs ago' }</div> </div> ))} </div> ... <div className='title'>{!selectedBlock ? null : 'block #' + selectedBlock}</div> <div className='transactionsComponent'>
2.3
Heading back to your browser, you should be able to see this after the spinners load:
Congratulations, you’ve fetched the latest blocks!
3

Displaying Transactions of Each Block.

We’ll want to display a transactions table that contains all the transactions of the block on click of a selected block.
To do that, first, let’s create a <Transactions /> component inside the components/Transactions.js file (we’ll keep it empty for now). Then, let’s import it into App.js and render it in the JSX like so:
// App.js
import Transactions from './components/Transactions';
...
return (
...
<div className='transactionsComponent'>
  {selectedBlock ? <Transactions chainName={selectedChainName} selectedBlock={selectedBlock}/> : <Empty description="No blocks selected"/>}
</div>
...
)
3.1
We’ll update the div with className of ‘block’ to set the selectedBlock variable on click:
This will ensure the selectedBlock is passed into Transactions component when we click the block on the page above. Nice!
// App.js
<div className='block' key={block} onClick={() => setSelectedBlock(block.height)}>
3.2
Next, we will define the <Transactions /> component like so:
// Transactions.js import React, { useState, useEffect } from 'react' import { transform } from '../utils/transform' import { Table, Tag, Popover, Descriptions, Spin } from 'antd' import { blockExplorerURLs } from '../utils/blockExplorerURLs' import axios from 'axios' const Transactions = ({ chainName, selectedBlock }) => { const [txns, setTxns] = useState([]) const [isLoading, setIsLoading] = useState(false) const blockExplorer = blockExplorerURLs.filter(item => item.chainId[1] === chainName) const blockexplorerURL = (blockExplorer?.length) ? blockExplorer[0].url : '<https://blockscan.com/>' useEffect(() => { setIsLoading(true) const blockTxnsEndpoint = `https://api.covalenthq.com/v1/${chainName}/block/${selectedBlock}/transactions_v3/` const config = { auth: { username: process.env.REACT_APP_COVALENT_APIKEY } } axios.get(blockTxnsEndpoint, config) .then(res => { const txns = res.data.data.items const transformedTxns = transform(txns.filter((txn) => txn.log_events?.length < 30)) setTxns(transformedTxns) setIsLoading(false) }) .catch(err => console.log(err)) }, [chainName, selectedBlock]) if (isLoading) { return <Spin /> } if (txns.length) { return ( <> <Table dataSource={txns} className='table' columns={columns(blockexplorerURL)} rowKey="txnHash" /> </> ) } } export default Transactions
As you can see, all we’re doing here is that we’re making a call to the blockTxnsEndpoint, supplying the chainName and selectedBlock as arguments, and txns state variable.
3.3
Now within the axios call, you’ll notice that I’ve applied a transform function to the returned response. This step helps create a new field in the response objects called ‘events’, which contains a list of event names contained within the transaction. It’s ultimately these event names that are rendered. You’ll find this function in your utils folder.
However, if you npm start now, this won’t work. We’ve yet to define a few important dependencies for our Ant Design table: columns, and a truncateEthAddress function.
Columns are where you supply the definition of what columns (and data) to render, as well as how you render them. I won’t get into details here - you can check out the documentation yourself. For now, let’s just copy those 2 dependencies and paste them into the Transactions.js file for simplicity.
//Transactions.js ... export default Transactions const columns = (blockexplorerURL) => ([ { title: 'Txn Hash', dataIndex: 'tx_hash', key: 'tx_hash', width: '15%', render: (text) => (<><a href={blockexplorerURL + 'tx/' + text} target="_blank" rel="noreferrer">{text.slice(0,15)}</a>...</>) }, { title: 'Events', dataIndex: 'events', key: 'event', width: '25%', render: (_, record) => { if (record.log_events.length > 0) { return ( <> {record.log_events.map((log_events, index) => { if (log_events) { let color = log_events.name.length > 5 ? 'geekblue' : 'green' if (log_events.name === 'Transfer') { color = 'volcano' } const content = ( <> <Descriptions size={'small'} column={1} title={log_events.name + ' Event'} bordered> {Object.entries(log_events).map((item) => { return ( <Descriptions.Item key={item[0]} label={item[0]}> {item[1]} </Descriptions.Item> ) })} </Descriptions> </> ) return ( <Popover key={index} content={content} placement="rightBottom" trigger="click"> <Tag color={color} key={index} style={{ cursor: 'pointer' }}> {log_events.name.toUpperCase()} </Tag> </Popover> ) } return null })} </> ) } } }, { title: 'Block', dataIndex: 'block_height', width: '5%', key: 'tx_hash' }, { title: 'Date', dataIndex: 'block_signed_at', width: '15%', key: 'tx_hash', render: (text, record) => { const newDate = new Date(record.block_signed_at) const timeString12Hr = newDate.toLocaleTimeString('en-US', { timeZone: 'UTC', hour12: true, hour: 'numeric', minute: 'numeric', second: 'numeric' }) const day = newDate.toLocaleString('en-US', { day: 'numeric', month: 'short' }) return ( <> <div> {day}, {timeString12Hr} </div> </> ) } }, { title: 'From', dataIndex: 'from_address', width: '10%', key: 'from_address', render: (text) => (<> <a href={blockexplorerURL + 'address/' + text} target="_blank" rel="noreferrer">{truncateEthAddress(text)} </a> <img src="<https://res.cloudinary.com/dl4murstw/image/upload/v1685502645/copy_prfttd.png>" alt="copy" height="16px" onClick={() => navigator.clipboard.writeText(text)} className='copyText' /> </> ) }, { title: 'To', dataIndex: 'to_address', width: '10%', key: 'to_address', render: (text) => (<> <a href={blockexplorerURL + 'address/' + text} target="_blank" rel="noreferrer">{truncateEthAddress(text)} </a> <img src="<https://res.cloudinary.com/dl4murstw/image/upload/v1685502645/copy_prfttd.png>" alt="copy" height="16px" onClick={() => navigator.clipboard.writeText(text)} className='copyText' /> </> ) }, { title: 'Value', dataIndex: 'value', width: '15%', key: 'value', render: (text) => Number(text) !== 0 ? (Number(text)/10**18).toFixed(8) + ' ETH': 0 + ' ETH' }, { title: 'Fees', dataIndex: 'fees_paid', width: '15%', key: 'fees_paid', render: (text) => Number(text) !== 0 ? (Number(text)/10**18).toFixed(8) + ' ETH': 0 + ' ETH' }, ]) const truncateRegex = /^(0x[a-zA-Z0-9]{4})[a-zA-Z0-9]+([a-zA-Z0-9]{4})$/ const truncateEthAddress = (address) => { const match = address.match(truncateRegex); if (!match) return address; return `${match[1]}…${match[2]}`; };
In these columns, especially for Events, I’ve relied on a few Antd components, such as Popover, Tags, and Descriptions, to display the data in the way I want.
You’ll also notice that there’s also a blockExplorerURL variable. This is a helper array defined in utils that matches a chain name or ID to their various block explorers so that the links we used are correctly defined. This too is in the utils folder.
3.4
By this point, your component will look like the state in this branch. Notice how the multi-chain dropdown is defined here:
//App.js ... const handleChange = (value) => { setSelectedChainName(value) setSelectedBlock(null) setLoadingBlocks(true) }; const chainOptions = blockExplorerURLs.map(item => ({ value: item.chainId[1], label: item.chainId[1] })) return ( ... <Select defaultValue="eth-mainnet" style={{ width: 156 }} onChange={handleChange} options={chainOptions} /> )
3.5
Now, running npm start...
3.6
And when you select another chain…

There you have it: a super powerful multi-chain block explorer in around 20 minutes.

Clicking on each of the event tags even opens up a popover that shows us the details of the data contained within the event.

All in a Day’s Work

That was quite a lot! Hopefully, you’ll be able to see how easy it is to spin up your own multi-chain block explorer for your project in just around 20 minutes, with the help of the GoldRush API.

As always, GoldRush’s API is free to try, if you haven’t already, get your FREE API key and give it a spin today!

Happy buidling.

Read more