Build your first DeFi dashboard with Subsquid

Massimo Luraschi
squid Blog
Published in
20 min readMay 4, 2023

--

Summary

Given the positive feedback received for this past article I had in mind for quite a while to create a similar one, with a different subject. I settled for a DeFi dashboard, because, along with NFT marketplace and gallery, they cover a good portion of the most common use cases for an indexing middleware.

I thought this topic would be a good introduction for Web3 beginners, provide some inspiration to hackathon participants, as well as providing a good introduction to Subsquid for more expert developers, and how this tech can be used in a familiar context.

Here’s what we’ll be building in this walkthrough:

Our finished DeFi dashboard

Introduction

When there's a part of a project that is necessary, but not the actual core of it, not the primary focus, I am a big fan of leveraging what has already been created and integrating it, building on top of it. This way, I can get close to the objective much faster, it removes part of the complexity, and I genuinely think that this is the true strength of the Open Source community.

I qualify myself as "below average" (being generous with myself here) in regards to frontend development, especially when it comes to make a web app look good. For this reason, I set out to look out for dashboard examples and tutorials, and that's when I came across this one.

I liked the end result and the overall style, I also thought I could make use of pretty much all pages and it was as close as possible to what I wanted to obtain in the end, which meant I had to do minimal changes to turn it into a DeFi dashboard.

Project and setup

The video linked above does a pretty good job explaining how to get the frontend up and running and how to correctly architecture and style the various components. Because of this, I thought it best to avoid repetition and start exactly where the original tutorial ended. I have created a start branch in the repository for this. The final product of this tutorial can, instead, be found in the main branch.

Note: I have coded my project starting from a TypeScript NextJS app, so some things are going to look a bit different and that is why I suggest using my repository as a starting point. This is the repository of the original tutorial:

https://github.com/fireclint/nextjs-dashboard-tailwind

If you want to dive deeper into how this was built and learn a bit more about NextJS, or Tailwind, I advise watching the tutorial, and then come back here to find out how we can add dynamic on-chain data to it.

The original tutorial, in fact, uses hard-coded data and a JSON file to provide numbers and information shown on the page. In this walkthrough we are going to build a Squid project, which we are going to use to Extract, Transform, Load (and Query) data from an actual on-chain DeFi project.

While preparing this project I was a bit short on ideas regarding the DeFi part, I didn’t know what I could use as an example, or if, due to lack of options, I should develop and deploy a smart contract for this purpose.

It just so happened that around the same time, I had to index some data for the Moonwell protocol for a different assignment, so in the end, to the occasion presented itself and the choice was made.

So for some of the data that will eventually be displayed in the dashboard, I took inspiration from Moonwell app page:

Squid ETL

In order to make my chart dynamic and show real data, I created a new squid project. The project created for this article is hosted on GitHub in this repository.

Note: the squid project is separate from the React app. Think of it as the backend for the dashboard's frontend.

It is hosted in a separate repository, and when writing this tutorial, it has been generated in a separate directory from the dashboard project.

Because Moonbeam is an EVM chain, just like Ethereum, I chose the evm template.

sqd init moonwell-squid -t evm

Which should create these files and folders:

Initial structure of the squid ETL project

Schema

Once I had an idea of what data I wanted to show, I set off to edit the schema.graphql file and configure what queries and fields I needed. Moonwell has multiple liquidity pools; for this project I only focussed on GLMR, but I designed the schema to work if the project is expanded to include more, that's why I have a Market entity, where its ID is the address of the token used for the liquidity pool.

I then grouped up every interaction of users with the token itself (be it providing liquidity, asking to morrow some, repaying the lended sum, or being liquidated, …) into a MarketOrder entity, and finally, added the bare minimum information to capture users' Accounts, which I wanted to show in a separate page.

Here's the end result, I have left comments to each fields to explain them:

