initikghuiu
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user