This commit is contained in:
ZOMBIIIIIII
2026-05-14 14:41:03 +03:00
parent 53635806d6
commit e88ee3a55f
8 changed files with 415 additions and 48 deletions

View File

@@ -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,8 +773,23 @@ export const WalletController = {
return;
}
const { transaction } = req.body ?? {};
if (typeof transaction !== 'string' || transaction.length === 0 || transaction.length > 8192) {
// Body может быть в одном из двух форматов:
// A) { transaction: '<base64>' } — 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:"<base64>"} or {instructions:[...]}' });
return;
}
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;
}
@@ -777,6 +797,11 @@ export const WalletController = {
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;
}
const idempKey = extractIdempotencyKey(req.headers['idempotency-key']);
if (idempKey) {
@@ -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 {
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;

View File

@@ -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<number, Set<string>> = {
// 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<string>;
}
/**
@@ -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)) {
// Combined trust set = static whitelist dynamic cache (from /relay/execute responses)
const isTrusted = (addr: string): boolean =>
staticRouters.has(addr) || (p.dynamicTrusted?.has(addr) ?? false);
const selector = p.data.length >= 10 ? p.data.slice(0, 10).toLowerCase() : '';
let flowKind: 'router-call' | 'approve-to-relay';
let selectorName: string | undefined;
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}`);
}
// 2. Selector blacklist — `approve()` etc. никогда не подписывается
let selectorName: string | undefined;
if (p.data.length >= 10) {
const selector = p.data.slice(0, 10).toLowerCase();
if (FORBIDDEN_SELECTORS[selector]) {
// 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,
};
}

View File

@@ -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<void> {
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<number, Set<string>>();
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<string>();
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<Set<string>> {
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();
}
}

View File

@@ -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') {

View File

@@ -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',
};

View File

@@ -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 {

View File

@@ -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 }> {

View File

@@ -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: '<base64>' }` — 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": {
"oneOf": [
{
"title": "Pre-built VersionedTransaction (Jupiter / Relay serialized)",
"type": "object",
"required": ["transaction"],
"properties": {
"transaction": { "type": "string", "description": "Base64-encoded VersionedTransaction (max ~1500 bytes raw)" }
"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)" }
}
}
},