"""
Market stores all high level variables for a cToken market
"""
type Market @entity {
"CToken address"
id: ID!

address: String!

"Name of the cToken"
name: String!

"CToken symbol"
symbol: String!

"Underlying token name"
underlyingName: String!

"Underlying token price in USD"
underlyingPriceUSD: BigDecimal!

"Underlying token decimal length"
underlyingDecimals: Int!

"Borrows in the market"
totalBorrows: BigDecimal!

"CToken supply."
totalSupply: BigDecimal!

"Reserves stored in the contract"
reserves: BigDecimal!

"The cToken contract balance of ERC20 or ETH"
cash: BigDecimal!

"Yearly supply rate."
supplyRate: BigDecimal!

"Yearly borrow rate."
borrowRate: BigDecimal!

"Exchange rate of tokens / cTokens"
exchangeRate: BigDecimal!

"The history of the markets borrow index return (Think S&P 500)"
borrowIndex: BigInt!

"The factor determining interest that goes to reserves"
reserveFactor: BigInt!

"Block the market is updated to"
accrualBlockTimestamp: Int!

"Timestamp the market was most recently updated"
blockTimestamp: BigInt!
}

enum MaketOrderType {
BORROW
LIQUIDATE_BORROW
MINT
REDEEM
REPAY_BORROW
TRANSFER
}

type MarketOrder @entity {
"Transaction hash concatenated with log index"
id: ID!
type: MaketOrderType!
"cTokens transferred"
amount: BigDecimal!
"Account that received tokens"
to: String!
"Account that sent tokens"
from: String!
"Block number"
blockNumber: Int!
"Block time"
blockTime: BigInt!
"Symbol of the cToken transferred"
cTokenSymbol: String!
"Symbol of the underlying asset repaid through liquidation"
underlyingSymbol: String!
"Underlying token amount transferred"
underlyingAmount: BigDecimal
"Underlying cToken amount that was repaid by liquidator"
underlyingRepayAmount: BigDecimal
}

type Account @entity {
id: ID!

"Timestamp of most recent order"
latestOrder: BigInt!

orderType: MaketOrderType!
}

Thanks to Subsquid’s SDK, it’s possible to automatically generate TypeScript models for these types, and use them in our code. It’s only necessary to run this command in the squid’s root folder:

sqd codegen

This will create TypeScript files with the necessary classes in the src/model/generated folder, which are, in fact, an abstraction of tables on the database, and will be used to save data to it.

Smart contract and ABI code-bindings

The next step in Squid development is usually importing the ABI of interested smart contracts into the project, and generate code bindings for them.

  • The GLMR liquidity pool has its main logic handled by this smart contract, which "wraps" the original GLMR token, creates an mGLMR money market.
    I copied the ABI from the contract's page, then pasted it in a file named mGLMR.json in the abi folder.
  • Since I wanted the FIAT price of the GLMR token, I needed a price oracle, luckily, Moonwell has deployed a Chainlink oracle on Moonbeam, at this addres.
    I followed the same step as before, copying the ABI and pasting it to a file named chainlinkOracle.json in the same folder.
  • Last thing I needed is to know when the GLMR price is updated in that Oracle. For this, I have found the Feed contract that emits an event whenever this happens.
    I copied its ABI as well, and pasted it in a file I named feed.json in the same folder as the others.

Once all ABI JSON files have been created, it's possible to use Subsquid's CLI to generate TypeScript bindings for them, using this command:

sqd typegen

This will generate TypeScript files in the src/abi folder, that make it possible to interact with the contracts (decode event, access topic signature, use ethers.js to interact with contract view functions, …) while indexing their data.

Data processing

It's finally time to work with the on-chain data. I wanted to keep this part simple, but it still a little bit verbose. To keep the article short, I am going to list the functionalities that need to be accomplished, and add some code snippets to showcase how they are implemented:

  • configure the indexer to extract data generated by the mGLMR and Oracle Feed smart contracts and filter for the events I am interested in: AccrueInterest, Borrow, LiquidateBorrow, Mint, Redeem, RepayBorrow, Transfer (for mGLMR contract), AnswerUpdated (for Oracle Feed)

