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 { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
|
||||||
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
|
||||||
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.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 { swapBsc, swapTrx, swapSol } from '../services/swap-orchestrator.service';
|
||||||
import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service';
|
import { getEvmFeeTiers, type FeeTier } from '../services/gas-oracle.service';
|
||||||
import { applyEvmTxPolicy } from '../lib/evm-tx-policy';
|
import { applyEvmTxPolicy } from '../lib/evm-tx-policy';
|
||||||
|
import { getRelayTrustedAddresses } from '../lib/relay-trusted-cache';
|
||||||
import { acquireSendLock } from '../lib/send-lock';
|
import { acquireSendLock } from '../lib/send-lock';
|
||||||
import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency';
|
import { claimIdempotency, saveIdempotencyResponse, extractIdempotencyKey } from '../lib/idempotency';
|
||||||
import { auditLog, auditLogStrict, completeAudit } from '../lib/audit-log';
|
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) +
|
// C1, C2, H2 — sign-raw policy: `to` allowlist (Relay routers — static + dynamic
|
||||||
// selector blacklist (approve/permit etc.) + value/gas caps. Throws on violation.
|
// cache из /relay/execute) + selector blacklist + value/gas caps.
|
||||||
|
// Dynamic cache позволяет авто-trust'ить новые Relay router'ы которые юзер только
|
||||||
|
// что увидел через /relay/execute (TTL 30 минут, set в Redis).
|
||||||
try {
|
try {
|
||||||
|
const dynamicTrusted = await getRelayTrustedAddresses(Number(chainId));
|
||||||
applyEvmTxPolicy({
|
applyEvmTxPolicy({
|
||||||
chainId: Number(chainId),
|
chainId: Number(chainId),
|
||||||
to,
|
to,
|
||||||
@@ -521,6 +525,7 @@ export const WalletController = {
|
|||||||
value: String(value),
|
value: String(value),
|
||||||
gas: String(gas),
|
gas: String(gas),
|
||||||
maxFeePerGas: String(maxFeePerGas),
|
maxFeePerGas: String(maxFeePerGas),
|
||||||
|
dynamicTrusted,
|
||||||
});
|
});
|
||||||
} catch (policyErr: any) {
|
} catch (policyErr: any) {
|
||||||
res.status(400).json({ success: false, error: policyErr.message });
|
res.status(400).json({ success: false, error: policyErr.message });
|
||||||
@@ -768,13 +773,33 @@ export const WalletController = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { transaction } = req.body ?? {};
|
// Body может быть в одном из двух форматов:
|
||||||
if (typeof transaction !== 'string' || transaction.length === 0 || transaction.length > 8192) {
|
// A) { transaction: '<base64>' } — pre-built VersionedTransaction (от Jupiter / Relay /execute если они вернули сериализованную tx)
|
||||||
res.status(400).json({ success: false, error: 'Invalid transaction (must be non-empty base64 string ≤8KB)' });
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^[A-Za-z0-9+/=]+$/.test(transaction)) {
|
if (hasTx && hasIxs) {
|
||||||
res.status(400).json({ success: false, error: 'Invalid base64 transaction' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,7 +837,9 @@ export const WalletController = {
|
|||||||
event: 'wallet.sign_sol_tx',
|
event: 'wallet.sign_sol_tx',
|
||||||
userId,
|
userId,
|
||||||
ip: req.ip || null,
|
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) {
|
} catch (auditErr: any) {
|
||||||
logger.error(`Audit DB INSERT MUST succeed: ${auditErr.message}`);
|
logger.error(`Audit DB INSERT MUST succeed: ${auditErr.message}`);
|
||||||
@@ -824,11 +851,20 @@ export const WalletController = {
|
|||||||
|
|
||||||
let result: { signature: string };
|
let result: { signature: string };
|
||||||
try {
|
try {
|
||||||
result = await signAndBroadcastSolanaTx({
|
if (hasTx) {
|
||||||
mnemonic,
|
result = await signAndBroadcastSolanaTx({
|
||||||
expectedFromAddress: wallet.address,
|
mnemonic,
|
||||||
serializedTransaction: transaction,
|
expectedFromAddress: wallet.address,
|
||||||
});
|
serializedTransaction: transaction,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await signAndBroadcastSolanaInstructions({
|
||||||
|
mnemonic,
|
||||||
|
expectedFromAddress: wallet.address,
|
||||||
|
instructions,
|
||||||
|
addressLookupTableAddresses,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (signErr: any) {
|
} catch (signErr: any) {
|
||||||
await completeAudit(auditId, 'failure', undefined, 'SOL_SIGN_FAILED');
|
await completeAudit(auditId, 'failure', undefined, 'SOL_SIGN_FAILED');
|
||||||
throw signErr;
|
throw signErr;
|
||||||
|
|||||||
@@ -18,16 +18,26 @@
|
|||||||
|
|
||||||
import { ethers } from 'ethers';
|
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>> = {
|
const RELAY_ROUTERS: Record<number, Set<string>> = {
|
||||||
// Ethereum mainnet — Relay router contracts (lowercase for canonical match)
|
// Ethereum mainnet — Relay router contracts (lowercase for canonical match)
|
||||||
1: new Set([
|
1: new Set([
|
||||||
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 ETH
|
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 (cross-chain bridge lock)
|
||||||
'0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router (legacy)
|
'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([
|
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;
|
value: string;
|
||||||
gas: string;
|
gas: string;
|
||||||
maxFeePerGas: 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'),
|
maxValueWei: ethers.utils.parseEther('100'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SELECTOR_APPROVE = '0x095ea7b3';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Применяет security policy. Throws if disallowed.
|
* Применяет 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 } {
|
export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string; flowKind: 'router-call' | 'approve-to-relay' } {
|
||||||
// 1. `to` validation — должен быть в Relay router allowlist для этого chainId
|
|
||||||
const toLower = p.to.toLowerCase();
|
const toLower = p.to.toLowerCase();
|
||||||
const routers = RELAY_ROUTERS[p.chainId];
|
const staticRouters = RELAY_ROUTERS[p.chainId];
|
||||||
if (!routers) {
|
if (!staticRouters) {
|
||||||
throw new Error(`Sign-raw policy: chainId ${p.chainId} not in router allowlist`);
|
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)
|
||||||
throw new Error(`Sign-raw policy: 'to' address ${p.to} not in Relay router allowlist for chainId ${p.chainId}`);
|
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;
|
let selectorName: string | undefined;
|
||||||
if (p.data.length >= 10) {
|
|
||||||
const selector = p.data.slice(0, 10).toLowerCase();
|
if (selector === SELECTOR_APPROVE) {
|
||||||
if (FORBIDDEN_SELECTORS[selector]) {
|
// ─── 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`);
|
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);
|
const gas = ethers.BigNumber.from(p.gas);
|
||||||
if (gas.gt(CAPS.maxGas)) {
|
if (gas.gt(CAPS.maxGas)) {
|
||||||
throw new Error(`Sign-raw policy: gas ${gas.toString()} exceeds cap ${CAPS.maxGas.toString()}`);
|
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)) {
|
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`);
|
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);
|
const value = ethers.BigNumber.from(p.value);
|
||||||
if (value.gt(CAPS.maxValueWei)) {
|
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`);
|
throw new Error(`Sign-raw policy: value ${ethers.utils.formatEther(value)} exceeds cap ${ethers.utils.formatEther(CAPS.maxValueWei)} native units`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
routerName: routers.has(toLower) ? `relay-router-${p.chainId}` : undefined,
|
routerName: flowKind === 'router-call' ? `relay-router-${p.chainId}` : `relay-approve-${p.chainId}`,
|
||||||
selectorName,
|
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 { logger } from '../lib/logger';
|
||||||
import { WalletModel } from '../models/wallet.model';
|
import { WalletModel } from '../models/wallet.model';
|
||||||
import type { ChainCode } from '../lib/address-validators';
|
import type { ChainCode } from '../lib/address-validators';
|
||||||
|
import { indexRelayExecuteResponse } from '../lib/relay-trusted-cache';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const RELAY_API_URL = 'https://api.relay.link';
|
const RELAY_API_URL = 'https://api.relay.link';
|
||||||
@@ -170,6 +171,21 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
|||||||
res.send(text);
|
res.send(text);
|
||||||
} catch {
|
} catch {
|
||||||
res.json({ success: false, error: 'Relay returned non-JSON' });
|
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) {
|
} catch (error: any) {
|
||||||
if (error?.name === 'AbortError') {
|
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 };
|
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> = {
|
const FLOOR_GWEI: Record<'ETH' | 'BSC', string> = {
|
||||||
ETH: '0.5',
|
ETH: '0',
|
||||||
BSC: '0.05',
|
BSC: '0.05',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -746,7 +746,9 @@ export async function swapTrx(
|
|||||||
// ─── SOL Jupiter ─────────────────────────────────────────────────────
|
// ─── SOL Jupiter ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
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;
|
let _solConnection: Connection | null = null;
|
||||||
function getSolConnection(): Connection {
|
function getSolConnection(): Connection {
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import * as bip39 from 'bip39';
|
|||||||
import { BIP32Factory } from 'bip32';
|
import { BIP32Factory } from 'bip32';
|
||||||
import * as ecc from 'tiny-secp256k1';
|
import * as ecc from 'tiny-secp256k1';
|
||||||
import * as bitcoin from 'bitcoinjs-lib';
|
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 {
|
import {
|
||||||
getAssociatedTokenAddressSync,
|
getAssociatedTokenAddressSync,
|
||||||
createAssociatedTokenAccountIdempotentInstruction,
|
createAssociatedTokenAccountIdempotentInstruction,
|
||||||
@@ -504,6 +507,117 @@ export async function signAndBroadcastSolanaTx(p: SignSolanaTxParams): Promise<{
|
|||||||
return { signature: sig };
|
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 ───
|
// ─── BITCOIN ───
|
||||||
|
|
||||||
async function sendBtc(p: SendParams): Promise<{ txid: string }> {
|
async function sendBtc(p: SendParams): Promise<{ txid: string }> {
|
||||||
|
|||||||
@@ -476,19 +476,60 @@
|
|||||||
},
|
},
|
||||||
"/wallets/SOL/sign-and-broadcast-tx": {
|
"/wallets/SOL/sign-and-broadcast-tx": {
|
||||||
"post": {
|
"post": {
|
||||||
"summary": "Custodial sign + broadcast arbitrary Solana VersionedTransaction",
|
"summary": "Custodial sign + broadcast Solana tx (2 формата body)",
|
||||||
"description": "Подписывает unsigned serialized Solana tx (от Relay /execute SOL-side, или любого aggregator'а). Server verify feePayer === user's pubkey, partial-sign keypair'ом, broadcast, confirm.",
|
"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"],
|
"tags": ["Wallet Ops"],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"oneOf": [
|
||||||
"required": ["transaction"],
|
{
|
||||||
"properties": {
|
"title": "Pre-built VersionedTransaction (Jupiter / Relay serialized)",
|
||||||
"transaction": { "type": "string", "description": "Base64-encoded VersionedTransaction (max ~1500 bytes raw)" }
|
"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",
|
"description": "Signed and broadcast",
|
||||||
"content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" }, "data": { "type": "object", "properties": { "signature": { "type": "string" }, "chain": { "type": "string" } } } } } } }
|
"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" },
|
"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