How to build a DEXs analytics application

View on GitHub

Decentralized exchanges (DEXs) generate enormous quantities of data which are interesting to read but difficult to gather.

Fortunately, TheGraph offers the possibility to obtain the minimum required amount of data that are needed in a simple manner using GraphQL.

In this post I'll try to explain how to develop an application built with Vite.js that queries Uniswap v2 and v3 subgraphs in TheGraph, processes the data, stores them in a global state and represents them using React.js components.

In this article

Approach

In order to address the development of this application, which may consist of an increasing number of exchanges, I've adopted a clean architecture adapted to the front end.

My implementation is based on a hexagonal architecture in which the state exists outside the core and persists globally using Redux toolkit.

The core is where the business logic is defined, this is, entities, repositories, use cases and adapters.

I thought of every DEX supported by the application as a feature that consists of its core, data sources, a Redux state slice and UI components.

Therefore, if the application was to escalate, supporting a new decentralised exchange would consist approximately of adding a new folder with independent code in src/features.

Inversely, removing an exchange from the app would consist approximately of getting rid of its corresponding feature, without affecting the rest of the logic.

This approach also grants a complete decoupling of the business logic, the infrastructure, the user interface and the state of the application.

In other words, the business logic is not affected by the data sources requirements, transitioning to a different front-end framework or library would be trivial and the state logic could be easily reused.

Data fetching

This application uses client side rendering (CSR) to fetch data and render pools and/or tokens.

For instance, to get the top 50 tokens in Uniswap v3, this app renders a React.js component with the hook useTokensUniswapV3, which will trigger two use cases or interactors:

Every interactor calls a different method of a repository and these methods are implemented in TypeScript classes, which represent data sources.

Each method will query a TheGraph subgraph with a GraphQL client and will return an object which, subsequently, is adapted to an interface common to all protocols.

queryBlocksEthereum will conform a Blocks entity with timestamps numbers, in seconds, and block numbers for four timestamps: current, t1D, t2D and t1W.

queryTokensAndPricesUniswapV3 will use these block numbers to conform an entity TokensAndPrices with:

  • 50 tokens ordered by totalValueLockedUSD for every timestamp.

  • ether prices for the mentioned timestamps, that are used to calculate ETH to USD conversions.

The logic to query the block numbers and timestamps is implemented along other code in the shared folder at the features folder level, since other protocols will share these code.

In the following section I'll go over the components that I just mentioned, in detail.

File structure

The structure of this repository is summarized below:

dexs-analytics
├── src
   ├── app
   ├── state
   └── ...
   ├── styles
   └── ...
   ├── ui
   └── ...
   └── utils
       └── ...
   ├── features
   ├── uniswapV2
   └── ...
   └── uniswapV3
       └── ...
   ├── shared
   ├── styles
   └── ...
   └── ui
       └── ...
   └── main.tsx
├── index.html
└── ...

In Vite.js index.html is the entry point to the application:

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DEXs Analytics</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

This will render render main.tsx which contains the method ReactDOM.render():

src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import 'src/app/styles/index.css';
import App from 'src/app/ui/App';
import { Provider } from 'react-redux';
import { store } from 'src/app/state/store';
import { BrowserRouter } from 'react-router-dom';
import LayoutSite from 'src/app/ui/layout/LayoutSite';
 
ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <BrowserRouter>
        <LayoutSite>
          <App />
        </LayoutSite>
      </BrowserRouter>
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
);

This method renders the app in the browser. The App.tsx component is in the folder src/app, which along src/features, both contain the bulk of the code.

I will explain the content of these two important folders below.

App

src/app gathers the logic that is not subject to a particular feature but to the application itself. It contains the App.tsx component, the route manager, the Redux store, global styles, layout components, etc

We can see below the code of the component App.tsx:

src/app/ui/App.tsx
import { Suspense } from 'react';
import FallbackMessage from 'src/shared/ui/FallbackMessage';
import RouteManager from 'src/app/ui/routes/RouteManager';
 
function App() {
  return (
    <Suspense fallback={<FallbackMessage message="Loading..." style={{ minHeight: '95vh' }} />}>
      <RouteManager />
    </Suspense>
  );
}
 