const mGLMROracleContract = "0xED301cd3EB27217BDB05C4E9B820a8A3c8B665f9";
const mGLMRPriceFeedContract = "0x62ca6b55f0bb1241c635e3dff51883f8b9f49aa4";
const mGLMRcontractAddress = "0x091608f4e4a15335145be0a279483c0f8e4c7955";

const processor = new EvmBatchProcessor()
.setDataSource({
chain: process.env.MOONBEAM_CHAIN_NODE || "wss://wss.api.moonbeam.network",
archive: lookupArchive("moonbeam", { type: "EVM" }),
})
.setBlockRange({ from: 1277865 })
.addLog(mGLMRcontractAddress, {
filter: [
[
mGLRMEvents.AccrueInterest.topic,
mGLRMEvents.Borrow.topic,
mGLRMEvents.LiquidateBorrow.topic,
mGLRMEvents.Mint.topic,
mGLRMEvents.Redeem.topic,
mGLRMEvents.RepayBorrow.topic,
mGLRMEvents.Transfer.topic,
],
],
data: {
evmLog: {
topics: true,
data: true,
},
transaction: {
hash: true,
},
},
})
.addLog(mGLMRPriceFeedContract, {
filter: [[feedEvents.AnswerUpdated.topic]],
data: {
evmLog: {
topics: true,
data: true,
},
transaction: {
hash: true,
},
},
});
  • Process the batch of mixed events that Subsquid Archive provides at recurring intervals and sort out the different event kinds, decode them separately, store the decoded data in an array of MarketOrder models. I have added an optimization for AccrueInterest and AnswerUpdated to avoid spamming RPC nodes and only save the latest price update
processor.run(new TypeormDatabase(), async (ctx) => {
let latestPriceUpdateEventCtx:
| LogHandlerContext<
Store,
{ evmLog: { topics: true; data: true }; transaction: { hash: true } }
>
| undefined = undefined;
let accrueInterestEventCtxArr:
| LogHandlerContext<
Store,
{ evmLog: { topics: true; data: true }; transaction: { hash: true } }
>[] = [];
let latestIntegerPrice = BigDecimal(0);
let marketOrders: MarketOrder[] = [];

// one full loop to get the latest price in this batch
// solves initialization problem for the market updates
for (let block of ctx.blocks) {
for (let i of block.items) {
if (i.address === mGLMRPriceFeedContract && i.kind === "evmLog") {
if (i.evmLog.topics[0] === feedEvents.AnswerUpdated.topic) {
latestPriceUpdateEventCtx = {
...ctx,
block: block.header,
...i,
};
}
}
}
}
if (latestPriceUpdateEventCtx !== undefined) {
// in reality this should be surrounded with try/catch
latestIntegerPrice = await handleAnswerUpdated(latestPriceUpdateEventCtx)
}

// one other loop over the batch to process market updates, with the price established.
for (let block of ctx.blocks) {
for (let i of block.items) {
if (i.address === mGLMRcontractAddress && i.kind === "evmLog") {
if (i.evmLog.topics[0] === mGLRMEvents.AccrueInterest.topic) {
// add events to be indexed when they are at least 1 hour apart
accrueInterestEventCtxArr.push({
...ctx,
block: block.header,
...i,
})
} else if (i.evmLog.topics[0] === mGLRMEvents.Borrow.topic) {
const { borrower, borrowAmount, totalBorrows, accountBorrows } =
mGLRMEvents.Borrow.decode(i.evmLog);
ctx.log.debug(
`totalBorrows ${totalBorrows}, accountBorrows ${accountBorrows}`
);

marketOrders.push(
handleMArketOrder(
mGLMRcontractAddress,
borrower,
borrowAmount,
i.transaction.hash,
i.evmLog.index,
block.header.height,
BigInt(block.header.timestamp),
MaketOrderType.TRANSFER
)
);
} else if (i.evmLog.topics[0] === mGLRMEvents.LiquidateBorrow.topic) {
// similar logic to Borrow event
} else if (i.evmLog.topics[0] === mGLRMEvents.Mint.topic) {
// similar logic to Borrow event
} else if (i.evmLog.topics[0] === mGLRMEvents.Redeem.topic) {
// similar logic to Borrow event
} else if (i.evmLog.topics[0] === mGLRMEvents.RepayBorrow.topic) {
// similar logic to Borrow event
} else if (i.evmLog.topics[0] === mGLRMEvents.Transfer.topic) {
// similar logic to Borrow event
}
}
}
}
// process all market updates found in the batch
// and save all the data
});
  • Process the events that updated the money market and fetch data from the on-chain contract, by querying its status.
