915 lines
37 KiB
TypeScript
915 lines
37 KiB
TypeScript
/**
|
||
* 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<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` напрямую.
|
||
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<string, string> = { '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<bigint> {
|
||
// 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<string, string> = { '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<bigint> {
|
||
const headers: Record<string, string> = { '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<bigint> {
|
||
const ownerHex = tronBase58ToHex(owner).padStart(64, '0');
|
||
const headers: Record<string, string> = { '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<bigint> {
|
||
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<string, number>;
|
||
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<any> {
|
||
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<string, string> = {
|
||
'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;
|
||
}
|