feat: expand non-evm relay and route planning support
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import reportRoutes from './report';
|
||||
import { getCanonicalTokenBySymbol } from '../../config/canonical-tokens';
|
||||
|
||||
jest.mock('../../database/repositories/token-repo', () => ({
|
||||
TokenRepository: jest.fn().mockImplementation(() => ({
|
||||
@@ -124,6 +125,30 @@ describe('Report API', () => {
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('fills canonical fallback usd pricing when market data is absent', async () => {
|
||||
const weth = getCanonicalTokenBySymbol(138, 'WETH');
|
||||
expect(weth?.addresses[138]).toBeTruthy();
|
||||
const wethAddress = String(weth?.addresses[138]).toLowerCase();
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/all?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
const tokens138 = body.tokens?.['138'];
|
||||
expect(Array.isArray(tokens138)).toBe(true);
|
||||
|
||||
const wethEntry = tokens138.find((token: Record<string, any>) => token.address === wethAddress);
|
||||
expect(wethEntry).toMatchObject({
|
||||
symbol: 'WETH',
|
||||
decimals: 18,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 2490,
|
||||
volume24h: 0,
|
||||
liquidityUsd: 0,
|
||||
lastUpdated: '2026-04-15T00:00:00.000Z',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/gas-registry', () => {
|
||||
@@ -413,6 +438,68 @@ describe('Report API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/gru-v2-pmm-pools', () => {
|
||||
it('returns resolved PMM pools from deployment-status when file is set', async () => {
|
||||
const previousPath = process.env.DEPLOYMENT_STATUS_JSON_PATH;
|
||||
const tempPath = `/tmp/token-aggregation-gru-v2-pmm-${Date.now()}.json`;
|
||||
|
||||
process.env.DEPLOYMENT_STATUS_JSON_PATH = tempPath;
|
||||
await import('fs/promises').then((fs) =>
|
||||
fs.writeFile(
|
||||
tempPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 'test-gru-pools',
|
||||
updated: '2026-04-18',
|
||||
homeChainId: 138,
|
||||
chains: {
|
||||
'1': {
|
||||
name: 'Ethereum Mainnet',
|
||||
cwTokens: { cWUSDT: '0xaf5017d0163ecb99d9b5d94e3b4d7b09af44d8ae' },
|
||||
anchorAddresses: { USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' },
|
||||
pmmPools: [
|
||||
{
|
||||
base: 'cWUSDT',
|
||||
quote: 'USDC',
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
feeBps: 3,
|
||||
role: 'public_routing',
|
||||
publicRoutingEnabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/gru-v2-pmm-pools?chainId=1`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.source).toBe('deployment-status-file');
|
||||
expect(body.complete).toBe(true);
|
||||
expect(body.version).toBe('test-gru-pools');
|
||||
expect(Array.isArray(body.pools)).toBe(true);
|
||||
expect((body.pools as unknown[]).length).toBeGreaterThanOrEqual(1);
|
||||
expect((body.pools as Array<{ poolAddress: string }>)[0]).toMatchObject({
|
||||
poolAddress: '0x1111111111111111111111111111111111111111',
|
||||
section: 'pmmPools',
|
||||
});
|
||||
} finally {
|
||||
await import('fs/promises').then((fs) => fs.unlink(tempPath).catch(() => undefined));
|
||||
if (previousPath === undefined) {
|
||||
delete process.env.DEPLOYMENT_STATUS_JSON_PATH;
|
||||
} else {
|
||||
process.env.DEPLOYMENT_STATUS_JSON_PATH = previousPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/report/gas-registry', () => {
|
||||
it('reads the live gas rollout registry from deployment-status json when available', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/v1/report/gas-registry?chainId=10`);
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
loadDeploymentStatusFile,
|
||||
type CwRegistryChain,
|
||||
} from '../../config/deployment-status';
|
||||
import { getGruV2DeploymentPoolRows } from '../../config/gru-v2-deployment-pools';
|
||||
import { getCanonicalPriceSnapshotGeneratedAt, getCanonicalPriceUsd } from '../../services/canonical-price-oracle';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -94,6 +96,8 @@ async function buildTokenReport(chainId: number) {
|
||||
})
|
||||
);
|
||||
|
||||
const fallbackPriceUsd = getCanonicalPriceUsd(chainId, address);
|
||||
|
||||
out.push({
|
||||
chainId,
|
||||
address: address.toLowerCase(),
|
||||
@@ -110,7 +114,7 @@ async function buildTokenReport(chainId: number) {
|
||||
liquiditySourceSymbol: spec.liquiditySourceSymbol,
|
||||
market: marketData
|
||||
? {
|
||||
priceUsd: marketData.priceUsd,
|
||||
priceUsd: marketData.priceUsd ?? fallbackPriceUsd,
|
||||
volume24h: marketData.volume24h,
|
||||
volume7d: marketData.volume7d,
|
||||
volume30d: marketData.volume30d,
|
||||
@@ -118,6 +122,15 @@ async function buildTokenReport(chainId: number) {
|
||||
liquidityUsd: marketData.liquidityUsd,
|
||||
lastUpdated: marketData.lastUpdated?.toISOString() ?? '',
|
||||
}
|
||||
: fallbackPriceUsd !== undefined
|
||||
? {
|
||||
priceUsd: fallbackPriceUsd,
|
||||
volume24h: 0,
|
||||
volume7d: 0,
|
||||
volume30d: 0,
|
||||
liquidityUsd: 0,
|
||||
lastUpdated: `${getCanonicalPriceSnapshotGeneratedAt()}T00:00:00.000Z`,
|
||||
}
|
||||
: undefined,
|
||||
pools: resolvedPools.map((p) => ({
|
||||
poolAddress: p.poolAddress,
|
||||
@@ -543,6 +556,36 @@ router.get('/cw-registry', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /report/gru-v2-pmm-pools — all GRU v2 PMM pools from deployment-status (stable, volatile, gas) with resolved token addresses. */
|
||||
router.get('/gru-v2-pmm-pools', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const chainIdParam = req.query.chainId as string | undefined;
|
||||
const chainIdFilter = chainIdParam ? parseInt(chainIdParam, 10) : null;
|
||||
const fileBacked = loadDeploymentStatusFile();
|
||||
let pools = getGruV2DeploymentPoolRows();
|
||||
|
||||
if (chainIdFilter && !Number.isNaN(chainIdFilter)) {
|
||||
pools = pools.filter((p) => p.chainId === chainIdFilter);
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||
res.json({
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: fileBacked ? 'deployment-status-file' : 'none',
|
||||
complete: !!fileBacked,
|
||||
version: fileBacked?.data.version,
|
||||
updated: fileBacked?.data.updated,
|
||||
lastModified: fileBacked?.lastModified,
|
||||
homeChainId: fileBacked?.data.homeChainId,
|
||||
count: pools.length,
|
||||
pools,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error building report/gru-v2-pmm-pools:', error);
|
||||
res.status(500).json({ error: 'Internal server error', pools: [] });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /report/gas-registry — live gas-family rollout registry from deployment-status.json plus GRU transport metadata. */
|
||||
router.get('/gas-registry', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
219
services/token-aggregation/src/api/routes/tokens.test.ts
Normal file
219
services/token-aggregation/src/api/routes/tokens.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { createServer } from 'http';
|
||||
import express from 'express';
|
||||
import { getCanonicalTokenBySymbol } from '../../config/canonical-tokens';
|
||||
|
||||
const mockGetTokens = jest.fn();
|
||||
const mockGetToken = jest.fn();
|
||||
const mockSearchTokens = jest.fn();
|
||||
const mockGetMarketData = jest.fn();
|
||||
const mockGetPoolsByToken = jest.fn();
|
||||
const mockGetPool = jest.fn();
|
||||
const mockGetLiveDodoPools = jest.fn();
|
||||
const mockResolveTokenDisplay = jest.fn();
|
||||
const mockResolvePoolTokenDisplays = jest.fn();
|
||||
const mockGetTokenByContract = jest.fn();
|
||||
|
||||
jest.mock('../../database/repositories/token-repo', () => ({
|
||||
TokenRepository: jest.fn().mockImplementation(() => ({
|
||||
getTokens: mockGetTokens,
|
||||
getToken: mockGetToken,
|
||||
searchTokens: mockSearchTokens,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../database/repositories/market-data-repo', () => ({
|
||||
MarketDataRepository: jest.fn().mockImplementation(() => ({
|
||||
getMarketData: mockGetMarketData,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../database/repositories/pool-repo', () => ({
|
||||
PoolRepository: jest.fn().mockImplementation(() => ({
|
||||
getPoolsByToken: mockGetPoolsByToken,
|
||||
getPool: mockGetPool,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../indexer/ohlcv-generator', () => ({
|
||||
OHLCVGenerator: jest.fn().mockImplementation(() => ({
|
||||
getOHLCV: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockGetMarketDataAdapter = jest.fn();
|
||||
|
||||
jest.mock('../../adapters/coingecko-adapter', () => ({
|
||||
CoinGeckoAdapter: jest.fn().mockImplementation(() => ({
|
||||
getTokenByContract: mockGetTokenByContract,
|
||||
getMarketData: mockGetMarketDataAdapter,
|
||||
getTrending: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../adapters/cmc-adapter', () => ({
|
||||
CoinMarketCapAdapter: jest.fn().mockImplementation(() => ({
|
||||
getTokenByContract: mockGetTokenByContract,
|
||||
getMarketData: mockGetMarketDataAdapter,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../adapters/dexscreener-adapter', () => ({
|
||||
DexScreenerAdapter: jest.fn().mockImplementation(() => ({
|
||||
getTokenByContract: mockGetTokenByContract,
|
||||
getMarketData: mockGetMarketDataAdapter,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../services/live-dodo-fallback', () => ({
|
||||
getLiveDodoPools: (...args: unknown[]) => mockGetLiveDodoPools(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../services/token-display', () => ({
|
||||
resolveTokenDisplay: (...args: unknown[]) => mockResolveTokenDisplay(...args),
|
||||
resolvePoolTokenDisplays: (...args: unknown[]) => mockResolvePoolTokenDisplays(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../middleware/cache');
|
||||
|
||||
const tokensRoutes = require('./tokens').default as typeof import('./tokens').default;
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use('/api/v1', tokensRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function startServer(app: express.Application): Promise<{ server: ReturnType<typeof createServer>; baseUrl: string }> {
|
||||
const server = createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, () => resolve()));
|
||||
const port = (server.address() as { port: number }).port;
|
||||
return { server, baseUrl: `http://127.0.0.1:${port}` };
|
||||
}
|
||||
|
||||
describe('Tokens API', () => {
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = createApp();
|
||||
const started = await startServer(app);
|
||||
server = started.server;
|
||||
baseUrl = started.baseUrl;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetMarketDataAdapter.mockResolvedValue(null);
|
||||
mockGetTokens.mockResolvedValue([]);
|
||||
mockGetToken.mockResolvedValue(null);
|
||||
mockSearchTokens.mockResolvedValue([]);
|
||||
mockGetMarketData.mockResolvedValue(null);
|
||||
mockGetPoolsByToken.mockResolvedValue([]);
|
||||
mockGetPool.mockResolvedValue(null);
|
||||
mockGetLiveDodoPools.mockResolvedValue([]);
|
||||
mockResolveTokenDisplay.mockResolvedValue({
|
||||
address: '',
|
||||
name: 'Unknown Token',
|
||||
symbol: 'UNKNOWN',
|
||||
decimals: 18,
|
||||
source: 'fallback',
|
||||
});
|
||||
mockResolvePoolTokenDisplays.mockResolvedValue({
|
||||
token0: { address: '', symbol: 'UNKNOWN', name: 'Unknown Token', source: 'fallback' },
|
||||
token1: { address: '', symbol: 'UNKNOWN', name: 'Unknown Token', source: 'fallback' },
|
||||
});
|
||||
mockGetTokenByContract.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('lists canonical 138 tokens with stable and ETH-family fallback pricing when db market data is missing', async () => {
|
||||
const usdt = getCanonicalTokenBySymbol(138, 'USDT');
|
||||
const weth = getCanonicalTokenBySymbol(138, 'WETH');
|
||||
const weth10 = getCanonicalTokenBySymbol(138, 'WETH10');
|
||||
|
||||
expect(usdt?.addresses[138]).toBeTruthy();
|
||||
expect(weth?.addresses[138]).toBeTruthy();
|
||||
expect(weth10?.addresses[138]).toBeTruthy();
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/tokens?chainId=138&limit=400`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.source).toBe('canonical');
|
||||
|
||||
const findByAddress = (address?: string) =>
|
||||
body.tokens.find((token: Record<string, any>) => token.address === address?.toLowerCase());
|
||||
|
||||
expect(findByAddress(usdt?.addresses[138])).toMatchObject({
|
||||
symbol: 'USDT',
|
||||
decimals: 6,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 1,
|
||||
volume24h: 0,
|
||||
liquidityUsd: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(findByAddress(weth?.addresses[138])).toMatchObject({
|
||||
symbol: 'WETH',
|
||||
decimals: 18,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 2490,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(findByAddress(weth10?.addresses[138])).toMatchObject({
|
||||
symbol: 'WETH10',
|
||||
decimals: 18,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 2490,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('fills missing priceUsd on token detail responses while preserving repository market fields', async () => {
|
||||
const weth10 = getCanonicalTokenBySymbol(138, 'WETH10');
|
||||
expect(weth10?.addresses[138]).toBeTruthy();
|
||||
const weth10Address = String(weth10?.addresses[138]).toLowerCase();
|
||||
|
||||
mockGetMarketData.mockResolvedValue({
|
||||
chainId: 138,
|
||||
tokenAddress: weth10Address,
|
||||
priceUsd: undefined,
|
||||
volume24h: 1234,
|
||||
volume7d: 5678,
|
||||
volume30d: 9012,
|
||||
liquidityUsd: 3456,
|
||||
holdersCount: 78,
|
||||
transfers24h: 9,
|
||||
lastUpdated: new Date('2026-04-16T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/tokens/${weth10Address}?chainId=138`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as Record<string, any>;
|
||||
expect(body.token).toMatchObject({
|
||||
symbol: 'WETH10',
|
||||
decimals: 18,
|
||||
market: expect.objectContaining({
|
||||
priceUsd: 2490,
|
||||
volume24h: 1234,
|
||||
liquidityUsd: 3456,
|
||||
}),
|
||||
hasDodoPool: false,
|
||||
});
|
||||
expect(body.token.canonicalLiquidity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
resolveCanonicalQuoteAddress,
|
||||
} from '../../config/canonical-tokens';
|
||||
import { getLiveDodoPools } from '../../services/live-dodo-fallback';
|
||||
import {
|
||||
buildExplorerLinks,
|
||||
mergeMarketWithValuation,
|
||||
resolveUsdValuation,
|
||||
} from '../../services/valuation-precedence';
|
||||
|
||||
const router: Router = Router();
|
||||
const tokenRepo = new TokenRepository();
|
||||
@@ -26,6 +31,26 @@ const coingeckoAdapter = new CoinGeckoAdapter();
|
||||
const cmcAdapter = new CoinMarketCapAdapter();
|
||||
const dexscreenerAdapter = new DexScreenerAdapter();
|
||||
|
||||
function buildMarketPricingExplorer(
|
||||
chainId: number,
|
||||
displayAddress: string,
|
||||
lookupAddress: string,
|
||||
marketData: Awaited<ReturnType<MarketDataRepository['getMarketData']>>,
|
||||
external: { coingecko?: Awaited<ReturnType<CoinGeckoAdapter['getMarketData']>>; cmc?: Awaited<ReturnType<CoinMarketCapAdapter['getMarketData']>>; dexscreener?: Awaited<ReturnType<DexScreenerAdapter['getMarketData']>> } | null
|
||||
) {
|
||||
const pricing = resolveUsdValuation({
|
||||
chainId,
|
||||
normalizedAddress: lookupAddress.toLowerCase(),
|
||||
indexer: marketData,
|
||||
coingecko: external?.coingecko ?? undefined,
|
||||
cmc: external?.cmc ?? undefined,
|
||||
dexscreener: external?.dexscreener ?? undefined,
|
||||
});
|
||||
const market = mergeMarketWithValuation(chainId, displayAddress.toLowerCase(), marketData, pricing);
|
||||
const explorer = buildExplorerLinks(chainId, displayAddress);
|
||||
return { market, pricing, explorer };
|
||||
}
|
||||
|
||||
function tokenFromCanonical(chainId: number, address: string): Token | null {
|
||||
const spec = getCanonicalTokenByAddress(chainId, address.toLowerCase());
|
||||
if (!spec) {
|
||||
@@ -182,10 +207,20 @@ router.get('/tokens', cacheMiddleware(60 * 1000), async (req: Request, res: Resp
|
||||
const { tokens, source } = await getTokensWithFallback(chainId, limit, offset);
|
||||
const tokensWithMarketData = await Promise.all(
|
||||
tokens.map(async (token) => {
|
||||
const marketData = await marketDataRepo.getMarketData(chainId, token.address);
|
||||
const resolution = resolveCanonicalQuoteAddress(chainId, token.address);
|
||||
const marketData = await marketDataRepo.getMarketData(chainId, resolution.lookupAddress);
|
||||
const { market, pricing, explorer } = buildMarketPricingExplorer(
|
||||
chainId,
|
||||
token.address,
|
||||
resolution.lookupAddress,
|
||||
marketData,
|
||||
null
|
||||
);
|
||||
const out: Record<string, unknown> = {
|
||||
...token,
|
||||
market: marketData || undefined,
|
||||
market: market || undefined,
|
||||
pricing,
|
||||
explorer,
|
||||
};
|
||||
if (includeDodoPool) {
|
||||
const pools = await getPoolsByTokenWithFallback(chainId, token.address);
|
||||
@@ -228,13 +263,32 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
return res.status(404).json({ error: 'Token not found' });
|
||||
}
|
||||
|
||||
const [marketData, pools, coingeckoData, cmcData, dexscreenerData] = await Promise.all([
|
||||
const [
|
||||
marketDataRaw,
|
||||
pools,
|
||||
coingeckoData,
|
||||
cmcData,
|
||||
dexscreenerData,
|
||||
coingeckoMarket,
|
||||
cmcMarket,
|
||||
dexscreenerMarket,
|
||||
] = await Promise.all([
|
||||
marketDataRepo.getMarketData(chainId, resolution.lookupAddress),
|
||||
getPoolsByTokenWithFallback(chainId, normalizedAddress),
|
||||
coingeckoAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
cmcAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
dexscreenerAdapter.getTokenByContract(chainId, resolution.lookupAddress),
|
||||
coingeckoAdapter.getMarketData(chainId, resolution.lookupAddress),
|
||||
cmcAdapter.getMarketData(chainId, resolution.lookupAddress),
|
||||
dexscreenerAdapter.getMarketData(chainId, resolution.lookupAddress),
|
||||
]);
|
||||
const { market: marketData, pricing, explorer } = buildMarketPricingExplorer(
|
||||
chainId,
|
||||
normalizedAddress,
|
||||
resolution.lookupAddress,
|
||||
marketDataRaw,
|
||||
{ coingecko: coingeckoMarket, cmc: cmcMarket, dexscreener: dexscreenerMarket }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token: {
|
||||
@@ -243,6 +297,8 @@ router.get('/tokens/:address', cacheMiddleware(60 * 1000), async (req: Request,
|
||||
totalSupply: token.totalSupply,
|
||||
},
|
||||
market: marketData || undefined,
|
||||
pricing,
|
||||
explorer,
|
||||
external: {
|
||||
coingecko: coingeckoData || undefined,
|
||||
cmc: cmcData || undefined,
|
||||
|
||||
Reference in New Issue
Block a user