From e88ee3a55ff6b7baa255b74d08739778b4dd9ec2 Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Thu, 14 May 2026 14:41:03 +0300 Subject: [PATCH] swagger2 --- apps/api/src/controllers/wallet.controller.ts | 64 +++++++--- apps/api/src/lib/evm-tx-policy.ts | 100 +++++++++++---- apps/api/src/lib/relay-trusted-cache.ts | 96 +++++++++++++++ apps/api/src/routes/relay-proxy.routes.ts | 16 +++ apps/api/src/services/gas-oracle.service.ts | 8 +- .../src/services/swap-orchestrator.service.ts | 4 +- .../api/src/services/wallet-signer.service.ts | 116 +++++++++++++++++- apps/api/swagger.json | 59 +++++++-- 8 files changed, 415 insertions(+), 48 deletions(-) create mode 100644 apps/api/src/lib/relay-trusted-cache.ts diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index 5a5cecc..8f958be 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -6,10 +6,11 @@ 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, signAndBroadcastSolanaTx } from '../services/wallet-signer.service'; +import { signAndBroadcast, signAndBroadcastRawEvm, signAndBroadcastSolanaTx, signAndBroadcastSolanaInstructions } 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 { getRelayTrustedAddresses } from '../lib/relay-trusted-cache'; import { acquireSendLock } from '../lib/send-lock'; import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency'; import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log'; @@ -511,9 +512,12 @@ export const WalletController = { } } - // C1, C2, H2 — sign-raw policy: `to` allowlist (Relay routers only) + - // selector blacklist (approve/permit etc.) + value/gas caps. Throws on violation. + // C1, C2, H2 — sign-raw policy: `to` allowlist (Relay routers — static + dynamic + // cache из /relay/execute) + selector blacklist + value/gas caps. + // Dynamic cache позволяет авто-trust'ить новые Relay router'ы которые юзер только + // что увидел через /relay/execute (TTL 30 минут, set в Redis). try { + const dynamicTrusted = await getRelayTrustedAddresses(Number(chainId)); applyEvmTxPolicy({ chainId: Number(chainId), to, @@ -521,6 +525,7 @@ export const WalletController = { value: String(value), gas: String(gas), maxFeePerGas: String(maxFeePerGas), + dynamicTrusted, }); } catch (policyErr: any) { res.status(400).json({ success: false, error: policyErr.message }); @@ -768,13 +773,33 @@ export const WalletController = { 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)' }); + // Body может быть в одном из двух форматов: + // A) { transaction: '' } — pre-built VersionedTransaction (от Jupiter / Relay /execute если они вернули сериализованную tx) + // B) { instructions: [...], addressLookupTableAddresses?: [...] } — Relay SOL-bridge instructions (сервер сам compile'ит tx) + const { transaction, instructions, addressLookupTableAddresses } = req.body ?? {}; + const hasTx = typeof transaction === 'string'; + const hasIxs = Array.isArray(instructions); + + if (!hasTx && !hasIxs) { + res.status(400).json({ success: false, error: 'Body must contain either {transaction:""} or {instructions:[...]}' }); return; } - if (!/^[A-Za-z0-9+/=]+$/.test(transaction)) { - res.status(400).json({ success: false, error: 'Invalid base64 transaction' }); + if (hasTx && hasIxs) { + res.status(400).json({ success: false, error: 'Body must contain either transaction OR instructions, not both' }); + return; + } + if (hasTx) { + if (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; + } + } + if (hasIxs && addressLookupTableAddresses !== undefined && !Array.isArray(addressLookupTableAddresses)) { + res.status(400).json({ success: false, error: 'addressLookupTableAddresses must be an array of strings' }); return; } @@ -812,7 +837,9 @@ export const WalletController = { event: 'wallet.sign_sol_tx', userId, ip: req.ip || null, - meta: { chain: 'SOL', txLength: transaction.length }, + meta: hasTx + ? { chain: 'SOL', mode: 'serialized', txLength: transaction.length } + : { chain: 'SOL', mode: 'instructions', count: instructions.length }, }); } catch (auditErr: any) { logger.error(`Audit DB INSERT MUST succeed: ${auditErr.message}`); @@ -824,11 +851,20 @@ export const WalletController = { let result: { signature: string }; try { - result = await signAndBroadcastSolanaTx({ - mnemonic, - expectedFromAddress: wallet.address, - serializedTransaction: transaction, - }); + if (hasTx) { + result = await signAndBroadcastSolanaTx({ + mnemonic, + expectedFromAddress: wallet.address, + serializedTransaction: transaction, + }); + } else { + result = await signAndBroadcastSolanaInstructions({ + mnemonic, + expectedFromAddress: wallet.address, + instructions, + addressLookupTableAddresses, + }); + } } catch (signErr: any) { await completeAudit(auditId, 'failure', undefined, 'SOL_SIGN_FAILED'); throw signErr; diff --git a/apps/api/src/lib/evm-tx-policy.ts b/apps/api/src/lib/evm-tx-policy.ts index 3619e81..49d9c78 100644 --- a/apps/api/src/lib/evm-tx-policy.ts +++ b/apps/api/src/lib/evm-tx-policy.ts @@ -18,16 +18,26 @@ import { ethers } from 'ethers'; -/** Relay-protocol router/depository contract addresses per chainId. */ +/** Relay-protocol router/depository contract addresses per chainId. + * + * Relay deploys new router contracts периодически (несколько раз в год). + * Если запрос к /sign-raw-evm-tx падает с "not in allowlist" — посмотри `to` адрес + * в Relay /execute response и добавь сюда. Relay использует deterministic deployer, + * так что один и тот же router обычно деплоится на ETH и BSC с тем же адресом. + * + * Полный список: https://docs.relay.link/references/contract-addresses + */ const RELAY_ROUTERS: Record> = { // Ethereum mainnet — Relay router contracts (lowercase for canonical match) 1: new Set([ - '0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 ETH - '0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router (legacy) + '0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 (cross-chain bridge lock) + '0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router v1 (legacy intra-chain entry point) + '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', // Relay Router v2 (current intra-chain swap entry point, since ~2025) ]), - // BSC mainnet + // BSC mainnet (Relay использует тот же deterministic-deployed address для router v2) 56: new Set([ - '0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 BSC + '0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 + '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', // Relay Router v2 (deterministic deploy) ]), }; @@ -49,6 +59,13 @@ export interface PolicyParams { value: string; gas: string; maxFeePerGas: string; + /** + * Dynamic trusted addresses из Redis cache (`relay-trusted:{chainId}`). + * Объединяются с статическим `RELAY_ROUTERS[chainId]` whitelist'ом. + * Каждое /relay/execute response добавляет туда `to` + approve spender'ы. + * Если caller не передаёт (legacy) — используется только static whitelist. + */ + dynamicTrusted?: Set; } /** @@ -67,32 +84,74 @@ const CAPS = { maxValueWei: ethers.utils.parseEther('100'), }; +const SELECTOR_APPROVE = '0x095ea7b3'; + /** * Применяет security policy. Throws if disallowed. * - * Возвращает result-обoject для info-логирования (matched router name, selector name). + * Два разрешённых пути: + * + * **A) Прямой call к Relay router:** + * `to` ∈ RELAY_ROUTERS[chainId] AND selector ∉ FORBIDDEN_SELECTORS + * Используется для основной swap/bridge tx через Relay. + * + * **B) Approve к Relay router:** + * selector == approve(address,uint256) AND + * spender (первый параметр approve) ∈ RELAY_ROUTERS[chainId] + * `to` может быть любым ERC20 token контрактом (USDT/USDC/etc). + * Используется для первого шага в multi-step Relay swap (token → X). + * Защита: attacker не может через sign-raw сделать approve на свой контракт — + * spender обязан быть Relay router из whitelist. + * + * Возвращает info-объект для логов. */ -export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string } { - // 1. `to` validation — должен быть в Relay router allowlist для этого chainId +export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string; flowKind: 'router-call' | 'approve-to-relay' } { const toLower = p.to.toLowerCase(); - const routers = RELAY_ROUTERS[p.chainId]; - if (!routers) { + const staticRouters = RELAY_ROUTERS[p.chainId]; + if (!staticRouters) { throw new Error(`Sign-raw policy: chainId ${p.chainId} not in router allowlist`); } - if (!routers.has(toLower)) { - throw new Error(`Sign-raw policy: 'to' address ${p.to} not in Relay router allowlist for chainId ${p.chainId}`); - } + // Combined trust set = static whitelist ∪ dynamic cache (from /relay/execute responses) + const isTrusted = (addr: string): boolean => + staticRouters.has(addr) || (p.dynamicTrusted?.has(addr) ?? false); - // 2. Selector blacklist — `approve()` etc. никогда не подписывается + const selector = p.data.length >= 10 ? p.data.slice(0, 10).toLowerCase() : ''; + let flowKind: 'router-call' | 'approve-to-relay'; let selectorName: string | undefined; - if (p.data.length >= 10) { - const selector = p.data.slice(0, 10).toLowerCase(); - if (FORBIDDEN_SELECTORS[selector]) { + + if (selector === SELECTOR_APPROVE) { + // ─── Path B: approve(spender, amount), spender must be Relay router ─── + flowKind = 'approve-to-relay'; + selectorName = 'approve(address,uint256)'; + // calldata layout: 4-byte selector + 32-byte spender + 32-byte amount = 68 bytes = 136 hex + '0x' + if (p.data.length < 138) { + throw new Error('Sign-raw policy: malformed approve calldata (too short)'); + } + // spender = lower 20 bytes of first 32-byte parameter (left-padded with zeros) + const spenderHex = '0x' + p.data.slice(10 + 24, 10 + 64).toLowerCase(); + if (!isTrusted(spenderHex)) { + throw new Error(`Sign-raw policy: approve spender ${spenderHex} not in Relay router allowlist for chainId ${p.chainId}`); + } + // `to` (token contract) может быть любым — это разрешённый flow. + // value для approve() должен быть 0 (стандартный ERC20 approve не принимает value) + if (p.value !== '0' && p.value !== '0x0' && ethers.BigNumber.from(p.value).gt(0)) { + throw new Error('Sign-raw policy: approve() with non-zero value rejected'); + } + } else { + // ─── Path A: direct call to Relay router ─── + flowKind = 'router-call'; + if (!isTrusted(toLower)) { + throw new Error(`Sign-raw policy: 'to' address ${p.to} not in Relay router allowlist for chainId ${p.chainId}`); + } + // Forbidden selectors (drain vectors) проверяются ТОЛЬКО для router-call path — + // потому что для approve у нас отдельный (более строгий) check на spender выше. + if (selector && FORBIDDEN_SELECTORS[selector]) { throw new Error(`Sign-raw policy: forbidden selector ${selector} (${FORBIDDEN_SELECTORS[selector]}) — drain vector`); } + selectorName = selector ? `selector ${selector}` : undefined; } - // 3. gas caps + // ─── Common caps (для обоих путей) ─── const gas = ethers.BigNumber.from(p.gas); if (gas.gt(CAPS.maxGas)) { throw new Error(`Sign-raw policy: gas ${gas.toString()} exceeds cap ${CAPS.maxGas.toString()}`); @@ -102,15 +161,14 @@ export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; select if (budget.gt(CAPS.maxGasBudgetWei)) { throw new Error(`Sign-raw policy: gas budget ${ethers.utils.formatEther(budget)} ETH exceeds cap ${ethers.utils.formatEther(CAPS.maxGasBudgetWei)} ETH`); } - - // 4. value cap const value = ethers.BigNumber.from(p.value); if (value.gt(CAPS.maxValueWei)) { throw new Error(`Sign-raw policy: value ${ethers.utils.formatEther(value)} exceeds cap ${ethers.utils.formatEther(CAPS.maxValueWei)} native units`); } return { - routerName: routers.has(toLower) ? `relay-router-${p.chainId}` : undefined, + routerName: flowKind === 'router-call' ? `relay-router-${p.chainId}` : `relay-approve-${p.chainId}`, selectorName, + flowKind, }; } diff --git a/apps/api/src/lib/relay-trusted-cache.ts b/apps/api/src/lib/relay-trusted-cache.ts new file mode 100644 index 0000000..9487b38 --- /dev/null +++ b/apps/api/src/lib/relay-trusted-cache.ts @@ -0,0 +1,96 @@ +/** + * Dynamic cache "trusted EVM addresses from Relay /execute responses". + * + * Каждый раз когда юзер делает /relay/execute/* → ответ Relay содержит unsigned tx'ы + * (`steps[].items[].data.to` + selector/parameter в `data` для approve). + * Эти адреса — официальные от Relay (т.к. идут через наш proxy к api.relay.link), + * безопасно довериться им для последующего sign-raw-evm-tx за короткое окно. + * + * Хранилище: KeyDB Redis set `relay-trusted:{chainId}` с TTL 30 минут. + * При sign-raw-evm-tx `applyEvmTxPolicy` объединяет static whitelist + этот cache. + * + * Защита от drain: + * - addresses попадают в cache ТОЛЬКО через /relay/execute response (наш proxy fetch'ит + * api.relay.link — компрометированный upstream может потенциально подсунуть свой + * адрес, но Relay уже trusted в security-модели; если они скомпрометированы, + * мы тоже). + * - TTL 30 минут — addresses сами протухают. + * - Set deduplicates, размер каждого set'а ≤ ~50 (Relay routers стабильные). + */ + +import { getRedis } from '../config/redis'; +import { logger } from './logger'; + +const CACHE_TTL_SECONDS = 30 * 60; // 30 минут +const SELECTOR_APPROVE = '0x095ea7b3'; +const SET_PREFIX = 'relay-trusted:'; + +/** + * Parse Relay execute response и записать обнаруженные EVM-адреса в KeyDB set. + * Не-EVM steps (chainId не указан / 0 / SOL=792703809) скипаем. + */ +export async function indexRelayExecuteResponse(payload: unknown): Promise { + try { + if (!payload || typeof payload !== 'object') return; + const steps = (payload as any).steps; + if (!Array.isArray(steps)) return; + + // Группируем addresses по chainId — один pipeline на chain + const perChain = new Map>(); + for (const step of steps) { + const items = step?.items; + if (!Array.isArray(items)) continue; + for (const item of items) { + const data = item?.data; + if (!data || typeof data !== 'object') continue; + const chainId = Number(data.chainId); + if (!Number.isFinite(chainId) || chainId <= 0) continue; + // Skip non-EVM (SOL = 792703809, и т.п.) — у них не EVM `to`/`data`. + if (chainId > 1_000_000) continue; + + const set = perChain.get(chainId) ?? new Set(); + perChain.set(chainId, set); + + // 1) сам `to` контракт + const to = String(data.to || '').toLowerCase(); + if (/^0x[0-9a-f]{40}$/.test(to)) set.add(to); + + // 2) approve spender — если selector approve, parse first param + const calldata = String(data.data || '').toLowerCase(); + if (calldata.startsWith(SELECTOR_APPROVE) && calldata.length >= 138) { + const spender = '0x' + calldata.slice(10 + 24, 10 + 64); + if (/^0x[0-9a-f]{40}$/.test(spender)) set.add(spender); + } + } + } + + if (perChain.size === 0) return; + + const redis = getRedis(); + const pipeline = redis.pipeline(); + for (const [chainId, addrs] of perChain.entries()) { + const key = `${SET_PREFIX}${chainId}`; + pipeline.sadd(key, ...Array.from(addrs)); + pipeline.expire(key, CACHE_TTL_SECONDS); + } + await pipeline.exec(); + } catch (err: any) { + // Не валим запрос — это enrichment, основной flow продолжается + logger.warn(`indexRelayExecuteResponse skipped: ${err?.message || 'unknown'}`); + } +} + +/** + * Получить set trusted addresses для chainId. Никогда не throws. + * Возвращает пустой Set если cache недоступен / пустой. + */ +export async function getRelayTrustedAddresses(chainId: number): Promise> { + try { + const redis = getRedis(); + const members = await redis.smembers(`${SET_PREFIX}${chainId}`); + return new Set(members.map((m) => m.toLowerCase())); + } catch (err: any) { + logger.warn(`getRelayTrustedAddresses(${chainId}) failed: ${err?.message || 'unknown'}`); + return new Set(); + } +} diff --git a/apps/api/src/routes/relay-proxy.routes.ts b/apps/api/src/routes/relay-proxy.routes.ts index 5bd9a88..ebf1b86 100644 --- a/apps/api/src/routes/relay-proxy.routes.ts +++ b/apps/api/src/routes/relay-proxy.routes.ts @@ -3,6 +3,7 @@ import { env } from '../config/env'; import { logger } from '../lib/logger'; import { WalletModel } from '../models/wallet.model'; import type { ChainCode } from '../lib/address-validators'; +import { indexRelayExecuteResponse } from '../lib/relay-trusted-cache'; const router = Router(); const RELAY_API_URL = 'https://api.relay.link'; @@ -170,6 +171,21 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction res.send(text); } catch { res.json({ success: false, error: 'Relay returned non-JSON' }); + return; + } + + // Indexing trusted Relay addresses в KeyDB (для последующего sign-raw-evm-tx). + // Только для /execute/* — там steps[].items[].data.to/data парсятся. + // Fire-and-forget — не блокирует response. + if (req.method === 'POST' && relayPath.startsWith('/execute/')) { + try { + const parsed = JSON.parse(text); + indexRelayExecuteResponse(parsed).catch((err) => + logger.warn(`indexRelayExecuteResponse error (ignored): ${err?.message || 'unknown'}`), + ); + } catch { + // ignore — already shipped response к юзеру + } } } catch (error: any) { if (error?.name === 'AbortError') { diff --git a/apps/api/src/services/gas-oracle.service.ts b/apps/api/src/services/gas-oracle.service.ts index c9fc707..d538c37 100644 --- a/apps/api/src/services/gas-oracle.service.ts +++ b/apps/api/src/services/gas-oracle.service.ts @@ -24,9 +24,13 @@ const BSC_RPC = 'https://bsc-dataseed.binance.org'; const RPC: Record<'ETH' | 'BSC', string> = { ETH: ETH_RPC, BSC: BSC_RPC }; -// Realistic mainnet floors (gwei). +// Realistic mainnet floors (gwei). 0 = without floor (use raw eth_feeHistory value). +// ETH: убран floor — eth_feeHistory сам по себе репрезентативный, искусственный floor +// перерасходовал gas в spam/low-traffic блоках. +// BSC: оставлен низкий floor — chain не полностью EIP-1559, без минимума получается +// 0.001 gwei который reject'ится min-relay. const FLOOR_GWEI: Record<'ETH' | 'BSC', string> = { - ETH: '0.5', + ETH: '0', BSC: '0.05', }; diff --git a/apps/api/src/services/swap-orchestrator.service.ts b/apps/api/src/services/swap-orchestrator.service.ts index 4f1fc2e..6991d77 100644 --- a/apps/api/src/services/swap-orchestrator.service.ts +++ b/apps/api/src/services/swap-orchestrator.service.ts @@ -746,7 +746,9 @@ export async function swapTrx( // ─── SOL Jupiter ───────────────────────────────────────────────────── const SOL_RPC = 'https://api.mainnet-beta.solana.com'; -const JUPITER_API = 'https://quote-api.jup.ag/v6'; +// Jupiter migrated в 2025: старый `quote-api.jup.ag/v6` deprecated и DNS удалён. +// `lite-api.jup.ag/swap/v1` — public anonymous endpoint (~600 req/min), JSON-schema идентична. +const JUPITER_API = 'https://lite-api.jup.ag/swap/v1'; let _solConnection: Connection | null = null; function getSolConnection(): Connection { diff --git a/apps/api/src/services/wallet-signer.service.ts b/apps/api/src/services/wallet-signer.service.ts index b5d8144..0ecf7f6 100644 --- a/apps/api/src/services/wallet-signer.service.ts +++ b/apps/api/src/services/wallet-signer.service.ts @@ -14,7 +14,10 @@ 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, VersionedTransaction } from '@solana/web3.js'; +import { + Keypair, Connection, PublicKey, SystemProgram, Transaction, ComputeBudgetProgram, + VersionedTransaction, TransactionMessage, AddressLookupTableAccount, TransactionInstruction, +} from '@solana/web3.js'; import { getAssociatedTokenAddressSync, createAssociatedTokenAccountIdempotentInstruction, @@ -504,6 +507,117 @@ export async function signAndBroadcastSolanaTx(p: SignSolanaTxParams): Promise<{ return { signature: sig }; } +/** Relay-style SOL step body: instructions[] + addressLookupTableAddresses[]. + * + * Когда Relay execute returns SOL bridge tx, format не serialized — а instructions array + * с program IDs, keys, и calldata. Серверу нужно: + * 1. Validate каждый isSigner=true key === user's SOL pubkey (мы можем подписать только за себя) + * 2. Resolve address lookup tables через SOL RPC (Relay не передаёт сами таблицы, только адреса) + * 3. Fetch latest blockhash + * 4. Build VersionedTransaction с feePayer=user + * 5. Sign + broadcast + */ +export interface SignSolanaInstructionsParams { + mnemonic: string; + expectedFromAddress: string; + instructions: Array<{ + programId: string; + keys: Array<{ pubkey: string; isSigner: boolean; isWritable: boolean }>; + data: string; // hex (no 0x prefix) или base64 — autodetect + }>; + addressLookupTableAddresses?: string[]; +} + +export async function signAndBroadcastSolanaInstructions( + p: SignSolanaInstructionsParams, +): 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'); + const userPubkey = keypair.publicKey; + + if (!Array.isArray(p.instructions) || p.instructions.length === 0) { + throw new Error('SOL instructions: empty array'); + } + if (p.instructions.length > 32) { + throw new Error('SOL instructions: too many (max 32)'); + } + + // ─── Build TransactionInstruction[] из Relay-style objects ─── + const ixs: TransactionInstruction[] = []; + for (const raw of p.instructions) { + if (!raw || typeof raw !== 'object') throw new Error('SOL instruction: not an object'); + let programId: PublicKey; + try { programId = new PublicKey(raw.programId); } catch { throw new Error(`SOL invalid programId: ${raw.programId}`); } + + if (!Array.isArray(raw.keys)) throw new Error('SOL instruction.keys must be array'); + const keys = raw.keys.map((k, idx) => { + let pubkey: PublicKey; + try { pubkey = new PublicKey(k.pubkey); } catch { throw new Error(`SOL invalid pubkey at keys[${idx}]: ${k.pubkey}`); } + // SECURITY: any signer-key must be userPubkey (мы можем подписать только за себя) + if (k.isSigner && !pubkey.equals(userPubkey)) { + throw new Error(`SOL instruction has signer key ${k.pubkey} ≠ user ${userPubkey.toBase58()}`); + } + return { pubkey, isSigner: Boolean(k.isSigner), isWritable: Boolean(k.isWritable) }; + }); + + // data: hex или base64? Relay обычно отдаёт hex без префикса. + let data: Buffer; + const dStr = String(raw.data || ''); + if (/^[0-9a-fA-F]*$/.test(dStr) && dStr.length % 2 === 0) { + data = Buffer.from(dStr, 'hex'); + } else { + try { data = Buffer.from(dStr, 'base64'); } catch { throw new Error('SOL instruction.data: not hex or base64'); } + } + ixs.push(new TransactionInstruction({ programId, keys, data })); + } + + const conn = getSolConnection(); + + // ─── Resolve address lookup tables через RPC ─── + const luts: AddressLookupTableAccount[] = []; + for (const lutAddr of (p.addressLookupTableAddresses ?? [])) { + let lutPk: PublicKey; + try { lutPk = new PublicKey(lutAddr); } catch { throw new Error(`SOL invalid LUT address: ${lutAddr}`); } + const acc = await conn.getAddressLookupTable(lutPk); + if (!acc.value) throw new Error(`SOL LUT not found on-chain: ${lutAddr}`); + luts.push(acc.value); + } + + // ─── Compile VersionedTransaction ─── + const latestBlock = await conn.getLatestBlockhash(); + const msg = new TransactionMessage({ + payerKey: userPubkey, + recentBlockhash: latestBlock.blockhash, + instructions: ixs, + }).compileToV0Message(luts); + + const tx = new VersionedTransaction(msg); + tx.sign([keypair]); + + // ─── Broadcast + confirm ─── + const sig = await conn.sendRawTransaction(tx.serialize()); + try { + 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 Relay-bridge tx EXPIRED. sig=${sig}`); + } + throw new Error(`SOL Relay-bridge confirm error (${name}): ${err.message}. sig=${sig}`); + } + + return { signature: sig }; +} + // ─── BITCOIN ─── async function sendBtc(p: SendParams): Promise<{ txid: string }> { diff --git a/apps/api/swagger.json b/apps/api/swagger.json index b9f1b2e..f1c7f89 100644 --- a/apps/api/swagger.json +++ b/apps/api/swagger.json @@ -476,19 +476,60 @@ }, "/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.", + "summary": "Custodial sign + broadcast Solana tx (2 формата body)", + "description": "Custodial sign + broadcast Solana tx. **Два формата body:**\n\n(a) `{ transaction: '' }` — pre-built VersionedTransaction (Jupiter swap, Relay serialized).\n\n(b) `{ instructions[], addressLookupTableAddresses[]? }` — Relay SOL bridge instructions. Server compile'ит `TransactionMessage` → `VersionedTransaction` с `feePayer = user`.\n\n**Security:** валидирует что каждый `isSigner=true` key равен derived user SOL pubkey, resolve LUTs через RPC, 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)" } - } + "oneOf": [ + { + "title": "Pre-built VersionedTransaction (Jupiter / Relay serialized)", + "type": "object", + "required": ["transaction"], + "properties": { + "transaction": { "type": "string", "description": "Base64-encoded VersionedTransaction (max ~8KB)" } + } + }, + { + "title": "Relay-style instructions (для SOL bridge)", + "type": "object", + "required": ["instructions"], + "properties": { + "instructions": { + "type": "array", + "description": "Array из {programId, keys, data}. Server compile'ит TransactionMessage → VersionedTransaction с feePayer=user.", + "items": { + "type": "object", + "required": ["programId", "keys", "data"], + "properties": { + "programId": { "type": "string", "description": "SPL program pubkey (base58)" }, + "keys": { + "type": "array", + "items": { + "type": "object", + "required": ["pubkey", "isSigner", "isWritable"], + "properties": { + "pubkey": { "type": "string", "description": "Account pubkey (base58)" }, + "isSigner": { "type": "boolean", "description": "Если true — pubkey ДОЛЖЕН равняться user'у (anti-drain)" }, + "isWritable": { "type": "boolean" } + } + } + }, + "data": { "type": "string", "description": "Instruction data: hex (без префикса) или base64 — autodetect" } + } + } + }, + "addressLookupTableAddresses": { + "type": "array", + "items": { "type": "string" }, + "description": "Опционально. SPL Address Lookup Table accounts которые server разрезолвит через SOL RPC (getAddressLookupTable)." + } + } + } + ] } } } @@ -498,9 +539,9 @@ "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" }, + "400": { "description": "Invalid body / feePayer mismatch / signer-key mismatch / malformed instruction" }, "404": { "description": "SOL wallet/mnemonic not found" }, - "502": { "description": "Sign or broadcast failed" } + "502": { "description": "Sign or broadcast failed (включая RPC ошибки / blockhash expired / on-chain revert)" } } } },