swagger2
This commit is contained in:
@@ -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: '<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 (!/^[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;
|
||||
|
||||
@@ -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)) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
96
apps/api/src/lib/relay-trusted-cache.ts
Normal file
96
apps/api/src/lib/relay-trusted-cache.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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": {
|
||||
"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)" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user