export default App;

App.tsx renders the RouteManager.tsx component, which defines all the supported paths in the application.

For instance, to follow with the task of retrieving Uniswap v3 tokens, the path /ethereum/uniswap-v3/tokens matches the path that renders the Tokens.tsx component, as we can see below:

src/app/ui/routes/RouteManager.tsx
import { lazy } from 'react';
import { Route, Routes } from 'react-router-dom';
const Home = lazy(() => import('src/app/ui/pages/Home'));
const NotFound = lazy(() => import('src/app/ui/pages/NotFound'));
const Overview = lazy(() => import('src/app/ui/pages/Overview'));
const Pools = lazy(() => import('src/app/ui/pages/Pools'));
const Tokens = lazy(() => import('src/app/ui/pages/Tokens'));
 
const RouteManager = () => {
  return (
    <Routes>
      <Route path="/:blockchainId/:protocolId/tokens" element={<Tokens />} />
      <Route path="/:blockchainId/:protocolId/pools" element={<Pools />} />
      <Route path="/:blockchainId/:protocolId/pairs" element={<Pools />} />
      <Route path="/:blockchainId/:protocolId/:networkId/tokens" element={<Tokens />} />
      <Route path="/:blockchainId/:protocolId/:networkId/pools" element={<Pools />} />
      <Route path="/:blockchainId/:protocolId/:networkId/pairs" element={<Pools />} />
      <Route path="/:blockchainId/:protocolId/:networkId" element={<Overview />} />
      <Route path="/:blockchainId/:protocolId" element={<Overview />} />
      <Route path="/404" element={<NotFound />} />
      <Route path="/" element={<Home />} />
      <Route path="*" element={<NotFound />} />
    </Routes>
  );
};
 
export default RouteManager;

Tokens.tsx renders the tokens of the protocol that is given in the path, as long as the app supports it.

Features

As we said, DEXs Analytics consists of a number of features (DEX protocols), and they all have the same file structure.

The file structure of Uniswap v3 can be seen below:

dexs-analytics
├── src
   ├── app
   └── ...
   ├── features
   ├── uniswapV2
   └── ...
   └── uniswapV3
       ├── core
   ├── adapters
   ├── etherPricesUniswapV3.adapter.ts
   ├── poolsTokensAndPricesUniswapV3.adapter.ts
   ├── poolsUniswapV3.adapter.ts
   └── tokensAndPricesUniswapV3.adapter.ts
   ├── entities
   ├── EtherPricesUniswapV3.ts
   ├── PoolsTokensAndPricesUniswapV3.ts
   ├── PoolsUniswapV3.ts
   └── TokensAndPricesUniswapV3.ts
   ├── interactors
   ├── index.ts
   ├── queryPoolsTokensAndPricesUniswapV3.interactor.ts
   ├── queryPoolsUniswapV3.interactor.ts
   └── queryTokensAndPricesUniswapV3.interactor.ts
   └── repositories
       └── UniswapV3.repository.ts
       ├── dataSources
   └── uniswapV3.datasource.ts
       ├── state
   ├── poolsUniswapV3Slice.ts
   └── tokensUniswapV3Slice.ts
       ├── ui
   ├── hooks
   ├── usePoolsTokensUniswapV3.ts
   ├── usePoolsUniswapV3.ts
   └── useTokensUniswapV3.ts
   ├── PoolsTokensUniswapV3.tsx
   ├── PoolsUniswapV3.tsx
   └── TokensUniswapV3.tsx
       └── utils
           ├── constatnts.ts
           └── helpers.ts
   └── ...
└── ...

In the following sections I will go over these folders and I will refer again to the task of getting the top 50 tokens in Uniswap v3, so that we can see some of the code.

Core

This folder gathers the business logic of the application. It consists of entities, repositories, interactors and adapters.

Interactors interact with repositories to get entities. Then, these entities are passed to adapters which return other entities that are common to the rest of the protocols.

All the code within the core is independent from the infrastructure.

Entities

The interface that represents the tokens object returned by the Uniswap v3 subgraph can be seen below:

