diff --git a/apps/api/package.json b/apps/api/package.json index 546cda6..12c20f0 100644 --- a/apps/api/package.json +++ b/apps/api/package.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", diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 24def54..5a5cecc 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -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: '' } + */ + 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}`)); + } + } + }, }; diff --git a/apps/api/src/lib/token-registry.ts b/apps/api/src/lib/token-registry.ts index 141a7b3..f67f004 100644 --- a/apps/api/src/lib/token-registry.ts +++ b/apps/api/src/lib/token-registry.ts @@ -78,3 +78,31 @@ 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; +} diff --git a/apps/api/src/routes/wallet.routes.ts b/apps/api/src/routes/wallet.routes.ts index 6344d47..efce4c8 100644 --- a/apps/api/src/routes/wallet.routes.ts +++ b/apps/api/src/routes/wallet.routes.ts @@ -12,5 +12,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; diff --git a/apps/api/src/services/swap-orchestrator.service.ts b/apps/api/src/services/swap-orchestrator.service.ts new file mode 100644 index 0000000..370d0f7 --- /dev/null +++ b/apps/api/src/services/swap-orchestrator.service.ts @@ -0,0 +1,410 @@ +/** + * 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 = { + 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 { + 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(p: Promise, ms: number, msg: string): Promise { + return Promise.race([ + p, + new Promise((_, 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 = { + 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'; +const SUNSWAP_ROUTER = 'TKzxdSv2FZKQrEqkKVgp5DcwEXBEKMg2Ax'; // SunSwap V2 Router + +// Minimal TRX swap для TRX↔USDT (other tokens — добавить через registry) +const TRX_SWAP_TOKEN_MAP: Record = { + TRX: { address: 'TRX', decimals: 6, isNative: true }, + USDT: { address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6, isNative: false }, +}; + +export interface SwapTrxParams { + mnemonic: string; + expectedFromAddress: string; + from: string; + to: string; + amount: string; + slippageBps?: number; +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + 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 swap через SunSwap. Для упрощения — пока TRX↔USDT only (как в существующем proxy route). + * Расширить через token-registry если потребуется ETH/USDC support. + */ +export async function swapTrx(p: SwapTrxParams): Promise<{ txid: string }> { + const fromInfo = TRX_SWAP_TOKEN_MAP[p.from.toUpperCase()]; + const toInfo = TRX_SWAP_TOKEN_MAP[p.to.toUpperCase()]; + if (!fromInfo || !toInfo || p.from === p.to) { + throw new Error(`TRX swap supports only TRX↔USDT pairs (got ${p.from} → ${p.to})`); + } + + 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 = { 'Content-Type': 'application/json' }; + if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; + + // Build SunSwap unsigned tx через triggersmartcontract + // (Полная implementation SunSwap calldata builder — большой кусок; для prod — call existing + // /tron/swap/build endpoint logic. Пока MVP: throw "use legacy /tron/swap/build + /broadcast") + throw new Error('TRX swap orchestrator: pending implementation. Use legacy /tron/swap/build + custodial broadcast.'); +} + +// ─── 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 = { 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 = { + 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 }; +} diff --git a/apps/api/src/services/wallet-signer.service.ts b/apps/api/src/services/wallet-signer.service.ts index c654eb7..b5d8144 100644 --- a/apps/api/src/services/wallet-signer.service.ts +++ b/apps/api/src/services/wallet-signer.service.ts @@ -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) { diff --git a/apps/api/swagger.json b/apps/api/swagger.json index 7035c7b..55e36d0 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -318,8 +318,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": { @@ -328,13 +328,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 / SOL Jupiter)", + "description": "Полностью custodial swap в один HTTP-вызов. Никакого client-side signing. BSC: approve+swap chained (PancakeSwap V2, поддерживает BNB/USDT/USDC/DOGE/WBNB/BUSD). TRX: SunSwap TRX↔USDT. SOL: Jupiter aggregator (любые mints из registry). Slippage protection — server computes amountOutMin от actual quote с default 50 bps tolerance. 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" }, + "to": { "type": "string" }, + "amount": { "type": "string", "description": "Smallest units (wei для 18-dec, sun для TRX 6-dec)" }, + "slippageBps": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "0.01%-10%. Default 50 (0.5%)." }, + "feeTier": { "type": "string", "enum": ["slow", "normal", "fast"] } + } + }, + { + "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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 748a04b..3c396fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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