init383838
This commit is contained in:
@@ -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}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user