src/features/uniswapV3/core/entities/TokensAndPricesUniswapV3.ts
import { EtherPriceUniswapV3 } from 'src/features/uniswapV3/core/entities/EtherPricesUniswapV3';
 
export interface TokenUniswapV3 {
  id: string;
  name: string;
  symbol: string;
  volumeUSD: string;
  totalValueLockedUSD: string;
  derivedETH: string;
}
 
export interface TokensAndPricesUniswapV3 {
  tokens_current: TokenUniswapV3[];
  tokens_t1D: TokenUniswapV3[];
  tokens_t2D: TokenUniswapV3[];
  tokens_t1W: TokenUniswapV3[];
  price_current: EtherPriceUniswapV3[];
  price_t1D: EtherPriceUniswapV3[];
  price_t2D: EtherPriceUniswapV3[];
  price_t1W: EtherPriceUniswapV3[];
}

We can infer from this entity that tokens and ether prices for different timestamps are be requested in the same query.

We can also check this in the implementation of the getTokensAndPricesByBlocks method in data sources.

Repositories

A repository is an interface that describes the methods that are required. In the case of UniswapV3Repository, it gathers 3 methods including getTokensAndPricesByBlocks.

src/features/uniswapV3/core/repositories/UniswapV3.repository.ts
import { Blocks } from 'src/features/shared/blocks/core/entities/Blocks';
import { PoolsUniswapV3 } from 'src/features/uniswapV3/core/entities/PoolsUniswapV3';
import { TokensAndPricesUniswapV3 } from 'src/features/uniswapV3/core/entities/TokensAndPricesUniswapV3';
import { PoolsTokensAndPricesUniswapV3 } from 'src/features/uniswapV3/core/entities/PoolsTokensAndPricesUniswapV3';
 
interface UniswapV3Repository {
  getPoolsByBlocks(endpoint: string, blocks: Blocks): Promise<PoolsUniswapV3 | undefined>;
  getTokensAndPricesByBlocks(endpoint: string, blocks: Blocks): Promise<TokensAndPricesUniswapV3 | undefined>;
  getPoolsTokensAndPricesByBlocks(endpoint: string, blocks: Blocks): Promise<PoolsTokensAndPricesUniswapV3 | undefined>;
}
 
export default UniswapV3Repository;
Interactors

Interactors are the use cases of their respective features.

For instance, the use case to generate a TokensAndPrices entity in Uniswap v3 is queryTokensAndPricesUniswapV3.

queryTokensAndPricesUniswapV3 gets an endpoint and a Blocks object and passes them to the getTokensAndPricesByBlocks method in the repository UniswapV3Repository.

The implementation of this method returns a promise of an object with the interface TokensAndPricesUniswapV3 which is resolved here and adapted to the interface Tokens thereafter.

An instance of UniswapV3DataSource could be created here. However, it gets an abstraction (a UniswapV3Repository repository) instead of depending on a specific data source implementation in order for our logic not to be affected by any change in the infrastructure.

src/features/uniswapV3/core/interactors/queryTokensAndPricesUniswapV3.interactor.ts
import UniswapV3Repository from 'src/features/uniswapV3/core/repositories/UniswapV3.repository';
import tokensAndPricesUniswapV3Adapter from 'src/features/uniswapV3/core/adapters/tokensAndPricesUniswapV3.adapter';
import { Blocks } from 'src/features/shared/blocks/core/entities/Blocks';
import { TokensAndPrices } from 'src/features/shared/tokensAndPrices/core/entities/TokensAndPrices';
 
const queryTokensAndPricesUniswapV3 =
  (repository: UniswapV3Repository) =>
  async (endpoint: string, blocks: Blocks): Promise<{ error: boolean; data: TokensAndPrices | null }> => {
    try {
      const data = await repository.getTokensAndPricesByBlocks(endpoint, blocks);
      return { error: false, data: data ? tokensAndPricesUniswapV3Adapter(data) : null };
    } catch (e) {
      console.error(e);
      return { error: true, data: null };
    }
  };
 
export default queryTokensAndPricesUniswapV3;

For this reason, the interactor that will be imported in our user interface will have the dependency -an instance to UniswapV3DataSource- already injected:

