From 399322973e3d79ab833796a1d1aaca2130f75c7d Mon Sep 17 00:00:00 2001 From: ZOMBIIIIIII <120676065+Metaton241@users.noreply.github.com> Date: Fri, 29 May 2026 13:34:29 +0300 Subject: [PATCH] initikghuiu --- apps/api/src/controllers/wallet.controller.ts | 5 +- apps/api/src/lib/app-fee.ts | 16 +- apps/api/src/lib/nearintents-client.ts | 9 +- apps/api/src/lib/token-registry.ts | 9 + apps/api/src/routes/bridge.routes.ts | 9 + .../src/services/bridge-execute.service.ts | 177 +++++++++++++++--- 6 files changed, 192 insertions(+), 33 deletions(-) diff --git a/apps/api/src/controllers/wallet.controller.ts b/apps/api/src/controllers/wallet.controller.ts index e06a546..bb21c55 100644 --- a/apps/api/src/controllers/wallet.controller.ts +++ b/apps/api/src/controllers/wallet.controller.ts @@ -1599,9 +1599,9 @@ export const WalletController = { async appFeeTransfer(req: Request, res: Response) { const userId = req.auth!.userId; const chainParam = String(req.params.chain || '').toUpperCase(); - const ALLOWED_FEE_CHAINS = new Set(['ETH', 'BSC', 'SOL', 'TRX']); + const ALLOWED_FEE_CHAINS = new Set(['ETH', 'BSC', 'SOL', 'TRX', 'BTC']); if (!ALLOWED_FEE_CHAINS.has(chainParam)) { - res.status(400).json({ success: false, error: `Chain ${chainParam} doesn't support app fee (allowed: ETH, BSC, SOL, TRX)` }); + res.status(400).json({ success: false, error: `Chain ${chainParam} doesn't support app fee (allowed: ETH, BSC, SOL, TRX, BTC)` }); return; } const chain = chainParam as ChainCode; @@ -1694,6 +1694,7 @@ export const WalletController = { to: feeWallet, amount: feeAmountBig.toString(), token: tokenSymbol, + feeTier: chain === 'BTC' ? 'slow' : undefined, }); txid = sendRes.txid; } catch (sendErr: any) { diff --git a/apps/api/src/lib/app-fee.ts b/apps/api/src/lib/app-fee.ts index 9e4e719..a878110 100644 --- a/apps/api/src/lib/app-fee.ts +++ b/apps/api/src/lib/app-fee.ts @@ -14,7 +14,7 @@ * EVM (ETH+BSC) → 0xeb9f... (один EVM wallet, оба chain'а) * SOL → DQkQ... (Solana base58) * TRX → TRwp... (Tron base58) - * BTC → не collectable (no wallet provided — bridge in BTC не имеет fee tx layer) + * BTC → bc1q... (bech32 P2WPKH, отдельная fee tx перед bridge) * * Изменение wallet → требует code review + новый release. */ @@ -30,6 +30,9 @@ export const APP_FEE_WALLET_SOL = 'DQkQegoX698XkcXZ6VX9P1qUpbV64Sgjz1BCPFgfWpjD' /** Tron base58 (с T-prefix). */ export const APP_FEE_WALLET_TRX = 'TRwpFjnfMBe4aDJbHYEqeUVCG1auF8wFXP'; +/** Bitcoin bech32 (P2WPKH). */ +export const APP_FEE_WALLET_BTC = 'bc1qwzm9e4qun4tptecalq9zwxshdnwmvcxtz27znm'; + /** 70 bps = 0.7%. Изменение требует code review. */ export const APP_FEE_BPS = 70n; @@ -37,22 +40,21 @@ export const APP_FEE_BPS = 70n; export const APP_FEE_DENOMINATOR = 10000n; /** - * Resolve fee recipient для chain. Throws для unsupported chain (BTC). + * Resolve fee recipient для chain. */ export function getAppFeeWallet(chain: ChainCode): string { if (chain === 'ETH' || chain === 'BSC') return APP_FEE_WALLET_EVM; if (chain === 'SOL') return APP_FEE_WALLET_SOL; if (chain === 'TRX') return APP_FEE_WALLET_TRX; - throw new Error( - `getAppFeeWallet: chain '${chain}' has no fee wallet (BTC bridges не имеют collectable fee layer)`, - ); + if (chain === 'BTC') return APP_FEE_WALLET_BTC; + throw new Error(`getAppFeeWallet: unsupported chain '${chain}'`); } /** - * Check если для chain есть fee wallet. Used для conditional fee tx (skip BTC). + * Check если для chain есть fee wallet. */ export function hasAppFee(chain: ChainCode): boolean { - return chain === 'ETH' || chain === 'BSC' || chain === 'SOL' || chain === 'TRX'; + return chain === 'ETH' || chain === 'BSC' || chain === 'SOL' || chain === 'TRX' || chain === 'BTC'; } /** diff --git a/apps/api/src/lib/nearintents-client.ts b/apps/api/src/lib/nearintents-client.ts index e89f070..a03dcbf 100644 --- a/apps/api/src/lib/nearintents-client.ts +++ b/apps/api/src/lib/nearintents-client.ts @@ -288,10 +288,10 @@ export function nearIntentsTrackerUrl(depositAddress: string): string { // валидный Tron address, не attacker-controlled garbage) const TRON_BASE58_REGEX = /^T[1-9A-HJ-NP-Za-km-z]{33}$/; +const BTC_BECH32_REGEX = /^bc1[ac-hj-np-z02-9]{6,}$/; /** * Throws если depositAddress не соответствует ожидаемому формату для chain. - * На MVP — только TRX validation. Расширить когда добавим SOL/BTC origins. */ export function assertValidDepositAddress(chain: ChainCode, depositAddress: string): void { if (chain === 'TRX') { @@ -300,5 +300,10 @@ export function assertValidDepositAddress(chain: ChainCode, depositAddress: stri } return; } - // Для других chains — пока no extra validation (TODO when extending) + if (chain === 'BTC') { + if (!BTC_BECH32_REGEX.test(depositAddress)) { + throw new Error(`NearIntents depositAddress ${depositAddress} не валидный Bitcoin bech32 — abort`); + } + return; + } } diff --git a/apps/api/src/lib/token-registry.ts b/apps/api/src/lib/token-registry.ts index 42bf513..7ab8acb 100644 --- a/apps/api/src/lib/token-registry.ts +++ b/apps/api/src/lib/token-registry.ts @@ -47,8 +47,15 @@ export interface TokenListEntry { name: string; contract: string | null; decimals: number; + /** LiFi/Jumper fromToken/toToken для native (BTC = "bitcoin"). */ + lifiAddress?: string; } +/** LiFi native sentinel для bridge quote (только BTC отличается от contract:null). */ +export const LIFI_NATIVE_ADDRESS: Partial> = { + BTC: 'bitcoin', +}; + /** * Native decimals per chain. Дублирует `NATIVE_DECIMALS` из `lib/amount-units.ts` * и `services/wallet-ops.service.ts` — небольшое количество constants, проще @@ -180,12 +187,14 @@ export function getAllTokens(filterChain?: ChainCode, bridgeableOnly: boolean = for (const chain of chains) { // Native first if (!bridgeableOnly || isBridgeable(chain, NATIVE_SYMBOLS[chain])) { + const lifiAddress = LIFI_NATIVE_ADDRESS[chain]; out.push({ chain, symbol: NATIVE_SYMBOLS[chain], name: NATIVE_NAMES[chain], contract: null, decimals: NATIVE_DECIMALS_LOCAL[chain], + ...(lifiAddress ? { lifiAddress } : {}), }); } // Tokens diff --git a/apps/api/src/routes/bridge.routes.ts b/apps/api/src/routes/bridge.routes.ts index 41ba9fc..f52ac16 100644 --- a/apps/api/src/routes/bridge.routes.ts +++ b/apps/api/src/routes/bridge.routes.ts @@ -84,6 +84,15 @@ async function executeHandler(req: Request, res: Response): Promise { return; } + const BTC_NATIVE_FROM_TOKENS = new Set(['bitcoin', 'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8']); + if (CHAINID_TO_CHAIN[fromChain] === 'BTC' && !BTC_NATIVE_FROM_TOKENS.has(fromToken)) { + res.status(400).json({ + success: false, + error: 'BTC bridge supports native only: fromToken must be "bitcoin" (LiFi sentinel)', + }); + return; + } + // ── 2. JWT-bind: fromAddress = user's source-chain wallet ── const sourceCode = CHAINID_TO_CHAIN[fromChain]; if (!sourceCode) { diff --git a/apps/api/src/services/bridge-execute.service.ts b/apps/api/src/services/bridge-execute.service.ts index c16f250..a56bd1b 100644 --- a/apps/api/src/services/bridge-execute.service.ts +++ b/apps/api/src/services/bridge-execute.service.ts @@ -28,7 +28,7 @@ import { signAndBroadcastEvmFeeTx, signAndBroadcastSolanaTx, } from './wallet-signer.service'; -import { computeAppFee, APP_FEE_WALLET_SOL, APP_FEE_WALLET_TRX } from '../lib/app-fee'; +import { computeAppFee, APP_FEE_WALLET_BTC, APP_FEE_WALLET_SOL, APP_FEE_WALLET_TRX } from '../lib/app-fee'; import { SOL_TOKENS, TRX_TOKENS } from '../lib/token-registry'; /** @@ -166,6 +166,18 @@ const CHAINID_TO_CHAIN: Record = { // EVM-native sentinels (0x0000...) — означает что from-token = native (BNB / ETH). const EVM_NATIVE_SENTINEL = '0x0000000000000000000000000000000000000000'; +/** Native token sentinels в LiFi/Jumper/Relay body (case-sensitive где указано). */ +const NATIVE_TOKEN_SENTINELS = new Set([ + EVM_NATIVE_SENTINEL, + '11111111111111111111111111111111', + 'T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb', + 'bitcoin', + 'bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqmql8k8', +]); + +/** Conservative network fee floor для 2× P2WPKH BTC tx (fee + bridge). */ +const BTC_NETWORK_FEE_FLOOR_SAT = 2000n; + export type BridgeProvider = 'jumper' | 'relay' | 'nearintents'; export interface BridgeExecuteParams { @@ -706,40 +718,161 @@ void readTrc20Allowance; // ─── BTC execute ────────────────────────────────────────────────────── +/** + * BTC bridge: NearIntents 1Click (primary, как TRX). Relay deposit — fallback если + * provider=relay и в upstream quote есть depositAddress. + */ async function executeBtc( p: BridgeExecuteParams, quote: NormalizedQuote, ): Promise { - // Для BTC source через Relay: quote.steps[0] = deposit step с {data.depositAddress, ...}. - // Relay просит юзера отправить BTC tx на их deposit address; solver видит UTXO в mempool - // → доставляет destination asset. - let depositAddress: string | undefined; - let amountSat: bigint | undefined; + const needed = BigInt(p.fromAmount); + + // Relay fallback (legacy path) if (p.provider === 'relay' && Array.isArray(quote.steps)) { const depositStep = quote.steps.find((s) => s.id === 'deposit' || s.id === 'tx'); - if (depositStep) { - depositAddress = depositStep.data?.depositAddress || depositStep.data?.to; - const amt = depositStep.data?.amount || depositStep.data?.value; - if (amt) amountSat = BigInt(amt); + const depositAddress = depositStep?.data?.depositAddress || depositStep?.data?.to; + if (depositAddress) { + const amountSat = depositStep?.data?.amount || depositStep?.data?.value + ? BigInt(depositStep.data.amount || depositStep.data.value) + : needed; + return executeBtcRelayDeposit(p, quote, depositAddress, amountSat); } } - if (!depositAddress) { - throw new Error('Relay BTC quote missing depositAddress'); - } - if (!amountSat) { - amountSat = BigInt(p.fromAmount); + + const destCode = CHAINID_TO_CHAIN[p.toChain]; + if (!destCode) { + throw new Error(`NearIntents (BTC): destination chainId ${p.toChain} not in our chain map`); + } + + const destToken = NATIVE_TOKEN_SENTINELS.has(p.toToken) ? null : p.toToken; + const originToken = NATIVE_TOKEN_SENTINELS.has(p.fromToken) ? null : p.fromToken; + if (originToken !== null) { + const err: any = new Error('BTC bridge supports native BTC only (fromToken=bitcoin)'); + err.code = 'NO_ROUTE'; + throw err; + } + + const originAssetId = await resolveAsset('BTC', null); + if (!originAssetId) { + const err: any = new Error('NearIntents: BTC native origin не поддерживается'); + err.code = 'NO_ROUTE'; + throw err; + } + const destAssetId = await resolveAsset(destCode, destToken); + if (!destAssetId) { + const err: any = new Error( + `NearIntents: destination asset ${destCode}:${destToken || 'native'} не поддерживается`, + ); + err.code = 'NO_ROUTE'; + throw err; + } + + const niQuote = await fetchNearIntentsQuote({ + originAssetId, + destinationAssetId: destAssetId, + amount: p.fromAmount, + slippageBps: 50, + refundTo: p.expectedFromAddress, + recipient: p.expectedToAddress, + deadlineMinutes: 30, + }); + + assertValidDepositAddress('BTC', niQuote.depositAddress); + + const acceptedMinOut = BigInt(p.acceptedMinOut); + const niMinOut = BigInt(niQuote.minAmountOut); + if (acceptedMinOut > 0n && niMinOut < acceptedMinOut) { + const lossBps = Number(((acceptedMinOut - niMinOut) * 10000n) / acceptedMinOut); + if (lossBps > 50) { + const err: any = new Error( + `NearIntents quote worse than user-accepted: minOut ${niMinOut} < accepted ${acceptedMinOut} (-${lossBps} bps)`, + ); + err.code = 'PRICE_MOVED'; + throw err; + } + } + + const remainingMs = niQuote.timeWhenInactiveMs - Date.now(); + if (remainingMs < 20_000) { + const err: any = new Error( + `NearIntents quote deadline too close (${Math.round(remainingMs / 1000)}s left). Re-quote and retry.`, + ); + err.code = 'PRICE_MOVED'; + throw err; + } + + const feeAmountBig = computeAppFee(p.fromAmount); + if (feeAmountBig <= 0n) { + throw new Error('BTC bridge: fromAmount too small — fee = 0'); } - // ── Balance pre-check (BTC) ── - // Минимум для tx: amount + fee (≥ ~500 sat для 1-input 2-output P2WPKH). - // Точный fee рассчитается в signAndBroadcastBtcDeposit; здесь делаем conservative - // нижнюю границу 1000 sat для anti-dust reject. const btcBal = await readBtcConfirmedBalance(p.expectedFromAddress); - const BTC_FEE_RESERVE_SAT = 1000n; - const totalNeeded = amountSat + BTC_FEE_RESERVE_SAT; + const totalNeeded = needed + feeAmountBig + BTC_NETWORK_FEE_FLOOR_SAT; if (btcBal < totalNeeded) { throw new InsufficientBalanceError( - `Insufficient BTC: confirmed UTXOs total ${formatAmountForHumanError(btcBal, 8)}, need at least ${formatAmountForHumanError(totalNeeded, 8)} BTC (= ${formatAmountForHumanError(amountSat, 8)} bridge + ${formatAmountForHumanError(BTC_FEE_RESERVE_SAT, 8)} fee floor). Top up BTC first.`, + `Insufficient BTC: have ${formatAmountForHumanError(btcBal, 8)}, need ${formatAmountForHumanError(totalNeeded, 8)} ` + + `(= ${formatAmountForHumanError(needed, 8)} bridge + ${formatAmountForHumanError(feeAmountBig, 8)} app fee + network fees). Top up BTC first.`, + ); + } + + const feeRes = await signAndBroadcast({ + chain: 'BTC', + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + to: APP_FEE_WALLET_BTC, + amount: feeAmountBig.toString(), + feeTier: 'slow', + }); + const feeTxid = feeRes.txid; + const feeAmount = feeAmountBig.toString(); + logger.info(`BTC bridge fee broadcast: ${feeAmount} sat → ${APP_FEE_WALLET_BTC} (txid ${feeTxid})`); + await new Promise((r) => setTimeout(r, 5000)); + + logger.info( + `NearIntents BTC bridge: ${p.fromAmount} sat → ${destCode} deposit=${niQuote.depositAddress} ` + + `correlationId=${niQuote.correlationId} deadlineLeft=${Math.round(remainingMs / 1000)}s`, + ); + + const depositResult = await signAndBroadcastBtcDeposit({ + mnemonic: p.mnemonic, + expectedFromAddress: p.expectedFromAddress, + depositAddress: niQuote.depositAddress, + amountSat: needed, + }); + + submitNearIntentsDeposit(niQuote.depositAddress, depositResult.txid).catch((err) => { + logger.warn(`NearIntents submitDeposit failed (non-fatal): ${err?.message}`); + }); + + return { + provider: 'nearintents', + fromChain: p.fromChain, + toChain: p.toChain, + toolName: 'NearIntents 1Click', + feeTxid, + feeAmount, + bridgeTxid: depositResult.txid, + fromAmount: p.fromAmount, + toAmountMin: niQuote.minAmountOut, + fromAmountUSD: niQuote.amountInUsd, + toAmountUSD: niQuote.amountOutUsd, + trackerUrl: nearIntentsTrackerUrl(niQuote.depositAddress), + }; +} + +/** Relay BTC: deposit без app fee (Relay path legacy; jumper использует NearIntents выше). */ +async function executeBtcRelayDeposit( + p: BridgeExecuteParams, + quote: NormalizedQuote, + depositAddress: string, + amountSat: bigint, +): Promise { + const btcBal = await readBtcConfirmedBalance(p.expectedFromAddress); + const totalNeeded = amountSat + BTC_NETWORK_FEE_FLOOR_SAT; + if (btcBal < totalNeeded) { + throw new InsufficientBalanceError( + `Insufficient BTC: confirmed UTXOs total ${formatAmountForHumanError(btcBal, 8)}, need at least ${formatAmountForHumanError(totalNeeded, 8)} BTC.`, ); }