deploy: POST /api/wallets + full swagger

This commit is contained in:
ZOMBIIIIIII
2026-05-03 20:01:58 +03:00
parent 59a7d1d9ca
commit 295c3a9d6d
27 changed files with 1994 additions and 430 deletions

View File

@@ -10,11 +10,14 @@
"lint": "eslint src/ --ext .ts"
},
"dependencies": {
"@solana/web3.js": "^1.98.4",
"bs58": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.0",
"ethers": "5.7.2",
"express": "^4.21.0",
"express-rate-limit": "^8.4.1",
"helmet": "^8.0.0",
"jose": "^6.2.2",
"knex": "^3.1.0",

View File

@@ -8,8 +8,10 @@ import { swaggerSpec } from './config/swagger';
import { traceMiddleware } from './middleware/trace';
import { authMiddleware } from './middleware/auth';
import { csrfMiddleware } from './middleware/csrf';
import { globalLimiter, mutateLimiter, sensitiveLimiter } from './middleware/rate-limit';
import { errorHandler } from './middleware/error-handler';
import walletRoutes from './routes/wallet.routes';
import vaultRoutes from './routes/vault.routes';
import relayProxyRoutes from './routes/relay-proxy.routes';
import tronProxyRoutes from './routes/tron-proxy.routes';
import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
@@ -19,9 +21,17 @@ import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
const app = express();
// Trust proxy для корректного req.ip за reverse proxy / load balancer
app.set('trust proxy', 1);
app.use(helmet());
app.use(cors({ origin: env.cors.origins, credentials: env.cors.allowCredentials }));
app.use(express.json());
app.use(
cors({
origin: env.cors.origins.length > 0 ? env.cors.origins : false,
credentials: env.cors.allowCredentials,
}),
);
app.use(express.json({ limit: '64kb' })); // защита от больших payload-DoS
app.use(cookieParser());
app.use(traceMiddleware);
@@ -35,16 +45,24 @@ app.get('/api/docs/swagger.json', (_req, res) => {
res.json(swaggerSpec);
});
// ── PROTECTED endpoints (JWT + CSRF for mutating methods) ────────────────────
// ── Глобальный rate limit на весь API после public endpoints ────────────────
app.use('/api', globalLimiter);
// ── PROTECTED endpoints (JWT + CSRF) ─────────────────────────────────────────
const protect = [authMiddleware, csrfMiddleware];
app.use('/api/wallets', ...protect, walletRoutes);
app.use('/api/relay', ...protect, relayProxyRoutes);
app.use('/api/tron', ...protect, tronProxyRoutes);
app.use('/api/sol/swap', ...protect, solSwapProxyRoutes);
app.use('/api/tron/swap', ...protect, tronSwapProxyRoutes);
app.use('/api/btc', ...protect, btcProxyRoutes);
app.use('/api/bsc/swap', ...protect, bscSwapProxyRoutes);
// Sensitive (send / vault) — самый строгий лимит
app.use('/api/wallets/:chain/send', ...protect, sensitiveLimiter);
app.use('/api/vault', ...protect, sensitiveLimiter, vaultRoutes);
// Mutating (создание кошельков / broadcast / build) — повышенный лимит
app.use('/api/wallets', ...protect, mutateLimiter, walletRoutes);
app.use('/api/relay', ...protect, mutateLimiter, relayProxyRoutes);
app.use('/api/tron', ...protect, mutateLimiter, tronProxyRoutes);
app.use('/api/sol/swap', ...protect, mutateLimiter, solSwapProxyRoutes);
app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes);
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes);
app.use(errorHandler);

View File

@@ -31,7 +31,8 @@ export let env = {
csrfPath: p.VAULT_CSRF_PATH || '',
},
cors: {
origins: (p.CORS_ORIGINS || 'http://localhost:3000').split(','),
// No default — каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin доступа.
origins: (p.CORS_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean),
allowCredentials: p.CORS_ALLOW_CREDENTIALS !== 'false',
},
port: parseInt(p.API_PORT || '3001'),
@@ -40,6 +41,8 @@ export let env = {
jupiterApiKey: p.JUPITER_API_KEY || null,
jupiterReferralAccount: p.JUPITER_REFERRAL_ACCOUNT || null,
jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'),
etherscanApiKey: p.ETHERSCAN_API_KEY || null,
bscscanApiKey: p.BSCSCAN_API_KEY || null,
};
let vaultToken: string | null = null;

View File

@@ -0,0 +1,67 @@
import { Request, Response } from 'express';
import { UserModel } from '../models/user.model';
const MAX_VAULT_SIZE = 8192; // base64 encrypted blob upper limit
const MAX_SALT_LEN = 128;
/**
* Encrypted vault — opaque blob (зашифрованный mnemonic, AES-GCM на клиенте).
* Сервис хранит как есть; никогда не расшифровывает. Ключ только у клиента
* (PBKDF2(password+pin) или аналог).
*/
export const VaultController = {
/**
* GET /api/vault — вернуть encrypted_vault + vault_salt пользователя.
*/
async getVault(req: Request, res: Response) {
const userId = req.auth!.userId;
try {
const row = await UserModel.getVault(userId);
if (!row || !row.encrypted_vault || !row.vault_salt) {
res.status(404).json({ success: false, error: 'Vault not found' });
return;
}
res.json({
success: true,
data: {
encryptedVault: row.encrypted_vault,
vaultSalt: row.vault_salt,
},
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
},
/**
* PUT /api/vault — сохранить новый encrypted_vault + vault_salt.
* Создаёт user-row если её ещё нет.
*/
async putVault(req: Request, res: Response) {
const userId = req.auth!.userId;
const { encryptedVault, vaultSalt } = req.body ?? {};
if (typeof encryptedVault !== 'string' || encryptedVault.length === 0 || encryptedVault.length > MAX_VAULT_SIZE) {
res.status(400).json({
success: false,
error: `encryptedVault must be a non-empty string (max ${MAX_VAULT_SIZE} chars)`,
});
return;
}
if (typeof vaultSalt !== 'string' || vaultSalt.length === 0 || vaultSalt.length > MAX_SALT_LEN) {
res.status(400).json({
success: false,
error: `vaultSalt must be a non-empty string (max ${MAX_SALT_LEN} chars)`,
});
return;
}
try {
await UserModel.ensureExists(userId);
await UserModel.setVault(userId, encryptedVault, vaultSalt);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
},
};

View File

@@ -1,7 +1,22 @@
import { Request, Response } from 'express';
import { WalletModel } from '../models/wallet.model';
import { UserModel } from '../models/user.model';
import { getBalance, getTransactions, buildSend } from '../services/wallet-ops.service';
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
const ALLOWED_CHAINS = new Set<ChainCode>(['ETH', 'BTC', 'SOL', 'TRX', 'BSC']);
const MAX_WALLETS_PER_REQUEST = 20;
const MAX_DERIVATION_PATH = 64;
const MAX_TX_LIMIT = 100;
function isChain(value: unknown): value is ChainCode {
return typeof value === 'string' && ALLOWED_CHAINS.has(value as ChainCode);
}
export const WalletController = {
/**
* GET /api/wallets — все кошельки юзера
*/
async getWallets(req: Request, res: Response) {
try {
const wallets = await WalletModel.findByUserId(req.auth!.userId);
@@ -13,8 +28,182 @@ export const WalletController = {
derivationPath: w.derivation_path,
})),
});
} catch {
res.status(500).json({ success: false, error: 'Internal error' });
}
},
/**
* POST /api/wallets — upsert массива кошельков для юзера из JWT.
*/
async createWallets(req: Request, res: Response) {
const userId = req.auth!.userId;
const { wallets } = req.body ?? {};
if (!Array.isArray(wallets) || wallets.length === 0 || wallets.length > MAX_WALLETS_PER_REQUEST) {
res.status(400).json({
success: false,
error: `wallets must be a non-empty array (max ${MAX_WALLETS_PER_REQUEST})`,
});
return;
}
for (const w of wallets) {
if (!w || typeof w !== 'object') {
res.status(400).json({ success: false, error: 'Invalid wallet entry' });
return;
}
if (!isChain(w.chain)) {
res.status(400).json({ success: false, error: 'Invalid chain' });
return;
}
if (!isValidAddress(w.chain, w.address)) {
res.status(400).json({ success: false, error: 'Invalid address format for chain' });
return;
}
if (
typeof w.derivationPath !== 'string' ||
w.derivationPath.length === 0 ||
w.derivationPath.length > MAX_DERIVATION_PATH ||
!/^m(\/[0-9]+'?)*$/.test(w.derivationPath)
) {
res.status(400).json({ success: false, error: 'Invalid derivationPath (BIP32 m/.. format expected)' });
return;
}
}
try {
await UserModel.ensureExists(userId);
const rows = await WalletModel.upsertMany(
wallets.map((w: { chain: ChainCode; address: string; derivationPath: string }) => ({
user_id: userId,
chain: w.chain,
address: w.address,
derivation_path: w.derivationPath,
}))
);
res.status(201).json({
success: true,
data: rows.map((w) => ({
chain: w.chain,
address: w.address,
derivationPath: w.derivation_path,
})),
});
} catch {
res.status(500).json({ success: false, error: 'Internal error' });
}
},
/**
* GET /api/wallets/:chain/balance — баланс для адреса юзера в данной chain.
*/
async getChainBalance(req: Request, res: Response) {
const userId = req.auth!.userId;
const chain = String(req.params.chain).toUpperCase();
if (!isChain(chain)) {
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
return;
}
try {
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
return;
}
const balance = await getBalance(chain, wallet.address);
res.json({ success: true, data: balance });
} catch {
res.status(502).json({ success: false, error: 'Upstream RPC error' });
}
},
/**
* GET /api/wallets/:chain/transactions
*/
async getChainTransactions(req: Request, res: Response) {
const userId = req.auth!.userId;
const chain = String(req.params.chain).toUpperCase();
if (!isChain(chain)) {
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
return;
}
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '20')) || 20, 1), MAX_TX_LIMIT);
try {
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
return;
}
const txs = await getTransactions(chain, wallet.address, limit);
res.json({ success: true, data: txs });
} catch {
res.status(502).json({ success: false, error: 'Upstream RPC error' });
}
},
/**
* POST /api/wallets/:chain/send — build unsigned транзакцию.
*/
async sendFromChain(req: Request, res: Response) {
const userId = req.auth!.userId;
const chain = String(req.params.chain).toUpperCase();
if (!isChain(chain)) {
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
return;
}
const { to, amount, token } = req.body ?? {};
if (!isValidAddress(chain, String(to))) {
res.status(400).json({ success: false, error: 'Invalid recipient address for chain' });
return;
}
if (!isValidAmount(String(amount))) {
res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' });
return;
}
let normalizedToken: string | undefined;
if (token !== undefined && token !== null) {
if (typeof token !== 'string' || !/^[A-Z0-9]{2,10}$/.test(token)) {
res.status(400).json({ success: false, error: 'Invalid token symbol' });
return;
}
normalizedToken = token.toUpperCase();
}
try {
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
return;
}
const tx = await buildSend({
chain,
from: wallet.address,
to,
amount,
token: normalizedToken,
});
res.json({ success: true, data: tx });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
// Не возвращаем raw upstream message — может содержать sensitive info
const safeMsg = err?.message?.toLowerCase().includes('not implemented')
? 'Send not supported for this chain/token combination'
: 'Failed to build transaction';
res.status(502).json({ success: false, error: safeMsg });
}
},
};

