How to build a DEXs analytics application
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:
<!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()
:
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
:
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:
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:
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
.
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.
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:
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.
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:
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.
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
.
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.
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.
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:
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:
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;