src/features/uniswapV3/core/interactors/index.ts
import UniswapV3DataSource from 'src/features/uniswapV3/dataSources/uniswapV3.datasource';
import queryPoolsUniswapV3 from 'src/features/uniswapV3/core/interactors/queryPoolsUniswapV3.interactor';
import queryPoolsTokensAndPricesUniswapV3 from 'src/features/uniswapV3/core/interactors/queryPoolsTokensAndPricesUniswapV3.interactor';
import queryTokensAndPricesUniswapV3 from 'src/features/uniswapV3/core/interactors/queryTokensAndPricesUniswapV3.interactor';
 
const repository = new UniswapV3DataSource();
 
const queryPoolsUniswapV3WithDep = queryPoolsUniswapV3(repository);
const queryTokensAndPricesUniswapV3WithDep = queryTokensAndPricesUniswapV3(repository);
const queryPoolsTokensAndPricesUniswapV3WithDep = queryPoolsTokensAndPricesUniswapV3(repository);
 
export { queryPoolsUniswapV3WithDep, queryTokensAndPricesUniswapV3WithDep, queryPoolsTokensAndPricesUniswapV3WithDep };

In my opinion, this is a clean design for our inversion of control. I learnt it in this YouTube video about hexagonal architecture

Adapters

Adapters are functions that convert the objects received from the TheGraph subgraph to objects with interfaces common to all protocols.

This ensures that components rendering data always get objects with the same interface, no matter the protocol.

src/features/uniswapV3/core/adapters/tokensUniswapV3.adapter.ts
import { Token } from 'src/features/shared/tokens/core/entities/Tokens';
import { TokensAndPrices } from 'src/features/shared/tokensAndPrices/core/entities/TokensAndPrices';
import { TokensAndPricesUniswapV3 } from 'src/features/uniswapV3/core/entities/TokensAndPricesUniswapV3';
import etherPricesUniswapV3Adapter from 'src/features/uniswapV3/core/adapters/etherPricesUniswapV3.adapter';
 
const tokensAndPricesUniswapV3Adapter = (dataRaw: TokensAndPricesUniswapV3): TokensAndPrices => {
  const tokens = { current: {}, t1D: {}, t2D: {}, t1W: {} };
  const { tokens_current, tokens_t1D, tokens_t1W, tokens_t2D, price_current, price_t1D, price_t1W, price_t2D } =
    dataRaw;
  const tokensRaw = { tokens_current, tokens_t1D, tokens_t1W, tokens_t2D };
  for (const key of Object.keys(tokensRaw)) {
    const tokensData: Record<string, Token> = {};
    for (const token of tokensRaw[key as keyof typeof tokensRaw]) {
      tokensData[token.id] = {
        name: token.name,
        symbol: token.symbol,
        address: token.id,
        volume: parseFloat(token.volumeUSD),
        tvl: parseFloat(token.totalValueLockedUSD),
        derivedETH: parseFloat(token.derivedETH),
      };
    }
    tokens[key.replace('tokens_', '') as keyof typeof tokens] = tokensData;
  }
  const etherPrices = etherPricesUniswapV3Adapter({
    current: price_current,
    t1D: price_t1D,
    t2D: price_t2D,
    t1W: price_t1W,
  });
  return { tokens, etherPrices };
};
 
export default tokensAndPricesUniswapV3Adapter;

Therefore, an adapter may help as a "barrier" in case there was a change on the subgraph GraphQL schema, as the only fields to be changed would be in the interface received, and this would not necessarily affect the rest of the code.

Data sources

Data sources are TypeScript classes that implement a protocol repository.

In the case of Uniswap v3, UniswapV3Repository requires three methods to be implemented. One of them isgetTokensAndPricesByBlocks, which is used to retrieve tokens and ether prices.

It queries the Uniswap V3 subgraph with n instance of a GraphQLClient and returns a promise of an object with the interface TokensAndPricesUniswapV3.

A Blocks object is passed to getTokensAndPricesByBlocks because we need to query for four different timestamps, and this is accomplished by setting the block number as an argument in every entity in the query.