View File

@@ -0,0 +1,39 @@
/**
* Chain-specific address format validators.
* НЕ заменяет реальную чеканку checksum — это первый barrier.
*/
import { ethers } from 'ethers';
const BTC_RE = /^(bc1[a-zA-HJ-NP-Z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/;
const TRX_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
const SOL_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
export function isValidAddress(chain: ChainCode, address: string): boolean {
if (typeof address !== 'string' || address.length === 0 || address.length > 256) return false;
switch (chain) {
case 'BTC':
return BTC_RE.test(address);
case 'TRX':
return TRX_RE.test(address);
case 'ETH':
case 'BSC':
return ethers.utils.isAddress(address);
case 'SOL':
return SOL_RE.test(address);
default:
return false;
}
}
export function isValidAmount(amount: string): boolean {
if (typeof amount !== 'string' || amount.length === 0 || amount.length > 78) return false;
if (!/^\d+$/.test(amount)) return false;
try {
return BigInt(amount) > 0n;
} catch {
return false;
}
}

View File

@@ -30,6 +30,25 @@ function getCallerInfo(): { file: string; line: number } {
return { file: 'unknown', line: 0 };
}
// Удаляем потенциально чувствительные значения из лог-сообщений.
// Защита от случайной утечки Vault tokens / passwords / secrets через сообщения ошибок.
const SENSITIVE_PATTERNS: { regex: RegExp; replace: string }[] = [
{ regex: /(password|passwd|pwd)\s*[:=]\s*\S+/gi, replace: '$1=***' },
{ regex: /(token|secret|api[_-]?key|auth)\s*[:=]\s*\S+/gi, replace: '$1=***' },
{ regex: /\bhvs\.[A-Za-z0-9]+/g, replace: 'hvs.***' },
{ regex: /\bs\.[A-Za-z0-9]{20,}/g, replace: 's.***' },
{ regex: /Bearer\s+[A-Za-z0-9._-]+/gi, replace: 'Bearer ***' },
{ regex: /Authorization:\s*\S+/gi, replace: 'Authorization: ***' },
];
function sanitize(msg: string): string {
let out = msg;
for (const { regex, replace } of SENSITIVE_PATTERNS) {
out = out.replace(regex, replace);
}
return out;
}
function log(level: string, message: string): void {
if ((LEVELS[level] ?? 0) < MIN_LEVEL) return;
@@ -41,7 +60,7 @@ function log(level: string, message: string): void {
file: caller.file,
line: caller.line,
trace_id: getTraceId(),
message,
message: sanitize(message),
};
process.stdout.write(JSON.stringify(entry) + '\n');
}

View File

@@ -11,16 +11,18 @@ export function csrfMiddleware(req: Request, res: Response, next: NextFunction):
return;
}
// CSRF отключён если VAULT_CSRF_PATH не задан
// Если CSRF полностью отключён через ENV (csrfPath пустой) — пропускаем.
// Это явная конфигурация, не fail-open.
if (!env.vault.csrfPath) {
next();
return;
}
// Секрет не загрузился (Vault недоступен) — пропускаем чтобы не блокировать сервис
// CSRF включён, но секрет не загружен (Vault недоступен) → fail-secure: 503.
// НИКОГДА не пропускаем mutating запросы при не-валидном состоянии.
if (!isCsrfConfigured()) {
logger.warn('CSRF check skipped: secret not loaded');
next();
logger.error('CSRF check unavailable: secret not loaded — rejecting mutating request');
res.status(503).json({ success: false, error: 'CSRF protection unavailable, retry later' });
return;
}

View File

@@ -1,7 +1,39 @@
import { Request, Response, NextFunction } from 'express';
import { logger } from '../lib/logger';
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
logger.error(err.message);
interface HttpError extends Error {
status?: number;
statusCode?: number;
type?: string;
}
/**
* Generic error handler. Sanitization of err.message происходит в logger.
* Клиенту НИКОГДА не отдаём raw err.message (может содержать sensitive data).
*/
export function errorHandler(err: HttpError, _req: Request, res: Response, _next: NextFunction): void {
const status = err.status || err.statusCode || 500;
// Standard Express body-parser errors
if (err.type === 'entity.too.large') {
logger.warn(`Payload too large: ${err.message}`);
res.status(413).json({ success: false, error: 'Payload too large' });
return;
}
if (err.type === 'entity.parse.failed') {
logger.warn(`Invalid JSON: ${err.message}`);
res.status(400).json({ success: false, error: 'Invalid JSON' });
return;
}
// Известные клиентские ошибки (4xx) — отдаём safe сообщение
if (status >= 400 && status < 500) {
logger.warn(`Client error ${status}: ${err.message}`);
res.status(status).json({ success: false, error: 'Bad request' });
return;
}
// Серверные ошибки (5xx) — generic message, детали только в логи
logger.error(`Server error: ${err.message}`);
res.status(500).json({ success: false, error: 'Internal server error' });
}

View File

@@ -0,0 +1,42 @@
import rateLimit, { ipKeyGenerator } from 'express-rate-limit';
import { Request } from 'express';
/**
* Per-user rate limiting (если auth есть, то по userId; иначе по IP).
* Защищает от brute force / DoS / API quota exhaustion.
*/
function keyByUserOrIp(req: Request, res: any): string {
if (req.auth?.userId) return `user:${req.auth.userId}`;
// ipKeyGenerator корректно нормализует IPv6 (по /64 префиксу)
return ipKeyGenerator(req.ip || '');
}
// Глобальный лимит на любые API запросы — защита от мусорного трафика
export const globalLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 120,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: keyByUserOrIp,
message: { success: false, error: 'Too many requests' },
});
// Жёсткий лимит на mutating операции с балансами/wallet
export const mutateLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 30,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: keyByUserOrIp,
message: { success: false, error: 'Too many mutating requests' },
});
// Самый строгий — для send / vault PUT (anti-abuse / spam tx prevention)
export const sensitiveLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 10,
standardHeaders: 'draft-7',
legacyHeaders: false,
keyGenerator: keyByUserOrIp,
message: { success: false, error: 'Too many sensitive requests' },
});

