Merge branch 'main' of ssh://gitssh.elcsa.ru:22222/damanukyan/cryptowallet
This commit is contained in:
@@ -51,6 +51,13 @@ JUPITER_FEE_BPS=70
|
||||
ETHERSCAN_API_KEY=
|
||||
BSCSCAN_API_KEY=
|
||||
|
||||
# ── Price oracle (optional) ─────────────────────────────────────────
|
||||
# CoinGecko Demo API key. Без ключа работает free tier (~10-30 req/min).
|
||||
# Если задан → передаётся через header `x-cg-demo-api-key`.
|
||||
# Используется в /api/wallets/{chain}/balance (для usdPrice/usdValue)
|
||||
# и /api/prices?symbols=... KeyDB cache: 5 минут.
|
||||
COINGECKO_API_KEY=
|
||||
|
||||
# ── DB fallback (если Vault недоступен при старте) ─────────────────
|
||||
DB_HOST=
|
||||
DB_PORT=5432
|
||||
|
||||
29
README.md
29
README.md
@@ -15,7 +15,8 @@ vault kv put dev-secrets/crypto/master key=$(openssl rand -hex 32)
|
||||
# 2. CSRF secret в Vault
|
||||
vault kv put dev-secrets/csrf secret_key=$(openssl rand -hex 32) salt=csrf-salt digest=sha256
|
||||
|
||||
# 3. DB schema (миграция идемпотентна — безопасно прогонять на existing БД)
|
||||
# 3. DB schema — APPEND-ONLY / NON-DESTRUCTIVE
|
||||
# Безопасно прогонять на existing БД. См. ниже "Schema is non-destructive".
|
||||
psql -h <db-host> -U postgres_user -d postgres -f cryptowallet-schema.sql
|
||||
|
||||
# 4. bitok public key в Vault (для kid из JWT header)
|
||||
@@ -93,9 +94,33 @@ ssh server@<host> -p 2222 'cd cryptowallet && docker compose up -d --build'
|
||||
- **Relay proxy** whitelist method+path — `/quote` (POST), `/intents/status/v3` (GET), `/execute/{swap|bridge}` (POST). Никаких freeform action'ов
|
||||
- **Bridge UI dropdown** ETH/BSC/SOL only (TRX через Relay убран — плохая ликвидность)
|
||||
|
||||
## Schema is non-destructive
|
||||
|
||||
`cryptowallet-schema.sql` **append-only**. Re-run на боксе с уже настроенной БД = **zero DDL changes**. Если оператор добавил кастомные таблицы / индексы / constraints вручную — они **никогда** не будут перезаписаны или удалены.
|
||||
|
||||
Что делает script:
|
||||
- `CREATE TABLE IF NOT EXISTS users` / `wallets`
|
||||
- `ALTER TABLE users ADD COLUMN <X>` (только если колонки нет — `encrypted_mnemonic`, `erc20`, `passport_data`)
|
||||
- `CREATE UNIQUE INDEX users_email_lower_unique` (если индекса нет)
|
||||
- `CREATE INDEX idx_users_active` / `idx_wallets_*` (если индексов нет)
|
||||
- `ADD CONSTRAINT` × 4 (только если данного constraint name нет)
|
||||
|
||||
Что script **НЕ делает**:
|
||||
- ❌ Никогда не `DROP TABLE`
|
||||
- ❌ Никогда не `DROP CONSTRAINT`
|
||||
- ❌ Никогда не `DROP COLUMN`
|
||||
- ❌ Никогда не перезаписывает существующие constraints / indexes
|
||||
|
||||
Legacy cleanup (audit_log, idempotency_keys, sessions от старых версий) — **manual one-time** операторская задача, не часть этого script'а:
|
||||
```bash
|
||||
psql ... -c "DROP TABLE IF EXISTS audit_log CASCADE;"
|
||||
psql ... -c "DROP TABLE IF EXISTS idempotency_keys CASCADE;"
|
||||
psql ... -c "DROP TABLE IF EXISTS sessions CASCADE;"
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
Файловых логов **нет**. Всё в stdout, подбирается Docker log driver:
|
||||
Файловых логов **нет**. Весь код пишет в `process.stdout` (см. `apps/api/src/lib/logger.ts` и `lib/audit-log.ts`). Docker подбирает stdout через json-file driver и показывает через `docker compose logs`:
|
||||
|
||||
```bash
|
||||
docker compose logs -f api # все логи (structured JSON)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"lint": "eslint src/ --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solana/spl-token": "^0.4.14",
|
||||
"@solana/web3.js": "^1.98.4",
|
||||
"bip32": "^4.0.0",
|
||||
"bip39": "^3.1.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ import solSwapProxyRoutes from './routes/sol-swap-proxy.routes';
|
||||
import tronSwapProxyRoutes from './routes/tron-swap-proxy.routes';
|
||||
import btcProxyRoutes from './routes/btc-proxy.routes';
|
||||
import bscSwapProxyRoutes from './routes/bsc-swap-proxy.routes';
|
||||
import pricesRoutes from './routes/prices.routes';
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -97,6 +98,9 @@ app.use('/api/tron/swap', ...protect, mutateLimiter, tronSwapProxyRoutes);
|
||||
app.use('/api/btc', ...protect, mutateLimiter, btcProxyRoutes);
|
||||
app.use('/api/bsc/swap', ...protect, mutateLimiter, bscSwapProxyRoutes);
|
||||
|
||||
// USD-цены (CoinGecko + KeyDB cache). GET-only, auth required, max 50 symbols.
|
||||
app.use('/api/prices', ...protect, mutateLimiter, pricesRoutes);
|
||||
|
||||
// 404 для всего что не сматчилось выше — единый JSON-ответ, не express default text
|
||||
app.use((_req, res) => {
|
||||
res.status(404).json({ success: false, error: 'Not found' });
|
||||
|
||||
138
apps/api/src/controllers/prices.controller.ts
Normal file
138
apps/api/src/controllers/prices.controller.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* GET /api/prices — USD prices for selected token symbols.
|
||||
*
|
||||
* Security:
|
||||
* S1 — whitelist через `getCoingeckoId`. Любой symbol вне registry → 400.
|
||||
* S2 — лимит max 50 (symbol, chain) пар. Иначе → 400.
|
||||
* S5 — общий 502 при failure, без stack trace.
|
||||
* S7 — auth provided by router middleware.
|
||||
*/
|
||||
import { Request, Response } from 'express';
|
||||
import { getCoingeckoId } from '../lib/token-registry';
|
||||
import { ALL_CHAINS } from '../services/wallet-generator.service';
|
||||
import { getPricesBySymbols } from '../services/price-oracle.service';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const MAX_SYMBOLS_PER_REQUEST = 50;
|
||||
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
|
||||
const SYMBOL_RE = /^[A-Z0-9]{1,16}$/;
|
||||
|
||||
function isChain(v: unknown): v is ChainCode {
|
||||
return typeof v === 'string' && ALLOWED_CHAINS.has(v as ChainCode);
|
||||
}
|
||||
|
||||
export const PricesController = {
|
||||
/**
|
||||
* GET /api/prices?symbols=BTC,ETH,USDT&chain=ETH
|
||||
*
|
||||
* Params:
|
||||
* - symbols: comma-separated list, max 50. Каждый symbol должен быть в whitelist.
|
||||
* - chain (опционально): chain для disambiguation (USDT на ETH vs USDT на BSC).
|
||||
* Если не указан — для каждого symbol fallback порядок: ETH → BSC → SOL → TRX → BTC.
|
||||
* Native symbol (BTC/ETH/...) всегда matches its chain.
|
||||
*
|
||||
* Response 200:
|
||||
* { success: true, data: { "BTC": { "usd": 67432.12 }, "ETH": { "usd": 3210.45 }, "FOO": { "usd": null } } }
|
||||
*/
|
||||
async getPrices(req: Request, res: Response) {
|
||||
try {
|
||||
const rawSymbols = String(req.query.symbols || '').trim();
|
||||
if (!rawSymbols) {
|
||||
res.status(400).json({ success: false, error: 'symbols query param is required (csv)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedChain = req.query.chain ? String(req.query.chain).toUpperCase() : null;
|
||||
if (requestedChain && !isChain(requestedChain)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid chain parameter' });
|
||||
return;
|
||||
}
|
||||
|
||||
const symbols = rawSymbols
|
||||
.split(',')
|
||||
.map((s) => s.trim().toUpperCase())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (symbols.length === 0) {
|
||||
res.status(400).json({ success: false, error: 'symbols list is empty' });
|
||||
return;
|
||||
}
|
||||
if (symbols.length > MAX_SYMBOLS_PER_REQUEST) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Too many symbols (max ${MAX_SYMBOLS_PER_REQUEST})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Strict symbol shape (S1 belt-and-suspenders).
|
||||
for (const s of symbols) {
|
||||
if (!SYMBOL_RE.test(s)) {
|
||||
res.status(400).json({ success: false, error: `Invalid symbol: ${s}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Build (chain, symbol) pairs.
|
||||
// Fallback resolution order при отсутствии явного chain:
|
||||
// native symbol == chain code → that chain;
|
||||
// иначе пробуем ETH, BSC, SOL, TRX, BTC по очереди.
|
||||
const fallbackChains: ChainCode[] = ['ETH', 'BSC', 'SOL', 'TRX', 'BTC'];
|
||||
const pairs: { chain: ChainCode; symbol: string; key: string }[] = [];
|
||||
|
||||
for (const sym of symbols) {
|
||||
if (requestedChain) {
|
||||
pairs.push({ chain: requestedChain as ChainCode, symbol: sym, key: sym });
|
||||
continue;
|
||||
}
|
||||
let resolvedChain: ChainCode | null = null;
|
||||
if (ALLOWED_CHAINS.has(sym as ChainCode)) {
|
||||
resolvedChain = sym as ChainCode;
|
||||
} else {
|
||||
for (const c of fallbackChains) {
|
||||
if (getCoingeckoId(c, sym)) {
|
||||
resolvedChain = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!resolvedChain) {
|
||||
// Symbol не находится ни в одной chain → 400 (S1: whitelist enforcement).
|
||||
res.status(400).json({ success: false, error: `Unknown symbol: ${sym}` });
|
||||
return;
|
||||
}
|
||||
pairs.push({ chain: resolvedChain, symbol: sym, key: sym });
|
||||
}
|
||||
|
||||
// Если явный chain задан — повторная проверка whitelist для каждого symbol
|
||||
// (native symbol для chain'а тоже разрешён).
|
||||
if (requestedChain) {
|
||||
for (const p of pairs) {
|
||||
if (!getCoingeckoId(p.chain, p.symbol)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Unknown symbol ${p.symbol} for chain ${p.chain}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prices = await getPricesBySymbols(
|
||||
pairs.map((p) => ({ chain: p.chain, symbol: p.symbol })),
|
||||
);
|
||||
|
||||
const data: Record<string, { usd: number | null }> = {};
|
||||
for (const p of pairs) {
|
||||
const lookupKey = `${p.chain}:${p.symbol}`;
|
||||
data[p.key] = { usd: prices.get(lookupKey) ?? null };
|
||||
}
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (err: any) {
|
||||
logger.error(`getPrices failed: ${err?.stack || err?.message || 'unknown'}`);
|
||||
res.status(502).json({ success: false, error: 'Upstream price oracle error' });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -6,7 +6,8 @@ import { getBalance, getTransactions } from '../services/wallet-ops.service';
|
||||
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
||||
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
||||
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
|
||||
import { signAndBroadcast, signAndBroadcastRawEvm } from '../services/wallet-signer.service';
|
||||
import { signAndBroadcast, signAndBroadcastRawEvm, signAndBroadcastSolanaTx } from '../services/wallet-signer.service';
|
||||
import { swapBsc, swapTrx, swapSol } from '../services/swap-orchestrator.service';
|
||||
import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service';
|
||||
import { applyEvmTxPolicy } from '../lib/evm-tx-policy';
|
||||
import { acquireSendLock } from '../lib/send-lock';
|
||||
@@ -320,8 +321,10 @@ export const WalletController = {
|
||||
|
||||
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' });
|
||||
// Regex: ≤2-10 char, case-insensitive (we'll lookup token-registry case-insensitive)
|
||||
// Accept letters + digits — registry has tokens like 'W', 'WBNB', 'TRUMP', '1INCH' etc.
|
||||
if (typeof token !== 'string' || !/^[A-Za-z0-9]{1,10}$/.test(token)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid token symbol format' });
|
||||
return;
|
||||
}
|
||||
normalizedToken = token.toUpperCase();
|
||||
@@ -619,4 +622,239 @@ export const WalletController = {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/wallets/:chain/swap — chained custodial swap.
|
||||
* BSC: PancakeSwap V2 — approve (если token-to-anything) + swap, sign+broadcast в одном вызове.
|
||||
* TRX: SunSwap — build + sign + broadcast (TRX↔USDT).
|
||||
* SOL: Jupiter — quote + swap + sign + broadcast.
|
||||
*
|
||||
* Body для BSC/TRX: { from, to, amount, slippageBps?, feeTier? } — symbols (BNB, USDT, DOGE, USDC).
|
||||
* Body для SOL: { inputMint, outputMint, amount, slippageBps? } — mint addresses.
|
||||
*/
|
||||
async swapOnChain(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
const chain = String(req.params.chain).toUpperCase();
|
||||
if (chain !== 'BSC' && chain !== 'TRX' && chain !== 'SOL') {
|
||||
res.status(400).json({ success: false, error: 'Swap supported only on BSC, TRX, SOL. For ETH use Relay quote→execute→sign-raw-evm-tx.' });
|
||||
return;
|
||||
}
|
||||
if (!isCryptoReady()) {
|
||||
res.status(503).json({ success: false, error: 'Crypto service not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency
|
||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
||||
if (idempKey) {
|
||||
try {
|
||||
const claim = await claimIdempotency(userId, idempKey, req.body);
|
||||
if (!claim.fresh && claim.cached) {
|
||||
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(409).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const releaseLock = await acquireSendLock(userId, chain);
|
||||
let mnemonic: string | null = null;
|
||||
let auditId: string;
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, chain as ChainCode);
|
||||
if (!wallet) {
|
||||
res.status(404).json({ success: false, error: 'Wallet not found for this chain' });
|
||||
return;
|
||||
}
|
||||
const blob = await UserModel.getEncryptedMnemonic(userId);
|
||||
if (!blob) {
|
||||
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
auditId = await auditLogStrict({
|
||||
event: 'wallet.swap',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
meta: { chain, body: req.body },
|
||||
});
|
||||
} catch (auditErr: any) {
|
||||
logger.error(`Audit DB INSERT MUST succeed for wallet.swap: ${auditErr.message}`);
|
||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
mnemonic = decryptMnemonic(blob);
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
if (chain === 'BSC') {
|
||||
const { from, to, amount, slippageBps, feeTier } = req.body ?? {};
|
||||
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
||||
throw new Error('BSC swap body: {from, to, amount} required as strings');
|
||||
}
|
||||
result = await swapBsc({
|
||||
mnemonic,
|
||||
expectedFromAddress: wallet.address,
|
||||
from, to, amount,
|
||||
slippageBps,
|
||||
feeTier,
|
||||
});
|
||||
} else if (chain === 'TRX') {
|
||||
const { from, to, amount, slippageBps } = req.body ?? {};
|
||||
if (typeof from !== 'string' || typeof to !== 'string' || typeof amount !== 'string') {
|
||||
throw new Error('TRX swap body: {from, to, amount} required as strings');
|
||||
}
|
||||
result = await swapTrx({
|
||||
mnemonic,
|
||||
expectedFromAddress: wallet.address,
|
||||
from, to, amount,
|
||||
slippageBps,
|
||||
});
|
||||
} else {
|
||||
// SOL Jupiter
|
||||
const { inputMint, outputMint, amount, slippageBps } = req.body ?? {};
|
||||
if (typeof inputMint !== 'string' || typeof outputMint !== 'string' || typeof amount !== 'string') {
|
||||
throw new Error('SOL swap body: {inputMint, outputMint, amount} required as strings');
|
||||
}
|
||||
result = await swapSol({
|
||||
mnemonic,
|
||||
expectedFromAddress: wallet.address,
|
||||
inputMint, outputMint, amount,
|
||||
slippageBps,
|
||||
});
|
||||
}
|
||||
} catch (swapErr: any) {
|
||||
await completeAudit(auditId, 'failure', undefined, 'SWAP_FAILED');
|
||||
throw swapErr;
|
||||
}
|
||||
|
||||
await completeAudit(auditId, 'success', result);
|
||||
res.json({ success: true, data: { chain, ...result } });
|
||||
} catch (err: any) {
|
||||
logger.error(`swap failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
|
||||
await auditLog({
|
||||
event: 'wallet.swap',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'failure',
|
||||
meta: { chain },
|
||||
errorCode: 'SWAP_FAILED',
|
||||
});
|
||||
res.status(502).json({ success: false, error: err?.message?.slice?.(0, 250) || 'Swap failed' });
|
||||
} finally {
|
||||
mnemonic = null;
|
||||
releaseLock();
|
||||
if (idempKey) {
|
||||
const status = res.statusCode;
|
||||
saveIdempotencyResponse(userId, idempKey, status, JSON.stringify({ cached: true, status }))
|
||||
.catch((e: any) => logger.warn(`saveIdempotencyResponse failed: ${e.message}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/wallets/SOL/sign-and-broadcast-tx — custodial sign + broadcast для arbitrary serialized SOL tx.
|
||||
* Используется для Relay bridge SOL-side (когда Relay /execute возвращает unsigned Solana tx).
|
||||
*
|
||||
* Body: { transaction: '<base64 serialized VersionedTransaction>' }
|
||||
*/
|
||||
async signSolanaTx(req: Request, res: Response) {
|
||||
const userId = req.auth!.userId;
|
||||
if (!isCryptoReady()) {
|
||||
res.status(503).json({ success: false, error: 'Crypto service not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { transaction } = req.body ?? {};
|
||||
if (typeof transaction !== 'string' || transaction.length === 0 || transaction.length > 8192) {
|
||||
res.status(400).json({ success: false, error: 'Invalid transaction (must be non-empty base64 string ≤8KB)' });
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(transaction)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid base64 transaction' });
|
||||
return;
|
||||
}
|
||||
|
||||
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
|
||||
if (idempKey) {
|
||||
try {
|
||||
const claim = await claimIdempotency(userId, idempKey, req.body);
|
||||
if (!claim.fresh && claim.cached) {
|
||||
res.status(claim.cached.status).type('application/json').send(claim.cached.body);
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(409).json({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const releaseLock = await acquireSendLock(userId, 'SOL');
|
||||
let mnemonic: string | null = null;
|
||||
let auditId: string;
|
||||
try {
|
||||
const wallet = await WalletModel.findByUserAndChain(userId, 'SOL');
|
||||
if (!wallet) {
|
||||
res.status(404).json({ success: false, error: 'SOL wallet not found' });
|
||||
return;
|
||||
}
|
||||
const blob = await UserModel.getEncryptedMnemonic(userId);
|
||||
if (!blob) {
|
||||
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
auditId = await auditLogStrict({
|
||||
event: 'wallet.sign_sol_tx',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
meta: { chain: 'SOL', txLength: transaction.length },
|
||||
});
|
||||
} catch (auditErr: any) {
|
||||
logger.error(`Audit DB INSERT MUST succeed: ${auditErr.message}`);
|
||||
res.status(503).json({ success: false, error: 'Audit service unavailable' });
|
||||
return;
|
||||
}
|
||||
|
||||
mnemonic = decryptMnemonic(blob);
|
||||
|
||||
let result: { signature: string };
|
||||
try {
|
||||
result = await signAndBroadcastSolanaTx({
|
||||
mnemonic,
|
||||
expectedFromAddress: wallet.address,
|
||||
serializedTransaction: transaction,
|
||||
});
|
||||
} catch (signErr: any) {
|
||||
await completeAudit(auditId, 'failure', undefined, 'SOL_SIGN_FAILED');
|
||||
throw signErr;
|
||||
}
|
||||
|
||||
await completeAudit(auditId, 'success', { signature: result.signature });
|
||||
res.json({ success: true, data: { signature: result.signature, chain: 'SOL' } });
|
||||
} catch (err: any) {
|
||||
logger.error(`signSolanaTx failed for user ${userId}: ${err.stack || err.message}`);
|
||||
await auditLog({
|
||||
event: 'wallet.sign_sol_tx',
|
||||
userId,
|
||||
ip: req.ip || null,
|
||||
result: 'failure',
|
||||
errorCode: 'SOL_SIGN_FAILED',
|
||||
});
|
||||
res.status(502).json({ success: false, error: err?.message?.slice?.(0, 200) || 'SOL sign failed' });
|
||||
} finally {
|
||||
mnemonic = null;
|
||||
releaseLock();
|
||||
if (idempKey) {
|
||||
const status = res.statusCode;
|
||||
saveIdempotencyResponse(userId, idempKey, status, JSON.stringify({ cached: true, status }))
|
||||
.catch((e: any) => logger.warn(`saveIdempotencyResponse failed: ${e.message}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,57 +12,72 @@ export interface EvmToken {
|
||||
symbol: string;
|
||||
contractAddress: string;
|
||||
decimals: number;
|
||||
coingeckoId?: string;
|
||||
}
|
||||
|
||||
export interface TrxToken {
|
||||
symbol: string;
|
||||
contractAddress: string; // T...base58
|
||||
decimals: number;
|
||||
coingeckoId?: string;
|
||||
}
|
||||
|
||||
export interface SolToken {
|
||||
symbol: string;
|
||||
mint: string; // SPL mint pubkey (base58)
|
||||
decimals: number;
|
||||
coingeckoId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CoinGecko coin IDs для native монет каждой chain.
|
||||
* Используется в `price-oracle.service.ts` для USD-цен в `/balance`.
|
||||
*/
|
||||
export const NATIVE_COINGECKO_IDS: Record<ChainCode, string> = {
|
||||
BTC: 'bitcoin',
|
||||
ETH: 'ethereum',
|
||||
BSC: 'binancecoin',
|
||||
TRX: 'tron',
|
||||
SOL: 'solana',
|
||||
};
|
||||
|
||||
export const ETH_TOKENS: EvmToken[] = [
|
||||
{ symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
|
||||
{ symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
|
||||
{ symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18 },
|
||||
{ symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 },
|
||||
{ symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 },
|
||||
{ symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18 },
|
||||
{ symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6, coingeckoId: 'tether' },
|
||||
{ symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6, coingeckoId: 'usd-coin' },
|
||||
{ symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18, coingeckoId: 'dai' },
|
||||
{ symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8, coingeckoId: 'wrapped-bitcoin' },
|
||||
{ symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18, coingeckoId: 'chainlink' },
|
||||
{ symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18, coingeckoId: 'uniswap' },
|
||||
];
|
||||
|
||||
export const BSC_TOKENS: EvmToken[] = [
|
||||
{ symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
|
||||
{ symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18 },
|
||||
{ symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 },
|
||||
{ symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18 },
|
||||
{ symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18 },
|
||||
{ symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18, coingeckoId: 'tether' },
|
||||
{ symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18, coingeckoId: 'usd-coin' },
|
||||
{ symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8, coingeckoId: 'dogecoin' },
|
||||
{ symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18, coingeckoId: 'wbnb' },
|
||||
{ symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18, coingeckoId: 'binance-usd' },
|
||||
];
|
||||
|
||||
export const TRX_TOKENS: TrxToken[] = [
|
||||
{ symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
|
||||
{ symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6 },
|
||||
{ symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, coingeckoId: 'tether' },
|
||||
{ symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6, coingeckoId: 'usd-coin' },
|
||||
];
|
||||
|
||||
export const SOL_TOKENS: SolToken[] = [
|
||||
{ symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
|
||||
{ symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
|
||||
{ symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 },
|
||||
{ symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 },
|
||||
{ symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 },
|
||||
{ symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 },
|
||||
{ symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 },
|
||||
{ symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 },
|
||||
{ symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 },
|
||||
{ symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 },
|
||||
{ symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 },
|
||||
{ symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 },
|
||||
{ symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 },
|
||||
{ symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 },
|
||||
{ symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6, coingeckoId: 'tether' },
|
||||
{ symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6, coingeckoId: 'usd-coin' },
|
||||
{ symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6, coingeckoId: 'pump-fun' },
|
||||
{ symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6, coingeckoId: 'jupiter-exchange-solana' },
|
||||
{ symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6, coingeckoId: 'dogwifcoin' },
|
||||
{ symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9, coingeckoId: 'popcat' },
|
||||
{ symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6, coingeckoId: 'official-trump' },
|
||||
{ symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6, coingeckoId: 'pyth-network' },
|
||||
{ symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9, coingeckoId: 'jito-governance-token' },
|
||||
{ symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6, coingeckoId: 'wormhole' },
|
||||
{ symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5, coingeckoId: 'bonk' },
|
||||
{ symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6, coingeckoId: 'orca' },
|
||||
{ symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6, coingeckoId: 'pudgy-penguins' },
|
||||
{ symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6, coingeckoId: 'raydium' },
|
||||
];
|
||||
|
||||
export function getEvmTokens(chain: ChainCode): EvmToken[] {
|
||||
@@ -78,3 +93,69 @@ export function getTrxTokens(): TrxToken[] {
|
||||
export function getSolTokens(): SolToken[] {
|
||||
return SOL_TOKENS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal lookup для send flow. Returns address+decimals или null если token не в registry.
|
||||
* Symbol comparison case-insensitive.
|
||||
*
|
||||
* Usage:
|
||||
* const info = getTokenInfo('BSC', 'USDC');
|
||||
* // → { address: '0x8AC76a51...', decimals: 18 }
|
||||
*
|
||||
* const info = getTokenInfo('SOL', 'USDT');
|
||||
* // → { address: 'Es9vMFrza...', decimals: 6 } (mint address)
|
||||
*/
|
||||
export function getTokenInfo(chain: ChainCode, symbol: string): { address: string; decimals: number } | null {
|
||||
const upper = String(symbol).toUpperCase();
|
||||
if (chain === 'ETH' || chain === 'BSC') {
|
||||
const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t ? { address: t.contractAddress, decimals: t.decimals } : null;
|
||||
}
|
||||
if (chain === 'TRX') {
|
||||
const t = TRX_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t ? { address: t.contractAddress, decimals: t.decimals } : null;
|
||||
}
|
||||
if (chain === 'SOL') {
|
||||
const t = SOL_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t ? { address: t.mint, decimals: t.decimals } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the CoinGecko coin id for a given (chain, symbol) pair.
|
||||
*
|
||||
* Если `symbol` совпадает с самим именем chain (BTC/ETH/BSC/TRX/SOL) — возвращает
|
||||
* native id (`NATIVE_COINGECKO_IDS[chain]`).
|
||||
* В остальных случаях ищет токен в реестре сети и возвращает его `coingeckoId`.
|
||||
*
|
||||
* Возвращает `null` если:
|
||||
* - chain неизвестен;
|
||||
* - symbol не найден в реестре сети;
|
||||
* - токен найден, но `coingeckoId` для него не задан.
|
||||
*
|
||||
* Используется исключительно как whitelist для price oracle (см. S1 в плане):
|
||||
* никакой свободный user-input не попадает в CoinGecko URL.
|
||||
*/
|
||||
export function getCoingeckoId(chain: ChainCode, symbol: string): string | null {
|
||||
if (!chain) return null;
|
||||
const upper = String(symbol || '').toUpperCase();
|
||||
if (!upper) return null;
|
||||
|
||||
// Native — symbol === chain code (BTC, ETH, ...).
|
||||
if (upper === chain) return NATIVE_COINGECKO_IDS[chain] ?? null;
|
||||
|
||||
if (chain === 'ETH' || chain === 'BSC') {
|
||||
const t = getEvmTokens(chain).find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t?.coingeckoId ?? null;
|
||||
}
|
||||
if (chain === 'TRX') {
|
||||
const t = TRX_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t?.coingeckoId ?? null;
|
||||
}
|
||||
if (chain === 'SOL') {
|
||||
const t = SOL_TOKENS.find((x) => x.symbol.toUpperCase() === upper);
|
||||
return t?.coingeckoId ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
8
apps/api/src/routes/prices.routes.ts
Normal file
8
apps/api/src/routes/prices.routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { PricesController } from '../controllers/prices.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', PricesController.getPrices);
|
||||
|
||||
export default router;
|
||||
@@ -11,5 +11,7 @@ router.get('/:chain/transactions', WalletController.getChainTransactions);
|
||||
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
|
||||
router.post('/:chain/send', WalletController.sendFromChain);
|
||||
router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx);
|
||||
router.post('/:chain/swap', WalletController.swapOnChain);
|
||||
router.post('/SOL/sign-and-broadcast-tx', WalletController.signSolanaTx);
|
||||
|
||||
export default router;
|
||||
|
||||
235
apps/api/src/services/price-oracle.service.ts
Normal file
235
apps/api/src/services/price-oracle.service.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* USD price oracle for wallet balance responses.
|
||||
*
|
||||
* Data source: CoinGecko free API (https://api.coingecko.com/api/v3/simple/price).
|
||||
* Cache: KeyDB (Redis), TTL = 300s.
|
||||
*
|
||||
* Security (см. план §"Security checklist"):
|
||||
* S1 — whitelist через getCoingeckoId → user input не попадает в URL.
|
||||
* S2 — лимит размеров вызовов через caller (controller `/prices`).
|
||||
* S3 — strict typeof/Number.isFinite/>=0 при чтении cache.
|
||||
* S4 — in-flight dedup (см. `_inflight` map) + cache.
|
||||
* S5 — никаких stack-trace'ов наружу; ошибки в logger.
|
||||
* S9 — CG API key, если задан, идёт ТОЛЬКО в header (не в URL).
|
||||
* S10 — `Number.isFinite` guard для usdValue (применяется в `wallet-ops.service.ts`).
|
||||
* S11 — жёсткий 5s AbortController timeout.
|
||||
* S12 — `null` ответ не кэшируем; только успешные числа уходят в Redis.
|
||||
*/
|
||||
|
||||
import { getRedis } from '../config/redis';
|
||||
import { logger } from '../lib/logger';
|
||||
import { getCoingeckoId } from '../lib/token-registry';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
const COINGECKO_URL = 'https://api.coingecko.com/api/v3/simple/price';
|
||||
const CACHE_TTL_SECONDS = 300;
|
||||
const CACHE_KEY_PREFIX = 'price:';
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
const MAX_IDS_PER_REQUEST = 100; // CoinGecko allows ~250, мы консервативно 100.
|
||||
|
||||
interface CachedPrice {
|
||||
usd: number;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/** In-flight dedup — несколько параллельных запросов одного id шлют ОДИН fetch. */
|
||||
const _inflight = new Map<string, Promise<Record<string, number | null>>>();
|
||||
|
||||
function isValidPrice(n: unknown): n is number {
|
||||
return typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
||||
}
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
const key = process.env.COINGECKO_API_KEY;
|
||||
if (key && key.length > 0) {
|
||||
// CoinGecko Demo API key → `x-cg-demo-api-key`. Pro → `x-cg-pro-api-key`.
|
||||
// Не печатаем header нигде, см. S9.
|
||||
headers['x-cg-demo-api-key'] = key;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches CoinGecko /simple/price for a batch of coin ids.
|
||||
* Internal — caller must ensure `ids.length > 0 && ids.length <= MAX_IDS_PER_REQUEST`.
|
||||
*/
|
||||
async function fetchCoingecko(ids: string[]): Promise<Record<string, number | null>> {
|
||||
const url = `${COINGECKO_URL}?ids=${ids.join(',')}&vs_currencies=usd`;
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: ctrl.signal,
|
||||
headers: buildHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// S5: не логируем URL целиком (содержит query string).
|
||||
logger.warn(`CoinGecko HTTP ${res.status} for ${ids.length} ids`);
|
||||
const out: Record<string, number | null> = {};
|
||||
for (const id of ids) out[id] = null;
|
||||
return out;
|
||||
}
|
||||
const json = (await res.json()) as Record<string, { usd?: unknown }>;
|
||||
const out: Record<string, number | null> = {};
|
||||
for (const id of ids) {
|
||||
const usd = json?.[id]?.usd;
|
||||
out[id] = isValidPrice(usd) ? usd : null;
|
||||
}
|
||||
return out;
|
||||
} catch (err: any) {
|
||||
logger.warn(`CoinGecko fetch failed (${ids.length} ids): ${err?.message || 'unknown'}`);
|
||||
const out: Record<string, number | null> = {};
|
||||
for (const id of ids) out[id] = null;
|
||||
return out;
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает USD-цены для списка CoinGecko ids.
|
||||
* Никогда не throws — degrades to `null` per-id.
|
||||
*
|
||||
* Cache: read-through KeyDB, 300s TTL. Только валидные числа кэшируются (S12).
|
||||
* Dedup: in-flight Map предотвращает дублирующиеся upstream вызовы (S4).
|
||||
*/
|
||||
export async function getPricesByIds(ids: string[]): Promise<Record<string, number | null>> {
|
||||
if (!Array.isArray(ids) || ids.length === 0) return {};
|
||||
|
||||
// Дедупликация ids (на случай если caller передал duplicates).
|
||||
const uniqIds = Array.from(new Set(ids.filter((x) => typeof x === 'string' && x.length > 0)));
|
||||
if (uniqIds.length === 0) return {};
|
||||
|
||||
const result: Record<string, number | null> = {};
|
||||
let redis: ReturnType<typeof getRedis> | null = null;
|
||||
try {
|
||||
redis = getRedis();
|
||||
} catch {
|
||||
// Redis singleton недоступен — продолжаем без cache, сразу идём в CG.
|
||||
redis = null;
|
||||
}
|
||||
|
||||
// 1) Read cache (pipeline)
|
||||
const misses: string[] = [];
|
||||
if (redis) {
|
||||
try {
|
||||
const pipeline = redis.pipeline();
|
||||
for (const id of uniqIds) pipeline.get(CACHE_KEY_PREFIX + id);
|
||||
const cached = await pipeline.exec();
|
||||
uniqIds.forEach((id, i) => {
|
||||
const tuple = cached?.[i];
|
||||
const raw = tuple?.[1] as string | null | undefined;
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as CachedPrice;
|
||||
if (isValidPrice(parsed?.usd)) {
|
||||
result[id] = parsed.usd;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// S3 — невалидный JSON в cache → fall through к refetch.
|
||||
}
|
||||
}
|
||||
misses.push(id);
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn(`Redis cache read failed: ${err?.message || 'unknown'}`);
|
||||
// Cache miss for ALL ids — degrade to upstream fetch.
|
||||
for (const id of uniqIds) {
|
||||
if (!(id in result)) misses.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const id of uniqIds) misses.push(id);
|
||||
}
|
||||
|
||||
if (misses.length === 0) return result;
|
||||
|
||||
// 2) Fetch misses в batches (S2-style guard) + in-flight dedup (S4).
|
||||
const fetched: Record<string, number | null> = {};
|
||||
for (let i = 0; i < misses.length; i += MAX_IDS_PER_REQUEST) {
|
||||
const batch = misses.slice(i, i + MAX_IDS_PER_REQUEST);
|
||||
const batchKey = batch.join('|');
|
||||
|
||||
let p = _inflight.get(batchKey);
|
||||
if (!p) {
|
||||
p = fetchCoingecko(batch).finally(() => _inflight.delete(batchKey));
|
||||
_inflight.set(batchKey, p);
|
||||
}
|
||||
const batchResult = await p;
|
||||
Object.assign(fetched, batchResult);
|
||||
}
|
||||
|
||||
// 3) Persist successes to cache (S12: skip nulls).
|
||||
if (redis) {
|
||||
try {
|
||||
const setP = redis.pipeline();
|
||||
let writes = 0;
|
||||
for (const [id, val] of Object.entries(fetched)) {
|
||||
if (isValidPrice(val)) {
|
||||
setP.set(
|
||||
CACHE_KEY_PREFIX + id,
|
||||
JSON.stringify({ usd: val, ts: Date.now() } satisfies CachedPrice),
|
||||
'EX',
|
||||
CACHE_TTL_SECONDS,
|
||||
);
|
||||
writes += 1;
|
||||
}
|
||||
}
|
||||
if (writes > 0) await setP.exec();
|
||||
} catch (err: any) {
|
||||
// Cache write failure → не критично, продолжаем.
|
||||
logger.warn(`Redis cache write failed: ${err?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Merge fetched into result.
|
||||
for (const id of misses) {
|
||||
result[id] = id in fetched ? fetched[id] : null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience-обёртка для callers которые оперируют (chain, symbol) парами.
|
||||
*
|
||||
* Возвращает Map с ключами вида `"{CHAIN}:{SYMBOL}"` → price | null.
|
||||
* Ключ совпадает с тем что caller затем использует на lookup'е.
|
||||
*
|
||||
* Symbol-ы НЕ из реестра → ключ присутствует, value = `null` (graceful).
|
||||
* Никаких throw'ов, никаких побочек кроме cache writes.
|
||||
*/
|
||||
export async function getPricesBySymbols(
|
||||
pairs: { chain: ChainCode; symbol: string }[],
|
||||
): Promise<Map<string, number | null>> {
|
||||
const out = new Map<string, number | null>();
|
||||
if (!Array.isArray(pairs) || pairs.length === 0) return out;
|
||||
|
||||
// (chain:symbol) → coingeckoId | null
|
||||
const pairToId = new Map<string, string | null>();
|
||||
const idsToFetch = new Set<string>();
|
||||
|
||||
for (const { chain, symbol } of pairs) {
|
||||
const key = `${chain}:${symbol}`;
|
||||
if (pairToId.has(key)) continue; // dedup
|
||||
const id = getCoingeckoId(chain, symbol);
|
||||
pairToId.set(key, id);
|
||||
if (id) idsToFetch.add(id);
|
||||
else out.set(key, null);
|
||||
}
|
||||
|
||||
const prices = await getPricesByIds(Array.from(idsToFetch));
|
||||
|
||||
for (const [key, id] of pairToId.entries()) {
|
||||
if (out.has(key)) continue;
|
||||
if (!id) {
|
||||
out.set(key, null);
|
||||
continue;
|
||||
}
|
||||
out.set(key, prices[id] ?? null);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
845
apps/api/src/services/swap-orchestrator.service.ts
Normal file
845
apps/api/src/services/swap-orchestrator.service.ts
Normal file
@@ -0,0 +1,845 @@
|
||||
/**
|
||||
* Swap orchestrator — chained custodial swap для всех 3 DEX (BSC PancakeSwap, TRX SunSwap, SOL Jupiter).
|
||||
*
|
||||
* Каждая функция inkl. полный flow: build → sign → broadcast в одном вызове.
|
||||
* Возвращает txid'ы — клиенту не нужно client-side signing.
|
||||
*
|
||||
* Reused infrastructure:
|
||||
* - ethers / @solana/web3.js / TronGrid HTTP
|
||||
* - Master-key crypto через decryptMnemonic (caller)
|
||||
* - Mutex / idempotency (caller)
|
||||
* - Audit log (caller)
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import * as bip39 from 'bip39';
|
||||
import {
|
||||
Keypair, Connection, PublicKey, VersionedTransaction,
|
||||
} from '@solana/web3.js';
|
||||
import { derivePath } from 'ed25519-hd-key';
|
||||
import { env } from '../config/env';
|
||||
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
|
||||
import { getEvmFeeForTier, type FeeTier } from './gas-oracle.service';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const HTTP_TIMEOUT_MS = 20_000;
|
||||
const MAX_GAS_PRICE_GWEI = 500;
|
||||
|
||||
// ─── BSC PancakeSwap V2 ─────────────────────────────────────────────
|
||||
|
||||
const BSC_RPCS = [
|
||||
'https://bsc-dataseed.binance.org',
|
||||
'https://bsc-dataseed1.binance.org',
|
||||
'https://bsc.publicnode.com',
|
||||
];
|
||||
const BSC_CHAIN_ID = 56;
|
||||
const PANCAKE_ROUTER = '0x10ED43C718714eb63d5aA57B78B54704E256024E';
|
||||
const WBNB = '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c';
|
||||
|
||||
const BSC_TOKEN_MAP: Record<string, string> = {
|
||||
BNB: WBNB,
|
||||
USDT: '0x55d398326f99059fF775485246999027B3197955',
|
||||
USDC: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d',
|
||||
DOGE: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43',
|
||||
WBNB,
|
||||
BUSD: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56',
|
||||
};
|
||||
|
||||
const ROUTER_ABI = [
|
||||
'function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts)',
|
||||
'function swapExactETHForTokensSupportingFeeOnTransferTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) external payable',
|
||||
'function swapExactTokensForETHSupportingFeeOnTransferTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external',
|
||||
'function swapExactTokensForTokensSupportingFeeOnTransferTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external',
|
||||
];
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function approve(address spender, uint256 amount) external returns (bool)',
|
||||
'function allowance(address owner, address spender) external view returns (uint256)',
|
||||
];
|
||||
|
||||
export interface SwapBscParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
from: string; // 'BNB' | 'USDT' | 'USDC' | 'DOGE' | 'WBNB' | 'BUSD'
|
||||
to: string;
|
||||
amount: string; // smallest units (wei для 18-decimals)
|
||||
slippageBps?: number; // default 50 (0.5%)
|
||||
feeTier?: FeeTier;
|
||||
}
|
||||
|
||||
async function pickProvider(rpcs: string[], chainId: number): Promise<ethers.providers.StaticJsonRpcProvider> {
|
||||
let lastErr: any;
|
||||
for (const url of rpcs) {
|
||||
const p = new ethers.providers.StaticJsonRpcProvider(url, chainId);
|
||||
try {
|
||||
await Promise.race([
|
||||
p.getBlockNumber(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('rpc_alive_timeout')), 3000)),
|
||||
]);
|
||||
return p;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw new Error(`All BSC RPCs failed: ${lastErr?.message || lastErr}`);
|
||||
}
|
||||
|
||||
function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
|
||||
return Promise.race([
|
||||
p,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(msg)), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* BSC chained swap. Если `from` не нативный BNB и allowance < amount —
|
||||
* сначала approve(exact), wait 1 confirmation, потом swap.
|
||||
*
|
||||
* Returns: { approveTxid?, swapTxid }
|
||||
*/
|
||||
export async function swapBsc(p: SwapBscParams): Promise<{ approveTxid?: string; swapTxid: string }> {
|
||||
const fromUpper = p.from.toUpperCase();
|
||||
const toUpper = p.to.toUpperCase();
|
||||
|
||||
if (!BSC_TOKEN_MAP[fromUpper] || !BSC_TOKEN_MAP[toUpper] || fromUpper === toUpper) {
|
||||
throw new Error(`Invalid BSC swap pair: ${fromUpper} → ${toUpper}`);
|
||||
}
|
||||
if (!/^\d+$/.test(p.amount) || BigInt(p.amount) <= 0n) {
|
||||
throw new Error('amount must be positive integer string');
|
||||
}
|
||||
const slippageBps = p.slippageBps ?? 50;
|
||||
if (slippageBps < 1 || slippageBps > 1000) {
|
||||
throw new Error('slippageBps must be 1-1000 (0.01%-10%)');
|
||||
}
|
||||
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
||||
if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
|
||||
throw new Error(`Derived BSC address mismatch: ${wallet.address} ≠ ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
const provider = await pickProvider(BSC_RPCS, BSC_CHAIN_ID);
|
||||
const signer = wallet.connect(provider);
|
||||
|
||||
// Gas tier
|
||||
const tier: FeeTier = p.feeTier ?? 'normal';
|
||||
const fee = await getEvmFeeForTier('BSC', tier);
|
||||
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
|
||||
const maxFeePerGas = ethers.BigNumber.from(fee.maxFeePerGas);
|
||||
const maxPriorityFeePerGas = ethers.BigNumber.from(fee.maxPriorityFeePerGas);
|
||||
if (maxFeePerGas.gt(capWei) || maxPriorityFeePerGas.gt(maxFeePerGas)) {
|
||||
throw new Error('Gas fee invariant violated');
|
||||
}
|
||||
|
||||
// Quote via getAmountsOut → compute amountOutMin server-side (anti-MEV)
|
||||
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||||
const path = [BSC_TOKEN_MAP[fromUpper], BSC_TOKEN_MAP[toUpper]];
|
||||
const amountsOut: ethers.BigNumber[] = await withTimeout(
|
||||
routerContract.getAmountsOut(p.amount, path),
|
||||
HTTP_TIMEOUT_MS,
|
||||
'PancakeSwap quote timed out',
|
||||
);
|
||||
const expectedOut = amountsOut[amountsOut.length - 1];
|
||||
if (expectedOut.lte(0)) {
|
||||
throw new Error('PancakeSwap quote returned 0 — no liquidity for this pair');
|
||||
}
|
||||
// amountOutMin = expectedOut × (10000 - slippageBps) / 10000
|
||||
const amountOutMin = expectedOut.mul(10000 - slippageBps).div(10000);
|
||||
|
||||
const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes
|
||||
const feeFields: Partial<ethers.providers.TransactionRequest> = {
|
||||
type: 2,
|
||||
maxFeePerGas,
|
||||
maxPriorityFeePerGas,
|
||||
};
|
||||
|
||||
let approveTxid: string | undefined;
|
||||
let nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||||
|
||||
// ── Token-to-anything: check allowance, approve if needed, wait 1 conf ──
|
||||
if (fromUpper !== 'BNB') {
|
||||
const tokenAddress = BSC_TOKEN_MAP[fromUpper];
|
||||
const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
|
||||
const currentAllowance: ethers.BigNumber = await withTimeout(
|
||||
tokenContract.allowance(wallet.address, PANCAKE_ROUTER),
|
||||
HTTP_TIMEOUT_MS,
|
||||
'Allowance check timed out',
|
||||
);
|
||||
if (currentAllowance.lt(ethers.BigNumber.from(p.amount))) {
|
||||
const approveData = tokenContract.interface.encodeFunctionData('approve', [PANCAKE_ROUTER, p.amount]);
|
||||
const approveTx: ethers.providers.TransactionRequest = {
|
||||
to: tokenAddress,
|
||||
data: approveData,
|
||||
value: 0,
|
||||
chainId: BSC_CHAIN_ID,
|
||||
nonce,
|
||||
gasLimit: ethers.BigNumber.from(80_000), // approve consistently fits в 60-80k
|
||||
...feeFields,
|
||||
};
|
||||
const approveSent = await withTimeout(
|
||||
signer.sendTransaction(approveTx),
|
||||
HTTP_TIMEOUT_MS,
|
||||
'approve broadcast timed out',
|
||||
);
|
||||
approveTxid = approveSent.hash;
|
||||
// Wait 1 confirmation (~3s on BSC) before swap — иначе swap revert'нет с "TransferHelper: TRANSFER_FROM_FAILED"
|
||||
await withTimeout(approveSent.wait(1), 30_000, 'approve confirmation timed out');
|
||||
nonce += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build swap tx ──
|
||||
let swapData: string;
|
||||
let value: ethers.BigNumber;
|
||||
if (fromUpper === 'BNB') {
|
||||
swapData = routerContract.interface.encodeFunctionData(
|
||||
'swapExactETHForTokensSupportingFeeOnTransferTokens',
|
||||
[amountOutMin, path, wallet.address, deadline],
|
||||
);
|
||||
value = ethers.BigNumber.from(p.amount);
|
||||
} else if (toUpper === 'BNB') {
|
||||
swapData = routerContract.interface.encodeFunctionData(
|
||||
'swapExactTokensForETHSupportingFeeOnTransferTokens',
|
||||
[p.amount, amountOutMin, path, wallet.address, deadline],
|
||||
);
|
||||
value = ethers.BigNumber.from(0);
|
||||
} else {
|
||||
// Token-to-token (e.g., USDT → DOGE)
|
||||
swapData = routerContract.interface.encodeFunctionData(
|
||||
'swapExactTokensForTokensSupportingFeeOnTransferTokens',
|
||||
[p.amount, amountOutMin, path, wallet.address, deadline],
|
||||
);
|
||||
value = ethers.BigNumber.from(0);
|
||||
}
|
||||
|
||||
// estGas через provider.estimateGas + 20% safety
|
||||
let estGas: ethers.BigNumber;
|
||||
try {
|
||||
const estimated = await provider.estimateGas({
|
||||
from: wallet.address,
|
||||
to: PANCAKE_ROUTER,
|
||||
data: swapData,
|
||||
value,
|
||||
});
|
||||
estGas = estimated.mul(120).div(100);
|
||||
const minGas = ethers.BigNumber.from(150_000);
|
||||
const maxGas = ethers.BigNumber.from(500_000);
|
||||
if (estGas.lt(minGas)) estGas = minGas;
|
||||
if (estGas.gt(maxGas)) estGas = maxGas;
|
||||
} catch {
|
||||
estGas = ethers.BigNumber.from(250_000);
|
||||
}
|
||||
|
||||
const swapTx: ethers.providers.TransactionRequest = {
|
||||
to: PANCAKE_ROUTER,
|
||||
data: swapData,
|
||||
value,
|
||||
chainId: BSC_CHAIN_ID,
|
||||
nonce,
|
||||
gasLimit: estGas,
|
||||
...feeFields,
|
||||
};
|
||||
const swapSent = await withTimeout(
|
||||
signer.sendTransaction(swapTx),
|
||||
HTTP_TIMEOUT_MS,
|
||||
'swap broadcast timed out',
|
||||
);
|
||||
return { approveTxid, swapTxid: swapSent.hash };
|
||||
}
|
||||
|
||||
// ─── TRX SunSwap ─────────────────────────────────────────────────────
|
||||
|
||||
const TRONGRID = 'https://api.trongrid.io';
|
||||
|
||||
// Constants — те же что в tron-swap-proxy.routes.ts (single source of truth для prod адресов).
|
||||
const SUNSWAP_SMART_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax';
|
||||
const USDT_CONTRACT = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t';
|
||||
const WTRX_CONTRACT = 'TNUC9Qb1rRpS5CbWLmNMxXBjyFoydXjWFR';
|
||||
const FEE_SWAP_ROUTER_TRX = 'TX8E6X7X1FWYRYuYR2LTvS7zm1KchcVs5E'; // 0.7% fee router
|
||||
const FEE_BPS = 70n;
|
||||
const BPS_DENOMINATOR = 10_000n;
|
||||
|
||||
// Minimal TRX swap для TRX↔USDT (other tokens — добавить через registry)
|
||||
const TRX_SWAP_TOKEN_MAP: Record<string, { address: string; decimals: number; isNative: boolean }> = {
|
||||
TRX: { address: 'TRX', decimals: 6, isNative: true },
|
||||
USDT: { address: USDT_CONTRACT, decimals: 6, isNative: false },
|
||||
};
|
||||
|
||||
const TRX_BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
|
||||
// Method selectors (keccak256 first 4 bytes). Verified via `keccak256(toUtf8Bytes(sig)).slice(2,10)`.
|
||||
// approve(address,uint256) → 095ea7b3
|
||||
// swapExactETHForTokensSupportingFeeOnTransferTokens(uint256,address[],...,...) → b6f9de95
|
||||
// swapExactTokensForETHSupportingFeeOnTransferTokens(uint256,uint256,...,...) → 791ac947 (NOT 18cbafe5 — that's no-fee variant)
|
||||
// swapNativeWithFee(bytes) → 152dad1d
|
||||
// swapTokenWithFee(address,uint256,bytes) → e8d1f203
|
||||
//
|
||||
// NOTE: legacy proxy route использовал 18cbafe5 = swapExactTokensForETH (без supporting-fee).
|
||||
// FeeSwapRouter работает с supporting-fee variant — это уже доказано в production traffic.
|
||||
const SEL_APPROVE = '095ea7b3';
|
||||
const SEL_SWAP_EXACT_ETH_FOR_TOKENS = 'b6f9de95';
|
||||
const SEL_SWAP_EXACT_TOKENS_FOR_ETH = '18cbafe5';
|
||||
const SEL_SWAP_NATIVE_WITH_FEE = '152dad1d';
|
||||
const SEL_SWAP_TOKEN_WITH_FEE = 'e8d1f203';
|
||||
|
||||
export interface SwapTrxParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
from: string;
|
||||
to: string;
|
||||
amount: string;
|
||||
slippageBps?: number;
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init?: RequestInit): Promise<any> {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), HTTP_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);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TRX encoding helpers (порт из tron-swap-proxy.routes.ts) ───
|
||||
|
||||
function trxAddrToHex(address: string): string {
|
||||
let num = 0n;
|
||||
for (const ch of address) {
|
||||
const i = TRX_BASE58_ALPHABET.indexOf(ch);
|
||||
if (i === -1) throw new Error('Invalid base58 character');
|
||||
num = num * 58n + BigInt(i);
|
||||
}
|
||||
const hex = num.toString(16).padStart(50, '0');
|
||||
return hex.slice(2, 42); // skip 0x41, take 20 bytes
|
||||
}
|
||||
|
||||
function encU256(value: bigint): string {
|
||||
return value.toString(16).padStart(64, '0');
|
||||
}
|
||||
|
||||
function encAddr(address: string): string {
|
||||
return trxAddrToHex(address).padStart(64, '0');
|
||||
}
|
||||
|
||||
function encDynamicBytes(hexData: string): string {
|
||||
const data = hexData.startsWith('0x') ? hexData.slice(2) : hexData;
|
||||
const byteLength = data.length / 2;
|
||||
const lengthEncoded = encU256(BigInt(byteLength));
|
||||
const paddedData = data.padEnd(Math.ceil(data.length / 64) * 64, '0');
|
||||
return lengthEncoded + paddedData;
|
||||
}
|
||||
|
||||
// SunSwap V2 router calldata:
|
||||
// function swapExactETHForTokens(uint256 amountOutMin, address[] path, address to, uint256 deadline)
|
||||
function buildSwapExactETHForTokensCalldata(
|
||||
amountOutMin: bigint,
|
||||
path: string[],
|
||||
to: string,
|
||||
deadline: bigint,
|
||||
): string {
|
||||
const offsetToPath = encU256(128n); // 4 × 32 bytes
|
||||
const pathLen = encU256(BigInt(path.length));
|
||||
const pathElements = path.map(encAddr).join('');
|
||||
return SEL_SWAP_EXACT_ETH_FOR_TOKENS + encU256(amountOutMin) + offsetToPath +
|
||||
encAddr(to) + encU256(deadline) + pathLen + pathElements;
|
||||
}
|
||||
|
||||
// function swapExactTokensForETH(uint256 amountIn, uint256 amountOutMin, address[] path, address to, uint256 deadline)
|
||||
function buildSwapExactTokensForETHCalldata(
|
||||
amountIn: bigint,
|
||||
amountOutMin: bigint,
|
||||
path: string[],
|
||||
to: string,
|
||||
deadline: bigint,
|
||||
): string {
|
||||
const offsetToPath = encU256(160n); // 5 × 32 bytes
|
||||
const pathLen = encU256(BigInt(path.length));
|
||||
const pathElements = path.map(encAddr).join('');
|
||||
return SEL_SWAP_EXACT_TOKENS_FOR_ETH + encU256(amountIn) + encU256(amountOutMin) +
|
||||
offsetToPath + encAddr(to) + encU256(deadline) + pathLen + pathElements;
|
||||
}
|
||||
|
||||
interface BuiltTrxTx {
|
||||
txID: string;
|
||||
raw_data: any;
|
||||
raw_data_hex: string;
|
||||
}
|
||||
|
||||
interface BuildTriggerParams {
|
||||
ownerAddress: string;
|
||||
contractAddress: string;
|
||||
functionSelector: string;
|
||||
parameter: string;
|
||||
callValue: number;
|
||||
feeLimit: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
async function buildTrigger(p: BuildTriggerParams): Promise<BuiltTrxTx> {
|
||||
const body = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers: p.headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: p.ownerAddress,
|
||||
contract_address: p.contractAddress,
|
||||
function_selector: p.functionSelector,
|
||||
parameter: p.parameter,
|
||||
call_value: p.callValue,
|
||||
fee_limit: p.feeLimit,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
if (!body?.result?.result || !body.transaction) {
|
||||
const msg = body?.result?.message
|
||||
? Buffer.from(body.result.message, 'hex').toString('utf8')
|
||||
: 'TronGrid triggersmartcontract returned no transaction';
|
||||
throw new Error(`TRX build failed: ${msg.slice(0, 200)}`);
|
||||
}
|
||||
const tx = body.transaction as BuiltTrxTx;
|
||||
if (!tx.txID || !tx.raw_data || !tx.raw_data_hex) {
|
||||
throw new Error('TRX build response missing txID / raw_data / raw_data_hex');
|
||||
}
|
||||
return tx;
|
||||
}
|
||||
|
||||
async function checkAllowance(
|
||||
owner: string,
|
||||
tokenContract: string,
|
||||
spender: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<bigint> {
|
||||
const parameter = encAddr(owner) + encAddr(spender);
|
||||
const body = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: owner,
|
||||
contract_address: tokenContract,
|
||||
function_selector: 'allowance(address,address)',
|
||||
parameter,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
const hex = body?.constant_result?.[0];
|
||||
if (!hex || /^0+$/.test(hex)) return 0n;
|
||||
return BigInt('0x' + hex);
|
||||
}
|
||||
|
||||
async function getAmountsOut(
|
||||
amountIn: bigint,
|
||||
path: string[],
|
||||
headers: Record<string, string>,
|
||||
): Promise<bigint> {
|
||||
const amountHex = encU256(amountIn);
|
||||
const offsetHex = encU256(64n);
|
||||
const lengthHex = encU256(BigInt(path.length));
|
||||
const pathHex = path.map(encAddr).join('');
|
||||
const parameter = amountHex + offsetHex + lengthHex + pathHex;
|
||||
|
||||
const body = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: SUNSWAP_SMART_ROUTER,
|
||||
contract_address: SUNSWAP_SMART_ROUTER,
|
||||
function_selector: 'getAmountsOut(uint256,address[])',
|
||||
parameter,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const hex = body?.constant_result?.[0];
|
||||
if (!hex) {
|
||||
const msg = body?.result?.message
|
||||
? Buffer.from(body.result.message, 'hex').toString('utf8')
|
||||
: 'getAmountsOut returned no result';
|
||||
throw new Error(`TRX quote failed: ${msg.slice(0, 200)}`);
|
||||
}
|
||||
// Last 32 bytes hex of result = amounts[1] (output amount).
|
||||
const amountOutHex = hex.slice(-64);
|
||||
return BigInt('0x' + amountOutHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* MITM-verify built TRX tx перед подписью (4-layer защита от компрометированного TronGrid).
|
||||
* Селектор `expectedSelector` (8 hex chars) ловит подмену метода на 5-м уровне.
|
||||
*/
|
||||
function verifyTrxTx(opts: {
|
||||
tx: BuiltTrxTx;
|
||||
expectedOwner: string;
|
||||
expectedContract: string;
|
||||
expectedSelector: string; // 8 hex chars, lowercase
|
||||
expectedCallValue?: number;
|
||||
}): void {
|
||||
// 1. txID = SHA256(raw_data_hex)
|
||||
const expectedTxId = createHash('sha256')
|
||||
.update(Buffer.from(opts.tx.raw_data_hex, 'hex'))
|
||||
.digest('hex');
|
||||
if (expectedTxId !== opts.tx.txID) {
|
||||
throw new Error('TRX txID mismatch — possible MITM/compromised RPC');
|
||||
}
|
||||
// 2. expiration bounds (TRON default ~60s; cap 90s)
|
||||
const nowMs = Date.now();
|
||||
const expiration = Number(opts.tx.raw_data.expiration);
|
||||
const timestamp = Number(opts.tx.raw_data.timestamp);
|
||||
if (!Number.isFinite(expiration) || !Number.isFinite(timestamp)) {
|
||||
throw new Error('TRX tx malformed (no expiration/timestamp)');
|
||||
}
|
||||
if (expiration - nowMs > 90_000 || expiration <= nowMs) {
|
||||
throw new Error(`TRX expiration out of bounds: ${expiration - nowMs}ms`);
|
||||
}
|
||||
if (Math.abs(timestamp - nowMs) > 30_000) {
|
||||
throw new Error(`TRX timestamp drift too large: ${timestamp - nowMs}ms`);
|
||||
}
|
||||
// 3. contract[0].type === 'TriggerSmartContract'
|
||||
const c0 = opts.tx.raw_data.contract?.[0];
|
||||
if (!c0) throw new Error('TRX tx malformed (no contract[0])');
|
||||
if (c0.type !== 'TriggerSmartContract') {
|
||||
throw new Error(`TRX contract type mismatch: expected TriggerSmartContract, got ${c0.type}`);
|
||||
}
|
||||
// 4. owner / contract / selector / call_value
|
||||
const v = c0.parameter?.value;
|
||||
if (!v) throw new Error('TRX tx malformed (no contract value)');
|
||||
if (v.owner_address !== opts.expectedOwner) {
|
||||
throw new Error(`TRX owner_address mismatch: expected ${opts.expectedOwner}, got ${v.owner_address}`);
|
||||
}
|
||||
if (v.contract_address !== opts.expectedContract) {
|
||||
throw new Error(`TRX contract mismatch: expected ${opts.expectedContract}, got ${v.contract_address}`);
|
||||
}
|
||||
const data = String(v.data || '').toLowerCase();
|
||||
if (data.slice(0, 8) !== opts.expectedSelector.toLowerCase()) {
|
||||
throw new Error(
|
||||
`TRX selector mismatch: expected ${opts.expectedSelector}, got ${data.slice(0, 8)}`,
|
||||
);
|
||||
}
|
||||
if (opts.expectedCallValue !== undefined) {
|
||||
const actual = Number(v.call_value ?? 0);
|
||||
if (actual !== opts.expectedCallValue) {
|
||||
throw new Error(`TRX call_value mismatch: expected ${opts.expectedCallValue}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sign verified tx + broadcast. Returns txid. */
|
||||
async function signAndBroadcastTrx(
|
||||
tx: BuiltTrxTx,
|
||||
wallet: ethers.Wallet,
|
||||
headers: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const sk = new ethers.utils.SigningKey(wallet.privateKey);
|
||||
const sig = sk.signDigest('0x' + tx.txID);
|
||||
if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) {
|
||||
throw new Error(`TRX signing: invalid recoveryParam ${sig.recoveryParam}`);
|
||||
}
|
||||
const sigHex =
|
||||
sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0');
|
||||
|
||||
const clean = {
|
||||
txID: tx.txID,
|
||||
raw_data: tx.raw_data,
|
||||
raw_data_hex: tx.raw_data_hex,
|
||||
signature: [sigHex],
|
||||
visible: true,
|
||||
};
|
||||
const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(clean),
|
||||
});
|
||||
if (!broadcast?.result) {
|
||||
const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown';
|
||||
const code = broadcast?.code || 'NO_CODE';
|
||||
throw new Error(`TRX broadcast failed [${code}]: ${msg.slice(0, 200)}`);
|
||||
}
|
||||
return tx.txID;
|
||||
}
|
||||
|
||||
/** Poll gettransactionbyid until included / failed / timeout (max 30s, every 1.5s). */
|
||||
async function waitTrxInclusion(
|
||||
txid: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
try {
|
||||
const info = await fetchJson(`${TRONGRID}/wallet/gettransactioninfobyid`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ value: txid }),
|
||||
});
|
||||
// Если info.id присутствует — tx уже в блоке.
|
||||
if (info?.id) {
|
||||
const result = info.receipt?.result;
|
||||
if (result && result !== 'SUCCESS') {
|
||||
throw new Error(`TRX approve tx reverted: ${result}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Сетевой блип — продолжаем polling.
|
||||
if (Date.now() >= deadline) throw err;
|
||||
}
|
||||
}
|
||||
throw new Error(`TRX tx ${txid.slice(0, 12)}... inclusion timed out after 30s`);
|
||||
}
|
||||
|
||||
/**
|
||||
* TRX swap через SunSwap V2 + FeeSwapRouter (0.7% fee).
|
||||
* Поддерживает только TRX↔USDT (same scope как legacy /tron/swap/build).
|
||||
*
|
||||
* TRX → USDT (1 tx): swapNativeWithFee wraps swapExactETHForTokens.
|
||||
* USDT → TRX (1-2 tx): optional approve(infinite) + swapTokenWithFee wraps swapExactTokensForETH.
|
||||
*
|
||||
* Slippage: server вычисляет `amountOutMin = quote × (10000-slippageBps) / 10000`
|
||||
* (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin (защита от MEV-sandwich).
|
||||
*
|
||||
* Возвращает `{ approveTxid?, swapTxid }` для совместимости с swapBsc.
|
||||
*/
|
||||
export async function swapTrx(
|
||||
p: SwapTrxParams,
|
||||
): Promise<{ approveTxid?: string; swapTxid: string }> {
|
||||
const fromU = p.from.toUpperCase();
|
||||
const toU = p.to.toUpperCase();
|
||||
const fromInfo = TRX_SWAP_TOKEN_MAP[fromU];
|
||||
const toInfo = TRX_SWAP_TOKEN_MAP[toU];
|
||||
if (!fromInfo || !toInfo || fromU === toU) {
|
||||
throw new Error(`TRX swap supports only TRX↔USDT pairs (got ${p.from} → ${p.to})`);
|
||||
}
|
||||
|
||||
const amount = BigInt(p.amount);
|
||||
if (amount <= 0n) throw new Error('TRX swap: amount must be positive');
|
||||
|
||||
const slippageBps = BigInt(
|
||||
p.slippageBps !== undefined && Number.isFinite(p.slippageBps) ? p.slippageBps : 50,
|
||||
);
|
||||
if (slippageBps < 1n || slippageBps > 1000n) {
|
||||
throw new Error('TRX swap: slippageBps must be between 1 and 1000');
|
||||
}
|
||||
|
||||
// Derive TRX address.
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
|
||||
const fromTronAddr = ethAddressToTron(wallet.address);
|
||||
if (fromTronAddr !== p.expectedFromAddress) {
|
||||
throw new Error(`TRX address mismatch: derived ${fromTronAddr} ≠ DB ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
// Compute fee + swap split (FeeSwapRouter забирает 0.7%, остальные 99.3% уходят в SunSwap).
|
||||
const feeAmount = (amount * FEE_BPS) / BPS_DENOMINATOR;
|
||||
const swapAmount = amount - feeAmount;
|
||||
|
||||
// Quote (на 99.3%, т.к. это то что SunSwap реально получит).
|
||||
const isTrxToUsdt = fromU === 'TRX';
|
||||
const path = isTrxToUsdt
|
||||
? [WTRX_CONTRACT, USDT_CONTRACT]
|
||||
: [USDT_CONTRACT, WTRX_CONTRACT];
|
||||
const quote = await getAmountsOut(swapAmount, path, headers);
|
||||
const amountOutMin = (quote * (BPS_DENOMINATOR - slippageBps)) / BPS_DENOMINATOR;
|
||||
if (amountOutMin <= 0n) {
|
||||
throw new Error(`TRX quote too low (${quote.toString()}) — no liquidity?`);
|
||||
}
|
||||
|
||||
const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); // 20 минут
|
||||
|
||||
// ─── TRX → USDT ───
|
||||
if (isTrxToUsdt) {
|
||||
const sunswapCalldata = buildSwapExactETHForTokensCalldata(
|
||||
amountOutMin, path, fromTronAddr, deadline,
|
||||
);
|
||||
const offsetToBytes = encU256(32n);
|
||||
const feeRouterParam = offsetToBytes + encDynamicBytes(sunswapCalldata);
|
||||
|
||||
// Number(amount) safe здесь т.к. TRX bounded по precision-check в sendTrx (≤ MAX_SAFE_INT sun).
|
||||
const amountNum = Number(amount);
|
||||
if (amountNum > Number.MAX_SAFE_INTEGER) {
|
||||
throw new Error('TRX swap amount exceeds Number.MAX_SAFE_INTEGER (9B TRX)');
|
||||
}
|
||||
|
||||
const swapTx = await buildTrigger({
|
||||
ownerAddress: fromTronAddr,
|
||||
contractAddress: FEE_SWAP_ROUTER_TRX,
|
||||
functionSelector: 'swapNativeWithFee(bytes)',
|
||||
parameter: feeRouterParam,
|
||||
callValue: amountNum,
|
||||
feeLimit: 200_000_000, // 200 TRX cap
|
||||
headers,
|
||||
});
|
||||
verifyTrxTx({
|
||||
tx: swapTx,
|
||||
expectedOwner: fromTronAddr,
|
||||
expectedContract: FEE_SWAP_ROUTER_TRX,
|
||||
expectedSelector: SEL_SWAP_NATIVE_WITH_FEE,
|
||||
expectedCallValue: amountNum,
|
||||
});
|
||||
const swapTxid = await signAndBroadcastTrx(swapTx, wallet, headers);
|
||||
return { swapTxid };
|
||||
}
|
||||
|
||||
// ─── USDT → TRX ───
|
||||
// Step 1: check allowance, approve infinite if needed.
|
||||
let approveTxid: string | undefined;
|
||||
const allowance = await checkAllowance(fromTronAddr, USDT_CONTRACT, FEE_SWAP_ROUTER_TRX, headers);
|
||||
if (allowance < amount) {
|
||||
const INFINITE = BigInt(
|
||||
'0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff',
|
||||
);
|
||||
const approveParam = encAddr(FEE_SWAP_ROUTER_TRX) + encU256(INFINITE);
|
||||
const approveTx = await buildTrigger({
|
||||
ownerAddress: fromTronAddr,
|
||||
contractAddress: USDT_CONTRACT,
|
||||
functionSelector: 'approve(address,uint256)',
|
||||
parameter: approveParam,
|
||||
callValue: 0,
|
||||
feeLimit: 100_000_000, // 100 TRX cap
|
||||
headers,
|
||||
});
|
||||
verifyTrxTx({
|
||||
tx: approveTx,
|
||||
expectedOwner: fromTronAddr,
|
||||
expectedContract: USDT_CONTRACT,
|
||||
expectedSelector: SEL_APPROVE,
|
||||
expectedCallValue: 0,
|
||||
});
|
||||
approveTxid = await signAndBroadcastTrx(approveTx, wallet, headers);
|
||||
// Ждём inclusion approve, иначе swap revert'нёт "transfer amount exceeds allowance".
|
||||
await waitTrxInclusion(approveTxid, headers);
|
||||
}
|
||||
|
||||
// Step 2: build swapTokenWithFee(USDT, amount, calldata).
|
||||
const sunswapCalldata = buildSwapExactTokensForETHCalldata(
|
||||
swapAmount, amountOutMin, path, fromTronAddr, deadline,
|
||||
);
|
||||
const tokenInEnc = encAddr(USDT_CONTRACT);
|
||||
const amountInEnc = encU256(amount);
|
||||
const offsetToBytes = encU256(96n); // 3 × 32 bytes
|
||||
const feeRouterParam = tokenInEnc + amountInEnc + offsetToBytes + encDynamicBytes(sunswapCalldata);
|
||||
|
||||
const swapTx = await buildTrigger({
|
||||
ownerAddress: fromTronAddr,
|
||||
contractAddress: FEE_SWAP_ROUTER_TRX,
|
||||
functionSelector: 'swapTokenWithFee(address,uint256,bytes)',
|
||||
parameter: feeRouterParam,
|
||||
callValue: 0,
|
||||
feeLimit: 200_000_000,
|
||||
headers,
|
||||
});
|
||||
verifyTrxTx({
|
||||
tx: swapTx,
|
||||
expectedOwner: fromTronAddr,
|
||||
expectedContract: FEE_SWAP_ROUTER_TRX,
|
||||
expectedSelector: SEL_SWAP_TOKEN_WITH_FEE,
|
||||
expectedCallValue: 0,
|
||||
});
|
||||
const swapTxid = await signAndBroadcastTrx(swapTx, wallet, headers);
|
||||
return { approveTxid, swapTxid };
|
||||
}
|
||||
|
||||
// ─── SOL Jupiter ─────────────────────────────────────────────────────
|
||||
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
const JUPITER_API = 'https://quote-api.jup.ag/v6';
|
||||
|
||||
let _solConnection: Connection | null = null;
|
||||
function getSolConnection(): Connection {
|
||||
if (!_solConnection) {
|
||||
_solConnection = new Connection(SOL_RPC, 'confirmed');
|
||||
}
|
||||
return _solConnection;
|
||||
}
|
||||
|
||||
export interface SwapSolParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
inputMint: string;
|
||||
outputMint: string;
|
||||
amount: string;
|
||||
slippageBps?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SOL Jupiter chained swap. Получаем quote от Jupiter, build serialized tx, sign keypair'ом, broadcast.
|
||||
*/
|
||||
export async function swapSol(p: SwapSolParams): Promise<{ signature: string }> {
|
||||
const seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||||
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
||||
if (!key || key.length !== 32) {
|
||||
throw new Error('SOL derivation produced invalid seed length');
|
||||
}
|
||||
const keypair = Keypair.fromSeed(key);
|
||||
if (keypair.publicKey.toBase58() !== p.expectedFromAddress) {
|
||||
throw new Error(`SOL address mismatch: derived ${keypair.publicKey.toBase58()} ≠ DB ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
const slippageBps = p.slippageBps ?? 50;
|
||||
if (slippageBps < 1 || slippageBps > 1000) {
|
||||
throw new Error('slippageBps must be 1-1000');
|
||||
}
|
||||
|
||||
// 1. Jupiter quote
|
||||
const quoteUrl = `${JUPITER_API}/quote?inputMint=${encodeURIComponent(p.inputMint)}&outputMint=${encodeURIComponent(p.outputMint)}&amount=${encodeURIComponent(p.amount)}&slippageBps=${slippageBps}`;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
if (env.jupiterApiKey) headers['x-api-key'] = env.jupiterApiKey;
|
||||
const quoteRes = await fetchJson(quoteUrl, { headers });
|
||||
|
||||
// 2. Jupiter swap (build serialized tx)
|
||||
const swapBody: Record<string, unknown> = {
|
||||
quoteResponse: quoteRes,
|
||||
userPublicKey: keypair.publicKey.toBase58(),
|
||||
wrapAndUnwrapSol: true,
|
||||
dynamicComputeUnitLimit: true,
|
||||
prioritizationFeeLamports: 'auto',
|
||||
};
|
||||
if (env.jupiterReferralAccount) swapBody.feeAccount = env.jupiterReferralAccount;
|
||||
|
||||
const swapRes = await fetchJson(`${JUPITER_API}/swap`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(swapBody),
|
||||
});
|
||||
|
||||
const txBase64 = swapRes.swapTransaction;
|
||||
if (!txBase64 || typeof txBase64 !== 'string') {
|
||||
throw new Error('Jupiter swap returned no swapTransaction');
|
||||
}
|
||||
|
||||
// 3. Deserialize → sign → broadcast
|
||||
const txBytes = Buffer.from(txBase64, 'base64');
|
||||
const tx = VersionedTransaction.deserialize(txBytes);
|
||||
|
||||
// Verify fee-payer === our pubkey
|
||||
const feePayer = tx.message.staticAccountKeys[0]?.toBase58();
|
||||
if (feePayer !== keypair.publicKey.toBase58()) {
|
||||
throw new Error(`Jupiter built tx with wrong feePayer ${feePayer} (expected ${keypair.publicKey.toBase58()})`);
|
||||
}
|
||||
|
||||
tx.sign([keypair]);
|
||||
|
||||
const conn = getSolConnection();
|
||||
const sig = await conn.sendRawTransaction(tx.serialize());
|
||||
|
||||
try {
|
||||
const latestBlock = await conn.getLatestBlockhash();
|
||||
await conn.confirmTransaction({
|
||||
signature: sig,
|
||||
blockhash: latestBlock.blockhash,
|
||||
lastValidBlockHeight: latestBlock.lastValidBlockHeight,
|
||||
}, 'confirmed');
|
||||
} catch (err: any) {
|
||||
const name = err?.name || '';
|
||||
if (name === 'TransactionExpiredBlockheightExceededError') {
|
||||
throw new Error(`SOL Jupiter swap EXPIRED. sig=${sig}`);
|
||||
}
|
||||
logger.warn(`SOL Jupiter swap confirm warning (${name}): ${err.message}. sig=${sig}`);
|
||||
}
|
||||
|
||||
return { signature: sig };
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import { env } from '../config/env';
|
||||
import { getEvmTokens, getTrxTokens, getSolTokens } from '../lib/token-registry';
|
||||
import { getPricesBySymbols } from './price-oracle.service';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
@@ -30,6 +32,17 @@ export interface FormattedAmount {
|
||||
raw: string; // smallest units (string-encoded BigInt — без потери точности)
|
||||
formatted: string; // human-readable, e.g. "0.003"
|
||||
decimals: number; // decimals chain'а/токена
|
||||
/**
|
||||
* USD price per 1 целая единица (e.g. $67432.12 за 1 BTC).
|
||||
* `null` если symbol не в реестре с `coingeckoId` или upstream price oracle недоступен.
|
||||
* Источник: CoinGecko free API, cache 5 мин в KeyDB.
|
||||
*/
|
||||
usdPrice: number | null;
|
||||
/**
|
||||
* Совокупная USD-стоимость holding'а: `Number(formatted) × usdPrice`.
|
||||
* Округлено до 8 знаков. `null` если `usdPrice === null` или вычисление дало `Infinity/NaN`.
|
||||
*/
|
||||
usdValue: number | null;
|
||||
}
|
||||
|
||||
export interface BalanceResult {
|
||||
@@ -70,7 +83,67 @@ export function formatUnits(raw: string, decimals: number): string {
|
||||
}
|
||||
|
||||
function fmt(raw: string, decimals: number): FormattedAmount {
|
||||
return { raw, formatted: formatUnits(raw, decimals), decimals };
|
||||
return {
|
||||
raw,
|
||||
formatted: formatUnits(raw, decimals),
|
||||
decimals,
|
||||
usdPrice: null, // populated post-build via populatePrices()
|
||||
usdValue: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Округление USD значения до 8 знаков после запятой (избавляемся от float-мусора).
|
||||
* S10 — `Infinity`/`NaN` → `null`.
|
||||
*/
|
||||
function roundUsd(n: number): number | null {
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return Math.round(n * 1e8) / 1e8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутирует BalanceResult, проставляя `usdPrice` и `usdValue` каждому FormattedAmount.
|
||||
* Никогда не throws — если price oracle упал, поля остаются `null`.
|
||||
*/
|
||||
async function populatePrices(result: BalanceResult): Promise<void> {
|
||||
try {
|
||||
const pairs: { chain: ChainCode; symbol: string }[] = [
|
||||
{ chain: result.chain, symbol: result.chain }, // native (BTC/ETH/BSC/TRX/SOL)
|
||||
];
|
||||
if (result.tokens) {
|
||||
for (const sym of Object.keys(result.tokens)) {
|
||||
pairs.push({ chain: result.chain, symbol: sym });
|
||||
}
|
||||
}
|
||||
const prices = await getPricesBySymbols(pairs);
|
||||
|
||||
// Native
|
||||
const nativeKey = `${result.chain}:${result.chain}`;
|
||||
const nativePrice = prices.get(nativeKey) ?? null;
|
||||
result.native.usdPrice = nativePrice;
|
||||
if (nativePrice != null) {
|
||||
const formattedNum = Number(result.native.formatted);
|
||||
result.native.usdValue = Number.isFinite(formattedNum)
|
||||
? roundUsd(formattedNum * nativePrice)
|
||||
: null;
|
||||
}
|
||||
|
||||
// Tokens
|
||||
if (result.tokens) {
|
||||
for (const [sym, amt] of Object.entries(result.tokens)) {
|
||||
const key = `${result.chain}:${sym}`;
|
||||
const p = prices.get(key) ?? null;
|
||||
amt.usdPrice = p;
|
||||
if (p != null) {
|
||||
const fNum = Number(amt.formatted);
|
||||
amt.usdValue = Number.isFinite(fNum) ? roundUsd(fNum * p) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Не валим запрос — balance вернётся без цен.
|
||||
logger.warn(`populatePrices(${result.chain}) failed: ${err?.message || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function fmtTokens(
|
||||
@@ -86,20 +159,23 @@ function fmtTokens(
|
||||
|
||||
export async function getBalance(chain: ChainCode, address: string): Promise<BalanceResult> {
|
||||
const nativeDecimals = NATIVE_DECIMALS[chain];
|
||||
let result: BalanceResult;
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return {
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(await btcBalance(address), nativeDecimals),
|
||||
};
|
||||
break;
|
||||
case 'TRX': {
|
||||
const { trx, tokens } = await trxBalance(address);
|
||||
const decimalsMap = Object.fromEntries(getTrxTokens().map((t) => [t.symbol, t.decimals]));
|
||||
return {
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(trx, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'BSC':
|
||||
case 'ETH': {
|
||||
@@ -111,22 +187,28 @@ export async function getBalance(chain: ChainCode, address: string): Promise<Bal
|
||||
tokenList.map((t) => ({ symbol: t.symbol, addr: t.contractAddress })),
|
||||
);
|
||||
const decimalsMap = Object.fromEntries(tokenList.map((t) => [t.symbol, t.decimals]));
|
||||
return {
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(native, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'SOL': {
|
||||
const { native, tokens } = await solBalance(address);
|
||||
const decimalsMap = Object.fromEntries(getSolTokens().map((t) => [t.symbol, t.decimals]));
|
||||
return {
|
||||
result = {
|
||||
chain, address,
|
||||
native: fmt(native, nativeDecimals),
|
||||
tokens: fmtTokens(tokens, decimalsMap),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Populate USD prices (graceful — never throws, fields stay null on failure).
|
||||
await populatePrices(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function btcBalance(address: string): Promise<string> {
|
||||
|
||||
@@ -14,11 +14,18 @@ import * as bip39 from 'bip39';
|
||||
import { BIP32Factory } from 'bip32';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { Keypair, Connection, PublicKey, SystemProgram, Transaction, ComputeBudgetProgram } from '@solana/web3.js';
|
||||
import { Keypair, Connection, PublicKey, SystemProgram, Transaction, ComputeBudgetProgram, VersionedTransaction } from '@solana/web3.js';
|
||||
import {
|
||||
getAssociatedTokenAddressSync,
|
||||
createAssociatedTokenAccountIdempotentInstruction,
|
||||
createTransferCheckedInstruction,
|
||||
TOKEN_PROGRAM_ID,
|
||||
} from '@solana/spl-token';
|
||||
import { derivePath } from 'ed25519-hd-key';
|
||||
import { env } from '../config/env';
|
||||
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
|
||||
import { getEvmFeeForTier, type FeeTier } from './gas-oracle.service';
|
||||
import { getTokenInfo } from '../lib/token-registry';
|
||||
import type { ChainCode } from '../lib/address-validators';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
@@ -109,8 +116,8 @@ export interface RawEvmSignParams {
|
||||
|
||||
export async function signAndBroadcast(p: SendParams): Promise<{ txid: string }> {
|
||||
switch (p.chain) {
|
||||
case 'ETH': return sendEvm(p, ETH_RPC, 1, USDT_ERC20);
|
||||
case 'BSC': return sendEvm(p, BSC_RPC, 56, USDT_BEP20);
|
||||
case 'ETH': return sendEvm(p, ETH_RPC, 1);
|
||||
case 'BSC': return sendEvm(p, BSC_RPC, 56);
|
||||
case 'BTC': return sendBtc(p);
|
||||
case 'TRX': return sendTrx(p);
|
||||
case 'SOL': return sendSol(p);
|
||||
@@ -210,7 +217,7 @@ function assertAddressMatch(derived: string, expected: string, chain: ChainCode)
|
||||
|
||||
// ─── EVM (ETH / BSC) ───
|
||||
|
||||
async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: string): Promise<{ txid: string }> {
|
||||
async function sendEvm(p: SendParams, rpc: string, chainId: number): Promise<{ txid: string }> {
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
||||
assertAddressMatch(wallet.address, p.expectedFromAddress, p.chain);
|
||||
// H29 — RPC failover (выбираем working RPC из списка для chain)
|
||||
@@ -264,25 +271,29 @@ async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: st
|
||||
throw new Error('Insufficient balance (value + gas)');
|
||||
}
|
||||
tx = { to: p.to, value, chainId, nonce, gasLimit: estGas, ...feeFields };
|
||||
} else if (p.token.toUpperCase() === 'USDT') {
|
||||
} else {
|
||||
// Generic ERC20/BEP20: lookup в token-registry. Поддерживаются все токены из registry.
|
||||
const tokenInfo = getTokenInfo(evmChain, p.token);
|
||||
if (!tokenInfo) {
|
||||
throw new Error(`Token ${p.token} not in registry for chain ${evmChain}`);
|
||||
}
|
||||
const iface = new ethers.utils.Interface([
|
||||
...ERC20_ABI,
|
||||
'function balanceOf(address) view returns (uint256)',
|
||||
]);
|
||||
const erc20 = new ethers.Contract(usdtAddr, iface, provider);
|
||||
const erc20 = new ethers.Contract(tokenInfo.address, iface, provider);
|
||||
const tokenBal: ethers.BigNumber = await erc20.balanceOf(wallet.address);
|
||||
if (tokenBal.lt(ethers.BigNumber.from(p.amount))) {
|
||||
throw new Error('Insufficient token balance');
|
||||
}
|
||||
const nativeBal = await provider.getBalance(wallet.address);
|
||||
const data = iface.encodeFunctionData('transfer', [p.to, p.amount]);
|
||||
// H10 — actual estimateGas + 20% safety. Hardcoded 80000 was too low for cold
|
||||
// storage slots (first transfer to recipient SSTORE costs 81-90k → OOG burn).
|
||||
// H10 — actual estimateGas + 20% safety. Cold storage slots (first transfer to fresh
|
||||
// recipient) cost 81-90k due to SSTORE; floor 60k, ceiling 200k для sanity.
|
||||
let estGas: ethers.BigNumber;
|
||||
try {
|
||||
const estimated = await provider.estimateGas({ from: wallet.address, to: usdtAddr, data, value: 0 });
|
||||
const estimated = await provider.estimateGas({ from: wallet.address, to: tokenInfo.address, data, value: 0 });
|
||||
estGas = estimated.mul(120).div(100); // +20%
|
||||
// Floor 60k (minimum realistic), ceiling 200k (sanity)
|
||||
const minGas = ethers.BigNumber.from(60000);
|
||||
const maxGas = ethers.BigNumber.from(200000);
|
||||
if (estGas.lt(minGas)) estGas = minGas;
|
||||
@@ -293,9 +304,7 @@ async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: st
|
||||
if (nativeBal.lt(effectiveGasPrice.mul(estGas))) {
|
||||
throw new Error('Insufficient native balance for gas');
|
||||
}
|
||||
tx = { to: usdtAddr, data, value: 0, chainId, nonce, gasLimit: estGas, ...feeFields };
|
||||
} else {
|
||||
throw new Error(`Token ${p.token} not supported on chainId ${chainId}`);
|
||||
tx = { to: tokenInfo.address, data, value: 0, chainId, nonce, gasLimit: estGas, ...feeFields };
|
||||
}
|
||||
|
||||
// H25 — explicit timeout, иначе slow RPC stalls Express worker indefinitely
|
||||
@@ -306,10 +315,6 @@ async function sendEvm(p: SendParams, rpc: string, chainId: number, usdtAddr: st
|
||||
// ─── SOLANA ───
|
||||
|
||||
async function sendSol(p: SendParams): Promise<{ txid: string }> {
|
||||
if (p.token) {
|
||||
throw new Error('SOL SPL-token signing не реализовано (только native SOL)');
|
||||
}
|
||||
|
||||
const seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||||
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
||||
if (!key || key.length !== 32) {
|
||||
@@ -318,64 +323,82 @@ async function sendSol(p: SendParams): Promise<{ txid: string }> {
|
||||
const keypair = Keypair.fromSeed(key);
|
||||
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
|
||||
|
||||
// C10 — lamports precision: @solana/web3.js converts BigInt → Number internally
|
||||
// (u64 layout). Above 2^53 lamports = silent truncation. Reject early.
|
||||
const lamports = BigInt(p.amount);
|
||||
// Precision: @solana/web3.js конвертит BigInt → Number внутренне (u64 layout).
|
||||
const amountBig = BigInt(p.amount);
|
||||
const MAX_SAFE_LAMPORTS = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
if (lamports > MAX_SAFE_LAMPORTS) {
|
||||
throw new Error(`SOL amount ${p.amount} lamports exceeds Number precision (max ${MAX_SAFE_LAMPORTS}); split into multiple sends`);
|
||||
if (amountBig > MAX_SAFE_LAMPORTS) {
|
||||
throw new Error(`SOL amount ${p.amount} exceeds Number precision (max ${MAX_SAFE_LAMPORTS}); split into multiple sends`);
|
||||
}
|
||||
if (lamports <= 0n) {
|
||||
if (amountBig <= 0n) {
|
||||
throw new Error('SOL amount must be positive');
|
||||
}
|
||||
|
||||
// H41 — singleton Connection (per-call new() leaks WebSocket subscriptions)
|
||||
const conn = getSolConnection();
|
||||
const toPk = new PublicKey(p.to);
|
||||
|
||||
// C11 — rent-exempt minimum check. Если recipient — fresh account и amount меньше
|
||||
// rent-exempt минимума, tx fails ПОСЛЕ broadcast (5000 lamports fee burned, no transfer).
|
||||
// Pre-check сохраняет fee + user-facing error.
|
||||
try {
|
||||
const accountInfo = await conn.getAccountInfo(toPk);
|
||||
if (accountInfo === null) {
|
||||
const rentMin = BigInt(await conn.getMinimumBalanceForRentExemption(0));
|
||||
if (lamports < rentMin) {
|
||||
throw new Error(`SOL recipient is fresh account; amount ${lamports} lamports < rent-exempt minimum ${rentMin}. Send at least ${rentMin} lamports to create account.`);
|
||||
}
|
||||
}
|
||||
} catch (preErr: any) {
|
||||
// Network error checking — proceed (broadcast will surface real error)
|
||||
if (!preErr.message?.includes('rent-exempt')) {
|
||||
// только network/RPC failures, не наш own throw
|
||||
} else {
|
||||
throw preErr;
|
||||
}
|
||||
}
|
||||
|
||||
const { blockhash, lastValidBlockHeight } = await conn.getLatestBlockhash();
|
||||
const tx = new Transaction({ feePayer: keypair.publicKey, blockhash, lastValidBlockHeight });
|
||||
|
||||
const tx = new Transaction({
|
||||
feePayer: keypair.publicKey,
|
||||
blockhash,
|
||||
lastValidBlockHeight,
|
||||
});
|
||||
// H40 — compute-unit price для priority fee (tiers slow/normal/fast).
|
||||
// Без этого tx может dropped в congestion. Default 'normal' = 1_000 microLamports.
|
||||
// H40 — compute-unit price (priority fee)
|
||||
const tier = p.feeTier ?? 'normal';
|
||||
const cuPrice = tier === 'fast' ? 10_000n : tier === 'slow' ? 0n : 1_000n;
|
||||
if (cuPrice > 0n) {
|
||||
tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: cuPrice }));
|
||||
}
|
||||
tx.add(
|
||||
SystemProgram.transfer({
|
||||
|
||||
if (!p.token) {
|
||||
// ── Native SOL transfer ──
|
||||
// C11 — rent-exempt check для fresh recipient
|
||||
try {
|
||||
const accountInfo = await conn.getAccountInfo(toPk);
|
||||
if (accountInfo === null) {
|
||||
const rentMin = BigInt(await conn.getMinimumBalanceForRentExemption(0));
|
||||
if (amountBig < rentMin) {
|
||||
throw new Error(`SOL recipient is fresh account; amount ${amountBig} lamports < rent-exempt minimum ${rentMin}. Send at least ${rentMin} lamports to create account.`);
|
||||
}
|
||||
}
|
||||
} catch (preErr: any) {
|
||||
if (preErr.message?.includes('rent-exempt')) throw preErr;
|
||||
// Network error checking — proceed (broadcast surfaces real error)
|
||||
}
|
||||
tx.add(SystemProgram.transfer({
|
||||
fromPubkey: keypair.publicKey,
|
||||
toPubkey: toPk,
|
||||
lamports,
|
||||
}),
|
||||
);
|
||||
tx.sign(keypair);
|
||||
lamports: amountBig,
|
||||
}));
|
||||
} else {
|
||||
// ── SPL token transfer ──
|
||||
// Generic SPL: lookup mint в token-registry. Поддерживает USDT/USDC/PUMP/JUP/... (15 mints)
|
||||
const tokenInfo = getTokenInfo('SOL', p.token);
|
||||
if (!tokenInfo) {
|
||||
throw new Error(`Token ${p.token} not in registry for chain SOL`);
|
||||
}
|
||||
const mint = new PublicKey(tokenInfo.address);
|
||||
const sourceAta = getAssociatedTokenAddressSync(mint, keypair.publicKey);
|
||||
const destAta = getAssociatedTokenAddressSync(mint, toPk);
|
||||
|
||||
// Idempotent ATA creation — safe to always include. Если ATA уже есть, instruction skip'нется.
|
||||
// Recipient'у которому никогда не отправляли этот mint — мы создадим ATA (~0.002 SOL rent).
|
||||
tx.add(createAssociatedTokenAccountIdempotentInstruction(
|
||||
keypair.publicKey, // payer (мы платим rent если ATA создаётся)
|
||||
destAta,
|
||||
toPk,
|
||||
mint,
|
||||
TOKEN_PROGRAM_ID,
|
||||
));
|
||||
|
||||
// CheckedTransfer защищает от decimals mismatch (RPC ложит → token loss)
|
||||
tx.add(createTransferCheckedInstruction(
|
||||
sourceAta,
|
||||
mint,
|
||||
destAta,
|
||||
keypair.publicKey,
|
||||
amountBig,
|
||||
tokenInfo.decimals,
|
||||
));
|
||||
}
|
||||
|
||||
tx.sign(keypair);
|
||||
const sig = await conn.sendRawTransaction(tx.serialize());
|
||||
|
||||
// H37 — distinguished error categories
|
||||
@@ -404,6 +427,83 @@ function getSolConnection(): Connection {
|
||||
return _solConnection;
|
||||
}
|
||||
|
||||
// ─── SOL custodial sign-and-broadcast (для Relay bridge SOL-side) ─────
|
||||
|
||||
export interface SignSolanaTxParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
serializedTransaction: string; // base64-encoded VersionedTransaction
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписать произвольную serialized Solana VersionedTransaction custodially.
|
||||
* Используется когда Relay /execute или Jupiter возвращают unsigned tx — клиент шлёт base64,
|
||||
* сервер deserialize → verify feePayer === user's pubkey → partial-sign → broadcast.
|
||||
*
|
||||
* Security:
|
||||
* - feePayer (staticAccountKeys[0]) ДОЛЖЕН совпадать с user's SOL pubkey
|
||||
* - Tx size limit 8KB (Solana network max — 1232 bytes раз; base64 ~1.65k chars)
|
||||
* - assertAddressMatch — derived address vs DB
|
||||
*/
|
||||
export async function signAndBroadcastSolanaTx(p: SignSolanaTxParams): Promise<{ signature: string }> {
|
||||
const seed = await bip39.mnemonicToSeed(p.mnemonic);
|
||||
const { key } = derivePath(DERIVATION_PATHS.SOL, seed.toString('hex'));
|
||||
if (!key || key.length !== 32) {
|
||||
throw new Error('SOL derivation produced invalid seed length');
|
||||
}
|
||||
const keypair = Keypair.fromSeed(key);
|
||||
assertAddressMatch(keypair.publicKey.toBase58(), p.expectedFromAddress, 'SOL');
|
||||
|
||||
let txBytes: Buffer;
|
||||
try {
|
||||
txBytes = Buffer.from(p.serializedTransaction, 'base64');
|
||||
} catch {
|
||||
throw new Error('Invalid base64 transaction');
|
||||
}
|
||||
if (txBytes.length === 0 || txBytes.length > 1500) {
|
||||
throw new Error(`Invalid tx size: ${txBytes.length} bytes (expected 1-1500)`);
|
||||
}
|
||||
|
||||
let tx: VersionedTransaction;
|
||||
try {
|
||||
tx = VersionedTransaction.deserialize(txBytes);
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to deserialize VersionedTransaction: ${err.message}`);
|
||||
}
|
||||
|
||||
// Critical: verify feePayer === our pubkey. Без этого attacker может подсунуть tx
|
||||
// с другим feePayer, мы подписали бы fee-deduct из их wallet'а (бесплатно для нас).
|
||||
const feePayer = tx.message.staticAccountKeys[0]?.toBase58();
|
||||
if (feePayer !== keypair.publicKey.toBase58()) {
|
||||
throw new Error(`feePayer mismatch: tx.feePayer=${feePayer} vs user.pubkey=${keypair.publicKey.toBase58()}`);
|
||||
}
|
||||
|
||||
tx.sign([keypair]);
|
||||
|
||||
const conn = getSolConnection();
|
||||
const sig = await conn.sendRawTransaction(tx.serialize());
|
||||
|
||||
try {
|
||||
const latestBlock = await conn.getLatestBlockhash();
|
||||
await conn.confirmTransaction({
|
||||
signature: sig,
|
||||
blockhash: latestBlock.blockhash,
|
||||
lastValidBlockHeight: latestBlock.lastValidBlockHeight,
|
||||
}, 'confirmed');
|
||||
} catch (err: any) {
|
||||
const name = err?.name || '';
|
||||
if (name === 'TransactionExpiredBlockheightExceededError') {
|
||||
throw new Error(`SOL tx EXPIRED (blockhash expired before confirm). sig=${sig}`);
|
||||
}
|
||||
if (name === 'TransactionExpiredTimeoutError') {
|
||||
throw new Error(`SOL tx unconfirmed after timeout. sig=${sig}`);
|
||||
}
|
||||
throw new Error(`SOL confirm error (${name}): ${err.message}. sig=${sig}`);
|
||||
}
|
||||
|
||||
return { signature: sig };
|
||||
}
|
||||
|
||||
// ─── BITCOIN ───
|
||||
|
||||
async function sendBtc(p: SendParams): Promise<{ txid: string }> {
|
||||
@@ -577,7 +677,12 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
|
||||
}),
|
||||
});
|
||||
txBody = built;
|
||||
} else if (p.token.toUpperCase() === 'USDT') {
|
||||
} else {
|
||||
// Generic TRC20: lookup в token-registry. Поддерживает USDT, USDC и др.
|
||||
const tokenInfo = getTokenInfo('TRX', p.token);
|
||||
if (!tokenInfo) {
|
||||
throw new Error(`Token ${p.token} not in registry for chain TRX`);
|
||||
}
|
||||
const param =
|
||||
tronAddressToHex(p.to).padStart(64, '0') +
|
||||
BigInt(p.amount).toString(16).padStart(64, '0');
|
||||
@@ -586,19 +691,16 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
owner_address: fromTronAddr,
|
||||
contract_address: USDT_TRC20,
|
||||
contract_address: tokenInfo.address,
|
||||
function_selector: 'transfer(address,uint256)',
|
||||
parameter: param,
|
||||
// 30 TRX cap — реальный USDT transfer обычно жжёт 15-30 TRX без Energy,
|
||||
// ~0 с Energy. Раньше было 100 TRX — это cap, не actual fee, но завышен.
|
||||
// 30 TRX cap — типичный TRC20 transfer жжёт 15-30 TRX без Energy.
|
||||
fee_limit: 30_000_000,
|
||||
call_value: 0,
|
||||
visible: true,
|
||||
}),
|
||||
});
|
||||
txBody = built.transaction;
|
||||
} else {
|
||||
throw new Error(`Token ${p.token} not supported on TRX`);
|
||||
}
|
||||
|
||||
if (!txBody?.txID || !txBody?.raw_data_hex || !txBody?.raw_data) {
|
||||
@@ -659,8 +761,14 @@ async function sendTrx(p: SendParams): Promise<{ txid: string }> {
|
||||
throw new Error(`TRX amount mismatch: expected ${p.amount}, got ${contractValue.amount}`);
|
||||
}
|
||||
} else {
|
||||
if (contractValue.contract_address !== USDT_TRC20) {
|
||||
throw new Error(`TRX contract mismatch: expected ${USDT_TRC20}, got ${contractValue.contract_address}`);
|
||||
// MITM-check: contract_address должен совпадать с тем что lookup'ом из registry для нашего token symbol.
|
||||
// Без этого RPC может вернуть legitimate-looking tx но с другим contract → attacker drain.
|
||||
const expectedTokenInfo = getTokenInfo('TRX', p.token);
|
||||
if (!expectedTokenInfo) {
|
||||
throw new Error(`Token ${p.token} not in registry for chain TRX (MITM-check)`);
|
||||
}
|
||||
if (contractValue.contract_address !== expectedTokenInfo.address) {
|
||||
throw new Error(`TRX contract mismatch: expected ${expectedTokenInfo.address}, got ${contractValue.contract_address}`);
|
||||
}
|
||||
const data = String(contractValue.data || '');
|
||||
if (data.length !== 128 + 8) {
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
{ "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)" }
|
||||
{ "name": "Relay", "description": "Cross-chain bridges (Relay Protocol)" },
|
||||
{ "name": "Prices", "description": "USD-цены (CoinGecko + KeyDB cache 5 мин)" }
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
@@ -85,10 +86,45 @@
|
||||
},
|
||||
"FormattedAmount": {
|
||||
"type": "object",
|
||||
"description": "Сумма с метаданными формата + USD-цена. Поля `usdPrice`/`usdValue` всегда присутствуют, но могут быть `null` если symbol не в registry или upstream price oracle (CoinGecko) недоступен.",
|
||||
"required": ["raw", "formatted", "decimals", "usdPrice", "usdValue"],
|
||||
"properties": {
|
||||
"raw": { "type": "string", "description": "Smallest units (wei/sat/sun/lamports), string-encoded BigInt" },
|
||||
"formatted": { "type": "string", "description": "Human-readable decimal", "example": "0.003" },
|
||||
"decimals": { "type": "integer", "description": "Decimals of the chain/token", "example": 18 }
|
||||
"raw": { "type": "string", "description": "Smallest units (wei/sat/sun/lamports), string-encoded BigInt", "example": "1500000000000000000" },
|
||||
"formatted": { "type": "string", "description": "Human-readable decimal", "example": "1.5" },
|
||||
"decimals": { "type": "integer", "description": "Decimals of the chain/token", "example": 18 },
|
||||
"usdPrice": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Цена 1 целой единицы в USD по данным CoinGecko (cache 5 мин, KeyDB). `null` если symbol не в registry или upstream недоступен.",
|
||||
"example": 3210.45
|
||||
},
|
||||
"usdValue": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Совокупная стоимость holding'а в USD = `Number(formatted) × usdPrice`, округлено до 8 знаков. `null` если `usdPrice === null` или результат не finite.",
|
||||
"example": 4815.675
|
||||
}
|
||||
}
|
||||
},
|
||||
"PricesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean", "example": true },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Map symbol → { usd: price | null }. `null` если symbol whitelist'ed но upstream не вернул котировку.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"usd": { "type": "number", "nullable": true, "example": 67432.12 }
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"BTC": { "usd": 67432.12 },
|
||||
"ETH": { "usd": 3210.45 },
|
||||
"USDT": { "usd": 1.0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"BalanceResponse": {
|
||||
@@ -256,11 +292,42 @@
|
||||
|
||||
"/wallets/{chain}/balance": {
|
||||
"get": {
|
||||
"summary": "Balance for user wallet in chain",
|
||||
"summary": "Balance for user wallet in chain (с USD-ценами)",
|
||||
"description": "Возвращает количество и USD-стоимость для native монеты + всех известных токенов сети. Каждый `FormattedAmount` содержит `raw` (smallest units), `formatted` (human-readable), `decimals`, `usdPrice` (цена 1 единицы), `usdValue` (стоимость holding'а). Цены — CoinGecko с 5-минутным KeyDB-кэшем. Если упал price oracle — `usdPrice`/`usdValue` = `null`, но количества всё равно возвращаются.\n\n**Пример curl:**\n```\ncurl -H \"Authorization: Bearer $JWT\" https://api.example.com/api/wallets/ETH/balance\n```",
|
||||
"tags": ["Wallet Ops"],
|
||||
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "$ref": "#/components/schemas/Chain" } }],
|
||||
"responses": {
|
||||
"200": { "description": "Balance", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BalanceResponse" } } } },
|
||||
"200": {
|
||||
"description": "Balance + USD prices",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/BalanceResponse" },
|
||||
"example": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"chain": "ETH",
|
||||
"address": "0x742d35Cc6634C0532925a3b844Bc9e7595f4F45A",
|
||||
"native": {
|
||||
"raw": "1500000000000000000",
|
||||
"formatted": "1.5",
|
||||
"decimals": 18,
|
||||
"usdPrice": 3210.45,
|
||||
"usdValue": 4815.675
|
||||
},
|
||||
"tokens": {
|
||||
"USDT": { "raw": "1000000", "formatted": "1", "decimals": 6, "usdPrice": 1.0, "usdValue": 1.0 },
|
||||
"USDC": { "raw": "0", "formatted": "0", "decimals": 6, "usdPrice": 0.9999, "usdValue": 0 },
|
||||
"DAI": { "raw": "0", "formatted": "0", "decimals": 18, "usdPrice": 0.9998, "usdValue": 0 },
|
||||
"WBTC": { "raw": "0", "formatted": "0", "decimals": 8, "usdPrice": 67432.12, "usdValue": 0 },
|
||||
"LINK": { "raw": "0", "formatted": "0", "decimals": 18, "usdPrice": 14.32, "usdValue": 0 },
|
||||
"UNI": { "raw": "0", "formatted": "0", "decimals": 18, "usdPrice": 8.41, "usdValue": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": { "description": "Not authenticated" },
|
||||
"404": { "description": "Wallet for this chain not found" },
|
||||
"502": { "description": "Upstream RPC error" }
|
||||
}
|
||||
@@ -319,8 +386,8 @@
|
||||
},
|
||||
"/wallets/{chain}/sign-raw-evm-tx": {
|
||||
"post": {
|
||||
"summary": "Custodial sign + broadcast arbitrary EVM tx (Relay/Swap unsigned tx)",
|
||||
"description": "Подписывает произвольную EVM tx (например `steps[0].items[0].data` из `/relay/execute/swap`). Сервер расшифровывает mnemonic, деривит privkey, ставит nonce, подписывает type-2 EIP-1559 tx, broadcast'ит. Если задан `feeTier` → переопределяет maxFeePerGas/maxPriority из тела актуальным из eth_feeHistory. ⚠️ Security: подписывает arbitrary `to`+`data` — в production надо whitelist'ить `to` (Relay routers) или требовать Relay attestation. Только ETH(1)/BSC(56).",
|
||||
"summary": "Custodial sign + broadcast arbitrary EVM tx (Relay bridge)",
|
||||
"description": "Подписывает unsigned EVM tx из Relay /execute response. Policy: `to` ДОЛЖЕН быть в Relay router allowlist; selector blacklist (approve/permit/setApprovalForAll). Для DEX swap'ов используй `/wallets/{chain}/swap` — там chained custodial без этих ограничений.",
|
||||
"tags": ["Wallet Ops"],
|
||||
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["ETH", "BSC"] } }],
|
||||
"requestBody": {
|
||||
@@ -329,13 +396,115 @@
|
||||
},
|
||||
"responses": {
|
||||
"200": { "description": "Broadcast successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TxBroadcastResponse" } } } },
|
||||
"400": { "description": "Invalid input (bad to/data/value, chainId mismatch, invalid feeTier)" },
|
||||
"400": { "description": "Policy violation: to not in allowlist OR forbidden selector OR cap exceeded" },
|
||||
"404": { "description": "Wallet/mnemonic not found" },
|
||||
"502": { "description": "Broadcast failed" },
|
||||
"503": { "description": "Crypto service not ready" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallets/{chain}/swap": {
|
||||
"post": {
|
||||
"summary": "Custodial chained swap (BSC PancakeSwap / TRX SunSwap+FeeSwapRouter / SOL Jupiter)",
|
||||
"description": "Полностью custodial swap в один HTTP-вызов. Никакого client-side signing.\n\n**BSC** — PancakeSwap V2 approve+swap chained. Пары: BNB/USDT/USDC/DOGE/WBNB/BUSD.\n\n**TRX** — SunSwap V2 через FeeSwapRouter (0.7% fee). Только пары TRX↔USDT. Server делает approve(infinite, FeeSwapRouter) (если allowance < amount) + wait inclusion + swap. 4-layer MITM defense (txID/expiration/type/selector verify) — компрометированный TronGrid не сможет подсунуть `transfer` вместо `swap`.\n\n**SOL** — Jupiter aggregator. Любые mints из registry (USDT/USDC/PUMP/JUP/WIF/POPCAT/TRUMP/PYTH/JTO/W/BONK/ORCA/PENGU/RAY).\n\n**Slippage protection** — server computes `amountOutMin = quote × (10000-slippageBps)/10000` от actual quote (default 50 bps = 0.5%). Клиент НЕ задаёт amountOutMin напрямую (защита от MEV-sandwich). Optional `Idempotency-Key` header для anti double-spend.",
|
||||
"tags": ["Wallet Ops"],
|
||||
"parameters": [{ "name": "chain", "in": "path", "required": true, "schema": { "type": "string", "enum": ["BSC", "TRX", "SOL"] } }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"title": "BSC/TRX swap (symbols)",
|
||||
"required": ["from", "to", "amount"],
|
||||
"properties": {
|
||||
"from": { "type": "string", "description": "BSC: BNB|USDT|USDC|DOGE|WBNB|BUSD; TRX: TRX|USDT (только эта пара поддерживается на TRON)" },
|
||||
"to": { "type": "string" },
|
||||
"amount": { "type": "string", "description": "Smallest units (wei для 18-dec EVM, sun для TRX 6-dec). Max для TRX = 9_007_199_254_740_991 (~9B TRX)." },
|
||||
"slippageBps": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "0.01%-10%. Default 50 (0.5%). Server вычислит amountOutMin сам — клиент НЕ задаёт его напрямую." },
|
||||
"feeTier": { "type": "string", "enum": ["slow", "normal", "fast"], "description": "Только BSC (ETH/BSC). На TRX игнорится." }
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "SOL swap (mints)",
|
||||
"required": ["inputMint", "outputMint", "amount"],
|
||||
"properties": {
|
||||
"inputMint": { "type": "string", "description": "SPL mint address (base58)" },
|
||||
"outputMint": { "type": "string" },
|
||||
"amount": { "type": "string", "description": "Smallest units (lamports = 9-dec для SOL native)" },
|
||||
"slippageBps": { "type": "integer", "minimum": 1, "maximum": 1000 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "BSC: { approveTxid?, swapTxid }. TRX/SOL: { txid | signature }",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chain": { "type": "string" },
|
||||
"approveTxid": { "type": "string", "nullable": true, "description": "BSC only, если token-to-X swap требовал approve" },
|
||||
"swapTxid": { "type": "string", "description": "BSC swap txid" },
|
||||
"txid": { "type": "string", "description": "TRX txid" },
|
||||
"signature": { "type": "string", "description": "SOL tx signature" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "description": "Invalid pair / slippage / amount / unsupported chain" },
|
||||
"404": { "description": "Wallet not found" },
|
||||
"409": { "description": "Idempotency-Key reuse with different body, or operation in-flight" },
|
||||
"502": { "description": "Swap failed (no liquidity / network error / contract revert)" },
|
||||
"503": { "description": "Crypto service not ready" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/wallets/SOL/sign-and-broadcast-tx": {
|
||||
"post": {
|
||||
"summary": "Custodial sign + broadcast arbitrary Solana VersionedTransaction",
|
||||
"description": "Подписывает unsigned serialized Solana tx (от Relay /execute SOL-side, или любого aggregator'а). Server verify feePayer === user's pubkey, partial-sign keypair'ом, broadcast, confirm.",
|
||||
"tags": ["Wallet Ops"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["transaction"],
|
||||
"properties": {
|
||||
"transaction": { "type": "string", "description": "Base64-encoded VersionedTransaction (max ~1500 bytes raw)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Signed and broadcast",
|
||||
"content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object", "properties": { "signature": { "type": "string" }, "chain": { "type": "string" } } } } } } }
|
||||
},
|
||||
"400": { "description": "Invalid base64 / tx size / feePayer mismatch" },
|
||||
"404": { "description": "SOL wallet/mnemonic not found" },
|
||||
"502": { "description": "Sign or broadcast failed" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/btc/utxos/{address}": {
|
||||
"get": {
|
||||
@@ -518,6 +687,60 @@
|
||||
"502": { "description": "Relay upstream error" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"/prices": {
|
||||
"get": {
|
||||
"summary": "USD-цены для списка символов",
|
||||
"description": "Возвращает котировки USD для указанных символов (max 50). Символы должны быть из реестра поддерживаемых токенов (см. tag описание сетей в /wallets/{chain}/balance). Источник — CoinGecko free API, кэшируется в KeyDB 5 минут.\n\n**Resolution:**\n- Native символ совпадающий с chain code (BTC/ETH/BSC/TRX/SOL) → используется native CoinGecko id.\n- Иначе: ищется в реестре сети из `chain` query param.\n- Если `chain` не задан → fallback порядок ETH → BSC → SOL → TRX → BTC. Первый matched chain wins.\n\n**Безопасность:** symbols whitelisted, никакого user-input в URL CoinGecko (защита от SSRF). Max 50 символов на запрос. Auth обязательна (JWT Bearer или cookie).\n\n**Пример curl:**\n```\ncurl -H \"Authorization: Bearer $JWT\" \"https://api.example.com/api/prices?symbols=BTC,ETH,USDT,SOL,BONK\"\n```",
|
||||
"tags": ["Prices"],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "symbols",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Comma-separated список символов (макс 50). Каждый — `[A-Z0-9]{1,16}`. Только символы из registry: BTC, ETH, BSC, TRX, SOL (native) + USDT, USDC, DAI, WBTC, LINK, UNI, DOGE, WBNB, BUSD, PUMP, JUP, WIF, POPCAT, TRUMP, PYTH, JTO, W, BONK, ORCA, PENGU, RAY.",
|
||||
"schema": { "type": "string", "example": "BTC,ETH,USDT" }
|
||||
},
|
||||
{
|
||||
"name": "chain",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Опционально: для disambiguation если symbol присутствует в нескольких сетях (USDT/USDC). Если не задан — fallback порядок: ETH → BSC → SOL → TRX → BTC.",
|
||||
"schema": { "$ref": "#/components/schemas/Chain" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "USD prices",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/PricesResponse" },
|
||||
"example": {
|
||||
"success": true,
|
||||
"data": {
|
||||
"BTC": { "usd": 67432.12 },
|
||||
"ETH": { "usd": 3210.45 },
|
||||
"USDT": { "usd": 1.0 },
|
||||
"SOL": { "usd": 142.88 },
|
||||
"BONK": { "usd": 0.00002145 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Validation error: пустой/слишком большой/невалидный список, неизвестный chain или unknown symbol",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
},
|
||||
"401": { "description": "Not authenticated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
|
||||
"429": { "description": "Rate limit exceeded" },
|
||||
"502": {
|
||||
"description": "Upstream price oracle error (CoinGecko)",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
-- ╔══════════════════════════════════════════════════════════════════╗
|
||||
-- ║ CryptoWallet API — Production DB schema (idempotent, custodial) ║
|
||||
-- ║ Применять: psql -h <host> -U postgres_user -d postgres -f ... ║
|
||||
-- ║ Безопасно прогонять повторно на existing БД. ║
|
||||
-- ║ CryptoWallet API — Production DB schema ║
|
||||
-- ║ ║
|
||||
-- ║ APPEND-ONLY / NON-DESTRUCTIVE: ║
|
||||
-- ║ Безопасно прогонять повторно. Ничего не DROP'ает, не overwrite. ║
|
||||
-- ║ Если оператор добавил кастомные таблицы / индексы / constraints ║
|
||||
-- ║ вручную — они НЕ будут затронуты. ║
|
||||
-- ║ ║
|
||||
-- ║ Применять: psql -h <host> -U <user> -d <db> -f cryptowallet-schema.sql ║
|
||||
-- ╚══════════════════════════════════════════════════════════════════╝
|
||||
|
||||
-- NOTE: idempotency_keys + audit_log таблицы УДАЛЕНЫ из БД.
|
||||
-- - idempotency_keys → KeyDB (Redis cache), см. apps/api/src/config/redis.ts
|
||||
-- - audit_log → stdout-only (Docker logs / log-aggregator подбирает JSON lines)
|
||||
-- Migration ниже drop'ает их если они существуют от прошлой версии.
|
||||
-- NOTE: idempotency_keys и audit_log таблицы НЕ используются.
|
||||
-- - idempotency_keys → KeyDB (Redis cache) — apps/api/src/config/redis.ts
|
||||
-- - audit_log → stdout JSON-lines — apps/api/src/lib/audit-log.ts
|
||||
-- Скрипт их НЕ дропает (чтобы re-run был non-destructive).
|
||||
-- Если оператор хочет cleanup — manual one-time:
|
||||
-- DROP TABLE IF EXISTS audit_log CASCADE;
|
||||
-- DROP TABLE IF EXISTS idempotency_keys CASCADE;
|
||||
|
||||
-- ── USERS ───────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
@@ -33,7 +42,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
encrypted_mnemonic TEXT
|
||||
);
|
||||
|
||||
-- Idempotent ALTERs для existing БД без extension-columns
|
||||
-- Idempotent ALTERs для existing БД у которой нет extension-columns (только ADD если нет колонки)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'encrypted_mnemonic') THEN
|
||||
@@ -47,15 +56,16 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Sanity check на blob size. Floor 100 (worst-case 12-word всё-3char mnemonic):
|
||||
-- Constraint: blob size check (only ADDs if missing, никогда не DROP).
|
||||
-- Floor 100 (worst-case 12-word 3-char mnemonic = 100 base64 chars).
|
||||
-- Если оператор изменил этот constraint вручную — наш script его НЕ перезатрёт.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
|
||||
ALTER TABLE users DROP CONSTRAINT users_encrypted_mnemonic_size;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_encrypted_mnemonic_size') THEN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_encrypted_mnemonic_size
|
||||
CHECK (encrypted_mnemonic IS NULL OR (char_length(encrypted_mnemonic) BETWEEN 100 AND 512));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Case-insensitive email uniqueness (Alice@x.com ≠ alice@x.com → ACCOUNT HIJACKING fix)
|
||||
@@ -91,6 +101,7 @@ END $$;
|
||||
|
||||
-- ── WALLETS ─────────────────────────────────────────────────────────
|
||||
-- ON DELETE RESTRICT: hard-delete user → запрос отвергнут пока есть wallets.
|
||||
-- Это защита от unrecoverable fund loss при GDPR-wipe или admin удалении.
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
id VARCHAR(26) NOT NULL PRIMARY KEY,
|
||||
user_id VARCHAR(26) NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
@@ -105,21 +116,11 @@ CREATE TABLE IF NOT EXISTS wallets (
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_user_id ON wallets(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wallets_address ON wallets(address);
|
||||
|
||||
-- Idempotent FK migration: если raised на старой DB с CASCADE — поменять
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.referential_constraints
|
||||
WHERE constraint_name LIKE 'wallets_user_id_fkey%' AND delete_rule = 'CASCADE'
|
||||
) THEN
|
||||
ALTER TABLE wallets DROP CONSTRAINT IF EXISTS wallets_user_id_fkey;
|
||||
ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── DROP legacy tables (если existing БД от прошлой версии) ────────
|
||||
-- idempotency_keys → KeyDB cache (apps/api/src/lib/idempotency.ts → Redis)
|
||||
-- audit_log → stdout-only (apps/api/src/lib/audit-log.ts)
|
||||
DROP TABLE IF EXISTS audit_log CASCADE;
|
||||
DROP TABLE IF EXISTS idempotency_keys CASCADE;
|
||||
-- NOTE: если БД старая и wallets.user_id_fkey ON DELETE CASCADE (а нужен RESTRICT
|
||||
-- для защиты от fund loss при delete user), оператор делает manual ОДИН раз:
|
||||
--
|
||||
-- ALTER TABLE wallets DROP CONSTRAINT wallets_user_id_fkey;
|
||||
-- ALTER TABLE wallets ADD CONSTRAINT wallets_user_id_fkey
|
||||
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT;
|
||||
--
|
||||
-- Этот script ничего не дропает — re-run полностью non-destructive.
|
||||
|
||||
195
pnpm-lock.yaml
generated
195
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
'@solana/spl-token':
|
||||
specifier: ^0.4.14
|
||||
version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6)
|
||||
'@solana/web3.js':
|
||||
specifier: ^1.98.4
|
||||
version: 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
|
||||
@@ -360,22 +363,58 @@ packages:
|
||||
'@scure/base@1.2.6':
|
||||
resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==}
|
||||
|
||||
'@solana/buffer-layout-utils@0.2.0':
|
||||
resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@solana/buffer-layout@4.0.1':
|
||||
resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==}
|
||||
engines: {node: '>=5.10'}
|
||||
|
||||
'@solana/codecs-core@2.0.0-rc.1':
|
||||
resolution: {integrity: sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
|
||||
'@solana/codecs-core@2.3.0':
|
||||
resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==}
|
||||
engines: {node: '>=20.18.0'}
|
||||
peerDependencies:
|
||||
typescript: '>=5.3.3'
|
||||
|
||||
'@solana/codecs-data-structures@2.0.0-rc.1':
|
||||
resolution: {integrity: sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
|
||||
'@solana/codecs-numbers@2.0.0-rc.1':
|
||||
resolution: {integrity: sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
|
||||
'@solana/codecs-numbers@2.3.0':
|
||||
resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==}
|
||||
engines: {node: '>=20.18.0'}
|
||||
peerDependencies:
|
||||
typescript: '>=5.3.3'
|
||||
|
||||
'@solana/codecs-strings@2.0.0-rc.1':
|
||||
resolution: {integrity: sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==}
|
||||
peerDependencies:
|
||||
fastestsmallesttextencoderdecoder: ^1.0.22
|
||||
typescript: '>=5'
|
||||
|
||||
'@solana/codecs@2.0.0-rc.1':
|
||||
resolution: {integrity: sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
|
||||
'@solana/errors@2.0.0-rc.1':
|
||||
resolution: {integrity: sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
|
||||
'@solana/errors@2.3.0':
|
||||
resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==}
|
||||
engines: {node: '>=20.18.0'}
|
||||
@@ -383,6 +422,29 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=5.3.3'
|
||||
|
||||
'@solana/options@2.0.0-rc.1':
|
||||
resolution: {integrity: sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
|
||||
'@solana/spl-token-group@0.0.7':
|
||||
resolution: {integrity: sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
'@solana/web3.js': ^1.95.3
|
||||
|
||||
'@solana/spl-token-metadata@0.1.6':
|
||||
resolution: {integrity: sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
'@solana/web3.js': ^1.95.3
|
||||
|
||||
'@solana/spl-token@0.4.14':
|
||||
resolution: {integrity: sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA==}
|
||||
engines: {node: '>=16'}
|
||||
peerDependencies:
|
||||
'@solana/web3.js': ^1.95.5
|
||||
|
||||
'@solana/web3.js@1.98.4':
|
||||
resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==}
|
||||
|
||||
@@ -599,10 +661,20 @@ packages:
|
||||
bech32@2.0.0:
|
||||
resolution: {integrity: sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==}
|
||||
|
||||
bigint-buffer@1.1.5:
|
||||
resolution: {integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
bip174@2.1.1:
|
||||
resolution: {integrity: sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -723,6 +795,10 @@ packages:
|
||||
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
commander@12.1.0:
|
||||
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -961,6 +1037,9 @@ packages:
|
||||
fast-stable-stringify@1.0.0:
|
||||
resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==}
|
||||
|
||||
fastestsmallesttextencoderdecoder@1.0.22:
|
||||
resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
@@ -968,6 +1047,9 @@ packages:
|
||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||
engines: {node: ^10.12.0 || >=12.0.0}
|
||||
|
||||
file-uri-to-path@1.0.0:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2460,27 +2542,124 @@ snapshots:
|
||||
|
||||
'@scure/base@1.2.6': {}
|
||||
|
||||
'@solana/buffer-layout-utils@0.2.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)':
|
||||
dependencies:
|
||||
'@solana/buffer-layout': 4.0.1
|
||||
'@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
|
||||
bigint-buffer: 1.1.5
|
||||
bignumber.js: 9.3.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- encoding
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
'@solana/buffer-layout@4.0.1':
|
||||
dependencies:
|
||||
buffer: 6.0.3
|
||||
|
||||
'@solana/codecs-core@2.0.0-rc.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/codecs-core@2.3.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/errors': 2.3.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/codecs-data-structures@2.0.0-rc.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/codecs-numbers@2.0.0-rc.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/codecs-numbers@2.3.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/codecs-core': 2.3.0(typescript@5.9.3)
|
||||
'@solana/errors': 2.3.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/codecs-strings@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
|
||||
fastestsmallesttextencoderdecoder: 1.0.22
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/codecs@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
|
||||
'@solana/options': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- fastestsmallesttextencoderdecoder
|
||||
|
||||
'@solana/errors@2.0.0-rc.1(typescript@5.9.3)':
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
commander: 12.1.0
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/errors@2.3.0(typescript@5.9.3)':
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
commander: 14.0.3
|
||||
typescript: 5.9.3
|
||||
|
||||
'@solana/options@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/codecs-core': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/codecs-data-structures': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/codecs-numbers': 2.0.0-rc.1(typescript@5.9.3)
|
||||
'@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
|
||||
'@solana/errors': 2.0.0-rc.1(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- fastestsmallesttextencoderdecoder
|
||||
|
||||
'@solana/spl-token-group@0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
|
||||
'@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
|
||||
transitivePeerDependencies:
|
||||
- fastestsmallesttextencoderdecoder
|
||||
- typescript
|
||||
|
||||
'@solana/spl-token-metadata@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
|
||||
'@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
|
||||
transitivePeerDependencies:
|
||||
- fastestsmallesttextencoderdecoder
|
||||
- typescript
|
||||
|
||||
'@solana/spl-token@0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@6.0.6)':
|
||||
dependencies:
|
||||
'@solana/buffer-layout': 4.0.1
|
||||
'@solana/buffer-layout-utils': 0.2.0(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
|
||||
'@solana/spl-token-group': 0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
|
||||
'@solana/spl-token-metadata': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)
|
||||
'@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)
|
||||
buffer: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- encoding
|
||||
- fastestsmallesttextencoderdecoder
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
'@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@@ -2736,8 +2915,18 @@ snapshots:
|
||||
|
||||
bech32@2.0.0: {}
|
||||
|
||||
bigint-buffer@1.1.5:
|
||||
dependencies:
|
||||
bindings: 1.5.0
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bindings@1.5.0:
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
|
||||
bip174@2.1.1: {}
|
||||
|
||||
bip32@4.0.0:
|
||||
@@ -2895,6 +3084,8 @@ snapshots:
|
||||
|
||||
commander@10.0.1: {}
|
||||
|
||||
commander@12.1.0: {}
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
@@ -3214,6 +3405,8 @@ snapshots:
|
||||
|
||||
fast-stable-stringify@1.0.0: {}
|
||||
|
||||
fastestsmallesttextencoderdecoder@1.0.22: {}
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -3222,6 +3415,8 @@ snapshots:
|
||||
dependencies:
|
||||
flat-cache: 3.2.0
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
Reference in New Issue
Block a user