However, instead of querying the subgraph eight times, once for every timestamp (current, t1D, t2D and t1W) for every entity (tokens and ether prices), there is a single query:

src/features/uniswapV3/dataSources/uniswapV3.datasource.ts
import { GraphQLClient } from 'graphql-request';
import { Blocks } from 'src/features/shared/blocks/core/entities/Blocks';
import UniswapV3Repository from 'src/features/uniswapV3/core/repositories/UniswapV3.repository';
import { TokensAndPricesUniswapV3 } from 'src/features/uniswapV3/core/entities/TokensAndPricesUniswapV3';
import { UNISWAP_V3_TOKENS_TO_HIDE } from 'src/features/uniswapV3/utils/constants';
import { PoolsUniswapV3 } from 'src/features/uniswapV3/core/entities/PoolsUniswapV3';
import { UNISWAP_V3_POOLS_TO_HIDE } from 'src/features/uniswapV3/utils/constants';
import { PoolsTokensAndPricesUniswapV3 } from 'src/features/uniswapV3/core/entities/PoolsTokensAndPricesUniswapV3';
 
class UniswapV3DataSource implements UniswapV3Repository {
  public async getPoolsByBlocks(endpoint: string, blocks: Blocks): Promise<PoolsUniswapV3 | undefined> {
    ...
  }
 
  public async getPoolsTokensAndPricesByBlocks(
    endpoint: string,
    blocks: Blocks
  ): Promise<PoolsTokensAndPricesUniswapV3 | undefined> {
    ...
  }
 
  public async getTokensAndPricesByBlocks(
    endpoint: string,
    blocks: Blocks
  ): Promise<TokensAndPricesUniswapV3 | undefined> {
    const client = new GraphQLClient(endpoint);
    let tokensToHide = ``;
    UNISWAP_V3_TOKENS_TO_HIDE.map((address) => {
      return (tokensToHide += `"${address}",`);
    });
    const QUERY = `
      query TokensUniswapV3($tokensToHide: String!, $blockT1D: Int!, $blockT2D: Int!, $blockT1W: Int!) {
        tokens_current: tokens(
          where: {id_not_in: [$tokensToHide]}
          orderBy: totalValueLockedUSD
          orderDirection: desc
          first: 50
          subgraphError: allow
        ) {
          ...tokensFields
        }
        tokens_t1D: tokens(
          where: {id_not_in: [$tokensToHide]}
          orderBy: totalValueLockedUSD
          orderDirection: desc
          first: 50
          block: {number: $blockT1D}
          subgraphError: allow
        ) {
          ...tokensFields
        }
        tokens_t2D: tokens(
          where: {id_not_in: [$tokensToHide]}
          orderBy: totalValueLockedUSD
          orderDirection: desc
          first: 50
          block: {number: $blockT2D}
          subgraphError: allow
        ) {
          ...tokensFields
        }
        tokens_t1W: tokens(
          where: {id_not_in: [$tokensToHide]}
          orderBy: totalValueLockedUSD
          orderDirection: desc
          first: 50
          block: {number: $blockT1W}
          subgraphError: allow
        ) {
          ...tokensFields
        }
        price_current: bundles(first: 1, subgraphError: allow) {
          ...priceField
        }
        price_t1D: bundles(first: 1, block: {number: $blockT1D}, subgraphError: allow) {
          ...priceField
        }
        price_t2D: bundles(first: 1, block: {number: $blockT2D}, subgraphError: allow) {
          ...priceField
        }
        price_t1W: bundles(first: 1, block: {number: $blockT1W}, subgraphError: allow) {
          ...priceField
        }
      }
      fragment tokensFields on Token {
        id
        name
        symbol
        volumeUSD
        totalValueLockedUSD
        derivedETH
      }
      fragment priceField on Bundle {
        ethPriceUSD
      }
    `;
    return client.request(QUERY, {
      tokensToHide,
      blockT1D: blocks.t1D.number,
      blockT2D: blocks.t2D.number,
      blockT1W: blocks.t1W.number,
    });
  }
}
 
export default UniswapV3DataSource;

"..." in lines 12 and 19 represents code that has been removed because of its length.