async function updateMarket(
ctx: LogHandlerContext<
Store,
{ evmLog: { topics: true; data: true }; transaction: { hash: true } }
>,
latestIntegerPrice: BigDecimal
): Promise<void> {
const { cashPrior, borrowIndex, interestAccumulated, totalBorrows } =
mGLRMEvents.AccrueInterest.decode(ctx.evmLog);
const market = new Market({
// create a new instance with default values
});
const contractAPI = new mGLMRContract(
ctx,
{ height: ctx.block.height },
mGLMRcontractAddress
);

const [
accrualBlockTimestamp,
totalSupply,
exchangeRateStored,
totalReserves,
reserveFactorMantissa,
supplyRatePerTimestampResult,
borrowRatePerTimestampResult,
] = await Promise.all([
contractAPI.accrualBlockTimestamp(),
contractAPI.totalSupply(),
contractAPI.exchangeRateStored(),
contractAPI.totalReserves(),
contractAPI.reserveFactorMantissa(),
contractAPI.supplyRatePerTimestamp(),
contractAPI.borrowRatePerTimestamp(),
]);

// data operations to convert data types and respect decimals

await ctx.store.save(market);
}
  • Process the various events generated by users interacting with the money market, and save Account data
async function saveAccounts(
ctx: BlockHandlerContext<Store>,
marketOrders: MarketOrder[]
) {
const accountIds: Set<string> = new Set();

for (const marketOrder of marketOrders) {
if (marketOrder.from) accountIds.add(marketOrder.from.toLowerCase());
if (marketOrder.to) accountIds.add(marketOrder.to.toLowerCase());
}

const accounts: Map<string, Account> = new Map(
(await ctx.store.findBy(Account, { id: In([...accountIds]) })).map((account) => [
account.id,
account,
])
);

for (const marketOrder of marketOrders) {

let fromAccount = accounts.get(marketOrder.from);
if (fromAccount == null) {
fromAccount = new Account({
id: marketOrder.from.toLowerCase(),
latestOrder: marketOrder.blockTime,
orderType: marketOrder.type
});
accounts.set(fromAccount.id, fromAccount);
}
if (fromAccount.latestOrder < marketOrder.blockTime) {
fromAccount.latestOrder = marketOrder.blockTime
fromAccount.orderType = marketOrder.type
}

let toAccount = accounts.get(marketOrder.to);
if (toAccount == null) {
toAccount = new Account({
id: marketOrder.to.toLowerCase(),
latestOrder: marketOrder.blockTime,
orderType: marketOrder.type
});
accounts.set(toAccount.id, toAccount);
}
if (toAccount.latestOrder < marketOrder.blockTime) {
toAccount.latestOrder = marketOrder.blockTime
toAccount.orderType = marketOrder.type
}
}

ctx.log.info(`Saving ${accounts.size} accounts`)

await ctx.store.save([...accounts.values()]);
}

All the logic is defined in the processor.ts file, under src folder. If you want to take a closer look, I invite you to browse the GitHub repository and inspect the code in detail.

Data aggregation

So far, the defined data processing logic makes sure that all the historical information is saved on the database. But the dashboard I had in mind does not need to show a data point for every single event that updated the money market. Rather, it needs some aggregated daily data for it.

