Cómo crear una aplicación de analítcas de DEXs

Ver en GitHub

Los exchanges descentralizados (DEXs) generan enormes cantidades de datos que son interesantes de leer pero difíciles de recopilar.

Afortunadamente, TheGraph ofrece la posibilidad de obtener la cantidad mínima requerida de datos que se necesitan de una manera simple utilizando GraphQL.

En esta publicación intentaré explicar cómo desarrollar una aplicación creada con Vite.js que consulte los subgraphs de Uniswap v2 y v3 en TheGraph, procese los datos, los almacene en un estado global y los represente utilizando componentes de React.js.

En este artículo

Enfoque

Para abordar el desarrollo de esta aplicación, que puede consistir en un número creciente de exchanges, he adoptado una arquitectura limpia adaptada al front end.

Mi implementación se basa en una arquitectura hexagonal en la que el estado existe fuera del núcleo y persiste globalmente utilizando Redux toolkit.

El núcleo, o core, es donde se define la lógica de negocio, esto es, entidades, repositorios, casos de uso y adaptadores.

He pensado en cada DEX compatible con la aplicación como una feature la cuál consta de su core, fuentes de datos, un slice de estado de Redux y componentes de interfaz de usuario.

Por lo tanto, si la aplicación fuera a escalar, dar soporte a un nuevo exchange descentralizado consistiría aproximádamente en agregar una nueva carpeta con código independiente en src/features.

Inversamente, eliminar un exchange de la aplicación consistiría aproximádamente en remover la carpeta correspondiente, sin afectar el resto de la lógica.

Este enfoque también garantiza un desacoplamiento completo de la lógica de negocio, la infraestructura, la interfaz de usuario y el estado de la aplicación.

En otras palabras, la lógica de negocio no está afectada por los requisitos de las fuentes de datos, la transición a un framework o librería de front end diferente sería trivial y la lógica de estado podría ser facilmente reutilizada.

Obtención de datos

Esta aplicación utiliza renderizado del lado del cliente (client side rendering o CSR) para obtener y renderizar pools y/o tokens.

Por ejemplo, para obtener el top 50 de tokens en Uniswap v3, esta app renderiza un componente de React.js con el hook useTokensUniswapV3, el cuál activará dos casos de uso o interactors:

Cada interactor llama a un método diferente de un repositorio y estos métodos se implementan en clases de TypeScript, que representan fuentes de datos.

queryBlocksEthereum conformará una entidad Blocks con números de marcas de tiempo, en segundos, y números de bloques para cuatro marcas de tiempo: current, t1D, t2D y t1W.

queryTokensAndPricesUniswapV3 utilizará estos números de bloques para conformar una entidad TokensAndPrices con:

  • 50 tokens ordenados por totalValueLockedUSD para cada marca de tiempo.

  • precios de Ether para las mencionadas marcas de tiempo, los cuales se utilizarán para calcular conversiones de ETH a USD.

La lógica para consultar los números de bloques y las marcas de tiempo se implementa junto con otro código en la carpeta compartida en el nivel de la carpeta de features, ya que otros protocolos compartirán este código.

En la siguiente sección explicaré en detalle los componentes que he mencionado.

Estrucutra de los archivos

La estructura de este repositorio se resume a continuación:

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

index.html es el punto de entrada a la aplicación en Vite.js:

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>

Este archivo renderizará el archivo main.tsx el cuál contiene el método 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')
);

Este método renderiza la aplicación en el navegador. El componente App.tsx está en la carpeta src/app, que junto con src/features, ambas contienen la mayor parte del código.

Desarrollaré el contenido de estas dos carpetas importantes a continuación.

App

src/app reúne la lógica que no está sujeta a una feature en particular sino a la aplicación en sí. Contiene el componente App.tsx, el manager de rutas, el store de Redux, estilos globales, componentes de maquetación o diseño (layout), etc

A continuación podemos ver el código del componente 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 renderiza el componente RouteManager.tsx, el cual define todas las rutas admitidas por la aplicación.

Por ejemplo, para continuar con la tarea de obtener tokens en Uniswap v3, la ruta /ethereum/uniswap-v3/tokens se ajusta a la ruta que renderiza el componente Tokens.tsx, como podemos ver a continuación:

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 renderiza los tokens del protocolo que figure en la URL, siempre y cuando la aplicación le de soporte.