The argument id_not_in, that gets a list of ids, is used in order to discard certain tokens.

State

The state of every feature is a group of several Redux Toolkit slices, which live outside the core of the feature.

Following previous example, the state of the Uniswap v3 tokens is given by a Redux toolkit slice with only one reducer: setTokensUniswapV3.

setTokensUniswapV3 will get a TokenState object as the payload and update the state.

src/features/uniswapV3/state/tokensUniswapV3Slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { TokensState } from 'src/features/shared/tokens/core/entities/Tokens';
 
// initial state
const initialState: TokensState = {
  loading: null,
  error: null,
  data: null,
};
 
// slice
const tokensUniswapV3Slice = createSlice({
  name: 'tokensUniswapV3',
  initialState,
  reducers: {
    setTokensUniswapV3(state, { payload: { loading, error, data } }: PayloadAction<TokensState>) {
      state.loading = loading;
      state.error = error;
      state.data = { ...state.data, ...data };
    },
  },
});
 
export const { setTokensUniswapV3 } = tokensUniswapV3Slice.actions;
export default tokensUniswapV3Slice.reducer;

TokenState is an interface with a loading and error control fields, and the field data, which has the interface of Record with the selected blockchain network as index key, and an object with two fields as value: tokens and lastUpdated.

src/features/shared/tokens/core/entities/Tokens.ts
export interface Token {
  address: string;
  name: string;
  symbol: string;
  volume: number | null;
  tvl: number;
  derivedETH: number;
}
 
export interface Tokens {
  current: Record<string, Token>;
  t1D: Record<string, Token>;
  t2D: Record<string, Token>;
  t1W: Record<string, Token>;
}
 
export type TokenExtended = Token & {
  volumeChange: number | null;
  volume1W: number | null;
  tvlChange: number | null;
  price: number;
  priceChange: number | null;
  priceChange1W: number | null;
};
 
export interface TokensObject {
  [tokenId: string]: TokenExtended;
}
 
export interface TokensStateData {
  [networkId: string]: {
    tokens: TokensObject;
    lastUpdated: number;
  };
}
 
export interface TokensState {
  loading: boolean | null;
  error: boolean | null;
  data?: TokensStateData | null;
}

As we can see the tokens for different timestamps of a network that a protocol is deployed will be indexed in a Record object alongside with other data.

The idea is that data of all protocols and networks coexist in their respective state slices and persist in an index of that state

This avoids making queries every time that data are to be rendered unless an specific amount of time has passed which currently is 15 minutes.

Therefore, the states of all the tokens and pools are defined in their slices and passed to the Redux store.

src/app/state/store.ts
import { configureStore } from '@reduxjs/toolkit';
import poolsUniswapV3SliceReducer from 'src/features/uniswapV3/state/poolsUniswapV3Slice';
import tokensUniswapV3SliceReducer from 'src/features/uniswapV3/state/tokensUniswapV3Slice';
import protocolSlice from 'src/app/state/protocolSlice';
import searchSliceReducer from 'src/app/state/searchSlice';
import pairsUniswapV2SliceReducer from 'src/features/uniswapV2/state/pairsUniswapV2Slice';
import tokensUniswapV2SliceReducer from 'src/features/uniswapV2/state/tokensUniswapV2Slice';
import blocksSliceReducer from 'src/features/shared/blocks/state/blocksSlice';
 
export const store = configureStore({
  reducer: {
    protocol: protocolSlice,
    search: searchSliceReducer,
    poolsUniswapV3: poolsUniswapV3SliceReducer,
    tokensUniswapV3: tokensUniswapV3SliceReducer,
    pairsUniswapV2: pairsUniswapV2SliceReducer,
    tokensUniswapV2: tokensUniswapV2SliceReducer,
    blocks: blocksSliceReducer,
  },
});
 
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;

User interface

The ui folder gathers both the implementation of the hooks and the React.js components. These components get tokens and pools from the hooks and render other components with tables and pagination.

In our tokens example, useTokensUniswapV3 is the hook that sets the tokens state by dispatching the proper payload to setTokensUniswapV3, depending on the responses of every interactor that is called.

