add project
This commit is contained in:
112
apps/web/src/lib/bridge/constants.ts
Normal file
112
apps/web/src/lib/bridge/constants.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export const RELAY_PROXY_BASE_URL = '/api/relay';
|
||||
export const RELAY_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
// ── Bridge platform fee (0.7%) ──
|
||||
export const BRIDGE_FEE_BPS = 70; // 0.7%
|
||||
export const BRIDGE_FEE_RECIPIENT_EVM = '0xeDEb157eF86A4ecd1242762f339c2Bd5a0822718';
|
||||
export const BRIDGE_FEE_RECIPIENT_SOL = 'Co43MKwqMRMCvhscVVrtQWvma87NEV7ba4cfo8cksgzJ';
|
||||
export const BRIDGE_FEE_RECIPIENT_TRX = 'TYTfrem65362TFyQSARTheeYza1GQA37Ug';
|
||||
|
||||
// ─── Chain types ───
|
||||
|
||||
export type BridgeChainKey = 'ETH' | 'BSC' | 'SOL' | 'TRX';
|
||||
|
||||
export interface BridgeCurrencyConfig {
|
||||
symbol: string;
|
||||
address: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
export interface BridgeChainConfig {
|
||||
key: BridgeChainKey;
|
||||
label: string;
|
||||
chainId: number;
|
||||
walletChain: 'ETH' | 'BSC' | 'SOL' | 'TRX';
|
||||
explorerTxBaseUrl: string;
|
||||
tokens: Record<string, BridgeCurrencyConfig>;
|
||||
}
|
||||
|
||||
export const BRIDGE_CHAINS: Record<BridgeChainKey, BridgeChainConfig> = {
|
||||
ETH: {
|
||||
key: 'ETH',
|
||||
label: 'Ethereum',
|
||||
chainId: 1,
|
||||
walletChain: 'ETH',
|
||||
explorerTxBaseUrl: 'https://etherscan.io/tx/',
|
||||
tokens: {
|
||||
ETH: { symbol: 'ETH', address: '0x0000000000000000000000000000000000000000', decimals: 18 },
|
||||
USDT: { symbol: 'USDT', address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
|
||||
USDC: { symbol: 'USDC', address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
|
||||
},
|
||||
},
|
||||
SOL: {
|
||||
key: 'SOL',
|
||||
label: 'Solana',
|
||||
chainId: 792703809,
|
||||
walletChain: 'SOL',
|
||||
explorerTxBaseUrl: 'https://solscan.io/tx/',
|
||||
tokens: {
|
||||
SOL: { symbol: 'SOL', address: '11111111111111111111111111111111', decimals: 9 },
|
||||
USDT: { symbol: 'USDT', address: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
|
||||
USDC: { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
|
||||
},
|
||||
},
|
||||
BSC: {
|
||||
key: 'BSC',
|
||||
label: 'BNB Smart Chain',
|
||||
chainId: 56,
|
||||
walletChain: 'BSC',
|
||||
explorerTxBaseUrl: 'https://bscscan.com/tx/',
|
||||
tokens: {
|
||||
BNB: { symbol: 'BNB', address: '0x0000000000000000000000000000000000000000', decimals: 18 },
|
||||
USDT: { symbol: 'USDT', address: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
|
||||
},
|
||||
},
|
||||
TRX: {
|
||||
key: 'TRX',
|
||||
label: 'TRON',
|
||||
chainId: 728126428,
|
||||
walletChain: 'TRX',
|
||||
explorerTxBaseUrl: 'https://tronscan.org/#/transaction/',
|
||||
tokens: {
|
||||
USDT: { symbol: 'USDT', address: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const BRIDGE_CHAIN_OPTIONS: BridgeChainKey[] = ['ETH', 'BSC', 'SOL', 'TRX'];
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
export function getDestinationChainOptions(sourceChain: BridgeChainKey): BridgeChainKey[] {
|
||||
return BRIDGE_CHAIN_OPTIONS.filter((c) => c !== sourceChain);
|
||||
}
|
||||
|
||||
export function getTokenOptions(chainKey: BridgeChainKey): string[] {
|
||||
return Object.keys(BRIDGE_CHAINS[chainKey].tokens);
|
||||
}
|
||||
|
||||
export function getDefaultToken(chainKey: BridgeChainKey): string {
|
||||
const tokens = getTokenOptions(chainKey);
|
||||
return tokens[0];
|
||||
}
|
||||
|
||||
export function getTokenConfig(chainKey: BridgeChainKey, tokenSymbol: string): BridgeCurrencyConfig {
|
||||
const token = BRIDGE_CHAINS[chainKey].tokens[tokenSymbol];
|
||||
if (!token) {
|
||||
throw new Error(`Token ${tokenSymbol} not found on ${BRIDGE_CHAINS[chainKey].label}`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// ─── Request type ───
|
||||
|
||||
export interface BridgeQuoteRequest {
|
||||
sourceChain: BridgeChainKey;
|
||||
sourceToken: string;
|
||||
destChain: BridgeChainKey;
|
||||
destToken: string;
|
||||
amount: string;
|
||||
userAddress: string;
|
||||
recipientAddress: string;
|
||||
}
|
||||
482
apps/web/src/lib/bridge/execute.ts
Normal file
482
apps/web/src/lib/bridge/execute.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { ethers } from 'ethers';
|
||||
import {
|
||||
Connection,
|
||||
Keypair,
|
||||
PublicKey,
|
||||
TransactionInstruction,
|
||||
TransactionMessage,
|
||||
VersionedTransaction,
|
||||
AddressLookupTableAccount,
|
||||
} from '@solana/web3.js';
|
||||
import { createEthProvider } from '@/lib/eth-provider';
|
||||
import { webEnv } from '@/lib/env';
|
||||
import { BSC_GAS_PRICE } from '@/lib/crypto/bsc-constants';
|
||||
import {
|
||||
BRIDGE_CHAINS,
|
||||
BRIDGE_FEE_BPS,
|
||||
BRIDGE_FEE_RECIPIENT_EVM,
|
||||
BRIDGE_FEE_RECIPIENT_SOL,
|
||||
BRIDGE_FEE_RECIPIENT_TRX,
|
||||
RELAY_PROXY_BASE_URL,
|
||||
RELAY_REQUEST_TIMEOUT_MS,
|
||||
type BridgeChainKey,
|
||||
} from './constants';
|
||||
import type { RelayQuoteResponse, RelayStep } from './quote';
|
||||
|
||||
const provider = createEthProvider();
|
||||
|
||||
// TYTfrem65362TFyQSARTheeYza1GQA37Ug → hex (20 bytes, no 0x prefix)
|
||||
const BRIDGE_FEE_RECIPIENT_TRX_HEX = 'f6b4d4e650fc67982894f37ba97ab2496781ddb6';
|
||||
|
||||
interface ExecuteBridgeParams {
|
||||
sourceChain: BridgeChainKey;
|
||||
sourceToken: string;
|
||||
originalAmount: string;
|
||||
sourceTokenDecimals: number;
|
||||
sourceTokenAddress: string;
|
||||
privateKey: string;
|
||||
quote: RelayQuoteResponse;
|
||||
maxFeeGwei?: string | null;
|
||||
priorityFeeGwei?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecuteBridgeResult {
|
||||
requestId: string | null;
|
||||
txHashes: string[];
|
||||
}
|
||||
|
||||
export async function executeBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
|
||||
switch (params.sourceChain) {
|
||||
case 'ETH':
|
||||
return executeEvmBridge(params, provider);
|
||||
case 'BSC':
|
||||
return executeEvmBridge(params, new ethers.providers.StaticJsonRpcProvider(webEnv.bscRpcUrl, 56));
|
||||
case 'SOL':
|
||||
return executeSolBridge(params);
|
||||
case 'TRX':
|
||||
return executeTrxBridge(params);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── EVM origin (existing logic) ───
|
||||
|
||||
async function executeEvmBridge(
|
||||
params: ExecuteBridgeParams,
|
||||
evmProvider: ethers.providers.Provider,
|
||||
): Promise<ExecuteBridgeResult> {
|
||||
const wallet = new ethers.Wallet(params.privateKey, evmProvider);
|
||||
const isBsc = params.sourceChain === 'BSC';
|
||||
const txHashes: string[] = [];
|
||||
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
|
||||
|
||||
// ── Send 0.7% platform fee before bridge ──
|
||||
await sendEvmBridgeFee(wallet, params, isBsc);
|
||||
|
||||
for (const step of params.quote.steps) {
|
||||
if (!step.items?.length) continue;
|
||||
|
||||
for (const item of step.items) {
|
||||
if (step.kind === 'signature') {
|
||||
await executeSignatureStep(wallet, step, item.data);
|
||||
} else {
|
||||
const hash = await executeEvmTransactionStep(wallet, item.data, params.maxFeeGwei, params.priorityFeeGwei, isBsc);
|
||||
txHashes.push(hash);
|
||||
}
|
||||
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
return { requestId, txHashes };
|
||||
}
|
||||
|
||||
async function executeEvmTransactionStep(
|
||||
wallet: ethers.Wallet,
|
||||
data: Record<string, any>,
|
||||
maxFeeGwei?: string | null,
|
||||
priorityFeeGwei?: string | null,
|
||||
isBsc?: boolean,
|
||||
): Promise<string> {
|
||||
const gasOverrides = isBsc
|
||||
? { gasPrice: BSC_GAS_PRICE }
|
||||
: maxFeeGwei?.trim()
|
||||
? {
|
||||
maxFeePerGas: ethers.utils.parseUnits(maxFeeGwei, 'gwei'),
|
||||
maxPriorityFeePerGas: ethers.utils.parseUnits(priorityFeeGwei?.trim() || '0.01', 'gwei'),
|
||||
}
|
||||
: {
|
||||
...(data.maxFeePerGas ? { maxFeePerGas: ethers.BigNumber.from(data.maxFeePerGas) } : {}),
|
||||
...(data.maxPriorityFeePerGas ? { maxPriorityFeePerGas: ethers.BigNumber.from(data.maxPriorityFeePerGas) } : {}),
|
||||
};
|
||||
|
||||
const response = await wallet.sendTransaction({
|
||||
to: data.to,
|
||||
data: data.data,
|
||||
value: data.value ? ethers.BigNumber.from(data.value) : ethers.constants.Zero,
|
||||
gasLimit: data.gas ? ethers.BigNumber.from(data.gas) : undefined,
|
||||
...gasOverrides,
|
||||
});
|
||||
|
||||
const receipt = await response.wait();
|
||||
if (!receipt || receipt.status !== 1) {
|
||||
throw new Error('Bridge transaction reverted');
|
||||
}
|
||||
|
||||
return response.hash;
|
||||
}
|
||||
|
||||
// ─── Bridge Fee Helpers ───
|
||||
|
||||
async function sendEvmBridgeFee(wallet: ethers.Wallet, params: ExecuteBridgeParams, isBsc?: boolean): Promise<void> {
|
||||
const fullAmountRaw = ethers.utils.parseUnits(params.originalAmount, params.sourceTokenDecimals);
|
||||
const feeAmount = fullAmountRaw.mul(BRIDGE_FEE_BPS).div(10000);
|
||||
if (feeAmount.isZero()) return;
|
||||
|
||||
const gasOverrides = isBsc ? { gasPrice: BSC_GAS_PRICE } : {};
|
||||
const isNative = params.sourceTokenAddress === '0x0000000000000000000000000000000000000000';
|
||||
|
||||
if (isNative) {
|
||||
const tx = await wallet.sendTransaction({
|
||||
to: BRIDGE_FEE_RECIPIENT_EVM,
|
||||
value: feeAmount,
|
||||
...gasOverrides,
|
||||
});
|
||||
await tx.wait();
|
||||
} else {
|
||||
const tokenContract = new ethers.Contract(
|
||||
params.sourceTokenAddress,
|
||||
['function transfer(address to, uint256 amount) returns (bool)'],
|
||||
wallet,
|
||||
);
|
||||
const tx = await tokenContract.transfer(BRIDGE_FEE_RECIPIENT_EVM, feeAmount, gasOverrides);
|
||||
await tx.wait();
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSolBridgeFee(
|
||||
connection: Connection,
|
||||
keypair: Keypair,
|
||||
params: ExecuteBridgeParams,
|
||||
): Promise<void> {
|
||||
const { SystemProgram } = await import('@solana/web3.js');
|
||||
|
||||
const fullAmountRaw = BigInt(
|
||||
Math.round(Number(params.originalAmount) * 10 ** params.sourceTokenDecimals),
|
||||
);
|
||||
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
|
||||
if (feeAmount === 0n) return;
|
||||
|
||||
const feeRecipient = new PublicKey(BRIDGE_FEE_RECIPIENT_SOL);
|
||||
const isNative = params.sourceTokenAddress === '11111111111111111111111111111111';
|
||||
|
||||
// Bridge fee only supports native SOL transfers
|
||||
// (SOL bridge primarily uses SOL, USDT, USDC — SPL fee handled off-chain if needed)
|
||||
if (!isNative) return;
|
||||
|
||||
const instruction = SystemProgram.transfer({
|
||||
fromPubkey: keypair.publicKey,
|
||||
toPubkey: feeRecipient,
|
||||
lamports: feeAmount,
|
||||
});
|
||||
|
||||
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
|
||||
const messageV0 = new TransactionMessage({
|
||||
payerKey: keypair.publicKey,
|
||||
recentBlockhash: latestBlockhash.blockhash,
|
||||
instructions: [instruction],
|
||||
}).compileToV0Message();
|
||||
|
||||
const tx = new VersionedTransaction(messageV0);
|
||||
tx.sign([keypair]);
|
||||
|
||||
const sig = await connection.sendRawTransaction(tx.serialize(), { skipPreflight: false });
|
||||
await connection.confirmTransaction(
|
||||
{ signature: sig, blockhash: latestBlockhash.blockhash, lastValidBlockHeight: latestBlockhash.lastValidBlockHeight },
|
||||
'confirmed',
|
||||
);
|
||||
}
|
||||
|
||||
async function sendTrxBridgeFee(
|
||||
signingKey: ethers.utils.SigningKey,
|
||||
apiUrl: string,
|
||||
params: ExecuteBridgeParams,
|
||||
): Promise<void> {
|
||||
const decimals = params.sourceTokenDecimals;
|
||||
const fullAmountRaw = BigInt(
|
||||
Math.round(Number(params.originalAmount) * 10 ** decimals),
|
||||
);
|
||||
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
|
||||
if (feeAmount === 0n) return;
|
||||
|
||||
// TRX bridge only supports USDT (TRC-20) — build a TRC20 transfer via API
|
||||
// Use the tron proxy to build a transfer tx, sign it, and broadcast
|
||||
const buildResp = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
owner_address: params.quote.steps[0]?.items?.[0]?.data?.parameter?.owner_address
|
||||
?? ethers.utils.computeAddress(signingKey.publicKey).toLowerCase(),
|
||||
contract_address: params.sourceTokenAddress,
|
||||
function_selector: 'transfer(address,uint256)',
|
||||
parameter:
|
||||
BRIDGE_FEE_RECIPIENT_TRX_HEX.padStart(64, '0') +
|
||||
feeAmount.toString(16).padStart(64, '0'),
|
||||
call_value: 0,
|
||||
fee_limit: 100000000,
|
||||
visible: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!buildResp.ok) return; // Fee transfer is best-effort; don't block bridge
|
||||
|
||||
const buildResult = await buildResp.json();
|
||||
const tx = buildResult.transaction;
|
||||
if (!tx?.txID) return;
|
||||
|
||||
// Sign and broadcast
|
||||
const digest = ethers.utils.arrayify('0x' + tx.txID);
|
||||
const signature = signingKey.signDigest(digest);
|
||||
const sigHex = ethers.utils.joinSignature(signature).slice(2);
|
||||
|
||||
const broadcastResp = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...tx, signature: [sigHex] }),
|
||||
});
|
||||
|
||||
// Wait for broadcast, but don't fail the bridge if fee transfer fails
|
||||
await broadcastResp.json();
|
||||
}
|
||||
|
||||
async function executeSignatureStep(wallet: ethers.Wallet, step: RelayStep, data: Record<string, any>): Promise<void> {
|
||||
const signData = data.sign;
|
||||
const postData = data.post;
|
||||
|
||||
if (!signData || !postData?.endpoint) {
|
||||
throw new Error(`Invalid signature step payload for ${step.id}`);
|
||||
}
|
||||
|
||||
const signature = await signRelayPayload(wallet, signData);
|
||||
const endpoint = new URL(`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}${postData.endpoint}`);
|
||||
endpoint.searchParams.set('signature', signature);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint.toString(), {
|
||||
method: postData.method ?? 'POST',
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(postData.body ?? {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.text();
|
||||
throw new Error(payload || 'Relay signature submission failed');
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function signRelayPayload(wallet: ethers.Wallet, signData: Record<string, any>): Promise<string> {
|
||||
if (signData.signatureKind === 'eip191') {
|
||||
const message = typeof signData.message === 'string' && signData.message.startsWith('0x')
|
||||
? ethers.utils.arrayify(signData.message)
|
||||
: signData.message;
|
||||
return wallet.signMessage(message);
|
||||
}
|
||||
|
||||
if (signData.signatureKind === 'eip712') {
|
||||
const { EIP712Domain, ...types } = signData.types ?? {};
|
||||
return wallet._signTypedData(signData.domain ?? {}, types, signData.value ?? {});
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Relay signature kind: ${signData.signatureKind}`);
|
||||
}
|
||||
|
||||
// ─── SOL origin ───
|
||||
|
||||
async function executeSolBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
|
||||
const connection = new Connection(webEnv.solRpcUrl, 'confirmed');
|
||||
const keypair = Keypair.fromSecretKey(Buffer.from(params.privateKey, 'hex'));
|
||||
const txHashes: string[] = [];
|
||||
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
|
||||
|
||||
// ── Send 0.7% platform fee before bridge ──
|
||||
await sendSolBridgeFee(connection, keypair, params);
|
||||
|
||||
for (const step of params.quote.steps) {
|
||||
if (!step.items?.length) continue;
|
||||
|
||||
for (const item of step.items) {
|
||||
const data = item.data;
|
||||
|
||||
if (!data.instructions || !Array.isArray(data.instructions)) {
|
||||
throw new Error('Expected Solana instructions in bridge step');
|
||||
}
|
||||
|
||||
const hash = await executeSolTransactionStep(connection, keypair, data);
|
||||
txHashes.push(hash);
|
||||
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
return { requestId, txHashes };
|
||||
}
|
||||
|
||||
async function executeSolTransactionStep(
|
||||
connection: Connection,
|
||||
keypair: Keypair,
|
||||
data: Record<string, any>,
|
||||
): Promise<string> {
|
||||
// Build instructions from Relay response
|
||||
const instructions: TransactionInstruction[] = data.instructions.map((ix: any) => ({
|
||||
programId: new PublicKey(ix.programId),
|
||||
keys: ix.keys.map((k: any) => ({
|
||||
pubkey: new PublicKey(k.pubkey),
|
||||
isSigner: k.isSigner,
|
||||
isWritable: k.isWritable,
|
||||
})),
|
||||
data: Buffer.from(ix.data, 'hex'),
|
||||
}));
|
||||
|
||||
// Load address lookup tables
|
||||
const lookupTableAddresses: string[] = data.addressLookupTableAddresses ?? [];
|
||||
const lookupTables: AddressLookupTableAccount[] = [];
|
||||
|
||||
for (const addr of lookupTableAddresses) {
|
||||
const account = await connection.getAddressLookupTable(new PublicKey(addr));
|
||||
if (account.value) {
|
||||
lookupTables.push(account.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Build versioned transaction
|
||||
const latestBlockhash = await connection.getLatestBlockhash('confirmed');
|
||||
const messageV0 = new TransactionMessage({
|
||||
payerKey: keypair.publicKey,
|
||||
recentBlockhash: latestBlockhash.blockhash,
|
||||
instructions,
|
||||
}).compileToV0Message(lookupTables);
|
||||
|
||||
const transaction = new VersionedTransaction(messageV0);
|
||||
transaction.sign([keypair]);
|
||||
|
||||
// Send and confirm
|
||||
const signature = await connection.sendRawTransaction(transaction.serialize(), {
|
||||
skipPreflight: false,
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
await connection.confirmTransaction(
|
||||
{
|
||||
signature,
|
||||
blockhash: latestBlockhash.blockhash,
|
||||
lastValidBlockHeight: latestBlockhash.lastValidBlockHeight,
|
||||
},
|
||||
'confirmed',
|
||||
);
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
// ─── TRX origin ───
|
||||
|
||||
async function executeTrxBridge(params: ExecuteBridgeParams): Promise<ExecuteBridgeResult> {
|
||||
const signingKey = new ethers.utils.SigningKey('0x' + params.privateKey);
|
||||
const apiUrl = webEnv.apiUrl || 'http://localhost:3001';
|
||||
const txHashes: string[] = [];
|
||||
let requestId = params.quote.steps.find((s) => s.requestId)?.requestId ?? null;
|
||||
|
||||
// ── Send 0.7% platform fee before bridge ──
|
||||
await sendTrxBridgeFee(signingKey, apiUrl, params);
|
||||
|
||||
for (const step of params.quote.steps) {
|
||||
if (!step.items?.length) continue;
|
||||
|
||||
for (const item of step.items) {
|
||||
const data = item.data;
|
||||
|
||||
if (data.type !== 'TriggerSmartContract') {
|
||||
throw new Error(`Unsupported TRX step type: ${data.type}`);
|
||||
}
|
||||
|
||||
const hash = await executeTrxTransactionStep(signingKey, apiUrl, data);
|
||||
txHashes.push(hash);
|
||||
requestId = requestId ?? step.requestId ?? extractRequestId(item.check?.endpoint);
|
||||
}
|
||||
|
||||
// Small delay between steps (e.g., approve → deposit)
|
||||
if (step.items.length > 0 && step !== params.quote.steps[params.quote.steps.length - 1]) {
|
||||
await delay(3000);
|
||||
}
|
||||
}
|
||||
|
||||
return { requestId, txHashes };
|
||||
}
|
||||
|
||||
async function executeTrxTransactionStep(
|
||||
signingKey: ethers.utils.SigningKey,
|
||||
apiUrl: string,
|
||||
data: Record<string, any>,
|
||||
): Promise<string> {
|
||||
// 1. Build transaction via TronGrid triggersmartcontract
|
||||
const buildResponse = await fetch(`${apiUrl}/api/tron/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data.parameter),
|
||||
});
|
||||
|
||||
if (!buildResponse.ok) {
|
||||
const body = await buildResponse.json().catch(() => ({ error: 'Failed to build TRX bridge tx' }));
|
||||
throw new Error(body.error || `TRX bridge build failed (${buildResponse.status})`);
|
||||
}
|
||||
|
||||
const buildResult = await buildResponse.json();
|
||||
const tx = buildResult.transaction;
|
||||
if (!tx?.txID) {
|
||||
throw new Error('TronGrid did not return a valid transaction');
|
||||
}
|
||||
|
||||
// 2. Sign txID with secp256k1
|
||||
const digest = ethers.utils.arrayify('0x' + tx.txID);
|
||||
const signature = signingKey.signDigest(digest);
|
||||
const sigHex = ethers.utils.joinSignature(signature).slice(2); // 65 bytes hex, no 0x
|
||||
|
||||
const signedTx = {
|
||||
...tx,
|
||||
signature: [sigHex],
|
||||
};
|
||||
|
||||
// 3. Broadcast
|
||||
const broadcastResponse = await fetch(`${apiUrl}/api/tron/broadcasttransaction`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(signedTx),
|
||||
});
|
||||
|
||||
const result = await broadcastResponse.json();
|
||||
if (!result.result) {
|
||||
const errorMsg = result.message || result.code || 'TRX broadcast failed';
|
||||
throw new Error(`TRX bridge broadcast error: ${errorMsg}`);
|
||||
}
|
||||
|
||||
return tx.txID;
|
||||
}
|
||||
|
||||
// ─── Utils ───
|
||||
|
||||
function extractRequestId(endpoint?: string): string | null {
|
||||
if (!endpoint) return null;
|
||||
try {
|
||||
const url = new URL(endpoint, 'https://api.relay.link');
|
||||
return url.searchParams.get('requestId');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
184
apps/web/src/lib/bridge/quote.ts
Normal file
184
apps/web/src/lib/bridge/quote.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ethers } from 'ethers';
|
||||
import { webEnv } from '@/lib/env';
|
||||
import {
|
||||
BRIDGE_CHAINS,
|
||||
BRIDGE_FEE_BPS,
|
||||
RELAY_PROXY_BASE_URL,
|
||||
RELAY_REQUEST_TIMEOUT_MS,
|
||||
getTokenConfig,
|
||||
type BridgeQuoteRequest,
|
||||
} from './constants';
|
||||
|
||||
export interface RelayStep {
|
||||
id: string;
|
||||
kind: 'transaction' | 'signature';
|
||||
requestId?: string;
|
||||
items: Array<{
|
||||
status: 'complete' | 'incomplete';
|
||||
data: Record<string, any>;
|
||||
check?: {
|
||||
endpoint: string;
|
||||
method: 'GET' | 'POST';
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RelayQuoteResponse {
|
||||
steps: RelayStep[];
|
||||
fees?: Record<string, { amountUsd?: string; amountFormatted?: string; currency?: { symbol?: string } }>;
|
||||
details?: {
|
||||
timeEstimate?: number;
|
||||
currencyOut?: {
|
||||
currency?: {
|
||||
symbol?: string;
|
||||
decimals?: number;
|
||||
};
|
||||
amount?: string;
|
||||
amountFormatted?: string;
|
||||
};
|
||||
totalImpact?: {
|
||||
usd?: string;
|
||||
percent?: string;
|
||||
};
|
||||
slippageTolerance?: {
|
||||
destination?: {
|
||||
percent?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface BridgeQuoteResult {
|
||||
quote: RelayQuoteResponse;
|
||||
sourceChain: string;
|
||||
requestId: string | null;
|
||||
outputAmountFormatted: string;
|
||||
outputSymbol: string;
|
||||
minimumAmountFormatted: string;
|
||||
feeSummary: string;
|
||||
timeEstimateSeconds: number | null;
|
||||
}
|
||||
|
||||
export async function getBridgeQuote(request: BridgeQuoteRequest): Promise<BridgeQuoteResult> {
|
||||
if (!request.amount || Number(request.amount) <= 0) {
|
||||
throw new Error('Enter a valid bridge amount');
|
||||
}
|
||||
|
||||
const sourceChainConfig = BRIDGE_CHAINS[request.sourceChain];
|
||||
const destChainConfig = BRIDGE_CHAINS[request.destChain];
|
||||
const sourceTokenConfig = getTokenConfig(request.sourceChain, request.sourceToken);
|
||||
const destTokenConfig = getTokenConfig(request.destChain, request.destToken);
|
||||
|
||||
// Apply 0.7% platform fee — bridge only 99.3% of input
|
||||
const fullAmountRaw = BigInt(parseAmountToRaw(request.amount, sourceTokenConfig.decimals));
|
||||
const feeAmount = (fullAmountRaw * BigInt(BRIDGE_FEE_BPS)) / 10000n;
|
||||
const amount = (fullAmountRaw - feeAmount).toString();
|
||||
|
||||
const quote = await fetchRelayJson<RelayQuoteResponse>(
|
||||
`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}/quote/v2`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user: request.userAddress,
|
||||
recipient: request.recipientAddress,
|
||||
originChainId: sourceChainConfig.chainId,
|
||||
destinationChainId: destChainConfig.chainId,
|
||||
originCurrency: sourceTokenConfig.address,
|
||||
destinationCurrency: destTokenConfig.address,
|
||||
amount,
|
||||
tradeType: 'EXACT_INPUT',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const requestId = quote.steps.find((step) => step.requestId)?.requestId ?? null;
|
||||
const currencyOut = quote.details?.currencyOut;
|
||||
|
||||
return {
|
||||
quote,
|
||||
sourceChain: request.sourceChain,
|
||||
requestId,
|
||||
outputAmountFormatted: currencyOut?.amountFormatted ?? 'Unavailable',
|
||||
outputSymbol: currencyOut?.currency?.symbol ?? destTokenConfig.symbol,
|
||||
minimumAmountFormatted: computeMinimumAmount(currencyOut, destTokenConfig.decimals),
|
||||
feeSummary: buildFeeSummary(quote.fees),
|
||||
timeEstimateSeconds: quote.details?.timeEstimate ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRelayJson<T>(url: string, options: RequestInit): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as T & { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error((payload as { message?: string }).message || 'Relay quote request failed');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFeeSummary(fees: RelayQuoteResponse['fees']): string {
|
||||
if (!fees) return 'Unavailable';
|
||||
|
||||
const usdTotal = Object.values(fees).reduce((total, fee) => {
|
||||
const amountUsd = Number(fee.amountUsd ?? 0);
|
||||
return Number.isFinite(amountUsd) ? total + amountUsd : total;
|
||||
}, 0);
|
||||
|
||||
if (usdTotal > 0) return `$${usdTotal.toFixed(4)}`;
|
||||
|
||||
const relayerFee = fees.relayer;
|
||||
if (relayerFee?.amountFormatted && relayerFee.currency?.symbol) {
|
||||
return `${relayerFee.amountFormatted} ${relayerFee.currency.symbol}`;
|
||||
}
|
||||
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
function computeMinimumAmount(
|
||||
currencyOut: NonNullable<RelayQuoteResponse['details']>['currencyOut'] | undefined,
|
||||
decimals: number,
|
||||
): string {
|
||||
if (!currencyOut?.amount || !currencyOut.currency?.decimals) {
|
||||
return 'Unavailable';
|
||||
}
|
||||
|
||||
// Apply 2% slippage to displayed minimum
|
||||
const raw = BigInt(currencyOut.amount);
|
||||
const minimum = (raw * 98n) / 100n;
|
||||
return formatRawUnits(minimum.toString(), currencyOut.currency.decimals);
|
||||
}
|
||||
|
||||
function parseAmountToRaw(amount: string, decimals: number): string {
|
||||
const parts = amount.split('.');
|
||||
const whole = parts[0] || '0';
|
||||
const fraction = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
|
||||
const raw = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(fraction);
|
||||
return raw.toString();
|
||||
}
|
||||
|
||||
function formatRawUnits(raw: string, decimals: number): string {
|
||||
const value = BigInt(raw);
|
||||
if (value === 0n) return '0';
|
||||
|
||||
const divisor = 10n ** BigInt(decimals);
|
||||
const whole = value / divisor;
|
||||
const fraction = value % divisor;
|
||||
|
||||
if (fraction === 0n) return whole.toString();
|
||||
return `${whole}.${fraction.toString().padStart(decimals, '0').replace(/0+$/, '')}`;
|
||||
}
|
||||
40
apps/web/src/lib/bridge/status.ts
Normal file
40
apps/web/src/lib/bridge/status.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { webEnv } from '@/lib/env';
|
||||
import { RELAY_PROXY_BASE_URL, RELAY_REQUEST_TIMEOUT_MS } from './constants';
|
||||
|
||||
export interface BridgeStatusResult {
|
||||
status: 'waiting' | 'pending' | 'submitted' | 'success' | 'delayed' | 'refunded' | 'failure';
|
||||
details?: string;
|
||||
inTxHashes?: string[];
|
||||
txHashes?: string[];
|
||||
updatedAt?: number;
|
||||
originChainId?: number;
|
||||
destinationChainId?: number;
|
||||
}
|
||||
|
||||
export async function getBridgeStatus(requestId: string): Promise<BridgeStatusResult> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), RELAY_REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${webEnv.apiUrl}${RELAY_PROXY_BASE_URL}/intents/status/v3?requestId=${encodeURIComponent(requestId)}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
const payload = (await response.json()) as BridgeStatusResult & { message?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || 'Unable to fetch bridge status');
|
||||
}
|
||||
|
||||
return payload;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export function isBridgeTerminalStatus(status: BridgeStatusResult['status']): boolean {
|
||||
return status === 'success' || status === 'failure' || status === 'refunded';
|
||||
}
|
||||
Reference in New Issue
Block a user