This is where the custom resolver pattern for GraphQL servers comes in handy. This simply consists in writing a server extension and specify custom queries that the resolver will use to return the data.

With this in mind, I set out to create new index.ts file under the src/server-extension/resolvers folder, where I implemented daily aggregation of the money market data using relatively simple SQL queries.

Here's the content of the index.ts file:

import "reflect-metadata";
import type { EntityManager } from "typeorm";
import { Field, ObjectType, Query, Resolver } from "type-graphql";
import { Market } from "../../model";
import { BigDecimal } from "@subsquid/big-decimal";

@ObjectType()
export class MarketDayData {
@Field(() => Date, { nullable: false })
day!: Date;

@Field(() => Number, { nullable: true })
totalSupply!: number;

@Field(() => Number, { nullable: true })
totalBorrows!: number;

constructor(props: Partial<MarketDayData>) {
Object.assign(this, props);
}
}

@Resolver()
export class MarketDayDataResolver {
constructor(private tx: () => Promise<EntityManager>) {}

@Query(() => [MarketDayData])
async getMarketDayData(): Promise<MarketDayData[]> {
const manager = await this.tx();
const repository = manager.getRepository(Market);

const data: {
day: string;
total_supply: number;
total_borrows: number;
}[] = await repository.query(`
SELECT
DATE(to_timestamp(block_timestamp::numeric/1000)) AS day,
AVG(total_supply) as total_supply,
AVG(total_borrows) as total_borrows
FROM market
GROUP BY day
ORDER BY day ASC
`);
return data.map(
(i) =>
new MarketDayData({
day: new Date(i.day),
totalSupply: i.total_supply,
totalBorrows: i.total_borrows
})
);
}
}

@ObjectType()
export class PostDayData {
@Field(() => Date, { nullable: false })
day!: Date;

@Field(() => Number, { nullable: false })
count!: number;

constructor(props: Partial<PostDayData>) {
Object.assign(this, props);
}
}

Because TypeScript works with…well…types, I had to install a new library for this to work properly:

npm i type-graphql

There are only a couple of steps left to have the indexer up and running. First, launch the database container, with the command:

sqd up

Then, the SDK handles interactions with the database itself, like creating tables and columns via migrations. Because the template is a fully finished project, it came with its own migration files to apply the correct schema to the database. Because the initial schema has been replaced, the migration files need to be re-created as well. this can be done with these two commands:

sqd migration:clean
sqd migration

Next, it's time to launch the indexer and start processing blockchain data, with the command:

sqd process

This will block the terminal, showing indexing logs until either the processor terminates due to an error, or it's manually stopped.

The last step is to launch the GraphQL server, so in a different terminal window, run the command:

sqd serve

The log message says the server is listening to port number 4350. This means that opening the browser at the URL http://localhost:4350/graphql, it's possible to use the GraphQL playground to test some queries, for example, this one:

query MyQuery {
getMarketDayData {
day
totalBorrows
totalSupply
}
}

Can be used to populate a chart showing the trend of total supply and total borrows for Moonwell protocol, on a daily basis. And it's exactly what I'm going to explain in the next section.

I have deployed this project in Aquarium: Subsquid's hosting service for squids. The API is available at this endpoint, if you want to test it out and compare with what you developed so far.

Dynamic data in React

Now that the data indexing is being taken care of, the final touch is to go back to the React app, perform API requests to the squid GraphQL API, and use this data to feed the chart on the page.

To do so, I installed the axios library first, to be able to perform http requests:

npm i axios

Home page — Line chart

