Skip to content

Commit

Permalink
feat: improve data requests for trading pairs (#356)
Browse files Browse the repository at this point in the history
  • Loading branch information
VanishMax authored Feb 6, 2025
1 parent 202c0ec commit fa7b6eb
Show file tree
Hide file tree
Showing 5 changed files with 129 additions and 100 deletions.
7 changes: 5 additions & 2 deletions src/pages/explore/api/use-summaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,26 @@ import { InfiniteData, QueryKey, useInfiniteQuery } from '@tanstack/react-query'
import { SummaryData } from '@/shared/api/server/summary/types';
import { DurationWindow } from '@/shared/utils/duration';
import { apiFetch } from '@/shared/utils/api-fetch';
import { useDebounce } from '@/shared/utils/use-debounce';

/// The base limit will need to be increased as more trading pairs are added to the explore page.
const BASE_LIMIT = 20;
const BASE_PAGE = 0;
const BASE_WINDOW: DurationWindow = '1d';

export const useSummaries = (search: string) => {
const debouncedSearch = useDebounce(search, 400);

return useInfiniteQuery<SummaryData[], Error, InfiniteData<SummaryData[]>, QueryKey, number>({
queryKey: ['summaries', search],
queryKey: ['summaries', debouncedSearch],
staleTime: 1000 * 60 * 5,
initialPageParam: BASE_PAGE,
getNextPageParam: (lastPage, _, lastPageParam) => {
return lastPage.length ? lastPageParam + 1 : undefined;
},
queryFn: async ({ pageParam }) => {
return apiFetch<SummaryData[]>('/api/summaries', {
search,
search: debouncedSearch,
limit: BASE_LIMIT.toString(),
offset: (pageParam * BASE_LIMIT).toString(),
durationWindow: BASE_WINDOW,
Expand Down
11 changes: 10 additions & 1 deletion src/pages/explore/ui/pairs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,10 @@ export const ExplorePairs = () => {
/>
</div>

<div ref={parent} className='grid grid-cols-[1fr_1fr_1fr_1fr_128px_56px] gap-2 overflow-auto'>
<div
ref={parent}
className='grid grid-cols-[1fr_1fr_1fr_1fr_128px_56px] gap-2 overflow-y-auto overflow-x-auto desktop:overflow-x-hidden'
>
<div className='grid grid-cols-subgrid col-span-6 py-2 px-3'>
<Text detail color='text.secondary' align='left'>
Pair
Expand Down Expand Up @@ -100,6 +103,12 @@ export const ExplorePairs = () => {
</>
)}

{!isLoading && data?.pages[0]?.length === 0 && (
<div className='py-5 col-span-5 text-text-secondary'>
<Text small>No pairs found matching your search</Text>
</div>
)}

{data?.pages.map(page =>
page.map(summary => (
<PairCard
Expand Down
150 changes: 71 additions & 79 deletions src/shared/api/server/summary/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,100 +6,53 @@ import { adaptSummary, SummaryData } from '@/shared/api/server/summary/types.ts'
import { AssetId, Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';
import { serialize, Serialized } from '@/shared/utils/serializer';
import { getStablecoins } from '@/shared/utils/stables';
import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view';

interface GetPairsParams {
window: DurationWindow;
window: DurationWindow | null;
limit: number;
offset: number;
search: string;
}

const getURLParams = (url: string): GetPairsParams => {
const { searchParams } = new URL(url);
const limit = Number(searchParams.get('limit')) || 15;
const offset = Number(searchParams.get('offset')) || 0;
const search = searchParams.get('search') ?? '';
const window = searchParams.get('durationWindow');
return {
limit,
offset,
search,
window: window as DurationWindow | null,
};
};

const getAssetById = (allAssets: Metadata[], id: Buffer): Metadata | undefined => {
return allAssets.find(asset => {
return asset.penumbraAssetId?.equals(new AssetId({ inner: id }));
});
};

export const getAllSummaries = async (
params: GetPairsParams,
): Promise<Serialized<SummaryData>[]> => {
const chainId = process.env['PENUMBRA_CHAIN_ID'];
if (!chainId) {
throw new Error('PENUMBRA_CHAIN_ID is not set');
}

const registryClient = new ChainRegistryClient();
const registry = await registryClient.remote.get(chainId);
const allAssets = registry.getAllAssets();

const { stablecoins, usdc } = getStablecoins(allAssets, 'USDC');
if (!usdc) {
throw new Error('usdc asset does not exist');
}

const results = await pindexer.summaries({
...params,
stablecoins: stablecoins.map(asset => asset.penumbraAssetId) as AssetId[],
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style -- usdc is defined
usdc: usdc.penumbraAssetId as AssetId,
});

const summaries = await Promise.all(
results.map(summary => {
const baseAsset = getAssetById(allAssets, summary.asset_start);
const quoteAsset = getAssetById(allAssets, summary.asset_end);
if (!baseAsset || !quoteAsset) {
return undefined;
}

const data = adaptSummary(
summary,
baseAsset,
quoteAsset,
usdc,
summary.candles,
summary.candle_times,
);

// Filter out pairs with zero liquidity and trading volume
if (
(data.liquidity.valueView.value?.amount?.lo &&
data.directVolume.valueView.value?.amount?.lo) === 0n
) {
return;
}

return data;
}),
);

// Sorting by decreasing 24 hour volume in the pool
// TODO: sort directly in SQL to avoid breaking server-side pagination
const sortedSummaries = summaries.filter(Boolean).sort((a, b) => {
if (!a || !b) {
return 0;
}

const aVolume = Number(getFormattedAmtFromValueView(a.directVolume)) || 0;
const bVolume = Number(getFormattedAmtFromValueView(b.directVolume)) || 0;

return bVolume - aVolume;
});

return sortedSummaries.map(data => serialize(data)) as Serialized<SummaryData>[];
};

export type SummariesResponse = Serialized<SummaryData>[] | { error: string };

export const GET = async (req: NextRequest): Promise<NextResponse<SummariesResponse>> => {
try {
const { searchParams } = new URL(req.url);
const limit = Number(searchParams.get('limit')) || 15;
const offset = Number(searchParams.get('offset')) || 0;
const search = searchParams.get('search') ?? '';
const window = searchParams.get('durationWindow');
const chainId = process.env['PENUMBRA_CHAIN_ID'];
if (!chainId) {
return NextResponse.json({ error: 'Error: PENUMBRA_CHAIN_ID is not set' }, { status: 500 });
}

const registryClient = new ChainRegistryClient();
const registry = await registryClient.remote.get(chainId);
const allAssets = registry.getAllAssets();

const { stablecoins, usdc } = getStablecoins(allAssets, 'USDC');
if (!usdc) {
return NextResponse.json({ error: 'Error: USDC asset does not exist' }, { status: 500 });
}

const { window, search, limit, offset } = getURLParams(req.url);
if (!window || !isDurationWindow(window)) {
return NextResponse.json(
{
Expand All @@ -109,14 +62,53 @@ export const GET = async (req: NextRequest): Promise<NextResponse<SummariesRespo
);
}

const result = await getAllSummaries({
// If 'search' param is provided, find AssetIds with names matching the string
const searchAssets = !search
? undefined
: (allAssets
.filter(
asset =>
asset.penumbraAssetId &&
(asset.name.toLowerCase().includes(search.toLowerCase()) ||
asset.symbol.toLowerCase().includes(search.toLowerCase())),
)
.map(asset => asset.penumbraAssetId) as AssetId[]);
if (searchAssets?.length === 0) {
return NextResponse.json([]);
}

const results = await pindexer.summaries({
window,
limit,
offset,
search,
searchAssets,
stablecoins: stablecoins.map(asset => asset.penumbraAssetId) as AssetId[],
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style -- usdc is defined
usdc: usdc.penumbraAssetId as AssetId,
});

return NextResponse.json(result);
const summaries = await Promise.all(
results.map(summary => {
const baseAsset = getAssetById(allAssets, summary.asset_start);
const quoteAsset = getAssetById(allAssets, summary.asset_end);
if (!baseAsset || !quoteAsset) {
return undefined;
}

return adaptSummary(
summary,
baseAsset,
quoteAsset,
usdc,
summary.candles ?? [],
summary.candle_times ?? [],
);
}),
);

return NextResponse.json(
summaries.filter(Boolean).map(data => serialize(data)) as Serialized<SummaryData>[],
);
} catch (error) {
return NextResponse.json({ error: `Error: ${(error as Error).message}` }, { status: 500 });
}
Expand Down
41 changes: 23 additions & 18 deletions src/shared/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,7 @@ class Pindexer {
offset,
stablecoins,
usdc,
// TODO: implement search of assets
// search,
searchAssets,
}: {
/** Window duration of information of the pairs */
window: DurationWindow;
Expand All @@ -157,8 +156,8 @@ class Pindexer {
candlesInterval?: `${number}${number} hours` | `${number} hour`;
/** The list of AssetIDs to exclude from base assets (stable coins can only be quote assets) */
stablecoins: AssetId[];
/** A string that filters the assets by name */
search: string;
/** An array of assetId to filter by */
searchAssets?: AssetId[];
limit: number;
offset: number;
usdc: AssetId;
Expand All @@ -171,24 +170,31 @@ class Pindexer {
.selectAll();

// Selects only distinct pairs (USDT/USDC, but not reverse) with its data
const summaryTable = this.db
let summaryTable = this.db
.selectFrom('dex_ex_pairs_summary')
// Filters out the reversed pairs (e.g. if found UM/OSMO, then there won't be OSMO/UM)
.distinctOn(sql<string>`least(asset_start, asset_end), greatest(asset_start, asset_end)`)
.selectAll()
.where('the_window', '=', window)
.where('price', '!=', 0)
// Make sure pair bases are not stablecoins
.where(
'asset_start',
'not in',
stablecoins.map(asset => Buffer.from(asset.inner)),
)
.where(exp =>
exp.and([
exp.eb('the_window', '=', window),
exp.eb('price', '!=', 0),
// Filters out pairs where stablecoins are base assets (e.g. no USDC/UM, only UM/USDC)
exp.eb(
exp.ref('asset_start'),
'not in',
stablecoins.map(asset => Buffer.from(asset.inner)),
),
]),
exp.or([exp.eb('liquidity', '!=', 0), exp.eb('direct_volume_over_window', '!=', 0)]),
);

// Filter the assets if searchAssets is provided
if (typeof searchAssets !== 'undefined') {
const buffers = searchAssets.map(asset => Buffer.from(asset.inner));
summaryTable = summaryTable.where(exp =>
exp.or([exp.eb('asset_start', 'in', buffers), exp.eb('asset_end', 'in', buffers)]),
);
}

// Selects 1h-candles for the last 24 hours and aggregates them into a single array, ordering by assets
const candlesTable = this.db
.selectFrom('dex_ex_price_charts')
Expand All @@ -210,13 +216,12 @@ class Pindexer {
// Joins summaryTable with candlesTable to get pair info with the latest candles
const joinedTable = this.db
.selectFrom(summaryTable.as('summary'))
.innerJoin(candlesTable.as('candles'), join =>
.leftJoin(candlesTable.as('candles'), join =>
join
.onRef('candles.asset_start', '=', 'summary.asset_start')
.onRef('candles.asset_end', '=', 'summary.asset_end'),
)
.leftJoin(usdcTable.as('usdc'), 'summary.asset_end', 'usdc.asset_start')
.orderBy('trades_over_window', 'desc')
.select([
'summary.asset_start',
'summary.asset_end',
Expand All @@ -236,7 +241,7 @@ class Pindexer {
'candles.candle_times',
'usdc.price as usdc_price',
])
.orderBy('summary.liquidity', 'desc')
.orderBy('summary.direct_volume_indexing_denom_over_window', 'desc')
.limit(limit)
.offset(offset);

Expand Down
20 changes: 20 additions & 0 deletions src/shared/utils/use-debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useState, useEffect } from 'react';

/**
* Respectfully copied from https://usehooks.com/usedebounce
*/
export const useDebounce = <VALUE>(value: VALUE, delay: number): VALUE => {
const [debouncedValue, setDebouncedValue] = useState<VALUE>(value);

useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
clearTimeout(handler);
};
}, [value, delay]);

return debouncedValue;
};

0 comments on commit fa7b6eb

Please sign in to comment.