init
This commit is contained in:
854
apps/api/src/services/wallet-signer-bridge.ts
Normal file
854
apps/api/src/services/wallet-signer-bridge.ts
Normal file
@@ -0,0 +1,854 @@
|
||||
/**
|
||||
* Bridge-specific signers/helpers — отдельный файл чтобы не разрастать `wallet-signer.service.ts`.
|
||||
*
|
||||
* Чем отличается от обычного signAndBroadcast:
|
||||
* - EVM `signAndBroadcastEvmApprove` — ERC20.approve(spender, amount) для bridge router'а;
|
||||
* включает wait 1 conf чтобы next tx видел свежий allowance.
|
||||
* - `readErc20Allowance` — direct view call (без подписи) для pre-check.
|
||||
* - TRX/BTC — bridge-specific path для unsigned tx от Relay/LiFi.
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import * as bip39 from 'bip39';
|
||||
import { BIP32Factory } from 'bip32';
|
||||
import * as ecc from 'tiny-secp256k1';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { env } from '../config/env';
|
||||
import { DERIVATION_PATHS, ethAddressToTron } from './wallet-generator.service';
|
||||
import { pickProxiedEvmProvider } from '../lib/outbound-proxy';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
|
||||
const ETH_RPCS = [
|
||||
'https://ethereum-rpc.publicnode.com',
|
||||
'https://eth.llamarpc.com',
|
||||
'https://rpc.ankr.com/eth',
|
||||
];
|
||||
const BSC_RPCS = [
|
||||
'https://bsc-dataseed.binance.org',
|
||||
'https://bsc-dataseed1.binance.org',
|
||||
'https://bsc-dataseed2.binance.org',
|
||||
'https://bsc.publicnode.com',
|
||||
];
|
||||
|
||||
const TRONGRID = 'https://api.trongrid.io';
|
||||
const BLOCKSTREAM = 'https://blockstream.info/api';
|
||||
|
||||
const HTTP_TIMEOUT_MS = 20_000;
|
||||
const APPROVE_GAS_LIMIT = 80_000; // EIP-2 approve ~50k базовая + overhead
|
||||
const MAX_GAS_PRICE_GWEI = 500;
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function approve(address spender, uint256 amount) returns (bool)',
|
||||
'function allowance(address owner, address spender) view returns (uint256)',
|
||||
'function balanceOf(address owner) view returns (uint256)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Структурированная ошибка для balance pre-check. Controller'ы маппят `code === 'INSUFFICIENT_BALANCE'`
|
||||
* в HTTP 400 с human-readable message.
|
||||
*/
|
||||
export class InsufficientBalanceError extends Error {
|
||||
code = 'INSUFFICIENT_BALANCE' as const;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InsufficientBalanceError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Структурированная ошибка для pre-broadcast simulation revert. Controller'ы маппят
|
||||
* `code === 'SIMULATION_FAILED'` в HTTP 400. Поскольку simulation НЕ broadcast'ит — fees
|
||||
* пользователя не сгорают.
|
||||
*/
|
||||
export class BridgeSimulationError extends Error {
|
||||
code = 'SIMULATION_FAILED' as const;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'BridgeSimulationError';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EVM helpers ──────────────────────────────────────────────────────
|
||||
|
||||
export interface SignEvmApproveParams {
|
||||
chain: 'ETH' | 'BSC';
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
spender: string; // bridge router address (LiFi diamond / Relay router)
|
||||
token: string; // ERC20 contract address
|
||||
amount: string; // exact approve amount (smallest units, decimal string)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign + broadcast ERC20.approve(spender, amount). Waits 1 confirmation
|
||||
* перед return — bridge tx сразу следующий видит свежий allowance.
|
||||
*/
|
||||
export async function signAndBroadcastEvmApprove(p: SignEvmApproveParams): Promise<{ txid: string }> {
|
||||
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH);
|
||||
if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) {
|
||||
throw new Error(`Derived ${p.chain} address ${wallet.address} ≠ stored ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
|
||||
const signer = wallet.connect(provider);
|
||||
const token = new ethers.Contract(p.token, ERC20_ABI, signer);
|
||||
|
||||
// Fee tier: используем provider.getFeeData() — это OK для approve (low priority).
|
||||
const feeData = await provider.getFeeData();
|
||||
const capWei = ethers.utils.parseUnits(String(MAX_GAS_PRICE_GWEI), 'gwei');
|
||||
let maxFeePerGas = feeData.maxFeePerGas ?? ethers.utils.parseUnits('30', 'gwei');
|
||||
let maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.utils.parseUnits('1', 'gwei');
|
||||
if (maxFeePerGas.gt(capWei)) maxFeePerGas = capWei;
|
||||
if (maxPriorityFeePerGas.gt(maxFeePerGas)) maxPriorityFeePerGas = maxFeePerGas;
|
||||
|
||||
const nonce = await provider.getTransactionCount(wallet.address, 'pending');
|
||||
const data = token.interface.encodeFunctionData('approve', [p.spender, ethers.BigNumber.from(p.amount)]);
|
||||
|
||||
const sent = await signer.sendTransaction({
|
||||
to: p.token,
|
||||
data,
|
||||
value: 0,
|
||||
chainId: expectedChainId,
|
||||
nonce,
|
||||
gasLimit: APPROVE_GAS_LIMIT,
|
||||
type: 2,
|
||||
maxFeePerGas,
|
||||
maxPriorityFeePerGas,
|
||||
});
|
||||
// Wait 1 conf чтобы bridge tx (next) видел updated allowance
|
||||
await Promise.race([
|
||||
sent.wait(1),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('approve confirm timeout')), 60_000)),
|
||||
]);
|
||||
logger.info(`EVM approve confirmed: chain=${p.chain} token=${p.token} spender=${p.spender} amount=${p.amount} txid=${sent.hash}`);
|
||||
return { txid: sent.hash };
|
||||
}
|
||||
|
||||
export interface ReadErc20AllowanceParams {
|
||||
chain: 'ETH' | 'BSC';
|
||||
token: string;
|
||||
owner: string;
|
||||
spender: string;
|
||||
}
|
||||
|
||||
export async function readErc20Allowance(p: ReadErc20AllowanceParams): Promise<bigint> {
|
||||
const expectedChainId = p.chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = p.chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
const provider = await pickProxiedEvmProvider(rpcs, expectedChainId);
|
||||
const token = new ethers.Contract(p.token, ERC20_ABI, provider);
|
||||
const res: ethers.BigNumber = await token.allowance(p.owner, p.spender);
|
||||
return BigInt(res.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read EVM native balance (BNB / ETH) for an address. Smallest units (wei) as bigint.
|
||||
*/
|
||||
export async function readEvmNativeBalance(chain: 'ETH' | 'BSC', address: string): Promise<bigint> {
|
||||
const chainId = chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
const provider = await pickProxiedEvmProvider(rpcs, chainId);
|
||||
const bal: ethers.BigNumber = await provider.getBalance(address);
|
||||
return BigInt(bal.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ERC20 token balance (USDT / USDC / etc.) for an address. Smallest units as bigint.
|
||||
*/
|
||||
export async function readErc20Balance(chain: 'ETH' | 'BSC', token: string, owner: string): Promise<bigint> {
|
||||
const chainId = chain === 'ETH' ? 1 : 56;
|
||||
const rpcs = chain === 'ETH' ? ETH_RPCS : BSC_RPCS;
|
||||
const provider = await pickProxiedEvmProvider(rpcs, chainId);
|
||||
const c = new ethers.Contract(token, ERC20_ABI, provider);
|
||||
const res: ethers.BigNumber = await c.balanceOf(owner);
|
||||
return BigInt(res.toString());
|
||||
}
|
||||
|
||||
// ─── SOL balance helpers ──────────────────────────────────────────────
|
||||
|
||||
const SOL_RPC = 'https://api.mainnet-beta.solana.com';
|
||||
|
||||
/**
|
||||
* Read SOL native balance in lamports. Используется для bridge-execute pre-check
|
||||
* чтобы сразу отвергать "insufficient lamports" simulation errors с человеческим message.
|
||||
*/
|
||||
export async function readSolBalance(address: string): Promise<bigint> {
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getBalance',
|
||||
params: [address],
|
||||
});
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const lamports = res?.result?.value;
|
||||
if (typeof lamports !== 'number') {
|
||||
throw new Error(`SOL balance read failed: ${JSON.stringify(res).slice(0, 200)}`);
|
||||
}
|
||||
return BigInt(lamports);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read SPL token balance (USDC/USDT/...) для SOL owner. Returns smallest units.
|
||||
* Если token account не существует (юзер ни разу не получал token) — возвращает 0n.
|
||||
*/
|
||||
export async function readSplTokenBalance(owner: string, mint: string): Promise<bigint> {
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'getTokenAccountsByOwner',
|
||||
params: [owner, { mint }, { encoding: 'jsonParsed' }],
|
||||
});
|
||||
const res = await fetchJson(SOL_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const accounts = res?.result?.value || [];
|
||||
let total = 0n;
|
||||
for (const acc of accounts) {
|
||||
const raw = acc?.account?.data?.parsed?.info?.tokenAmount?.amount;
|
||||
if (typeof raw === 'string') total += BigInt(raw);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// ─── TRX bridge helpers ───────────────────────────────────────────────
|
||||
|
||||
export interface SignRawTronParams {
|
||||
mnemonic: string;
|
||||
expectedFromAddress: string;
|
||||
contractAddress: string; // TRC20 token / LiFi router (T...base58)
|
||||
callData: string; // hex calldata (без 0x ИЛИ с 0x — нормализуем)
|
||||
callValue: bigint; // TRX amount в sun (0 для most contract calls)
|
||||
feeLimit: number; // максимум sun сжигается на energy/bandwidth (typical 30-150 TRX)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign + broadcast arbitrary Tron smart-contract call. Используется для bridge'а
|
||||
* через LiFi/Jumper (которые возвращают raw contract call для TRC20 token approve / bridge).
|
||||
*
|
||||
* Flow (HTTP-only через TronGrid, no tronweb lib):
|
||||
* 1. POST /wallet/triggersmartcontract (build unsigned tx)
|
||||
* 2. MITM check: recompute txID, verify expiration/timestamp bounds, verify owner/contract
|
||||
* 3. Sign (ECDSA secp256k1, same as EVM signing с recoveryParam append)
|
||||
* 4. POST /wallet/broadcasttransaction
|
||||
*/
|
||||
export async function signAndBroadcastRawTron(p: SignRawTronParams): Promise<{ txid: string }> {
|
||||
const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.TRX);
|
||||
const fromTronAddr = ethAddressToTron(wallet.address);
|
||||
if (fromTronAddr !== p.expectedFromAddress) {
|
||||
throw new Error(`Derived TRX address ${fromTronAddr} ≠ stored ${p.expectedFromAddress}`);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
|
||||
// Normalize calldata. triggersmartcontract API принимает либо:
|
||||
// - function_selector (canonical string "transfer(address,uint256)") + parameter (hex args)
|
||||
// → TronGrid keccak'ит selector NAME и prepend'ит к parameter
|
||||
// - data (full hex calldata = selector + params) → используется как-есть
|
||||
//
|
||||
// Если у нас неизвестный selector (LiFi bridge calls, custom routers) — мы НЕ можем
|
||||
// передать "0x..." как function_selector (TronGrid keccak'нёт строку и получит
|
||||
// полностью другие 4 байта → contract revert). Используем `data` напрямую.
|
||||
let data = p.callData.startsWith('0x') ? p.callData.slice(2) : p.callData;
|
||||
if (data.length < 8) throw new Error('TRX call data too short (need >= 4-byte selector)');
|
||||
const selector8 = data.slice(0, 8);
|
||||
const knownCanonical = lookupKnownSelector(selector8);
|
||||
|
||||
const callBody: any = {
|
||||
owner_address: fromTronAddr,
|
||||
contract_address: p.contractAddress,
|
||||
fee_limit: p.feeLimit,
|
||||
call_value: p.callValue > 0n ? Number(p.callValue) : 0,
|
||||
visible: true,
|
||||
};
|
||||
if (knownCanonical) {
|
||||
callBody.function_selector = knownCanonical;
|
||||
callBody.parameter = data.slice(8);
|
||||
} else {
|
||||
// Unknown selector (LiFi bridge call) — pass full calldata as-is
|
||||
callBody.data = data;
|
||||
}
|
||||
|
||||
// ── Fix 2: pre-simulation guard ──
|
||||
// Dry-run через triggerconstantcontract (read-only) ДО build+broadcast'а.
|
||||
// Если контракт revert'нёт на simulation — НЕ broadcast'им, fees не сгорают.
|
||||
// Это catches LiFi/Relay stale quotes + bad calldata + insufficient allowance + др.
|
||||
try {
|
||||
const simRes = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(callBody),
|
||||
});
|
||||
const simOk = simRes?.result?.result === true;
|
||||
if (!simOk) {
|
||||
const rawMsg = simRes?.result?.message;
|
||||
const msgDecoded = rawMsg
|
||||
? Buffer.from(rawMsg, 'hex').toString().replace(/[ | ||||