As I just mentioned, the custom resolver build in the previous section can be used to populate a chart, so this is exactly what I am going to start with. I am going to describe what I have done in a short list, and then provide a code snippet:

  • I wanted a Line chart, not a Bar chart, so I renamed the BarChart file to LineChart, as well as the component's name (if you do this via an IDE, it should take care of the import and usage in index.tsx, otherwise, you'd have to change this manually)
  • Because of this the Line component has to be imported and returned by our custom component.
  • The configuration and options for Line chart are slightly different from Bar ones. I also took the chance to make it a Multi Axis Line chart, because the two values I am showing have different scales, which would appear on opposite sides of the chart.
  • The axios library needed to be imported
  • the useState and useEffect calls needed to be changed to fetch data from the API and save it to a React state
  • I also added a default message, for when the API fails to fetch data, or it is simply waiting the response

Here's the code for LineChart.tsx:

import { useState, useEffect } from "react";
import { Line } from "react-chartjs-2";
import { ChartData, ChartOptions } from "chart.js";
import 'chart.js/auto'
import axios from "axios";

export const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top' as const,
},
title: {
display: true,
text: 'Supply and Borrow totals',
},
},
scales: {
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
},
y1: {
type: 'linear' as const,
display: true,
position: 'right' as const,
grid: {
drawOnChartArea: false,
},
},
},
};

const headers = {
"content-type": "application/json",
};

const requestBody = {
query: `query MyQuery {
getMarketDayData {
day
totalBorrows
totalSupply
}
}
`,
};

const graphQLOptions = {
method: "POST",
url: process.env.NEXT_PUBLIC_SQUID_URL || "http://localhost:4350/graphql",
headers,
data: requestBody,
};

export const CHART_COLORS = {
red: 'rgb(255, 99, 132)',
orange: 'rgb(255, 159, 64)',
yellow: 'rgb(255, 205, 86)',
green: 'rgb(75, 192, 192)',
blue: 'rgb(54, 162, 235)',
purple: 'rgb(153, 102, 255)',
grey: 'rgb(201, 203, 207)'
};

function LineChart() {
const [chartData, setChartData] = useState<ChartData<'line'> | null>(
null
);
const [chartOptions, setChartOptions] = useState<ChartOptions<'line'> | null>(
null
);
useEffect(() => {
try {
axios(graphQLOptions).then((response) => {
setChartData({
labels: [
...response.data.data.getMarketDayData.map((obj: any) => new Date(obj.day).toLocaleDateString()),
],
datasets: [
{
label: "Total Borrows daily values",
data: [
...response.data.data.getMarketDayData.map((obj: any) => obj.totalBorrows),
],
borderColor: CHART_COLORS.red,
backgroundColor: CHART_COLORS.red.replace(')', ', 0.5)').replace('rgb', 'rgba'),
yAxisID: 'y',
},
{
label: "Total Supply daily values",
data: [
...response.data.data.getMarketDayData.map((obj: any) => obj.totalSupply),
],
borderColor: CHART_COLORS.blue,
backgroundColor: CHART_COLORS.blue.replace(')', ', 0.5)').replace('rgb', 'rgba'),
yAxisID: 'y1',
},
],
});
});
} catch (err) {
console.log("ERROR DURING AXIOS REQUEST", err);
}

setChartOptions(options)
}, [])
if (!chartData) return (<div>
<h1>No data 🤷‍♂️!</h1>
<h2>Try to refresh the page in a short while</h2>
</div>);

return (
<div className="w-full md:col-span-2 relative lg:h-[70vh] h-[50vh] m-auto p-4 border rounded-lg bg-white">
<Line data={chartData} options={options}></Line>
</div>
);
}

export default LineChart;

Note: I am using an environment variable named NEXT_PUBLIC_SQUID_URL for the squid API endpoint. I advise you to create a .env file in the project's root folder and define this variable, by specifying the URL of the squid running locally, or deployed in Aquarium.

Home page — Top Cards

I then set out to change the section at the top, so the TopCards.tsx file needed some editing as well:

  • In order to show the "+/- X%" bubbles in the cards, I decided to perform two separate queries, two separate API requests.
  • I introduced, similarly to the Line chart component, useState and useEffect React function calls, to perform the API request and set the result as a state.
  • I changed the returned html code returned to make use of the dynamic data
  • I took this chance to change this from 3 cards to return 5 cards, because I found out I had more data points. (In doing this, I think I broke the responsiveness, which I am not sure if I will have the time to fix before wrapping up the article 😝)