Features

Como dijimos, DEXs Analytics consiste de un número de features (protocolos de exchanges descentralizados), y todos ellos tienen la misma estructura de archivos.

A continuación podemos ver la estructura de archivo de Uniswap v3:

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
   └── ...
└── ...

En las siguientes secciones explicaré estos archivos y me referiré, de nuevo, a la tarea de la obtención del top 50 de tokens en Uniswap v3, para poder ver parte del código.

Core

Esta carpeta reúne la lógica de negocio de la aplicación. Consiste de entidades, repositorios, interactores y adaptadores.

Los interactores interactuan con los repositorios para obtener entidades. Estas entidades, entonces, se pasan a los adaptadores los cuáles devuelven otras entidades que son comunes al resto de protocolos.

Todo el código en el núcleo (core) es indepediente de la infraestructura.

Entidades

La interfaz que representa el objeto con tokens devuelto por el subgraph de Uniswap v3 se puede ver a continuación:

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[];
}

Podemos inferir a partir de esta entidad que en una misma consulta se solicitan tokens y precios de ether para diferentes marcas de tiempo.

También lo podemos comprobar en la implementación del método getTokensAndPricesByBlocks en fuentes de datos.

Repositorios

Un repositorio es una interfaz que describe los métodos que se requieren. En el caso de UniswapV3Repository, reúne 3 métodos incluyendo 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;
Interactores

Los interactores son los casos de uso de sus respectivas features (o protocólos, en el caso que nos ocupa) .

Por ejemplo, el caso de uso para generar una entidad TokensAndPrices en Uniswap v3 es queryTokensAndPricesUniswapV3.

queryTokensAndPricesUniswapV3 obtiene un endpoint y un objeto Blocks y los pasa al método getTokensAndPricesByBlocks en el repositorio UniswapV3Repository.

La implementación de este método devuelve una promesa de un objeto con la interfaz TokensAndPricesUniswapV3 que se resuelve y, posteriormente, se adapta a la interfaz Tokens.

Aquí se podría crear una instancia de UniswapV3DataSource. Sin embargo, para que nuestra lógica no se vea afectada por ningún cambio en la infraestructura, obtiene una abstracción (un repositorio UniswapV3Repository) en lugar de depender de una implementación de fuente de datos específica.

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;

Por esta razón, el interactor que se importará en nuestra interfaz de usuario tendrá la dependencia -una instancia a UniswapV3DataSource- ya inyectada:

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 };

En mi opinión, este es un diseño limpio para nuestra inversión de control. Lo aprendí en este video de YouTube sobre arquitectura hexagonal

Adaptadores

Los adpatadores son funciones que convierten los objetos recibidos por los subgraphs en TheGraph en objetos con interfaces comunes a todos los protocolos.

Esto asegura que los componentes que renderizan datos siempre obtengan objetos con la misma interfaz, sin importar el protocolo.

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;

Por lo tanto, un adaptador puede ayudar como "barrera" en caso de que haya un cambio en el esquema de GraphQL del subgraph, ya que los únicos campos a cambiar serían los de la interfaz recibida, y esto no afectaría necesariamente al resto del código.

Fuentes de datos

Las fuentes de datos son clases de TypeScript que implementan un repositorio de un protocolo.

En el caso de Uniswap v3, UniswapV3Repository requiere la implementación de tres métodos. Uno de ellos es getTokensAndPricesByBlocks, el cuál se utiliza para la obtención de tokens y precios de ether.

Para ello, consulta el subgraph de Uniswap v3 con un instancia de un GraphQLClient y devuelve una promesa de un objeto con la interfaz TokensAndPricesUniswapV3.

Un objeto de Blocks se pasa a getTokensAndPricesByBlocks porque necesitamos consultar cuatro marcas de tiempo diferentes, y esto se logra pasando el número de bloque como argumento en cada entidad en la consulta.

Sin embargo, en lugar de consultar el subgraph ocho veces, una vez por cada marca de tiempo (current, t1D, t2D y t1W) para cada entidad (tokens y precios de ether), creamos una única consulta:

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/TokensUniswapV3';
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;

