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, ...)
|
||||
ssh server@<host> -p 2222
|
||||
cd ~/cryptowallet/deployserver
|
||||
cp .env.example .env
|
||||
nano .env # VAULT_ADDR / VAULT_ROLE_ID / VAULT_SECRET_ID НЕ должны быть пустыми
|
||||
docker compose up -d --build
|
||||
docker compose logs -f api
|
||||
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).
|
||||
|
||||
Если в логах есть `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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -171,7 +171,7 @@ function isBridgeable(chain: ChainCode, symbol: string): boolean {
|
||||
*
|
||||
* @param filterChain — если задан, фильтрует только этот chain
|
||||
* @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[] {
|
||||
const out: TokenListEntry[] = [];
|
||||
@@ -210,6 +210,18 @@ export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean =
|
||||
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[] {
|
||||
if (chain === 'ETH') return ETH_TOKENS;
|
||||
if (chain === 'BSC') return BSC_TOKENS;
|
||||
|
||||
@@ -28,11 +28,12 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
|
||||
return;
|
||||
}
|
||||
|
||||
// Bearer-auth (Authorization header, no access_token cookie) — CSRF не нужен.
|
||||
// Bearer-auth (explicit Authorization header) — CSRF не нужен.
|
||||
// Browser-based CSRF атаки требуют auto-sent cookie. Если auth идёт через explicit
|
||||
// Authorization header, attacker не может его выставить cross-origin (CORS preflight блокирует).
|
||||
// Это позволяет API-клиентам (Swagger UI, Postman, curl, мобилке) работать без CSRF.
|
||||
if (!req.cookies?.access_token && req.headers.authorization) {
|
||||
// Это позволяет API-клиентам и фронту с Bearer-token работать без double-submit CSRF,
|
||||
// даже если браузер параллельно прислал stale access_token cookie.
|
||||
if (req.headers.authorization) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { logger } from '../lib/logger';
|
||||
import { WalletModel } from '../models/wallet.model';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
import { proxiedFetch } from '../lib/outbound-proxy';
|
||||
import { getTokensForChains } from '../lib/token-registry';
|
||||
|
||||
const router = Router();
|
||||
const LIFI_API_URL = 'https://li.quest/v1';
|
||||
@@ -43,6 +44,24 @@ const JUMPER_CHAINID_TO_CHAIN: Record<number, ChainCode> = {
|
||||
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).
|
||||
const ALLOWED_GET_PATHS = new Set([
|
||||
'/quote', // single best route
|
||||
@@ -194,6 +213,11 @@ async function proxyJumperRequest(req: Request, res: Response, _next: NextFuncti
|
||||
}
|
||||
|
||||
try {
|
||||
const filtered = filterJumperMetadata(jumperPath, text);
|
||||
if (filtered) {
|
||||
res.json(filtered);
|
||||
return;
|
||||
}
|
||||
res.send(text);
|
||||
} catch {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
* никаких user-specific данных — только статический list контрактов с symbol + name.
|
||||
*
|
||||
* Optional query params:
|
||||
* ?chain=ETH|BSC|BTC|TRX|SOL — filter одной сетью.
|
||||
* ?bridgeable=true — возвращает только tokens которые реально bridgeable
|
||||
* через Jumper/NearIntents (без SOL memes, BSC wrapped, и т.п.).
|
||||
* Используется UI dropdowns в Jumper bridge section.
|
||||
* ?chains=ETH,BSC,BTC,TRX,SOL — filter нескольких сетей.
|
||||
* ?includeUnsupported=true — debug/full registry mode; default = только usable tokens.
|
||||
*/
|
||||
|
||||
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 type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
const router = Router();
|
||||
const ALLOWED = new Set<ChainCode>(ALL_CHAINS);
|
||||
const ALLOWED_LABEL = 'ETH, BSC, BTC, TRX, SOL';
|
||||
|
||||
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;
|
||||
let filterChain: ChainCode | undefined;
|
||||
if (chainParam !== undefined && chainParam !== null && chainParam !== '') {
|
||||
const upper = String(chainParam).toUpperCase();
|
||||
if (!ALLOWED.has(upper as ChainCode)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid chain "${chainParam}" (allowed: ETH, BSC, BTC, TRX, SOL)`,
|
||||
});
|
||||
const invalid = addChain(Array.isArray(chainParam) ? chainParam[0] : chainParam);
|
||||
if (invalid) {
|
||||
res.status(400).json({ success: false, error: `Invalid chain "${invalid}" (allowed: ${ALLOWED_LABEL})` });
|
||||
return;
|
||||
}
|
||||
filterChain = upper as ChainCode;
|
||||
}
|
||||
// ?bridgeable=true → filter только bridgeable tokens
|
||||
const bridgeableOnly = String(req.query.bridgeable || '').toLowerCase() === 'true';
|
||||
const data = getAllTokens(filterChain, bridgeableOnly);
|
||||
|
||||
const chainsParam = req.query.chains;
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user