To keep it short, I am only reporting the queries used:

query latestMarketQuery {
markets(limit: 1, orderBy: blockTimestamp_DESC, where: {underlyingPriceUSD_not_eq: "0"}) {
totalSupply
totalBorrows
symbol
supplyRate
reserves
name
cash
borrowRate
underlyingPriceUSD
underlyingDecimals
blockTimestamp
}
}

query yesterdayMarketQuery {
markets(limit: 1, orderBy: blockTimestamp_DESC, where: {AND: {blockTimestamp_lt: "1681985229000", underlyingPriceUSD_not_eq: "0"}}) {
totalSupply
totalBorrows
supplyRate
reserves
cash
borrowRate
underlyingPriceUSD
blockTimestamp
}
}

The data conversion done for a successful API request:

setLatestData({
symbol: obj.symbol,
name: obj.name,
totalSupply: obj.totalSupply,
totalSupplyStr: Number(obj.totalSupply).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }),
totalBorrows: obj.totalBorrows,
totalBorrowsStr: Number(obj.totalBorrows).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }),
supplyRate: obj.supplyRate,
reserves: obj.reserves,
reservesStr: Number(obj.reserves).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }),
cash: obj.cash,
cashStr: Number(obj.cash).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }),
borrowRate: obj.borrowRate,
price: obj.underlyingPriceUSD,
priceStr: Number(obj.underlyingPriceUSD).toLocaleString(undefined, { maximumFractionDigits: 3, minimumFractionDigits: 3 }),
underlyingDecimals: obj.underlyingDecimals,
// date: new Date(Number(obj.blockTime)).toLocaleDateString(),
})

How the differences are calculated:

const supplyDiff = Number(Number((latestData.totalSupply - yesterdayData.totalSupply) * 100 / latestData.totalSupply).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }))
const borrowsDiff = Number(Number((latestData.totalBorrows - yesterdayData.totalBorrows) * 100 / latestData.totalBorrows).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }))
const cashDiff = Number(Number((latestData.cash - yesterdayData.cash) * 100 / latestData.cash).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }))
const reservesDiff = Number(Number((latestData.reserves - yesterdayData.reserves) * 100 / latestData.reserves).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }))
const priceDiff = Number(Number((latestData.price - yesterdayData.price) * 100 / latestData.price).toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 2 }));

Lastly, I decided to use the conditional formatting shown in the dashboard video, to change the bubble background and text color, depending on the differential value (red if negative, green if positive). Here's an example for the daily differential for total supply:

return(
//...
<p
className={
supplyDiff < 0
? "bg-red-200 flex justify-center items-center p-2 rounded-lg"
: "bg-green-200 flex justify-center items-center p-2 rounded-lg"
}
>
<span
className={
supplyDiff < 0 ? "text-red-700 text-lg" : "text-green-700 text-lg"
}
>
{supplyDiff}%
</span>
</p>
//...
);

If you want to "cheat", you can take a peak at the end result on GitHub.

Home page — Recent Orders

The last component of the home I took care of is the recent orders one. Following a similar pattern to the previous two:

  • I imported axios library
  • Leveraged useEffect to perform the API request, and set the result as a React state via useState function
  • Adapted the returned HTML to include the dynamic data, and fit the information I had available

Question time: how would you change the component, knowing this is the query I used for it?

query MyQuery {
marketOrders(limit: 20, orderBy: blockTime_DESC) {
type
to
from
cTokenSymbol
id
blockTime
amount
}
}

Hint: the answer is, once again, on GitHub 😅

Orders and customers pages

This took care of the home page, so the next step was to edit orders.tsx and add dynamic data over there too. The list of changes follows a similar pattern to the previous ones, as a matter of fact, the orders page is a more detailed version of the Recent Orders component.