View File

@@ -1,5 +1,4 @@
import { db } from '../config/database';
import { generateUlid } from '../utils/ulid';
export interface UserRow {
id: string;
@@ -18,32 +17,62 @@ export interface UserRow {
kyc_verified: boolean;
kyc_verified_at: Date | null;
is_deleted: boolean;
encrypted_vault: string | null;
vault_salt: string | null;
created_at: Date;
updated_at: Date;
}
export const UserModel = {
async findByEmail(email: string): Promise<UserRow | undefined> {
return db('users').where({ email, is_deleted: false }).first();
},
async findById(id: string): Promise<UserRow | undefined> {
return db('users').where({ id, is_deleted: false }).first();
},
async create(data: {
email: string;
password_hash: string;
}): Promise<UserRow> {
const [user] = await db('users').insert({ id: generateUlid(), ...data }).returning('*');
return user;
async findByEmail(email: string): Promise<UserRow | undefined> {
return db('users').where({ email, is_deleted: false }).first();
},
async update(id: string, data: Partial<Omit<UserRow, 'id' | 'created_at'>>): Promise<UserRow | undefined> {
/**
* Создать запись пользователя если её нет.
* id берётся из JWT (sub). Email/password_hash — заглушки, потому что реальный
* учёт авторизации в BITOK; мы только проксируем wallet-специфичные данные.
*/
async ensureExists(id: string): Promise<void> {
await db('users')
.insert({
id,
email: `${id}@elcsa.local`,
password_hash: 'EXTERNAL_AUTH',
})
.onConflict('id')
.ignore();
},
async update(
id: string,
data: Partial<Omit<UserRow, 'id' | 'created_at'>>,
): Promise<UserRow | undefined> {
const [user] = await db('users')
.where({ id })
.update({ ...data, updated_at: db.fn.now() })
.returning('*');
return user;
},
async setVault(id: string, encryptedVault: string, vaultSalt: string): Promise<void> {
await db('users')
.where({ id })
.update({
encrypted_vault: encryptedVault,
vault_salt: vaultSalt,
updated_at: db.fn.now(),
});
},
async getVault(id: string): Promise<{ encrypted_vault: string | null; vault_salt: string | null } | undefined> {
return db('users')
.where({ id, is_deleted: false })
.select('encrypted_vault', 'vault_salt')
.first();
},
};

View File

@@ -15,10 +15,29 @@ export const WalletModel = {
return db('wallets').where({ user_id: userId });
},
async findByUserAndChain(userId: string, chain: string): Promise<WalletRow | undefined> {
return db('wallets').where({ user_id: userId, chain }).first();
},
async createMany(
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[]
): Promise<WalletRow[]> {
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
return db('wallets').insert(withIds).returning('*');
},
/**
* Insert wallets, on conflict (user_id, chain) update address + derivation_path.
* Used by POST /api/wallets — клиент шлёт массив адресов после регистрации в BITOK.
*/
async upsertMany(
wallets: { user_id: string; chain: string; address: string; derivation_path: string }[]
): Promise<WalletRow[]> {
const withIds = wallets.map((w) => ({ id: generateUlid(), ...w }));
return db('wallets')
.insert(withIds)
.onConflict(['user_id', 'chain'])
.merge(['address', 'derivation_path'])
.returning('*');
},
};

View File

@@ -56,6 +56,12 @@ async function getQuote(req: Request, res: Response) {
return;
}
const parsedSlippage = parseInt(String(slippageBps), 10);
if (!Number.isFinite(parsedSlippage) || parsedSlippage < 1 || parsedSlippage > 500) {
res.status(400).json({ success: false, error: 'slippageBps must be 1-500 (max 5%)' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);

View File

@@ -193,15 +193,55 @@ async function createTransaction(req: Request, res: Response) {
}
}
// Whitelist contracts and functions accepted via /triggersmartcontract.
// Defence in depth: иначе клиент мог бы вызвать любой контракт (e.g. drain pool).
const ALLOWED_TRC_CONTRACTS = new Set<string>([
USDT_CONTRACT, // USDT TRC20
'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax', // SunSwap router
'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E', // FeeSwapRouter_TRX
'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR', // WTRX
]);
const ALLOWED_TRC_FUNCTIONS = new Set<string>([
'transfer(address,uint256)',
'approve(address,uint256)',
'balanceOf(address)',
'allowance(address,address)',
'swapExactETHForTokens(uint256,address[],address,uint256)',
'swapExactTokensForETH(uint256,uint256,address[],address,uint256)',
'swapNativeWithFee(bytes)',
'swapTokenWithFee(address,uint256,bytes)',
'getAmountsOut(uint256,address[])',
]);
/**
* POST /api/tron/triggersmartcontract
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction.
* Whitelisted contracts + function selectors only.
*/
async function triggerSmartContract(req: Request, res: Response) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
try {
const body = req.body ?? {};
const contractAddress = String(body.contract_address || '');
const functionSelector = String(body.function_selector || '');
const ownerAddress = String(body.owner_address || '');
if (!ALLOWED_TRC_CONTRACTS.has(contractAddress)) {
res.status(403).json({ success: false, error: 'Contract address not allowed' });
return;
}
if (!ALLOWED_TRC_FUNCTIONS.has(functionSelector)) {
res.status(403).json({ success: false, error: 'Function selector not allowed' });
return;
}
if (!TRON_ADDRESS_RE.test(ownerAddress)) {
res.status(400).json({ success: false, error: 'Invalid owner_address' });
return;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',

View File

@@ -0,0 +1,9 @@
import { Router } from 'express';
import { VaultController } from '../controllers/vault.controller';
const router = Router();
router.get('/', VaultController.getVault);
router.put('/', VaultController.putVault);
export default router;

View File

@@ -4,5 +4,10 @@ import { WalletController } from '../controllers/wallet.controller';
const router = Router();
router.get('/', WalletController.getWallets);
router.post('/', WalletController.createWallets);
router.get('/:chain/balance', WalletController.getChainBalance);
router.get('/:chain/transactions', WalletController.getChainTransactions);
router.post('/:chain/send', WalletController.sendFromChain);
export default router;

View File

@@ -1,5 +1,5 @@
import crypto from 'crypto';
import { logger } from '../lib/logger';
import { fetchVaultKV2 } from '../config/vault';
/**
* CSRF token validation compatible with Python's `itsdangerous`
@@ -18,57 +18,70 @@ import { logger } from '../lib/logger';
const ITSDANGEROUS_EPOCH = 1293840000; // 2011-01-01 UTC in unix time
let csrfSecret: string | null = null;
let csrfSalt = 'itsdangerous.Signer';
let csrfDigest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
let csrfMaxAgeSec = 60 * 60 * 24 * 7; // 7 days
export interface CsrfConfig {
secret: string;
salt: string;
digest: 'sha1' | 'sha256' | 'sha512';
maxAgeSec: number;
}
export async function loadCsrfSecret(
// Live config — атомарно подменяется через swapCsrfConfig()
let current: CsrfConfig | null = null;
export function swapCsrfConfig(cfg: CsrfConfig | null): void {
current = cfg;
}
export function isCsrfConfigured(): boolean {
return current !== null;
}
/**
* Pre-fetch CSRF config из Vault — НЕ мутирует глобал, возвращает новый объект.
*/
export async function fetchCsrfConfig(
addr: string,
token: string,
mount: string,
path: string,
): Promise<void> {
const { fetchVaultKV2 } = await import('../config/vault');
): Promise<CsrfConfig> {
const secrets = await fetchVaultKV2(addr, token, mount, path);
if (!secrets) {
logger.warn('Failed to load CSRF secret from Vault');
return;
throw new Error('Failed to load CSRF secret from Vault');
}
const secret = secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
if (!secret) {
logger.warn('CSRF secret not found in Vault payload (expected key: secret_key)');
return;
const secret =
secrets.secret_key || secrets.SECRET_KEY || secrets.key || secrets.csrf_secret;
if (!secret || typeof secret !== 'string' || secret.length < 32) {
throw new Error('CSRF secret invalid: must be string >= 32 chars');
}
csrfSecret = secret;
if (secrets.salt) csrfSalt = secrets.salt;
const salt = secrets.salt || 'itsdangerous.Signer';
if (typeof salt !== 'string' || salt.length < 8) {
throw new Error('CSRF salt invalid: must be string >= 8 chars');
}
let digest: 'sha1' | 'sha256' | 'sha512' = 'sha512';
if (secrets.digest === 'sha1' || secrets.digest === 'sha256' || secrets.digest === 'sha512') {
csrfDigest = secrets.digest as 'sha1' | 'sha256' | 'sha512';
digest = secrets.digest;
}
let maxAgeSec = 60 * 60 * 24 * 7; // 7 days
if (secrets.max_age_sec) {
const n = parseInt(secrets.max_age_sec);
if (!Number.isNaN(n) && n > 0) csrfMaxAgeSec = n;
if (!Number.isNaN(n) && n > 0) maxAgeSec = n;
}
logger.info(`CSRF secret loaded (digest=${csrfDigest}, salt="${csrfSalt}", max_age=${csrfMaxAgeSec}s)`);
}
export function isCsrfConfigured(): boolean {
return csrfSecret !== null;
return { secret, salt, digest, maxAgeSec };
}
function b64urlDecode(s: string): Buffer {
// itsdangerous strips padding
const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4);
const padded = s + '='.repeat(pad);
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function deriveKey(secret: string, salt: string, digest: string): Buffer {
// itsdangerous `Signer.derive_key`: HMAC(secret, salt + "signer")
return crypto.createHmac(digest, secret).update(salt + 'signer').digest();
}
@@ -85,13 +98,13 @@ export interface CsrfVerifyResult {
}
export function verifyCsrfToken(token: string): CsrfVerifyResult {
if (!csrfSecret) return { valid: false, reason: 'CSRF secret not loaded' };
if (!current) return { valid: false, reason: 'CSRF secret not loaded' };
if (!token || typeof token !== 'string') return { valid: false, reason: 'Empty token' };
const lastDot = token.lastIndexOf('.');
if (lastDot < 0) return { valid: false, reason: 'Malformed token (no signature)' };
const payloadTs = token.slice(0, lastDot); // "<payload>.<timestamp>"
const payloadTs = token.slice(0, lastDot);
const sigStr = token.slice(lastDot + 1);
const prevDot = payloadTs.lastIndexOf('.');
@@ -99,8 +112,8 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
const tsStr = payloadTs.slice(prevDot + 1);
const derived = deriveKey(csrfSecret, csrfSalt, csrfDigest);
const expectedSig = crypto.createHmac(csrfDigest, derived).update(payloadTs).digest();
const derived = deriveKey(current.secret, current.salt, current.digest);
const expectedSig = crypto.createHmac(current.digest, derived).update(payloadTs).digest();
let actualSig: Buffer;
try {
@@ -116,12 +129,11 @@ export function verifyCsrfToken(token: string): CsrfVerifyResult {
return { valid: false, reason: 'Signature mismatch' };
}
// Timestamp check
try {
const issuedAt = decodeTimestamp(tsStr);
const now = Math.floor(Date.now() / 1000);
if (issuedAt > now + 60) return { valid: false, reason: 'Token from the future' };
if (now - issuedAt > csrfMaxAgeSec) return { valid: false, reason: 'Token expired' };
if (now - issuedAt > current.maxAgeSec) return { valid: false, reason: 'Token expired' };
} catch {
return { valid: false, reason: 'Invalid timestamp' };
}

View File

@@ -1,5 +1,6 @@
import * as jose from 'jose';
import { env } from '../config/env';
import { fetchVaultKV2 } from '../config/vault';
import { logger } from '../lib/logger';
export interface AccessTokenPayload {
@@ -19,21 +20,41 @@ export interface AuthContext {
token: AccessTokenPayload;
}
const keyMap = new Map<string, Awaited<ReturnType<typeof jose.importSPKI>>>();
type KeyType = Awaited<ReturnType<typeof jose.importSPKI>>;
export async function loadJwtKeysFromVault(
// Whitelist надёжных асимметричных алгоритмов. Никогда не разрешаем 'none'/HS*
// (HS — симметричные, могли бы быть подставлены через algorithm confusion).
const ALLOWED_ALGORITHMS = new Set(['RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'EdDSA', 'PS256', 'PS384', 'PS512']);
if (!ALLOWED_ALGORITHMS.has(env.jwt.algorithm)) {
throw new Error(`JWT_ALGORITHM "${env.jwt.algorithm}" not allowed. Use one of: ${[...ALLOWED_ALGORITHMS].join(', ')}`);
}
// Live key store — атомарно подменяется через swapKeyMap()
let keyMap: Map<string, KeyType> = new Map();
export function swapKeyMap(newMap: Map<string, KeyType>): void {
keyMap = newMap;
}
export function getKeyMapSize(): number {
return keyMap.size;
}
/**
* Pre-fetch JWT public keys from Vault, не мутируя глобальный keyMap.
* Возвращает новую Map для атомарного swap'а.
*/
export async function fetchJwtKeysFromVault(
vaultAddr: string,
vaultToken: string,
mount: string,
kidPath: string,
kidsPrefix: string,
): Promise<void> {
const { fetchVaultKV2 } = await import('../config/vault');
): Promise<Map<string, KeyType>> {
const kidData = await fetchVaultKV2(vaultAddr, vaultToken, mount, kidPath);
if (!kidData) {
logger.warn('Failed to read JWT kid config from Vault');
return;
throw new Error('Failed to read JWT kid config from Vault');
}
const kids: string[] = [];
@@ -41,10 +62,11 @@ export async function loadJwtKeysFromVault(
if (kidData.previous && kidData.previous !== kidData.active) kids.push(kidData.previous);
if (kids.length === 0) {
logger.warn('No active/previous kids found in Vault');
return;
throw new Error('No active/previous kids found in Vault');
}
const next = new Map<string, KeyType>();
for (const kid of kids) {
const kidSecret = await fetchVaultKV2(vaultAddr, vaultToken, mount, `${kidsPrefix}/${kid}`);
if (!kidSecret?.public_key) {
@@ -52,16 +74,15 @@ export async function loadJwtKeysFromVault(
continue;
}
try {
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
keyMap.set(kid, key);
logger.info(`Loaded JWT public key for kid=${kid}`);
} catch (err: any) {
logger.error(`Failed to import public key for kid=${kid}: ${err.message}`);
}
const key = await jose.importSPKI(kidSecret.public_key, env.jwt.algorithm);
next.set(kid, key);
}
logger.info(`JWT key store loaded: ${keyMap.size} key(s)`);
if (next.size === 0) {
throw new Error('No public keys could be loaded from Vault');
}
return next;
}
export async function verifyAccessToken(token: string): Promise<AuthContext> {
@@ -71,17 +92,17 @@ export async function verifyAccessToken(token: string): Promise<AuthContext> {
const header = jose.decodeProtectedHeader(token);
const kid = header.kid;
if (!kid) {
throw Object.assign(new Error('Missing kid in token header'), { status: 401 });
if (!kid || typeof kid !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(kid)) {
throw Object.assign(new Error('Missing or invalid kid in token header'), { status: 401 });
}
const key = keyMap.get(kid);
if (!key) {
logger.warn(`Unknown kid=${kid}`);
throw Object.assign(new Error('Unknown token kid'), { status: 401 });
}
if (header.alg !== env.jwt.algorithm) {
// Двойная защита от algorithm confusion: проверяем точное совпадение
if (header.alg !== env.jwt.algorithm || !ALLOWED_ALGORITHMS.has(String(header.alg))) {
throw Object.assign(new Error('Invalid token algorithm'), { status: 401 });
}

View File

@@ -1,7 +1,7 @@
import { env, getVaultToken } from '../config/env';
import { vaultAppRoleLogin } from '../config/vault';
import { loadJwtKeysFromVault } from './jwt.service';
import { loadCsrfSecret } from './csrf.service';
import { fetchJwtKeysFromVault, swapKeyMap, getKeyMapSize } from './jwt.service';
import { fetchCsrfConfig, swapCsrfConfig } from './csrf.service';
import { logger } from '../lib/logger';
const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
@@ -10,9 +10,8 @@ let timer: NodeJS.Timeout | null = null;
let currentVaultToken: string | null = null;
/**
* Refresh JWT public keys (active + previous) and CSRF secret from Vault.
* Errors are logged but do NOT throw — старые значения остаются в памяти,
* сервис продолжает работать до следующего успешного refresh.
* Atomic refresh: pre-fetch JWT keys + CSRF config, swap globals only if BOTH succeed.
* При любой ошибке оставляем старые значения в памяти, сервис продолжает работать.
*/
export async function refreshAllKeys(): Promise<void> {
const { addr, roleId, secretId, mount, jwtKidPath, jwtKidsPrefix, csrfPath } = env.vault;
@@ -22,7 +21,7 @@ export async function refreshAllKeys(): Promise<void> {
return;
}
// Use token from initEnv first call; re-login only if we don't have one yet.
// Vault token: используем закэшированный из initEnv, либо логинимся заново
let token = currentVaultToken || getVaultToken();
if (!token) {
const fresh = await vaultAppRoleLogin(addr, roleId, secretId);
@@ -34,19 +33,32 @@ export async function refreshAllKeys(): Promise<void> {
currentVaultToken = fresh;
}
try {
await loadJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
} catch (err: any) {
logger.error(`Failed to refresh JWT keys: ${err.message}`);
// ── Pre-fetch обоих секретов параллельно (НЕ мутируя глобал) ───────────
const jwtPromise = fetchJwtKeysFromVault(addr, token, mount, jwtKidPath, jwtKidsPrefix);
const csrfPromise = csrfPath ? fetchCsrfConfig(addr, token, mount, csrfPath) : Promise.resolve(null);
const [jwtResult, csrfResult] = await Promise.allSettled([jwtPromise, csrfPromise]);
// ── Атомарность: если хоть один обязательный fetch упал — НИЧЕГО не меняем ──
if (jwtResult.status === 'rejected') {
logger.error(`Key refresh ABORTED — JWT keys fetch failed: ${jwtResult.reason?.message || jwtResult.reason}`);
return;
}
if (csrfPath && csrfResult.status === 'rejected') {
logger.error(`Key refresh ABORTED — CSRF fetch failed: ${csrfResult.reason?.message || csrfResult.reason}`);
return;
}
if (csrfPath) {
try {
await loadCsrfSecret(addr, token, mount, csrfPath);
} catch (err: any) {
logger.error(`Failed to refresh CSRF secret: ${err.message}`);
}
// ── Atomic swap (синхронные операции, нельзя прервать) ──────────────────
swapKeyMap(jwtResult.value);
if (csrfResult.status === 'fulfilled' && csrfResult.value) {
swapCsrfConfig(csrfResult.value);
}
logger.info(
`Keys refreshed atomically: JWT keys=${getKeyMapSize()}` +
(csrfPath ? `, CSRF=${csrfResult.status === 'fulfilled' ? 'updated' : 'unchanged'}` : '')
);
}
export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void {
@@ -56,8 +68,7 @@ export function startKeyRotation(intervalMs: number = DEFAULT_INTERVAL_MS): void
void refreshAllKeys().catch((err) =>
logger.error(`Key rotation tick failed: ${err?.message || err}`)
);
// On token expiry Vault will return 403 — we need to re-login.
// Reset cached token so refreshAllKeys re-logs in on next call.
// На каждый тик — invalidate Vault token (он мог истечь), будет re-login
currentVaultToken = null;
}, intervalMs);
logger.info(`Key rotation scheduled (every ${intervalMs}ms)`);

View File

@@ -0,0 +1,490 @@
/**
* Wallet operations across chains: balance, transactions, build unsigned send tx.
* Non-custodial: server NEVER signs — клиент подписывает приватом.
*/
import { ethers } from 'ethers';
import { env } from '../config/env';
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
const TIMEOUT_MS = 15_000;
// ── External APIs ──
const BLOCKSTREAM = 'https://blockstream.info/api';
const TRONGRID = 'https://api.trongrid.io';
const BSC_RPC = 'https://bsc-dataseed.binance.org';
const ETH_RPC = 'https://ethereum-rpc.publicnode.com';
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
const USDT_TRC20 = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
const USDT_BEP20 = '0x55d398326f99059fF775485246999027B3197955';
const USDT_ERC20 = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
const ERC20_ABI = [
'function balanceOf(address owner) view returns (uint256)',
'function transfer(address to, uint256 amount) returns (bool)',
'function decimals() view returns (uint8)',
];
// ─────────────────────── BALANCE ───────────────────────
export interface BalanceResult {
chain: ChainCode;
address: string;
native: string; // в smallest units (satoshi/wei/lamports/sun)
tokens?: Record<string, string>; // например { USDT: "12345678" }
}
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
switch (chain) {
case 'BTC':
return { chain, address, native: await btcBalance(address) };
case 'TRX': {
const { trx, usdt } = await trxBalance(address);
return { chain, address, native: trx, tokens: { USDT: usdt } };
}
case 'BSC': {
const { native, tokens } = await evmBalance(BSC_RPC, address, [{ symbol: 'USDT', addr: USDT_BEP20 }]);
return { chain, address, native, tokens };
}
case 'ETH': {
const { native, tokens } = await evmBalance(ETH_RPC, address, [{ symbol: 'USDT', addr: USDT_ERC20 }]);
return { chain, address, native, tokens };
}
case 'SOL':
return { chain, address, native: await solBalance(address) };
}
}
async function btcBalance(address: string): Promise<string> {
const res = await fetchJson(`${BLOCKSTREAM}/address/${address}`);
const stats = res.chain_stats;
const sat = BigInt(stats.funded_txo_sum) - BigInt(stats.spent_txo_sum);
return sat.toString();
}
async function trxBalance(address: string): Promise<{ trx: string; usdt: string }> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
const accRes = await fetchJson(`${TRONGRID}/v1/accounts/${address}`, { headers });
const trx = accRes.data?.[0]?.balance ? String(accRes.data[0].balance) : '0';
// USDT TRC20 balance
const usdtRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
owner_address: address,
contract_address: USDT_TRC20,
function_selector: 'balanceOf(address)',
parameter: tronAddressToHex(address).padStart(64, '0'),
visible: true,
}),
});
const usdtHex = usdtRes.constant_result?.[0];
const usdt = usdtHex && !/^0+$/.test(usdtHex) ? BigInt('0x' + usdtHex).toString() : '0';
return { trx, usdt };
}
async function evmBalance(
rpc: string,
address: string,
tokens: { symbol: string; addr: string }[],
): Promise<{ native: string; tokens: Record<string, string> }> {
const provider = new ethers.providers.StaticJsonRpcProvider(rpc);
const native = await withTimeout(provider.getBalance(address), TIMEOUT_MS, 'EVM balance timeout');
const tokenBalances: Record<string, string> = {};
await Promise.all(
tokens.map(async ({ symbol, addr }) => {
try {
const c = new ethers.Contract(addr, ERC20_ABI, provider);
const bal: ethers.BigNumber = await withTimeout(c.balanceOf(address), TIMEOUT_MS, `${symbol} balance timeout`);
tokenBalances[symbol] = bal.toString();
} catch {
tokenBalances[symbol] = '0';
}
}),
);
return { native: native.toString(), tokens: tokenBalances };
}
async function solBalance(address: string): Promise<string> {
const res = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getBalance',
params: [address],
}),
});
return String(res.result?.value ?? 0);
}
// ─────────────────────── TRANSACTIONS ───────────────────────
export interface TxItem {
txid: string;
timestamp: number | null; // unix seconds
direction: 'in' | 'out' | 'self';
amount?: string;
token?: string;
to?: string;
from?: string;
}
export async function getTransactions(chain: ChainCode, address: string, limit: number): Promise<TxItem[]> {
switch (chain) {
case 'BTC':
return btcTransactions(address, limit);
case 'TRX':
return trxTransactions(address, limit);
case 'BSC':
return scanTransactions('https://api.bscscan.com/api', env.bscscanApiKey, address, limit);
case 'ETH':
return scanTransactions('https://api.etherscan.io/api', env.etherscanApiKey, address, limit);
case 'SOL':
return solTransactions(address, limit);
}
}
async function scanTransactions(
apiBase: string,
apiKey: string | null,
address: string,
limit: number,
): Promise<TxItem[]> {
if (!apiKey) return [];
const url = new URL(apiBase);
url.searchParams.set('module', 'account');
url.searchParams.set('action', 'txlist');
url.searchParams.set('address', address);
url.searchParams.set('startblock', '0');
url.searchParams.set('endblock', '99999999');
url.searchParams.set('page', '1');
url.searchParams.set('offset', String(Math.min(limit, 100)));
url.searchParams.set('sort', 'desc');
url.searchParams.set('apikey', apiKey);
const res = await fetchJson(url.toString());
if (res.status !== '1' || !Array.isArray(res.result)) return [];
return (res.result as any[]).slice(0, limit).map((tx) => {
const isOut = String(tx.from).toLowerCase() === address.toLowerCase();
return {
txid: tx.hash,
timestamp: tx.timeStamp ? parseInt(tx.timeStamp, 10) : null,
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
amount: tx.value || undefined,
from: tx.from,
to: tx.to,
};
});
}
async function btcTransactions(address: string, limit: number): Promise<TxItem[]> {
const txs = await fetchJson(`${BLOCKSTREAM}/address/${address}/txs`);
return (txs as any[]).slice(0, limit).map((tx) => {
const inSelf = tx.vin.some((v: any) => v.prevout?.scriptpubkey_address === address);
const outSelf = tx.vout.some((v: any) => v.scriptpubkey_address === address);
const direction: TxItem['direction'] = inSelf && outSelf ? 'self' : inSelf ? 'out' : 'in';
return {
txid: tx.txid,
timestamp: tx.status?.block_time ?? null,
direction,
amount: String(
tx.vout
.filter((v: any) => (direction === 'in' ? v.scriptpubkey_address === address : v.scriptpubkey_address !== address))
.reduce((s: bigint, v: any) => s + BigInt(v.value), 0n),
),
};
});
}
async function trxTransactions(address: string, limit: number): Promise<TxItem[]> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
const res = await fetchJson(
`${TRONGRID}/v1/accounts/${address}/transactions?limit=${limit}`,
{ headers },
);
return ((res.data as any[]) || []).slice(0, limit).map((tx) => {
const contract = tx.raw_data?.contract?.[0];
const value = contract?.parameter?.value;
const fromAddr = value?.owner_address ? hexToTron(value.owner_address) : '';
const toAddr = value?.to_address ? hexToTron(value.to_address) : '';
const isOut = fromAddr === address;
return {
txid: tx.txID,
timestamp: tx.block_timestamp ? Math.floor(tx.block_timestamp / 1000) : null,
direction: (isOut ? 'out' : 'in') as TxItem['direction'],
amount: value?.amount ? String(value.amount) : undefined,
from: fromAddr || undefined,
to: toAddr || undefined,
};
});
}
async function solTransactions(address: string, limit: number): Promise<TxItem[]> {
const res = await fetchJson(SOL_RPC, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getSignaturesForAddress',
params: [address, { limit }],
}),
});
return ((res.result as any[]) || []).map((sig) => ({
txid: sig.signature,
timestamp: sig.blockTime ?? null,
direction: 'self' as const, // без deep parsing — направление неизвестно
}));
}
// ─────────────────────── BUILD SEND (UNSIGNED TX) ───────────────────────
export interface BuildSendParams {
chain: ChainCode;
from: string;
to: string;
amount: string;
token?: string; // 'USDT' и т.д.; для native перевода — undefined
}
export type UnsignedTx =
| { kind: 'btc'; from: string; to: string; amountSat: string; utxos: any[]; feeRateSatPerVb: number }
| { kind: 'tron'; transaction: any }
| { kind: 'evm'; to: string; data: string; value: string; chainId: number; gasLimit?: string }
| { kind: 'solana'; instructions: any; recentBlockhash: string };
export async function buildSend(p: BuildSendParams): Promise<UnsignedTx> {
switch (p.chain) {
case 'BTC':
return buildBtcSend(p);
case 'TRX':
return buildTrxSend(p);
case 'BSC':
return buildEvmSend(p, BSC_RPC, 56, USDT_BEP20);
case 'ETH':
return buildEvmSend(p, ETH_RPC, 1, USDT_ERC20);
case 'SOL':
return buildSolSend(p);
}
}
async function buildBtcSend(p: BuildSendParams): Promise<UnsignedTx> {
if (p.token) throw new Error('BTC tokens not supported');
const utxos = await fetchJson(`${BLOCKSTREAM}/address/${p.from}/utxo`);
const fees = await fetchJson(`${BLOCKSTREAM}/fee-estimates`);
const confirmed = ((utxos as any[]) || []).filter((u) => u.status?.confirmed);
return {
kind: 'btc',
from: p.from,
to: p.to,
amountSat: p.amount,
utxos: confirmed.map((u) => ({ txid: u.txid, vout: u.vout, value: u.value })),
feeRateSatPerVb: Math.ceil((fees as any)['3'] ?? (fees as any)['6'] ?? 5),
};
}
async function buildTrxSend(p: BuildSendParams): Promise<UnsignedTx> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
if (!p.token) {
// Native TRX
const res = await fetchJson(`${TRONGRID}/wallet/createtransaction`, {
method: 'POST',
headers,
body: JSON.stringify({ owner_address: p.from, to_address: p.to, amount: Number(p.amount), visible: true }),
});
return { kind: 'tron', transaction: res };
}
if (p.token.toUpperCase() === 'USDT') {
// TRC20 USDT
const param = tronAddressToHex(p.to).padStart(64, '0') + BigInt(p.amount).toString(16).padStart(64, '0');
const res = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
method: 'POST',
headers,
body: JSON.stringify({
owner_address: p.from,
contract_address: USDT_TRC20,
function_selector: 'transfer(address,uint256)',
parameter: param,
fee_limit: 100_000_000,
call_value: 0,
visible: true,
}),
});
return { kind: 'tron', transaction: res };
}
throw new Error(`Token ${p.token} not supported on TRX`);
}
async function buildEvmSend(p: BuildSendParams, rpc: string, chainId: number, usdtAddr: string): Promise<UnsignedTx> {
if (!ethers.utils.isAddress(p.to)) throw new Error('Invalid recipient address');
if (!p.token) {
return { kind: 'evm', to: p.to, data: '0x', value: ethers.BigNumber.from(p.amount).toHexString(), chainId };
}
if (p.token.toUpperCase() === 'USDT') {
const iface = new ethers.utils.Interface(ERC20_ABI);
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
return { kind: 'evm', to: usdtAddr, data, value: '0x0', chainId };
}
throw new Error(`Token ${p.token} not supported on ${chainId === 56 ? 'BSC' : 'ETH'}`);
}
async function buildSolSend(p: BuildSendParams): Promise<UnsignedTx> {
const {
Connection,
PublicKey,
SystemProgram,
Transaction,
} = await import('@solana/web3.js');
const conn = new Connection(SOL_RPC, 'confirmed');
let fromPk: InstanceType<typeof PublicKey>;
let toPk: InstanceType<typeof PublicKey>;
try {
fromPk = new PublicKey(p.from);
toPk = new PublicKey(p.to);
} catch {
throw new Error('Invalid Solana address');
}
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
const tx = new Transaction({
feePayer: fromPk,
blockhash,
lastValidBlockHeight,
});
if (!p.token) {
// Native SOL transfer
tx.add(
SystemProgram.transfer({
fromPubkey: fromPk,
toPubkey: toPk,
lamports: BigInt(p.amount),
}),
);
} else {
// SPL token transfer (manual instruction — не тянем @solana/spl-token)
const TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL');
const mint = solMintFor(p.token);
if (!mint) throw new Error(`Unsupported SOL token: ${p.token}`);
const fromAta = await deriveAta(new PublicKey(mint), fromPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
const toAta = await deriveAta(new PublicKey(mint), toPk, TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID);
// Transfer instruction (instruction tag = 3 для SPL Token Transfer)
const data = Buffer.alloc(9);
data.writeUInt8(3, 0);
data.writeBigUInt64LE(BigInt(p.amount), 1);
tx.add({
programId: TOKEN_PROGRAM_ID,
keys: [
{ pubkey: fromAta, isSigner: false, isWritable: true },
{ pubkey: toAta, isSigner: false, isWritable: true },
{ pubkey: fromPk, isSigner: true, isWritable: false },
],
data,
});
}
// Сериализуем сообщение (без подписей) для клиента
const serialized = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
return {
kind: 'solana',
instructions: serialized.toString('base64'),
recentBlockhash: blockhash,
};
}
function solMintFor(symbol: string): string | null {
const map: Record<string, string> = {
USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
};
return map[symbol] ?? null;
}
async function deriveAta(
mint: any,
owner: any,
tokenProgramId: any,
associatedTokenProgramId: any,
): Promise<any> {
const { PublicKey } = await import('@solana/web3.js');
const [pda] = await PublicKey.findProgramAddress(
[owner.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()],
associatedTokenProgramId,
);
return pda;
}
// ─────────────────────── HELPERS ───────────────────────
const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
function tronAddressToHex(address: string): string {
let num = 0n;
for (const ch of address) {
const i = BASE58_ALPHABET.indexOf(ch);
if (i === -1) throw new Error('Invalid base58 character in TRON address');
num = num * 58n + BigInt(i);
}
const hex = num.toString(16).padStart(50, '0');
return hex.slice(2, 42); // 20 bytes без префикса 0x41
}
function hexToTron(hex: string): string {
// Формат TronGrid: 41xxxx (20 bytes after prefix). Это base58check.
// Для простоты возвращаем как есть hex (можно улучшить на full base58check если нужно).
return hex;
}
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Upstream ${res.status}: ${body.slice(0, 200)}`);
}
return await res.json();
} finally {
clearTimeout(t);
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const t = setTimeout(() => reject(new Error(msg)), ms);
promise.then(
(v) => { clearTimeout(t); resolve(v); },
(e) => { clearTimeout(t); reject(e); },
);
});
}

View File

@@ -2,19 +2,28 @@
"openapi": "3.0.0",
"info": {
"title": "CryptoWallet API",
"version": "2.0.0",
"description": "Multi-chain cryptocurrency wallet API with blockchain proxy services"
"version": "2.1.0",
"description": "Multi-chain crypto wallet API. Auth via JWT (cookie/Bearer), issued by external auth-service (BITOK). Non-custodial: server NEVER signs transactions, только строит unsigned tx + хранит зашифрованный vault."
},
"servers": [
{ "url": "/api", "description": "API" }
{ "url": "/api", "description": "API root" }
],
"tags": [
{ "name": "System", "description": "Health & service info" },
{ "name": "Wallets", "description": "User wallet records" },
{ "name": "Wallet Ops", "description": "Per-chain balance / transactions / send" },
{ "name": "Vault", "description": "Encrypted mnemonic blob storage (opaque)" },
{ "name": "BTC", "description": "Bitcoin RPC proxy (Blockstream)" },
{ "name": "TRON", "description": "TRON RPC proxy (TronGrid)" },
{ "name": "Solana", "description": "Solana swap proxy (Jupiter)" },
{ "name": "TRON Swap", "description": "TRON swap proxy (SunSwap + FeeSwapRouter)" },
{ "name": "BSC", "description": "BSC swap proxy (PancakeSwap V2)" },
{ "name": "Relay", "description": "Cross-chain bridges (Relay Protocol)" }
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
"bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" },
"cookieAuth": { "type": "apiKey", "in": "cookie", "name": "access_token" }
},
"schemas": {
"Error": {
@@ -24,78 +33,388 @@
"error": { "type": "string" }
}
},
"SuccessEmpty": {
"type": "object",
"properties": { "success": { "type": "boolean", "example": true } }
},
"HealthResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": true },
"data": { "type": "object", "properties": { "status": { "type": "string", "example": "ok" } } }
}
},
"Chain": {
"type": "string",
"enum": ["ETH", "BTC", "SOL", "TRX", "BSC"]
},
"Wallet": {
"type": "object",
"properties": {
"chain": { "type": "string", "enum": ["ETH", "BTC", "SOL", "TRX", "BSC"] },
"chain": { "$ref": "#/components/schemas/Chain" },
"address": { "type": "string" },
"derivationPath": { "type": "string" }
}
},
"HealthResponse": {
"WalletInput": {
"type": "object",
"required": ["chain", "address", "derivationPath"],
"properties": {
"chain": { "$ref": "#/components/schemas/Chain" },
"address": { "type": "string", "maxLength": 256 },
"derivationPath": { "type": "string", "maxLength": 64 }
}
},
"CreateWalletsRequest": {
"type": "object",
"required": ["wallets"],
"properties": {
"wallets": {
"type": "array", "minItems": 1, "maxItems": 20,
"items": { "$ref": "#/components/schemas/WalletInput" }
}
}
},
"WalletsResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": true },
"data": { "type": "array", "items": { "$ref": "#/components/schemas/Wallet" } }
}
},
"BalanceResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": true },
"data": {
"type": "object",
"properties": {
"status": { "type": "string", "example": "ok" }
"chain": { "$ref": "#/components/schemas/Chain" },
"address": { "type": "string" },
"native": { "type": "string", "description": "Balance в smallest units (sat/wei/lamports/sun)" },
"tokens": {
"type": "object",
"additionalProperties": { "type": "string" },
"example": { "USDT": "12345678" }
}
}
}
}
},
"Transaction": {
"type": "object",
"properties": {
"txid": { "type": "string" },
"timestamp": { "type": "integer", "nullable": true, "description": "Unix seconds" },
"direction": { "type": "string", "enum": ["in", "out", "self"] },
"amount": { "type": "string", "nullable": true },
"token": { "type": "string", "nullable": true },
"from": { "type": "string", "nullable": true },
"to": { "type": "string", "nullable": true }
}
},
"TransactionsResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean" },
"data": { "type": "array", "items": { "$ref": "#/components/schemas/Transaction" } }
}
},
"SendRequest": {
"type": "object",
"required": ["to", "amount"],
"properties": {
"to": { "type": "string", "description": "Recipient address" },
"amount": { "type": "string", "description": "Amount в smallest units" },
"token": { "type": "string", "nullable": true, "description": "Например USDT для TRC20/ERC20/BEP20. Без token = native (TRX/ETH/BNB/BTC)" }
}
},
"UnsignedTxResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean" },
"data": {
"type": "object",
"description": "Unsigned tx — формат зависит от chain (kind: btc | tron | evm | solana). Клиент подписывает приватом и broadcast'ит через соответствующий /api/{btc,tron}/broadcast endpoint"
}
}
},
"VaultResponse": {
"type": "object",
"properties": {
"success": { "type": "boolean" },
"data": {
"type": "object",
"properties": {
"encryptedVault": { "type": "string", "description": "AES-GCM encrypted mnemonic, base64" },
"vaultSalt": { "type": "string", "description": "PBKDF2 salt, hex" }
}
}
}
},
"VaultPutRequest": {
"type": "object",
"required": ["encryptedVault", "vaultSalt"],
"properties": {
"encryptedVault": { "type": "string", "maxLength": 8192 },
"vaultSalt": { "type": "string", "maxLength": 128 }
}
}
}
},
"security": [
{ "cookieAuth": [] },
{ "bearerAuth": [] }
],
"paths": {
"/health": {
"get": {
"summary": "Health check",
"summary": "Liveness check",
"tags": ["System"],
"security": [],
"responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthResponse" } } } } }
}
},
"/wallets": {
"get": {
"summary": "Get all wallets of authenticated user",
"tags": ["Wallets"],
"responses": {
"200": {
"description": "Service is healthy",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HealthResponse" }
}
}
}
"200": { "description": "List of wallets", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
},
"post": {
"summary": "Upsert wallets for authenticated user",
"description": "user_id берётся из JWT (sub). При первом обращении создаёт user-row автоматически. На конфликт (user_id, chain) — обновляет address + derivationPath.",
"tags": ["Wallets"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateWalletsRequest" } } }
},
"responses": {
"201": { "description": "Created/updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WalletsResponse" } } } },
"400": { "description": "Invalid input", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/wallets": {
"/wallets/{chain}/balance": {
"get": {
"summary": "Get user wallets",
"tags": ["Wallets"],
"security": [{ "bearerAuth": [] }],
"summary": "Balance for user wallet in chain",
"tags": ["Wallet Ops"],
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
"responses": {
"200": {
"description": "List of wallets",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": { "type": "boolean", "example": true },
"data": {
"type": "array",
"items": { "$ref": "#/components/schemas/Wallet" }
}
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Error" }
}
}
}
"200": { "description": "Balance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BalanceResponse" } } } },
"404": { "description": "Wallet for this chain not found" },
"502": { "description": "Upstream RPC error" }
}
}
},
"/wallets/{chain}/transactions": {
"get": {
"summary": "Transaction history for user wallet in chain",
"tags": ["Wallet Ops"],
"parameters": [
{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } },
{ "name": "limit", "in": "query", "schema": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 } }
],
"responses": {
"200": { "description": "List of transactions", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TransactionsResponse" } } } },
"404": { "description": "Wallet for this chain not found" }
}
}
},
"/wallets/{chain}/send": {
"post": {
"summary": "Build unsigned send transaction (non-custodial)",
"description": "Возвращает unsigned tx. Клиент подписывает приватным ключом и broadcast'ит через /api/{btc,tron}/broadcast или RPC своей цепи.",
"tags": ["Wallet Ops"],
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendRequest" } } }
},
"responses": {
"200": { "description": "Unsigned tx", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnsignedTxResponse" } } } },
"400": { "description": "Invalid input" },
"404": { "description": "Wallet not found" },
"502": { "description": "Upstream RPC error" }
}
}
},
"/vault": {
"get": {
"summary": "Get user's encrypted mnemonic vault",
"tags": ["Vault"],
"responses": {
"200": { "description": "Encrypted vault blob", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VaultResponse" } } } },
"404": { "description": "Vault not yet stored" }
}
},
"put": {
"summary": "Save / replace encrypted mnemonic vault",
"description": "Vault — opaque blob (AES-GCM на стороне клиента). Сервер хранит как есть, не расшифровывает.",
"tags": ["Vault"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/VaultPutRequest" } } }
},
"responses": {
"200": { "description": "Saved", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SuccessEmpty" } } } },
"400": { "description": "Invalid input" }
}
}
},
"/btc/utxos/{address}": {
"get": {
"summary": "Confirmed UTXOs for Bitcoin address",
"tags": ["BTC"],
"parameters": [{ "name": "address", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "UTXOs" }, "401": { "description": "Not authenticated" } }
}
},
"/btc/fee-estimates": {
"get": {
"summary": "Bitcoin fee estimates (sat/vB)",
"tags": ["BTC"],
"responses": { "200": { "description": "fast/normal/slow" }, "401": { "description": "Not authenticated" } }
}
},
"/btc/broadcast": {
"post": {
"summary": "Broadcast raw signed Bitcoin tx",
"tags": ["BTC"],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["hex"], "properties": { "hex": { "type": "string" } } } } } },
"responses": { "200": { "description": "txid" }, "400": { "description": "Invalid hex" } }
}
},
"/tron/account/{address}": {
"get": {
"summary": "TRON account info + USDT (TRC20) balance",
"tags": ["TRON"],
"parameters": [{ "name": "address", "in": "path", "required": true, "schema": { "type": "string" } }],
"responses": { "200": { "description": "Account data" } }
}
},
"/tron/createtransaction": {
"post": {
"summary": "Build unsigned TRX transfer",
"tags": ["TRON"],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["owner_address", "to_address", "amount"], "properties": { "owner_address": { "type": "string" }, "to_address": { "type": "string" }, "amount": { "type": "integer" } } } } } },
"responses": { "200": { "description": "Unsigned tx" } }
}
},
"/tron/triggersmartcontract": {
"post": {
"summary": "Build unsigned TRC20 contract call",
"tags": ["TRON"],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
"responses": { "200": { "description": "Unsigned tx" } }
}
},
"/tron/broadcasttransaction": {
"post": {
"summary": "Broadcast signed TRON tx",
"tags": ["TRON"],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
"responses": { "200": { "description": "Result" } }
}
},
"/sol/swap/quote": {
"get": {
"summary": "Jupiter swap quote (Solana)",
"tags": ["Solana"],
"parameters": [
{ "name": "inputMint", "in": "query", "required": true, "schema": { "type": "string" } },
{ "name": "outputMint", "in": "query", "required": true, "schema": { "type": "string" } },
{ "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } },
{ "name": "slippageBps", "in": "query", "required": true, "schema": { "type": "integer" } }
],
"responses": { "200": { "description": "Quote" } }
}
},
"/sol/swap/build": {
"post": {
"summary": "Jupiter swap build",
"tags": ["Solana"],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["quoteResponse", "userPublicKey"], "properties": { "quoteResponse": { "type": "object" }, "userPublicKey": { "type": "string" } } } } } },
"responses": { "200": { "description": "Swap tx" } }
}
},
"/tron/swap/quote": {
"get": {
"summary": "TRON swap quote (TRX <-> USDT)",
"tags": ["TRON Swap"],
"parameters": [
{ "name": "from", "in": "query", "required": true, "schema": { "type": "string", "enum": ["TRX", "USDT"] } },
{ "name": "to", "in": "query", "required": true, "schema": { "type": "string", "enum": ["TRX", "USDT"] } },
{ "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Quote" } }
}
},
"/tron/swap/build": {
"post": {
"summary": "Build TRON swap transactions",
"tags": ["TRON Swap"],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["from", "to", "amount", "amountOutMin", "userAddress"], "properties": { "from": { "type": "string" }, "to": { "type": "string" }, "amount": { "type": "string" }, "amountOutMin": { "type": "string" }, "userAddress": { "type": "string" } } } } } },
"responses": { "200": { "description": "Unsigned txs" } }
}
},
"/tron/swap/broadcast": {
"post": {
"summary": "Broadcast signed TRON swap",
"tags": ["TRON Swap"],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["signedTransaction"], "properties": { "signedTransaction": { "type": "object" } } } } } },
"responses": { "200": { "description": "Result" } }
}
},
"/bsc/swap/quote": {
"get": {
"summary": "BSC swap quote (PancakeSwap V2)",
"tags": ["BSC"],
"parameters": [
{ "name": "from", "in": "query", "required": true, "schema": { "type": "string", "enum": ["BNB", "USDT", "DOGE"] } },
{ "name": "to", "in": "query", "required": true, "schema": { "type": "string", "enum": ["BNB", "USDT", "DOGE"] } },
{ "name": "amount", "in": "query", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "Quote" } }
}
},
"/bsc/swap/build": {
"post": {
"summary": "Build BSC swap transactions",
"tags": ["BSC"],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["from", "to", "amount", "amountOutMin", "userAddress"], "properties": { "from": { "type": "string" }, "to": { "type": "string" }, "amount": { "type": "string" }, "amountOutMin": { "type": "string" }, "userAddress": { "type": "string" } } } } } },
"responses": { "200": { "description": "Unsigned txs" } }
}
},
"/relay/quote/v2": {
"get": { "summary": "Relay bridge quote", "tags": ["Relay"], "responses": { "200": { "description": "Quote" } } }
},
"/relay/intents/status/v3": {
"get": { "summary": "Relay intent status", "tags": ["Relay"], "responses": { "200": { "description": "Status" } } }
},
"/relay/execute/{action}": {
"post": {
"summary": "Relay execute",
"tags": ["Relay"],
"parameters": [{ "name": "action", "in": "path", "required": true, "schema": { "type": "string" } }],
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object" } } } },
"responses": { "200": { "description": "Result" } }
}
}
}
}