init449494

This commit is contained in:
ZOMBIIIIIII
2026-05-28 22:02:37 +03:00
parent 15af7174c6
commit 444030e424
6 changed files with 269 additions and 21 deletions

39
.env.example Normal file
View 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=

View File

@@ -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

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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.
* *

View File

@@ -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 });
}); });