"..." en las líneas 12 y 19 representa código que se ha borrado por su longitud.

El argumento id_not_in, el cuál recibe una lista de identificadores, se utiliza para descartar ciertos tokens.

Estado

El estado de cada feature se reduce a un conjunto de slices de Redux Toolkit, las cuales se sitúan fuera del núcleo de la feature.

Siguiendo el ejemplo anterior, el estado de los tokens de Uniswap v3 viene dado por un slice de Redux toolkit con solo un reductor: setTokensUniswapV3.

setTokensUniswapV3 obtendrá un TokenState como payload y actualizará el estado.

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 es una interfaz con unos campos de control loading y error, y con el campo data, el cuál tiene como índice la interfaz de Record con la red de la cadena de bloques que se seleccione, y como valor un objeto con dos campos: tokens y 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;
}

Como podemos ver, los tokens para diferentes marcas de tiempo de las redes donde se desplieguen los protocolos se indexarán en un objeto Record junto con otros datos.

La idea es que los datos de todos los protocolos coexistan en sus respectivos slices de estado y persistan en un índice del estado.

De esta manera, se evitan consultas cada vez que los datos vayan a ser renderizados, a menos que haya pasado una cantidad de tiempo específica, que actualmente es 15 minutos.

Por lo tanto, los estados de todos los tokens y pools se definen en sus slices y se pasan a la store de Redux.

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>;

Interfaz de usuario

La carpeta ui reúne la implementación de los hooks y los componentes de React.js. Estos últimos obtienen tokens y pools de los hooks y renderizan otros componentes con tablas y la paginación.

En nuestro ejemplo de tokens, useTokensUniswapV3 es el hook que asigna el estado en el slice de Redux correspondiente enviando la payload adecuada a setTokensUniswapV3, dependiendo de las respuestas de cada interactor que se ejecuta.

En última instancia, este estado controlará qué mostrar en la tabla de tokens, es decir, un mensaje de carga, un mensaje de error o los tokens mismos.

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;
}

En este hook, usamos la función getFormattedTokensUniswapV3 para editar algunos campos y crear otros, es decir, los cambios diarios, de dos días y semanales.

Además, con un useEffect controlamos que la función callback fetchData solo se llame si los tokens aún no se han obtenido, o si se han obtenido hace más de 15 minutos.

Por lo tanto, aprovechando la persistencia del estado en todas las rutas de la aplicación, mi intención es tener una buena transición entre las rutas de la aplicación -sin demoras- una vez que se hayan obtenido los tokens, pools o pairs de todas las rutas que se hayan consultado, incluyendo los de otras redes donde opere un protocolo.

Podemos ver a continuación la implementación de TokensUniswapV3, donde se invoca el hook para obtener los tokens:

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 renderiza también el componente TokensTablePagination, el cuál es compartido con el resto de los protocols.

TokensTablePagination muestra una tabla con los tokens almacenados en el estado, siempre y cuando se hayan obtenido con éxito.

Si no es así, se muestra un mensaje cuyo contenido depende de los campos de control en el estado y de los datos memoizados:

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;

Artículos relacionados

Trazabilidad

Presentando Olive Oil Trust: front end

Aplicación de Next.js que da soporte a los miembros y clientes de Olive Oil Trust,...

Microservicios

Propuesta de una aplicación bancaria con arquitectura basada en microservicios

Aplicación bancaria de arquitectura basada en microservicios que incluye aplicaciones de back end, front end,...

Trazabilidad

Presentando Olive Oil Trust

Introducción a una serie de artículos acerca de Olive Oil Trust

zk-SNARK

Cómo desarrollar una DApp de conocimiento cero

Esta entrada ofrece una introducción a cómo desarrollar una aplicación capaz de generar y validar...

DeFi

Guía de principio a fin para crear una DApp específica para un token ERC-20

Aplicación descentralizada para operar con un token ERC-20 acuñable


¿Preparado para #buidl?

¿Está interesado en Web3 o en las sinergias entre la tecnología blockchain, la inteligencia artificial y el conocimiento cero?. Entonces, no dude en contactarme por e-mail o en mi perfil de LinkedIn. También me puede encontrar en GitHub.