init383838

This commit is contained in:
ZOMBIIIIIII
2026-05-13 23:59:32 +03:00
parent 9fe5311bbf
commit 0661fffb88
8 changed files with 1155 additions and 71 deletions

View File

@@ -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';
@@ -319,8 +320,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();
@@ -618,4 +621,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}`));
}
}
},
};