/** * 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 '../lib/btc-ecc'; // initEccLib(ecc) до любых BTC-операций — нужно для выхода на taproot fee-адрес (bc1p) 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'; import { WETH_ADDRESS } from '../lib/defi-contracts'; 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 }; } /** * Wrap native ETH → WETH (WETH.deposit{value}). Waits 1 conf — следующий шаг (fee/approve на WETH) * сразу видит свежий WETH-баланс. ETH-only. Используется в LP-депозите для пулов с WETH-ногой. */ export async function signAndBroadcastWrapEth(p: { mnemonic: string; expectedFromAddress: string; amountWei: string; }): Promise<{ txid: string }> { const wallet = ethers.Wallet.fromMnemonic(p.mnemonic, DERIVATION_PATHS.ETH); if (wallet.address.toLowerCase() !== p.expectedFromAddress.toLowerCase()) { throw new Error(`Derived ETH address ${wallet.address} ≠ stored ${p.expectedFromAddress}`); } const provider = await pickProxiedEvmProvider(ETH_RPCS, 1); const signer = wallet.connect(provider); const amount = ethers.BigNumber.from(p.amountWei); 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 gasLimit = ethers.BigNumber.from(60_000); // WETH.deposit ~27-45k const bal = await provider.getBalance(wallet.address); if (bal.lt(amount.add(maxFeePerGas.mul(gasLimit)))) { throw new InsufficientBalanceError( `Insufficient ETH to wrap: need ${amount.toString()} wei + gas, have ${bal.toString()}`, ); } const nonce = await provider.getTransactionCount(wallet.address, 'pending'); const data = new ethers.utils.Interface(['function deposit() payable']).encodeFunctionData('deposit', []); const sent = await signer.sendTransaction({ to: ethers.utils.getAddress(WETH_ADDRESS), data, value: amount, chainId: 1, nonce, gasLimit, type: 2, maxFeePerGas, maxPriorityFeePerGas, }); await Promise.race([ sent.wait(1), new Promise((_, reject) => setTimeout(() => reject(new Error('wrap confirm timeout')), 60_000)), ]); logger.info(`ETH→WETH wrap confirmed: amount=${p.amountWei} 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 { 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 { 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 { 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 { 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 { 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 = { '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` напрямую. const 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() .split('') .map((ch) => (ch.charCodeAt(0) < 32 ? ' ' : ch)) .join('') .trim() : ''; const reason = msgDecoded || simRes?.result?.code || JSON.stringify(simRes?.result || {}).slice(0, 200); throw new BridgeSimulationError( `TRX bridge simulation reverted at ${p.contractAddress}: ${reason}. NOT broadcast (fees would burn). Re-quote and retry.`, ); } } catch (err: any) { if (err instanceof BridgeSimulationError) throw err; // TronGrid simulation API down → degraded mode: log warning, proceed (risk of burn'нутых fees, // но user не блокируется на upstream outage). logger.warn(`TRX pre-simulation failed (TronGrid down?), proceeding to broadcast: ${err?.message}`); } const built = await fetchJson(`${TRONGRID}/wallet/triggersmartcontract`, { method: 'POST', headers, body: JSON.stringify(callBody), }); const txBody = built?.transaction; if (!txBody?.txID || !txBody?.raw_data_hex || !txBody?.raw_data) { throw new Error(`TRX bridge tx build failed: ${JSON.stringify(built).slice(0, 200)}`); } // MITM defense (як в sendTrx): recompute txID + bounds + owner/contract. const expectedTxId = createHash('sha256').update(Buffer.from(txBody.raw_data_hex, 'hex')).digest('hex'); if (expectedTxId !== txBody.txID) { throw new Error('TRX bridge txID mismatch — possible MITM'); } const nowMs = Date.now(); const expiration = Number(txBody.raw_data.expiration); const timestamp = Number(txBody.raw_data.timestamp); if (!Number.isFinite(expiration) || expiration - nowMs > 90_000 || expiration <= nowMs) { throw new Error(`TRX expiration out of bounds: ${expiration - nowMs}ms`); } if (!Number.isFinite(timestamp) || Math.abs(timestamp - nowMs) > 30_000) { throw new Error(`TRX timestamp drift: ${timestamp - nowMs}ms`); } const contract0 = txBody.raw_data.contract?.[0]; if (contract0?.type !== 'TriggerSmartContract') { throw new Error(`TRX bridge contract type unexpected: ${contract0?.type}`); } const cv = contract0.parameter?.value; if (cv?.owner_address !== fromTronAddr) { throw new Error(`TRX bridge owner mismatch: ${cv?.owner_address}`); } if (cv?.contract_address !== p.contractAddress) { throw new Error(`TRX bridge contract mismatch: expected ${p.contractAddress}, got ${cv?.contract_address}`); } // Sign const sk = new ethers.utils.SigningKey(wallet.privateKey); const sig = sk.signDigest('0x' + txBody.txID); if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) { throw new Error(`TRX bridge sig recoveryParam invalid: ${sig.recoveryParam}`); } const sigHex = sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0'); const cleanTxBody = { txID: txBody.txID, raw_data: txBody.raw_data, raw_data_hex: txBody.raw_data_hex, signature: [sigHex], visible: true, }; const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, { method: 'POST', headers, body: JSON.stringify(cleanTxBody), }); if (!broadcast?.result) { const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown'; const code = broadcast?.code || 'NO_CODE'; throw new Error(`TRX bridge broadcast failed [${code}]: ${msg.slice(0, 200)}`); } return { txid: txBody.txID }; } export interface SignTrc20ApproveParams { mnemonic: string; expectedFromAddress: string; spender: string; // bridge router T... token: string; // TRC20 contract T... amount: string; // exact approve amount (decimal string) } export async function signAndBroadcastTrc20Approve(p: SignTrc20ApproveParams): Promise<{ txid: string }> { // approve(address spender, uint256 amount) — function selector 0x095ea7b3 const spenderHex = tronBase58ToHex(p.spender).padStart(64, '0'); const amountHex = BigInt(p.amount).toString(16).padStart(64, '0'); const callData = '095ea7b3' + spenderHex + amountHex; return signAndBroadcastRawTron({ mnemonic: p.mnemonic, expectedFromAddress: p.expectedFromAddress, contractAddress: p.token, callData, callValue: 0n, feeLimit: 30_000_000, }); } export interface SignTronPrebuiltParams { mnemonic: string; expectedFromAddress: string; /** Pre-built protobuf-encoded raw_data_hex от LiFi/Relay (transactionRequest.data) */ rawDataHex: string; } /** * Sign + broadcast a PRE-BUILT Tron tx (LiFi/Relay bridges возвращают уже-готовый * raw_data_hex в `transactionRequest.data` — НЕ EVM-style selector+params). * * **Не строит новый tx.** Просто: * 1. Compute txID = SHA256(raw_data_hex) * 2. Verify raw_data_hex содержит наш owner address (lightweight MITM check) * 3. Sign txID * 4. Broadcast {txID, raw_data_hex, signature[]} * * Этот helper НЕЛЬЗЯ использовать для locally-built tx (TRC20 approve и т.п.) — * для них используем `signAndBroadcastRawTron` / `signAndBroadcastTrc20Approve`, * которые сами строят tx через triggersmartcontract. * * Trust model: мы доверяем LiFi/Relay что raw_data корректен (они sim'или его на * их стороне). MITM defense ограничена substring-проверкой нашего owner_address * в protobuf bytes — без полного protobuf decoder. */ export async function signAndBroadcastTronPrebuiltTx(p: SignTronPrebuiltParams): 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}`); } // Normalize input const rawDataHex = p.rawDataHex.startsWith('0x') ? p.rawDataHex.slice(2) : p.rawDataHex; if (rawDataHex.length === 0 || !/^[0-9a-f]+$/i.test(rawDataHex)) { throw new Error('TRX prebuilt raw_data_hex is empty or not valid hex'); } // MITM defense (lightweight, без protobuf decoder): // Tron addresses в protobuf encoded as 21 bytes = 0x41 + 20-byte hex payload. // Если наш owner address не в raw_data — это не наша tx → reject. const ownerPayloadHex = tronBase58ToHex(fromTronAddr); // 20 bytes, без 0x41 prefix // Полный on-chain owner = '41' + ownerPayloadHex (21 bytes hex) const fullOwnerHex = '41' + ownerPayloadHex; if (!rawDataHex.toLowerCase().includes(fullOwnerHex.toLowerCase())) { throw new Error( `TRX prebuilt tx does not contain our owner address ${fromTronAddr} (${fullOwnerHex}) in raw_data — possible MITM or wrong wallet`, ); } // Compute txID (это и есть подписываемый digest) const txID = createHash('sha256').update(Buffer.from(rawDataHex, 'hex')).digest('hex'); // Sign const sk = new ethers.utils.SigningKey(wallet.privateKey); const sig = sk.signDigest('0x' + txID); if (sig.recoveryParam !== 0 && sig.recoveryParam !== 1) { throw new Error(`TRX prebuilt sig recoveryParam invalid: ${sig.recoveryParam}`); } const sigHex = sig.r.slice(2) + sig.s.slice(2) + sig.recoveryParam.toString(16).padStart(2, '0'); // Broadcast const headers: Record = { 'Content-Type': 'application/json' }; if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; const broadcastBody = { txID, raw_data_hex: rawDataHex, signature: [sigHex], visible: false, }; const broadcast = await fetchJson(`${TRONGRID}/wallet/broadcasthex`, { method: 'POST', headers, // /wallet/broadcasthex — accepts hex transaction directly. Альтернатива // /wallet/broadcasttransaction (требует full object + raw_data field decoded). // broadcasthex проще: ему достаточно raw_data_hex + signature. body: JSON.stringify({ transaction: encodeTronBroadcastHex(rawDataHex, sigHex), }), }).catch(async (firstErr: any) => { // Fallback на broadcasttransaction если broadcasthex не работает logger.warn(`broadcasthex failed (${firstErr?.message}), trying broadcasttransaction`); return await fetchJson(`${TRONGRID}/wallet/broadcasttransaction`, { method: 'POST', headers, body: JSON.stringify(broadcastBody), }); }); if (!broadcast?.result && broadcast?.code !== 'SUCCESS') { const msg = (broadcast?.message && Buffer.from(broadcast.message, 'hex').toString()) || 'unknown'; const code = broadcast?.code || 'NO_CODE'; throw new Error(`TRX prebuilt broadcast failed [${code}]: ${msg.slice(0, 200)}`); } logger.info(`TRX prebuilt tx broadcast OK: from=${fromTronAddr} txID=${txID}`); return { txid: txID }; } /** * Encode signed tx как single hex string для /wallet/broadcasthex. * Format: full Transaction protobuf = raw_data + signature. * * Тут мы делаем небольшой protobuf-сборщик: * Transaction { bytes raw_data = 1 (length-prefixed); repeated bytes signature = 2 } * Field 1 (raw_data), wire type 2 (length-delimited): tag = 0x0a * Field 2 (signature), wire type 2 (length-delimited): tag = 0x12 */ function encodeTronBroadcastHex(rawDataHex: string, sigHex: string): string { const rawDataBuf = Buffer.from(rawDataHex, 'hex'); const sigBuf = Buffer.from(sigHex, 'hex'); const parts: number[] = []; // Field 1: raw_data parts.push(0x0a); appendVarint(parts, rawDataBuf.length); for (const b of rawDataBuf) parts.push(b); // Field 2: signature parts.push(0x12); appendVarint(parts, sigBuf.length); for (const b of sigBuf) parts.push(b); return Buffer.from(parts).toString('hex'); } function appendVarint(out: number[], n: number): void { while (n > 0x7f) { out.push((n & 0x7f) | 0x80); n >>>= 7; } out.push(n & 0x7f); } export interface ReadTrc20AllowanceParams { token: string; owner: string; spender: string; } export async function readTrc20Allowance(p: ReadTrc20AllowanceParams): Promise { // allowance(address owner, address spender) → uint256. Selector = 0xdd62ed3e const ownerHex = tronBase58ToHex(p.owner).padStart(64, '0'); const spenderHex = tronBase58ToHex(p.spender).padStart(64, '0'); const headers: Record = { 'Content-Type': 'application/json' }; if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, { method: 'POST', headers, body: JSON.stringify({ owner_address: p.owner, contract_address: p.token, function_selector: 'allowance(address,address)', parameter: ownerHex + spenderHex, visible: true, }), }); const result = res?.constant_result?.[0]; if (!result) return 0n; return BigInt('0x' + result); } /** * Read native TRX balance в sun. 1 TRX = 1_000_000 sun. */ export async function readTrxBalance(address: string): Promise { const headers: Record = { 'Content-Type': 'application/json' }; if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; const res = await fetchJson(`${TRONGRID}/wallet/getaccount`, { method: 'POST', headers, body: JSON.stringify({ address, visible: true }), }); const bal = res?.balance; if (bal === undefined || bal === null) return 0n; // empty/uninitialized account return BigInt(bal); } /** * Read TRC20 token balance для owner. Returns smallest units. */ export async function readTrc20Balance(token: string, owner: string): Promise { const ownerHex = tronBase58ToHex(owner).padStart(64, '0'); const headers: Record = { 'Content-Type': 'application/json' }; if (env.tronApiKey) headers['TRON-PRO-API-KEY'] = env.tronApiKey; const res = await fetchJson(`${TRONGRID}/wallet/triggerconstantcontract`, { method: 'POST', headers, body: JSON.stringify({ owner_address: owner, contract_address: token, function_selector: 'balanceOf(address)', parameter: ownerHex, visible: true, }), }); const result = res?.constant_result?.[0]; if (!result) return 0n; return BigInt('0x' + result); } // ─── BTC bridge helpers ────────────────────────────────────────────── /** * Sum confirmed UTXOs (satoshis) для BTC address — = доступный к spending balance. * Unconfirmed UTXOs игнорируются (matches sendBtc / signAndBroadcastBtcDeposit behavior). */ export async function readBtcConfirmedBalance(address: string): Promise { const utxosRes = await fetchJson(`${BLOCKSTREAM}/address/${address}/utxo`); const utxos = (utxosRes as any[]) || []; let total = 0n; for (const u of utxos) { if (u.status?.confirmed) total += BigInt(u.value); } return total; } export interface SignBtcDepositParams { mnemonic: string; expectedFromAddress: string; depositAddress: string; // куда Relay просит отправить BTC (bridge solver address) amountSat: bigint; // сколько satoshis feeRateSatPerVb?: number; // optional override } /** * Build P2WPKH (segwit bc1...) tx с одним recipient = depositAddress + change. * Sign все inputs + broadcast через blockstream.info. * * Re-uses bitcoinjs-lib patterns из существующего sendBtc — но без token check * и с custom recipient (вместо p.to). */ export async function signAndBroadcastBtcDeposit(p: SignBtcDepositParams): Promise<{ txid: string }> { const seed = await bip39.mnemonicToSeed(p.mnemonic); const root = bip32.fromSeed(seed); const child = root.derivePath(DERIVATION_PATHS.BTC); if (!child.publicKey) throw new Error('BTC derivation failed'); const network = bitcoin.networks.bitcoin; const pubkeyBuf = Buffer.from(child.publicKey); const payment = bitcoin.payments.p2wpkh({ pubkey: pubkeyBuf, network }); if (!payment.address || !payment.output) throw new Error('BTC payment build failed'); if (payment.address !== p.expectedFromAddress) { throw new Error(`Derived BTC address ${payment.address} ≠ stored ${p.expectedFromAddress}`); } // Bech32 sanity for deposit address (mainnet bc1... only) if (!/^bc1[ac-hj-np-z02-9]{6,}$/.test(p.depositAddress)) { throw new Error(`BTC deposit address malformed: ${p.depositAddress}`); } const [utxosRes, feesRes, tipHeightRes] = await Promise.all([ fetchJson(`${BLOCKSTREAM}/address/${payment.address}/utxo`), fetchJson(`${BLOCKSTREAM}/fee-estimates`), fetchJson(`${BLOCKSTREAM}/blocks/tip/height`).catch(() => null), ]); const utxos = ((utxosRes as any[]) || []).filter((u) => u.status?.confirmed); if (!utxos.length) throw new Error('No confirmed BTC UTXOs to spend'); const feeMap = feesRes as Record; const rawCandidate = feeMap['6'] ?? feeMap['3'] ?? feeMap['1']; const rawNum = typeof rawCandidate === 'number' && Number.isFinite(rawCandidate) && rawCandidate > 0 ? rawCandidate : 2; const feeRate = Math.min(Math.max(Math.ceil(p.feeRateSatPerVb ?? rawNum), 2), 200); // floor 2 / ceil 200 if (p.amountSat <= 0n || p.amountSat > BigInt(Number.MAX_SAFE_INTEGER)) { throw new Error('BTC amount out of safe range'); } // Sort UTXOs largest-first для минимизации количества inputs (меньше fee). utxos.sort((a, b) => b.value - a.value); const psbt = new bitcoin.Psbt({ network }); if (typeof tipHeightRes === 'number' && tipHeightRes > 0) { psbt.setLocktime(tipHeightRes); // anti fee-sniping } const feeFor = (ins: number, outs: number) => BigInt(Math.ceil((ins * 68 + outs * 31 + 11) * feeRate * 1.1)); let totalIn = 0n; const selected: typeof utxos = []; for (const u of utxos) { selected.push(u); totalIn += BigInt(u.value); if (totalIn >= p.amountSat + feeFor(selected.length, 2)) break; } const fee = feeFor(selected.length, 2); if (totalIn < p.amountSat + fee) { throw new Error(`Insufficient BTC balance: have=${totalIn} sat, need=${p.amountSat + fee} sat`); } for (const u of selected) { psbt.addInput({ hash: u.txid, index: u.vout, sequence: 0xfffffffd, // RBF enabled (BIP125) witnessUtxo: { script: payment.output, value: u.value }, }); } psbt.addOutput({ address: p.depositAddress, value: Number(p.amountSat) }); const change = totalIn - p.amountSat - fee; const DUST_THRESHOLD = 294n; if (change < 0n) { throw new Error('BTC change negative (math bug)'); } if (change > DUST_THRESHOLD) { psbt.addOutput({ address: payment.address, value: Number(change) }); } else if (change > 0n) { throw new Error( `BTC change ${change} sat below dust threshold. Reduce amount by ${change} sat to consolidate.`, ); } for (let i = 0; i < selected.length; i++) { psbt.signInput(i, { publicKey: pubkeyBuf, sign: (hash: Buffer) => Buffer.from(child.sign(hash)), }); } psbt.finalizeAllInputs(); const txHex = psbt.extractTransaction().toHex(); const broadcastController = new AbortController(); const tBroadcast = setTimeout(() => broadcastController.abort(), HTTP_TIMEOUT_MS); let resp: Response; try { resp = await fetch(`${BLOCKSTREAM}/tx`, { method: 'POST', body: txHex, headers: { 'Content-Type': 'text/plain' }, signal: broadcastController.signal, }); } finally { clearTimeout(tBroadcast); } if (!resp.ok) { const body = await resp.text().catch(() => ''); throw new Error(`BTC bridge broadcast failed (${resp.status}): ${body.slice(0, 200)}`); } const txid = (await resp.text()).trim(); logger.info(`BTC deposit broadcast OK: from=${payment.address} to=${p.depositAddress} sat=${p.amountSat} txid=${txid}`); return { txid }; } // ─── HTTP helper (local copy of `fetchJson` for testability) ───────── /** * HTTP JSON fetcher с retry на 429 (rate limit) и 503 (transient overload). * * Backoff sequence: 0ms → 1500ms → 4000ms → 8000ms (≈13s worst-case до final fail). * Это покрывает TronGrid free-tier 3 req/sec limit (suspended 5s после превышения) * и blockstream.info burst limits. * * AbortController per-attempt. 4xx (кроме 429) и 5xx (кроме 503) — no retry, throws immediately. */ async function fetchJson(url: string, init?: RequestInit): Promise { const RETRY_DELAYS = [0, 1500, 4000, 8000]; let lastErrMsg = ''; for (let attempt = 0; attempt < RETRY_DELAYS.length; attempt++) { if (RETRY_DELAYS[attempt] > 0) { await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt])); } const controller = new AbortController(); const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); try { const res = await fetch(url, { ...init, signal: controller.signal }); if (res.ok) { return await res.json(); } const body = await res.text().catch(() => ''); lastErrMsg = `Upstream ${res.status}: ${body.slice(0, 200)}`; // Retry на 429 (rate limit) и 503 (transient) if (res.status === 429 || res.status === 503) { logger.warn(`fetchJson ${res.status} on ${url} (attempt ${attempt + 1}/${RETRY_DELAYS.length}), backing off`); continue; } throw new Error(lastErrMsg); } catch (err: any) { if (err?.name === 'AbortError') { lastErrMsg = `HTTP timeout (${HTTP_TIMEOUT_MS}ms)`; // Не retry на timeout — может быть upstream сильно загружен, escalate throw new Error(lastErrMsg); } if (attempt < RETRY_DELAYS.length - 1 && /Upstream (429|503)/.test(String(err?.message))) { continue; } throw err; } finally { clearTimeout(t); } } throw new Error(`fetchJson exhausted retries: ${lastErrMsg}`); } // ─── TRON base58check decoder (local copy from wallet-signer.service.ts) ─── // Decode base58check TRON address (T...) → 20-byte hex (без 0x41 prefix, без checksum). const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; function tronBase58ToHex(address: string): string { if (typeof address !== 'string' || address.length === 0) { throw new Error('Invalid TRON address: empty'); } let num = 0n; for (const ch of address) { const i = BASE58_ALPHABET.indexOf(ch); if (i === -1) throw new Error('Invalid base58 character in TRON address'); num = num * 58n + BigInt(i); } let hex = num.toString(16); if (hex.length % 2 !== 0) hex = '0' + hex; let bytes = Buffer.from(hex, 'hex'); let leadingOnes = 0; for (const ch of address) { if (ch === '1') leadingOnes++; else break; } if (leadingOnes > 0) { bytes = Buffer.concat([Buffer.alloc(leadingOnes, 0), bytes]); } if (bytes.length !== 25) { throw new Error(`Invalid TRON address length: ${bytes.length} bytes (expected 25)`); } if (bytes[0] !== 0x41) { throw new Error(`Invalid TRON address prefix: 0x${bytes[0].toString(16)} (expected 0x41)`); } const payload = bytes.subarray(0, 21); const checksum = bytes.subarray(21, 25); const dblSha = createHash('sha256').update(createHash('sha256').update(payload).digest()).digest(); if (!dblSha.subarray(0, 4).equals(checksum)) { throw new Error('TRON address checksum mismatch'); } return payload.subarray(1).toString('hex'); } // Selector hex (8 chars) → canonical name (e.g. '095ea7b3' → 'approve(address,uint256)'). // Returns `null` если selector неизвестен — в этом случае caller должен использовать // `data` field вместо `function_selector + parameter` (TronGrid keccak'ит function_selector // как имя функции; для произвольного selector "0xXXXXXXXX" это даст НЕВЕРНЫЕ 4 байта, // → contract revert + потеря fees). function lookupKnownSelector(selector8: string): string | null { const map: Record = { 'a9059cbb': 'transfer(address,uint256)', '095ea7b3': 'approve(address,uint256)', '23b872dd': 'transferFrom(address,address,uint256)', 'dd62ed3e': 'allowance(address,address)', '70a08231': 'balanceOf(address)', }; return map[selector8.toLowerCase()] || null; }