I did, however, change the query to use the marketOrdersConnection, because it allows for pagination:

query MyQuery {
marketOrdersConnection(orderBy: blockTime_DESC, first: 25) {
totalCount
edges {
cursor
node {
amount
blockTime
cTokenSymbol
from
id
to
type
}
}
}
}

To keep this tutorial short, I am inviting you to directly check the result on GitHub, but I am letting you know that I ended up not using pagination on this page, so look at the conclusions section for some "homework". 😉

As for the customers page, blockchain is all about privacy, so I definitely don't have name, surname and email available, but I decided to list the address of accounts placing orders, as well as the account's latest order and the type of order. Similarly to the orders page, I imagined this one to use pagination, so here's the query:

query MyQuery {
accountsConnection(orderBy: id_ASC, first: 20) {
edges {
node {
id
latestOrder
orderType
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
endCursor
startCursor
}
totalCount
}
}

It's worth noting that the columns could also be made interactive and they could have an effect on the orderBy of this query, whose value could be turned into a variable.

The customers page code is available here.

Testing out the App

One of the best parts about React development is that the server launched with npm run dev updates every time you save, thanks to React's hot reloading. So if you had your browser open on http:localhost:3000 you should have seen it change while editing the code. Or, you could just open it now.

At any rate, this is how the home page should look like by now:

The DeFi dashboard using Moonwell's on-chain data, powered by Subsquid

Conclusions

I really enjoyed creating this dashboard. Even though it is not the most sophisticated piece of software I have written, by far, I like the end result. Furthermore, the real focus of this article is to showcase how Subsquid can be integrated in a dashboard such as this one.

I wanted to provide a mini-guide that would respond to the use case:

"I need to create a dashboard, that shows key data about a DeFi protocol, but this in formation is stored on-chain."

So in this article I have (hopefully successfully) shown how to use Subsquid SDK to source such information, and process it in order to make it easier to consume by a frontend.

Adding an indexing middleware in a DApp is, in my opinion, a very good way to future-proof it, making sure that the user experience is not degraded by the RPC bottleneck. And sometimes it's just simply not feasible to directly use data from a smart contract in your frontend.

At Subsquid is continuously developing, so head over to the website, and to the docs to know more, and follow Subsquid on social media to stay up to date.

What's next?

I have mentioned in the article that even while I was developing the project, I came up with ideas on how it could be improved (like pagination). So here's a couple of suggestions, if you want to use this as your playground to hone your skills, or simply, like me, you can't stand having an "incomplete" project in your hands.

  • The number of API requests should be reduced. In my opinion, the GraphQL queries could be performed by the page, and the data could be passed down to the components in a page. This will avoid multiple single queries on page load (I think the home page has 3 or 4…)
  • I'd like to make the line chart on the home page "draggable", so that it can start more zoomed-in on the last month for example, but I can move the canvas back to explore past time periods.
  • I had set up the queries in the Orders and Customers pages to be paginated, because I eventually want(ed) to add a "next" and "previous" buttons at the bottom of the page, for example, to load different pages of the corresponding data.
  • In a similar fashion, I would like to change the columns in the Orders and Customers pages to be interactive, so that clicking on one, would change the orderBy value of the query.
  • A cool idea would be to add Web3 integration, so that users could connect to the page with their wallet, and it would remove the hardcoded "Welcome back" message, and display your wallet address in the top right corner, instead.

This last feature, especially could lead to other interesting ideas, such as being able to show user-specific data, once their wallet is known.

If you found this article interesting, and you want to read more, please follow me and most importantly, Subsquid.

Subsquid socials:
Website | Twitter | Discord | LinkedIn | Telegram | GitHub | YouTube

--

--

Massimo Luraschi
squid Blog

Software Engineering, Blockchain, Basketball, Science 🇮🇹🇬🇧🇫🇷 If I have seen further it is by standing on the shoulders of giants