deploy: POST /api/wallets + full swagger

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

View File

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