That state will ultimately control what to render in the tokens table, i.e. either a loading message, an error message or the tokens themselves.

src/features/uniswapV3/ui/hooks/useTokensUniswapV3.tsx
import { useCallback, useEffect } from 'react';
import { useAppDispatch } from 'src/app/ui/hooks/useAppDispatch';
import { useAppSelector } from 'src/app/ui/hooks/useAppSelector';
import useEndpoint from 'src/app/ui/hooks/useEndpoint';
import useEndpointBlocks from 'src/app/ui/hooks/useEndpointBlocks';
import queryBlocksEthereumWithDep from 'src/features/shared/blocks/core/interactors';
import { queryTokensAndPricesUniswapV3WithDep } from 'src/features/uniswapV3/core/interactors';
import { setBlocks } from 'src/features/shared/blocks/state/blocksSlice';
import { setTokensUniswapV3 } from 'src/features/uniswapV3/state/tokensUniswapV3Slice';
import { getFormattedBlocks } from 'src/features/shared/blocks/ui/utils/helpers';
import { getFormattedTokensUniswapV3 } from 'src/features/uniswapV3/utils/helpers';
import { getTimestamps, shouldFetch } from 'src/features/shared/utils/helpers';
 
export function useTokensUniswapV3() {
  const dispatch = useAppDispatch();
  const tokensState = useAppSelector((state) => state.tokensUniswapV3);
  const protocolState = useAppSelector((state) => state.protocol);
  const endpoint = useEndpoint();
  const endpointBlocks = useEndpointBlocks();
  const shouldFetchTokens = Boolean(protocolState.data && shouldFetch(tokensState.data, protocolState.data.network));
 
  // create a callback function with the use cases
  const fetchData = useCallback(async () => {
    dispatch(setBlocks({ loading: true, error: null }));
    dispatch(setTokensUniswapV3({ loading: true, error: null }));
    if (protocolState.error || endpoint.error || endpointBlocks.error) {
      dispatch(setTokensUniswapV3({ loading: false, error: true, data: null }));
    } else {
      if (protocolState.data && endpoint.data && endpointBlocks.data) {
        const { blockchain, network } = protocolState.data;
        const [t1D, t2D, t1W] = getTimestamps();
        const { error: errorBlock, data: blocks } = await queryBlocksEthereumWithDep(endpointBlocks.data, {
          t1D,
          t2D,
          t1W,
        });
        if (errorBlock) {
          dispatch(setBlocks({ loading: false, error: true, data: null }));
          dispatch(setTokensUniswapV3({ loading: false, error: true, data: null }));
        } else if (blocks) {
          const formattedBlocks = getFormattedBlocks(blocks, blockchain, network);
          dispatch(setBlocks({ loading: false, error: false, data: formattedBlocks }));
          const { error, data } = await queryTokensAndPricesUniswapV3WithDep(endpoint.data, blocks);
          dispatch(
            setTokensUniswapV3({
              loading: false,
              error,
              data: data ? getFormattedTokensUniswapV3(data.tokens, data.etherPrices, network) : null,
            })
          );
        }
      } else {
        dispatch(setTokensUniswapV3({ loading: false, error: true, data: null }));
      }
    }
  }, [
    dispatch,
    endpoint.data,
    endpoint.error,
    endpointBlocks.data,
    endpointBlocks.error,
    protocolState.data,
    protocolState.error,
  ]);
 
  useEffect(() => {
    if (shouldFetchTokens) {
      fetchData();
    }
  }, [fetchData, shouldFetchTokens]);
 
  // return response and callback
  return tokensState;
}

In this hook we use the helper function getFormattedTokensUniswapV3 to edit some fields and create others, namely the daily, two-day and weekly changes.

Also with a useEffect we control that the fetchData callback function is only called if the tokens have not been fetched already, or if they have been fetched more than 15 mins ago.

Therefore, leveraging the persistence of the state in all the app routes I aim to have a smooth transition between pages -with no delays- once the tokens, pools or pairs, of all routes have been fetched, including the ones in other networks where a protocol operates.

We can see below the implementation of TokensUniswapV3 where the hook to get the tokens is called:

