Skip to content
Closed
12 changes: 12 additions & 0 deletions apps/evm/src/clients/api/__mocks__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,18 @@ export const useGetYieldPlusPositions = vi.fn(() =>
}),
);

export const getDexKlineCandles = vi.fn(async () => ({
candles: [],
}));
export const useGetDexKlineCandles = vi.fn(() =>
useQuery({
queryKey: [FunctionKey.GET_DEX_KLINE_CANDLES],
queryFn: getDexKlineCandles,
}),
);

export const useDexKlineWebSocket = vi.fn();

// Mutations
export const useApproveToken = vi.fn((_variables: never, options?: MutationObserverOptions) =>
useMutation({
Expand Down
4 changes: 4 additions & 0 deletions apps/evm/src/clients/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,7 @@ export * from './queries/getProposalCount/useGetProposalCount';

export * from './queries/getYieldPlusPositions';
export * from './queries/getYieldPlusPositions/useGetYieldPlusPositions';

export * from './queries/getDexKlineCandles';
export * from './queries/getDexKlineCandles/useGetDexKlineCandles';
export * from './queries/getDexKlineCandles/useDexKlineWebSocket';
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { WsClient } from 'libs/ws';
import type { DexKlineCandle, DexKlineCandleInterval } from './types';

type CandleCallback = (candle: DexKlineCandle) => void;

interface WsKlineMessage {
c?: string;
d?: { u?: number[] };
}

export class DexKlineWsClient extends WsClient {
private readonly callbacks = new Map<string, Set<CandleCallback>>();

subscribe(channel: string, callback: CandleCallback): void {
if (!this.callbacks.has(channel)) {
this.callbacks.set(channel, new Set());
this.openChannel(channel);
}
this.callbacks.get(channel)?.add(callback);
}

unsubscribe(channel: string, callback: CandleCallback): void {
const set = this.callbacks.get(channel);
if (!set) return;

set.delete(callback);

if (set.size === 0) {
this.callbacks.delete(channel);
this.closeChannel(channel);
}
}

protected buildSubscribeMessage(channel: string): string {
return JSON.stringify({ method: 'SUBSCRIPTION', params: [channel] });
}

protected buildUnsubscribeMessage(channel: string): string {
return JSON.stringify({ method: 'UNSUBSCRIPTION', params: [channel] });
}

protected onMessage(data: string): void {
let msg: WsKlineMessage;

try {
msg = JSON.parse(data) as WsKlineMessage;
} catch {
return;
}

const channel = msg.c;
const usdOhlcv = msg.d?.u;

if (!channel || !Array.isArray(usdOhlcv) || usdOhlcv.length < 6) return;

const [o, h, l, c, v, ts] = usdOhlcv;
const candle: DexKlineCandle = { open: o, high: h, low: l, close: c, volume: v, timestamp: ts };
Comment thread
cuzz-venus marked this conversation as resolved.

this.callbacks.get(channel)?.forEach(cb => cb(candle));
}
}

export const buildKlineChannel = (
platformId: number,
address: string,
interval: DexKlineCandleInterval,
) => `datahub@kline@${platformId}@${address}@${interval}`;
53 changes: 53 additions & 0 deletions apps/evm/src/clients/api/queries/getDexKlineCandles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import config from 'config';
import { VError } from 'libs/errors';
import { createQueryParams } from 'utilities';
import type {
GetApiDexKlineCandlesOutput,
GetDexKlineCandlesInput,
GetDexKlineCandlesOutput,
} from './types';

export * from './types';

export const getDexKlineCandles = async ({
platform,
address,
interval,
limit,
from,
to,
unit,
tokenIndex,
}: GetDexKlineCandlesInput): Promise<GetDexKlineCandlesOutput> => {
const queryParams = createQueryParams({
platform,
address,
interval,
limit,
from,
to,
unit,
tokenIndex,
});

const response = await fetch(`${config.dexApiUrl}/k-line/candles?${queryParams}`, {
headers: { Accept: 'application/json' },
});

if (!response.ok) {
throw new VError({ type: 'unexpected', code: 'somethingWentWrong' });
}

const payload: GetApiDexKlineCandlesOutput = await response.json();

const candles = (payload.data ?? []).map(([o, h, l, c, v, ts]) => ({
timestamp: ts,
open: o,
high: h,
low: l,
close: c,
volume: v,
}));

return { candles };
};
62 changes: 62 additions & 0 deletions apps/evm/src/clients/api/queries/getDexKlineCandles/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
export type DexKlineCandleInterval =
| '1s'
| '1min'
| '3min'
| '5min'
| '15min'
| '30min'
| '1h'
| '2h'
| '4h'
| '6h'
| '8h'
| '12h'
| '1d'
| '3d'
| '1w'
| '1m';

export interface GetDexKlineCandlesInput {
platform: string;
address: string;
interval: DexKlineCandleInterval;
limit: number;
from?: number;
to?: number;
unit?: 'usd' | 'native' | 'quote';
tokenIndex?: 0 | 1;
}

export type ApiDexKlineCandle = [
number, // o - open price
number, // h - highest price
number, // l - lowest price
number, // c - close price
number, // v - volume
number, // ts - opening time (Unix ms)
];

export interface GetApiDexKlineCandlesOutput {
data: ApiDexKlineCandle[];
status: {
timestamp: string;
error_code: string;
error_message: string;
elapsed: string;
credit_count: number;
};
}

export interface DexKlineCandle {
timestamp: number;
open: number;
high: number;
low: number;
close: number;
volume: number;
[key: string]: unknown;
}

export interface GetDexKlineCandlesOutput {
candles: DexKlineCandle[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useEffect, useRef } from 'react';

import config from 'config';
import { useChainId } from 'libs/wallet';
import { ChainId } from 'types';
import { DexKlineWsClient, buildKlineChannel } from './DexKlineWsClient';
import type { DexKlineCandle, DexKlineCandleInterval } from './types';

const PLATFORM_ID_BY_CHAIN_ID = new Map<ChainId, number>([
[ChainId.ETHEREUM, 1],
[ChainId.SEPOLIA, 1],
[ChainId.BSC_MAINNET, 14],
[ChainId.BSC_TESTNET, 14],
[ChainId.BASE_MAINNET, 199],
[ChainId.BASE_SEPOLIA, 199],
]);

let sharedClient: DexKlineWsClient | null = null;

const getSharedClient = (): DexKlineWsClient | null => {
if (!config.dexWsUrl) return null;
if (!sharedClient) sharedClient = new DexKlineWsClient(config.dexWsUrl);
return sharedClient;
};

export interface UseDexKlineWebSocketInput {
address: string;
interval: DexKlineCandleInterval;
onCandle: (candle: DexKlineCandle) => void;
enabled?: boolean;
}

export const useDexKlineWebSocket = ({
address,
interval,
onCandle,
enabled = true,
}: UseDexKlineWebSocketInput): void => {
const { chainId } = useChainId();
const onCandleRef = useRef(onCandle);
onCandleRef.current = onCandle;

useEffect(() => {
const platformId = PLATFORM_ID_BY_CHAIN_ID.get(chainId);
const client = getSharedClient();

if (!client || !platformId || !enabled || !address) return;

const channel = buildKlineChannel(platformId, address, interval);
const callback = (candle: DexKlineCandle) => onCandleRef.current(candle);

client.subscribe(channel, callback);
return () => client.unsubscribe(channel, callback);
}, [chainId, address, interval, enabled]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type QueryObserverOptions, useQuery } from '@tanstack/react-query';

import FunctionKey from 'constants/functionKey';
import { useChainId } from 'libs/wallet';
import { ChainId } from 'types';
import { type GetDexKlineCandlesInput, type GetDexKlineCandlesOutput, getDexKlineCandles } from '.';

// Platform names as defined by the DEX API
const DEX_PLATFORM_BY_CHAIN_ID = new Map<ChainId, string>([
[ChainId.ETHEREUM, 'ethereum'],
[ChainId.SEPOLIA, 'ethereum'],
[ChainId.BSC_MAINNET, 'bsc'],
[ChainId.BSC_TESTNET, 'bsc'],
[ChainId.BASE_MAINNET, 'base'],
[ChainId.BASE_SEPOLIA, 'base'],
]);

type TrimmedGetDexKlineCandlesInput = Omit<GetDexKlineCandlesInput, 'platform'>;

export type UseGetDexKlineCandlesQueryKey = [
FunctionKey.GET_DEX_KLINE_CANDLES,
TrimmedGetDexKlineCandlesInput & { chainId: ChainId },
];

type Options = QueryObserverOptions<
GetDexKlineCandlesOutput,
Error,
GetDexKlineCandlesOutput,
GetDexKlineCandlesOutput,
UseGetDexKlineCandlesQueryKey
>;

export const useGetDexKlineCandles = (
params: TrimmedGetDexKlineCandlesInput,
options?: Partial<Options>,
) => {
const { chainId } = useChainId();
const platform = DEX_PLATFORM_BY_CHAIN_ID.get(chainId);

return useQuery({
queryKey: [FunctionKey.GET_DEX_KLINE_CANDLES, { ...params, chainId }],
queryFn: () => getDexKlineCandles({ platform: platform ?? '', ...params }),
staleTime: Number.POSITIVE_INFINITY,
...options,
enabled: !!platform && (options?.enabled ?? true),
});
};
23 changes: 22 additions & 1 deletion apps/evm/src/components/KLineChart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ import { rgb } from './rgb';
export interface KLineChartProps {
title: string;
data: KLineData[];
liveCandle?: KLineData;
period?: PeriodType;
className?: string;
}

export const KLineChart: React.FC<KLineChartProps> = ({
className,
data,
liveCandle,
period = 'day',
title,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<ReturnType<typeof init> | null>(null);
const latestDataRef = useRef<KLineData[]>(data);
const subscribeCallbackRef = useRef<((data: KLineData) => void) | null>(null);

useEffect(() => {
if (!containerRef.current) {
Expand Down Expand Up @@ -104,9 +107,14 @@ export const KLineChart: React.FC<KLineChartProps> = ({
getBars: ({ callback }) => {
callback(latestDataRef.current);
},
subscribeBar: ({ callback }) => {
subscribeCallbackRef.current = callback;
},
unsubscribeBar: () => {
subscribeCallbackRef.current = null;
},
});

// Re-render chart when browser window is resized
const resizeObserver = new ResizeObserver(() => {
chart.resize();
});
Expand All @@ -115,6 +123,7 @@ export const KLineChart: React.FC<KLineChartProps> = ({

return () => {
chartRef.current = null;
subscribeCallbackRef.current = null;
resizeObserver?.disconnect();
dispose(chart);
};
Expand All @@ -135,8 +144,20 @@ export const KLineChart: React.FC<KLineChartProps> = ({
getBars: ({ callback }) => {
callback(data);
},
subscribeBar: ({ callback }) => {
subscribeCallbackRef.current = callback;
},
unsubscribeBar: () => {
subscribeCallbackRef.current = null;
},
});
}, [data]);

useEffect(() => {
if (liveCandle) {
subscribeCallbackRef.current?.(liveCandle);
}
}, [liveCandle]);

return <div ref={containerRef} className={cn('w-full h-full bg-dark-blue', className)} />;
};
6 changes: 6 additions & 0 deletions apps/evm/src/config/envVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,10 @@ export const envVariables = {
typeof process !== 'undefined'
? process.env.VITE_SAFE_API_KEY
: import.meta.env.VITE_SAFE_API_KEY,
VITE_DEX_API_URL:
typeof process !== 'undefined'
? process.env.VITE_DEX_API_URL
: import.meta.env.VITE_DEX_API_URL,
VITE_DEX_WS_URL:
typeof process !== 'undefined' ? process.env.VITE_DEX_WS_URL : import.meta.env.VITE_DEX_WS_URL,
Comment on lines +43 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if new env variables are documented and have fallback handling.

# Check for .env.example or similar documentation
fd -t f -e example -e sample | xargs rg -l 'DEX_API_URL\|DEX_WS_URL' 2>/dev/null || echo "No .env example files found with DEX vars"

# Check config/index.ts for fallback handling
rg -n 'dexApiUrl|dexWsUrl' apps/evm/src/config/

Repository: VenusProtocol/venus-protocol-interface

Length of output: 390


Add these environment variables to .env.example or documentation.

Code pattern is correct and follows established conventions. Fallback handling in apps/evm/src/config/index.ts (lines 50-51) is properly implemented with empty string defaults. However, VITE_DEX_API_URL and VITE_DEX_WS_URL are missing from example configuration files, making it unclear to developers how to configure these required endpoints.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/evm/src/config/envVariables.ts` around lines 43 - 48, Add
VITE_DEX_API_URL and VITE_DEX_WS_URL to the project's example environment file
and/or documentation so developers know the required endpoints and expected
format; update .env.example (or relevant docs) to include entries for
VITE_DEX_API_URL and VITE_DEX_WS_URL with brief placeholder values and a short
comment, referencing the variables used in apps/evm/src/config/envVariables.ts
(VITE_DEX_API_URL, VITE_DEX_WS_URL) and the fallback behavior in
apps/evm/src/config/index.ts so the example aligns with the code.

};
Loading
Loading