From d845edb0aa080268bcb8a460c0eb5065c15ff600 Mon Sep 17 00:00:00 2001 From: Markus Hauge Date: Fri, 6 Dec 2024 14:57:52 +0100 Subject: [PATCH 1/3] Refactor in preparation for SVM support --- babel.config.json | 13 ++--- rollup.config.mjs | 26 ++++----- src/duneApi.ts | 65 --------------------- src/evm/api.ts | 27 +++++++++ src/evm/index.ts | 3 + src/evm/types.ts | 85 ++++++++++++++++++++++++++++ src/evm/useTokenBalances.ts | 57 +++++++++++++++++++ src/evm/useTransactions.ts | 109 ++++++++++++++++++++++++++++++++++++ src/http.ts | 63 +++++++++++++++++++++ src/index.ts | 4 +- src/provider.tsx | 44 ++++++++++----- src/types.ts | 102 --------------------------------- src/useDeepMemo.ts | 30 +++++++--- src/useTokenBalances.ts | 55 ------------------ src/useTransactions.ts | 102 --------------------------------- 15 files changed, 415 insertions(+), 370 deletions(-) delete mode 100644 src/duneApi.ts create mode 100644 src/evm/api.ts create mode 100644 src/evm/index.ts create mode 100644 src/evm/types.ts create mode 100644 src/evm/useTokenBalances.ts create mode 100644 src/evm/useTransactions.ts create mode 100644 src/http.ts delete mode 100644 src/types.ts delete mode 100644 src/useTokenBalances.ts delete mode 100644 src/useTransactions.ts diff --git a/babel.config.json b/babel.config.json index 9c4d285..202d425 100644 --- a/babel.config.json +++ b/babel.config.json @@ -1,8 +1,7 @@ { - "presets": [ - "@babel/preset-env", - "@babel/preset-react", - "@babel/preset-typescript" - ] - } - \ No newline at end of file + "presets": [ + "@babel/preset-env", + "@babel/preset-react", + "@babel/preset-typescript" + ] +} diff --git a/rollup.config.mjs b/rollup.config.mjs index 1ac57fc..be573b4 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -4,7 +4,7 @@ import resolve from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import babel from "@rollup/plugin-babel"; import typescript from "@rollup/plugin-typescript"; -import pkg from './package.json' assert { type: 'json' }; +import pkg from "./package.json" assert { type: "json" }; export default { input: "src/index.ts", @@ -12,30 +12,30 @@ export default { { file: pkg.main, format: "cjs", - sourcemap: true + sourcemap: true, }, { file: pkg.module, format: "esm", - sourcemap: true - } + sourcemap: true, + }, ], plugins: [ peerDepsExternal(), resolve({ - extensions: ['.mjs', '.js', '.jsx', '.json', '.ts', '.tsx'] + extensions: [".mjs", ".js", ".jsx", ".json", ".ts", ".tsx"], }), commonjs(), typescript({ - tsconfig: './tsconfig.json', + tsconfig: "./tsconfig.json", declaration: true, - declarationDir: './dist/types', + declarationDir: "./dist/types", + }), + babel({ + babelHelpers: "bundled", + exclude: "node_modules/**", + extensions: [".js", ".jsx", ".ts", ".tsx"], }), - babel({ - babelHelpers: 'bundled', - exclude: 'node_modules/**', - extensions: ['.js', '.jsx', '.ts', '.tsx'] - }) ], - external: Object.keys(pkg.peerDependencies || {}) + external: Object.keys(pkg.peerDependencies || {}), }; diff --git a/src/duneApi.ts b/src/duneApi.ts deleted file mode 100644 index 5908e63..0000000 --- a/src/duneApi.ts +++ /dev/null @@ -1,65 +0,0 @@ - -import {BalanceData, TokenBalancesParams, TransactionData, TransactionsParams} from "./types"; - -const BALANCE_API_BASE_URL = "https://api.dune.com/api/echo/v1/balances/evm/"; -const TRANSACTIONS_API_BASE_URL = "https://api.dune.com/api/echo/v1/transactions/evm/"; - -const getBalanceQueryParams = (params: TokenBalancesParams): URLSearchParams => { - const queryParams = new URLSearchParams(); - if (params.allChains) queryParams.append("all_chains", "true"); - if (params.chainIds) queryParams.append("chain_ids", params.chainIds); - if (params.excludeSpamTokens) queryParams.append("exclude_spam_tokens", "true"); - if (params.filters) queryParams.append("filters", params.filters); - if (params.offset) queryParams.append("offset", params.offset.toString()); - if (params.limit) queryParams.append("limit", params.limit.toString()); - if (params.metadata) queryParams.append("metadata", "logo,url"); - return queryParams; - }; - - const getTransactionsQueryParams = (params: TransactionsParams): URLSearchParams => { - const queryParams = new URLSearchParams(); - if (params.chainIds) queryParams.append("chain_ids", params.chainIds); - if (params.offset) queryParams.append("offset", params.offset.toString()); - if (params.limit) queryParams.append("limit", params.limit.toString()); - if (params.method_id) queryParams.append("method_id", params.method_id); - if (params.to) queryParams.append("to", params.to); - if (params.decode) queryParams.append("decode", params.decode.toString()); - return queryParams; - }; - - -export async function fetchBalances(walletAddress: string, params: TokenBalancesParams, duneApiKey: string): Promise { - const queryParams = getBalanceQueryParams(params) - const apiUrl = `${BALANCE_API_BASE_URL}/${walletAddress}?${queryParams.toString()}`; - - const response = await fetch(apiUrl, { - method: 'GET', - headers: { - "x-dune-api-key": duneApiKey - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); -} - -export async function fetchTransactions(walletAddress: string, params: TransactionsParams, duneApiKey: string): Promise { - const queryParams = getTransactionsQueryParams(params) - const apiUrl = `${TRANSACTIONS_API_BASE_URL}/${walletAddress}?${queryParams.toString()}`; - - const response = await fetch(apiUrl, { - method: 'GET', - headers: { - "x-dune-api-key": duneApiKey - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return response.json(); -} \ No newline at end of file diff --git a/src/evm/api.ts b/src/evm/api.ts new file mode 100644 index 0000000..6c101b2 --- /dev/null +++ b/src/evm/api.ts @@ -0,0 +1,27 @@ +import { HttpClient } from "../http"; +import { + TokenBalancesData, + TokenBalancesParams, + TransactionsData, + TransactionsParams, +} from "./types"; + +export function fetchTokenBalances( + client: HttpClient, + walletAddress: string, + params?: TokenBalancesParams +): Promise { + return client.get(`/balances/evm/${walletAddress}`, { + query: params, + }); +} + +export function fetchTransactions( + client: HttpClient, + walletAddress: string, + params?: TransactionsParams +): Promise { + return client.get(`/transactions/evm/${walletAddress}`, { + query: params, + }); +} diff --git a/src/evm/index.ts b/src/evm/index.ts new file mode 100644 index 0000000..368b9bb --- /dev/null +++ b/src/evm/index.ts @@ -0,0 +1,3 @@ +export { useTokenBalances } from "./useTokenBalances"; +export { useTransactions } from "./useTransactions"; +export { fetchTokenBalances, fetchTransactions } from "./api"; diff --git a/src/evm/types.ts b/src/evm/types.ts new file mode 100644 index 0000000..708441d --- /dev/null +++ b/src/evm/types.ts @@ -0,0 +1,85 @@ +export type TokenBalancesParams = { + /** Comma separated list of chain ids to get balances for */ + chain_ids?: "all" | number[]; + + /** Specify this to exclude spam tokens from the response */ + exclude_spam_tokens?: string; + + /** Specify `erc20` or `native` to get only ERC20 tokens or native assets, respectively */ + filters?: "erc20" | "native"; + + /** Maximum number of transactions to return */ + limit?: number; + + /** The offset to paginate through result sets. This is a cursor being passed from the previous response, only use what the backend returns here. */ + offset?: string; + + /** A comma separated list of additional metadata fields to include for each token. Supported values: logo, url */ + metadata?: ("logo" | "url")[]; +}; + +export type TokenBalance = { + chain: string; + chain_id: number; + address: string; + amount: string; + symbol?: string; + decimals?: number; + price_usd?: number; + value_usd?: number; +}; + +export type TokenBalancesData = { + request_time: string; + response_time: string; + wallet_address: string; + balances: TokenBalance[]; +}; + +export type TransactionsParams = { + /** The offset to paginate through result sets. This is a cursor being passed from the previous response, only use what the backend has returned on previous responses. */ + offset?: string; + + /** Maximum number of transactions to return */ + limit?: number; + + /** Comma separated list of chain ids to get transactions for */ + chain_ids?: "all" | number[]; + + /** Return only transactions with this method id */ + method_id?: string; + + /** Filter transactions to a given address */ + to?: string; + + /** Return abi decoded transactions and logs */ + decode?: boolean; +}; + +export type Transaction = { + address: string; + block_hash: string; + block_number: string; + block_time: string; + block_version: number; + chain: string; + from: string; + to: string; + data: string; + gas_price: string; + hash: string; + index: string; + max_fee_per_gas: string; + max_priority_fee_per_gas: string; + nonce: string; + transaction_type: string; + value: string; +}; + +export type TransactionsData = { + request_time: string; + response_time: string; + wallet_address: string; + transactions: Transaction[]; + next_offset?: string | null; +}; diff --git a/src/evm/useTokenBalances.ts b/src/evm/useTokenBalances.ts new file mode 100644 index 0000000..63d8c7a --- /dev/null +++ b/src/evm/useTokenBalances.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { useDuneClient } from "../provider"; +import { useDeepMemo } from "../useDeepMemo"; +import { fetchTokenBalances } from "./api"; +import { TokenBalancesData, TokenBalancesParams } from "./types"; + +export const useTokenBalances = ( + walletAddress: string, + params?: TokenBalancesParams +) => { + const [state, setState] = useState<{ + isLoading: boolean; + data: TokenBalancesData | null; + error: Error | null; + }>({ + isLoading: true, + data: null, + error: null, + }); + + const memoizedParams = useDeepMemo(() => params, params); + const client = useDuneClient(); + + useEffect(() => { + const fetchDataAsync = async () => { + setState((prevState) => ({ ...prevState, isLoading: true })); + + try { + const result = await fetchTokenBalances( + client, + walletAddress, + memoizedParams + ); + + setState({ + isLoading: false, + data: result, + error: null, + }); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + + setState({ + isLoading: false, + data: null, + error, + }); + } + }; + + fetchDataAsync(); + }, [client, walletAddress, memoizedParams]); + + return state; +}; diff --git a/src/evm/useTransactions.ts b/src/evm/useTransactions.ts new file mode 100644 index 0000000..37e7185 --- /dev/null +++ b/src/evm/useTransactions.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from "react"; +import { useDuneClient } from "../provider"; +import { useDeepMemo } from "../useDeepMemo"; +import { fetchTransactions } from "./api"; +import { TransactionsData, TransactionsParams } from "./types"; + +export const useTransactions = ( + walletAddress: string, + params?: TransactionsParams +) => { + const [state, setState] = useState<{ + isLoading: boolean; + data: TransactionsData | null; + error: Error | null; + nextOffset: string | null; // Track next_offset + offsets: string[]; // Store offsets for each page + currentPage: number; // Track the current page + }>({ + isLoading: true, + data: null, + error: null, + nextOffset: null, // Next offset from the API + offsets: [], // List of offsets corresponding to pages + currentPage: 0, // Start at the first page + }); + + const memoizedParams = useDeepMemo(() => params, [params]); + const client = useDuneClient(); + + // Function to fetch data for a specific page + const fetchDataAsync = useCallback( + async (offset: string | null) => { + if (!walletAddress) return; + + setState((prevState) => ({ ...prevState, isLoading: true })); + + try { + // Convert offset to number or undefined + const updatedParams = { + ...memoizedParams, + offset: offset ?? undefined, + }; + const result = await fetchTransactions( + client, + walletAddress, + updatedParams + ); + + setState((prevState) => ({ + ...prevState, + data: result, + nextOffset: result.next_offset || null, + isLoading: false, + offsets: offset ? [...prevState.offsets, offset] : prevState.offsets, + })); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + + setState({ + data: null, + error, + isLoading: false, + nextOffset: null, + offsets: [], + currentPage: 0, + }); + } + }, + [client, walletAddress, memoizedParams] + ); + + // Trigger fetch when walletAddress or params change + useEffect(() => { + // Fetch the first page on initial load or when walletAddress changes + fetchDataAsync(null); + }, [fetchDataAsync]); + + // Function to go to the next page + const nextPage = () => { + if (state.nextOffset) { + fetchDataAsync(state.nextOffset); // Fetch using the next offset + setState((prevState) => ({ + ...prevState, + currentPage: prevState.currentPage + 1, // Update page number + })); + } + }; + + // Function to go to the previous page + const previousPage = () => { + if (state.currentPage > 0) { + // Use the offset corresponding to the previous page + const previousOffset = state.offsets[state.currentPage - 1]; + fetchDataAsync(previousOffset); + setState((prevState) => ({ + ...prevState, + currentPage: prevState.currentPage - 1, + })); + } + }; + + return { + ...state, + nextPage, + previousPage, + }; +}; diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..20f8ffc --- /dev/null +++ b/src/http.ts @@ -0,0 +1,63 @@ +export type QueryParamValue = string | number | boolean; + +export type QueryParams = Record< + string, + QueryParamValue | QueryParamValue[] | undefined +>; + +export type HttpRequestOptions = { + query?: QueryParams; +}; + +export class HttpClient { + constructor( + public readonly baseUrl: string, + public readonly apiKey: string + ) {} + + async get(path: string, options?: HttpRequestOptions): Promise { + return this.request("GET", path, options); + } + + async request( + method: string, + path: string, + { query }: HttpRequestOptions = {} + ): Promise { + const url = new URL(`${this.baseUrl}${path}`); + + if (query !== undefined) { + appendQueryParams(url.searchParams, query); + } + + const response = await fetch(url, { + method, + headers: { + "X-Dune-Api-Key": this.apiKey, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + } +} + +function appendQueryParams( + searchParams: URLSearchParams, + queryParams: QueryParams +) { + for (const [key, value] of Object.entries(queryParams)) { + if (value === undefined) { + continue; + } + + if (Array.isArray(value)) { + searchParams.append(key, value.join(",")); + } else { + searchParams.append(key, value.toString()); + } + } +} diff --git a/src/index.ts b/src/index.ts index 0b24b28..0666d29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,2 @@ -export { useTokenBalances } from "./useTokenBalances"; -export { useTransactions } from "./useTransactions"; export { DuneProvider } from "./provider"; -export { fetchBalances } from "./duneApi"; +export * as evm from "./evm"; diff --git a/src/provider.tsx b/src/provider.tsx index 2c01526..a23a7d0 100644 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -1,35 +1,49 @@ -import React, { createContext, useContext } from "react"; +import { createContext, PropsWithChildren, useContext, useRef } from "react"; +import { HttpClient } from "./http"; interface DuneContextType { - duneApiKey: string; + client: HttpClient; } -const DuneContext = createContext({duneApiKey: ""}) +const DuneContext = createContext(undefined); -export const useDuneContext = () => { +export function useDuneContext() { const context = useContext(DuneContext); + if (!context) { throw new Error("useDuneContext must be used within a DuneProvider"); } + return context; -}; +} -export const useGetApiKey = () => { +export function useDuneClient() { const context = useDuneContext(); - return context.duneApiKey; -}; - -interface DuneProviderProps { - duneApiKey: string; - children: React.ReactNode; + return context.client; } -export const DuneProvider= ({ - duneApiKey, +type DuneProviderProps = PropsWithChildren<{ + apiKey: string; + baseUrl?: string; +}>; + +export const DuneProvider = ({ + apiKey, + baseUrl = "https://api.dune.com/api/echo/v1", children, }: DuneProviderProps) => { + const clientRef = useRef(); + + if ( + clientRef.current === undefined || + clientRef.current.apiKey !== apiKey || + clientRef.current.baseUrl !== baseUrl + ) { + clientRef.current = new HttpClient(baseUrl, apiKey); + } + return ( - + {children} ); diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index c7ea85c..0000000 --- a/src/types.ts +++ /dev/null @@ -1,102 +0,0 @@ -// Use 'type' for simple type aliases -type TokenBalance = { - chain: string; - chain_id: number; - address: string; - amount: string; - symbol?: string; - decimals?: number; - price_usd?: number; - value_usd?: number; - }; - - export type BalanceData = { - request_time: string; - response_time: string; - wallet_address: string; - balances: TokenBalance[]; - }; - - export type FetchError = Error & { - status?: number; - info?: unknown; - }; - - export type ResponseData = { - data?: BalanceData; - error?: FetchError; - isLoading: boolean; - }; - - export type TokenBalancesParams = { - /** Specify this to get native balances for a long tail of EVM chains, where we don't support ERC20 assets */ - allChains?: boolean; - /** Comma separated list of chain ids to get balances for */ - chainIds?: string; - /** Specify this to exclude spam tokens from the response */ - excludeSpamTokens?: boolean; - /** Specify `erc20` or `native` to get only ERC20 tokens or native assets, respectively */ - filters?: "erc20" | "native"; - /** Maximum number of transactions to return */ - limit?: number; - /** The offset to paginate through result sets. This is a cursor being passed from the previous response, only use what the backend returns here. */ - offset?: string; - /** A comma separated list of additional metadata fields to include for each token. Supported values: logo, url */ - metadata?: string | null; - }; - - export type TransactionsParams = { - /** The offset to paginate through result sets. This is a cursor being passed from the previous response, only use what the backend has returned on previous responses. */ - offset?: string | null; - - /** Maximum number of transactions to return */ - limit?: number | null; - - /** Comma separated list of chain ids to get transactions for */ - chainIds?: string | null; - - /** Return only transactions with this method id */ - method_id?: string | null; - - /** Filter transactions to a given address */ - to?: string | null; - - /** Return abi decoded transactions and logs */ - decode?: boolean | null; - }; - - export type UseTokenBalancesConfig = { - queryOptions?: { - refetchOnWindowFocus?: boolean; - staleTime?: number; - refetchInterval?: number; - } - }; - -export type Transaction = { - address: string; - block_hash: string; - block_number: string; - block_time: string; - block_version: number; - chain: string; - from: string; - to: string; - data: string; - gas_price: string; - hash: string; - index: string; - max_fee_per_gas: string; - max_priority_fee_per_gas: string; - nonce: string; - transaction_type: string; - value: string; -}; - -export type TransactionData = { - request_time: string; - response_time: string; - wallet_address: string; - transactions: Transaction[]; - next_offset?: string | null; -}; diff --git a/src/useDeepMemo.ts b/src/useDeepMemo.ts index b6b66d0..cd70cad 100644 --- a/src/useDeepMemo.ts +++ b/src/useDeepMemo.ts @@ -25,17 +25,31 @@ export function useDeepMemo( // Custom deep equality function function deepEqual(obj1: any, obj2: any): boolean { - if (obj1 === obj2) return true; - if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) return false; - + if (obj1 === obj2) { + return true; + } + + if ( + typeof obj1 !== "object" || + obj1 === null || + typeof obj2 !== "object" || + obj2 === null + ) { + return false; + } + const keys1 = Object.keys(obj1); const keys2 = Object.keys(obj2); - - if (keys1.length !== keys2.length) return false; - + + if (keys1.length !== keys2.length) { + return false; + } + for (const key of keys1) { - if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) return false; + if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) { + return false; + } } - + return true; } diff --git a/src/useTokenBalances.ts b/src/useTokenBalances.ts deleted file mode 100644 index 27d21d3..0000000 --- a/src/useTokenBalances.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - TokenBalancesParams, - BalanceData, - FetchError, - } from "./types"; - import { fetchBalances } from "./duneApi"; - import { useState, useEffect } from "react"; - import { useDeepMemo } from "./useDeepMemo"; - import { useGetApiKey } from "./provider"; - - export const useTokenBalances = ( - walletAddress: string, - params: TokenBalancesParams - ) => { - const [state, setState] = useState<{ - data: BalanceData | null; - error: FetchError | null; - isLoading: boolean; - }>({ - data: null, - error: null, - isLoading: false, - }); - - const memoizedParams = useDeepMemo(() => params, params); - const apiKey = useGetApiKey(); - - useEffect(() => { - if (!apiKey) return; - const fetchDataAsync = async () => { - if (!walletAddress) return; - - setState(prevState => ({ ...prevState, isLoading: true })); - - try { - const result = await fetchBalances(walletAddress, memoizedParams, apiKey); - setState({ - data: result, - error: null, - isLoading: false, - }); - } catch (err) { - setState({ - data: null, - error: err as FetchError, - isLoading: false, - }); - } - }; - - fetchDataAsync(); - }, [walletAddress, memoizedParams, apiKey]); - - return state; - }; \ No newline at end of file diff --git a/src/useTransactions.ts b/src/useTransactions.ts deleted file mode 100644 index 834553e..0000000 --- a/src/useTransactions.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - TransactionData, - FetchError, - TransactionsParams, - } from "./types"; - import { fetchTransactions } from "./duneApi"; - import { useState, useEffect } from "react"; - import { useDeepMemo } from "./useDeepMemo"; - import { useGetApiKey } from "./provider"; - - export const useTransactions = ( - walletAddress: string, - params: TransactionsParams - ) => { - const [state, setState] = useState<{ - data: TransactionData | null; - error: FetchError | null; - isLoading: boolean; - nextOffset: string | null; // Track next_offset - offsets: string[]; // Store offsets for each page - currentPage: number; // Track the current page - }>({ - data: null, - error: null, - isLoading: false, - nextOffset: null, // Next offset from the API - offsets: [], // List of offsets corresponding to pages - currentPage: 0, // Start at the first page - }); - - const memoizedParams = useDeepMemo(() => params, [params]); - const apiKey = useGetApiKey(); - - // Function to fetch data for a specific page - const fetchDataAsync = async (offset: string | null) => { - if (!walletAddress) return; - - setState(prevState => ({ ...prevState, isLoading: true })); - - try { - // Convert offset to number or undefined - const updatedParams = { - ...memoizedParams, - offset: offset ?? undefined - }; - const result = await fetchTransactions(walletAddress, updatedParams, apiKey); - - setState(prevState => ({ - ...prevState, - data: result as TransactionData, // Type assertion - nextOffset: result.next_offset || null, - isLoading: false, - offsets: offset ? [...prevState.offsets, offset] : prevState.offsets, - })); - } catch (err) { - setState({ - data: null, - error: err as FetchError, - isLoading: false, - nextOffset: null, - offsets: [], - currentPage: 0, - }); - } - }; - - // Trigger fetch when walletAddress or params change - useEffect(() => { - // Fetch the first page on initial load or when walletAddress changes - fetchDataAsync(null); - }, [walletAddress, memoizedParams, apiKey]); - - // Function to go to the next page - const nextPage = () => { - if (state.nextOffset) { - fetchDataAsync(state.nextOffset); // Fetch using the next offset - setState(prevState => ({ - ...prevState, - currentPage: prevState.currentPage + 1, // Update page number - })); - } - }; - - // Function to go to the previous page - const previousPage = () => { - if (state.currentPage > 0) { - // Use the offset corresponding to the previous page - const previousOffset = state.offsets[state.currentPage - 1]; - fetchDataAsync(previousOffset); - setState(prevState => ({ - ...prevState, - currentPage: prevState.currentPage - 1, - })); - } - }; - - return { - ...state, - nextPage, - previousPage, - }; - }; From 78471cb0c6128d890b673be12e69d1edb08e9811 Mon Sep 17 00:00:00 2001 From: Markus Hauge Date: Tue, 10 Dec 2024 12:10:37 +0100 Subject: [PATCH 2/3] Add SVM support --- src/index.ts | 1 + src/svm/api.ts | 27 +++++++++ src/svm/index.ts | 3 + src/svm/types.ts | 63 +++++++++++++++++++++ src/svm/useTokenBalances.ts | 57 +++++++++++++++++++ src/svm/useTransactions.ts | 109 ++++++++++++++++++++++++++++++++++++ 6 files changed, 260 insertions(+) create mode 100644 src/svm/api.ts create mode 100644 src/svm/index.ts create mode 100644 src/svm/types.ts create mode 100644 src/svm/useTokenBalances.ts create mode 100644 src/svm/useTransactions.ts diff --git a/src/index.ts b/src/index.ts index 0666d29..1eff93c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,3 @@ export { DuneProvider } from "./provider"; export * as evm from "./evm"; +export * as svm from "./svm"; diff --git a/src/svm/api.ts b/src/svm/api.ts new file mode 100644 index 0000000..bf679d7 --- /dev/null +++ b/src/svm/api.ts @@ -0,0 +1,27 @@ +import { HttpClient } from "../http"; +import { + TokenBalancesData, + TokenBalancesParams, + TransactionsData, + TransactionsParams, +} from "./types"; + +export function fetchTokenBalances( + client: HttpClient, + walletAddress: string, + params?: TokenBalancesParams +): Promise { + return client.get(`/balances/svm/${walletAddress}`, { + query: params, + }); +} + +export function fetchTransactions( + client: HttpClient, + walletAddress: string, + params?: TransactionsParams +): Promise { + return client.get(`/transactions/svm/${walletAddress}`, { + query: params, + }); +} diff --git a/src/svm/index.ts b/src/svm/index.ts new file mode 100644 index 0000000..368b9bb --- /dev/null +++ b/src/svm/index.ts @@ -0,0 +1,3 @@ +export { useTokenBalances } from "./useTokenBalances"; +export { useTransactions } from "./useTransactions"; +export { fetchTokenBalances, fetchTransactions } from "./api"; diff --git a/src/svm/types.ts b/src/svm/types.ts new file mode 100644 index 0000000..7802d47 --- /dev/null +++ b/src/svm/types.ts @@ -0,0 +1,63 @@ +export type TokenBalancesParams = { + /** + * Either 'all' or a comma separated list of chains to get balances for. + * Currently supports 'solana' and 'eclipse' + */ + chains?: "all" | string[]; + + /** + * The offset to paginate through result sets. This is a cursor being passed + * from the previous response, only use what the backend returns here. + */ + offset?: string; + + /** + * Maximum number of balances to return + */ + limit?: number; +}; + +export type TokenBalancesData = { + balances: TokenBalance[]; + request_time: string; + response_time: string; + wallet_address: string; + next_offset?: string | null; +}; + +export type TokenBalance = { + address: string; + amount: string; + chain: string; + chain_id: number | null; + decimals: number | null; + symbol: string | null; + price_usd: number | null; + value_usd: number | null; +}; + +export type TransactionsParams = { + /** + * The offset to paginate through result sets. This is a cursor being passed + * from the previous response, only use what the backend has returned here. + */ + offset?: string; + + /** + * Maximum number of transactions to return + */ + limit?: number; +}; + +export type TransactionsData = { + transactions: Transaction[]; + next_offset?: string | null; +}; + +export type Transaction = { + address: string; + block_slot: number; + block_time?: number | null; + chain: "solana" | "eclipse"; + raw_transaction: any; +}; diff --git a/src/svm/useTokenBalances.ts b/src/svm/useTokenBalances.ts new file mode 100644 index 0000000..63d8c7a --- /dev/null +++ b/src/svm/useTokenBalances.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { useDuneClient } from "../provider"; +import { useDeepMemo } from "../useDeepMemo"; +import { fetchTokenBalances } from "./api"; +import { TokenBalancesData, TokenBalancesParams } from "./types"; + +export const useTokenBalances = ( + walletAddress: string, + params?: TokenBalancesParams +) => { + const [state, setState] = useState<{ + isLoading: boolean; + data: TokenBalancesData | null; + error: Error | null; + }>({ + isLoading: true, + data: null, + error: null, + }); + + const memoizedParams = useDeepMemo(() => params, params); + const client = useDuneClient(); + + useEffect(() => { + const fetchDataAsync = async () => { + setState((prevState) => ({ ...prevState, isLoading: true })); + + try { + const result = await fetchTokenBalances( + client, + walletAddress, + memoizedParams + ); + + setState({ + isLoading: false, + data: result, + error: null, + }); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + + setState({ + isLoading: false, + data: null, + error, + }); + } + }; + + fetchDataAsync(); + }, [client, walletAddress, memoizedParams]); + + return state; +}; diff --git a/src/svm/useTransactions.ts b/src/svm/useTransactions.ts new file mode 100644 index 0000000..37e7185 --- /dev/null +++ b/src/svm/useTransactions.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from "react"; +import { useDuneClient } from "../provider"; +import { useDeepMemo } from "../useDeepMemo"; +import { fetchTransactions } from "./api"; +import { TransactionsData, TransactionsParams } from "./types"; + +export const useTransactions = ( + walletAddress: string, + params?: TransactionsParams +) => { + const [state, setState] = useState<{ + isLoading: boolean; + data: TransactionsData | null; + error: Error | null; + nextOffset: string | null; // Track next_offset + offsets: string[]; // Store offsets for each page + currentPage: number; // Track the current page + }>({ + isLoading: true, + data: null, + error: null, + nextOffset: null, // Next offset from the API + offsets: [], // List of offsets corresponding to pages + currentPage: 0, // Start at the first page + }); + + const memoizedParams = useDeepMemo(() => params, [params]); + const client = useDuneClient(); + + // Function to fetch data for a specific page + const fetchDataAsync = useCallback( + async (offset: string | null) => { + if (!walletAddress) return; + + setState((prevState) => ({ ...prevState, isLoading: true })); + + try { + // Convert offset to number or undefined + const updatedParams = { + ...memoizedParams, + offset: offset ?? undefined, + }; + const result = await fetchTransactions( + client, + walletAddress, + updatedParams + ); + + setState((prevState) => ({ + ...prevState, + data: result, + nextOffset: result.next_offset || null, + isLoading: false, + offsets: offset ? [...prevState.offsets, offset] : prevState.offsets, + })); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + + setState({ + data: null, + error, + isLoading: false, + nextOffset: null, + offsets: [], + currentPage: 0, + }); + } + }, + [client, walletAddress, memoizedParams] + ); + + // Trigger fetch when walletAddress or params change + useEffect(() => { + // Fetch the first page on initial load or when walletAddress changes + fetchDataAsync(null); + }, [fetchDataAsync]); + + // Function to go to the next page + const nextPage = () => { + if (state.nextOffset) { + fetchDataAsync(state.nextOffset); // Fetch using the next offset + setState((prevState) => ({ + ...prevState, + currentPage: prevState.currentPage + 1, // Update page number + })); + } + }; + + // Function to go to the previous page + const previousPage = () => { + if (state.currentPage > 0) { + // Use the offset corresponding to the previous page + const previousOffset = state.offsets[state.currentPage - 1]; + fetchDataAsync(previousOffset); + setState((prevState) => ({ + ...prevState, + currentPage: prevState.currentPage - 1, + })); + } + }; + + return { + ...state, + nextPage, + previousPage, + }; +}; From c42e9cbd4380e00d249730595467b97635022386 Mon Sep 17 00:00:00 2001 From: Markus Hauge Date: Tue, 10 Dec 2024 12:11:28 +0100 Subject: [PATCH 3/3] Bump major version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 179dfab..e6b5a57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@duneanalytics/hooks", - "version": "1.0.8", + "version": "2.0.0", "description": "A collection of React hooks for Dune Analytics.", "main": "dist/index.cjs.js", "module": "dist/index.esm.js",