src/features/uniswapV3/ui/TokensUniswapV3.tsx
import { useTokensUniswapV3 } from 'src/features/uniswapV3/ui/hooks/useTokensUniswapV3';
import TokensTablePagination from 'src/features/shared/tokens/ui/TokensTablePagination';
 
const TokensUniswapV3 = () => {
  // get tokens
  const tokensState = useTokensUniswapV3();
 
  return <TokensTablePagination {...tokensState} />;
};
 
export default TokensUniswapV3;

TokensUniswapV3 also renders the component TokensTablePagination, which is shared with the rest of the protocols.

TokensTablePagination displays a table with the data stored in the state, as long as it is succesfully retrieved.

Otherwise, a message is shown whose content depends on the control fields in the state and the memoized data:

src/features/shared/tokens/ui/TokensTablePagination.tsx
import { useEffect, useMemo, useState } from 'react';
import Pagination from 'src/features/shared/pagination/ui/Pagination';
import { TokensState } from 'src/features/shared/tokens/core/entities/Tokens';
import { TokenExtended } from 'src/features/shared/tokens/core/entities/Tokens';
import FallbackMessage from 'src/shared/ui/FallbackMessage';
import TokensTable from 'src/features/shared/tokens/ui/TokensTable';
import styles from 'src/features/shared/tokens/styles/tableToken.module.css';
import { searchTokens } from 'src/features/shared/utils/helpers';
import { useAppSelector } from 'src/app/ui/hooks/useAppSelector';
 
const TokensTablePagination = ({ loading, error, data }: TokensState) => {
  // get protocol attributes
  const protocolState = useAppSelector((state) => state.protocol);
 
  // get query
  const query = useAppSelector((state) =>
    protocolState.data
      ? protocolState.data.name === 'uniswap-v2'
        ? state.search.queryUniswapV2
        : protocolState.data.name === 'uniswap-v3'
        ? state.search.queryUniswapV3
        : null
      : null
  );
 
  // get filtered tokens
  const tokenData = useMemo(() => {
    if (data && protocolState.data && data[protocolState.data.network]) {
      const tokens = Object.values(data[protocolState.data.network].tokens).map((p: TokenExtended) => p);
      if (query) return searchTokens(tokens, query);
      else return tokens;
    } else return null;
  }, [data, query, protocolState.data]);
 
  // set page 0 if searching tokens
  useEffect(() => {
    if (query) {
      setPageNum(0);
    }
  }, [query]);
 
  // pagination
  const itemsPerPage = 10;
  const [pageNum, setPageNum] = useState<number>(0);
 
  return (
    <div className={styles.containerOuter}>
      <div className={styles.containerInner}>
        <div className={styles.table}>
          {loading ? (
            <FallbackMessage message="Loading..." />
          ) : error ? (
            <FallbackMessage message="There has been a problem." />
          ) : tokenData ? (
            <>
              <TokensTable data={tokenData} itemsPerPage={itemsPerPage} pageNum={pageNum} />
              <Pagination
                dataLength={tokenData.length}
                itemsPerPage={itemsPerPage}
                currentPage={pageNum}
                setCurrentPage={setPageNum}
              />
            </>
          ) : (
            <FallbackMessage message="No info available." />
          )}
        </div>
      </div>
    </div>
  );
};
 
export default TokensTablePagination;

Related posts

zk-SNARK

How to build a zero-knowledge DApp

This post offers an introduction to how to develop an application capable of generating and...

Microservices

Approach to a microservices-based architecture bank application

A microservices-based architecture bank application that includes back-end and front-end applications, as well as a...

DeFi

End-to-end guide to creating an ERC-20 token-specific DApp

Decentralized application to operate with a mintable ERC-20 token

Traceability

Introducing Olive Oil Trust

Introduction to a series of posts about Olive Oil Trust

Traceability

Introducing Olive Oil Trust: front end

Next.js application that gives support to members and customers in Olive Oil Trust, and reduces...


Ready to #buidl?

Are you seeking collaboration on a Web3 project or a developer to join your team?. Then, do not hesitate to contact me by e-mail or on my LinkedIn profile. You can also find me on GitHub.