initikghuiu

This commit is contained in:
ZOMBIIIIIII
2026-05-29 13:34:29 +03:00
parent 1b3fc444fc
commit 399322973e
6 changed files with 192 additions and 33 deletions

View File

@@ -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) {

View File

@@ -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';
}
/**

View File

@@ -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;
}
}

View File

@@ -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<Record<ChainCode, string>> = {
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

View File

@@ -84,6 +84,15 @@ async function executeHandler(req: Request, res: Response): Promise<void> {
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) {

View File

@@ -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<number, ChainCode> = {
// 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<BridgeExecuteResult> {
// Для 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<BridgeExecuteResult> {
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.`,
);
}