init449494
This commit is contained in:
39
.env.example
Normal file
39
.env.example
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# CryptoWallet API deploy env.
|
||||||
|
# Copy to .env on server and fill real values before docker compose up.
|
||||||
|
|
||||||
|
# REQUIRED. API will not start without Vault AppRole.
|
||||||
|
VAULT_ADDR=https://corp.vault.elcsa.ru
|
||||||
|
VAULT_ROLE_ID=
|
||||||
|
VAULT_SECRET_ID=
|
||||||
|
VAULT_MOUNT_POINT=dev-secrets
|
||||||
|
VAULT_SECRET_PATH=database
|
||||||
|
VAULT_JWT_KID_PATH=jwt/kid
|
||||||
|
VAULT_JWT_KIDS_PREFIX=jwt/kids
|
||||||
|
VAULT_CSRF_PATH=csrf
|
||||||
|
VAULT_CRYPTO_KEY_PATH=crypto/master
|
||||||
|
|
||||||
|
JWT_ALGORITHM=RS256
|
||||||
|
JWT_ISSUER=bitok
|
||||||
|
JWT_AUDIENCE=elcsa
|
||||||
|
|
||||||
|
API_PORT=3001
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Production: replace * with exact frontend origins.
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
CORS_ALLOW_CREDENTIALS=false
|
||||||
|
|
||||||
|
REDIS_HOST=keydb
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
COINGECKO_API_KEY=
|
||||||
|
RELAY_API_KEY=
|
||||||
|
TRON_API_KEY=
|
||||||
|
JUPITER_API_KEY=
|
||||||
|
JUPITER_REFERRAL_ACCOUNT=
|
||||||
|
JUPITER_FEE_BPS=70
|
||||||
|
|
||||||
|
# Optional outbound proxy for RPC / swap / bridge calls.
|
||||||
|
OUTBOUND_PROXY_URL=
|
||||||
@@ -37,6 +37,8 @@ scp -P 2222 -r deployserver/ server@<host>:~/cryptowallet/
|
|||||||
# На сервере: убедись что .env заполнен (VAULT_*, JWT_*, CORS_ORIGINS, REDIS_PASSWORD, ...)
|
# На сервере: убедись что .env заполнен (VAULT_*, JWT_*, CORS_ORIGINS, REDIS_PASSWORD, ...)
|
||||||
ssh server@<host> -p 2222
|
ssh server@<host> -p 2222
|
||||||
cd ~/cryptowallet/deployserver
|
cd ~/cryptowallet/deployserver
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # VAULT_ADDR / VAULT_ROLE_ID / VAULT_SECRET_ID НЕ должны быть пустыми
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
docker compose logs -f api
|
docker compose logs -f api
|
||||||
curl http://localhost:3001/api/health
|
curl http://localhost:3001/api/health
|
||||||
@@ -44,6 +46,8 @@ curl http://localhost:3001/api/health
|
|||||||
|
|
||||||
API **не делает migrations / DROP / ALTER** при старте — только INSERT/UPDATE/SELECT. Schema (если нужны новые колонки/таблицы для нового функционала) обновляется только руками: `psql -f cryptowallet-schema.sql` (script append-only — `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE ADD COLUMN IF NOT EXISTS`, никаких DROP).
|
API **не делает migrations / DROP / ALTER** при старте — только INSERT/UPDATE/SELECT. Schema (если нужны новые колонки/таблицы для нового функционала) обновляется только руками: `psql -f cryptowallet-schema.sql` (script append-only — `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE ADD COLUMN IF NOT EXISTS`, никаких DROP).
|
||||||
|
|
||||||
|
Если в логах есть `Vault not configured, using .env` и затем `Initial Vault refresh failed: vault_not_configured`, значит контейнер получил пустые `VAULT_ADDR`, `VAULT_ROLE_ID` или `VAULT_SECRET_ID`. Это не nginx-проблема: API падает на старте, пока AppRole не заполнен.
|
||||||
|
|
||||||
## Update / Rebuild
|
## Update / Rebuild
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ function isBridgeable(chain: ChainCode, symbol: string): boolean {
|
|||||||
*
|
*
|
||||||
* @param filterChain — если задан, фильтрует только этот chain
|
* @param filterChain — если задан, фильтрует только этот chain
|
||||||
* @param bridgeableOnly — если true, возвращает только tokens из `BRIDGEABLE_TOKENS` allowlist
|
* @param bridgeableOnly — если true, возвращает только tokens из `BRIDGEABLE_TOKENS` allowlist
|
||||||
* (used by Jumper bridge UI чтобы не показывать unsupported memecoins)
|
* (used by bridge/swap UI чтобы не показывать unsupported memecoins)
|
||||||
*/
|
*/
|
||||||
export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean = false): TokenListEntry[] {
|
export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean = false): TokenListEntry[] {
|
||||||
const out: TokenListEntry[] = [];
|
const out: TokenListEntry[] = [];
|
||||||
@@ -210,6 +210,18 @@ export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean =
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-chain variant для `/api/tokens?chains=ETH,BSC,...`.
|
||||||
|
* По умолчанию compact/bridgeable список, потому что endpoint используется UI dropdown'ами.
|
||||||
|
*/
|
||||||
|
export function getTokensForChains(
|
||||||
|
filterChains?: ChainCode[],
|
||||||
|
bridgeableOnly: boolean = true,
|
||||||
|
): TokenListEntry[] {
|
||||||
|
const chains = filterChains && filterChains.length > 0 ? filterChains : ALL_CHAINS_ORDERED;
|
||||||
|
return chains.flatMap((chain) => getAllTokens(chain, bridgeableOnly));
|
||||||
|
}
|
||||||
|
|
||||||
export function getEvmTokens(chain: ChainCode): EvmToken[] {
|
export function getEvmTokens(chain: ChainCode): EvmToken[] {
|
||||||
if (chain === 'ETH') return ETH_TOKENS;
|
if (chain === 'ETH') return ETH_TOKENS;
|
||||||
if (chain === 'BSC') return BSC_TOKENS;
|
if (chain === 'BSC') return BSC_TOKENS;
|
||||||
|
|||||||
@@ -28,11 +28,12 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bearer-auth (Authorization header, no access_token cookie) — CSRF не нужен.
|
// Bearer-auth (explicit Authorization header) — CSRF не нужен.
|
||||||
// Browser-based CSRF атаки требуют auto-sent cookie. Если auth идёт через explicit
|
// Browser-based CSRF атаки требуют auto-sent cookie. Если auth идёт через explicit
|
||||||
// Authorization header, attacker не может его выставить cross-origin (CORS preflight блокирует).
|
// Authorization header, attacker не может его выставить cross-origin (CORS preflight блокирует).
|
||||||
// Это позволяет API-клиентам (Swagger UI, Postman, curl, мобилке) работать без CSRF.
|
// Это позволяет API-клиентам и фронту с Bearer-token работать без double-submit CSRF,
|
||||||
if (!req.cookies?.access_token && req.headers.authorization) {
|
// даже если браузер параллельно прислал stale access_token cookie.
|
||||||
|
if (req.headers.authorization) {
|
||||||
next();
|
next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { logger } from '../lib/logger';
|
|||||||
import { WalletModel } from '../models/wallet.model';
|
import { WalletModel } from '../models/wallet.model';
|
||||||
import type { ChainCode } from '../lib/address-validators';
|
import type { ChainCode } from '../lib/address-validators';
|
||||||
import { proxiedFetch } from '../lib/outbound-proxy';
|
import { proxiedFetch } from '../lib/outbound-proxy';
|
||||||
|
import { getTokensForChains } from '../lib/token-registry';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const LIFI_API_URL = 'https://li.quest/v1';
|
const LIFI_API_URL = 'https://li.quest/v1';
|
||||||
@@ -43,6 +44,24 @@ const JUMPER_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
|
|||||||
20000000000001: 'BTC',
|
20000000000001: 'BTC',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ALLOWED_JUMPER_CHAIN_IDS = new Set(Object.keys(JUMPER_CHAINID_TO_CHAIN).map(Number));
|
||||||
|
const JUMPER_CHAIN_BY_CODE: Partial<Record<ChainCode, number>> = Object.entries(JUMPER_CHAINID_TO_CHAIN)
|
||||||
|
.reduce((acc, [chainId, code]) => ({ ...acc, [code]: Number(chainId) }), {});
|
||||||
|
const JUMPER_NATIVE_SENTINELS: Partial<Record<ChainCode, string>> = {
|
||||||
|
ETH: '0x0000000000000000000000000000000000000000',
|
||||||
|
BSC: '0x0000000000000000000000000000000000000000',
|
||||||
|
SOL: '11111111111111111111111111111111',
|
||||||
|
TRX: 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb',
|
||||||
|
BTC: 'bitcoin',
|
||||||
|
};
|
||||||
|
const LOCAL_JUMPER_CHAINS = [
|
||||||
|
{ key: 'eth', chainType: 'EVM', name: 'Ethereum', coin: 'ETH', id: 1, mainnet: true },
|
||||||
|
{ key: 'bsc', chainType: 'EVM', name: 'BSC', coin: 'BNB', id: 56, mainnet: true },
|
||||||
|
{ key: 'sol', chainType: 'SVM', name: 'Solana', coin: 'SOL', id: 1151111081099710, mainnet: true },
|
||||||
|
{ key: 'trx', chainType: 'TVM', name: 'Tron', coin: 'TRX', id: 728126428, mainnet: true },
|
||||||
|
{ key: 'btc', chainType: 'UTXO', name: 'Bitcoin', coin: 'BTC', id: 20000000000001, mainnet: true },
|
||||||
|
];
|
||||||
|
|
||||||
// Whitelist: GET-paths + POST-paths. Запрещён любой другой путь (path traversal).
|
// Whitelist: GET-paths + POST-paths. Запрещён любой другой путь (path traversal).
|
||||||
const ALLOWED_GET_PATHS = new Set([
|
const ALLOWED_GET_PATHS = new Set([
|
||||||
'/quote', // single best route
|
'/quote', // single best route
|
||||||
@@ -194,6 +213,11 @@ async function proxyJumperRequest(req: Request, res: Response, _next: NextFuncti
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const filtered = filterJumperMetadata(jumperPath, text);
|
||||||
|
if (filtered) {
|
||||||
|
res.json(filtered);
|
||||||
|
return;
|
||||||
|
}
|
||||||
res.send(text);
|
res.send(text);
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ success: false, error: 'Jumper returned non-JSON' });
|
res.json({ success: false, error: 'Jumper returned non-JSON' });
|
||||||
@@ -209,6 +233,150 @@ async function proxyJumperRequest(req: Request, res: Response, _next: NextFuncti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterJumperMetadata(jumperPath: string, text: string): unknown | null {
|
||||||
|
if (jumperPath !== '/chains' && jumperPath !== '/tokens' && jumperPath !== '/tools') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (jumperPath === '/chains') {
|
||||||
|
return filterChainsResponse(parsed);
|
||||||
|
}
|
||||||
|
if (jumperPath === '/tokens') {
|
||||||
|
return filterTokensResponse(parsed);
|
||||||
|
}
|
||||||
|
return filterToolsResponse(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterChainsResponse(body: any): any {
|
||||||
|
if (!Array.isArray(body?.chains)) return body;
|
||||||
|
const upstream = body.chains.filter((chain: any) => ALLOWED_JUMPER_CHAIN_IDS.has(Number(chain?.id)));
|
||||||
|
const byId = new Map<number, any>();
|
||||||
|
for (const chain of [...upstream, ...LOCAL_JUMPER_CHAINS]) {
|
||||||
|
byId.set(Number(chain.id), chain);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...body,
|
||||||
|
chains: [...ALLOWED_JUMPER_CHAIN_IDS]
|
||||||
|
.map((chainId) => byId.get(chainId))
|
||||||
|
.filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterTokensResponse(body: any): any {
|
||||||
|
if (!body?.tokens || typeof body.tokens !== 'object') return body;
|
||||||
|
|
||||||
|
const allow = buildAllowedTokenMap();
|
||||||
|
const local = buildLocalTokenMap();
|
||||||
|
const filteredByChain = new Map<number, Map<string, any>>();
|
||||||
|
|
||||||
|
for (const [chainId, tokens] of Object.entries(body.tokens)) {
|
||||||
|
if (!ALLOWED_JUMPER_CHAIN_IDS.has(Number(chainId)) || !Array.isArray(tokens)) continue;
|
||||||
|
const numericChainId = Number(chainId);
|
||||||
|
const allowedForChain = allow.get(numericChainId);
|
||||||
|
if (!allowedForChain) continue;
|
||||||
|
const merged = filteredByChain.get(numericChainId) ?? buildTokenMap(local.get(numericChainId) ?? []);
|
||||||
|
for (const token of tokens) {
|
||||||
|
const key = tokenKey(token);
|
||||||
|
if (allowedForChain.has(key)) {
|
||||||
|
merged.set(key, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filteredByChain.set(numericChainId, merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiFi currently omits SOL/BTC/TRX token lists from /tokens. Add our local whitelist
|
||||||
|
// so frontend can use one metadata contract for quote-best/quote and bridge/execute.
|
||||||
|
for (const chainId of ALLOWED_JUMPER_CHAIN_IDS) {
|
||||||
|
if (!filteredByChain.has(chainId)) {
|
||||||
|
filteredByChain.set(chainId, buildTokenMap(local.get(chainId) ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredTokens: Record<string, any[]> = {};
|
||||||
|
for (const chainId of ALLOWED_JUMPER_CHAIN_IDS) {
|
||||||
|
const tokens = [...(filteredByChain.get(chainId)?.values() ?? [])];
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
filteredTokens[String(chainId)] = tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...body, tokens: filteredTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterToolsResponse(body: any): any {
|
||||||
|
const filterPair = (pair: any): boolean =>
|
||||||
|
ALLOWED_JUMPER_CHAIN_IDS.has(Number(pair?.fromChainId)) &&
|
||||||
|
ALLOWED_JUMPER_CHAIN_IDS.has(Number(pair?.toChainId));
|
||||||
|
|
||||||
|
const bridges = Array.isArray(body?.bridges)
|
||||||
|
? body.bridges
|
||||||
|
.map((bridge: any) => {
|
||||||
|
const supportedChains = Array.isArray(bridge?.supportedChains)
|
||||||
|
? bridge.supportedChains.filter(filterPair)
|
||||||
|
: [];
|
||||||
|
return { ...bridge, supportedChains };
|
||||||
|
})
|
||||||
|
.filter((bridge: any) => bridge.supportedChains.length > 0)
|
||||||
|
: body?.bridges;
|
||||||
|
|
||||||
|
const exchanges = Array.isArray(body?.exchanges)
|
||||||
|
? body.exchanges
|
||||||
|
.map((exchange: any) => {
|
||||||
|
const supportedChains = Array.isArray(exchange?.supportedChains)
|
||||||
|
? exchange.supportedChains.filter((chainId: unknown) => ALLOWED_JUMPER_CHAIN_IDS.has(Number(chainId)))
|
||||||
|
: [];
|
||||||
|
return { ...exchange, supportedChains };
|
||||||
|
})
|
||||||
|
.filter((exchange: any) => exchange.supportedChains.length > 0)
|
||||||
|
: body?.exchanges;
|
||||||
|
|
||||||
|
return { ...body, bridges, exchanges };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAllowedTokenMap(): Map<number, Set<string>> {
|
||||||
|
const map = new Map<number, Set<string>>();
|
||||||
|
for (const [chainId, tokens] of buildLocalTokenMap()) {
|
||||||
|
map.set(chainId, new Set(tokens.map(tokenKey)));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalTokenMap(): Map<number, any[]> {
|
||||||
|
const map = new Map<number, any[]>();
|
||||||
|
const rows = getTokensForChains(['ETH', 'BSC', 'SOL', 'BTC', 'TRX'], true);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const chainId = JUMPER_CHAIN_BY_CODE[row.chain];
|
||||||
|
if (!chainId) continue;
|
||||||
|
const address = row.contract || JUMPER_NATIVE_SENTINELS[row.chain] || '';
|
||||||
|
if (!address) continue;
|
||||||
|
const bucket = map.get(chainId) ?? [];
|
||||||
|
bucket.push({
|
||||||
|
chainId,
|
||||||
|
address,
|
||||||
|
symbol: row.symbol,
|
||||||
|
name: row.name,
|
||||||
|
decimals: row.decimals,
|
||||||
|
coinKey: row.symbol,
|
||||||
|
source: 'cryptowallet-whitelist',
|
||||||
|
});
|
||||||
|
map.set(chainId, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTokenMap(tokens: any[]): Map<string, any> {
|
||||||
|
return new Map(tokens.map((token) => [tokenKey(token), token]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenKey(token: any): string {
|
||||||
|
const symbol = String(token?.symbol || '').toUpperCase();
|
||||||
|
const address = String(token?.address || '').toLowerCase();
|
||||||
|
return `${symbol}:${address}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/jumper/quote-best — bridge quote с приоритетом NearIntents.
|
* GET /api/jumper/quote-best — bridge quote с приоритетом NearIntents.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,41 +1,65 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/tokens — реестр всех известных активов всех 5 сетей + native.
|
* GET /api/tokens — compact allowlist активов для bridge/swap UI.
|
||||||
*
|
*
|
||||||
* Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls,
|
* Read-only. Источник — `lib/token-registry.ts`. Никаких RPC calls,
|
||||||
* никаких user-specific данных — только статический list контрактов с symbol + name.
|
* никаких user-specific данных — только статический list контрактов с symbol + name.
|
||||||
*
|
*
|
||||||
* Optional query params:
|
* Optional query params:
|
||||||
* ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью.
|
* ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью.
|
||||||
* ?bridgeable=true — возвращает только tokens которые реально bridgeable
|
* ?chains=ETH,BSC,BTC,TRX,SOL — filter нескольких сетей.
|
||||||
* через Jumper/NearIntents (без SOL memes, BSC wrapped, и т.п.).
|
* ?includeUnsupported=true — debug/full registry mode; default = только usable tokens.
|
||||||
* Используется UI dropdowns в Jumper bridge section.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { getAllTokens } from '../lib/token-registry';
|
import { getTokensForChains } from '../lib/token-registry';
|
||||||
import { ALL_CHAINS } from '../services/wallet-generator.service';
|
import { ALL_CHAINS } from '../services/wallet-generator.service';
|
||||||
import type { ChainCode } from '../lib/address-validators';
|
import type { ChainCode } from '../lib/address-validators';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const ALLOWED = new Set<ChainCode>(ALL_CHAINS);
|
const ALLOWED = new Set<ChainCode>(ALL_CHAINS);
|
||||||
|
const ALLOWED_LABEL = 'ETH, BSC, BTC, TRX, SOL';
|
||||||
|
|
||||||
router.get('/', (req: Request, res: Response) => {
|
router.get('/', (req: Request, res: Response) => {
|
||||||
|
const parseChain = (raw: string): ChainCode | null => {
|
||||||
|
const upper = raw.trim().toUpperCase();
|
||||||
|
if (!upper) return null;
|
||||||
|
return ALLOWED.has(upper as ChainCode) ? (upper as ChainCode) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requested = new Set<ChainCode>();
|
||||||
|
const addChain = (raw: unknown): string | null => {
|
||||||
|
const chain = parseChain(String(raw));
|
||||||
|
if (!chain) return String(raw);
|
||||||
|
requested.add(chain);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const chainParam = req.query.chain;
|
const chainParam = req.query.chain;
|
||||||
let filterChain: ChainCode | undefined;
|
|
||||||
if (chainParam !== undefined && chainParam !== null && chainParam !== '') {
|
if (chainParam !== undefined && chainParam !== null && chainParam !== '') {
|
||||||
const upper = String(chainParam).toUpperCase();
|
const invalid = addChain(Array.isArray(chainParam) ? chainParam[0] : chainParam);
|
||||||
if (!ALLOWED.has(upper as ChainCode)) {
|
if (invalid) {
|
||||||
res.status(400).json({
|
res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` });
|
||||||
success: false,
|
|
||||||
error: `Invalid chain "${chainParam}" (allowed: ETH, BSC, BTC, TRX, SOL)`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
filterChain = upper as ChainCode;
|
|
||||||
}
|
}
|
||||||
// ?bridgeable=true → filter только bridgeable tokens
|
|
||||||
const bridgeableOnly = String(req.query.bridgeable || '').toLowerCase() === 'true';
|
const chainsParam = req.query.chains;
|
||||||
const data = getAllTokens(filterChain, bridgeableOnly);
|
if (chainsParam !== undefined && chainsParam !== null && chainsParam !== '') {
|
||||||
|
const rawValues = Array.isArray(chainsParam) ? chainsParam : [chainsParam];
|
||||||
|
for (const raw of rawValues.flatMap((value) => String(value).split(','))) {
|
||||||
|
if (!raw.trim()) continue;
|
||||||
|
const invalid = addChain(raw);
|
||||||
|
if (invalid) {
|
||||||
|
res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default = compact UI whitelist. Full registry only by explicit debug opt-in.
|
||||||
|
const includeUnsupported = String(req.query.includeUnsupported || '').toLowerCase() === 'true' ||
|
||||||
|
String(req.query.bridgeable || '').toLowerCase() === 'false';
|
||||||
|
const data = getTokensForChains([...requested], !includeUnsupported);
|
||||||
res.json({ success: true, data });
|
res.json({ success: true, data });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user